diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4388899306..77781b9456 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -39,9 +39,25 @@ use crate::tui::TuiEvent; use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; use codex_ansi_escape::ansi_escape_line; +use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; +use codex_app_server_client::InProcessAppServerClient; +use codex_app_server_client::InProcessClientStartArgs; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigLayerSource; +use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::PluginInstallParams; +use codex_app_server_protocol::PluginInstallResponse; +use codex_app_server_protocol::PluginListParams; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginReadParams; +use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginUninstallParams; +use codex_app_server_protocol::PluginUninstallResponse; +use codex_app_server_protocol::RequestId; +use codex_arg0::Arg0DispatchPaths; use codex_core::AuthManager; use codex_core::CodexAuth; +use codex_core::ForkSnapshot; use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config::ConfigBuilder; @@ -50,14 +66,16 @@ use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::types::ApprovalsReviewer; use codex_core::config::types::ModelAvailabilityNuxConfig; +use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::ConfigLayerStackOrdering; -use codex_core::features::Feature; +use codex_core::config_loader::LoaderOverrides; use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_core::models_manager::manager::RefreshStrategy; use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; #[cfg(target_os = "windows")] use codex_core::windows_sandbox::WindowsSandboxLevelExt; +use codex_features::Feature; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; @@ -80,6 +98,7 @@ use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillErrorInfo; use codex_protocol::protocol::TokenUsage; +use codex_terminal_detection::user_agent; use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -111,6 +130,7 @@ use tokio::sync::mpsc::error::TrySendError; use tokio::sync::mpsc::unbounded_channel; use tokio::task::JoinHandle; use toml::Value as TomlValue; +use uuid::Uuid; mod agent_navigation; mod btw; @@ -240,6 +260,180 @@ fn emit_skill_load_warnings(app_event_tx: &AppEventSender, errors: &[SkillErrorI } } +fn config_warning_notifications(config: &Config) -> Vec { + config + .startup_warnings + .iter() + .map(|warning| ConfigWarningNotification { + summary: warning.clone(), + details: None, + path: None, + range: None, + }) + .collect() +} + +async fn start_plugin_request_client( + arg0_paths: Arg0DispatchPaths, + config: Config, + cli_kv_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, + cloud_requirements: CloudRequirementsLoader, + feedback: codex_feedback::CodexFeedback, +) -> Result { + InProcessAppServerClient::start(InProcessClientStartArgs { + arg0_paths, + config_warnings: config_warning_notifications(&config), + config: Arc::new(config), + cli_overrides: cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + session_source: SessionSource::Cli, + enable_codex_api_key_env: false, + client_name: "codex-tui".to_string(), + client_version: env!("CARGO_PKG_VERSION").to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await + .wrap_err("failed to start embedded app server for plugin request") +} + +async fn request_plugins_list( + arg0_paths: Arg0DispatchPaths, + config: Config, + cli_kv_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, + cloud_requirements: CloudRequirementsLoader, + feedback: codex_feedback::CodexFeedback, + cwd: PathBuf, +) -> Result { + let client = start_plugin_request_client( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + ) + .await?; + let request_handle = client.request_handle(); + 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 response = request_handle + .request_typed(ClientRequest::PluginList { + request_id, + params: PluginListParams { + cwds: Some(vec![cwd]), + force_remote_sync: false, + }, + }) + .await + .wrap_err("plugin/list failed in legacy TUI"); + if let Err(err) = client.shutdown().await { + tracing::warn!(%err, "failed to shut down embedded app server after plugin/list"); + } + response +} + +async fn request_plugin_detail( + arg0_paths: Arg0DispatchPaths, + config: Config, + cli_kv_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, + cloud_requirements: CloudRequirementsLoader, + feedback: codex_feedback::CodexFeedback, + params: PluginReadParams, +) -> Result { + let client = start_plugin_request_client( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + ) + .await?; + let request_handle = client.request_handle(); + let request_id = RequestId::String(format!("plugin-read-{}", Uuid::new_v4())); + let response = request_handle + .request_typed(ClientRequest::PluginRead { request_id, params }) + .await + .wrap_err("plugin/read failed in legacy TUI"); + if let Err(err) = client.shutdown().await { + tracing::warn!(%err, "failed to shut down embedded app server after plugin/read"); + } + response +} + +async fn request_plugin_install( + arg0_paths: Arg0DispatchPaths, + config: Config, + cli_kv_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, + cloud_requirements: CloudRequirementsLoader, + feedback: codex_feedback::CodexFeedback, + params: PluginInstallParams, +) -> Result { + let client = start_plugin_request_client( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + ) + .await?; + let request_handle = client.request_handle(); + let request_id = RequestId::String(format!("plugin-install-{}", Uuid::new_v4())); + let response = request_handle + .request_typed(ClientRequest::PluginInstall { request_id, params }) + .await + .wrap_err("plugin/install failed in legacy TUI"); + if let Err(err) = client.shutdown().await { + tracing::warn!(%err, "failed to shut down embedded app server after plugin/install"); + } + response +} + +async fn request_plugin_uninstall( + arg0_paths: Arg0DispatchPaths, + config: Config, + cli_kv_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, + cloud_requirements: CloudRequirementsLoader, + feedback: codex_feedback::CodexFeedback, + plugin_id: String, +) -> Result { + let client = start_plugin_request_client( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + ) + .await?; + let request_handle = client.request_handle(); + let request_id = RequestId::String(format!("plugin-uninstall-{}", Uuid::new_v4())); + let response = request_handle + .request_typed(ClientRequest::PluginUninstall { + request_id, + params: PluginUninstallParams { + plugin_id, + force_remote_sync: false, + }, + }) + .await + .wrap_err("plugin/uninstall failed in legacy TUI"); + if let Err(err) = client.shutdown().await { + tracing::warn!(%err, "failed to shut down embedded app server after plugin/uninstall"); + } + response +} + fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) { let mut disabled_folders = Vec::new(); @@ -283,6 +477,42 @@ fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) ))); } +fn emit_system_bwrap_warning(app_event_tx: &AppEventSender) { + let Some(message) = codex_core::config::system_bwrap_warning() else { + return; + }; + + app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_warning_event(message), + ))); +} + +async fn emit_custom_prompt_deprecation_notice(app_event_tx: &AppEventSender, codex_home: &Path) { + let prompts_dir = codex_home.join("prompts"); + let prompt_count = codex_core::custom_prompts::discover_prompts_in(&prompts_dir) + .await + .len(); + if prompt_count == 0 { + return; + } + + let prompt_label = if prompt_count == 1 { + "prompt" + } else { + "prompts" + }; + let details = format!( + "Detected {prompt_count} custom {prompt_label} in `$CODEX_HOME/prompts`. Use the `$skill-creator` skill to convert each custom prompt into a skill." + ); + + app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_deprecation_notice( + "Custom prompts are deprecated and will soon be removed.".to_string(), + Some(details), + ), + ))); +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SessionSummary { usage_line: String, @@ -677,6 +907,9 @@ pub(crate) struct App { pub(crate) config: Config, pub(crate) active_profile: Option, cli_kv_overrides: Vec<(String, TomlValue)>, + arg0_paths: Arg0DispatchPaths, + loader_overrides: LoaderOverrides, + cloud_requirements: CloudRequirementsLoader, harness_overrides: ConfigOverrides, runtime_approval_policy_override: Option, runtime_sandbox_policy_override: Option, @@ -696,6 +929,8 @@ pub(crate) struct App { pub(crate) commit_anim_running: Arc, // Shared across ChatWidget instances so invalid status-line config warnings only emit once. status_line_invalid_items_warned: Arc, + // Shared across ChatWidget instances so invalid terminal-title config warnings only emit once. + terminal_title_invalid_items_warned: Arc, // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, @@ -711,9 +946,9 @@ pub(crate) struct App { /// One-shot guard used while switching threads. /// - /// We set this to the specific thread we are intentionally stopping before - /// moving to another one, then ignore that thread's `ShutdownComplete` so - /// it is not misclassified as an unexpected sub-agent death. + /// We set this when intentionally stopping the current thread before moving + /// to another one, then ignore exactly one `ShutdownComplete` so it is not + /// misclassified as an unexpected sub-agent death. suppress_shutdown_complete_thread_id: Option, /// Tracks the thread we intentionally shut down while exiting the app. /// @@ -784,6 +1019,7 @@ impl App { startup_tooltip_override: None, status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), session_telemetry: self.session_telemetry.clone(), + terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(), } } @@ -806,6 +1042,7 @@ impl App { .await?; self.apply_runtime_policy_overrides(&mut config); self.config = config; + self.chat_widget.sync_plugin_mentions_config(&self.config); Ok(()) } @@ -1153,6 +1390,141 @@ impl App { .add_info_message(format!("Opened {url} in your browser."), /*hint*/ None); } + fn fetch_plugins_list(&mut self, cwd: PathBuf) { + let config = self.config.clone(); + let arg0_paths = self.arg0_paths.clone(); + let cli_kv_overrides = self.cli_kv_overrides.clone(); + let loader_overrides = self.loader_overrides.clone(); + let cloud_requirements = self.cloud_requirements.clone(); + let feedback = self.feedback.clone(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let result = request_plugins_list( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + cwd, + ) + .await + .map_err(|err| format!("Failed to load plugins: {err}")); + app_event_tx.send(AppEvent::PluginsLoaded { + cwd: cwd_for_event, + result, + }); + }); + } + + fn fetch_plugin_detail(&mut self, cwd: PathBuf, params: PluginReadParams) { + let config = self.config.clone(); + let arg0_paths = self.arg0_paths.clone(); + let cli_kv_overrides = self.cli_kv_overrides.clone(); + let loader_overrides = self.loader_overrides.clone(); + let cloud_requirements = self.cloud_requirements.clone(); + let feedback = self.feedback.clone(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let result = request_plugin_detail( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + params, + ) + .await + .map_err(|err| format!("Failed to load plugin details: {err}")); + app_event_tx.send(AppEvent::PluginDetailLoaded { + cwd: cwd_for_event, + result, + }); + }); + } + + fn fetch_plugin_install( + &mut self, + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + ) { + let config = self.config.clone(); + let arg0_paths = self.arg0_paths.clone(); + let cli_kv_overrides = self.cli_kv_overrides.clone(); + let loader_overrides = self.loader_overrides.clone(); + let cloud_requirements = self.cloud_requirements.clone(); + let feedback = self.feedback.clone(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let marketplace_path_for_event = marketplace_path.clone(); + let plugin_name_for_event = plugin_name.clone(); + let result = request_plugin_install( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + PluginInstallParams { + marketplace_path, + plugin_name, + force_remote_sync: false, + }, + ) + .await + .map_err(|err| format!("Failed to install plugin: {err}")); + app_event_tx.send(AppEvent::PluginInstallLoaded { + cwd: cwd_for_event, + marketplace_path: marketplace_path_for_event, + plugin_name: plugin_name_for_event, + plugin_display_name, + result, + }); + }); + } + + fn fetch_plugin_uninstall( + &mut self, + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + ) { + let config = self.config.clone(); + let arg0_paths = self.arg0_paths.clone(); + let cli_kv_overrides = self.cli_kv_overrides.clone(); + let loader_overrides = self.loader_overrides.clone(); + let cloud_requirements = self.cloud_requirements.clone(); + let feedback = self.feedback.clone(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let plugin_id_for_event = plugin_id.clone(); + let result = request_plugin_uninstall( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + plugin_id, + ) + .await + .map_err(|err| format!("Failed to uninstall plugin: {err}")); + app_event_tx.send(AppEvent::PluginUninstallLoaded { + cwd: cwd_for_event, + plugin_id: plugin_id_for_event, + plugin_display_name, + result, + }); + }); + } + fn clear_ui_header_lines_with_version( &self, width: u16, @@ -1242,7 +1614,6 @@ impl App { async fn request_thread_shutdown(&mut self, thread_id: ThreadId) { if self.chat_widget.thread_id() == Some(thread_id) { - // Clear any in-flight rollback guard when switching threads. self.backtrack.pending_rollback = None; self.suppress_shutdown_complete_thread_id = Some(thread_id); self.chat_widget.submit_op(Op::Shutdown); @@ -1893,6 +2264,16 @@ impl App { self.sync_active_agent_label(); } + fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) { + let previous_terminal_title = self.chat_widget.last_terminal_title.take(); + if chat_widget.last_terminal_title.is_none() { + chat_widget.last_terminal_title = previous_terminal_title; + } + self.chat_widget = chat_widget; + self.sync_active_agent_label(); + self.refresh_status_surfaces(); + } + async fn start_fresh_session_with_summary_hint(&mut self, tui: &mut tui::Tui) { // Start a fresh in-memory session while preserving resumability via persisted rollout // history. @@ -1933,8 +2314,9 @@ impl App { startup_tooltip_override: None, status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), session_telemetry: self.session_telemetry.clone(), + terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(), }; - self.chat_widget = ChatWidget::new(init, self.server.clone()); + self.replace_chat_widget(ChatWidget::new(init, self.server.clone())); self.reset_thread_event_state(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -2025,7 +2407,7 @@ impl App { if resume_restored_queue { self.chat_widget.maybe_send_next_queued_input(); } - self.refresh_status_line(); + self.refresh_status_surfaces(); } fn should_wait_for_initial_session(session_selection: &SessionSelection) -> bool { @@ -2055,6 +2437,9 @@ impl App { auth_manager: Arc, mut config: Config, cli_kv_overrides: Vec<(String, TomlValue)>, + arg0_paths: Arg0DispatchPaths, + loader_overrides: LoaderOverrides, + cloud_requirements: CloudRequirementsLoader, harness_overrides: ConfigOverrides, active_profile: Option, initial_prompt: Option, @@ -2068,6 +2453,8 @@ impl App { let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); emit_project_config_warnings(&app_event_tx, &config); + emit_system_bwrap_warning(&app_event_tx); + emit_custom_prompt_deprecation_notice(&app_event_tx, &config.codex_home).await; tui.set_notification_method(config.tui_notification_method); let harness_overrides = @@ -2082,10 +2469,6 @@ impl App { .enabled(Feature::DefaultModeRequestUserInput), }, )); - // TODO(xl): Move into PluginManager once this no longer depends on config feature gating. - thread_manager - .plugins_manager() - .maybe_start_curated_repo_sync_for_config(&config); let mut model = thread_manager .get_models_manager() .get_default_model(&config.model, RefreshStrategy::Offline) @@ -2133,7 +2516,7 @@ impl App { auth_mode, codex_core::default_client::originator().value, config.otel.log_user_prompt, - codex_core::terminal::user_agent(), + user_agent(), SessionSource::Cli, ); if config @@ -2145,6 +2528,7 @@ impl App { } let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false)); + let terminal_title_invalid_items_warned = Arc::new(AtomicBool::new(false)); let enhanced_keys_supported = tui.enhanced_keys_supported(); let wait_for_initial_session_configured = @@ -2174,6 +2558,8 @@ impl App { startup_tooltip_override, status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), + terminal_title_invalid_items_warned: terminal_title_invalid_items_warned + .clone(), }; ChatWidget::new(init, thread_manager.clone()) } @@ -2210,6 +2596,8 @@ impl App { startup_tooltip_override: None, status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), + terminal_title_invalid_items_warned: terminal_title_invalid_items_warned + .clone(), }; ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured) } @@ -2221,7 +2609,7 @@ impl App { ); let forked = thread_manager .fork_thread( - ForkSnapshot::TruncateBeforeNthUserMessage(usize::MAX), + ForkSnapshot::Interrupted, config.clone(), target_session.path.clone(), /*persist_extended_history*/ false, @@ -2252,6 +2640,8 @@ impl App { startup_tooltip_override: None, status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), + terminal_title_invalid_items_warned: terminal_title_invalid_items_warned + .clone(), }; ChatWidget::new_from_existing(init, forked.thread, forked.session_configured) } @@ -2273,6 +2663,9 @@ impl App { config, active_profile, cli_kv_overrides, + arg0_paths, + loader_overrides, + cloud_requirements, harness_overrides, runtime_approval_policy_override: None, runtime_sandbox_policy_override: None, @@ -2284,6 +2677,7 @@ impl App { has_emitted_history_lines: false, commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), + terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: feedback.clone(), @@ -2379,16 +2773,14 @@ impl App { waiting_for_initial_session_configured, app.active_thread_rx.is_some() ) => { - match active { - Some(event) => match app.handle_active_thread_event(tui, event).await { - Ok(control) => control, - Err(err) => break Err(err), - }, - None => { - app.clear_active_thread().await; - AppRunControl::Continue + if let Some(event) = active { + if let Err(err) = app.handle_active_thread_event(tui, event).await { + break Err(err); } + } else { + app.clear_active_thread().await; } + AppRunControl::Continue } Some(event) = tui_events.next() => { match app.handle_tui_event(tui, event).await { @@ -2456,7 +2848,7 @@ impl App { if matches!(event, TuiEvent::Draw) { let size = tui.terminal.size()?; if size != tui.terminal.last_known_screen_size { - self.refresh_status_line(); + self.refresh_status_surfaces(); } } @@ -2585,11 +2977,11 @@ impl App { tui, self.config.clone(), ); - self.chat_widget = ChatWidget::new_from_existing( + self.replace_chat_widget(ChatWidget::new_from_existing( init, resumed.thread, resumed.session_configured, - ); + )); self.reset_thread_event_state(); if let Some(summary) = summary { let mut lines: Vec> = @@ -2642,7 +3034,7 @@ impl App { match self .server .fork_thread( - ForkSnapshot::TruncateBeforeNthUserMessage(usize::MAX), + ForkSnapshot::Interrupted, self.config.clone(), path.clone(), /*persist_extended_history*/ false, @@ -2657,11 +3049,11 @@ impl App { tui, self.config.clone(), ); - self.chat_widget = ChatWidget::new_from_existing( + self.replace_chat_widget(ChatWidget::new_from_existing( init, forked.thread, forked.session_configured, - ); + )); self.reset_thread_event_state(); if let Some(summary) = summary { let mut lines: Vec> = @@ -2698,14 +3090,6 @@ impl App { tui.frame_requester().schedule_frame(); } - AppEvent::StartBtw { - parent_thread_id, - user_message, - } => { - return self - .handle_start_btw(tui, parent_thread_id, user_message) - .await; - } AppEvent::InsertHistoryCell(cell) => { let cell: Arc = cell.into(); if let Some(Overlay::Transcript(t)) = &mut self.overlay { @@ -2828,6 +3212,36 @@ impl App { AppEvent::RefreshConnectors { force_refetch } => { self.chat_widget.refresh_connectors(force_refetch); } + AppEvent::PluginInstallAuthAdvance { refresh_connectors } => { + if refresh_connectors { + self.chat_widget.refresh_connectors(/*force_refetch*/ true); + } + self.chat_widget.advance_plugin_install_auth_flow(); + } + AppEvent::PluginInstallAuthAbandon => { + self.chat_widget.abandon_plugin_install_auth_flow(); + } + AppEvent::FetchPluginsList { cwd } => { + self.fetch_plugins_list(cwd); + } + AppEvent::OpenPluginDetailLoading { + plugin_display_name, + } => { + self.chat_widget + .open_plugin_detail_loading_popup(&plugin_display_name); + } + AppEvent::OpenPluginInstallLoading { + plugin_display_name, + } => { + self.chat_widget + .open_plugin_install_loading_popup(&plugin_display_name); + } + AppEvent::OpenPluginUninstallLoading { + plugin_display_name, + } => { + self.chat_widget + .open_plugin_uninstall_loading_popup(&plugin_display_name); + } AppEvent::StartFileSearch(query) => { self.file_search.on_user_query(query); } @@ -2840,17 +3254,76 @@ impl App { AppEvent::ConnectorsLoaded { result, is_final } => { self.chat_widget.on_connectors_loaded(result, is_final); } + AppEvent::PluginsLoaded { cwd, result } => { + self.chat_widget.on_plugins_loaded(cwd, result); + } + AppEvent::FetchPluginDetail { cwd, params } => { + self.fetch_plugin_detail(cwd, params); + } + AppEvent::PluginDetailLoaded { cwd, result } => { + self.chat_widget.on_plugin_detail_loaded(cwd, result); + } + AppEvent::FetchPluginInstall { + cwd, + marketplace_path, + plugin_name, + plugin_display_name, + } => { + self.fetch_plugin_install(cwd, marketplace_path, plugin_name, plugin_display_name); + } + AppEvent::FetchPluginUninstall { + cwd, + plugin_id, + plugin_display_name, + } => { + self.fetch_plugin_uninstall(cwd, plugin_id, plugin_display_name); + } + AppEvent::PluginInstallLoaded { + cwd, + marketplace_path, + plugin_name, + plugin_display_name, + result, + } => { + let install_succeeded = result.is_ok(); + if install_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!(error = %err, "failed to refresh config after plugin install"); + } + self.chat_widget.refresh_plugin_mentions(); + self.chat_widget.submit_op(Op::ReloadUserConfig); + } + let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded( + cwd.clone(), + marketplace_path.clone(), + plugin_name.clone(), + plugin_display_name, + result, + ); + if install_succeeded && self.chat_widget.config_ref().cwd == cwd { + self.fetch_plugins_list(cwd.clone()); + if should_refresh_plugin_detail { + self.fetch_plugin_detail( + cwd, + PluginReadParams { + marketplace_path, + plugin_name, + }, + ); + } + } + } AppEvent::UpdateReasoningEffort(effort) => { self.on_update_reasoning_effort(effort); - self.refresh_status_line(); + self.refresh_status_surfaces(); } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); - self.refresh_status_line(); + self.refresh_status_surfaces(); } AppEvent::UpdateCollaborationMode(mask) => { self.chat_widget.set_collaboration_mask(mask); - self.refresh_status_line(); + self.refresh_status_surfaces(); } AppEvent::UpdatePersonality(personality) => { self.on_update_personality(personality); @@ -2959,7 +3432,7 @@ impl App { Ok(()) => { session_telemetry.counter( "codex.windows_sandbox.elevated_setup_success", - 1, + /*inc*/ 1, &[], ); AppEvent::EnableWindowsSandboxForAgentMode { @@ -2989,7 +3462,7 @@ impl App { codex_core::windows_sandbox::elevated_setup_failure_metric_name( &err, ), - 1, + /*inc*/ 1, &tags, ); tracing::error!( @@ -3030,7 +3503,7 @@ impl App { ) { session_telemetry.counter( "codex.windows_sandbox.legacy_setup_preflight_failed", - 1, + /*inc*/ 1, &[], ); tracing::warn!( @@ -3055,7 +3528,7 @@ impl App { self.chat_widget .add_to_history(history_cell::new_info_event( format!("Granting sandbox read access to {path} ..."), - None, + /*hint*/ None, )); let policy = self.config.permissions.sandbox_policy.get().clone(); @@ -3130,11 +3603,13 @@ impl App { match builder.apply().await { Ok(()) => { if elevated_enabled { - self.config.set_windows_sandbox_enabled(false); - self.config.set_windows_elevated_sandbox_enabled(true); + self.config.set_windows_sandbox_enabled(/*value*/ false); + self.config + .set_windows_elevated_sandbox_enabled(/*value*/ true); } else { - self.config.set_windows_sandbox_enabled(true); - self.config.set_windows_elevated_sandbox_enabled(false); + self.config.set_windows_sandbox_enabled(/*value*/ true); + self.config + .set_windows_elevated_sandbox_enabled(/*value*/ false); } self.chat_widget.set_windows_sandbox_mode( self.config.permissions.windows_sandbox_mode, @@ -3255,6 +3730,32 @@ impl App { } } } + AppEvent::PluginUninstallLoaded { + cwd, + plugin_id: _plugin_id, + plugin_display_name, + result, + } => { + let uninstall_succeeded = result.is_ok(); + if uninstall_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!( + error = %err, + "failed to refresh config after plugin uninstall" + ); + } + self.chat_widget.refresh_plugin_mentions(); + self.chat_widget.submit_op(Op::ReloadUserConfig); + } + self.chat_widget.on_plugin_uninstall_loaded( + cwd.clone(), + plugin_display_name, + result, + ); + if uninstall_succeeded && self.chat_widget.config_ref().cwd == cwd { + self.fetch_plugins_list(cwd); + } + } AppEvent::PersistPersonalitySelection { personality } => { let profile = self.active_profile.as_deref(); match ConfigEditsBuilder::new(&self.config.codex_home) @@ -3291,7 +3792,7 @@ impl App { } } AppEvent::PersistServiceTierSelection { service_tier } => { - self.refresh_status_line(); + self.refresh_status_surfaces(); let profile = self.active_profile.as_deref(); match ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(profile) @@ -3496,7 +3997,7 @@ impl App { AppEvent::UpdatePlanModeReasoningEffort(effort) => { self.config.plan_mode_reasoning_effort = effort; self.chat_widget.set_plan_mode_reasoning_effort(effort); - self.refresh_status_line(); + self.refresh_status_surfaces(); } AppEvent::PersistFullAccessWarningAcknowledged => { if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) @@ -3609,6 +4110,14 @@ impl App { AppEvent::SelectAgentThread(thread_id) => { self.select_agent_thread(tui, thread_id).await?; } + AppEvent::StartBtw { + parent_thread_id, + user_message, + } => { + return self + .handle_start_btw(tui, parent_thread_id, user_message) + .await; + } AppEvent::OpenSkillsList => { self.chat_widget.open_skills_list(); } @@ -3785,7 +4294,11 @@ impl App { AppEvent::UpdateRecordingMeter { id, text } => { // Update in place to preserve the element id for subsequent frames. let updated = self.chat_widget.update_transcription_in_place(&id, &text); - if updated { + if updated + || self + .chat_widget + .stop_realtime_conversation_for_deleted_meter(&id) + { tui.frame_requester().schedule_frame(); } } @@ -3810,11 +4323,38 @@ impl App { } AppEvent::StatusLineBranchUpdated { cwd, branch } => { self.chat_widget.set_status_line_branch(cwd, branch); - self.refresh_status_line(); + self.refresh_status_surfaces(); } AppEvent::StatusLineSetupCancelled => { self.chat_widget.cancel_status_line_setup(); } + AppEvent::TerminalTitleSetup { items } => { + let ids = items.iter().map(ToString::to_string).collect::>(); + let edit = codex_core::config::edit::terminal_title_items_edit(&ids); + let apply_result = ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits([edit]) + .apply() + .await; + match apply_result { + Ok(()) => { + self.config.tui_terminal_title = Some(ids.clone()); + self.chat_widget.setup_terminal_title(items); + } + Err(err) => { + tracing::error!(error = %err, "failed to persist terminal title items; keeping previous selection"); + self.chat_widget.revert_terminal_title_setup_preview(); + self.chat_widget.add_error_message(format!( + "Failed to save terminal title items: {err}" + )); + } + } + } + AppEvent::TerminalTitleSetupPreview { items } => { + self.chat_widget.preview_terminal_title(items); + } + AppEvent::TerminalTitleSetupCancelled => { + self.chat_widget.cancel_terminal_title_setup(); + } AppEvent::SyntaxThemeSelected { name } => { let edit = codex_core::config::edit::syntax_theme_edit(&name); let apply_result = ConfigEditsBuilder::new(&self.config.codex_home) @@ -3891,7 +4431,7 @@ impl App { self.chat_widget.handle_codex_event(event); if needs_refresh { - self.refresh_status_line(); + self.refresh_status_surfaces(); } } @@ -3904,11 +4444,7 @@ impl App { /// This function enforces shutdown intent routing: unexpected non-primary /// thread shutdowns fail over to the primary thread, while user-requested /// app exits consume only the tracked shutdown completion and then proceed. - async fn handle_active_thread_event( - &mut self, - tui: &mut tui::Tui, - event: Event, - ) -> Result { + async fn handle_active_thread_event(&mut self, tui: &mut tui::Tui, event: Event) -> Result<()> { // Capture this before any potential thread switch: we only want to clear // the exit marker when the currently active thread acknowledges shutdown. let pending_shutdown_exit_completed = matches!(&event.msg, EventMsg::ShutdownComplete) @@ -3940,7 +4476,7 @@ impl App { "Agent thread {closed_thread_id} closed. Failed to switch back to main thread {primary_thread_id}.", )); } - return Ok(AppRunControl::Continue); + return Ok(()); } if pending_shutdown_exit_completed { @@ -3949,13 +4485,10 @@ impl App { self.pending_shutdown_exit_thread_id = None; } self.handle_codex_event_now(event); - if pending_shutdown_exit_completed { - return Ok(AppRunControl::Exit(ExitReason::UserRequested)); - } if self.backtrack_render_pending { tui.frame_requester().schedule_frame(); } - Ok(AppRunControl::Continue) + Ok(()) } async fn handle_thread_created(&mut self, thread_id: ThreadId) -> Result<()> { @@ -3970,6 +4503,12 @@ impl App { } }; let config_snapshot = thread.config_snapshot().await; + self.upsert_agent_picker_thread( + thread_id, + config_snapshot.session_source.get_nickname(), + config_snapshot.session_source.get_agent_role(), + /*is_closed*/ false, + ); let event = Event { id: String::new(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { @@ -4276,8 +4815,8 @@ impl App { }; } - fn refresh_status_line(&mut self) { - self.chat_widget.refresh_status_line(); + fn refresh_status_surfaces(&mut self) { + self.chat_widget.refresh_status_surfaces(); } #[cfg(target_os = "windows")] @@ -4309,12 +4848,21 @@ impl App { } } +impl Drop for App { + fn drop(&mut self) { + if let Err(err) = self.chat_widget.clear_managed_terminal_title() { + tracing::debug!(error = %err, "failed to clear terminal title on app drop"); + } + } +} + #[cfg(test)] mod tests { use super::*; use crate::app_backtrack::BacktrackSelection; use crate::app_backtrack::BacktrackState; use crate::app_backtrack::user_count; + use crate::bottom_pane::TerminalTitleItem; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; use crate::chatwidget::tests::set_chatgpt_auth; use crate::file_search::FileSearchManager; @@ -4360,9 +4908,6 @@ mod tests { use tempfile::tempdir; use tokio::time; - #[path = "btw.rs"] - mod btw; - #[test] fn normalize_harness_overrides_resolves_relative_add_dirs() -> Result<()> { let temp_dir = tempdir()?; @@ -4440,6 +4985,62 @@ mod tests { ); } + fn render_history_cell(cell: &dyn HistoryCell, width: u16) -> String { + cell.display_lines(width) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n") + } + + #[tokio::test] + async fn startup_custom_prompt_deprecation_notice_emits_when_prompts_exist() -> Result<()> { + let codex_home = tempdir()?; + let prompts_dir = codex_home.path().join("prompts"); + std::fs::create_dir_all(&prompts_dir)?; + std::fs::write(prompts_dir.join("review.md"), "# Review\n")?; + + let (tx_raw, mut rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(tx_raw); + + emit_custom_prompt_deprecation_notice(&app_event_tx, codex_home.path()).await; + + let cell = match rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected InsertHistoryCell event, got {other:?}"), + }; + let rendered = render_history_cell(cell.as_ref(), 120); + + assert_snapshot!("startup_custom_prompt_deprecation_notice", rendered); + assert!(rx.try_recv().is_err(), "expected only one startup notice"); + Ok(()) + } + + #[tokio::test] + async fn startup_custom_prompt_deprecation_notice_skips_missing_prompts_dir() -> Result<()> { + let codex_home = tempdir()?; + let (tx_raw, mut rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(tx_raw); + + emit_custom_prompt_deprecation_notice(&app_event_tx, codex_home.path()).await; + + assert!(rx.try_recv().is_err(), "expected no startup notice"); + Ok(()) + } + + #[tokio::test] + async fn startup_custom_prompt_deprecation_notice_skips_empty_prompts_dir() -> Result<()> { + let codex_home = tempdir()?; + std::fs::create_dir_all(codex_home.path().join("prompts"))?; + let (tx_raw, mut rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(tx_raw); + + emit_custom_prompt_deprecation_notice(&app_event_tx, codex_home.path()).await; + + assert!(rx.try_recv().is_err(), "expected no startup notice"); + Ok(()) + } + #[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( @@ -4780,7 +5381,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -4862,7 +5463,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -4943,7 +5544,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -5018,7 +5619,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -5084,6 +5685,38 @@ mod tests { } } + #[tokio::test] + async fn replace_chat_widget_preserves_terminal_title_cache_for_empty_replacement_title() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.chat_widget.last_terminal_title = Some("my-project | Ready".to_string()); + + let (mut replacement, _app_event_tx, _rx, _new_op_rx) = + make_chatwidget_manual_with_sender().await; + replacement.setup_terminal_title(Vec::new()); + + app.replace_chat_widget(replacement); + + assert_eq!(app.chat_widget.last_terminal_title, None); + } + + #[tokio::test] + async fn replace_chat_widget_keeps_replacement_terminal_title_cache_when_present() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.chat_widget.last_terminal_title = Some("old-project | Ready".to_string()); + + let (mut replacement, _app_event_tx, _rx, _new_op_rx) = + make_chatwidget_manual_with_sender().await; + replacement.setup_terminal_title(vec![TerminalTitleItem::AppName]); + replacement.last_terminal_title = Some("codex".to_string()); + + app.replace_chat_widget(replacement); + + assert_eq!( + app.chat_widget.last_terminal_title, + Some("codex".to_string()) + ); + } + #[tokio::test] async fn replay_thread_snapshot_restores_pending_pastes_for_submit() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -5525,7 +6158,7 @@ mod tests { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); - let guardian_approvals = super::guardian_approvals_mode(); + let guardian_approvals = guardian_approvals_mode(); app.update_feature_flags(vec![(Feature::GuardianApproval, true)]) .await; @@ -5583,7 +6216,6 @@ mod tests { personality: None, }) ); - let cell = match app_event_rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => cell, other => panic!("expected InsertHistoryCell event, got {other:?}"), @@ -5701,7 +6333,7 @@ mod tests { let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); - let guardian_approvals = super::guardian_approvals_mode(); + let guardian_approvals = guardian_approvals_mode(); let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; let config_toml = "approvals_reviewer = \"user\"\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; @@ -5828,7 +6460,7 @@ mod tests { let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); - let guardian_approvals = super::guardian_approvals_mode(); + let guardian_approvals = guardian_approvals_mode(); app.active_profile = Some("guardian".to_string()); let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; let config_toml = "profile = \"guardian\"\napprovals_reviewer = \"user\"\n"; @@ -6398,7 +7030,7 @@ guardian_approval = true make_header(true), Arc::new(crate::history_cell::new_info_event( "startup tip that used to replay".to_string(), - None, + /*hint*/ None, )) as Arc, user_cell("Tell me a long story about a town with a dark lighthouse."), agent_cell(story_part_one), @@ -6495,6 +7127,9 @@ guardian_approval = true config, active_profile: None, cli_kv_overrides: Vec::new(), + arg0_paths: Arg0DispatchPaths::default(), + loader_overrides: LoaderOverrides::default(), + cloud_requirements: CloudRequirementsLoader::default(), harness_overrides: ConfigOverrides::default(), runtime_approval_policy_override: None, runtime_sandbox_policy_override: None, @@ -6506,6 +7141,7 @@ guardian_approval = true enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), + terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), @@ -6556,6 +7192,9 @@ guardian_approval = true config, active_profile: None, cli_kv_overrides: Vec::new(), + arg0_paths: Arg0DispatchPaths::default(), + loader_overrides: LoaderOverrides::default(), + cloud_requirements: CloudRequirementsLoader::default(), harness_overrides: ConfigOverrides::default(), runtime_approval_policy_override: None, runtime_sandbox_policy_override: None, @@ -6567,6 +7206,7 @@ guardian_approval = true enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), + terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), @@ -6617,10 +7257,6 @@ guardian_approval = true ) } - fn make_test_tui() -> crate::tui::Tui { - crate::tui::Tui::new_test() - } - fn app_enabled_in_effective_config(config: &Config, app_id: &str) -> Option { config .config_layer_stack @@ -7630,35 +8266,6 @@ guardian_approval = true assert_eq!(op_rx.try_recv(), Ok(Op::Shutdown)); } - #[tokio::test] - async fn shutdown_first_exit_matching_shutdown_complete_exits() -> Result<()> { - let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; - let mut tui = make_test_tui(); - let thread_id = ThreadId::new(); - app.active_thread_id = Some(thread_id); - app.primary_thread_id = Some(thread_id); - - let control = app.handle_exit_mode(ExitMode::ShutdownFirst); - assert!(matches!(control, AppRunControl::Continue)); - assert_eq!(op_rx.try_recv(), Ok(Op::Shutdown)); - - let control = app - .handle_active_thread_event( - &mut tui, - Event { - id: "shutdown-complete".to_string(), - msg: EventMsg::ShutdownComplete, - }, - ) - .await?; - assert!(matches!( - control, - AppRunControl::Exit(ExitReason::UserRequested) - )); - assert_eq!(app.pending_shutdown_exit_thread_id, None); - Ok(()) - } - #[tokio::test] async fn clear_only_ui_reset_preserves_chat_session_state() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 97f3e7a019..7b7b383eb1 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -10,12 +10,18 @@ use std::path::PathBuf; +use codex_app_server_protocol::PluginInstallResponse; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginReadParams; +use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginUninstallResponse; use codex_chatgpt::connectors::AppInfo; use codex_file_search::FileMatch; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::Event; use codex_protocol::protocol::RateLimitSnapshot; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; use crate::bottom_pane::ApprovalRequest; @@ -25,7 +31,7 @@ use crate::chatwidget::UserMessage; use crate::history_cell::HistoryCell; use codex_core::config::types::ApprovalsReviewer; -use codex_core::features::Feature; +use codex_features::Feature; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ServiceTier; @@ -76,7 +82,6 @@ pub(crate) enum AppEvent { OpenAgentPicker, /// Switch the active thread to the selected agent. SelectAgentThread(ThreadId), - /// Fork the current thread into a transient BTW child and submit a side-question there. StartBtw { parent_thread_id: ThreadId, @@ -169,6 +174,84 @@ pub(crate) enum AppEvent { force_refetch: bool, }, + /// Fetch plugin marketplace state for the provided working directory. + FetchPluginsList { + cwd: PathBuf, + }, + + /// Result of fetching plugin marketplace state. + PluginsLoaded { + cwd: PathBuf, + result: Result, + }, + + /// Replace the plugins popup with a plugin-detail loading state. + OpenPluginDetailLoading { + plugin_display_name: String, + }, + + /// Fetch detail for a specific plugin from a marketplace. + FetchPluginDetail { + cwd: PathBuf, + params: PluginReadParams, + }, + + /// Result of fetching plugin detail. + PluginDetailLoaded { + cwd: PathBuf, + result: Result, + }, + + /// Replace the plugins popup with an install loading state. + OpenPluginInstallLoading { + plugin_display_name: String, + }, + + /// Replace the plugins popup with an uninstall loading state. + OpenPluginUninstallLoading { + plugin_display_name: String, + }, + + /// Install a specific plugin from a marketplace. + FetchPluginInstall { + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + }, + + /// Result of installing a plugin. + PluginInstallLoaded { + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + result: Result, + }, + + /// Uninstall a specific plugin by canonical plugin id. + FetchPluginUninstall { + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + }, + + /// Result of uninstalling a plugin. + PluginUninstallLoaded { + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + result: Result, + }, + + /// Advance the post-install plugin app-auth flow. + PluginInstallAuthAdvance { + refresh_connectors: bool, + }, + + /// Abandon the post-install plugin app-auth flow. + PluginInstallAuthAbandon, + InsertHistoryCell(Box), /// Apply rollback semantics to local transcript cells. @@ -459,6 +542,16 @@ pub(crate) enum AppEvent { }, /// Dismiss the status-line setup UI without changing config. StatusLineSetupCancelled, + /// Apply a user-confirmed terminal-title item ordering/selection. + TerminalTitleSetup { + items: Vec, + }, + /// Apply a temporary terminal-title preview while the setup UI is open. + TerminalTitleSetupPreview { + items: Vec, + }, + /// Dismiss the terminal-title setup UI without changing config. + TerminalTitleSetupCancelled, /// Apply a user-confirmed syntax theme selection. SyntaxThemeSelected { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f4acd87a56..e3b20dc7a9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -37,19 +37,27 @@ use std::sync::atomic::Ordering; use std::time::Duration; use std::time::Instant; +use url::Url; + use self::realtime::PendingSteerCompareKey; use crate::app_event::RealtimeAudioDeviceKind; -#[cfg(all(not(target_os = "linux"), feature = "voice-input"))] +#[cfg(not(target_os = "linux"))] use crate::audio_device::list_realtime_audio_device_names; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::StatusLinePreviewData; use crate::bottom_pane::StatusLineSetupView; +use crate::bottom_pane::TerminalTitleItem; +use crate::bottom_pane::TerminalTitleSetupView; use crate::status::RateLimitWindowDisplay; use crate::status::format_directory_display; use crate::status::format_tokens_compact; use crate::status::rate_limit_snapshot_display_for_limit; +use crate::terminal_title::SetTerminalTitleResult; +use crate::terminal_title::clear_terminal_title; +use crate::terminal_title::set_terminal_title; use crate::text_formatting::proper_join; use crate::version::CODEX_CLI_VERSION; +use codex_app_server_protocol::AppSummary; use codex_app_server_protocol::ConfigLayerSource; use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; @@ -60,20 +68,21 @@ use codex_core::config::types::ApprovalsReviewer; use codex_core::config::types::Notifications; use codex_core::config::types::WindowsSandboxModeToml; use codex_core::config_loader::ConfigLayerStackOrdering; -use codex_core::features::FEATURES; -use codex_core::features::Feature; -use codex_core::git_info::current_branch_name; -use codex_core::git_info::get_git_repo_root; -use codex_core::git_info::local_git_branches; use codex_core::mcp::McpManager; use codex_core::models_manager::manager::ModelsManager; use codex_core::plugins::PluginsManager; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use codex_core::skills::model::SkillMetadata; -use codex_core::terminal::TerminalName; -use codex_core::terminal::terminal_info; #[cfg(target_os = "windows")] use codex_core::windows_sandbox::WindowsSandboxLevelExt; +use codex_features::FEATURES; +use codex_features::Feature; +#[cfg(test)] +use codex_git_utils::CommitLogEntry; +use codex_git_utils::current_branch_name; +use codex_git_utils::get_git_repo_root; +use codex_git_utils::local_git_branches; +use codex_git_utils::recent_commits; use codex_otel::RuntimeMetricsSummary; use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; @@ -149,6 +158,10 @@ use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; +use codex_terminal_detection::TerminalName; +use codex_terminal_detection::terminal_info; use codex_utils_sleep_inhibitor::SleepInhibitor; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -168,6 +181,7 @@ use tokio::sync::mpsc::UnboundedSender; use tokio::task::JoinHandle; use tracing::debug; use tracing::warn; +use unicode_segmentation::UnicodeSegmentation; const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading"; const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?"; @@ -186,14 +200,21 @@ const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection"; /// Choose the keybinding used to edit the most-recently queued message. /// /// Apple Terminal, Warp, and VSCode integrated terminals intercept or silently -/// swallow Alt+Up, so users in those environments would never be able to trigger -/// the edit action. We fall back to Shift+Left for those terminals while -/// keeping the more discoverable Alt+Up everywhere else. +/// swallow Alt+Up, and tmux does not reliably pass that chord through. We fall +/// back to Shift+Left for those environments while keeping the more discoverable +/// Alt+Up everywhere else. /// /// The match is exhaustive so that adding a new `TerminalName` variant forces /// an explicit decision about which binding that terminal should use. -fn queued_message_edit_binding_for_terminal(terminal_name: TerminalName) -> KeyBinding { - match terminal_name { +fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyBinding { + if matches!( + terminal_info.multiplexer.as_ref(), + Some(Multiplexer::Tmux { .. }) + ) { + return key_hint::shift(KeyCode::Left); + } + + match terminal_info.name { TerminalName::AppleTerminal | TerminalName::WarpTerminal | TerminalName::VsCode => { key_hint::shift(KeyCode::Left) } @@ -280,9 +301,16 @@ mod skills; use self::skills::collect_tool_mentions; use self::skills::find_app_mentions; use self::skills::find_skill_mentions_with_tool_mentions; +mod plugins; +use self::plugins::PluginsCacheState; mod realtime; use self::realtime::RealtimeConversationUiState; use self::realtime::RenderedUserMessageEvent; +mod status_surfaces; +use self::status_surfaces::CachedProjectRootName; +#[cfg(test)] +use self::status_surfaces::TERMINAL_TITLE_SPINNER_INTERVAL; +use self::status_surfaces::TerminalTitleStatusKind; use crate::mention_codec::LinkedMention; use crate::mention_codec::encode_history_mentions; use crate::streaming::chunking::AdaptiveChunkingPolicy; @@ -299,6 +327,7 @@ use codex_file_search::FileMatch; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; @@ -483,6 +512,8 @@ pub(crate) struct ChatWidgetInit { pub(crate) startup_tooltip_override: Option, // Shared latch so we only warn once about invalid status-line item IDs. pub(crate) status_line_invalid_items_warned: Arc, + // Shared latch so we only warn once about invalid terminal-title item IDs. + pub(crate) terminal_title_invalid_items_warned: Arc, pub(crate) session_telemetry: SessionTelemetry, } @@ -503,6 +534,18 @@ enum ConnectorsCacheState { Failed(String), } +#[derive(Debug, Clone, Default)] +struct PluginListFetchState { + cache_cwd: Option, + in_flight_cwd: Option, +} + +#[derive(Debug, Clone)] +struct PluginInstallAuthFlowState { + plugin_display_name: String, + next_app_index: usize, +} + #[derive(Debug)] enum RateLimitErrorKind { ServerOverloaded, @@ -701,6 +744,11 @@ pub(crate) struct ChatWidget { connectors_partial_snapshot: Option, connectors_prefetch_in_flight: bool, connectors_force_refetch_pending: bool, + pending_mcp_output_requests: usize, + plugins_cache: PluginsCacheState, + plugins_fetch_state: PluginListFetchState, + plugin_install_apps_needing_auth: Vec, + plugin_install_auth_flow: Option, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, // Accumulates the current reasoning block text to extract a header @@ -714,6 +762,8 @@ pub(crate) struct ChatWidget { // Guardian review keeps its own pending set so it can derive a single // footer summary from one or more in-flight review events. pending_guardian_review_status: PendingGuardianReviewStatus, + // Semantic status used for terminal-title status rendering (avoid string matching on headers). + terminal_title_status_kind: TerminalTitleStatusKind, // Previous status header to restore after a transient stream retry. retry_status_header: Option, // Set when commentary output completes; once stream queues go idle we restore the status row. @@ -735,6 +785,8 @@ pub(crate) struct ChatWidget { suppress_session_configured_redraw: bool, // User messages queued while a turn is in progress queued_user_messages: VecDeque, + // User messages that tried to steer a non-regular turn and must be retried first. + rejected_steers_queue: VecDeque, // Steers already submitted to core but not yet committed into history. // // The bottom pane shows these above queued drafts until core records the @@ -779,6 +831,8 @@ pub(crate) struct ChatWidget { // later steer. This is cleared when the user submits a steer so the plan popup only appears // if a newer proposed plan arrives afterward. saw_plan_item_this_turn: bool, + // Latest `update_plan` checklist task counts for terminal-title rendering. + last_plan_progress: Option<(usize, usize)>, // Incremental buffer for streamed plan content. plan_delta_buffer: String, // True while a plan item is streaming. @@ -802,6 +856,21 @@ pub(crate) struct ChatWidget { session_network_proxy: Option, // Shared latch so we only warn once about invalid status-line item IDs. status_line_invalid_items_warned: Arc, + // Shared latch so we only warn once about invalid terminal-title item IDs. + terminal_title_invalid_items_warned: Arc, + // Last terminal title emitted, to avoid writing duplicate OSC updates. + // + // App carries this cache across ChatWidget replacement so the next widget can + // clear a stale title when its own configuration renders no title content. + pub(crate) last_terminal_title: Option, + // Original terminal-title config captured when opening the setup UI so live preview can be + // rolled back on cancel. + terminal_title_setup_original_items: Option>>, + // Baseline instant used to animate spinner-prefixed title statuses. + terminal_title_animation_origin: Instant, + // Cached project root display name for the current cwd; avoids walking parent directories on + // frequent title/status refreshes. + status_line_project_root_name_cache: Option, // Cached git branch name for the status line (None if unknown). status_line_branch: Option, // CWD used to resolve the cached branch; change resets branch state. @@ -878,9 +947,11 @@ impl ThreadComposerState { pub(crate) struct ThreadInputState { composer: Option, pending_steers: VecDeque, + rejected_steers_queue: VecDeque, queued_user_messages: VecDeque, current_collaboration_mode: CollaborationMode, active_collaboration_mask: Option, + task_running: bool, agent_turn_running: bool, } @@ -1087,7 +1158,7 @@ impl ChatWidget { } fn realtime_audio_device_selection_enabled(&self) -> bool { - self.realtime_conversation_enabled() && cfg!(feature = "voice-input") + self.realtime_conversation_enabled() } /// Synchronize the bottom-pane "task running" indicator with the current lifecycles. @@ -1097,12 +1168,15 @@ impl ChatWidget { fn update_task_running_state(&mut self) { self.bottom_pane .set_task_running(self.agent_turn_running || self.mcp_startup_status.is_some()); + self.refresh_terminal_title(); } fn restore_reasoning_status_header(&mut self) { if let Some(header) = extract_first_bold(&self.reasoning_buffer) { + self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; self.set_status_header(header); } else if self.bottom_pane.is_task_running() { + self.terminal_title_status_kind = TerminalTitleStatusKind::Working; self.set_status_header(String::from("Working")); } } @@ -1195,6 +1269,22 @@ impl ChatWidget { 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 == "status")); + let title_uses_spinner = self + .config + .tui_terminal_title + .as_ref() + .is_none_or(|items| items.iter().any(|item| item == "spinner")); + if title_uses_status + || (title_uses_spinner + && self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing) + { + self.refresh_terminal_title(); + } } /// Convenience wrapper around [`Self::set_status`]; @@ -1221,70 +1311,6 @@ impl ChatWidget { 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) { - let (items, invalid_items) = self.status_line_items_with_invalids(); - if self.thread_id.is_some() - && !invalid_items.is_empty() - && self - .status_line_invalid_items_warned - .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - let label = if invalid_items.len() == 1 { - "item" - } else { - "items" - }; - let message = format!( - "Ignored invalid status line {label}: {}.", - proper_join(invalid_items.as_slice()) - ); - self.on_warning(message); - } - if !items.contains(&StatusLineItem::GitBranch) { - self.status_line_branch = None; - self.status_line_branch_pending = false; - self.status_line_branch_lookup_complete = false; - } - let enabled = !items.is_empty(); - self.bottom_pane.set_status_line_enabled(enabled); - if !enabled { - self.set_status_line(/*status_line*/ None); - return; - } - - let cwd = self.status_line_cwd().to_path_buf(); - self.sync_status_line_branch_state(&cwd); - - if items.contains(&StatusLineItem::GitBranch) && !self.status_line_branch_lookup_complete { - self.request_status_line_branch(cwd); - } - - let mut parts = Vec::new(); - for item in items { - if let Some(value) = self.status_line_value_for_item(&item) { - parts.push(value); - } - } - - let line = if parts.is_empty() { - None - } else { - Some(Line::from(parts.join(" · "))) - }; - self.set_status_line(line); - } - /// Records that status-line setup was canceled. /// /// Cancellation is intentionally side-effect free for config state; the existing configuration @@ -1300,7 +1326,45 @@ impl ChatWidget { tracing::info!("status line setup confirmed with items: {items:#?}"); let ids = items.iter().map(ToString::to_string).collect::>(); self.config.tui_status_line = Some(ids); - self.refresh_status_line(); + self.refresh_status_surfaces(); + } + + /// Applies a temporary terminal-title selection while the setup UI is open. + pub(crate) fn preview_terminal_title(&mut self, items: Vec) { + 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::>(); + self.config.tui_terminal_title = Some(ids); + self.refresh_terminal_title(); + } + + /// Restores the terminal title selection captured before opening the setup UI. + 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(); + } + + /// Records that terminal-title setup was canceled and rolls back live preview changes. + pub(crate) fn cancel_terminal_title_setup(&mut self) { + tracing::info!("Terminal title setup canceled by user"); + self.revert_terminal_title_setup_preview(); + } + + /// Applies terminal-title item selection from the setup view to in-memory config. + /// + /// An empty selection persists as an explicit empty list (disables title updates). + pub(crate) fn setup_terminal_title(&mut self, items: Vec) { + tracing::info!("terminal title setup confirmed with items: {items:#?}"); + let ids = items.iter().map(ToString::to_string).collect::>(); + 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. @@ -1317,17 +1381,6 @@ impl ChatWidget { self.status_line_branch_lookup_complete = true; } - /// Forces a new git-branch lookup when `GitBranch` is part of the configured status line. - fn request_status_line_branch_refresh(&mut self) { - let (items, _) = self.status_line_items_with_invalids(); - if items.is_empty() || !items.contains(&StatusLineItem::GitBranch) { - return; - } - let cwd = self.status_line_cwd().to_path_buf(); - self.sync_status_line_branch_state(&cwd); - self.request_status_line_branch(cwd); - } - fn collect_runtime_metrics_delta(&mut self) { if let Some(delta) = self.session_telemetry.runtime_metrics_summary() { self.apply_runtime_metrics_delta(delta); @@ -1393,6 +1446,7 @@ impl ChatWidget { Constrained::allow_only(event.sandbox_policy.clone()); } self.config.approvals_reviewer = event.approvals_reviewer; + self.status_line_project_root_name_cache = None; let initial_messages = event.initial_messages.clone(); self.last_copyable_output = None; let forked_from_id = event.forked_from_id; @@ -1410,6 +1464,7 @@ impl ChatWidget { self.refresh_model_display(); self.sync_fast_command_enabled(); self.sync_personality_command_enabled(); + self.sync_plugins_command_enabled(); self.refresh_plugin_mentions(); let startup_tooltip_override = self.startup_tooltip_override.take(); let show_fast_status = self.should_show_fast_status(&model_for_header, event.service_tier); @@ -1435,9 +1490,6 @@ impl ChatWidget { cwds: Vec::new(), force_reload: true, }); - if self.connectors_enabled() { - self.prefetch_connectors(); - } if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } @@ -1476,6 +1528,7 @@ impl ChatWidget { fn on_thread_name_updated(&mut self, event: codex_protocol::protocol::ThreadNameUpdatedEvent) { if self.thread_id == Some(event.thread_id) { self.thread_name = event.thread_name; + self.refresh_terminal_title(); self.request_redraw(); } } @@ -1647,6 +1700,7 @@ impl ChatWidget { if let Some(header) = extract_first_bold(&self.reasoning_buffer) { // Update the shimmer header to the extracted reasoning chunk header. + self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; self.set_status_header(header); } else { // Fallback while we don't yet have a bold header: leave existing header as-is. @@ -1684,6 +1738,7 @@ impl ChatWidget { .set_turn_running(/*turn_running*/ true); self.saw_plan_update_this_turn = false; self.saw_plan_item_this_turn = false; + self.last_plan_progress = None; self.plan_delta_buffer.clear(); self.plan_item_active = false; self.adaptive_chunking.reset(); @@ -1698,6 +1753,7 @@ impl ChatWidget { self.pending_status_indicator_restore = false; self.bottom_pane .set_interrupt_hint_visible(/*visible*/ true); + self.terminal_title_status_kind = TerminalTitleStatusKind::Working; self.set_status_header(String::from("Working")); self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); @@ -1758,7 +1814,7 @@ impl ChatWidget { let had_pending_steers = !self.pending_steers.is_empty(); self.refresh_pending_input_preview(); - if !from_replay && self.queued_user_messages.is_empty() && !had_pending_steers { + if !from_replay && !self.has_queued_follow_up_messages() && !had_pending_steers { self.maybe_prompt_plan_implementation(); } // Keep this flag for replayed completion events so a subsequent live TurnComplete can @@ -1780,7 +1836,7 @@ impl ChatWidget { if !self.collaboration_modes_enabled() { return; } - if !self.queued_user_messages.is_empty() { + if self.has_queued_follow_up_messages() { return; } if self.active_mode_kind() != ModeKind::Plan { @@ -1852,6 +1908,50 @@ impl ChatWidget { }); } + fn has_queued_follow_up_messages(&self) -> bool { + !self.rejected_steers_queue.is_empty() || !self.queued_user_messages.is_empty() + } + + fn pop_next_queued_user_message(&mut self) -> Option { + if self.rejected_steers_queue.is_empty() { + self.queued_user_messages.pop_front() + } else { + Some(merge_user_messages( + self.rejected_steers_queue.drain(..).collect(), + )) + } + } + + fn pop_latest_queued_user_message(&mut self) -> Option { + self.queued_user_messages + .pop_back() + .or_else(|| self.rejected_steers_queue.pop_back()) + } + + pub(crate) fn enqueue_rejected_steer(&mut self) -> bool { + let Some(pending_steer) = self.pending_steers.pop_front() else { + tracing::warn!( + "received active-turn-not-steerable error without a matching pending steer" + ); + return false; + }; + self.rejected_steers_queue + .push_back(pending_steer.user_message); + if !self.bottom_pane.is_task_running() { + // Will drain rejected_steers_queue in case the steer rejection arrives after task completion + self.maybe_send_next_queued_input(); + } + self.refresh_pending_input_preview(); + true + } + + fn handle_steer_rejected_error(&mut self, codex_error_info: &CodexErrorInfo) -> bool { + matches!( + codex_error_info, + CodexErrorInfo::ActiveTurnNotSteerable { .. } + ) && self.enqueue_rejected_steer() + } + pub(crate) fn open_multi_agent_enable_prompt(&mut self) { let items = vec![ SelectionItem { @@ -2011,7 +2111,14 @@ impl ChatWidget { .map(|w| 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!( @@ -2036,7 +2143,7 @@ impl ChatWidget { } else { self.rate_limit_snapshots_by_limit_id.clear(); } - self.refresh_status_line(); + self.refresh_status_surfaces(); } /// Finalize any active exec as failed and stop/clear agent-turn UI state. /// @@ -2139,6 +2246,7 @@ impl ChatWidget { } fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) { + let codex_apps_ready = ev.ready.iter().any(|server| server == "codex_apps"); let mut parts = Vec::new(); if !ev.failed.is_empty() { let failed_servers: Vec<_> = ev.failed.iter().map(|f| f.server.clone()).collect(); @@ -2157,6 +2265,11 @@ impl ChatWidget { self.mcp_startup_status = None; self.update_task_running_state(); self.maybe_send_next_queued_input(); + if self.connectors_enabled() && codex_apps_ready { + // Populate `$` app mentions from the session's already-started MCP manager + // instead of doing a separate TUI-side connector prefetch. + self.submit_op(Op::ListMcpTools); + } self.request_redraw(); } @@ -2210,7 +2323,7 @@ impl ChatWidget { /// state stays aligned with the merged attachment list. Returns `None` when there is nothing to /// restore. fn drain_pending_messages_for_restore(&mut self) -> Option { - if self.pending_steers.is_empty() && self.queued_user_messages.is_empty() { + if self.pending_steers.is_empty() && !self.has_queued_follow_up_messages() { return None; } @@ -2222,11 +2335,12 @@ impl ChatWidget { mention_bindings: self.bottom_pane.composer_mention_bindings(), }; - let mut to_merge: Vec = self - .pending_steers - .drain(..) - .map(|steer| steer.user_message) - .collect(); + let mut to_merge: Vec = self.rejected_steers_queue.drain(..).collect(); + to_merge.extend( + self.pending_steers + .drain(..) + .map(|steer| steer.user_message), + ); to_merge.extend(self.queued_user_messages.drain(..)); if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() @@ -2272,14 +2386,17 @@ impl ChatWidget { .iter() .map(|pending| pending.user_message.clone()) .collect(), + rejected_steers_queue: self.rejected_steers_queue.clone(), queued_user_messages: self.queued_user_messages.clone(), current_collaboration_mode: self.current_collaboration_mode.clone(), active_collaboration_mask: self.active_collaboration_mask.clone(), + task_running: self.bottom_pane.is_task_running(), agent_turn_running: self.agent_turn_running, }) } pub(crate) fn restore_thread_input_state(&mut self, input_state: Option) { + let restored_task_running = input_state.as_ref().is_some_and(|state| state.task_running); if let Some(input_state) = input_state { self.current_collaboration_mode = input_state.current_collaboration_mode; self.active_collaboration_mask = input_state.active_collaboration_mask; @@ -2311,13 +2428,24 @@ impl ChatWidget { ); self.bottom_pane.set_composer_pending_pastes(Vec::new()); } - self.pending_steers.clear(); - self.queued_user_messages = input_state.pending_steers; - self.queued_user_messages - .extend(input_state.queued_user_messages); + self.pending_steers = input_state + .pending_steers + .into_iter() + .map(|user_message| PendingSteer { + compare_key: PendingSteerCompareKey { + message: user_message.text.clone(), + image_count: user_message.local_images.len() + + user_message.remote_image_urls.len(), + }, + user_message, + }) + .collect(); + self.rejected_steers_queue = input_state.rejected_steers_queue; + self.queued_user_messages = input_state.queued_user_messages; } else { self.agent_turn_running = false; self.pending_steers.clear(); + self.rejected_steers_queue.clear(); self.set_remote_image_urls(Vec::new()); self.bottom_pane.set_composer_text_with_mention_bindings( String::new(), @@ -2331,6 +2459,10 @@ impl ChatWidget { self.turn_sleep_inhibitor .set_turn_running(self.agent_turn_running); self.update_task_running_state(); + if restored_task_running && !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + self.refresh_terminal_title(); + } self.refresh_pending_input_preview(); self.request_redraw(); } @@ -2341,6 +2473,17 @@ impl ChatWidget { fn on_plan_update(&mut self, update: UpdatePlanArgs) { self.saw_plan_update_this_turn = true; + let total = update.plan.len(); + let completed = update + .plan + .iter() + .filter(|item| match &item.status { + StepStatus::Completed => true, + StepStatus::Pending | StepStatus::InProgress => false, + }) + .count(); + self.last_plan_progress = (total > 0).then_some((completed, total)); + self.refresh_terminal_title(); self.add_to_history(history_cell::new_plan_update(update)); } @@ -2659,6 +2802,7 @@ impl ChatWidget { self.bottom_pane.ensure_status_indicator(); self.bottom_pane .set_interrupt_hint_visible(/*visible*/ true); + self.terminal_title_status_kind = TerminalTitleStatusKind::WaitingForBackgroundTerminal; self.set_status( "Waiting for background terminal".to_string(), command_display.clone(), @@ -2717,15 +2861,15 @@ impl ChatWidget { fn on_image_generation_end(&mut self, event: ImageGenerationEndEvent) { self.flush_answer_stream_with_separator(); - let saved_to = event.saved_path.as_deref().and_then(|saved_path| { - std::path::Path::new(saved_path) - .parent() - .map(|parent| parent.display().to_string()) + let saved_path = event.saved_path.map(|saved_path| { + Url::from_file_path(Path::new(&saved_path)) + .map(|url| url.to_string()) + .unwrap_or(saved_path) }); self.add_to_history(history_cell::new_image_generation_call( event.call_id, event.revised_prompt, - saved_to, + saved_path, )); self.request_redraw(); } @@ -2901,7 +3045,7 @@ impl ChatWidget { fn on_turn_diff(&mut self, unified_diff: String) { debug!("TurnDiffEvent: {unified_diff}"); - self.refresh_status_line(); + self.refresh_status_surfaces(); } fn on_deprecation_notice(&mut self, event: DeprecationNoticeEvent) { @@ -2915,6 +3059,7 @@ impl ChatWidget { self.bottom_pane.ensure_status_indicator(); self.bottom_pane .set_interrupt_hint_visible(/*visible*/ true); + self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; self.set_status_header(message); } @@ -2956,12 +3101,15 @@ impl ChatWidget { let message = event .message .unwrap_or_else(|| "Undo in progress...".to_string()); + self.terminal_title_status_kind = TerminalTitleStatusKind::Undoing; self.set_status_header(message); } fn on_undo_completed(&mut self, event: UndoCompletedEvent) { let UndoCompletedEvent { success, message } = event; self.bottom_pane.hide_status_indicator(); + self.terminal_title_status_kind = TerminalTitleStatusKind::Working; + self.refresh_terminal_title(); let message = message.unwrap_or_else(|| { if success { "Undo completed successfully.".to_string() @@ -2981,6 +3129,7 @@ impl ChatWidget { self.retry_status_header = Some(self.current_status.header.clone()); } self.bottom_pane.ensure_status_indicator(); + self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; self.set_status( message, additional_details, @@ -2991,6 +3140,9 @@ impl ChatWidget { pub(crate) fn pre_draw_tick(&mut self) { self.bottom_pane.pre_draw_tick(); + if self.should_animate_terminal_title_spinner() { + self.refresh_terminal_title(); + } } /// Handle completion of an `AgentMessage` turn item. @@ -3513,6 +3665,7 @@ impl ChatWidget { 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()); @@ -3547,8 +3700,7 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.clone()); - let queued_message_edit_binding = - queued_message_edit_binding_for_terminal(terminal_info().name); + let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -3599,11 +3751,17 @@ impl ChatWidget { connectors_partial_snapshot: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, + pending_mcp_output_requests: 0, + plugins_cache: PluginsCacheState::default(), + plugins_fetch_state: PluginListFetchState::default(), + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), current_status: StatusIndicatorState::working(), pending_guardian_review_status: PendingGuardianReviewStatus::default(), + terminal_title_status_kind: TerminalTitleStatusKind::Working, retry_status_header: None, pending_status_indicator_restore: false, suppress_queue_autosend: false, @@ -3613,6 +3771,7 @@ impl ChatWidget { forked_from: None, next_fork_banner_parent_label: None, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -3628,6 +3787,7 @@ impl ChatWidget { had_work_activity: false, saw_plan_update_this_turn: false, saw_plan_item_this_turn: false, + last_plan_progress: None, plan_delta_buffer: String::new(), plan_item_active: false, last_separator_elapsed_secs: None, @@ -3639,6 +3799,11 @@ impl ChatWidget { current_cwd, session_network_proxy: None, status_line_invalid_items_warned, + terminal_title_invalid_items_warned, + last_terminal_title: None, + 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, @@ -3666,6 +3831,7 @@ impl ChatWidget { .set_collaboration_modes_enabled(/*enabled*/ true); widget.sync_fast_command_enabled(); widget.sync_personality_command_enabled(); + widget.sync_plugins_command_enabled(); widget .bottom_pane .set_queued_message_edit_binding(widget.queued_message_edit_binding); @@ -3683,6 +3849,8 @@ impl ChatWidget { .bottom_pane .set_connectors_enabled(widget.connectors_enabled()); + widget.refresh_terminal_title(); + widget } @@ -3704,6 +3872,7 @@ impl ChatWidget { 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()); @@ -3737,8 +3906,7 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.clone()); - let queued_message_edit_binding = - queued_message_edit_binding_for_terminal(terminal_info().name); + let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -3789,11 +3957,17 @@ impl ChatWidget { connectors_partial_snapshot: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, + pending_mcp_output_requests: 0, + plugins_cache: PluginsCacheState::default(), + plugins_fetch_state: PluginListFetchState::default(), + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), current_status: StatusIndicatorState::working(), pending_guardian_review_status: PendingGuardianReviewStatus::default(), + terminal_title_status_kind: TerminalTitleStatusKind::Working, retry_status_header: None, pending_status_indicator_restore: false, suppress_queue_autosend: false, @@ -3804,9 +3978,11 @@ impl ChatWidget { next_fork_banner_parent_label: None, saw_plan_update_this_turn: false, saw_plan_item_this_turn: false, + last_plan_progress: None, plan_delta_buffer: String::new(), plan_item_active: false, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -3829,6 +4005,11 @@ impl ChatWidget { current_cwd, session_network_proxy: None, status_line_invalid_items_warned, + terminal_title_invalid_items_warned, + last_terminal_title: None, + 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, @@ -3856,12 +4037,15 @@ impl ChatWidget { .set_collaboration_modes_enabled(/*enabled*/ true); widget.sync_fast_command_enabled(); widget.sync_personality_command_enabled(); + widget.sync_plugins_command_enabled(); widget .bottom_pane .set_queued_message_edit_binding(widget.queued_message_edit_binding); widget .bottom_pane .set_connectors_enabled(widget.connectors_enabled()); + widget.refresh_terminal_title(); + widget.refresh_terminal_title(); widget } @@ -3886,6 +4070,7 @@ impl ChatWidget { 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()); @@ -3919,8 +4104,7 @@ impl ChatWidget { settings: fallback_default, }; - let queued_message_edit_binding = - queued_message_edit_binding_for_terminal(terminal_info().name); + let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -3971,11 +4155,17 @@ impl ChatWidget { connectors_partial_snapshot: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, + pending_mcp_output_requests: 0, + plugins_cache: PluginsCacheState::default(), + plugins_fetch_state: PluginListFetchState::default(), + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), current_status: StatusIndicatorState::working(), pending_guardian_review_status: PendingGuardianReviewStatus::default(), + terminal_title_status_kind: TerminalTitleStatusKind::Working, retry_status_header: None, pending_status_indicator_restore: false, suppress_queue_autosend: false, @@ -3985,6 +4175,7 @@ impl ChatWidget { forked_from: None, next_fork_banner_parent_label: None, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -4000,6 +4191,7 @@ impl ChatWidget { had_work_activity: false, saw_plan_update_this_turn: false, saw_plan_item_this_turn: false, + last_plan_progress: None, plan_delta_buffer: String::new(), plan_item_active: false, last_separator_elapsed_secs: None, @@ -4011,6 +4203,11 @@ impl ChatWidget { current_cwd, session_network_proxy: None, status_line_invalid_items_warned, + terminal_title_invalid_items_warned, + last_terminal_title: None, + 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, @@ -4038,6 +4235,7 @@ impl ChatWidget { .set_collaboration_modes_enabled(/*enabled*/ true); widget.sync_fast_command_enabled(); widget.sync_personality_command_enabled(); + widget.sync_plugins_command_enabled(); widget .bottom_pane .set_queued_message_edit_binding(widget.queued_message_edit_binding); @@ -4053,6 +4251,8 @@ impl ChatWidget { widget .bottom_pane .set_connectors_enabled(widget.connectors_enabled()); + widget.refresh_terminal_title(); + widget.refresh_terminal_title(); widget } @@ -4118,9 +4318,9 @@ impl ChatWidget { if key_event.kind == KeyEventKind::Press && self.queued_message_edit_binding.is_press(key_event) - && !self.queued_user_messages.is_empty() + && self.has_queued_follow_up_messages() { - if let Some(user_message) = self.queued_user_messages.pop_back() { + 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(); @@ -4360,6 +4560,9 @@ impl ChatWidget { } SlashCommand::Compact => { self.clear_token_usage(); + if !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + } self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); } SlashCommand::Review => { @@ -4386,7 +4589,7 @@ impl ChatWidget { return; } if self.realtime_conversation.is_live() { - self.request_realtime_conversation_close(/*info_message*/ None); + self.stop_realtime_conversation_from_ui(); } else { self.start_realtime_conversation(); } @@ -4477,7 +4680,7 @@ impl ChatWidget { self.session_telemetry.counter( "codex.windows_sandbox.setup_elevated_sandbox_command", - 1, + /*inc*/ 1, &[], ); self.app_event_tx @@ -4569,6 +4772,9 @@ impl ChatWidget { SlashCommand::DebugConfig => { self.add_debug_config_output(); } + SlashCommand::Title => { + self.open_terminal_title_setup(); + } SlashCommand::Statusline => { self.open_status_line_setup(); } @@ -4593,6 +4799,9 @@ impl ChatWidget { SlashCommand::Apps => { self.add_connectors_output(); } + SlashCommand::Plugins => { + self.add_plugins_output(); + } SlashCommand::Rollout => { if let Some(path) = self.rollout_path() { self.add_info_message( @@ -4897,10 +5106,7 @@ impl ChatWidget { } fn queue_user_message(&mut self, user_message: UserMessage) { - if !self.is_session_configured() - || self.bottom_pane.is_task_running() - || self.is_review_mode - { + if !self.is_session_configured() || self.bottom_pane.is_task_running() { self.queued_user_messages.push_back(user_message); self.refresh_pending_input_preview(); } else { @@ -4931,12 +5137,6 @@ impl ChatWidget { self.refresh_pending_input_preview(); return None; } - if self.is_review_mode { - self.queued_user_messages.push_back(user_message); - self.refresh_pending_input_preview(); - return None; - } - let UserMessage { text, local_images, @@ -5136,6 +5336,7 @@ impl ChatWidget { items, cwd: self.config.cwd.clone(), approval_policy: self.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: self.config.permissions.sandbox_policy.get().clone(), model: effective_mode.model().to_string(), effort: effective_mode.reasoning_effort(), @@ -5146,8 +5347,7 @@ impl ChatWidget { personality, }; - let submitted_op = op.clone(); - if !self.submit_op(op) { + if !self.submit_op(op.clone()) { return None; } @@ -5210,7 +5410,7 @@ impl ChatWidget { } self.needs_final_message_separator = false; - Some(submitted_op) + Some(op) } /// Restore the blocked submission draft without losing mention resolution state. @@ -5345,7 +5545,9 @@ impl ChatWidget { } EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message, .. - }) => self.on_task_complete(last_agent_message, from_replay), + }) => { + self.on_task_complete(last_agent_message, from_replay); + } EventMsg::TokenCount(ev) => { self.set_token_info(ev.info); self.on_rate_limit_snapshot(ev.rate_limits); @@ -5357,8 +5559,11 @@ impl ChatWidget { message, codex_error_info, }) => { - if let Some(info) = codex_error_info - && let Some(kind) = rate_limit_error_kind(&info) + if codex_error_info + .as_ref() + .is_some_and(|info| self.handle_steer_rejected_error(info)) + { + } else if let Some(kind) = codex_error_info.as_ref().and_then(rate_limit_error_kind) { match kind { RateLimitErrorKind::ServerOverloaded => { @@ -5422,7 +5627,6 @@ impl ChatWidget { EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), EventMsg::ListCustomPromptsResponse(ev) => self.on_list_custom_prompts(ev), EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev), - EventMsg::ListRemoteSkillsResponse(_) | EventMsg::RemoteSkillDownloaded(_) => {} EventMsg::SkillsUpdateAvailable => { self.submit_op(Op::ListSkills { cwds: Vec::new(), @@ -5718,7 +5922,7 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } - if let Some(user_message) = self.queued_user_messages.pop_front() { + if let Some(user_message) = self.pop_next_queued_user_message() { self.submit_user_message(user_message); } // Update the list to reflect the remaining queued messages (if any). @@ -5737,8 +5941,16 @@ impl ChatWidget { .iter() .map(|steer| steer.user_message.text.clone()) .collect(); - self.bottom_pane - .set_pending_input_preview(queued_messages, pending_steers); + let rejected_steers: Vec = self + .rejected_steers_queue + .iter() + .map(|message| message.text.clone()) + .collect(); + self.bottom_pane.set_pending_input_preview( + queued_messages, + pending_steers, + rejected_steers, + ); } pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec) { @@ -5817,188 +6029,14 @@ impl ChatWidget { self.bottom_pane.show_selection_view(params); } - /// Parses configured status-line ids into known items and collects unknown ids. - /// - /// Unknown ids are deduplicated in insertion order for warning messages. - fn status_line_items_with_invalids(&self) -> (Vec, Vec) { - let mut invalid = Vec::new(); - let mut invalid_seen = HashSet::new(); - let mut items = Vec::new(); - for id in self.configured_status_line_items() { - match id.parse::() { - Ok(item) => items.push(item), - Err(_) => { - if invalid_seen.insert(id.clone()) { - invalid.push(format!(r#""{id}""#)); - } - } - } - } - (items, invalid) - } - - fn configured_status_line_items(&self) -> Vec { - self.config.tui_status_line.clone().unwrap_or_else(|| { - DEFAULT_STATUS_LINE_ITEMS - .iter() - .map(ToString::to_string) - .collect() - }) - } - - fn status_line_cwd(&self) -> &Path { - self.current_cwd.as_ref().unwrap_or(&self.config.cwd) - } - - fn status_line_project_root(&self) -> Option { - let cwd = self.status_line_cwd(); - if let Some(repo_root) = get_git_repo_root(cwd) { - return Some(repo_root); - } - - self.config - .config_layer_stack - .get_layers( - ConfigLayerStackOrdering::LowestPrecedenceFirst, - /*include_disabled*/ true, - ) - .iter() - .find_map(|layer| match &layer.name { - ConfigLayerSource::Project { dot_codex_folder } => { - dot_codex_folder.as_path().parent().map(Path::to_path_buf) - } - _ => None, - }) - } - - fn status_line_project_root_name(&self) -> Option { - self.status_line_project_root().map(|root| { - root.file_name() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_else(|| format_directory_display(&root, /*max_width*/ None)) - }) - } - - /// Resets git-branch cache state when the status-line cwd changes. - /// - /// The branch cache is keyed by cwd because branch lookup is performed relative to that path. - /// Keeping stale branch values across cwd changes would surface incorrect repository context. - fn sync_status_line_branch_state(&mut self, cwd: &Path) { - if self - .status_line_branch_cwd - .as_ref() - .is_some_and(|path| path == cwd) - { - return; - } - self.status_line_branch_cwd = Some(cwd.to_path_buf()); - self.status_line_branch = None; - self.status_line_branch_pending = false; - self.status_line_branch_lookup_complete = false; - } - - /// Starts an async git-branch lookup unless one is already running. - /// - /// The resulting `StatusLineBranchUpdated` event carries the lookup cwd so callers can reject - /// stale completions after directory changes. - fn request_status_line_branch(&mut self, cwd: PathBuf) { - if self.status_line_branch_pending { - return; - } - self.status_line_branch_pending = true; - let tx = self.app_event_tx.clone(); - tokio::spawn(async move { - let branch = current_branch_name(&cwd).await; - tx.send(AppEvent::StatusLineBranchUpdated { cwd, branch }); - }); - } - - /// Resolves a display string for one configured status-line item. - /// - /// Returning `None` means "omit this item for now", not "configuration error". Callers rely on - /// this to keep partially available status lines readable while waiting for session, token, or - /// git metadata. - fn status_line_value_for_item(&self, item: &StatusLineItem) -> Option { - match item { - StatusLineItem::ModelName => Some(self.model_display_name().to_string()), - StatusLineItem::ModelWithReasoning => { - let label = - Self::status_line_reasoning_effort_label(self.effective_reasoning_effort()); - let fast_label = if self - .should_show_fast_status(self.current_model(), self.config.service_tier) - { - " fast" - } else { - "" - }; - Some(format!("{} {label}{fast_label}", self.model_display_name())) - } - StatusLineItem::CurrentDir => { - Some(format_directory_display( - self.status_line_cwd(), - /*max_width*/ None, - )) - } - StatusLineItem::ProjectRoot => self.status_line_project_root_name(), - StatusLineItem::GitBranch => self.status_line_branch.clone(), - StatusLineItem::UsedTokens => { - let usage = self.status_line_total_usage(); - let total = usage.tokens_in_context_window(); - if total <= 0 { - None - } else { - Some(format!("{} used", format_tokens_compact(total))) - } - } - StatusLineItem::ContextRemaining => self - .status_line_context_remaining_percent() - .map(|remaining| format!("{remaining}% left")), - StatusLineItem::ContextUsed => self - .status_line_context_used_percent() - .map(|used| format!("{used}% used")), - StatusLineItem::FiveHourLimit => { - let window = self - .rate_limit_snapshots_by_limit_id - .get("codex") - .and_then(|s| s.primary.as_ref()); - let label = window - .and_then(|window| window.window_minutes) - .map(get_limits_duration) - .unwrap_or_else(|| "5h".to_string()); - self.status_line_limit_display(window, &label) - } - StatusLineItem::WeeklyLimit => { - let window = self - .rate_limit_snapshots_by_limit_id - .get("codex") - .and_then(|s| s.secondary.as_ref()); - let label = window - .and_then(|window| window.window_minutes) - .map(get_limits_duration) - .unwrap_or_else(|| "weekly".to_string()); - self.status_line_limit_display(window, &label) - } - StatusLineItem::CodexVersion => Some(CODEX_CLI_VERSION.to_string()), - StatusLineItem::ContextWindowSize => self - .status_line_context_window_size() - .map(|cws| format!("{} window", format_tokens_compact(cws))), - StatusLineItem::TotalInputTokens => Some(format!( - "{} in", - format_tokens_compact(self.status_line_total_usage().input_tokens) - )), - StatusLineItem::TotalOutputTokens => Some(format!( - "{} out", - format_tokens_compact(self.status_line_total_usage().output_tokens) - )), - StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()), - StatusLineItem::FastMode => Some( - if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { - "Fast on".to_string() - } else { - "Fast off".to_string() - }, - ), - } + 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.app_event_tx.clone(), + ); + self.bottom_pane.show_view(Box::new(view)); } fn status_line_context_window_size(&self) -> Option { @@ -6088,10 +6126,6 @@ impl ChatWidget { self.prefetch_connectors_with_options(force_refetch); } - 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; @@ -6439,7 +6473,7 @@ impl ChatWidget { }); } - #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] + #[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) => { @@ -6454,12 +6488,12 @@ impl ChatWidget { } } - #[cfg(any(target_os = "linux", not(feature = "voice-input")))] + #[cfg(target_os = "linux")] pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) { let _ = kind; } - #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] + #[cfg(not(target_os = "linux"))] fn open_realtime_audio_device_selection_with_names( &mut self, kind: RealtimeAudioDeviceKind, @@ -7693,8 +7727,11 @@ impl ChatWidget { return; } - self.session_telemetry - .counter("codex.windows_sandbox.elevated_prompt_shown", 1, &[]); + self.session_telemetry.counter( + "codex.windows_sandbox.elevated_prompt_shown", + /*inc*/ 1, + &[], + ); let mut header = ColumnRenderable::new(); header.push(*Box::new( @@ -7713,7 +7750,11 @@ impl ChatWidget { 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", 1, &[]); + accept_otel.counter( + "codex.windows_sandbox.elevated_prompt_accept", + /*inc*/ 1, + &[], + ); tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset: preset.clone(), }); @@ -7725,7 +7766,11 @@ impl ChatWidget { 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", 1, &[]); + legacy_otel.counter( + "codex.windows_sandbox.elevated_prompt_use_legacy", + /*inc*/ 1, + &[], + ); tx.send(AppEvent::BeginWindowsSandboxLegacySetup { preset: legacy_preset.clone(), }); @@ -7737,7 +7782,11 @@ impl ChatWidget { name: "Quit".to_string(), description: None, actions: vec![Box::new(move |tx| { - quit_otel.counter("codex.windows_sandbox.elevated_prompt_quit", 1, &[]); + quit_otel.counter( + "codex.windows_sandbox.elevated_prompt_quit", + /*inc*/ 1, + &[], + ); tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); })], dismiss_on_select: true, @@ -7787,7 +7836,11 @@ impl ChatWidget { let otel = self.session_telemetry.clone(); let preset = elevated_preset; move |tx| { - otel.counter("codex.windows_sandbox.fallback_retry_elevated", 1, &[]); + otel.counter( + "codex.windows_sandbox.fallback_retry_elevated", + /*inc*/ 1, + &[], + ); tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset: preset.clone(), }); @@ -7803,7 +7856,11 @@ impl ChatWidget { let otel = self.session_telemetry.clone(); let preset = legacy_preset; move |tx| { - otel.counter("codex.windows_sandbox.fallback_use_legacy", 1, &[]); + otel.counter( + "codex.windows_sandbox.fallback_use_legacy", + /*inc*/ 1, + &[], + ); tx.send(AppEvent::BeginWindowsSandboxLegacySetup { preset: preset.clone(), }); @@ -7816,7 +7873,11 @@ impl ChatWidget { name: "Quit".to_string(), description: None, actions: vec![Box::new(move |tx| { - quit_otel.counter("codex.windows_sandbox.fallback_prompt_quit", 1, &[]); + quit_otel.counter( + "codex.windows_sandbox.fallback_prompt_quit", + /*inc*/ 1, + &[], + ); tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); })], dismiss_on_select: true, @@ -7856,11 +7917,12 @@ impl ChatWidget { // 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( - false, + /*enabled*/ false, Some("Input disabled until setup completes.".to_string()), ); self.bottom_pane.ensure_status_indicator(); - self.bottom_pane.set_interrupt_hint_visible(false); + 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()), @@ -7876,7 +7938,8 @@ impl ChatWidget { #[cfg(target_os = "windows")] pub(crate) fn clear_windows_sandbox_setup_status(&mut self) { - self.bottom_pane.set_composer_input_enabled(true, None); + self.bottom_pane + .set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None); self.bottom_pane.hide_status_indicator(); self.request_redraw(); } @@ -7934,7 +7997,6 @@ impl ChatWidget { self.request_realtime_conversation_close(Some( "Realtime voice mode was closed because the feature was disabled.".to_string(), )); - self.reset_realtime_conversation_state(); } } if feature == Feature::FastMode { @@ -7944,6 +8006,7 @@ impl ChatWidget { self.sync_personality_command_enabled(); } if feature == Feature::Plugins { + self.sync_plugins_command_enabled(); self.refresh_plugin_mentions(); } if feature == Feature::PreventIdleSleep { @@ -8121,7 +8184,7 @@ impl ChatWidget { } pub(crate) fn realtime_conversation_is_live(&self) -> bool { - self.realtime_conversation.is_active() + self.realtime_conversation.is_live() } fn current_realtime_audio_device_name(&self, kind: RealtimeAudioDeviceKind) -> Option { @@ -8146,6 +8209,11 @@ impl ChatWidget { .set_personality_command_enabled(self.config.features.enabled(Feature::Personality)); } + fn sync_plugins_command_enabled(&mut self) { + self.bottom_pane + .set_plugins_command_enabled(self.config.features.enabled(Feature::Plugins)); + } + fn current_model_supports_personality(&self) -> bool { let model = self.current_model(); self.models_manager @@ -8257,6 +8325,7 @@ impl ChatWidget { self.session_header.set_model(effective.model()); // Keep composer paste affordances aligned with the currently effective model. self.sync_image_paste_enabled(); + self.refresh_terminal_title(); } fn model_display_name(&self) -> &str { @@ -8478,8 +8547,8 @@ impl ChatWidget { .is_empty() { self.add_to_history(history_cell::empty_mcp_output()); - } else { - self.submit_op(Op::ListMcpTools); + } else if self.submit_op(Op::ListMcpTools) { + self.pending_mcp_output_requests = self.pending_mcp_output_requests.saturating_add(1); } } @@ -8716,10 +8785,20 @@ impl ChatWidget { /// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut /// is armed; this interrupts the turn but intentionally preserves background terminals. /// + /// 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. + /// /// If the same quit shortcut is pressed again before expiry, this 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 { @@ -8822,10 +8901,6 @@ impl ChatWidget { self.bottom_pane.composer_is_empty() } - pub(crate) fn agent_turn_running(&self) -> bool { - self.agent_turn_running - } - pub(crate) fn submit_user_message_with_mode( &mut self, text: String, @@ -8897,9 +8972,14 @@ impl ChatWidget { #[cfg(test)] pub(crate) fn queued_user_message_texts(&self) -> Vec { - self.queued_user_messages + self.rejected_steers_queue .iter() .map(|message| message.text.clone()) + .chain( + self.queued_user_messages + .iter() + .map(|message| message.text.clone()), + ) .collect() } @@ -8935,13 +9015,83 @@ impl ChatWidget { } fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { - self.add_to_history(history_cell::new_mcp_tools_output( - &self.config, - ev.tools, - ev.resources, - ev.resource_templates, - &ev.auth_statuses, - )); + if self.connectors_enabled() { + let plugin_provenance = McpManager::new(Arc::new(PluginsManager::new( + self.config.codex_home.clone(), + ))) + .tool_plugin_provenance(&self.config); + let mut connectors_by_id: HashMap = HashMap::new(); + for tool in ev.tools.values() { + let Some(meta) = tool.meta.as_ref().and_then(serde_json::Value::as_object) else { + continue; + }; + let Some(connector_id) = meta + .get("connector_id") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.is_empty()) + else { + continue; + }; + connectors_by_id + .entry(connector_id.to_string()) + .or_insert_with(|| { + let name = meta + .get("connector_name") + .or_else(|| meta.get("connector_display_name")) + .and_then(serde_json::Value::as_str) + .filter(|name| !name.trim().is_empty()) + .unwrap_or(connector_id) + .to_string(); + let description = meta + .get("connector_description") + .or_else(|| meta.get("connectorDescription")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(ToString::to_string); + connectors::AppInfo { + id: connector_id.to_string(), + name, + description, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: plugin_provenance + .plugin_display_names_for_connector_id(connector_id) + .to_vec(), + } + }); + } + + let mut app_connectors = connectors_by_id.into_values().collect::>(); + app_connectors.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.id.cmp(&right.id)) + }); + let app_connectors = connectors::with_app_enabled_state(app_connectors, &self.config); + self.bottom_pane + .set_connectors_snapshot(Some(ConnectorsSnapshot { + connectors: app_connectors, + })); + } + + if self.pending_mcp_output_requests > 0 { + self.pending_mcp_output_requests -= 1; + self.add_to_history(history_cell::new_mcp_tools_output( + &self.config, + ev.tools, + ev.resources, + ev.resource_templates, + &ev.auth_statuses, + )); + } } fn on_list_custom_prompts(&mut self, ev: ListCustomPromptsResponseEvent) { @@ -9050,7 +9200,7 @@ impl ChatWidget { self.bottom_pane.set_connectors_snapshot(Some(snapshot)); } - fn refresh_plugin_mentions(&mut self) { + pub(crate) fn refresh_plugin_mentions(&mut self) { if !self.config.features.enabled(Feature::Plugins) { self.bottom_pane.set_plugin_mentions(/*plugins*/ None); return; @@ -9063,6 +9213,11 @@ impl ChatWidget { self.bottom_pane.set_plugin_mentions(Some(plugins)); } + pub(crate) fn sync_plugin_mentions_config(&mut self, config: &Config) { + self.config.features = config.features.clone(); + self.config.config_layer_stack = config.config_layer_stack.clone(); + } + pub(crate) fn open_review_popup(&mut self) { let mut items: Vec = Vec::new(); @@ -9161,7 +9316,7 @@ impl ChatWidget { } pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) { - let commits = codex_core::git_info::recent_commits(cwd, /*limit*/ 100).await; + let commits = recent_commits(cwd, /*limit*/ 100).await; let mut items: Vec = Vec::with_capacity(commits.len()); for entry in commits { @@ -9233,6 +9388,10 @@ impl ChatWidget { self.thread_id } + pub(crate) fn agent_turn_running(&self) -> bool { + self.agent_turn_running + } + pub(crate) fn thread_name(&self) -> Option { self.thread_name.clone() } @@ -9345,8 +9504,8 @@ fn has_websocket_timing_metrics(summary: RuntimeMetricsSummary) -> bool { impl Drop for ChatWidget { fn drop(&mut self) { - self.reset_realtime_conversation_state(); self.stop_rate_limit_poller(); + self.reset_realtime_conversation_state(); } } @@ -9537,7 +9696,9 @@ fn extract_first_bold(s: &str) -> Option { fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str { match event_name { + codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse", codex_protocol::protocol::HookEventName::SessionStart => "SessionStart", + codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", codex_protocol::protocol::HookEventName::Stop => "Stop", } } @@ -9561,7 +9722,7 @@ async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Vec, + entries: Vec, ) { let mut items: Vec = Vec::with_capacity(entries.len()); for entry in entries { diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 079f70e226..e44081f761 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -1,4 +1,6 @@ use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; use super::ChatWidget; use crate::app_event::AppEvent; @@ -7,6 +9,9 @@ use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::history_cell; use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::shimmer::shimmer_spans; +use crate::tui::FrameRequester; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallResponse; @@ -17,10 +22,76 @@ use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::PluginUninstallResponse; use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::WidgetRef; const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection"; +const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); +const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); + +struct DelayedLoadingHeader { + started_at: Instant, + frame_requester: FrameRequester, + animations_enabled: bool, + loading_text: String, + note: Option, +} + +impl DelayedLoadingHeader { + fn new( + frame_requester: FrameRequester, + animations_enabled: bool, + loading_text: String, + note: Option, + ) -> Self { + Self { + started_at: Instant::now(), + frame_requester, + animations_enabled, + loading_text, + note, + } + } +} + +impl Renderable for DelayedLoadingHeader { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + + let mut lines = Vec::with_capacity(3); + lines.push(Line::from("Plugins".bold())); + + let now = Instant::now(); + let elapsed = now.saturating_duration_since(self.started_at); + if elapsed < LOADING_ANIMATION_DELAY { + self.frame_requester + .schedule_frame_in(LOADING_ANIMATION_DELAY - elapsed); + lines.push(Line::from(self.loading_text.as_str().dim())); + } else if self.animations_enabled { + self.frame_requester + .schedule_frame_in(LOADING_ANIMATION_INTERVAL); + lines.push(Line::from(shimmer_spans(self.loading_text.as_str()))); + } else { + lines.push(Line::from(self.loading_text.as_str().dim())); + } + + if let Some(note) = &self.note { + lines.push(Line::from(note.as_str().dim())); + } + + Paragraph::new(lines).render_ref(area, buf); + } + + fn desired_height(&self, _width: u16) -> u16 { + 2 + u16::from(self.note.is_some()) + } +} #[derive(Debug, Clone, Default)] pub(super) enum PluginsCacheState { @@ -474,16 +545,14 @@ impl ChatWidget { } fn plugins_loading_popup_params(&self) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from("Loading available plugins...".dim())); - header.push(Line::from( - "Available marketplaces will appear here when ready.".dim(), - )); - SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + "Loading available plugins...".to_string(), + Some("This first pass shows the ChatGPT marketplace only.".to_string()), + )), items: vec![SelectionItem { name: "Loading plugins...".to_string(), description: Some("This updates when the marketplace list is ready.".to_string()), @@ -495,15 +564,14 @@ impl ChatWidget { } fn plugin_detail_loading_popup_params(&self, plugin_display_name: &str) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from( - format!("Loading details for {plugin_display_name}...").dim(), - )); - SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + format!("Loading details for {plugin_display_name}..."), + /*note*/ None, + )), items: vec![SelectionItem { name: "Loading plugin details...".to_string(), description: Some("This updates when plugin details load.".to_string()), diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap index 42eff7f81f..2b0f6837eb 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap @@ -4,6 +4,6 @@ expression: popup --- Plugins Loading available plugins... - Available marketplaces will appear here when ready. + This first pass shows the ChatGPT marketplace only. › Loading plugins... This updates when the marketplace list is ready. diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index 68702d8bc0..b6044bd6cb 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -9,6 +9,7 @@ use crate::app_event::WindowsSandboxEnableMode; use crate::app_event_sender::AppEventSender; use crate::app_server_session::AppServerSession; use crate::app_server_session::AppServerStartedThread; +use crate::app_server_session::ThreadSessionState; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::FeedbackAudience; use crate::bottom_pane::McpServerElicitationFormRequest; @@ -17,6 +18,7 @@ use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::chatwidget::ChatWidget; use crate::chatwidget::ExternalEditorState; +use crate::chatwidget::ReplayKind; use crate::chatwidget::ThreadInputState; use crate::cwd_prompt::CwdPromptAction; use crate::diff_render::DiffSummary; @@ -36,6 +38,7 @@ use crate::multi_agents::format_agent_picker_item_name; use crate::multi_agents::next_agent_shortcut_matches; use crate::multi_agents::previous_agent_shortcut_matches; use crate::pager_overlay::Overlay; +use crate::read_session_model; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; use crate::resume_picker::SessionSelection; @@ -44,7 +47,30 @@ use crate::tui::TuiEvent; use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; use codex_ansi_escape::ansi_escape_line; +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_client::TypedRequestError; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::ConfigLayerSource; +use codex_app_server_protocol::ListMcpServerStatusParams; +use codex_app_server_protocol::ListMcpServerStatusResponse; +use codex_app_server_protocol::McpServerStatus; +use codex_app_server_protocol::PluginInstallParams; +use codex_app_server_protocol::PluginInstallResponse; +use codex_app_server_protocol::PluginListParams; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginReadParams; +use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginUninstallParams; +use codex_app_server_protocol::PluginUninstallResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::SkillsListResponse; +use codex_app_server_protocol::ThreadRollbackResponse; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStatus; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; @@ -53,34 +79,35 @@ use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::types::ApprovalsReviewer; use codex_core::config::types::ModelAvailabilityNuxConfig; use codex_core::config_loader::ConfigLayerStackOrdering; -use codex_core::features::Feature; +use codex_core::message_history; use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; #[cfg(target_os = "windows")] use codex_core::windows_sandbox::WindowsSandboxLevelExt; +use codex_features::Feature; use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; +use codex_protocol::approvals::ExecApprovalRequestEvent; use codex_protocol::config_types::Personality; #[cfg(target_os = "windows")] use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::items::TurnItem; use codex_protocol::openai_models::ModelAvailabilityNux; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::Event; -use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::FinalOutput; +use codex_protocol::protocol::GetHistoryEntryResponseEvent; use codex_protocol::protocol::ListSkillsResponseEvent; #[cfg(test)] +use codex_protocol::protocol::McpAuthStatus; use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; -use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillErrorInfo; use codex_protocol::protocol::TokenUsage; +use codex_terminal_detection::user_agent; use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -93,7 +120,6 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; use std::collections::BTreeMap; use std::collections::HashMap; -use std::collections::HashSet; use std::collections::VecDeque; use std::path::Path; use std::path::PathBuf; @@ -111,6 +137,7 @@ use tokio::sync::mpsc::error::TrySendError; use tokio::sync::mpsc::unbounded_channel; use tokio::task::JoinHandle; use toml::Value as TomlValue; +use uuid::Uuid; mod agent_navigation; mod app_server_adapter; mod app_server_requests; @@ -131,6 +158,74 @@ enum ThreadInteractiveRequest { McpServerElicitation(McpServerElicitationFormRequest), } +fn app_server_request_id_to_mcp_request_id( + request_id: &codex_app_server_protocol::RequestId, +) -> codex_protocol::mcp::RequestId { + match request_id { + codex_app_server_protocol::RequestId::String(value) => { + codex_protocol::mcp::RequestId::String(value.clone()) + } + codex_app_server_protocol::RequestId::Integer(value) => { + codex_protocol::mcp::RequestId::Integer(*value) + } + } +} + +fn command_execution_decision_to_review_decision( + decision: codex_app_server_protocol::CommandExecutionApprovalDecision, +) -> codex_protocol::protocol::ReviewDecision { + match decision { + codex_app_server_protocol::CommandExecutionApprovalDecision::Accept => { + codex_protocol::protocol::ReviewDecision::Approved + } + codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptForSession => { + codex_protocol::protocol::ReviewDecision::ApprovedForSession + } + codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment, + } => codex_protocol::protocol::ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: execpolicy_amendment.into_core(), + }, + codex_app_server_protocol::CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { + network_policy_amendment, + } => codex_protocol::protocol::ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment: network_policy_amendment.into_core(), + }, + codex_app_server_protocol::CommandExecutionApprovalDecision::Decline => { + codex_protocol::protocol::ReviewDecision::Denied + } + codex_app_server_protocol::CommandExecutionApprovalDecision::Cancel => { + codex_protocol::protocol::ReviewDecision::Abort + } + } +} + +fn convert_via_json(value: T) -> Option +where + T: serde::Serialize, + U: serde::de::DeserializeOwned, +{ + serde_json::to_value(value) + .ok() + .and_then(|value| serde_json::from_value(value).ok()) +} + +fn default_exec_approval_decisions( + network_approval_context: Option<&codex_protocol::protocol::NetworkApprovalContext>, + proposed_execpolicy_amendment: Option<&codex_protocol::approvals::ExecPolicyAmendment>, + proposed_network_policy_amendments: Option< + &[codex_protocol::approvals::NetworkPolicyAmendment], + >, + additional_permissions: Option<&codex_protocol::models::PermissionProfile>, +) -> Vec { + ExecApprovalRequestEvent::default_available_decisions( + network_approval_context, + proposed_execpolicy_amendment, + proposed_network_policy_amendments, + additional_permissions, + ) +} + #[derive(Clone, Debug, PartialEq, Eq)] struct GuardianApprovalsMode { approval_policy: AskForApproval, @@ -214,6 +309,77 @@ fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec ListSkillsResponseEvent { + ListSkillsResponseEvent { + skills: response + .data + .into_iter() + .map(|entry| codex_protocol::protocol::SkillsListEntry { + cwd: entry.cwd, + skills: entry + .skills + .into_iter() + .map(|skill| codex_protocol::protocol::SkillMetadata { + name: skill.name, + description: skill.description, + short_description: skill.short_description, + interface: skill.interface.map(|interface| { + codex_protocol::protocol::SkillInterface { + display_name: interface.display_name, + short_description: interface.short_description, + icon_small: interface.icon_small, + icon_large: interface.icon_large, + brand_color: interface.brand_color, + default_prompt: interface.default_prompt, + } + }), + dependencies: skill.dependencies.map(|dependencies| { + codex_protocol::protocol::SkillDependencies { + tools: dependencies + .tools + .into_iter() + .map(|tool| codex_protocol::protocol::SkillToolDependency { + r#type: tool.r#type, + value: tool.value, + description: tool.description, + transport: tool.transport, + command: tool.command, + url: tool.url, + }) + .collect(), + } + }), + path: skill.path, + scope: match skill.scope { + codex_app_server_protocol::SkillScope::User => { + codex_protocol::protocol::SkillScope::User + } + codex_app_server_protocol::SkillScope::Repo => { + codex_protocol::protocol::SkillScope::Repo + } + codex_app_server_protocol::SkillScope::System => { + codex_protocol::protocol::SkillScope::System + } + codex_app_server_protocol::SkillScope::Admin => { + codex_protocol::protocol::SkillScope::Admin + } + }, + enabled: skill.enabled, + }) + .collect(), + errors: entry + .errors + .into_iter() + .map(|error| codex_protocol::protocol::SkillErrorInfo { + path: error.path, + message: error.message, + }) + .collect(), + }) + .collect(), + } +} + fn emit_skill_load_warnings(app_event_tx: &AppEventSender, errors: &[SkillErrorInfo]) { if errors.is_empty() { return; @@ -278,6 +444,16 @@ fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) ))); } +fn emit_system_bwrap_warning(app_event_tx: &AppEventSender) { + let Some(message) = codex_core::config::system_bwrap_warning() else { + return; + }; + + app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_warning_event(message), + ))); +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SessionSummary { usage_line: String, @@ -286,16 +462,24 @@ struct SessionSummary { #[derive(Debug, Clone)] struct ThreadEventSnapshot { - session_configured: Option, - events: Vec, + session: Option, + turns: Vec, + events: Vec, input_state: Option, } +#[derive(Debug, Clone)] +enum ThreadBufferedEvent { + Notification(ServerNotification), + Request(ServerRequest), + HistoryEntryResponse(GetHistoryEntryResponseEvent), +} + #[derive(Debug)] struct ThreadEventStore { - session_configured: Option, - buffer: VecDeque, - user_message_ids: HashSet, + session: Option, + turns: Vec, + buffer: VecDeque, pending_interactive_replay: PendingInteractiveReplayState, active_turn_id: Option, input_state: Option, @@ -304,11 +488,15 @@ struct ThreadEventStore { } impl ThreadEventStore { + fn event_survives_session_refresh(event: &ThreadBufferedEvent) -> bool { + matches!(event, ThreadBufferedEvent::Request(_)) + } + fn new(capacity: usize) -> Self { Self { - session_configured: None, + session: None, + turns: Vec::new(), buffer: VecDeque::new(), - user_message_ids: HashSet::new(), pending_interactive_replay: PendingInteractiveReplayState::default(), active_turn_id: None, input_state: None, @@ -318,83 +506,94 @@ impl ThreadEventStore { } #[cfg_attr(not(test), allow(dead_code))] - fn new_with_session_configured(capacity: usize, event: Event) -> Self { + fn new_with_session(capacity: usize, session: ThreadSessionState, turns: Vec) -> Self { let mut store = Self::new(capacity); - store.session_configured = Some(event); + store.session = Some(session); + store.set_turns(turns); store } - fn push_event(&mut self, event: Event) { - self.pending_interactive_replay.note_event(&event); - match &event.msg { - EventMsg::SessionConfigured(_) => { - self.session_configured = Some(event); - return; + fn set_session(&mut self, session: ThreadSessionState, turns: Vec) { + self.session = Some(session); + self.set_turns(turns); + } + + fn rebase_buffer_after_session_refresh(&mut self) { + self.buffer.retain(Self::event_survives_session_refresh); + } + + fn set_turns(&mut self, turns: Vec) { + self.active_turn_id = turns + .iter() + .rev() + .find(|turn| matches!(turn.status, TurnStatus::InProgress)) + .map(|turn| turn.id.clone()); + self.turns = turns; + } + + fn push_notification(&mut self, notification: ServerNotification) { + self.pending_interactive_replay + .note_server_notification(¬ification); + match ¬ification { + ServerNotification::TurnStarted(turn) => { + self.active_turn_id = Some(turn.turn.id.clone()); } - EventMsg::TurnStarted(turn) => { - self.active_turn_id = Some(turn.turn_id.clone()); - } - EventMsg::TurnComplete(turn) => { - if self.active_turn_id.as_deref() == Some(turn.turn_id.as_str()) { + ServerNotification::TurnCompleted(turn) => { + if self.active_turn_id.as_deref() == Some(turn.turn.id.as_str()) { self.active_turn_id = None; } } - EventMsg::TurnAborted(turn) => { - if self.active_turn_id.as_deref() == turn.turn_id.as_deref() { - self.active_turn_id = None; - } - } - EventMsg::ShutdownComplete => { + ServerNotification::ThreadClosed(_) => { self.active_turn_id = None; } - EventMsg::ItemCompleted(completed) => { - if let TurnItem::UserMessage(item) = &completed.item { - if !event.id.is_empty() && self.user_message_ids.contains(&event.id) { - return; - } - let legacy = Event { - id: event.id, - msg: item.as_legacy_event(), - }; - self.push_legacy_event(legacy); - return; - } - } _ => {} } - - self.push_legacy_event(event); - } - - fn push_legacy_event(&mut self, event: Event) { - if let EventMsg::UserMessage(_) = &event.msg - && !event.id.is_empty() - && !self.user_message_ids.insert(event.id.clone()) - { - return; - } - self.buffer.push_back(event); + self.buffer + .push_back(ThreadBufferedEvent::Notification(notification)); if self.buffer.len() > self.capacity && let Some(removed) = self.buffer.pop_front() + && let ThreadBufferedEvent::Request(request) = &removed { - self.pending_interactive_replay.note_evicted_event(&removed); - if matches!(removed.msg, EventMsg::UserMessage(_)) && !removed.id.is_empty() { - self.user_message_ids.remove(&removed.id); - } + self.pending_interactive_replay + .note_evicted_server_request(request); } } + fn push_request(&mut self, request: ServerRequest) { + self.pending_interactive_replay + .note_server_request(&request); + self.buffer.push_back(ThreadBufferedEvent::Request(request)); + if self.buffer.len() > self.capacity + && let Some(removed) = self.buffer.pop_front() + && let ThreadBufferedEvent::Request(request) = &removed + { + self.pending_interactive_replay + .note_evicted_server_request(request); + } + } + + fn apply_thread_rollback(&mut self, response: &ThreadRollbackResponse) { + self.turns = response.thread.turns.clone(); + self.buffer.clear(); + self.pending_interactive_replay = PendingInteractiveReplayState::default(); + self.active_turn_id = None; + } + fn snapshot(&self) -> ThreadEventSnapshot { ThreadEventSnapshot { - session_configured: self.session_configured.clone(), + session: self.session.clone(), + turns: self.turns.clone(), // Thread switches replay buffered events into a rebuilt ChatWidget. Only replay // interactive prompts that are still pending, or answered approvals/input will reappear. events: self .buffer .iter() - .filter(|event| { - self.pending_interactive_replay - .should_replay_snapshot_event(event) + .filter(|event| match event { + ThreadBufferedEvent::Request(request) => self + .pending_interactive_replay + .should_replay_snapshot_request(request), + ThreadBufferedEvent::Notification(_) + | ThreadBufferedEvent::HistoryEntryResponse(_) => true, }) .cloned() .collect(), @@ -416,10 +615,6 @@ impl ThreadEventStore { PendingInteractiveReplayState::op_can_change_state(op) } - fn event_can_change_pending_thread_approvals(event: &Event) -> bool { - PendingInteractiveReplayState::event_can_change_pending_thread_approvals(event) - } - fn has_pending_thread_approvals(&self) -> bool { self.pending_interactive_replay .has_pending_thread_approvals() @@ -432,8 +627,8 @@ impl ThreadEventStore { #[derive(Debug)] struct ThreadEventChannel { - sender: mpsc::Sender, - receiver: Option>, + sender: mpsc::Sender, + receiver: Option>, store: Arc>, } @@ -448,13 +643,13 @@ impl ThreadEventChannel { } #[cfg_attr(not(test), allow(dead_code))] - fn new_with_session_configured(capacity: usize, event: Event) -> Self { + fn new_with_session(capacity: usize, session: ThreadSessionState, turns: Vec) -> Self { let (sender, receiver) = mpsc::channel(capacity); Self { sender, receiver: Some(receiver), - store: Arc::new(Mutex::new(ThreadEventStore::new_with_session_configured( - capacity, event, + store: Arc::new(Mutex::new(ThreadEventStore::new_with_session( + capacity, session, turns, ))), } } @@ -734,12 +929,6 @@ pub(crate) struct App { /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option, - /// One-shot guard used while switching threads. - /// - /// We set this when intentionally stopping the current thread before moving - /// to another one, then ignore exactly one `ShutdownComplete` so it is not - /// misclassified as an unexpected sub-agent death. - suppress_shutdown_complete: bool, /// Tracks the thread we intentionally shut down while exiting the app. /// /// When this matches the active thread, its `ShutdownComplete` should lead to @@ -757,10 +946,10 @@ pub(crate) struct App { agent_navigation: AgentNavigationState, btw_threads: HashMap, active_thread_id: Option, - active_thread_rx: Option>, + active_thread_rx: Option>, primary_thread_id: Option, - primary_session_configured: Option, - pending_primary_events: VecDeque, + primary_session_configured: Option, + pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, } @@ -788,6 +977,18 @@ fn normalize_harness_overrides_for_cwd( Ok(overrides) } +fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option { + let TypedRequestError::Server { source, .. } = error else { + return None; + }; + let turn_error: AppServerTurnError = serde_json::from_value(source.data.clone()?).ok()?; + matches!( + turn_error.codex_error_info, + Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. }) + ) + .then_some(turn_error) +} + impl App { pub fn chatwidget_init_for_forked_or_resumed_thread( &self, @@ -834,6 +1035,7 @@ impl App { .await?; self.apply_runtime_policy_overrides(&mut config); self.config = config; + self.chat_widget.sync_plugin_mentions_config(&self.config); Ok(()) } @@ -1147,18 +1349,18 @@ impl App { let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); self.app_event_tx.send(AppEvent::CodexOp( AppCommand::override_turn_context( - None, - None, - None, - None, + /*cwd*/ None, + /*approval_policy*/ None, + /*approvals_reviewer*/ None, + /*sandbox_policy*/ None, #[cfg(target_os = "windows")] Some(windows_sandbox_level), - None, - None, - None, - None, - None, - None, + /*model*/ None, + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + /*personality*/ None, ) .into_core(), )); @@ -1327,7 +1529,7 @@ impl App { async fn activate_thread_for_replay( &mut self, thread_id: ThreadId, - ) -> Option<(mpsc::Receiver, ThreadEventSnapshot)> { + ) -> Option<(mpsc::Receiver, ThreadEventSnapshot)> { let channel = self.thread_event_channels.get_mut(&thread_id)?; let receiver = channel.receiver.take()?; let mut store = channel.store.lock().await; @@ -1422,88 +1624,125 @@ impl App { async fn thread_cwd(&self, thread_id: ThreadId) -> Option { let channel = self.thread_event_channels.get(&thread_id)?; let store = channel.store.lock().await; - match store.session_configured.as_ref().map(|event| &event.msg) { - Some(EventMsg::SessionConfigured(session)) => Some(session.cwd.clone()), - _ => None, - } + store.session.as_ref().map(|session| session.cwd.clone()) } - async fn interactive_request_for_thread_event( + async fn interactive_request_for_thread_request( &self, thread_id: ThreadId, - event: &Event, + request: &ServerRequest, ) -> Option { let thread_label = Some(self.thread_label(thread_id)); - match &event.msg { - EventMsg::ExecApprovalRequest(ev) => { + match request { + ServerRequest::CommandExecutionRequestApproval { params, .. } => { + let network_approval_context = params + .network_approval_context + .clone() + .and_then(convert_via_json); + let additional_permissions = params + .additional_permissions + .clone() + .and_then(convert_via_json); + let proposed_execpolicy_amendment = params + .proposed_execpolicy_amendment + .clone() + .map(codex_app_server_protocol::ExecPolicyAmendment::into_core); + let proposed_network_policy_amendments = params + .proposed_network_policy_amendments + .clone() + .map(|amendments| { + amendments + .into_iter() + .map(codex_app_server_protocol::NetworkPolicyAmendment::into_core) + .collect::>() + }); Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec { thread_id, thread_label, - id: ev.effective_approval_id(), - command: ev.command.clone(), - reason: ev.reason.clone(), - available_decisions: ev.effective_available_decisions(), - network_approval_context: ev.network_approval_context.clone(), - additional_permissions: ev.additional_permissions.clone(), + id: params + .approval_id + .clone() + .unwrap_or_else(|| params.item_id.clone()), + command: params.command.clone().into_iter().collect(), + reason: params.reason.clone(), + available_decisions: params + .available_decisions + .clone() + .map(|decisions| { + decisions + .into_iter() + .map(command_execution_decision_to_review_decision) + .collect() + }) + .unwrap_or_else(|| { + default_exec_approval_decisions( + network_approval_context.as_ref(), + proposed_execpolicy_amendment.as_ref(), + proposed_network_policy_amendments.as_deref(), + additional_permissions.as_ref(), + ) + }), + network_approval_context, + additional_permissions, })) } - EventMsg::ApplyPatchApprovalRequest(ev) => Some(ThreadInteractiveRequest::Approval( - ApprovalRequest::ApplyPatch { + ServerRequest::FileChangeRequestApproval { params, .. } => Some( + ThreadInteractiveRequest::Approval(ApprovalRequest::ApplyPatch { thread_id, thread_label, - id: ev.call_id.clone(), - reason: ev.reason.clone(), + id: params.item_id.clone(), + reason: params.reason.clone(), cwd: self .thread_cwd(thread_id) .await .unwrap_or_else(|| self.config.cwd.clone()), - changes: ev.changes.clone(), - }, - )), - EventMsg::ElicitationRequest(ev) => { - if let Some(request) = - McpServerElicitationFormRequest::from_event(thread_id, ev.clone()) - { + changes: HashMap::new(), + }), + ), + ServerRequest::McpServerElicitationRequest { request_id, params } => { + if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( + thread_id, + app_server_request_id_to_mcp_request_id(request_id), + params.clone(), + ) { Some(ThreadInteractiveRequest::McpServerElicitation(request)) } else { Some(ThreadInteractiveRequest::Approval( ApprovalRequest::McpElicitation { thread_id, thread_label, - server_name: ev.server_name.clone(), - request_id: ev.id.clone(), - message: ev.request.message().to_string(), + server_name: params.server_name.clone(), + request_id: app_server_request_id_to_mcp_request_id(request_id), + message: match ¶ms.request { + codex_app_server_protocol::McpServerElicitationRequest::Form { + message, + .. + } + | codex_app_server_protocol::McpServerElicitationRequest::Url { + message, + .. + } => message.clone(), + }, }, )) } } - EventMsg::RequestPermissions(ev) => Some(ThreadInteractiveRequest::Approval( - ApprovalRequest::Permissions { + ServerRequest::PermissionsRequestApproval { params, .. } => Some( + ThreadInteractiveRequest::Approval(ApprovalRequest::Permissions { thread_id, thread_label, - call_id: ev.call_id.clone(), - reason: ev.reason.clone(), - permissions: ev.permissions.clone(), - }, - )), + call_id: params.item_id.clone(), + reason: params.reason.clone(), + permissions: serde_json::from_value( + serde_json::to_value(¶ms.permissions).ok()?, + ) + .ok()?, + }), + ), _ => None, } } - async fn submit_op_to_thread(&mut self, thread_id: ThreadId, op: AppCommand) { - let replay_state_op = - ThreadEventStore::op_can_change_pending_replay_state(&op).then(|| op.clone()); - crate::session_log::log_outbound_op(&op); - let submitted = false; - self.chat_widget.add_error_message(format!( - "Not available in app-server TUI yet for thread {thread_id}." - )); - if submitted && let Some(op) = replay_state_op.as_ref() { - self.note_thread_outbound_op(thread_id, op).await; - self.refresh_pending_thread_approvals().await; - } - } - async fn submit_active_thread_op( &mut self, app_server: &mut AppServerSession, @@ -1515,8 +1754,21 @@ impl App { return Ok(()); }; + self.submit_thread_op(app_server, thread_id, op).await + } + + async fn submit_thread_op( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + op: AppCommand, + ) -> Result<()> { crate::session_log::log_outbound_op(&op); + if self.try_handle_local_history_op(thread_id, &op).await? { + return Ok(()); + } + if self .try_resolve_app_server_request(app_server, thread_id, &op) .await? @@ -1529,16 +1781,222 @@ impl App { .await? { if ThreadEventStore::op_can_change_pending_replay_state(&op) { - self.note_active_thread_outbound_op(&op).await; + self.note_thread_outbound_op(thread_id, &op).await; self.refresh_pending_thread_approvals().await; } return Ok(()); } - self.submit_op_to_thread(thread_id, op).await; + self.chat_widget.add_error_message(format!( + "Not available in app-server TUI yet for thread {thread_id}." + )); Ok(()) } + /// Spawn a background task that fetches the full MCP server inventory from the + /// app-server via paginated RPCs, then delivers the result back through + /// `AppEvent::McpInventoryLoaded`. + /// + /// The spawned task is fire-and-forget: no `JoinHandle` is stored, so a stale + /// result may arrive after the user has moved on. We currently accept that + /// tradeoff because the effect is limited to stale inventory output in history, + /// while request-token invalidation would add cross-cutting async state for a + /// low-severity path. + fn fetch_mcp_inventory(&mut self, app_server: &AppServerSession) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = fetch_all_mcp_server_statuses(request_handle) + .await + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::McpInventoryLoaded { result }); + }); + } + + fn fetch_plugins_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = fetch_plugins_list(request_handle, cwd.clone()) + .await + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::PluginsLoaded { cwd, result }); + }); + } + + fn fetch_plugin_detail( + &mut self, + app_server: &AppServerSession, + cwd: PathBuf, + params: PluginReadParams, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = fetch_plugin_detail(request_handle, params) + .await + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::PluginDetailLoaded { cwd, result }); + }); + } + + fn fetch_plugin_install( + &mut self, + app_server: &AppServerSession, + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let marketplace_path_for_event = marketplace_path.clone(); + let plugin_name_for_event = plugin_name.clone(); + let result = fetch_plugin_install(request_handle, marketplace_path, plugin_name) + .await + .map_err(|err| format!("Failed to install plugin: {err}")); + app_event_tx.send(AppEvent::PluginInstallLoaded { + cwd: cwd_for_event, + marketplace_path: marketplace_path_for_event, + plugin_name: plugin_name_for_event, + plugin_display_name, + result, + }); + }); + } + + fn fetch_plugin_uninstall( + &mut self, + app_server: &AppServerSession, + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let plugin_id_for_event = plugin_id.clone(); + let result = fetch_plugin_uninstall(request_handle, plugin_id) + .await + .map_err(|err| format!("Failed to uninstall plugin: {err}")); + app_event_tx.send(AppEvent::PluginUninstallLoaded { + cwd: cwd_for_event, + plugin_id: plugin_id_for_event, + plugin_display_name, + result, + }); + }); + } + + /// Process the completed MCP inventory fetch: clear the loading spinner, then + /// render either the full tool/resource listing or an error into chat history. + /// + /// When both the local config and the app-server report zero servers, a special + /// "empty" cell is shown instead of the full table. + fn handle_mcp_inventory_result(&mut self, result: Result, String>) { + let config = self.chat_widget.config_ref().clone(); + self.chat_widget.clear_mcp_inventory_loading(); + self.clear_committed_mcp_inventory_loading(); + + let statuses = match result { + Ok(statuses) => statuses, + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to load MCP inventory: {err}")); + return; + } + }; + + if config.mcp_servers.get().is_empty() && statuses.is_empty() { + self.chat_widget + .add_to_history(history_cell::empty_mcp_output()); + return; + } + + self.chat_widget + .add_to_history(history_cell::new_mcp_tools_output_from_statuses( + &config, &statuses, + )); + } + + fn clear_committed_mcp_inventory_loading(&mut self) { + let Some(index) = self + .transcript_cells + .iter() + .rposition(|cell| cell.as_any().is::()) + else { + return; + }; + + self.transcript_cells.remove(index); + if let Some(Overlay::Transcript(overlay)) = &mut self.overlay { + overlay.replace_cells(self.transcript_cells.clone()); + } + } + + /// Intercept composer-history operations and handle them locally against + /// `$CODEX_HOME/history.jsonl`, bypassing the app-server RPC layer. + async fn try_handle_local_history_op( + &mut self, + thread_id: ThreadId, + op: &AppCommand, + ) -> Result { + match op.view() { + AppCommandView::Other(Op::AddToHistory { text }) => { + let text = text.clone(); + let config = self.chat_widget.config_ref().clone(); + tokio::spawn(async move { + if let Err(err) = + message_history::append_entry(&text, &thread_id, &config).await + { + tracing::warn!( + thread_id = %thread_id, + error = %err, + "failed to append to message history" + ); + } + }); + Ok(true) + } + AppCommandView::Other(Op::GetHistoryEntryRequest { offset, log_id }) => { + let offset = *offset; + let log_id = *log_id; + let config = self.chat_widget.config_ref().clone(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let entry_opt = tokio::task::spawn_blocking(move || { + message_history::lookup(log_id, offset, &config) + }) + .await + .unwrap_or_else(|err| { + tracing::warn!(error = %err, "history lookup task failed"); + None + }); + + app_event_tx.send(AppEvent::ThreadHistoryEntryResponse { + thread_id, + event: GetHistoryEntryResponseEvent { + offset, + log_id, + entry: entry_opt.map(|entry| { + codex_protocol::message_history::HistoryEntry { + conversation_id: entry.session_id, + ts: entry.ts, + text: entry.text, + } + }), + }, + }); + }); + Ok(true) + } + _ => Ok(false), + } + } + async fn try_submit_active_thread_op_via_app_server( &mut self, app_server: &mut AppServerSession, @@ -1557,6 +2015,7 @@ impl App { items, cwd, approval_policy, + approvals_reviewer, sandbox_policy, model, effort, @@ -1567,9 +2026,21 @@ impl App { personality, } => { if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { - app_server + match app_server .turn_steer(thread_id, turn_id, items.to_vec()) - .await?; + .await + { + Ok(_) => {} + Err(error) => { + if let Some(turn_error) = active_turn_not_steerable_turn_error(&error) { + if !self.chat_widget.enqueue_rejected_steer() { + self.chat_widget.add_error_message(turn_error.message); + } + } else { + return Err(error.into()); + } + } + } } else { app_server .turn_start( @@ -1577,7 +2048,8 @@ impl App { items.to_vec(), cwd.clone(), approval_policy, - self.chat_widget.config_ref().approvals_reviewer, + approvals_reviewer + .unwrap_or(self.chat_widget.config_ref().approvals_reviewer), sandbox_policy.clone(), model.to_string(), effort, @@ -1599,79 +2071,7 @@ impl App { per_cwd_extra_user_roots: None, }) .await?; - self.handle_codex_event_now(Event { - id: String::new(), - msg: EventMsg::ListSkillsResponse(ListSkillsResponseEvent { - skills: response - .data - .into_iter() - .map(|entry| codex_protocol::protocol::SkillsListEntry { - cwd: entry.cwd, - skills: entry - .skills - .into_iter() - .map(|skill| codex_protocol::protocol::SkillMetadata { - name: skill.name, - description: skill.description, - short_description: skill.short_description, - interface: skill.interface.map(|interface| { - codex_protocol::protocol::SkillInterface { - display_name: interface.display_name, - short_description: interface.short_description, - icon_small: interface.icon_small, - icon_large: interface.icon_large, - brand_color: interface.brand_color, - default_prompt: interface.default_prompt, - } - }), - dependencies: skill.dependencies.map(|dependencies| { - codex_protocol::protocol::SkillDependencies { - tools: dependencies - .tools - .into_iter() - .map(|tool| { - codex_protocol::protocol::SkillToolDependency { - r#type: tool.r#type, - value: tool.value, - description: tool.description, - transport: tool.transport, - command: tool.command, - url: tool.url, - } - }) - .collect(), - } - }), - path: skill.path, - scope: match skill.scope { - codex_app_server_protocol::SkillScope::User => { - codex_protocol::protocol::SkillScope::User - } - codex_app_server_protocol::SkillScope::Repo => { - codex_protocol::protocol::SkillScope::Repo - } - codex_app_server_protocol::SkillScope::System => { - codex_protocol::protocol::SkillScope::System - } - codex_app_server_protocol::SkillScope::Admin => { - codex_protocol::protocol::SkillScope::Admin - } - }, - enabled: skill.enabled, - }) - .collect(), - errors: entry - .errors - .into_iter() - .map(|error| codex_protocol::protocol::SkillErrorInfo { - path: error.path, - message: error.message, - }) - .collect(), - }) - .collect(), - }), - }); + self.handle_skills_list_response(response); Ok(true) } AppCommandView::Compact => { @@ -1685,7 +2085,15 @@ impl App { Ok(true) } AppCommandView::ThreadRollback { num_turns } => { - app_server.thread_rollback(thread_id, num_turns).await?; + let response = match app_server.thread_rollback(thread_id, num_turns).await { + Ok(response) => response, + Err(err) => { + self.handle_backtrack_rollback_failed(); + return Err(err); + } + }; + self.handle_thread_rollback_response(thread_id, num_turns, &response) + .await; Ok(true) } AppCommandView::Review { review_request } => { @@ -1722,6 +2130,12 @@ impl App { app_server.thread_realtime_stop(thread_id).await?; Ok(true) } + AppCommandView::RunUserShellCommand { command } => { + app_server + .thread_shell_command(thread_id, command.to_string()) + .await?; + Ok(true) + } AppCommandView::OverrideTurnContext { .. } => Ok(true), _ => Ok(false), } @@ -1790,11 +2204,89 @@ impl App { self.chat_widget.set_pending_thread_approvals(threads); } - async fn enqueue_thread_event(&mut self, thread_id: ThreadId, event: Event) -> Result<()> { - let refresh_pending_thread_approvals = - ThreadEventStore::event_can_change_pending_thread_approvals(&event); + async fn enqueue_thread_notification( + &mut self, + thread_id: ThreadId, + notification: ServerNotification, + ) -> Result<()> { + let inferred_session = self + .infer_session_for_thread_notification(thread_id, ¬ification) + .await; + let (sender, store) = { + let channel = self.ensure_thread_channel(thread_id); + (channel.sender.clone(), Arc::clone(&channel.store)) + }; + + let should_send = { + let mut guard = store.lock().await; + if guard.session.is_none() + && let Some(session) = inferred_session + { + guard.session = Some(session); + } + guard.push_notification(notification.clone()); + guard.active + }; + + if should_send { + match sender.try_send(ThreadBufferedEvent::Notification(notification)) { + Ok(()) => {} + Err(TrySendError::Full(event)) => { + tokio::spawn(async move { + if let Err(err) = sender.send(event).await { + tracing::warn!("thread {thread_id} event channel closed: {err}"); + } + }); + } + Err(TrySendError::Closed(_)) => { + tracing::warn!("thread {thread_id} event channel closed"); + } + } + } + self.refresh_pending_thread_approvals().await; + Ok(()) + } + + async fn infer_session_for_thread_notification( + &mut self, + thread_id: ThreadId, + notification: &ServerNotification, + ) -> Option { + let ServerNotification::ThreadStarted(notification) = notification else { + return None; + }; + let mut session = self.primary_session_configured.clone()?; + session.thread_id = thread_id; + session.thread_name = notification.thread.name.clone(); + session.model_provider_id = notification.thread.model_provider.clone(); + session.cwd = notification.thread.cwd.clone(); + let rollout_path = notification.thread.path.clone(); + if let Some(model) = + read_session_model(&self.config, thread_id, rollout_path.as_deref()).await + { + session.model = model; + } else if rollout_path.is_some() { + session.model.clear(); + } + session.history_log_id = 0; + session.history_entry_count = 0; + session.rollout_path = rollout_path; + self.upsert_agent_picker_thread( + thread_id, + notification.thread.agent_nickname.clone(), + notification.thread.agent_role.clone(), + /*is_closed*/ false, + ); + Some(session) + } + + async fn enqueue_thread_request( + &mut self, + thread_id: ThreadId, + request: ServerRequest, + ) -> Result<()> { let inactive_interactive_request = if self.active_thread_id != Some(thread_id) { - self.interactive_request_for_thread_event(thread_id, &event) + self.interactive_request_for_thread_request(thread_id, &request) .await } else { None @@ -1806,15 +2298,12 @@ impl App { let should_send = { let mut guard = store.lock().await; - guard.push_event(event.clone()); + guard.push_request(request.clone()); guard.active }; if should_send { - // Never await a bounded channel send on the main TUI loop: if the receiver falls behind, - // `send().await` can block and the UI stops drawing. If the channel is full, wait in a - // spawned task instead. - match sender.try_send(event) { + match sender.try_send(ThreadBufferedEvent::Request(request)) { Ok(()) => {} Err(TrySendError::Full(event)) => { tokio::spawn(async move { @@ -1838,52 +2327,174 @@ impl App { } } } - if refresh_pending_thread_approvals { - self.refresh_pending_thread_approvals().await; - } + self.refresh_pending_thread_approvals().await; Ok(()) } - async fn handle_routed_thread_event( + async fn enqueue_thread_history_entry_response( &mut self, thread_id: ThreadId, - event: Event, + event: GetHistoryEntryResponseEvent, ) -> Result<()> { - if !self.thread_event_channels.contains_key(&thread_id) { - tracing::debug!("dropping stale event for untracked thread {thread_id}"); - return Ok(()); - } + let (sender, store) = { + let channel = self.ensure_thread_channel(thread_id); + (channel.sender.clone(), Arc::clone(&channel.store)) + }; - self.enqueue_thread_event(thread_id, event).await - } - - async fn enqueue_primary_event(&mut self, event: Event) -> Result<()> { - if let Some(thread_id) = self.primary_thread_id { - return self.enqueue_thread_event(thread_id, event).await; - } - - if let EventMsg::SessionConfigured(session) = &event.msg { - let thread_id = session.session_id; - self.primary_thread_id = Some(thread_id); - self.primary_session_configured = Some(session.clone()); - self.upsert_agent_picker_thread( - thread_id, /*agent_nickname*/ None, /*agent_role*/ None, - /*is_closed*/ false, - ); - self.ensure_thread_channel(thread_id); - self.activate_thread_channel(thread_id).await; - self.enqueue_thread_event(thread_id, event).await?; - - let pending = std::mem::take(&mut self.pending_primary_events); - for pending_event in pending { - self.enqueue_thread_event(thread_id, pending_event).await?; + let should_send = { + let mut guard = store.lock().await; + guard + .buffer + .push_back(ThreadBufferedEvent::HistoryEntryResponse(event.clone())); + if guard.buffer.len() > guard.capacity + && let Some(removed) = guard.buffer.pop_front() + && let ThreadBufferedEvent::Request(request) = &removed + { + guard + .pending_interactive_replay + .note_evicted_server_request(request); + } + guard.active + }; + + if should_send { + match sender.try_send(ThreadBufferedEvent::HistoryEntryResponse(event)) { + Ok(()) => {} + Err(TrySendError::Full(event)) => { + tokio::spawn(async move { + if let Err(err) = sender.send(event).await { + tracing::warn!("thread {thread_id} event channel closed: {err}"); + } + }); + } + Err(TrySendError::Closed(_)) => { + tracing::warn!("thread {thread_id} event channel closed"); + } } - } else { - self.pending_primary_events.push_back(event); } Ok(()) } + async fn enqueue_primary_thread_session( + &mut self, + session: ThreadSessionState, + turns: Vec, + ) -> Result<()> { + let thread_id = session.thread_id; + self.primary_thread_id = Some(thread_id); + self.primary_session_configured = Some(session.clone()); + self.upsert_agent_picker_thread( + thread_id, /*agent_nickname*/ None, /*agent_role*/ None, + /*is_closed*/ false, + ); + let channel = self.ensure_thread_channel(thread_id); + { + let mut store = channel.store.lock().await; + store.set_session(session.clone(), turns.clone()); + } + self.activate_thread_channel(thread_id).await; + self.chat_widget + .set_initial_user_message_submit_suppressed(/*suppressed*/ true); + self.chat_widget.handle_thread_session(session); + self.chat_widget + .replay_thread_turns(turns, ReplayKind::ResumeInitialMessages); + let pending = std::mem::take(&mut self.pending_primary_events); + for pending_event in pending { + match pending_event { + ThreadBufferedEvent::Notification(notification) => { + self.enqueue_thread_notification(thread_id, notification) + .await?; + } + ThreadBufferedEvent::Request(request) => { + self.enqueue_thread_request(thread_id, request).await?; + } + ThreadBufferedEvent::HistoryEntryResponse(event) => { + self.enqueue_thread_history_entry_response(thread_id, event) + .await?; + } + } + } + self.chat_widget + .set_initial_user_message_submit_suppressed(/*suppressed*/ false); + self.chat_widget.submit_initial_user_message_if_pending(); + Ok(()) + } + + async fn enqueue_primary_thread_notification( + &mut self, + notification: ServerNotification, + ) -> Result<()> { + if let Some(thread_id) = self.primary_thread_id { + return self + .enqueue_thread_notification(thread_id, notification) + .await; + } + self.pending_primary_events + .push_back(ThreadBufferedEvent::Notification(notification)); + Ok(()) + } + + async fn enqueue_primary_thread_request(&mut self, request: ServerRequest) -> Result<()> { + if let Some(thread_id) = self.primary_thread_id { + return self.enqueue_thread_request(thread_id, request).await; + } + self.pending_primary_events + .push_back(ThreadBufferedEvent::Request(request)); + Ok(()) + } + + async fn refresh_snapshot_session_if_needed( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + is_replay_only: bool, + snapshot: &mut ThreadEventSnapshot, + ) { + let should_refresh = !is_replay_only + && snapshot.session.as_ref().is_none_or(|session| { + session.model.trim().is_empty() || session.rollout_path.is_none() + }); + if !should_refresh { + return; + } + + match app_server + .resume_thread(self.config.clone(), thread_id) + .await + { + Ok(started) => { + self.apply_refreshed_snapshot_thread(thread_id, started, snapshot) + .await + } + Err(err) => { + tracing::warn!( + thread_id = %thread_id, + error = %err, + "failed to refresh inferred thread session before replay" + ); + } + } + } + + async fn apply_refreshed_snapshot_thread( + &mut self, + thread_id: ThreadId, + started: AppServerStartedThread, + snapshot: &mut ThreadEventSnapshot, + ) { + let AppServerStartedThread { session, turns } = started; + if let Some(channel) = self.thread_event_channels.get(&thread_id) { + let mut store = channel.store.lock().await; + store.set_session(session.clone(), turns.clone()); + store.rebase_buffer_after_session_refresh(); + } + snapshot.session = Some(session); + snapshot.turns = turns; + snapshot + .events + .retain(ThreadEventStore::event_survives_session_refresh); + } + /// Opens the `/agent` picker after refreshing cached labels for known threads. /// /// The picker state is derived from long-lived thread channels plus best-effort metadata @@ -1990,7 +2601,12 @@ impl App { self.sync_active_agent_label(); } - async fn select_agent_thread(&mut self, tui: &mut tui::Tui, thread_id: ThreadId) -> Result<()> { + async fn select_agent_thread( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + thread_id: ThreadId, + ) -> Result<()> { if self.active_thread_id == Some(thread_id) { return Ok(()); } @@ -2008,7 +2624,8 @@ impl App { let previous_thread_id = self.active_thread_id; self.store_active_thread_receiver().await; self.active_thread_id = None; - let Some((receiver, snapshot)) = self.activate_thread_for_replay(thread_id).await else { + let Some((receiver, mut snapshot)) = self.activate_thread_for_replay(thread_id).await + else { self.chat_widget .add_error_message(format!("Agent thread {thread_id} is already active.")); if let Some(previous_thread_id) = previous_thread_id { @@ -2017,6 +2634,14 @@ impl App { return Ok(()); }; + self.refresh_snapshot_session_if_needed( + app_server, + thread_id, + is_replay_only, + &mut snapshot, + ) + .await; + self.active_thread_id = Some(thread_id); self.active_thread_rx = Some(receiver); @@ -2061,6 +2686,7 @@ impl App { self.active_thread_id = None; self.active_thread_rx = None; self.primary_thread_id = None; + self.primary_session_configured = None; self.pending_primary_events.clear(); self.pending_app_server_requests.clear(); self.chat_widget.set_pending_thread_approvals(Vec::new()); @@ -2128,11 +2754,8 @@ impl App { let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); self.chat_widget = ChatWidget::new_with_app_event(init); self.reset_thread_event_state(); - self.enqueue_primary_event(Event { - id: String::new(), - msg: EventMsg::SessionConfigured(started.session_configured), - }) - .await + self.enqueue_primary_thread_session(started.session, started.turns) + .await } fn fresh_session_config(&self) -> Config { @@ -2149,7 +2772,7 @@ impl App { let mut disconnected = false; loop { match rx.try_recv() { - Ok(event) => self.handle_codex_event_now(event), + Ok(event) => self.handle_thread_event_now(event), Err(TryRecvError::Empty) => break, Err(TryRecvError::Disconnected) => { disconnected = true; @@ -2178,11 +2801,14 @@ impl App { /// here so Ctrl+C-like exits don't accidentally resurrect the main thread. /// /// Failover is only eligible when all of these are true: - /// 1. the event is `ShutdownComplete`; + /// 1. the event is `thread/closed`; /// 2. the active thread differs from the primary thread; /// 3. the active thread is not the pending shutdown-exit thread. - fn active_non_primary_shutdown_target(&self, msg: &EventMsg) -> Option<(ThreadId, ThreadId)> { - if !matches!(msg, EventMsg::ShutdownComplete) { + fn active_non_primary_shutdown_target( + &self, + notification: &ServerNotification, + ) -> Option<(ThreadId, ThreadId)> { + if !matches!(notification, ServerNotification::ThreadClosed(_)) { return None; } let active_thread_id = self.active_thread_id?; @@ -2198,18 +2824,25 @@ impl App { snapshot: ThreadEventSnapshot, resume_restored_queue: bool, ) { - if let Some(event) = snapshot.session_configured { - self.handle_codex_event_replay(event); + if let Some(session) = snapshot.session { + self.chat_widget.handle_thread_session(session); } self.chat_widget .set_queue_autosend_suppressed(/*suppressed*/ true); self.chat_widget .restore_thread_input_state(snapshot.input_state); + if !snapshot.turns.is_empty() { + self.chat_widget + .replay_thread_turns(snapshot.turns, ReplayKind::ThreadSnapshot); + } for event in snapshot.events { - self.handle_codex_event_replay(event); + self.handle_thread_event_replay(event); } self.chat_widget .set_queue_autosend_suppressed(/*suppressed*/ false); + self.chat_widget + .set_initial_user_message_submit_suppressed(/*suppressed*/ false); + self.chat_widget.submit_initial_user_message_if_pending(); if resume_restored_queue { self.chat_widget.maybe_send_next_queued_input(); } @@ -2257,6 +2890,7 @@ impl App { let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); emit_project_config_warnings(&app_event_tx, &config); + emit_system_bwrap_warning(&app_event_tx); tui.set_notification_method(config.tui_notification_method); let harness_overrides = @@ -2308,7 +2942,7 @@ impl App { auth_mode, codex_core::default_client::originator().value, config.otel.log_user_prompt, - codex_core::terminal::user_agent(), + user_agent(), SessionSource::Cli, ); if config @@ -2324,7 +2958,7 @@ impl App { let enhanced_keys_supported = tui.enhanced_keys_supported(); let wait_for_initial_session_configured = Self::should_wait_for_initial_session(&session_selection); - let (mut chat_widget, initial_session_configured) = match session_selection { + let (mut chat_widget, initial_started_thread) = match session_selection { SessionSelection::StartFresh | SessionSelection::Exit => { let started = app_server.start_thread(&config).await?; let startup_tooltip_override = @@ -2353,10 +2987,7 @@ impl App { status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), }; - ( - ChatWidget::new_with_app_event(init), - Some(started.session_configured), - ) + (ChatWidget::new_with_app_event(init), Some(started)) } SessionSelection::Resume(target_session) => { let resumed = app_server @@ -2389,10 +3020,7 @@ impl App { status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), }; - ( - ChatWidget::new_with_app_event(init), - Some(resumed.session_configured), - ) + (ChatWidget::new_with_app_event(init), Some(resumed)) } SessionSelection::Fork(target_session) => { session_telemetry.counter( @@ -2430,10 +3058,7 @@ impl App { status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), }; - ( - ChatWidget::new_with_app_event(init), - Some(forked.session_configured), - ) + (ChatWidget::new_with_app_event(init), Some(forked)) } }; @@ -2472,7 +3097,6 @@ impl App { feedback_audience, remote_app_server_url, pending_update_action: None, - suppress_shutdown_complete: false, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), @@ -2486,12 +3110,9 @@ impl App { pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), }; - if let Some(session_configured) = initial_session_configured { - app.enqueue_primary_event(Event { - id: String::new(), - msg: EventMsg::SessionConfigured(session_configured), - }) - .await?; + if let Some(started) = initial_started_thread { + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; } // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. @@ -2571,7 +3192,7 @@ impl App { app.active_thread_rx.is_some() ) => { if let Some(event) = active { - if let Err(err) = app.handle_active_thread_event(tui, event).await { + if let Err(err) = app.handle_active_thread_event(tui, &mut app_server, event).await { break Err(err); } } else { @@ -2942,12 +3563,6 @@ impl App { AppEvent::CommitTick => { self.chat_widget.on_commit_tick(); } - AppEvent::CodexEvent(event) => { - self.enqueue_primary_event(event).await?; - } - AppEvent::ThreadEvent { thread_id, event } => { - self.handle_routed_thread_event(thread_id, event).await?; - } AppEvent::Exit(mode) => { return Ok(self.handle_exit_mode(app_server, mode).await); } @@ -2958,14 +3573,12 @@ impl App { self.submit_active_thread_op(app_server, op.into()).await?; } AppEvent::SubmitThreadOp { thread_id, op } => { - let app_command: AppCommand = op.into(); - if self - .try_resolve_app_server_request(app_server, thread_id, &app_command) - .await? - { - return Ok(AppRunControl::Continue); - } - self.submit_op_to_thread(thread_id, app_command).await; + self.submit_thread_op(app_server, thread_id, op.into()) + .await?; + } + AppEvent::ThreadHistoryEntryResponse { thread_id, event } => { + self.enqueue_thread_history_entry_response(thread_id, event) + .await?; } AppEvent::DiffResult(text) => { // Clear the in-progress state in the bottom pane @@ -3012,6 +3625,108 @@ impl App { AppEvent::RefreshConnectors { force_refetch } => { self.chat_widget.refresh_connectors(force_refetch); } + AppEvent::PluginInstallAuthAdvance { refresh_connectors } => { + if refresh_connectors { + self.chat_widget.refresh_connectors(/*force_refetch*/ true); + } + self.chat_widget.advance_plugin_install_auth_flow(); + } + AppEvent::PluginInstallAuthAbandon => { + self.chat_widget.abandon_plugin_install_auth_flow(); + } + AppEvent::FetchPluginsList { cwd } => { + self.fetch_plugins_list(app_server, cwd); + } + AppEvent::OpenPluginDetailLoading { + plugin_display_name, + } => { + self.chat_widget + .open_plugin_detail_loading_popup(&plugin_display_name); + } + AppEvent::OpenPluginInstallLoading { + plugin_display_name, + } => { + self.chat_widget + .open_plugin_install_loading_popup(&plugin_display_name); + } + AppEvent::OpenPluginUninstallLoading { + plugin_display_name, + } => { + self.chat_widget + .open_plugin_uninstall_loading_popup(&plugin_display_name); + } + AppEvent::PluginsLoaded { cwd, result } => { + self.chat_widget.on_plugins_loaded(cwd, result); + } + AppEvent::FetchPluginDetail { cwd, params } => { + self.fetch_plugin_detail(app_server, cwd, params); + } + AppEvent::PluginDetailLoaded { cwd, result } => { + self.chat_widget.on_plugin_detail_loaded(cwd, result); + } + AppEvent::FetchPluginInstall { + cwd, + marketplace_path, + plugin_name, + plugin_display_name, + } => { + self.fetch_plugin_install( + app_server, + cwd, + marketplace_path, + plugin_name, + plugin_display_name, + ); + } + AppEvent::FetchPluginUninstall { + cwd, + plugin_id, + plugin_display_name, + } => { + self.fetch_plugin_uninstall(app_server, cwd, plugin_id, plugin_display_name); + } + AppEvent::PluginInstallLoaded { + cwd, + marketplace_path, + plugin_name, + plugin_display_name, + result, + } => { + let install_succeeded = result.is_ok(); + if install_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!(error = %err, "failed to refresh config after plugin install"); + } + self.chat_widget.refresh_plugin_mentions(); + self.chat_widget.submit_op(AppCommand::reload_user_config()); + } + let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded( + cwd.clone(), + marketplace_path.clone(), + plugin_name.clone(), + plugin_display_name, + result, + ); + if install_succeeded && self.chat_widget.config_ref().cwd == cwd { + self.fetch_plugins_list(app_server, cwd.clone()); + if should_refresh_plugin_detail { + self.fetch_plugin_detail( + app_server, + cwd, + PluginReadParams { + marketplace_path, + plugin_name, + }, + ); + } + } + } + AppEvent::FetchMcpInventory => { + self.fetch_mcp_inventory(app_server); + } + AppEvent::McpInventoryLoaded { result } => { + self.handle_mcp_inventory_result(result); + } AppEvent::StartFileSearch(query) => { self.file_search.on_user_query(query); } @@ -3143,7 +3858,7 @@ impl App { Ok(()) => { session_telemetry.counter( "codex.windows_sandbox.elevated_setup_success", - 1, + /*inc*/ 1, &[], ); AppEvent::EnableWindowsSandboxForAgentMode { @@ -3173,7 +3888,7 @@ impl App { codex_core::windows_sandbox::elevated_setup_failure_metric_name( &err, ), - 1, + /*inc*/ 1, &tags, ); tracing::error!( @@ -3214,7 +3929,7 @@ impl App { ) { session_telemetry.counter( "codex.windows_sandbox.legacy_setup_preflight_failed", - 1, + /*inc*/ 1, &[], ); tracing::warn!( @@ -3239,7 +3954,7 @@ impl App { self.chat_widget .add_to_history(history_cell::new_info_event( format!("Granting sandbox read access to {path} ..."), - None, + /*hint*/ None, )); let policy = self.config.permissions.sandbox_policy.get().clone(); @@ -3314,11 +4029,13 @@ impl App { match builder.apply().await { Ok(()) => { if elevated_enabled { - self.config.set_windows_sandbox_enabled(false); - self.config.set_windows_elevated_sandbox_enabled(true); + self.config.set_windows_sandbox_enabled(/*value*/ false); + self.config + .set_windows_elevated_sandbox_enabled(/*value*/ true); } else { - self.config.set_windows_sandbox_enabled(true); - self.config.set_windows_elevated_sandbox_enabled(false); + self.config.set_windows_sandbox_enabled(/*value*/ true); + self.config + .set_windows_elevated_sandbox_enabled(/*value*/ false); } self.chat_widget.set_windows_sandbox_mode( self.config.permissions.windows_sandbox_mode, @@ -3330,18 +4047,18 @@ impl App { { self.app_event_tx.send(AppEvent::CodexOp( AppCommand::override_turn_context( - None, - None, - None, - None, + /*cwd*/ None, + /*approval_policy*/ None, + /*approvals_reviewer*/ None, + /*sandbox_policy*/ None, #[cfg(target_os = "windows")] Some(windows_sandbox_level), - None, - None, - None, - None, - None, - None, + /*model*/ None, + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + /*personality*/ None, ) .into(), )); @@ -3356,18 +4073,18 @@ impl App { } else { self.app_event_tx.send(AppEvent::CodexOp( AppCommand::override_turn_context( - None, + /*cwd*/ None, Some(preset.approval), Some(self.config.approvals_reviewer), Some(preset.sandbox.clone()), #[cfg(target_os = "windows")] Some(windows_sandbox_level), - None, - None, - None, - None, - None, - None, + /*model*/ None, + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + /*personality*/ None, ) .into(), )); @@ -3443,6 +4160,32 @@ impl App { } } } + AppEvent::PluginUninstallLoaded { + cwd, + plugin_id: _plugin_id, + plugin_display_name, + result, + } => { + let uninstall_succeeded = result.is_ok(); + if uninstall_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!( + error = %err, + "failed to refresh config after plugin uninstall" + ); + } + self.chat_widget.refresh_plugin_mentions(); + self.chat_widget.submit_op(AppCommand::reload_user_config()); + } + self.chat_widget.on_plugin_uninstall_loaded( + cwd.clone(), + plugin_display_name, + result, + ); + if uninstall_succeeded && self.chat_widget.config_ref().cwd == cwd { + self.fetch_plugins_list(app_server, cwd); + } + } AppEvent::PersistPersonalitySelection { personality } => { let profile = self.active_profile.as_deref(); match ConfigEditsBuilder::new(&self.config.codex_home) @@ -3982,7 +4725,11 @@ impl App { AppEvent::UpdateRecordingMeter { id, text } => { // Update in place to preserve the element id for subsequent frames. let updated = self.chat_widget.update_transcription_in_place(&id, &text); - if updated { + if updated + || self + .chat_widget + .stop_realtime_conversation_for_deleted_meter(&id) + { tui.frame_requester().schedule_frame(); } } @@ -4068,33 +4815,85 @@ impl App { } } - fn handle_codex_event_now(&mut self, event: Event) { - let needs_refresh = matches!( - event.msg, - EventMsg::SessionConfigured(_) | EventMsg::TurnStarted(_) | EventMsg::TokenCount(_) - ); - // This guard is only for intentional thread-switch shutdowns. - // App-exit shutdowns are tracked by `pending_shutdown_exit_thread_id` - // and resolved in `handle_active_thread_event`. - if self.suppress_shutdown_complete && matches!(event.msg, EventMsg::ShutdownComplete) { - self.suppress_shutdown_complete = false; - return; - } - if let EventMsg::ListSkillsResponse(response) = &event.msg { - let cwd = self.chat_widget.config_ref().cwd.clone(); - let errors = errors_for_cwd(&cwd, response); - emit_skill_load_warnings(&self.app_event_tx, &errors); - } - self.handle_backtrack_event(&event.msg); - self.chat_widget.handle_codex_event(event); + fn handle_skills_list_response(&mut self, response: SkillsListResponse) { + let response = list_skills_response_to_core(response); + let cwd = self.chat_widget.config_ref().cwd.clone(); + let errors = errors_for_cwd(&cwd, &response); + emit_skill_load_warnings(&self.app_event_tx, &errors); + self.chat_widget.handle_skills_list_response(response); + } + async fn handle_thread_rollback_response( + &mut self, + thread_id: ThreadId, + num_turns: u32, + response: &ThreadRollbackResponse, + ) { + if let Some(channel) = self.thread_event_channels.get(&thread_id) { + let mut store = channel.store.lock().await; + store.apply_thread_rollback(response); + } + if self.active_thread_id == Some(thread_id) + && let Some(mut rx) = self.active_thread_rx.take() + { + let mut disconnected = false; + loop { + match rx.try_recv() { + Ok(_) => {} + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + + if !disconnected { + self.active_thread_rx = Some(rx); + } else { + self.clear_active_thread().await; + } + } + self.handle_backtrack_rollback_succeeded(num_turns); + self.chat_widget.handle_thread_rolled_back(); + } + + fn handle_thread_event_now(&mut self, event: ThreadBufferedEvent) { + let needs_refresh = matches!( + &event, + ThreadBufferedEvent::Notification(ServerNotification::TurnStarted(_)) + | ThreadBufferedEvent::Notification(ServerNotification::ThreadTokenUsageUpdated(_)) + ); + match event { + ThreadBufferedEvent::Notification(notification) => { + self.chat_widget + .handle_server_notification(notification, /*replay_kind*/ None); + } + ThreadBufferedEvent::Request(request) => { + self.chat_widget + .handle_server_request(request, /*replay_kind*/ None); + } + ThreadBufferedEvent::HistoryEntryResponse(event) => { + self.chat_widget.handle_history_entry_response(event); + } + } if needs_refresh { self.refresh_status_line(); } } - fn handle_codex_event_replay(&mut self, event: Event) { - self.chat_widget.handle_codex_event_replay(event); + fn handle_thread_event_replay(&mut self, event: ThreadBufferedEvent) { + match event { + ThreadBufferedEvent::Notification(notification) => self + .chat_widget + .handle_server_notification(notification, Some(ReplayKind::ThreadSnapshot)), + ThreadBufferedEvent::Request(request) => self + .chat_widget + .handle_server_request(request, Some(ReplayKind::ThreadSnapshot)), + ThreadBufferedEvent::HistoryEntryResponse(event) => { + self.chat_widget.handle_history_entry_response(event) + } + } } /// Handles an event emitted by the currently active thread. @@ -4102,11 +4901,19 @@ impl App { /// This function enforces shutdown intent routing: unexpected non-primary /// thread shutdowns fail over to the primary thread, while user-requested /// app exits consume only the tracked shutdown completion and then proceed. - async fn handle_active_thread_event(&mut self, tui: &mut tui::Tui, event: Event) -> Result<()> { + async fn handle_active_thread_event( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + event: ThreadBufferedEvent, + ) -> Result<()> { // Capture this before any potential thread switch: we only want to clear // the exit marker when the currently active thread acknowledges shutdown. - let pending_shutdown_exit_completed = matches!(&event.msg, EventMsg::ShutdownComplete) - && self.pending_shutdown_exit_thread_id == self.active_thread_id; + let pending_shutdown_exit_completed = matches!( + &event, + ThreadBufferedEvent::Notification(ServerNotification::ThreadClosed(_)) + ) && self.pending_shutdown_exit_thread_id + == self.active_thread_id; // Processing order matters: // @@ -4116,11 +4923,13 @@ impl App { // // This preserves the mental model that user-requested exits do not trigger // failover, while true sub-agent deaths still do. - if let Some((closed_thread_id, primary_thread_id)) = - self.active_non_primary_shutdown_target(&event.msg) + if let ThreadBufferedEvent::Notification(notification) = &event + && let Some((closed_thread_id, primary_thread_id)) = + self.active_non_primary_shutdown_target(notification) { self.mark_agent_picker_thread_closed(closed_thread_id); - self.select_agent_thread(tui, primary_thread_id).await?; + self.select_agent_thread_and_discard_btw_chain(tui, app_server, primary_thread_id) + .await?; if self.active_thread_id == Some(primary_thread_id) { self.chat_widget.add_info_message( format!( @@ -4142,7 +4951,7 @@ impl App { // thread, so unrelated shutdowns cannot consume this marker. self.pending_shutdown_exit_thread_id = None; } - self.handle_codex_event_now(event); + self.handle_thread_event_now(event); if self.backtrack_render_pending { tui.frame_requester().schedule_frame(); } @@ -4376,18 +5185,22 @@ impl App { // Esc so the active UI (e.g. status indicator, modals, popups) // handles it. KeyEvent { - code: KeyCode::Char('c'), - modifiers: crossterm::event::KeyModifiers::CONTROL, + code: KeyCode::Esc, kind: KeyEventKind::Press | KeyEventKind::Repeat, .. } => { if self.maybe_return_from_btw(tui, app_server).await { + } else if self.chat_widget.is_normal_backtrack_mode() + && self.chat_widget.composer_is_empty() + { + self.handle_backtrack_esc_key(tui); } else { self.chat_widget.handle_key_event(key_event); } } KeyEvent { - code: KeyCode::Esc, + code: KeyCode::Char('c'), + modifiers: crossterm::event::KeyModifiers::CONTROL, kind: KeyEventKind::Press | KeyEventKind::Repeat, .. } => { @@ -4464,12 +5277,154 @@ impl App { } } +/// Collect every MCP server status from the app-server by walking the paginated +/// `mcpServerStatus/list` RPC until no `next_cursor` is returned. +/// +/// All pages are eagerly gathered into a single `Vec` so the caller can render +/// the inventory atomically. Each page requests up to 100 entries. +async fn fetch_all_mcp_server_statuses( + request_handle: AppServerRequestHandle, +) -> Result> { + let mut cursor = None; + let mut statuses = Vec::new(); + + loop { + let request_id = RequestId::String(format!("mcp-inventory-{}", Uuid::new_v4())); + let response: ListMcpServerStatusResponse = request_handle + .request_typed(ClientRequest::McpServerStatusList { + request_id, + params: ListMcpServerStatusParams { + cursor: cursor.clone(), + limit: Some(100), + }, + }) + .await + .wrap_err("mcpServerStatus/list failed in app-server TUI")?; + statuses.extend(response.data); + if let Some(next_cursor) = response.next_cursor { + cursor = Some(next_cursor); + } else { + break; + } + } + + Ok(statuses) +} + +async fn fetch_plugins_list( + request_handle: AppServerRequestHandle, + cwd: PathBuf, +) -> Result { + 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]), + force_remote_sync: false, + }, + }) + .await + .wrap_err("plugin/list failed in app-server TUI") +} + +async fn fetch_plugin_detail( + request_handle: AppServerRequestHandle, + params: PluginReadParams, +) -> Result { + let request_id = RequestId::String(format!("plugin-read-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::PluginRead { request_id, params }) + .await + .wrap_err("plugin/read failed in app-server TUI") +} + +async fn fetch_plugin_install( + request_handle: AppServerRequestHandle, + marketplace_path: AbsolutePathBuf, + plugin_name: String, +) -> Result { + let request_id = RequestId::String(format!("plugin-install-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::PluginInstall { + request_id, + params: PluginInstallParams { + marketplace_path, + plugin_name, + force_remote_sync: false, + }, + }) + .await + .wrap_err("plugin/install failed in app-server TUI") +} + +async fn fetch_plugin_uninstall( + request_handle: AppServerRequestHandle, + plugin_id: String, +) -> Result { + let request_id = RequestId::String(format!("plugin-uninstall-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::PluginUninstall { + request_id, + params: PluginUninstallParams { + plugin_id, + force_remote_sync: false, + }, + }) + .await + .wrap_err("plugin/uninstall failed in app-server TUI") +} + +/// Convert flat `McpServerStatus` responses into the per-server maps used by the +/// in-process MCP subsystem (tools keyed as `mcp__{server}__{tool}`, plus +/// per-server resource/template/auth maps). Test-only because the app-server TUI +/// renders directly from `McpServerStatus` rather than these maps. +#[cfg(test)] +type McpInventoryMaps = ( + HashMap, + HashMap>, + HashMap>, + HashMap, +); + +#[cfg(test)] +fn mcp_inventory_maps_from_statuses(statuses: Vec) -> McpInventoryMaps { + let mut tools = HashMap::new(); + let mut resources = HashMap::new(); + let mut resource_templates = HashMap::new(); + let mut auth_statuses = HashMap::new(); + + for status in statuses { + let server_name = status.name; + auth_statuses.insert( + server_name.clone(), + match status.auth_status { + codex_app_server_protocol::McpAuthStatus::Unsupported => McpAuthStatus::Unsupported, + codex_app_server_protocol::McpAuthStatus::NotLoggedIn => McpAuthStatus::NotLoggedIn, + codex_app_server_protocol::McpAuthStatus::BearerToken => McpAuthStatus::BearerToken, + codex_app_server_protocol::McpAuthStatus::OAuth => McpAuthStatus::OAuth, + }, + ); + resources.insert(server_name.clone(), status.resources); + resource_templates.insert(server_name.clone(), status.resource_templates); + for (tool_name, tool) in status.tools { + tools.insert(format!("mcp__{server_name}__{tool_name}"), tool); + } + } + + (tools, resources, resource_templates, auth_statuses) +} + #[cfg(test)] mod tests { use super::*; use crate::app_backtrack::BacktrackSelection; use crate::app_backtrack::BacktrackState; use crate::app_backtrack::user_count; + + use crate::chatwidget::ChatWidgetInit; + use crate::chatwidget::create_initial_user_message; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; use crate::chatwidget::tests::set_chatgpt_auth; use crate::file_search::FileSearchManager; @@ -4480,6 +5435,33 @@ mod tests { use crate::multi_agents::AgentPickerThreadEntry; use assert_matches::assert_matches; + use codex_app_server_protocol::AdditionalNetworkPermissions; + use codex_app_server_protocol::AdditionalPermissionProfile; + use codex_app_server_protocol::AgentMessageDeltaNotification; + use codex_app_server_protocol::CommandExecutionRequestApprovalParams; + use codex_app_server_protocol::ConfigWarningNotification; + use codex_app_server_protocol::JSONRPCErrorError; + use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext; + use codex_app_server_protocol::NetworkApprovalProtocol as AppServerNetworkApprovalProtocol; + use codex_app_server_protocol::NetworkPolicyAmendment as AppServerNetworkPolicyAmendment; + use codex_app_server_protocol::NetworkPolicyRuleAction as AppServerNetworkPolicyRuleAction; + use codex_app_server_protocol::NonSteerableTurnKind as AppServerNonSteerableTurnKind; + use codex_app_server_protocol::RequestId as AppServerRequestId; + use codex_app_server_protocol::ServerNotification; + use codex_app_server_protocol::ServerRequest; + use codex_app_server_protocol::Thread; + use codex_app_server_protocol::ThreadClosedNotification; + use codex_app_server_protocol::ThreadItem; + use codex_app_server_protocol::ThreadStartedNotification; + use codex_app_server_protocol::ThreadTokenUsage; + use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; + use codex_app_server_protocol::TokenUsageBreakdown; + use codex_app_server_protocol::Turn; + use codex_app_server_protocol::TurnCompletedNotification; + use codex_app_server_protocol::TurnError as AppServerTurnError; + use codex_app_server_protocol::TurnStartedNotification; + use codex_app_server_protocol::TurnStatus; + use codex_app_server_protocol::UserInput as AppServerUserInput; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::types::ModelAvailabilityNuxConfig; @@ -4489,20 +5471,22 @@ mod tests { use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; + use codex_protocol::mcp::Tool; + use codex_protocol::models::NetworkPermissions; + use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ModelAvailabilityNux; - use codex_protocol::protocol::AgentMessageDeltaEvent; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::McpAuthStatus; + use codex_protocol::protocol::NetworkApprovalContext; + use codex_protocol::protocol::NetworkApprovalProtocol; + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; - use codex_protocol::protocol::ThreadRolledBackEvent; - use codex_protocol::protocol::TurnAbortReason; - use codex_protocol::protocol::TurnAbortedEvent; - use codex_protocol::protocol::TurnCompleteEvent; - use codex_protocol::protocol::TurnStartedEvent; - use codex_protocol::protocol::UserMessageEvent; + use codex_protocol::protocol::TurnContextItem; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; use crossterm::event::KeyModifiers; @@ -4534,6 +5518,75 @@ mod tests { Ok(()) } + #[test] + fn mcp_inventory_maps_prefix_tool_names_by_server() { + let statuses = vec![ + McpServerStatus { + name: "docs".to_string(), + tools: HashMap::from([( + "list".to_string(), + Tool { + description: None, + name: "list".to_string(), + title: None, + input_schema: serde_json::json!({"type": "object"}), + output_schema: None, + annotations: None, + icons: None, + meta: None, + }, + )]), + resources: Vec::new(), + resource_templates: Vec::new(), + auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported, + }, + McpServerStatus { + name: "disabled".to_string(), + tools: HashMap::new(), + resources: Vec::new(), + resource_templates: Vec::new(), + auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported, + }, + ]; + + let (tools, resources, resource_templates, auth_statuses) = + mcp_inventory_maps_from_statuses(statuses); + let mut resource_names = resources.keys().cloned().collect::>(); + resource_names.sort(); + let mut template_names = resource_templates.keys().cloned().collect::>(); + template_names.sort(); + + assert_eq!( + tools.keys().cloned().collect::>(), + vec!["mcp__docs__list".to_string()] + ); + assert_eq!(resource_names, vec!["disabled", "docs"]); + assert_eq!(template_names, vec!["disabled", "docs"]); + assert_eq!( + auth_statuses.get("disabled"), + Some(&McpAuthStatus::Unsupported) + ); + } + + #[tokio::test] + async fn handle_mcp_inventory_result_clears_committed_loading_cell() { + let mut app = make_test_app().await; + app.transcript_cells + .push(Arc::new(history_cell::new_mcp_inventory_loading( + /*animations_enabled*/ false, + ))); + + app.handle_mcp_inventory_result(Ok(vec![McpServerStatus { + name: "docs".to_string(), + tools: HashMap::new(), + resources: Vec::new(), + resource_templates: Vec::new(), + auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported, + }])); + + assert_eq!(app.transcript_cells.len(), 0); + } + #[test] fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() { assert_eq!( @@ -4617,74 +5670,36 @@ mod tests { } #[tokio::test] - async fn enqueue_primary_event_delivers_session_configured_before_buffered_approval() - -> Result<()> { + async fn enqueue_primary_thread_session_replays_buffered_approval_after_attach() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); - let approval_event = Event { - id: "approval-event".to_string(), - msg: EventMsg::ExecApprovalRequest( - codex_protocol::protocol::ExecApprovalRequestEvent { - call_id: "call-1".to_string(), - approval_id: None, - turn_id: "turn-1".to_string(), - command: vec!["echo".to_string(), "hello".to_string()], - cwd: PathBuf::from("/tmp/project"), - reason: Some("needs approval".to_string()), - network_approval_context: None, - proposed_execpolicy_amendment: None, - proposed_network_policy_amendments: None, - additional_permissions: None, - skill_metadata: None, - available_decisions: None, - parsed_cmd: Vec::new(), - }, - ), - }; - let session_configured_event = Event { - id: "session-configured".to_string(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: thread_id, - forked_from_id: None, - thread_name: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/tmp/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(PathBuf::new()), - }), - }; + let approval_request = exec_approval_request(thread_id, "turn-1", "call-1", None); - app.enqueue_primary_event(approval_event.clone()).await?; - app.enqueue_primary_event(session_configured_event.clone()) - .await?; + app.enqueue_primary_thread_request(approval_request).await?; + app.enqueue_primary_thread_session( + test_thread_session(thread_id, PathBuf::from("/tmp/project")), + Vec::new(), + ) + .await?; let rx = app .active_thread_rx .as_mut() .expect("primary thread receiver should be active"); - let first_event = time::timeout(Duration::from_millis(50), rx.recv()) - .await - .expect("timed out waiting for session configured event") - .expect("channel closed unexpectedly"); - let second_event = time::timeout(Duration::from_millis(50), rx.recv()) + let event = time::timeout(Duration::from_millis(50), rx.recv()) .await .expect("timed out waiting for buffered approval event") .expect("channel closed unexpectedly"); - assert!(matches!(first_event.msg, EventMsg::SessionConfigured(_))); - assert!(matches!(second_event.msg, EventMsg::ExecApprovalRequest(_))); + assert!(matches!( + &event, + ThreadBufferedEvent::Request(ServerRequest::CommandExecutionRequestApproval { + params, + .. + }) if params.turn_id == "turn-1" + )); - app.handle_codex_event_now(first_event); - app.handle_codex_event_now(second_event); + app.handle_thread_event_now(event); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); @@ -4703,30 +5718,85 @@ mod tests { } #[tokio::test] - async fn routed_thread_event_does_not_recreate_channel_after_reset() -> Result<()> { - let mut app = make_test_app().await; + async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_submit() + -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); - app.thread_event_channels.insert( - thread_id, - ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY), - ); + let initial_prompt = "follow-up after replay".to_string(); + let config = app.config.clone(); + let model = codex_core::test_support::get_model_offline(config.model.as_deref()); + app.chat_widget = ChatWidget::new_with_app_event(ChatWidgetInit { + config, + frame_requester: crate::tui::FrameRequester::test_dummy(), + app_event_tx: app.app_event_tx.clone(), + initial_user_message: create_initial_user_message( + Some(initial_prompt.clone()), + Vec::new(), + Vec::new(), + ), + enhanced_keys_supported: false, + has_chatgpt_account: false, + model_catalog: app.model_catalog.clone(), + feedback: codex_feedback::CodexFeedback::new(), + is_first_run: false, + feedback_audience: app.feedback_audience, + status_account_display: None, + initial_plan_type: None, + model: Some(model), + startup_tooltip_override: None, + status_line_invalid_items_warned: app.status_line_invalid_items_warned.clone(), + session_telemetry: app.session_telemetry.clone(), + }); - app.reset_thread_event_state(); - app.handle_routed_thread_event( - thread_id, - Event { - id: "stale-event".to_string(), - msg: EventMsg::ShutdownComplete, - }, + app.enqueue_primary_thread_session( + test_thread_session(thread_id, PathBuf::from("/tmp/project")), + vec![test_turn( + "turn-1", + TurnStatus::Completed, + vec![ThreadItem::UserMessage { + id: "user-1".to_string(), + content: vec![AppServerUserInput::Text { + text: "earlier prompt".to_string(), + text_elements: Vec::new(), + }], + }], + )], ) .await?; + let mut saw_replayed_answer = false; + let mut submitted_items = None; + while let Ok(event) = app_event_rx.try_recv() { + match event { + AppEvent::InsertHistoryCell(cell) => { + let transcript = lines_to_single_string(&cell.transcript_lines(80)); + saw_replayed_answer |= transcript.contains("earlier prompt"); + } + AppEvent::SubmitThreadOp { + thread_id: op_thread_id, + op: Op::UserTurn { items, .. }, + } => { + assert_eq!(op_thread_id, thread_id); + submitted_items = Some(items); + } + AppEvent::CodexOp(Op::UserTurn { items, .. }) => { + submitted_items = Some(items); + } + _ => {} + } + } assert!( - !app.thread_event_channels.contains_key(&thread_id), - "stale routed events should not recreate cleared thread channels" + saw_replayed_answer, + "expected replayed history before initial prompt submit" ); - assert_eq!(app.active_thread_id, None); - assert_eq!(app.primary_thread_id, None); + assert_eq!( + submitted_items, + Some(vec![UserInput::Text { + text: initial_prompt, + text_elements: Vec::new(), + }]) + ); + Ok(()) } @@ -4765,6 +5835,44 @@ mod tests { .expect("listener task drop notification should succeed"); } + #[tokio::test] + async fn history_lookup_response_is_routed_to_requesting_thread() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + + let handled = app + .try_handle_local_history_op( + thread_id, + &Op::GetHistoryEntryRequest { + offset: 0, + log_id: 1, + } + .into(), + ) + .await?; + + assert!(handled); + + let app_event = tokio::time::timeout(Duration::from_secs(1), app_event_rx.recv()) + .await + .expect("history lookup should emit an app event") + .expect("app event channel should stay open"); + + let AppEvent::ThreadHistoryEntryResponse { + thread_id: routed_thread_id, + event, + } = app_event + else { + panic!("expected thread-routed history response"); + }; + assert_eq!(routed_thread_id, thread_id); + assert_eq!(event.offset, 0); + assert_eq!(event.log_id, 1); + assert!(event.entry.is_none()); + + Ok(()) + } + #[tokio::test] async fn enqueue_thread_event_does_not_block_when_channel_full() -> Result<()> { let mut app = make_test_app().await; @@ -4773,18 +5881,16 @@ mod tests { .insert(thread_id, ThreadEventChannel::new(1)); app.set_thread_active(thread_id, true).await; - let event = Event { - id: String::new(), - msg: EventMsg::ShutdownComplete, - }; + let event = thread_closed_notification(thread_id); - app.enqueue_thread_event(thread_id, event.clone()).await?; + app.enqueue_thread_notification(thread_id, event.clone()) + .await?; time::timeout( Duration::from_millis(50), - app.enqueue_thread_event(thread_id, event), + app.enqueue_thread_notification(thread_id, event), ) .await - .expect("enqueue_thread_event blocked on a full channel")?; + .expect("enqueue_thread_notification blocked on a full channel")?; let mut rx = app .thread_event_channels @@ -4810,34 +5916,17 @@ mod tests { async fn replay_thread_snapshot_restores_draft_and_queued_input() { let mut app = make_test_app().await; let thread_id = ThreadId::new(); + let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); app.thread_event_channels.insert( thread_id, - ThreadEventChannel::new_with_session_configured( + ThreadEventChannel::new_with_session( THREAD_EVENT_CHANNEL_CAPACITY, - Event { - id: "session-configured".to_string(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: thread_id, - forked_from_id: None, - thread_name: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/tmp/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(PathBuf::new()), - }), - }, + session.clone(), + Vec::new(), ), ); app.activate_thread_channel(thread_id).await; + app.chat_widget.handle_thread_session(session.clone()); app.chat_widget .apply_external_edit("draft prompt".to_string()); @@ -4876,63 +5965,50 @@ mod tests { assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt"); assert!(app.chat_widget.queued_user_message_texts().is_empty()); - match next_user_turn_op(&mut new_op_rx) { - Op::UserTurn { items, .. } => assert_eq!( - items, - vec![UserInput::Text { - text: "queued follow-up".to_string(), - text_elements: Vec::new(), - }] - ), - other => panic!("expected queued follow-up submission, got {other:?}"), + while let Ok(op) = new_op_rx.try_recv() { + assert!( + !matches!(op, Op::UserTurn { .. }), + "draft-only replay should not auto-submit queued input" + ); } } + #[tokio::test] + async fn active_turn_id_for_thread_uses_snapshot_turns() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); + app.thread_event_channels.insert( + thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + session, + vec![test_turn("turn-1", TurnStatus::InProgress, Vec::new())], + ), + ); + + assert_eq!( + app.active_turn_id_for_thread(thread_id).await, + Some("turn-1".to_string()) + ); + } + #[tokio::test] async fn replayed_turn_complete_submits_restored_queued_follow_up() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); - let session_configured = Event { - id: "session-configured".to_string(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: thread_id, - forked_from_id: None, - thread_name: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/tmp/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(PathBuf::new()), - }), - }; + let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); + app.chat_widget.handle_thread_session(session.clone()); app.chat_widget - .handle_codex_event(session_configured.clone()); - app.chat_widget.handle_codex_event(Event { - id: "turn-started".to_string(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - model_context_window: None, - collaboration_mode_kind: Default::default(), - }), - }); - app.chat_widget.handle_codex_event(Event { - id: "agent-delta".to_string(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "streaming".to_string(), - }), - }); + .handle_server_notification(turn_started_notification(thread_id, "turn-1"), None); + app.chat_widget.handle_server_notification( + agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"), + None, + ); app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -4941,18 +6017,15 @@ mod tests { let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = make_chatwidget_manual_with_sender().await; app.chat_widget = chat_widget; - app.chat_widget.handle_codex_event(session_configured); + app.chat_widget.handle_thread_session(session.clone()); while new_op_rx.try_recv().is_ok() {} app.replay_thread_snapshot( ThreadEventSnapshot { - session_configured: None, - events: vec![Event { - id: "turn-complete".to_string(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - }), - }], + session: None, + turns: Vec::new(), + events: vec![ThreadBufferedEvent::Notification( + turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed), + )], input_state: Some(input_state), }, true, @@ -4974,47 +6047,18 @@ mod tests { async fn replay_only_thread_keeps_restored_queue_visible() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); - let session_configured = Event { - id: "session-configured".to_string(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: thread_id, - forked_from_id: None, - thread_name: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/tmp/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(PathBuf::new()), - }), - }; + let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); + app.chat_widget.handle_thread_session(session.clone()); app.chat_widget - .handle_codex_event(session_configured.clone()); - app.chat_widget.handle_codex_event(Event { - id: "turn-started".to_string(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - model_context_window: None, - collaboration_mode_kind: Default::default(), - }), - }); - app.chat_widget.handle_codex_event(Event { - id: "agent-delta".to_string(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "streaming".to_string(), - }), - }); + .handle_server_notification(turn_started_notification(thread_id, "turn-1"), None); + app.chat_widget.handle_server_notification( + agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"), + None, + ); app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -5023,19 +6067,16 @@ mod tests { let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = make_chatwidget_manual_with_sender().await; app.chat_widget = chat_widget; - app.chat_widget.handle_codex_event(session_configured); + app.chat_widget.handle_thread_session(session.clone()); while new_op_rx.try_recv().is_ok() {} app.replay_thread_snapshot( ThreadEventSnapshot { - session_configured: None, - events: vec![Event { - id: "turn-complete".to_string(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - }), - }], + session: None, + turns: Vec::new(), + events: vec![ThreadBufferedEvent::Notification( + turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed), + )], input_state: Some(input_state), }, false, @@ -5055,47 +6096,18 @@ mod tests { async fn replay_thread_snapshot_keeps_queue_when_running_state_only_comes_from_snapshot() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); - let session_configured = Event { - id: "session-configured".to_string(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: thread_id, - forked_from_id: None, - thread_name: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/tmp/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(PathBuf::new()), - }), - }; + let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); + app.chat_widget.handle_thread_session(session.clone()); app.chat_widget - .handle_codex_event(session_configured.clone()); - app.chat_widget.handle_codex_event(Event { - id: "turn-started".to_string(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - model_context_window: None, - collaboration_mode_kind: Default::default(), - }), - }); - app.chat_widget.handle_codex_event(Event { - id: "agent-delta".to_string(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "streaming".to_string(), - }), - }); + .handle_server_notification(turn_started_notification(thread_id, "turn-1"), None); + app.chat_widget.handle_server_notification( + agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"), + None, + ); app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -5104,12 +6116,13 @@ mod tests { let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = make_chatwidget_manual_with_sender().await; app.chat_widget = chat_widget; - app.chat_widget.handle_codex_event(session_configured); + app.chat_widget.handle_thread_session(session.clone()); while new_op_rx.try_recv().is_ok() {} app.replay_thread_snapshot( ThreadEventSnapshot { - session_configured: None, + session: None, + turns: Vec::new(), events: vec![], input_state: Some(input_state), }, @@ -5127,50 +6140,21 @@ mod tests { } #[tokio::test] - async fn replay_thread_snapshot_does_not_submit_queue_before_replay_catches_up() { + async fn replay_thread_snapshot_in_progress_turn_restores_running_queue_state() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); - let session_configured = Event { - id: "session-configured".to_string(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: thread_id, - forked_from_id: None, - thread_name: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/tmp/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(PathBuf::new()), - }), - }; + let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); + app.chat_widget.handle_thread_session(session.clone()); app.chat_widget - .handle_codex_event(session_configured.clone()); - app.chat_widget.handle_codex_event(Event { - id: "turn-started".to_string(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - model_context_window: None, - collaboration_mode_kind: Default::default(), - }), - }); - app.chat_widget.handle_codex_event(Event { - id: "agent-delta".to_string(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "streaming".to_string(), - }), - }); + .handle_server_notification(turn_started_notification(thread_id, "turn-1"), None); + app.chat_widget.handle_server_notification( + agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"), + None, + ); app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -5179,28 +6163,92 @@ mod tests { let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = make_chatwidget_manual_with_sender().await; app.chat_widget = chat_widget; - app.chat_widget.handle_codex_event(session_configured); + app.chat_widget.handle_thread_session(session.clone()); while new_op_rx.try_recv().is_ok() {} app.replay_thread_snapshot( ThreadEventSnapshot { - session_configured: None, + session: None, + turns: vec![test_turn("turn-1", TurnStatus::InProgress, Vec::new())], + events: Vec::new(), + input_state: Some(input_state), + }, + true, + ); + + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["queued follow-up".to_string()] + ); + assert!( + new_op_rx.try_recv().is_err(), + "restored queue should stay queued while replayed turn is still running" + ); + } + + #[tokio::test] + async fn replay_thread_snapshot_in_progress_turn_restores_running_state_without_input_state() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); + let (chat_widget, _app_event_tx, _rx, _new_op_rx) = + make_chatwidget_manual_with_sender().await; + app.chat_widget = chat_widget; + app.chat_widget.handle_thread_session(session); + + app.replay_thread_snapshot( + ThreadEventSnapshot { + session: None, + turns: vec![test_turn("turn-1", TurnStatus::InProgress, Vec::new())], + events: Vec::new(), + input_state: None, + }, + false, + ); + + assert!(app.chat_widget.is_task_running_for_test()); + } + + #[tokio::test] + async fn replay_thread_snapshot_does_not_submit_queue_before_replay_catches_up() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); + app.chat_widget.handle_thread_session(session.clone()); + app.chat_widget + .handle_server_notification(turn_started_notification(thread_id, "turn-1"), None); + app.chat_widget.handle_server_notification( + agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"), + None, + ); + app.chat_widget + .apply_external_edit("queued follow-up".to_string()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + let input_state = app + .chat_widget + .capture_thread_input_state() + .expect("expected queued follow-up state"); + + let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = + make_chatwidget_manual_with_sender().await; + app.chat_widget = chat_widget; + app.chat_widget.handle_thread_session(session.clone()); + while new_op_rx.try_recv().is_ok() {} + + app.replay_thread_snapshot( + ThreadEventSnapshot { + session: None, + turns: Vec::new(), events: vec![ - Event { - id: "older-turn-complete".to_string(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-0".to_string(), - last_agent_message: None, - }), - }, - Event { - id: "latest-turn-started".to_string(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - model_context_window: None, - collaboration_mode_kind: Default::default(), - }), - }, + ThreadBufferedEvent::Notification(turn_completed_notification( + thread_id, + "turn-0", + TurnStatus::Completed, + )), + ThreadBufferedEvent::Notification(turn_started_notification( + thread_id, "turn-1", + )), ], input_state: Some(input_state), }, @@ -5216,13 +6264,10 @@ mod tests { vec!["queued follow-up".to_string()] ); - app.chat_widget.handle_codex_event(Event { - id: "latest-turn-complete".to_string(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - }), - }); + app.chat_widget.handle_server_notification( + turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed), + None, + ); match next_user_turn_op(&mut new_op_rx) { Op::UserTurn { items, .. } => assert_eq!( @@ -5240,34 +6285,17 @@ mod tests { async fn replay_thread_snapshot_restores_pending_pastes_for_submit() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); + let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); app.thread_event_channels.insert( thread_id, - ThreadEventChannel::new_with_session_configured( + ThreadEventChannel::new_with_session( THREAD_EVENT_CHANNEL_CAPACITY, - Event { - id: "session-configured".to_string(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: thread_id, - forked_from_id: None, - thread_name: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/tmp/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(PathBuf::new()), - }), - }, + session.clone(), + Vec::new(), ), ); app.activate_thread_channel(thread_id).await; + app.chat_widget.handle_thread_session(session); let large = "x".repeat(1005); app.chat_widget.handle_paste(large.clone()); @@ -5314,29 +6342,8 @@ mod tests { async fn replay_thread_snapshot_restores_collaboration_mode_for_draft_submit() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); - let session_configured = Event { - id: "session-configured".to_string(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: thread_id, - forked_from_id: None, - thread_name: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/tmp/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(PathBuf::new()), - }), - }; - app.chat_widget - .handle_codex_event(session_configured.clone()); + let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); + app.chat_widget.handle_thread_session(session.clone()); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::High)); app.chat_widget @@ -5357,7 +6364,7 @@ mod tests { let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = make_chatwidget_manual_with_sender().await; app.chat_widget = chat_widget; - app.chat_widget.handle_codex_event(session_configured); + app.chat_widget.handle_thread_session(session.clone()); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::Low)); app.chat_widget @@ -5372,7 +6379,8 @@ mod tests { app.replay_thread_snapshot( ThreadEventSnapshot { - session_configured: None, + session: None, + turns: Vec::new(), events: vec![], input_state: Some(input_state), }, @@ -5418,29 +6426,8 @@ mod tests { async fn replay_thread_snapshot_restores_collaboration_mode_without_input() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); - let session_configured = Event { - id: "session-configured".to_string(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: thread_id, - forked_from_id: None, - thread_name: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/tmp/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(PathBuf::new()), - }), - }; - app.chat_widget - .handle_codex_event(session_configured.clone()); + let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); + app.chat_widget.handle_thread_session(session.clone()); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::High)); app.chat_widget @@ -5459,7 +6446,7 @@ mod tests { let (chat_widget, _app_event_tx, _rx, _new_op_rx) = make_chatwidget_manual_with_sender().await; app.chat_widget = chat_widget; - app.chat_widget.handle_codex_event(session_configured); + app.chat_widget.handle_thread_session(session.clone()); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::Low)); app.chat_widget @@ -5473,7 +6460,8 @@ mod tests { app.replay_thread_snapshot( ThreadEventSnapshot { - session_configured: None, + session: None, + turns: Vec::new(), events: vec![], input_state: Some(input_state), }, @@ -5495,43 +6483,14 @@ mod tests { async fn replayed_interrupted_turn_restores_queued_input_to_composer() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); - let session_configured = Event { - id: "session-configured".to_string(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: thread_id, - forked_from_id: None, - thread_name: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/tmp/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(PathBuf::new()), - }), - }; + let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); + app.chat_widget.handle_thread_session(session.clone()); app.chat_widget - .handle_codex_event(session_configured.clone()); - app.chat_widget.handle_codex_event(Event { - id: "turn-started".to_string(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - model_context_window: None, - collaboration_mode_kind: Default::default(), - }), - }); - app.chat_widget.handle_codex_event(Event { - id: "agent-delta".to_string(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "streaming".to_string(), - }), - }); + .handle_server_notification(turn_started_notification(thread_id, "turn-1"), None); + app.chat_widget.handle_server_notification( + agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"), + None, + ); app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget @@ -5544,19 +6503,16 @@ mod tests { let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = make_chatwidget_manual_with_sender().await; app.chat_widget = chat_widget; - app.chat_widget.handle_codex_event(session_configured); + app.chat_widget.handle_thread_session(session.clone()); while new_op_rx.try_recv().is_ok() {} app.replay_thread_snapshot( ThreadEventSnapshot { - session_configured: None, - events: vec![Event { - id: "turn-aborted".to_string(), - msg: EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::ReviewEnded, - }), - }], + session: None, + turns: Vec::new(), + events: vec![ThreadBufferedEvent::Notification( + turn_completed_notification(thread_id, "turn-1", TurnStatus::Interrupted), + )], input_state: Some(input_state), }, true, @@ -5574,21 +6530,18 @@ mod tests { } #[tokio::test] - async fn live_turn_started_refreshes_status_line_with_runtime_context_window() { + async fn token_usage_update_refreshes_status_line_with_runtime_context_window() { let mut app = make_test_app().await; app.chat_widget .setup_status_line(vec![crate::bottom_pane::StatusLineItem::ContextWindowSize]); assert_eq!(app.chat_widget.status_line_text(), None); - app.handle_codex_event_now(Event { - id: "turn-started".to_string(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - model_context_window: Some(950_000), - collaboration_mode_kind: Default::default(), - }), - }); + app.handle_thread_event_now(ThreadBufferedEvent::Notification(token_usage_notification( + ThreadId::new(), + "turn-1", + Some(950_000), + ))); assert_eq!( app.chat_widget.status_line_text(), @@ -6236,26 +7189,12 @@ guardian_approval = true let agent_channel = ThreadEventChannel::new(1); { let mut store = agent_channel.store.lock().await; - store.push_event(Event { - id: "ev-1".to_string(), - msg: EventMsg::ExecApprovalRequest( - codex_protocol::protocol::ExecApprovalRequestEvent { - call_id: "call-1".to_string(), - approval_id: None, - turn_id: "turn-1".to_string(), - command: vec!["echo".to_string(), "hi".to_string()], - cwd: PathBuf::from("/tmp"), - reason: None, - network_approval_context: None, - proposed_execpolicy_amendment: None, - proposed_network_policy_amendments: None, - additional_permissions: None, - skill_metadata: None, - available_decisions: None, - parsed_cmd: Vec::new(), - }, - ), - }); + store.push_request(exec_approval_request( + agent_thread_id, + "turn-1", + "call-1", + None, + )); } app.thread_event_channels .insert(agent_thread_id, agent_channel); @@ -6291,29 +7230,15 @@ guardian_approval = true .insert(main_thread_id, ThreadEventChannel::new(1)); app.thread_event_channels.insert( agent_thread_id, - ThreadEventChannel::new_with_session_configured( + ThreadEventChannel::new_with_session( 1, - Event { - id: String::new(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: agent_thread_id, - forked_from_id: None, - thread_name: None, - model: "gpt-5".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::OnRequest, - approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_workspace_write_policy(), - cwd: PathBuf::from("/tmp/agent"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(PathBuf::from("/tmp/agent-rollout.jsonl")), - }), + ThreadSessionState { + approval_policy: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + rollout_path: Some(PathBuf::from("/tmp/agent-rollout.jsonl")), + ..test_thread_session(agent_thread_id, PathBuf::from("/tmp/agent")) }, + Vec::new(), ), ); app.agent_navigation.upsert( @@ -6323,28 +7248,9 @@ guardian_approval = true false, ); - app.enqueue_thread_event( + app.enqueue_thread_request( agent_thread_id, - Event { - id: "ev-approval".to_string(), - msg: EventMsg::ExecApprovalRequest( - codex_protocol::protocol::ExecApprovalRequestEvent { - call_id: "call-approval".to_string(), - approval_id: None, - turn_id: "turn-approval".to_string(), - command: vec!["echo".to_string(), "hi".to_string()], - cwd: PathBuf::from("/tmp/agent"), - reason: Some("need approval".to_string()), - network_approval_context: None, - proposed_execpolicy_amendment: None, - proposed_network_policy_amendments: None, - additional_permissions: None, - skill_metadata: None, - available_decisions: None, - parsed_cmd: Vec::new(), - }, - ), - }, + exec_approval_request(agent_thread_id, "turn-approval", "call-approval", None), ) .await?; @@ -6357,6 +7263,298 @@ guardian_approval = true Ok(()) } + #[tokio::test] + async fn inactive_thread_exec_approval_preserves_context() { + let app = make_test_app().await; + let thread_id = ThreadId::new(); + let mut request = exec_approval_request(thread_id, "turn-approval", "call-approval", None); + let ServerRequest::CommandExecutionRequestApproval { params, .. } = &mut request else { + panic!("expected exec approval request"); + }; + params.network_approval_context = Some(AppServerNetworkApprovalContext { + host: "example.com".to_string(), + protocol: AppServerNetworkApprovalProtocol::Https, + }); + params.additional_permissions = Some(AdditionalPermissionProfile { + network: Some(AdditionalNetworkPermissions { + enabled: Some(true), + }), + file_system: None, + macos: None, + }); + params.proposed_network_policy_amendments = Some(vec![AppServerNetworkPolicyAmendment { + host: "example.com".to_string(), + action: AppServerNetworkPolicyRuleAction::Allow, + }]); + + let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec { + available_decisions, + network_approval_context, + additional_permissions, + .. + })) = app + .interactive_request_for_thread_request(thread_id, &request) + .await + else { + panic!("expected exec approval request"); + }; + + assert_eq!( + network_approval_context, + Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + }) + ); + assert_eq!( + additional_permissions, + Some(PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + file_system: None, + macos: None, + }) + ); + assert_eq!( + available_decisions, + vec![ + codex_protocol::protocol::ReviewDecision::Approved, + codex_protocol::protocol::ReviewDecision::ApprovedForSession, + codex_protocol::protocol::ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment: codex_protocol::approvals::NetworkPolicyAmendment { + host: "example.com".to_string(), + action: codex_protocol::approvals::NetworkPolicyRuleAction::Allow, + }, + }, + codex_protocol::protocol::ReviewDecision::Abort, + ] + ); + } + + #[tokio::test] + async fn inactive_thread_approval_badge_clears_after_turn_completion_notification() -> Result<()> + { + let mut app = make_test_app().await; + let main_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000101").expect("valid thread"); + let agent_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000202").expect("valid thread"); + + app.primary_thread_id = Some(main_thread_id); + app.active_thread_id = Some(main_thread_id); + app.thread_event_channels + .insert(main_thread_id, ThreadEventChannel::new(1)); + app.thread_event_channels.insert( + agent_thread_id, + ThreadEventChannel::new_with_session( + 4, + ThreadSessionState { + approval_policy: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + rollout_path: Some(PathBuf::from("/tmp/agent-rollout.jsonl")), + ..test_thread_session(agent_thread_id, PathBuf::from("/tmp/agent")) + }, + Vec::new(), + ), + ); + app.agent_navigation.upsert( + agent_thread_id, + Some("Robie".to_string()), + Some("explorer".to_string()), + false, + ); + + app.enqueue_thread_request( + agent_thread_id, + exec_approval_request(agent_thread_id, "turn-approval", "call-approval", None), + ) + .await?; + assert_eq!( + app.chat_widget.pending_thread_approvals(), + &["Robie [explorer]".to_string()] + ); + + app.enqueue_thread_notification( + agent_thread_id, + turn_completed_notification(agent_thread_id, "turn-approval", TurnStatus::Completed), + ) + .await?; + + assert!( + app.chat_widget.pending_thread_approvals().is_empty(), + "turn completion should clear inactive-thread approval badge immediately" + ); + + Ok(()) + } + + #[tokio::test] + async fn inactive_thread_started_notification_initializes_replay_session() -> Result<()> { + let mut app = make_test_app().await; + let temp_dir = tempdir()?; + let main_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000101").expect("valid thread"); + let agent_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000202").expect("valid thread"); + let primary_session = ThreadSessionState { + approval_policy: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + ..test_thread_session(main_thread_id, PathBuf::from("/tmp/main")) + }; + + app.primary_thread_id = Some(main_thread_id); + app.active_thread_id = Some(main_thread_id); + app.primary_session_configured = Some(primary_session.clone()); + app.thread_event_channels.insert( + main_thread_id, + ThreadEventChannel::new_with_session(4, primary_session.clone(), Vec::new()), + ); + + let rollout_path = temp_dir.path().join("agent-rollout.jsonl"); + let turn_context = TurnContextItem { + turn_id: None, + trace_id: None, + cwd: PathBuf::from("/tmp/agent"), + current_date: None, + timezone: None, + approval_policy: primary_session.approval_policy, + sandbox_policy: primary_session.sandbox_policy.clone(), + network: None, + model: "gpt-agent".to_string(), + personality: None, + collaboration_mode: None, + realtime_active: Some(false), + effort: primary_session.reasoning_effort, + summary: app.config.model_reasoning_summary.unwrap_or_default(), + user_instructions: None, + developer_instructions: None, + final_output_json_schema: None, + truncation_policy: None, + }; + let rollout = RolloutLine { + timestamp: "t0".to_string(), + item: RolloutItem::TurnContext(turn_context), + }; + std::fs::write( + &rollout_path, + format!("{}\n", serde_json::to_string(&rollout)?), + )?; + app.enqueue_thread_notification( + agent_thread_id, + ServerNotification::ThreadStarted(ThreadStartedNotification { + thread: Thread { + id: agent_thread_id.to_string(), + preview: "agent thread".to_string(), + ephemeral: false, + model_provider: "agent-provider".to_string(), + created_at: 1, + updated_at: 2, + status: codex_app_server_protocol::ThreadStatus::Idle, + path: Some(rollout_path.clone()), + cwd: PathBuf::from("/tmp/agent"), + cli_version: "0.0.0".to_string(), + source: codex_app_server_protocol::SessionSource::Unknown, + agent_nickname: Some("Robie".to_string()), + agent_role: Some("explorer".to_string()), + git_info: None, + name: Some("agent thread".to_string()), + turns: Vec::new(), + }, + }), + ) + .await?; + + let store = app + .thread_event_channels + .get(&agent_thread_id) + .expect("agent thread channel") + .store + .lock() + .await; + let session = store.session.clone().expect("inferred session"); + drop(store); + + assert_eq!(session.thread_id, agent_thread_id); + assert_eq!(session.thread_name, Some("agent thread".to_string())); + assert_eq!(session.model, "gpt-agent"); + assert_eq!(session.model_provider_id, "agent-provider"); + assert_eq!(session.approval_policy, primary_session.approval_policy); + assert_eq!(session.cwd, PathBuf::from("/tmp/agent")); + assert_eq!(session.rollout_path, Some(rollout_path)); + assert_eq!( + app.agent_navigation.get(&agent_thread_id), + Some(&AgentPickerThreadEntry { + agent_nickname: Some("Robie".to_string()), + agent_role: Some("explorer".to_string()), + is_closed: false, + }) + ); + + Ok(()) + } + + #[tokio::test] + async fn inactive_thread_started_notification_preserves_primary_model_when_path_missing() + -> Result<()> { + let mut app = make_test_app().await; + let main_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000301").expect("valid thread"); + let agent_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000302").expect("valid thread"); + let primary_session = ThreadSessionState { + approval_policy: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + ..test_thread_session(main_thread_id, PathBuf::from("/tmp/main")) + }; + + app.primary_thread_id = Some(main_thread_id); + app.active_thread_id = Some(main_thread_id); + app.primary_session_configured = Some(primary_session.clone()); + app.thread_event_channels.insert( + main_thread_id, + ThreadEventChannel::new_with_session(4, primary_session.clone(), Vec::new()), + ); + + app.enqueue_thread_notification( + agent_thread_id, + ServerNotification::ThreadStarted(ThreadStartedNotification { + thread: Thread { + id: agent_thread_id.to_string(), + preview: "agent thread".to_string(), + ephemeral: false, + model_provider: "agent-provider".to_string(), + created_at: 1, + updated_at: 2, + status: codex_app_server_protocol::ThreadStatus::Idle, + path: None, + cwd: PathBuf::from("/tmp/agent"), + cli_version: "0.0.0".to_string(), + source: codex_app_server_protocol::SessionSource::Unknown, + agent_nickname: Some("Robie".to_string()), + agent_role: Some("explorer".to_string()), + git_info: None, + name: Some("agent thread".to_string()), + turns: Vec::new(), + }, + }), + ) + .await?; + + let store = app + .thread_event_channels + .get(&agent_thread_id) + .expect("agent thread channel") + .store + .lock() + .await; + let session = store.session.clone().expect("inferred session"); + + assert_eq!(session.model, primary_session.model); + + Ok(()) + } + #[test] fn agent_picker_item_name_snapshot() { let thread_id = @@ -6400,7 +7598,9 @@ guardian_approval = true app.primary_thread_id = Some(ThreadId::new()); assert_eq!( - app.active_non_primary_shutdown_target(&EventMsg::SkillsUpdateAvailable), + app.active_non_primary_shutdown_target(&ServerNotification::SkillsChanged( + codex_app_server_protocol::SkillsChangedNotification {}, + )), None ); Ok(()) @@ -6415,7 +7615,7 @@ guardian_approval = true app.primary_thread_id = Some(thread_id); assert_eq!( - app.active_non_primary_shutdown_target(&EventMsg::ShutdownComplete), + app.active_non_primary_shutdown_target(&thread_closed_notification(thread_id)), None ); Ok(()) @@ -6431,7 +7631,7 @@ guardian_approval = true app.primary_thread_id = Some(primary_thread_id); assert_eq!( - app.active_non_primary_shutdown_target(&EventMsg::ShutdownComplete), + app.active_non_primary_shutdown_target(&thread_closed_notification(active_thread_id)), Some((active_thread_id, primary_thread_id)) ); Ok(()) @@ -6448,7 +7648,7 @@ guardian_approval = true app.pending_shutdown_exit_thread_id = Some(active_thread_id); assert_eq!( - app.active_non_primary_shutdown_target(&EventMsg::ShutdownComplete), + app.active_non_primary_shutdown_target(&thread_closed_notification(active_thread_id)), None ); Ok(()) @@ -6465,7 +7665,7 @@ guardian_approval = true app.pending_shutdown_exit_thread_id = Some(ThreadId::new()); assert_eq!( - app.active_non_primary_shutdown_target(&EventMsg::ShutdownComplete), + app.active_non_primary_shutdown_target(&thread_closed_notification(active_thread_id)), Some((active_thread_id, primary_thread_id)) ); Ok(()) @@ -6653,7 +7853,6 @@ guardian_approval = true feedback_audience: FeedbackAudience::External, remote_app_server_url: None, pending_update_action: None, - suppress_shutdown_complete: false, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), @@ -6706,7 +7905,6 @@ guardian_approval = true feedback_audience: FeedbackAudience::External, remote_app_server_url: None, pending_update_action: None, - suppress_shutdown_complete: false, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), @@ -6725,40 +7923,193 @@ guardian_approval = true ) } + fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState { + ThreadSessionState { + thread_id, + forked_from_id: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd, + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + } + } + + fn test_turn(turn_id: &str, status: TurnStatus, items: Vec) -> Turn { + Turn { + id: turn_id.to_string(), + items, + status, + error: None, + } + } + + fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + }) + } + + fn turn_completed_notification( + thread_id: ThreadId, + turn_id: &str, + status: TurnStatus, + ) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: test_turn(turn_id, status, Vec::new()), + }) + } + + fn thread_closed_notification(thread_id: ThreadId) -> ServerNotification { + ServerNotification::ThreadClosed(ThreadClosedNotification { + thread_id: thread_id.to_string(), + }) + } + + fn token_usage_notification( + thread_id: ThreadId, + turn_id: &str, + model_context_window: Option, + ) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 10, + input_tokens: 4, + cached_input_tokens: 1, + output_tokens: 5, + reasoning_output_tokens: 0, + }, + last: TokenUsageBreakdown { + total_tokens: 10, + input_tokens: 4, + cached_input_tokens: 1, + output_tokens: 5, + reasoning_output_tokens: 0, + }, + model_context_window, + }, + }) + } + + fn agent_message_delta_notification( + thread_id: ThreadId, + turn_id: &str, + item_id: &str, + delta: &str, + ) -> ServerNotification { + ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + item_id: item_id.to_string(), + delta: delta.to_string(), + }) + } + + fn exec_approval_request( + thread_id: ThreadId, + turn_id: &str, + item_id: &str, + approval_id: Option<&str>, + ) -> ServerRequest { + ServerRequest::CommandExecutionRequestApproval { + request_id: AppServerRequestId::Integer(1), + params: CommandExecutionRequestApprovalParams { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + item_id: item_id.to_string(), + approval_id: approval_id.map(str::to_string), + reason: Some("needs approval".to_string()), + network_approval_context: None, + command: Some("echo hello".to_string()), + cwd: Some(PathBuf::from("/tmp/project")), + command_actions: None, + additional_permissions: None, + skill_metadata: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }, + } + } + #[test] fn thread_event_store_tracks_active_turn_lifecycle() { let mut store = ThreadEventStore::new(8); assert_eq!(store.active_turn_id(), None); - store.push_event(Event { - id: "turn-started".to_string(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - model_context_window: Some(128_000), - collaboration_mode_kind: ModeKind::Default, - }), - }); + let thread_id = ThreadId::new(); + store.push_notification(turn_started_notification(thread_id, "turn-1")); assert_eq!(store.active_turn_id(), Some("turn-1")); - store.push_event(Event { - id: "other-turn-complete".to_string(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-2".to_string(), - last_agent_message: None, - }), - }); + store.push_notification(turn_completed_notification( + thread_id, + "turn-2", + TurnStatus::Completed, + )); assert_eq!(store.active_turn_id(), Some("turn-1")); - store.push_event(Event { - id: "turn-aborted".to_string(), - msg: EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::Interrupted, - }), - }); + store.push_notification(turn_completed_notification( + thread_id, + "turn-1", + TurnStatus::Interrupted, + )); assert_eq!(store.active_turn_id(), None); } + #[test] + fn thread_event_store_restores_active_turn_from_snapshot_turns() { + let thread_id = ThreadId::new(); + let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); + let turns = vec![ + test_turn("turn-1", TurnStatus::Completed, Vec::new()), + test_turn("turn-2", TurnStatus::InProgress, Vec::new()), + ]; + + let store = ThreadEventStore::new_with_session(8, session.clone(), turns.clone()); + assert_eq!(store.active_turn_id(), Some("turn-2")); + + let mut refreshed_store = ThreadEventStore::new(8); + refreshed_store.set_session(session, turns); + assert_eq!(refreshed_store.active_turn_id(), Some("turn-2")); + } + + #[test] + fn thread_event_store_rebase_preserves_resolved_request_state() { + let thread_id = ThreadId::new(); + let mut store = ThreadEventStore::new(8); + store.push_request(exec_approval_request( + thread_id, + "turn-approval", + "call-approval", + None, + )); + store.push_notification(ServerNotification::ServerRequestResolved( + codex_app_server_protocol::ServerRequestResolvedNotification { + request_id: AppServerRequestId::Integer(1), + thread_id: thread_id.to_string(), + }, + )); + + store.rebase_buffer_after_session_refresh(); + + let snapshot = store.snapshot(); + assert!(snapshot.events.is_empty()); + assert_eq!(store.has_pending_thread_approvals(), false); + } + fn next_user_turn_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { let mut seen = Vec::new(); while let Ok(op) = op_rx.try_recv() { @@ -6770,6 +8121,19 @@ guardian_approval = true panic!("expected UserTurn op, saw: {seen:?}"); } + fn lines_to_single_string(lines: &[Line<'_>]) -> String { + lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n") + } + fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry { let model_info = codex_core::test_support::construct_model_info_offline(model, config); SessionTelemetry::new( @@ -6928,6 +8292,30 @@ guardian_approval = true ); } + #[test] + fn active_turn_not_steerable_turn_error_extracts_structured_server_error() { + let turn_error = AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: AppServerNonSteerableTurnKind::Review, + }), + additional_details: None, + }; + let error = TypedRequestError::Server { + method: "turn/steer".to_string(), + source: JSONRPCErrorError { + code: -32602, + message: turn_error.message.clone(), + data: Some(serde_json::to_value(&turn_error).expect("turn error should serialize")), + }, + }; + + assert_eq!( + active_turn_not_steerable_turn_error(&error), + Some(turn_error) + ); + } + #[test] fn select_model_availability_nux_uses_existing_model_order_as_priority() { let mut presets = all_model_presets(); @@ -7516,71 +8904,62 @@ guardian_approval = true } #[tokio::test] - async fn replayed_initial_messages_apply_rollback_in_queue_order() { + async fn replay_thread_snapshot_replays_turn_history_in_order() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.replay_thread_snapshot( + ThreadEventSnapshot { + session: Some(test_thread_session( + thread_id, + PathBuf::from("/home/user/project"), + )), + turns: vec![ + Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::UserMessage { + id: "user-1".to_string(), + content: vec![AppServerUserInput::Text { + text: "first prompt".to_string(), + text_elements: Vec::new(), + }], + }], + status: TurnStatus::Completed, + error: None, + }, + Turn { + id: "turn-2".to_string(), + items: vec![ + ThreadItem::UserMessage { + id: "user-2".to_string(), + content: vec![AppServerUserInput::Text { + text: "third prompt".to_string(), + text_elements: Vec::new(), + }], + }, + ThreadItem::AgentMessage { + id: "assistant-2".to_string(), + text: "done".to_string(), + phase: None, + memory_citation: None, + }, + ], + status: TurnStatus::Completed, + error: None, + }, + ], + events: Vec::new(), + input_state: None, + }, + false, + ); - let session_id = ThreadId::new(); - app.handle_codex_event_replay(Event { - id: String::new(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id, - forked_from_id: None, - thread_name: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/home/user/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: Some(vec![ - EventMsg::UserMessage(UserMessageEvent { - message: "first prompt".to_string(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - }), - EventMsg::UserMessage(UserMessageEvent { - message: "second prompt".to_string(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - }), - EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), - EventMsg::UserMessage(UserMessageEvent { - message: "third prompt".to_string(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - }), - ]), - network_proxy: None, - rollout_path: Some(PathBuf::new()), - }), - }); - - let mut saw_rollback = false; while let Ok(event) = app_event_rx.try_recv() { - match event { - AppEvent::InsertHistoryCell(cell) => { - let cell: Arc = cell.into(); - app.transcript_cells.push(cell); - } - AppEvent::ApplyThreadRollback { num_turns } => { - saw_rollback = true; - crate::app_backtrack::trim_transcript_cells_drop_last_n_user_turns( - &mut app.transcript_cells, - num_turns, - ); - } - _ => {} + if let AppEvent::InsertHistoryCell(cell) = event { + let cell: Arc = cell.into(); + app.transcript_cells.push(cell); } } - assert!(saw_rollback); let user_messages: Vec = app .transcript_cells .iter() @@ -7597,80 +8976,60 @@ guardian_approval = true } #[tokio::test] - async fn live_rollback_during_replay_is_applied_in_app_event_order() { - let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + async fn refreshed_snapshot_session_persists_resumed_turns() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + let initial_session = test_thread_session(thread_id, PathBuf::from("/tmp/original")); + app.thread_event_channels.insert( + thread_id, + ThreadEventChannel::new_with_session(4, initial_session.clone(), Vec::new()), + ); - let session_id = ThreadId::new(); - app.handle_codex_event_replay(Event { - id: String::new(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id, - forked_from_id: None, - thread_name: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/home/user/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: Some(vec![ - EventMsg::UserMessage(UserMessageEvent { - message: "first prompt".to_string(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - }), - EventMsg::UserMessage(UserMessageEvent { - message: "second prompt".to_string(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - }), - ]), - network_proxy: None, - rollout_path: Some(PathBuf::new()), - }), - }); + let resumed_turns = vec![test_turn( + "turn-1", + TurnStatus::Completed, + vec![ThreadItem::UserMessage { + id: "user-1".to_string(), + content: vec![AppServerUserInput::Text { + text: "restored prompt".to_string(), + text_elements: Vec::new(), + }], + }], + )]; + let resumed_session = ThreadSessionState { + cwd: PathBuf::from("/tmp/refreshed"), + ..initial_session.clone() + }; + let mut snapshot = ThreadEventSnapshot { + session: Some(initial_session), + turns: Vec::new(), + events: Vec::new(), + input_state: None, + }; - // Simulate a live rollback arriving before queued replay inserts are drained. - app.handle_codex_event_now(Event { - id: "live-rollback".to_string(), - msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), - }); + app.apply_refreshed_snapshot_thread( + thread_id, + AppServerStartedThread { + session: resumed_session.clone(), + turns: resumed_turns.clone(), + }, + &mut snapshot, + ) + .await; - let mut saw_rollback = false; - while let Ok(event) = app_event_rx.try_recv() { - match event { - AppEvent::InsertHistoryCell(cell) => { - let cell: Arc = cell.into(); - app.transcript_cells.push(cell); - } - AppEvent::ApplyThreadRollback { num_turns } => { - saw_rollback = true; - crate::app_backtrack::trim_transcript_cells_drop_last_n_user_turns( - &mut app.transcript_cells, - num_turns, - ); - } - _ => {} - } - } + assert_eq!(snapshot.session, Some(resumed_session.clone())); + assert_eq!(snapshot.turns, resumed_turns); - assert!(saw_rollback); - let user_messages: Vec = app - .transcript_cells - .iter() - .filter_map(|cell| { - cell.as_any() - .downcast_ref::() - .map(|cell| cell.message.clone()) - }) - .collect(); - assert_eq!(user_messages, vec!["first prompt".to_string()]); + let store = app + .thread_event_channels + .get(&thread_id) + .expect("thread channel") + .store + .lock() + .await; + let store_snapshot = store.snapshot(); + assert_eq!(store_snapshot.session, Some(resumed_session)); + assert_eq!(store_snapshot.turns, snapshot.turns); } #[tokio::test] @@ -7726,6 +9085,57 @@ guardian_approval = true assert_eq!(overlay_cell_count, app.transcript_cells.len()); } + #[tokio::test] + async fn thread_rollback_response_discards_queued_active_thread_events() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + let (tx, rx) = mpsc::channel(8); + app.active_thread_id = Some(thread_id); + app.active_thread_rx = Some(rx); + tx.send(ThreadBufferedEvent::Notification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "stale warning".to_string(), + details: None, + path: None, + range: None, + }), + )) + .await + .expect("event should queue"); + + app.handle_thread_rollback_response( + thread_id, + 1, + &ThreadRollbackResponse { + thread: Thread { + id: thread_id.to_string(), + preview: String::new(), + ephemeral: false, + model_provider: "openai".to_string(), + created_at: 0, + updated_at: 0, + status: codex_app_server_protocol::ThreadStatus::Idle, + path: None, + cwd: PathBuf::from("/tmp/project"), + cli_version: "0.0.0".to_string(), + source: SessionSource::Cli.into(), + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: Vec::new(), + }, + }, + ) + .await; + + let rx = app + .active_thread_rx + .as_mut() + .expect("active receiver should remain attached"); + assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty))); + } + #[tokio::test] async fn new_session_requests_shutdown_for_previous_conversation() { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui_app_server/src/app/btw.rs b/codex-rs/tui_app_server/src/app/btw.rs index 63fb1cd568..141d991caa 100644 --- a/codex-rs/tui_app_server/src/app/btw.rs +++ b/codex-rs/tui_app_server/src/app/btw.rs @@ -151,13 +151,11 @@ impl App { let channel = self.thread_event_channels.get(&parent_thread_id)?; let store = channel.store.lock().await; - match store.session_configured.as_ref().map(|event| &event.msg) { - Some(EventMsg::SessionConfigured(session)) => session - .thread_name - .clone() - .filter(|name| !name.trim().is_empty()), - _ => None, - } + store + .session + .as_ref() + .and_then(|session| session.thread_name.clone()) + .filter(|name| !name.trim().is_empty()) } pub(super) async fn select_agent_thread_and_discard_btw_chain( @@ -167,7 +165,7 @@ impl App { thread_id: ThreadId, ) -> Result<()> { let btw_threads_to_discard = self.btw_threads_to_discard_after_switch(thread_id); - self.select_agent_thread(tui, thread_id).await?; + self.select_agent_thread(tui, app_server, thread_id).await?; if self.active_thread_id == Some(thread_id) { for btw_thread_id in btw_threads_to_discard { self.discard_btw_thread(app_server, btw_thread_id).await; @@ -193,45 +191,16 @@ impl App { let mut fork_config = self.config.clone(); fork_config.ephemeral = true; - let fork_result = match app_server.fork_thread(fork_config, parent_thread_id).await { - Ok(forked) => Ok(forked), - Err(err) => { - if self.current_displayed_thread_id() != Some(parent_thread_id) { - self.chat_widget.add_error_message(format!( - "Failed to fork BTW thread from {parent_thread_id}: {err}" - )); - return Ok(AppRunControl::Continue); - } - let Some(parent_rollout_path) = - self.chat_widget.rollout_path().filter(|path| path.exists()) - else { - self.chat_widget.add_error_message( - "A thread must contain at least one turn before /btw can fork it." - .to_string(), - ); - return Ok(AppRunControl::Continue); - }; - let mut fork_config = self.config.clone(); - fork_config.ephemeral = true; - app_server - .fork_thread_from_path(fork_config, parent_thread_id, parent_rollout_path) - .await - } - }; - - match fork_result { + match app_server.fork_thread(fork_config, parent_thread_id).await { Ok(forked) => { - let child_thread_id = forked.session_configured.session_id; + let child_thread_id = forked.session.thread_id; let next_fork_banner_parent_label = self.fork_banner_parent_label(parent_thread_id).await; - self.enqueue_thread_event( - child_thread_id, - Event { - id: String::new(), - msg: EventMsg::SessionConfigured(forked.session_configured), - }, - ) - .await?; + let channel = self.ensure_thread_channel(child_thread_id); + { + let mut store = channel.store.lock().await; + store.set_session(forked.session, forked.turns); + } self.btw_threads.insert( child_thread_id, BtwThreadState { @@ -259,7 +228,7 @@ impl App { } Err(err) => { self.chat_widget.add_error_message(format!( - "Failed to start BTW thread from {parent_thread_id}: {err}" + "Failed to fork BTW thread from {parent_thread_id}: {err}" )); } } diff --git a/codex-rs/tui_app_server/src/app_server_session.rs b/codex-rs/tui_app_server/src/app_server_session.rs index 6d6cff85fc..ecdea588ee 100644 --- a/codex-rs/tui_app_server/src/app_server_session.rs +++ b/codex-rs/tui_app_server/src/app_server_session.rs @@ -1,6 +1,7 @@ use codex_app_server_client::AppServerClient; use codex_app_server_client::AppServerEvent; use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ClientRequest; @@ -42,10 +43,13 @@ use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadSetNameResponse; +use codex_app_server_protocol::ThreadShellCommandParams; +use codex_app_server_protocol::ThreadShellCommandResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; @@ -53,17 +57,9 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_core::config::Config; +use codex_core::message_history; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; -use codex_protocol::items::AgentMessageContent; -use codex_protocol::items::AgentMessageItem; -use codex_protocol::items::ContextCompactionItem; -use codex_protocol::items::ImageGenerationItem; -use codex_protocol::items::PlanItem; -use codex_protocol::items::ReasoningItem; -use codex_protocol::items::TurnItem; -use codex_protocol::items::UserMessageItem; -use codex_protocol::items::WebSearchItem; use codex_protocol::openai_models::ModelAvailabilityNux; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; @@ -73,19 +69,18 @@ use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::ConversationStartParams; use codex_protocol::protocol::ConversationTextParams; use codex_protocol::protocol::CreditsSnapshot; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; -use codex_protocol::protocol::SessionConfiguredEvent; +use codex_protocol::protocol::SessionNetworkProxyRuntime; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use std::collections::HashMap; use std::path::PathBuf; +use tracing::debug; use crate::bottom_pane::FeedbackAudience; use crate::status::StatusAccountDisplay; @@ -108,6 +103,25 @@ pub(crate) struct AppServerSession { next_request_id: i64, } +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ThreadSessionState { + pub(crate) thread_id: ThreadId, + pub(crate) forked_from_id: Option, + pub(crate) thread_name: Option, + pub(crate) model: String, + pub(crate) model_provider_id: String, + pub(crate) service_tier: Option, + pub(crate) approval_policy: AskForApproval, + pub(crate) approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, + pub(crate) sandbox_policy: SandboxPolicy, + pub(crate) cwd: PathBuf, + pub(crate) reasoning_effort: Option, + pub(crate) history_log_id: u64, + pub(crate) history_entry_count: u64, + pub(crate) network_proxy: Option, + pub(crate) rollout_path: Option, +} + #[derive(Clone, Copy)] enum ThreadParamsMode { Embedded, @@ -124,7 +138,8 @@ impl ThreadParamsMode { } pub(crate) struct AppServerStartedThread { - pub(crate) session_configured: SessionConfiguredEvent, + pub(crate) session: ThreadSessionState, + pub(crate) turns: Vec, } impl AppServerSession { @@ -164,16 +179,6 @@ impl AppServerSession { }) .await .wrap_err("model/list failed during TUI bootstrap")?; - let rate_limit_request_id = self.next_request_id(); - let rate_limits: GetAccountRateLimitsResponse = self - .client - .request_typed(ClientRequest::GetAccountRateLimits { - request_id: rate_limit_request_id, - params: None, - }) - .await - .wrap_err("account/rateLimits/read failed during TUI bootstrap")?; - let available_models = models .data .into_iter() @@ -238,6 +243,25 @@ impl AppServerSession { false, ), }; + let rate_limit_snapshots = if account.requires_openai_auth && has_chatgpt_account { + let rate_limit_request_id = self.next_request_id(); + match self + .client + .request_typed(ClientRequest::GetAccountRateLimits { + request_id: rate_limit_request_id, + params: None, + }) + .await + { + Ok(rate_limits) => app_server_rate_limit_snapshots_to_core(rate_limits), + Err(error) => { + debug!(error = ?error, "failed to fetch rate limits during TUI bootstrap"); + Vec::new() + } + } + } else { + Vec::new() + }; Ok(AppServerBootstrap { account_auth_mode, @@ -249,7 +273,7 @@ impl AppServerSession { feedback_audience, has_chatgpt_account, available_models, - rate_limit_snapshots: app_server_rate_limit_snapshots_to_core(rate_limits), + rate_limit_snapshots, }) } @@ -267,7 +291,7 @@ impl AppServerSession { }) .await .wrap_err("thread/start failed during TUI bootstrap")?; - started_thread_from_start_response(&response) + started_thread_from_start_response(response, config).await } pub(crate) async fn resume_thread( @@ -275,21 +299,20 @@ impl AppServerSession { config: Config, thread_id: ThreadId, ) -> Result { - let show_raw_agent_reasoning = config.show_raw_agent_reasoning; let request_id = self.next_request_id(); let response: ThreadResumeResponse = self .client .request_typed(ClientRequest::ThreadResume { request_id, params: thread_resume_params_from_config( - config, + config.clone(), thread_id, self.thread_params_mode(), ), }) .await .wrap_err("thread/resume failed during TUI bootstrap")?; - started_thread_from_resume_response(&response, show_raw_agent_reasoning) + started_thread_from_resume_response(response, &config).await } pub(crate) async fn fork_thread( @@ -297,37 +320,20 @@ impl AppServerSession { config: Config, thread_id: ThreadId, ) -> Result { - self.fork_thread_with_path(config, thread_id, /*path*/ None) - .await - } - - pub(crate) async fn fork_thread_from_path( - &mut self, - config: Config, - thread_id: ThreadId, - path: PathBuf, - ) -> Result { - self.fork_thread_with_path(config, thread_id, Some(path)) - .await - } - - async fn fork_thread_with_path( - &mut self, - config: Config, - thread_id: ThreadId, - path: Option, - ) -> Result { - let show_raw_agent_reasoning = config.show_raw_agent_reasoning; let request_id = self.next_request_id(); - let mut params = - thread_fork_params_from_config(config, thread_id, self.thread_params_mode()); - params.path = path; let response: ThreadForkResponse = self .client - .request_typed(ClientRequest::ThreadFork { request_id, params }) + .request_typed(ClientRequest::ThreadFork { + request_id, + params: thread_fork_params_from_config( + config.clone(), + thread_id, + self.thread_params_mode(), + ), + }) .await .wrap_err("thread/fork failed during TUI bootstrap")?; - started_thread_from_fork_response(&response, show_raw_agent_reasoning) + started_thread_from_fork_response(response, &config).await } fn thread_params_mode(&self) -> ThreadParamsMode { @@ -434,7 +440,7 @@ impl AppServerSession { thread_id: ThreadId, turn_id: String, items: Vec, - ) -> Result { + ) -> std::result::Result { let request_id = self.next_request_id(); self.client .request_typed(ClientRequest::TurnSteer { @@ -446,7 +452,6 @@ impl AppServerSession { }, }) .await - .wrap_err("turn/steer failed in app-server TUI") } pub(crate) async fn thread_set_name( @@ -499,6 +504,26 @@ impl AppServerSession { Ok(()) } + pub(crate) async fn thread_shell_command( + &mut self, + thread_id: ThreadId, + command: String, + ) -> Result<()> { + let request_id = self.next_request_id(); + let _: ThreadShellCommandResponse = self + .client + .request_typed(ClientRequest::ThreadShellCommand { + request_id, + params: ThreadShellCommandParams { + thread_id: thread_id.to_string(), + command, + }, + }) + .await + .wrap_err("thread/shellCommand failed in app-server TUI")?; + Ok(()) + } + pub(crate) async fn thread_background_terminals_clean( &mut self, thread_id: ThreadId, @@ -851,54 +876,50 @@ fn thread_cwd_from_config(config: &Config, thread_params_mode: ThreadParamsMode) } } -fn started_thread_from_start_response( - response: &ThreadStartResponse, +async fn started_thread_from_start_response( + response: ThreadStartResponse, + config: &Config, ) -> Result { - let session_configured = session_configured_from_thread_start_response(response) - .map_err(color_eyre::eyre::Report::msg)?; - Ok(AppServerStartedThread { session_configured }) -} - -fn started_thread_from_resume_response( - response: &ThreadResumeResponse, - show_raw_agent_reasoning: bool, -) -> Result { - let session_configured = session_configured_from_thread_resume_response(response) + let session = thread_session_state_from_thread_start_response(&response, config) + .await .map_err(color_eyre::eyre::Report::msg)?; Ok(AppServerStartedThread { - session_configured: SessionConfiguredEvent { - initial_messages: thread_initial_messages( - &session_configured.session_id, - &response.thread.turns, - show_raw_agent_reasoning, - ), - ..session_configured - }, + session, + turns: response.thread.turns, }) } -fn started_thread_from_fork_response( - response: &ThreadForkResponse, - show_raw_agent_reasoning: bool, +async fn started_thread_from_resume_response( + response: ThreadResumeResponse, + config: &Config, ) -> Result { - let session_configured = session_configured_from_thread_fork_response(response) + let session = thread_session_state_from_thread_resume_response(&response, config) + .await .map_err(color_eyre::eyre::Report::msg)?; Ok(AppServerStartedThread { - session_configured: SessionConfiguredEvent { - initial_messages: thread_initial_messages( - &session_configured.session_id, - &response.thread.turns, - show_raw_agent_reasoning, - ), - ..session_configured - }, + session, + turns: response.thread.turns, }) } -fn session_configured_from_thread_start_response( +async fn started_thread_from_fork_response( + response: ThreadForkResponse, + config: &Config, +) -> Result { + let session = thread_session_state_from_thread_fork_response(&response, config) + .await + .map_err(color_eyre::eyre::Report::msg)?; + Ok(AppServerStartedThread { + session, + turns: response.thread.turns, + }) +} + +async fn thread_session_state_from_thread_start_response( response: &ThreadStartResponse, -) -> Result { - session_configured_from_thread_response( + config: &Config, +) -> Result { + thread_session_state_from_thread_response( &response.thread.id, response.thread.name.clone(), response.thread.path.clone(), @@ -910,13 +931,16 @@ fn session_configured_from_thread_start_response( response.sandbox.to_core(), response.cwd.clone(), response.reasoning_effort, + config, ) + .await } -fn session_configured_from_thread_resume_response( +async fn thread_session_state_from_thread_resume_response( response: &ThreadResumeResponse, -) -> Result { - session_configured_from_thread_response( + config: &Config, +) -> Result { + thread_session_state_from_thread_response( &response.thread.id, response.thread.name.clone(), response.thread.path.clone(), @@ -928,13 +952,16 @@ fn session_configured_from_thread_resume_response( response.sandbox.to_core(), response.cwd.clone(), response.reasoning_effort, + config, ) + .await } -fn session_configured_from_thread_fork_response( +async fn thread_session_state_from_thread_fork_response( response: &ThreadForkResponse, -) -> Result { - session_configured_from_thread_response( + config: &Config, +) -> Result { + thread_session_state_from_thread_response( &response.thread.id, response.thread.name.clone(), response.thread.path.clone(), @@ -946,7 +973,9 @@ fn session_configured_from_thread_fork_response( response.sandbox.to_core(), response.cwd.clone(), response.reasoning_effort, + config, ) + .await } fn review_target_to_app_server( @@ -972,7 +1001,7 @@ fn review_target_to_app_server( clippy::too_many_arguments, reason = "session mapping keeps explicit fields" )] -fn session_configured_from_thread_response( +async fn thread_session_state_from_thread_response( thread_id: &str, thread_name: Option, rollout_path: Option, @@ -984,12 +1013,15 @@ fn session_configured_from_thread_response( sandbox_policy: SandboxPolicy, cwd: PathBuf, reasoning_effort: Option, -) -> Result { - let session_id = ThreadId::from_string(thread_id) + config: &Config, +) -> Result { + let thread_id = ThreadId::from_string(thread_id) .map_err(|err| format!("thread id `{thread_id}` is invalid: {err}"))?; + let (history_log_id, history_entry_count) = message_history::history_metadata(config).await; + let history_entry_count = u64::try_from(history_entry_count).unwrap_or(u64::MAX); - Ok(SessionConfiguredEvent { - session_id, + Ok(ThreadSessionState { + thread_id, forked_from_id: None, thread_name, model, @@ -1000,129 +1032,13 @@ fn session_configured_from_thread_response( sandbox_policy, cwd, reasoning_effort, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, + history_log_id, + history_entry_count, network_proxy: None, rollout_path, }) } -fn thread_initial_messages( - thread_id: &ThreadId, - turns: &[codex_app_server_protocol::Turn], - show_raw_agent_reasoning: bool, -) -> Option> { - let events: Vec = turns - .iter() - .flat_map(|turn| turn_initial_messages(thread_id, turn, show_raw_agent_reasoning)) - .collect(); - (!events.is_empty()).then_some(events) -} - -fn turn_initial_messages( - thread_id: &ThreadId, - turn: &codex_app_server_protocol::Turn, - show_raw_agent_reasoning: bool, -) -> Vec { - turn.items - .iter() - .cloned() - .filter_map(app_server_thread_item_to_core) - .flat_map(|item| match item { - TurnItem::UserMessage(item) => vec![item.as_legacy_event()], - TurnItem::Plan(item) => vec![EventMsg::ItemCompleted(ItemCompletedEvent { - thread_id: *thread_id, - turn_id: turn.id.clone(), - item: TurnItem::Plan(item), - })], - item => item.as_legacy_events(show_raw_agent_reasoning), - }) - .collect() -} - -fn app_server_thread_item_to_core(item: codex_app_server_protocol::ThreadItem) -> Option { - match item { - codex_app_server_protocol::ThreadItem::UserMessage { id, content } => { - Some(TurnItem::UserMessage(UserMessageItem { - id, - content: content - .into_iter() - .map(codex_app_server_protocol::UserInput::into_core) - .collect(), - })) - } - codex_app_server_protocol::ThreadItem::AgentMessage { id, text, phase } => { - Some(TurnItem::AgentMessage(AgentMessageItem { - id, - content: vec![AgentMessageContent::Text { text }], - phase, - })) - } - codex_app_server_protocol::ThreadItem::Plan { id, text } => { - Some(TurnItem::Plan(PlanItem { id, text })) - } - codex_app_server_protocol::ThreadItem::Reasoning { - id, - summary, - content, - } => Some(TurnItem::Reasoning(ReasoningItem { - id, - summary_text: summary, - raw_content: content, - })), - codex_app_server_protocol::ThreadItem::WebSearch { id, query, action } => { - Some(TurnItem::WebSearch(WebSearchItem { - id, - query, - action: app_server_web_search_action_to_core(action?)?, - })) - } - codex_app_server_protocol::ThreadItem::ImageGeneration { - id, - status, - revised_prompt, - result, - } => Some(TurnItem::ImageGeneration(ImageGenerationItem { - id, - status, - revised_prompt, - result, - saved_path: None, - })), - codex_app_server_protocol::ThreadItem::ContextCompaction { id } => { - Some(TurnItem::ContextCompaction(ContextCompactionItem { id })) - } - codex_app_server_protocol::ThreadItem::CommandExecution { .. } - | codex_app_server_protocol::ThreadItem::FileChange { .. } - | codex_app_server_protocol::ThreadItem::McpToolCall { .. } - | codex_app_server_protocol::ThreadItem::DynamicToolCall { .. } - | codex_app_server_protocol::ThreadItem::CollabAgentToolCall { .. } - | codex_app_server_protocol::ThreadItem::ImageView { .. } - | codex_app_server_protocol::ThreadItem::EnteredReviewMode { .. } - | codex_app_server_protocol::ThreadItem::ExitedReviewMode { .. } => None, - } -} - -fn app_server_web_search_action_to_core( - action: codex_app_server_protocol::WebSearchAction, -) -> Option { - match action { - codex_app_server_protocol::WebSearchAction::Search { query, queries } => { - Some(codex_protocol::models::WebSearchAction::Search { query, queries }) - } - codex_app_server_protocol::WebSearchAction::OpenPage { url } => { - Some(codex_protocol::models::WebSearchAction::OpenPage { url }) - } - codex_app_server_protocol::WebSearchAction::FindInPage { url, pattern } => { - Some(codex_protocol::models::WebSearchAction::FindInPage { url, pattern }) - } - codex_app_server_protocol::WebSearchAction::Other => { - Some(codex_protocol::models::WebSearchAction::Other) - } - } -} - fn app_server_rate_limit_snapshots_to_core( response: GetAccountRateLimitsResponse, ) -> Vec { @@ -1219,8 +1135,10 @@ mod tests { assert_eq!(fork.model_provider, None); } - #[test] - fn resume_response_restores_initial_messages_from_turn_items() { + #[tokio::test] + async fn resume_response_restores_turns_from_thread_items() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config = build_config(&temp_dir).await; let thread_id = ThreadId::new(); let response = ThreadResumeResponse { thread: codex_app_server_protocol::Thread { @@ -1253,6 +1171,7 @@ mod tests { id: "assistant-1".to_string(), text: "assistant reply".to_string(), phase: None, + memory_citation: None, }, ], status: TurnStatus::Completed, @@ -1269,30 +1188,44 @@ mod tests { reasoning_effort: None, }; - let started = - started_thread_from_resume_response(&response, /*show_raw_agent_reasoning*/ false) - .expect("resume response should map"); - let initial_messages = started - .session_configured - .initial_messages - .expect("resume response should restore replay history"); + let started = started_thread_from_resume_response(response.clone(), &config) + .await + .expect("resume response should map"); + assert_eq!(started.turns.len(), 1); + assert_eq!(started.turns[0], response.thread.turns[0]); + } - assert_eq!(initial_messages.len(), 2); - match &initial_messages[0] { - EventMsg::UserMessage(event) => { - assert_eq!(event.message, "hello from history"); - assert_eq!(event.images.as_ref(), Some(&Vec::new())); - assert!(event.local_images.is_empty()); - assert!(event.text_elements.is_empty()); - } - other => panic!("expected replayed user message, got {other:?}"), - } - match &initial_messages[1] { - EventMsg::AgentMessage(event) => { - assert_eq!(event.message, "assistant reply"); - assert_eq!(event.phase, None); - } - other => panic!("expected replayed agent message, got {other:?}"), - } + #[tokio::test] + async fn session_configured_populates_history_metadata() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config = build_config(&temp_dir).await; + let thread_id = ThreadId::new(); + + message_history::append_entry("older", &thread_id, &config) + .await + .expect("history append should succeed"); + message_history::append_entry("newer", &thread_id, &config) + .await + .expect("history append should succeed"); + + let session = thread_session_state_from_thread_response( + &thread_id.to_string(), + Some("restore".to_string()), + None, + "gpt-5.4".to_string(), + "openai".to_string(), + None, + AskForApproval::Never, + codex_protocol::config_types::ApprovalsReviewer::User, + SandboxPolicy::new_read_only_policy(), + PathBuf::from("/tmp/project"), + None, + &config, + ) + .await + .expect("session should map"); + + assert_ne!(session.history_log_id, 0); + assert_eq!(session.history_entry_count, 2); } } diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index b3edb693b8..fdd22a7d71 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -34,17 +34,24 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; +use std::time::Duration; use std::time::Instant; +use url::Url; + use self::realtime::PendingSteerCompareKey; use crate::app_command::AppCommand; use crate::app_event::RealtimeAudioDeviceKind; -#[cfg(all(not(target_os = "linux"), feature = "voice-input"))] +use crate::app_server_session::ThreadSessionState; +#[cfg(not(target_os = "linux"))] use crate::audio_device::list_realtime_audio_device_names; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::StatusLinePreviewData; use crate::bottom_pane::StatusLineSetupView; +use crate::mention_codec::LinkedMention; +use crate::mention_codec::encode_history_mentions; use crate::model_catalog::ModelCatalog; +use crate::multi_agents; use crate::status::RateLimitWindowDisplay; use crate::status::StatusAccountDisplay; use crate::status::format_directory_display; @@ -52,7 +59,27 @@ use crate::status::format_tokens_compact; use crate::status::rate_limit_snapshot_display_for_limit; use crate::text_formatting::proper_join; use crate::version::CODEX_CLI_VERSION; +use codex_app_server_protocol::AppSummary; +use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CollabAgentState as AppServerCollabAgentState; +use codex_app_server_protocol::CollabAgentStatus as AppServerCollabAgentStatus; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::ConfigLayerSource; +use codex_app_server_protocol::ErrorNotification; +use codex_app_server_protocol::FileChangeRequestApprovalParams; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ToolRequestUserInputParams; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnPlanStepStatus; +use codex_app_server_protocol::TurnStatus; use codex_chatgpt::connectors; use codex_core::config::Config; use codex_core::config::Constrained; @@ -61,19 +88,19 @@ use codex_core::config::types::ApprovalsReviewer; use codex_core::config::types::Notifications; use codex_core::config::types::WindowsSandboxModeToml; use codex_core::config_loader::ConfigLayerStackOrdering; -use codex_core::features::FEATURES; -use codex_core::features::Feature; -use codex_core::git_info::current_branch_name; -use codex_core::git_info::get_git_repo_root; -use codex_core::git_info::local_git_branches; -use codex_core::mcp::McpManager; use codex_core::plugins::PluginsManager; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use codex_core::skills::model::SkillMetadata; -use codex_core::terminal::TerminalName; -use codex_core::terminal::terminal_info; #[cfg(target_os = "windows")] use codex_core::windows_sandbox::WindowsSandboxLevelExt; +use codex_features::FEATURES; +use codex_features::Feature; +#[cfg(test)] +use codex_git_utils::CommitLogEntry; +use codex_git_utils::current_branch_name; +use codex_git_utils::get_git_repo_root; +use codex_git_utils::local_git_branches; +use codex_git_utils::recent_commits; use codex_otel::RuntimeMetricsSummary; use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; @@ -92,35 +119,55 @@ use codex_protocol::items::AgentMessageItem; use codex_protocol::models::MessagePhase; use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::plan_tool::PlanItemArg as UpdatePlanItemArg; +use codex_protocol::plan_tool::StepStatus as UpdatePlanItemStatus; +#[cfg(test)] use codex_protocol::protocol::AgentMessageDeltaEvent; +#[cfg(test)] use codex_protocol::protocol::AgentMessageEvent; +#[cfg(test)] use codex_protocol::protocol::AgentReasoningDeltaEvent; +#[cfg(test)] use codex_protocol::protocol::AgentReasoningEvent; +#[cfg(test)] use codex_protocol::protocol::AgentReasoningRawContentDeltaEvent; +#[cfg(test)] use codex_protocol::protocol::AgentReasoningRawContentEvent; +use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; +#[cfg(test)] use codex_protocol::protocol::BackgroundEventEvent; -use codex_protocol::protocol::CodexErrorInfo; +#[cfg(test)] +use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; +#[cfg(test)] use codex_protocol::protocol::CollabAgentSpawnBeginEvent; +use codex_protocol::protocol::CollabAgentStatusEntry; use codex_protocol::protocol::CreditsSnapshot; use codex_protocol::protocol::DeprecationNoticeEvent; +#[cfg(test)] use codex_protocol::protocol::ErrorEvent; +#[cfg(test)] use codex_protocol::protocol::Event; +#[cfg(test)] use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::ExecCommandBeginEvent; use codex_protocol::protocol::ExecCommandEndEvent; use codex_protocol::protocol::ExecCommandOutputDeltaEvent; use codex_protocol::protocol::ExecCommandSource; +#[cfg(test)] use codex_protocol::protocol::ExitedReviewModeEvent; use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::ImageGenerationBeginEvent; use codex_protocol::protocol::ImageGenerationEndEvent; use codex_protocol::protocol::ListSkillsResponseEvent; +#[cfg(test)] use codex_protocol::protocol::McpListToolsResponseEvent; +#[cfg(test)] use codex_protocol::protocol::McpStartupCompleteEvent; use codex_protocol::protocol::McpStartupStatus; +#[cfg(test)] use codex_protocol::protocol::McpStartupUpdateEvent; use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; @@ -130,24 +177,35 @@ use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; +#[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::TurnAbortReason; +#[cfg(test)] use codex_protocol::protocol::TurnCompleteEvent; +#[cfg(test)] use codex_protocol::protocol::TurnDiffEvent; +#[cfg(test)] use codex_protocol::protocol::UndoCompletedEvent; +#[cfg(test)] use codex_protocol::protocol::UndoStartedEvent; use codex_protocol::protocol::UserMessageEvent; use codex_protocol::protocol::ViewImageToolCallEvent; +#[cfg(test)] use codex_protocol::protocol::WarningEvent; use codex_protocol::protocol::WebSearchBeginEvent; use codex_protocol::protocol::WebSearchEndEvent; use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::request_user_input::RequestUserInputEvent; +use codex_protocol::request_user_input::RequestUserInputQuestionOption; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; +use codex_terminal_detection::TerminalName; +use codex_terminal_detection::terminal_info; use codex_utils_sleep_inhibitor::SleepInhibitor; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -185,14 +243,21 @@ const APP_SERVER_TUI_STUB_MESSAGE: &str = "Not available in app-server TUI yet." /// Choose the keybinding used to edit the most-recently queued message. /// /// Apple Terminal, Warp, and VSCode integrated terminals intercept or silently -/// swallow Alt+Up, so users in those environments would never be able to trigger -/// the edit action. We fall back to Shift+Left for those terminals while -/// keeping the more discoverable Alt+Up everywhere else. +/// swallow Alt+Up, and tmux does not reliably pass that chord through. We fall +/// back to Shift+Left for those environments while keeping the more discoverable +/// Alt+Up everywhere else. /// /// The match is exhaustive so that adding a new `TerminalName` variant forces /// an explicit decision about which binding that terminal should use. -fn queued_message_edit_binding_for_terminal(terminal_name: TerminalName) -> KeyBinding { - match terminal_name { +fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyBinding { + if matches!( + terminal_info.multiplexer.as_ref(), + Some(Multiplexer::Tmux { .. }) + ) { + return key_hint::shift(KeyCode::Left); + } + + match terminal_info.name { TerminalName::AppleTerminal | TerminalName::WarpTerminal | TerminalName::VsCode => { key_hint::shift(KeyCode::Left) } @@ -246,6 +311,7 @@ use crate::exec_cell::new_active_exec_command; use crate::exec_command::strip_bash_lc_and_escape; use crate::get_git_diff::get_git_diff; use crate::history_cell; +#[cfg(test)] use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; use crate::history_cell::McpToolCallCell; @@ -253,8 +319,8 @@ use crate::history_cell::PlainHistoryCell; use crate::history_cell::WebSearchCell; use crate::key_hint; use crate::key_hint::KeyBinding; +#[cfg(test)] use crate::markdown::append_markdown; -use crate::multi_agents; use crate::render::Insets; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::FlexRenderable; @@ -269,14 +335,14 @@ use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; mod interrupts; use self::interrupts::InterruptManager; -mod agent; -use self::agent::spawn_agent_from_existing; mod session_header; use self::session_header::SessionHeader; mod skills; use self::skills::collect_tool_mentions; use self::skills::find_app_mentions; use self::skills::find_skill_mentions_with_tool_mentions; +mod plugins; +use self::plugins::PluginsCacheState; mod realtime; use self::realtime::RealtimeConversationUiState; use self::realtime::RenderedUserMessageEvent; @@ -497,6 +563,18 @@ enum ConnectorsCacheState { Failed(String), } +#[derive(Debug, Clone, Default)] +struct PluginListFetchState { + cache_cwd: Option, + in_flight_cwd: Option, +} + +#[derive(Debug, Clone)] +struct PluginInstallAuthFlowState { + plugin_display_name: String, + next_app_index: usize, +} + #[derive(Debug)] enum RateLimitErrorKind { ServerOverloaded, @@ -504,17 +582,23 @@ enum RateLimitErrorKind { Generic, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ShellEscapePolicy { - Allow, - Disallow, +#[cfg(test)] +fn core_rate_limit_error_kind(info: &CoreCodexErrorInfo) -> Option { + match info { + CoreCodexErrorInfo::ServerOverloaded => Some(RateLimitErrorKind::ServerOverloaded), + CoreCodexErrorInfo::UsageLimitExceeded => Some(RateLimitErrorKind::UsageLimit), + CoreCodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: Some(429), + } => Some(RateLimitErrorKind::Generic), + _ => None, + } } -fn rate_limit_error_kind(info: &CodexErrorInfo) -> Option { +fn app_server_rate_limit_error_kind(info: &AppServerCodexErrorInfo) -> Option { match info { - CodexErrorInfo::ServerOverloaded => Some(RateLimitErrorKind::ServerOverloaded), - CodexErrorInfo::UsageLimitExceeded => Some(RateLimitErrorKind::UsageLimit), - CodexErrorInfo::ResponseTooManyFailedAttempts { + AppServerCodexErrorInfo::ServerOverloaded => Some(RateLimitErrorKind::ServerOverloaded), + AppServerCodexErrorInfo::UsageLimitExceeded => Some(RateLimitErrorKind::UsageLimit), + AppServerCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code: Some(429), } => Some(RateLimitErrorKind::Generic), _ => None, @@ -695,6 +779,10 @@ pub(crate) struct ChatWidget { connectors_partial_snapshot: Option, connectors_prefetch_in_flight: bool, connectors_force_refetch_pending: bool, + plugins_cache: PluginsCacheState, + plugins_fetch_state: PluginListFetchState, + plugin_install_apps_needing_auth: Vec, + plugin_install_auth_flow: Option, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, // Accumulates the current reasoning block text to extract a header @@ -717,6 +805,7 @@ pub(crate) struct ChatWidget { thread_name: Option, thread_rename_block_message: Option, forked_from: Option, + /// Pretty parent label used only for the next fork banner inserted on session configure. next_fork_banner_parent_label: Option, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured @@ -726,8 +815,14 @@ pub(crate) struct ChatWidget { // When resuming an existing session (selected via resume picker), avoid an // immediate redraw on SessionConfigured to prevent a gratuitous UI flicker. suppress_session_configured_redraw: bool, + // During snapshot restore, defer startup prompt submission until replayed + // history has been rendered so resumed/forked prompts keep chronological + // order. + suppress_initial_user_message_submit: bool, // User messages queued while a turn is in progress queued_user_messages: VecDeque, + // User messages that tried to steer a non-regular turn and must be retried first. + rejected_steers_queue: VecDeque, // Steers already submitted to core but not yet committed into history. // // The bottom pane shows these above queued drafts until core records the @@ -753,6 +848,7 @@ pub(crate) struct ChatWidget { quit_shortcut_key: Option, // Simple review mode flag; used to adjust layout and banners. is_review_mode: bool, + #[cfg(test)] // Snapshot of token usage to restore after review mode exits. pre_review_token_info: Option>, // Whether the next streamed assistant content should be preceded by a final message separator. @@ -806,6 +902,7 @@ pub(crate) struct ChatWidget { external_editor_state: ExternalEditorState, realtime_conversation: RealtimeConversationUiState, last_rendered_user_message_event: Option, + last_non_retry_error: Option<(String, String)>, } #[cfg_attr(not(test), allow(dead_code))] @@ -852,6 +949,12 @@ pub(crate) struct UserMessage { mention_bindings: Vec, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ShellEscapePolicy { + Allow, + Disallow, +} + #[derive(Debug, Clone, PartialEq, Default)] struct ThreadComposerState { text: String, @@ -877,9 +980,11 @@ impl ThreadComposerState { pub(crate) struct ThreadInputState { composer: Option, pending_steers: VecDeque, + rejected_steers_queue: VecDeque, queued_user_messages: VecDeque, current_collaboration_mode: CollaborationMode, active_collaboration_mask: Option, + task_running: bool, agent_turn_running: bool, } @@ -1074,11 +1179,322 @@ fn merge_user_messages(messages: Vec) -> UserMessage { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ReplayKind { +pub(crate) enum ReplayKind { ResumeInitialMessages, ThreadSnapshot, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ThreadItemRenderSource { + Live, + Replay(ReplayKind), +} + +impl ThreadItemRenderSource { + fn is_replay(self) -> bool { + matches!(self, Self::Replay(_)) + } + + fn replay_kind(self) -> Option { + match self { + Self::Live => None, + Self::Replay(replay_kind) => Some(replay_kind), + } + } +} + +fn thread_session_state_to_legacy_event( + session: ThreadSessionState, +) -> codex_protocol::protocol::SessionConfiguredEvent { + codex_protocol::protocol::SessionConfiguredEvent { + session_id: session.thread_id, + forked_from_id: session.forked_from_id, + thread_name: session.thread_name, + model: session.model, + model_provider_id: session.model_provider_id, + service_tier: session.service_tier, + approval_policy: session.approval_policy, + approvals_reviewer: session.approvals_reviewer, + sandbox_policy: session.sandbox_policy, + cwd: session.cwd, + reasoning_effort: session.reasoning_effort, + history_log_id: session.history_log_id, + history_entry_count: usize::try_from(session.history_entry_count).unwrap_or(usize::MAX), + initial_messages: None, + network_proxy: session.network_proxy, + rollout_path: session.rollout_path, + } +} + +fn convert_via_json(value: T) -> Option +where + T: serde::Serialize, + U: serde::de::DeserializeOwned, +{ + serde_json::to_value(value) + .ok() + .and_then(|value| serde_json::from_value(value).ok()) +} + +fn app_server_request_id_to_mcp_request_id( + request_id: &codex_app_server_protocol::RequestId, +) -> codex_protocol::mcp::RequestId { + match request_id { + codex_app_server_protocol::RequestId::String(value) => { + codex_protocol::mcp::RequestId::String(value.clone()) + } + codex_app_server_protocol::RequestId::Integer(value) => { + codex_protocol::mcp::RequestId::Integer(*value) + } + } +} + +fn exec_approval_request_from_params( + params: CommandExecutionRequestApprovalParams, +) -> ExecApprovalRequestEvent { + ExecApprovalRequestEvent { + call_id: params.item_id, + command: params.command.into_iter().collect(), + cwd: params.cwd.unwrap_or_default(), + reason: params.reason, + network_approval_context: params + .network_approval_context + .and_then(convert_via_json), + additional_permissions: params.additional_permissions.and_then(convert_via_json), + turn_id: params.turn_id, + approval_id: params.approval_id, + proposed_execpolicy_amendment: params + .proposed_execpolicy_amendment + .map(codex_app_server_protocol::ExecPolicyAmendment::into_core), + proposed_network_policy_amendments: params.proposed_network_policy_amendments.map( + |amendments| { + amendments + .into_iter() + .map(codex_app_server_protocol::NetworkPolicyAmendment::into_core) + .collect() + }, + ), + skill_metadata: params.skill_metadata.map(|metadata| { + codex_protocol::approvals::ExecApprovalRequestSkillMetadata { + path_to_skills_md: metadata.path_to_skills_md, + } + }), + available_decisions: params.available_decisions.map(|decisions| { + decisions + .into_iter() + .map(|decision| match decision { + codex_app_server_protocol::CommandExecutionApprovalDecision::Accept => { + codex_protocol::protocol::ReviewDecision::Approved + } + codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptForSession => { + codex_protocol::protocol::ReviewDecision::ApprovedForSession + } + codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment, + } => codex_protocol::protocol::ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: execpolicy_amendment.into_core(), + }, + codex_app_server_protocol::CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { + network_policy_amendment, + } => codex_protocol::protocol::ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment: network_policy_amendment.into_core(), + }, + codex_app_server_protocol::CommandExecutionApprovalDecision::Decline => { + codex_protocol::protocol::ReviewDecision::Denied + } + codex_app_server_protocol::CommandExecutionApprovalDecision::Cancel => { + codex_protocol::protocol::ReviewDecision::Abort + } + }) + .collect() + }), + parsed_cmd: params + .command_actions + .unwrap_or_default() + .into_iter() + .map(codex_app_server_protocol::CommandAction::into_core) + .collect(), + } +} + +fn patch_approval_request_from_params( + params: FileChangeRequestApprovalParams, +) -> ApplyPatchApprovalRequestEvent { + ApplyPatchApprovalRequestEvent { + call_id: params.item_id, + turn_id: params.turn_id, + changes: HashMap::new(), + reason: params.reason, + grant_root: params.grant_root, + } +} + +fn app_server_patch_changes_to_core( + changes: Vec, +) -> HashMap { + changes + .into_iter() + .map(|change| { + let path = PathBuf::from(change.path); + let file_change = match change.kind { + codex_app_server_protocol::PatchChangeKind::Add => { + codex_protocol::protocol::FileChange::Add { + content: change.diff, + } + } + codex_app_server_protocol::PatchChangeKind::Delete => { + codex_protocol::protocol::FileChange::Delete { + content: change.diff, + } + } + codex_app_server_protocol::PatchChangeKind::Update { move_path } => { + codex_protocol::protocol::FileChange::Update { + unified_diff: change.diff, + move_path, + } + } + }; + (path, file_change) + }) + .collect() +} + +fn app_server_collab_thread_id_to_core(thread_id: &str) -> Option { + match ThreadId::from_string(thread_id) { + Ok(thread_id) => Some(thread_id), + Err(err) => { + warn!("ignoring collab tool-call item with invalid thread id {thread_id}: {err}"); + None + } + } +} + +fn app_server_collab_state_to_core(state: &AppServerCollabAgentState) -> AgentStatus { + match state.status { + AppServerCollabAgentStatus::PendingInit => AgentStatus::PendingInit, + AppServerCollabAgentStatus::Running => AgentStatus::Running, + AppServerCollabAgentStatus::Interrupted => AgentStatus::Interrupted, + AppServerCollabAgentStatus::Completed => AgentStatus::Completed(state.message.clone()), + AppServerCollabAgentStatus::Errored => AgentStatus::Errored( + state + .message + .clone() + .unwrap_or_else(|| "Agent errored".into()), + ), + AppServerCollabAgentStatus::Shutdown => AgentStatus::Shutdown, + AppServerCollabAgentStatus::NotFound => AgentStatus::NotFound, + } +} + +fn app_server_collab_agent_statuses_to_core( + receiver_thread_ids: &[String], + agents_states: &HashMap, +) -> (Vec, HashMap) { + let mut agent_statuses = Vec::new(); + let mut statuses = HashMap::new(); + + for receiver_thread_id in receiver_thread_ids { + let Some(thread_id) = app_server_collab_thread_id_to_core(receiver_thread_id) else { + continue; + }; + let Some(agent_state) = agents_states.get(receiver_thread_id) else { + continue; + }; + let status = app_server_collab_state_to_core(agent_state); + agent_statuses.push(CollabAgentStatusEntry { + thread_id, + agent_nickname: None, + agent_role: None, + status: status.clone(), + }); + statuses.insert(thread_id, status); + } + + (agent_statuses, statuses) +} + +fn request_permissions_from_params( + params: codex_app_server_protocol::PermissionsRequestApprovalParams, +) -> RequestPermissionsEvent { + RequestPermissionsEvent { + turn_id: params.turn_id, + call_id: params.item_id, + reason: params.reason, + permissions: serde_json::from_value( + serde_json::to_value(params.permissions).unwrap_or(serde_json::Value::Null), + ) + .unwrap_or_default(), + } +} + +fn request_user_input_from_params(params: ToolRequestUserInputParams) -> RequestUserInputEvent { + RequestUserInputEvent { + turn_id: params.turn_id, + call_id: params.item_id, + questions: params + .questions + .into_iter() + .map( + |question| codex_protocol::request_user_input::RequestUserInputQuestion { + id: question.id, + header: question.header, + question: question.question, + is_other: question.is_other, + is_secret: question.is_secret, + options: question.options.map(|options| { + options + .into_iter() + .map(|option| RequestUserInputQuestionOption { + label: option.label, + description: option.description, + }) + .collect() + }), + }, + ) + .collect(), + } +} + +fn token_usage_info_from_app_server(token_usage: ThreadTokenUsage) -> TokenUsageInfo { + TokenUsageInfo { + total_token_usage: TokenUsage { + total_tokens: token_usage.total.total_tokens, + input_tokens: token_usage.total.input_tokens, + cached_input_tokens: token_usage.total.cached_input_tokens, + output_tokens: token_usage.total.output_tokens, + reasoning_output_tokens: token_usage.total.reasoning_output_tokens, + }, + last_token_usage: TokenUsage { + total_tokens: token_usage.last.total_tokens, + input_tokens: token_usage.last.input_tokens, + cached_input_tokens: token_usage.last.cached_input_tokens, + output_tokens: token_usage.last.output_tokens, + reasoning_output_tokens: token_usage.last.reasoning_output_tokens, + }, + model_context_window: token_usage.model_context_window, + } +} + +fn web_search_action_to_core( + action: codex_app_server_protocol::WebSearchAction, +) -> codex_protocol::models::WebSearchAction { + match action { + codex_app_server_protocol::WebSearchAction::Search { query, queries } => { + codex_protocol::models::WebSearchAction::Search { query, queries } + } + codex_app_server_protocol::WebSearchAction::OpenPage { url } => { + codex_protocol::models::WebSearchAction::OpenPage { url } + } + codex_app_server_protocol::WebSearchAction::FindInPage { url, pattern } => { + codex_protocol::models::WebSearchAction::FindInPage { url, pattern } + } + codex_app_server_protocol::WebSearchAction::Other => { + codex_protocol::models::WebSearchAction::Other + } + } +} + impl ChatWidget { fn realtime_conversation_enabled(&self) -> bool { self.config.features.enabled(Feature::RealtimeConversation) @@ -1086,7 +1502,7 @@ impl ChatWidget { } fn realtime_audio_device_selection_enabled(&self) -> bool { - self.realtime_conversation_enabled() && cfg!(feature = "voice-input") + self.realtime_conversation_enabled() } /// Synchronize the bottom-pane "task running" indicator with the current lifecycles. @@ -1392,7 +1808,6 @@ impl ChatWidget { Constrained::allow_only(event.sandbox_policy.clone()); } self.config.approvals_reviewer = event.approvals_reviewer; - let initial_messages = event.initial_messages.clone(); self.last_copyable_output = None; let forked_from_id = event.forked_from_id; let model_for_header = event.model.clone(); @@ -1409,9 +1824,12 @@ impl ChatWidget { self.refresh_model_display(); self.sync_fast_command_enabled(); self.sync_personality_command_enabled(); + self.sync_plugins_command_enabled(); self.refresh_plugin_mentions(); let startup_tooltip_override = self.startup_tooltip_override.take(); let show_fast_status = self.should_show_fast_status(&model_for_header, event.service_tier); + #[cfg(test)] + let initial_messages = event.initial_messages.clone(); let session_info_cell = history_cell::new_session_info( &self.config, &model_for_header, @@ -1423,6 +1841,7 @@ impl ChatWidget { ); self.apply_session_info_cell(session_info_cell); + #[cfg(test)] if let Some(messages) = initial_messages { self.replay_initial_messages(messages); } @@ -1434,7 +1853,11 @@ impl ChatWidget { self.prefetch_connectors(); } if let Some(user_message) = self.initial_user_message.take() { - self.submit_user_message(user_message); + if self.suppress_initial_user_message_submit { + self.initial_user_message = Some(user_message); + } else { + self.submit_user_message(user_message); + } } if let Some(forked_from_id) = forked_from_id { self.emit_forked_thread_event(forked_from_id); @@ -1444,6 +1867,20 @@ impl ChatWidget { } } + pub(crate) fn set_initial_user_message_submit_suppressed(&mut self, suppressed: bool) { + self.suppress_initial_user_message_submit = suppressed; + } + + pub(crate) fn submit_initial_user_message_if_pending(&mut self) { + if let Some(user_message) = self.initial_user_message.take() { + self.submit_user_message(user_message); + } + } + + pub(crate) fn handle_thread_session(&mut self, session: ThreadSessionState) { + self.on_session_configured(thread_session_state_to_legacy_event(session)); + } + fn emit_forked_thread_event(&mut self, forked_from_id: ThreadId) { let line: Line<'static> = if let Some(label) = self.next_fork_banner_parent_label.take() { vec![ @@ -1739,7 +2176,7 @@ impl ChatWidget { let had_pending_steers = !self.pending_steers.is_empty(); self.refresh_pending_input_preview(); - if !from_replay && self.queued_user_messages.is_empty() && !had_pending_steers { + if !from_replay && !self.has_queued_follow_up_messages() && !had_pending_steers { self.maybe_prompt_plan_implementation(); } // Keep this flag for replayed completion events so a subsequent live TurnComplete can @@ -1761,7 +2198,7 @@ impl ChatWidget { if !self.collaboration_modes_enabled() { return; } - if !self.queued_user_messages.is_empty() { + if self.has_queued_follow_up_messages() { return; } if self.active_mode_kind() != ModeKind::Plan { @@ -1833,6 +2270,57 @@ impl ChatWidget { }); } + fn has_queued_follow_up_messages(&self) -> bool { + !self.rejected_steers_queue.is_empty() || !self.queued_user_messages.is_empty() + } + + fn pop_next_queued_user_message(&mut self) -> Option { + if self.rejected_steers_queue.is_empty() { + self.queued_user_messages.pop_front() + } else { + Some(merge_user_messages( + self.rejected_steers_queue.drain(..).collect(), + )) + } + } + + fn pop_latest_queued_user_message(&mut self) -> Option { + self.queued_user_messages + .pop_back() + .or_else(|| self.rejected_steers_queue.pop_back()) + } + + pub(crate) fn enqueue_rejected_steer(&mut self) -> bool { + let Some(pending_steer) = self.pending_steers.pop_front() else { + tracing::warn!( + "received active-turn-not-steerable error without a matching pending steer" + ); + return false; + }; + self.rejected_steers_queue + .push_back(pending_steer.user_message); + self.refresh_pending_input_preview(); + true + } + + #[cfg(test)] + fn handle_steer_rejected_error(&mut self, codex_error_info: &CoreCodexErrorInfo) -> bool { + matches!( + codex_error_info, + CoreCodexErrorInfo::ActiveTurnNotSteerable { .. } + ) && self.enqueue_rejected_steer() + } + + fn handle_app_server_steer_rejected_error( + &mut self, + codex_error_info: &AppServerCodexErrorInfo, + ) -> bool { + matches!( + codex_error_info, + AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. } + ) && self.enqueue_rejected_steer() + } + pub(crate) fn open_multi_agent_enable_prompt(&mut self) { let items = vec![ SelectionItem { @@ -1879,6 +2367,7 @@ impl ChatWidget { } } + #[cfg(test)] fn apply_turn_started_context_window(&mut self, model_context_window: Option) { let info = match self.token_info.take() { Some(mut info) => { @@ -1922,6 +2411,7 @@ impl ChatWidget { Some(info.total_token_usage.tokens_in_context_window()) } + #[cfg(test)] fn restore_pre_review_token_info(&mut self) { if let Some(saved) = self.pre_review_token_info.take() { match saved { @@ -1992,7 +2482,14 @@ impl ChatWidget { .map(|w| 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!( @@ -2068,11 +2565,36 @@ impl ChatWidget { self.maybe_send_next_queued_input(); } + fn handle_non_retry_error( + &mut self, + message: String, + codex_error_info: Option, + ) { + if codex_error_info + .as_ref() + .is_some_and(|info| self.handle_app_server_steer_rejected_error(info)) + { + } else if let Some(info) = codex_error_info + .as_ref() + .and_then(app_server_rate_limit_error_kind) + { + match info { + RateLimitErrorKind::ServerOverloaded => self.on_server_overloaded_error(message), + RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => { + self.on_error(message) + } + } + } else { + self.on_error(message); + } + } + fn on_warning(&mut self, message: impl Into) { self.add_to_history(history_cell::new_warning_event(message.into())); self.request_redraw(); } + #[cfg(test)] fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) { let mut status = self.mcp_startup_status.take().unwrap_or_default(); if let McpStartupStatus::Failed { error } = &ev.status { @@ -2119,6 +2641,7 @@ impl ChatWidget { self.request_redraw(); } + #[cfg(test)] fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) { let mut parts = Vec::new(); if !ev.failed.is_empty() { @@ -2191,7 +2714,7 @@ impl ChatWidget { /// state stays aligned with the merged attachment list. Returns `None` when there is nothing to /// restore. fn drain_pending_messages_for_restore(&mut self) -> Option { - if self.pending_steers.is_empty() && self.queued_user_messages.is_empty() { + if self.pending_steers.is_empty() && !self.has_queued_follow_up_messages() { return None; } @@ -2203,11 +2726,12 @@ impl ChatWidget { mention_bindings: self.bottom_pane.composer_mention_bindings(), }; - let mut to_merge: Vec = self - .pending_steers - .drain(..) - .map(|steer| steer.user_message) - .collect(); + let mut to_merge: Vec = self.rejected_steers_queue.drain(..).collect(); + to_merge.extend( + self.pending_steers + .drain(..) + .map(|steer| steer.user_message), + ); to_merge.extend(self.queued_user_messages.drain(..)); if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() @@ -2253,14 +2777,17 @@ impl ChatWidget { .iter() .map(|pending| pending.user_message.clone()) .collect(), + rejected_steers_queue: self.rejected_steers_queue.clone(), queued_user_messages: self.queued_user_messages.clone(), current_collaboration_mode: self.current_collaboration_mode.clone(), active_collaboration_mask: self.active_collaboration_mask.clone(), + task_running: self.bottom_pane.is_task_running(), agent_turn_running: self.agent_turn_running, }) } pub(crate) fn restore_thread_input_state(&mut self, input_state: Option) { + let restored_task_running = input_state.as_ref().is_some_and(|state| state.task_running); if let Some(input_state) = input_state { self.current_collaboration_mode = input_state.current_collaboration_mode; self.active_collaboration_mask = input_state.active_collaboration_mask; @@ -2292,13 +2819,24 @@ impl ChatWidget { ); self.bottom_pane.set_composer_pending_pastes(Vec::new()); } - self.pending_steers.clear(); - self.queued_user_messages = input_state.pending_steers; - self.queued_user_messages - .extend(input_state.queued_user_messages); + self.pending_steers = input_state + .pending_steers + .into_iter() + .map(|user_message| PendingSteer { + compare_key: PendingSteerCompareKey { + message: user_message.text.clone(), + image_count: user_message.local_images.len() + + user_message.remote_image_urls.len(), + }, + user_message, + }) + .collect(); + self.rejected_steers_queue = input_state.rejected_steers_queue; + self.queued_user_messages = input_state.queued_user_messages; } else { self.agent_turn_running = false; self.pending_steers.clear(); + self.rejected_steers_queue.clear(); self.set_remote_image_urls(Vec::new()); self.bottom_pane.set_composer_text_with_mention_bindings( String::new(), @@ -2312,6 +2850,9 @@ impl ChatWidget { self.turn_sleep_inhibitor .set_turn_running(self.agent_turn_running); self.update_task_running_state(); + if restored_task_running && !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + } self.refresh_pending_input_preview(); self.request_redraw(); } @@ -2698,15 +3239,15 @@ impl ChatWidget { fn on_image_generation_end(&mut self, event: ImageGenerationEndEvent) { self.flush_answer_stream_with_separator(); - let saved_to = event.saved_path.as_deref().and_then(|saved_path| { - std::path::Path::new(saved_path) - .parent() - .map(|parent| parent.display().to_string()) + let saved_path = event.saved_path.map(|saved_path| { + Url::from_file_path(Path::new(&saved_path)) + .map(|url| url.to_string()) + .unwrap_or(saved_path) }); self.add_to_history(history_cell::new_image_generation_call( event.call_id, event.revised_prompt, - saved_to, + saved_path, )); self.request_redraw(); } @@ -2863,7 +3404,185 @@ impl ChatWidget { self.request_redraw(); } - fn on_get_history_entry_response( + fn on_collab_agent_tool_call(&mut self, item: ThreadItem) { + let ThreadItem::CollabAgentToolCall { + id, + tool, + status, + sender_thread_id, + receiver_thread_ids, + prompt, + model, + reasoning_effort, + agents_states, + } = item + else { + return; + }; + let sender_thread_id = app_server_collab_thread_id_to_core(&sender_thread_id) + .or(self.thread_id) + .unwrap_or_default(); + let first_receiver = receiver_thread_ids + .first() + .and_then(|thread_id| app_server_collab_thread_id_to_core(thread_id)); + + match tool { + CollabAgentTool::SpawnAgent => { + if let (Some(model), Some(reasoning_effort)) = (model.clone(), reasoning_effort) { + self.pending_collab_spawn_requests.insert( + id.clone(), + multi_agents::SpawnRequestSummary { + model, + reasoning_effort, + }, + ); + } + + if !matches!(status, CollabAgentToolCallStatus::InProgress) { + let spawn_request = + self.pending_collab_spawn_requests.remove(&id).or_else(|| { + model + .zip(reasoning_effort) + .map(|(model, reasoning_effort)| { + multi_agents::SpawnRequestSummary { + model, + reasoning_effort, + } + }) + }); + self.on_collab_event(multi_agents::spawn_end( + codex_protocol::protocol::CollabAgentSpawnEndEvent { + call_id: id, + sender_thread_id, + new_thread_id: first_receiver, + new_agent_nickname: None, + new_agent_role: None, + prompt: prompt.unwrap_or_default(), + model: String::new(), + reasoning_effort: ReasoningEffortConfig::Medium, + status: first_receiver + .as_ref() + .and_then(|thread_id| agents_states.get(&thread_id.to_string())) + .map(app_server_collab_state_to_core) + .unwrap_or_else(|| { + AgentStatus::Errored("Agent spawn failed".into()) + }), + }, + spawn_request.as_ref(), + )); + } + } + CollabAgentTool::SendInput => { + if let Some(receiver_thread_id) = first_receiver + && !matches!(status, CollabAgentToolCallStatus::InProgress) + { + self.on_collab_event(multi_agents::interaction_end( + codex_protocol::protocol::CollabAgentInteractionEndEvent { + call_id: id, + sender_thread_id, + receiver_thread_id, + receiver_agent_nickname: None, + receiver_agent_role: None, + prompt: prompt.unwrap_or_default(), + status: receiver_thread_ids + .iter() + .find_map(|thread_id| agents_states.get(thread_id)) + .map(app_server_collab_state_to_core) + .unwrap_or_else(|| { + AgentStatus::Errored("Agent interaction failed".into()) + }), + }, + )); + } + } + CollabAgentTool::ResumeAgent => { + if let Some(receiver_thread_id) = first_receiver { + if matches!(status, CollabAgentToolCallStatus::InProgress) { + self.on_collab_event(multi_agents::resume_begin( + codex_protocol::protocol::CollabResumeBeginEvent { + call_id: id, + sender_thread_id, + receiver_thread_id, + receiver_agent_nickname: None, + receiver_agent_role: None, + }, + )); + } else { + self.on_collab_event(multi_agents::resume_end( + codex_protocol::protocol::CollabResumeEndEvent { + call_id: id, + sender_thread_id, + receiver_thread_id, + receiver_agent_nickname: None, + receiver_agent_role: None, + status: receiver_thread_ids + .iter() + .find_map(|thread_id| agents_states.get(thread_id)) + .map(app_server_collab_state_to_core) + .unwrap_or_else(|| { + AgentStatus::Errored("Agent resume failed".into()) + }), + }, + )); + } + } + } + CollabAgentTool::Wait => { + if matches!(status, CollabAgentToolCallStatus::InProgress) { + self.on_collab_event(multi_agents::waiting_begin( + codex_protocol::protocol::CollabWaitingBeginEvent { + sender_thread_id, + receiver_thread_ids: receiver_thread_ids + .iter() + .filter_map(|thread_id| { + app_server_collab_thread_id_to_core(thread_id) + }) + .collect(), + receiver_agents: Vec::new(), + call_id: id, + }, + )); + } else { + let (agent_statuses, statuses) = app_server_collab_agent_statuses_to_core( + &receiver_thread_ids, + &agents_states, + ); + self.on_collab_event(multi_agents::waiting_end( + codex_protocol::protocol::CollabWaitingEndEvent { + sender_thread_id, + call_id: id, + agent_statuses, + statuses, + }, + )); + } + } + CollabAgentTool::CloseAgent => { + if let Some(receiver_thread_id) = first_receiver + && !matches!(status, CollabAgentToolCallStatus::InProgress) + { + self.on_collab_event(multi_agents::close_end( + codex_protocol::protocol::CollabCloseEndEvent { + call_id: id, + sender_thread_id, + receiver_thread_id, + receiver_agent_nickname: None, + receiver_agent_role: None, + status: receiver_thread_ids + .iter() + .find_map(|thread_id| agents_states.get(thread_id)) + .map(app_server_collab_state_to_core) + .unwrap_or_else(|| { + AgentStatus::Errored("Agent close failed".into()) + }), + }, + )); + } + } + } + } + + pub(crate) fn handle_history_entry_response( &mut self, event: codex_protocol::protocol::GetHistoryEntryResponseEvent, ) { @@ -2891,6 +3610,7 @@ impl ChatWidget { self.request_redraw(); } + #[cfg(test)] fn on_background_event(&mut self, message: String) { debug!("BackgroundEvent: {message}"); self.bottom_pane.ensure_status_indicator(); @@ -2930,6 +3650,7 @@ impl ChatWidget { self.request_redraw(); } + #[cfg(test)] fn on_undo_started(&mut self, event: UndoStartedEvent) { self.bottom_pane.ensure_status_indicator(); self.bottom_pane @@ -2940,6 +3661,7 @@ impl ChatWidget { self.set_status_header(message); } + #[cfg(test)] fn on_undo_completed(&mut self, event: UndoCompletedEvent) { let UndoCompletedEvent { success, message } = event; self.bottom_pane.hide_status_indicator(); @@ -3541,8 +4263,7 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.clone()); - let queued_message_edit_binding = - queued_message_edit_binding_for_terminal(terminal_info().name); + let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -3593,6 +4314,10 @@ impl ChatWidget { connectors_partial_snapshot: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, + plugins_cache: PluginsCacheState::default(), + plugins_fetch_state: PluginListFetchState::default(), + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -3607,16 +4332,19 @@ impl ChatWidget { forked_from: None, next_fork_banner_parent_label: None, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_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, is_review_mode: false, + #[cfg(test)] pre_review_token_info: None, needs_final_message_separator: false, had_work_activity: false, @@ -3640,6 +4368,7 @@ impl ChatWidget { external_editor_state: ExternalEditorState::Closed, realtime_conversation: RealtimeConversationUiState::default(), last_rendered_user_message_event: None, + last_non_retry_error: None, }; widget.bottom_pane.set_voice_transcription_enabled( @@ -3659,6 +4388,7 @@ impl ChatWidget { .set_collaboration_modes_enabled(/*enabled*/ true); widget.sync_fast_command_enabled(); widget.sync_personality_command_enabled(); + widget.sync_plugins_command_enabled(); widget .bottom_pane .set_queued_message_edit_binding(widget.queued_message_edit_binding); @@ -3679,199 +4409,6 @@ impl ChatWidget { widget } - /// Create a ChatWidget attached to an existing conversation (e.g., a fork). - #[allow(dead_code)] - pub(crate) fn new_from_existing( - common: ChatWidgetInit, - conversation: std::sync::Arc, - session_configured: codex_protocol::protocol::SessionConfiguredEvent, - ) -> Self { - let ChatWidgetInit { - config, - frame_requester, - app_event_tx, - initial_user_message, - enhanced_keys_supported, - has_chatgpt_account, - model_catalog, - feedback, - is_first_run: _, - feedback_audience, - status_account_display, - initial_plan_type, - model, - startup_tooltip_override: _, - status_line_invalid_items_warned, - session_telemetry, - } = common; - let model = model.filter(|m| !m.trim().is_empty()); - 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 model_override = model.as_deref(); - let header_model = model - .clone() - .unwrap_or_else(|| session_configured.model.clone()); - 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(header_model); - - let current_cwd = Some(session_configured.cwd.clone()); - let codex_op_tx = - spawn_agent_from_existing(conversation, session_configured, app_event_tx.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 queued_message_edit_binding = - queued_message_edit_binding_for_terminal(terminal_info().name); - let mut widget = Self { - app_event_tx: app_event_tx.clone(), - frame_requester: frame_requester.clone(), - codex_op_target: CodexOpTarget::Direct(codex_op_tx), - bottom_pane: BottomPane::new(BottomPaneParams { - frame_requester, - app_event_tx, - has_input_focus: true, - enhanced_keys_supported, - placeholder_text: placeholder, - disable_paste_burst: config.disable_paste_burst, - animations_enabled: config.animations, - skills: None, - }), - active_cell: None, - active_cell_revision: 0, - config, - 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, - token_info: None, - rate_limit_snapshots_by_limit_id: BTreeMap::new(), - plan_type: initial_plan_type, - rate_limit_warnings: RateLimitWarningState::default(), - rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), - adaptive_chunking: AdaptiveChunkingPolicy::default(), - stream_controller: None, - plan_stream_controller: None, - last_copyable_output: None, - running_commands: HashMap::new(), - pending_collab_spawn_requests: HashMap::new(), - suppressed_exec_calls: HashSet::new(), - last_unified_wait: None, - unified_exec_wait_streak: None, - turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep), - task_complete_pending: false, - unified_exec_processes: Vec::new(), - agent_turn_running: false, - mcp_startup_status: None, - connectors_cache: ConnectorsCacheState::default(), - connectors_partial_snapshot: None, - connectors_prefetch_in_flight: false, - connectors_force_refetch_pending: false, - interrupts: InterruptManager::new(), - reasoning_buffer: String::new(), - full_reasoning_buffer: String::new(), - current_status: StatusIndicatorState::working(), - pending_guardian_review_status: PendingGuardianReviewStatus::default(), - retry_status_header: None, - pending_status_indicator_restore: false, - suppress_queue_autosend: false, - thread_id: None, - thread_name: None, - thread_rename_block_message: None, - forked_from: None, - next_fork_banner_parent_label: None, - queued_user_messages: VecDeque::new(), - pending_steers: VecDeque::new(), - submit_pending_steers_after_interrupt: false, - queued_message_edit_binding, - show_welcome_banner: false, - startup_tooltip_override: None, - suppress_session_configured_redraw: true, - pending_notification: None, - quit_shortcut_expires_at: None, - quit_shortcut_key: None, - is_review_mode: false, - pre_review_token_info: None, - needs_final_message_separator: false, - had_work_activity: false, - saw_plan_update_this_turn: false, - saw_plan_item_this_turn: false, - plan_delta_buffer: String::new(), - plan_item_active: false, - last_separator_elapsed_secs: None, - turn_runtime_metrics: RuntimeMetricsSummary::default(), - last_rendered_width: std::cell::Cell::new(None), - feedback, - feedback_audience, - current_rollout_path: None, - current_cwd, - session_network_proxy: None, - status_line_invalid_items_warned, - status_line_branch: None, - status_line_branch_cwd: None, - status_line_branch_pending: false, - status_line_branch_lookup_complete: false, - external_editor_state: ExternalEditorState::Closed, - realtime_conversation: RealtimeConversationUiState::default(), - last_rendered_user_message_event: None, - }; - - widget.bottom_pane.set_voice_transcription_enabled( - widget.config.features.enabled(Feature::VoiceTranscription), - ); - 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_fast_command_enabled(); - widget.sync_personality_command_enabled(); - widget - .bottom_pane - .set_queued_message_edit_binding(widget.queued_message_edit_binding); - #[cfg(target_os = "windows")] - widget.bottom_pane.set_windows_degraded_sandbox_active( - codex_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 - } - pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { KeyEvent { @@ -3933,9 +4470,9 @@ impl ChatWidget { if key_event.kind == KeyEventKind::Press && self.queued_message_edit_binding.is_press(key_event) - && !self.queued_user_messages.is_empty() + && self.has_queued_follow_up_messages() { - if let Some(user_message) = self.queued_user_messages.pop_back() { + 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(); @@ -4082,22 +4619,6 @@ impl ChatWidget { self.bottom_pane.set_footer_hint_override(items); } - pub(crate) fn set_thread_footer_hint_override(&mut self, items: Option>) { - self.bottom_pane.set_thread_footer_hint_override(items); - } - - pub(crate) fn clear_thread_rename_block(&mut self) { - self.thread_rename_block_message = None; - } - - pub(crate) fn set_thread_rename_block_message(&mut self, message: impl Into) { - self.thread_rename_block_message = Some(message.into()); - } - - pub(crate) fn set_next_fork_banner_parent_label(&mut self, label: Option) { - self.next_fork_banner_parent_label = label; - } - pub(crate) fn show_selection_view(&mut self, params: SelectionViewParams) { self.bottom_pane.show_selection_view(params); self.request_redraw(); @@ -4175,6 +4696,9 @@ impl ChatWidget { } SlashCommand::Compact => { self.clear_token_usage(); + if !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + } self.app_event_tx.compact(); } SlashCommand::Review => { @@ -4201,7 +4725,7 @@ impl ChatWidget { return; } if self.realtime_conversation.is_live() { - self.request_realtime_conversation_close(/*info_message*/ None); + self.stop_realtime_conversation_from_ui(); } else { self.start_realtime_conversation(); } @@ -4242,6 +4766,9 @@ impl ChatWidget { } self.open_collaboration_modes_popup(); } + SlashCommand::Btw => { + self.add_error_message("Usage: /btw ".to_string()); + } SlashCommand::Agent | SlashCommand::MultiAgents => { self.app_event_tx.send(AppEvent::OpenAgentPicker); } @@ -4289,7 +4816,7 @@ impl ChatWidget { self.session_telemetry.counter( "codex.windows_sandbox.setup_elevated_sandbox_command", - 1, + /*inc*/ 1, &[], ); self.app_event_tx @@ -4405,8 +4932,8 @@ impl ChatWidget { SlashCommand::Apps => { self.add_connectors_output(); } - SlashCommand::Btw => { - self.add_error_message("Usage: /btw ".to_string()); + SlashCommand::Plugins => { + self.add_plugins_output(); } SlashCommand::Rollout => { if let Some(path) = self.rollout_path() { @@ -4422,21 +4949,14 @@ impl ChatWidget { } } SlashCommand::TestApproval => { - use codex_protocol::protocol::EventMsg; use std::collections::HashMap; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::FileChange; - self.app_event_tx.send(AppEvent::CodexEvent(Event { - id: "1".to_string(), - // msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { - // call_id: "1".to_string(), - // command: vec!["git".into(), "apply".into()], - // cwd: self.config.cwd.clone(), - // reason: Some("test".to_string()), - // }), - msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + self.on_apply_patch_approval_request( + "1".to_string(), + ApplyPatchApprovalRequestEvent { call_id: "1".to_string(), turn_id: "turn-1".to_string(), changes: HashMap::from([ @@ -4456,8 +4976,8 @@ impl ChatWidget { ]), reason: None, grant_root: Some(PathBuf::from("/tmp")), - }), - })); + }, + ); } } } @@ -4709,10 +5229,7 @@ impl ChatWidget { } fn queue_user_message(&mut self, user_message: UserMessage) { - if !self.is_session_configured() - || self.bottom_pane.is_task_running() - || self.is_review_mode - { + if !self.is_session_configured() || self.bottom_pane.is_task_running() { self.queued_user_messages.push_back(user_message); self.refresh_pending_input_preview(); } else { @@ -4743,12 +5260,6 @@ impl ChatWidget { self.refresh_pending_input_preview(); return None; } - if self.is_review_mode { - self.queued_user_messages.push_back(user_message); - self.refresh_pending_input_preview(); - return None; - } - let UserMessage { text, local_images, @@ -4789,14 +5300,9 @@ impl ChatWidget { ))); return None; } - // TODO: Restore `!` support in app-server TUI once command execution can - // persist transcript-visible output into thread history with parity to the - // legacy TUI. - self.add_to_history(history_cell::new_error_event( - "`!` shell commands are unavailable in app-server TUI because command output is not yet persisted in thread history.".to_string(), - )); - self.request_redraw(); - return None; + let app_command = AppCommand::run_user_shell_command(cmd.to_string()); + self.submit_op(app_command.clone()); + return Some(app_command); } for image_url in &remote_image_urls { @@ -4925,6 +5431,12 @@ impl ChatWidget { } let effective_mode = self.effective_collaboration_mode(); + if effective_mode.model().trim().is_empty() { + self.add_error_message( + "Thread model is unavailable. Wait for the thread to finish syncing or choose a model before sending input.".to_string(), + ); + return None; + } let collaboration_mode = if self.collaboration_modes_enabled() { self.active_collaboration_mask .as_ref() @@ -4962,14 +5474,23 @@ impl ChatWidget { personality, ); - let submitted_command = op.clone(); - if !self.submit_op(op) { + if !self.submit_op(op.clone()) { return None; } - // Persist the text to cross-session message history. + // Persist the text to cross-session message history. Mentions are + // encoded into placeholder syntax so recall can reconstruct the + // mention bindings in a future session. if !text.is_empty() { - warn!("skipping composer history persistence in app-server TUI"); + let encoded_mentions = mention_bindings + .iter() + .map(|binding| LinkedMention { + mention: binding.mention.clone(), + path: binding.path.clone(), + }) + .collect::>(); + let history_text = encode_history_mentions(&text, &encoded_mentions); + self.submit_op(Op::AddToHistory { text: history_text }); } if let Some(pending_steer) = pending_steer { @@ -5014,7 +5535,7 @@ impl ChatWidget { } self.needs_final_message_separator = false; - Some(submitted_command) + Some(op) } /// Restore the blocked submission draft without losing mention resolution state. @@ -5052,6 +5573,880 @@ impl ChatWidget { /// is intentionally conservative: only safe-to-replay items are rendered to /// avoid triggering side effects. Event ids are passed as `None` to /// distinguish replayed events from live ones. + pub(crate) fn replay_thread_turns(&mut self, turns: Vec, replay_kind: ReplayKind) { + for turn in turns { + let Turn { + id: turn_id, + items, + status, + error, + } = turn; + if matches!(status, TurnStatus::InProgress) { + self.last_non_retry_error = None; + self.on_task_started(); + } + for item in items { + self.replay_thread_item(item, turn_id.clone(), replay_kind); + } + if matches!( + status, + TurnStatus::Completed | TurnStatus::Interrupted | TurnStatus::Failed + ) { + self.handle_turn_completed_notification( + TurnCompletedNotification { + thread_id: self.thread_id.map(|id| id.to_string()).unwrap_or_default(), + turn: Turn { + id: turn_id, + items: Vec::new(), + status, + error, + }, + }, + Some(replay_kind), + ); + } + } + } + + pub(crate) fn replay_thread_item( + &mut self, + item: ThreadItem, + turn_id: String, + replay_kind: ReplayKind, + ) { + self.handle_thread_item(item, turn_id, ThreadItemRenderSource::Replay(replay_kind)); + } + + fn handle_thread_item( + &mut self, + item: ThreadItem, + turn_id: String, + render_source: ThreadItemRenderSource, + ) { + let from_replay = render_source.is_replay(); + let replay_kind = render_source.replay_kind(); + match item { + ThreadItem::UserMessage { id, content } => { + let user_message = codex_protocol::items::UserMessageItem { + id, + content: content + .into_iter() + .map(codex_app_server_protocol::UserInput::into_core) + .collect(), + }; + let codex_protocol::protocol::EventMsg::UserMessage(event) = + user_message.as_legacy_event() + else { + unreachable!("user message item should convert to a user message event"); + }; + if from_replay { + self.on_user_message_event(event); + } else { + let rendered = Self::rendered_user_message_event_from_event(&event); + let compare_key = + Self::pending_steer_compare_key_from_items(&user_message.content); + if self + .pending_steers + .front() + .is_some_and(|pending| pending.compare_key == compare_key) + { + if let Some(pending) = self.pending_steers.pop_front() { + self.refresh_pending_input_preview(); + let pending_event = UserMessageEvent { + message: pending.user_message.text, + images: Some(pending.user_message.remote_image_urls), + local_images: pending + .user_message + .local_images + .into_iter() + .map(|image| image.path) + .collect(), + text_elements: pending.user_message.text_elements, + }; + self.on_user_message_event(pending_event); + } else if self.last_rendered_user_message_event.as_ref() != Some(&rendered) + { + tracing::warn!( + "pending steer matched compare key but queue was empty when rendering committed user message" + ); + self.on_user_message_event(event); + } + } else if self.last_rendered_user_message_event.as_ref() != Some(&rendered) { + self.on_user_message_event(event); + } + } + } + ThreadItem::AgentMessage { + id, + text, + phase, + memory_citation, + } => { + self.on_agent_message_item_completed(AgentMessageItem { + id, + content: vec![AgentMessageContent::Text { text }], + phase, + memory_citation: memory_citation.map(|citation| { + codex_protocol::memory_citation::MemoryCitation { + entries: citation + .entries + .into_iter() + .map( + |entry| codex_protocol::memory_citation::MemoryCitationEntry { + path: entry.path, + line_start: entry.line_start, + line_end: entry.line_end, + note: entry.note, + }, + ) + .collect(), + rollout_ids: citation.thread_ids, + } + }), + }); + } + ThreadItem::Plan { text, .. } => self.on_plan_item_completed(text), + ThreadItem::Reasoning { + summary, content, .. + } => { + for delta in summary { + self.on_agent_reasoning_delta(delta); + } + if self.config.show_raw_agent_reasoning { + for delta in content { + self.on_agent_reasoning_delta(delta); + } + } + self.on_agent_reasoning_final(); + } + ThreadItem::CommandExecution { + id, + command, + cwd, + process_id, + source, + status, + command_actions, + aggregated_output, + exit_code, + duration_ms, + } => { + if matches!( + status, + codex_app_server_protocol::CommandExecutionStatus::InProgress + ) { + self.on_exec_command_begin(ExecCommandBeginEvent { + call_id: id, + process_id, + turn_id: turn_id.clone(), + command: vec![command], + cwd, + parsed_cmd: command_actions + .into_iter() + .map(codex_app_server_protocol::CommandAction::into_core) + .collect(), + source: source.to_core(), + interaction_input: None, + }); + } else { + let aggregated_output = aggregated_output.unwrap_or_default(); + self.on_exec_command_end(ExecCommandEndEvent { + call_id: id, + process_id, + turn_id: turn_id.clone(), + command: vec![command], + cwd, + parsed_cmd: command_actions + .into_iter() + .map(codex_app_server_protocol::CommandAction::into_core) + .collect(), + source: source.to_core(), + interaction_input: None, + stdout: String::new(), + stderr: String::new(), + aggregated_output: aggregated_output.clone(), + exit_code: exit_code.unwrap_or_default(), + duration: Duration::from_millis( + duration_ms.unwrap_or_default().max(0) as u64 + ), + formatted_output: aggregated_output, + status: match status { + codex_app_server_protocol::CommandExecutionStatus::Completed => { + codex_protocol::protocol::ExecCommandStatus::Completed + } + codex_app_server_protocol::CommandExecutionStatus::Failed => { + codex_protocol::protocol::ExecCommandStatus::Failed + } + codex_app_server_protocol::CommandExecutionStatus::Declined => { + codex_protocol::protocol::ExecCommandStatus::Declined + } + codex_app_server_protocol::CommandExecutionStatus::InProgress => { + codex_protocol::protocol::ExecCommandStatus::Failed + } + }, + }); + } + } + ThreadItem::FileChange { + id, + changes, + status, + } => { + if !matches!( + status, + codex_app_server_protocol::PatchApplyStatus::InProgress + ) { + self.on_patch_apply_end(codex_protocol::protocol::PatchApplyEndEvent { + call_id: id, + turn_id: turn_id.clone(), + stdout: String::new(), + stderr: String::new(), + success: !matches!( + status, + codex_app_server_protocol::PatchApplyStatus::Failed + ), + changes: app_server_patch_changes_to_core(changes), + status: match status { + codex_app_server_protocol::PatchApplyStatus::Completed => { + codex_protocol::protocol::PatchApplyStatus::Completed + } + codex_app_server_protocol::PatchApplyStatus::Failed => { + codex_protocol::protocol::PatchApplyStatus::Failed + } + codex_app_server_protocol::PatchApplyStatus::Declined => { + codex_protocol::protocol::PatchApplyStatus::Declined + } + codex_app_server_protocol::PatchApplyStatus::InProgress => { + codex_protocol::protocol::PatchApplyStatus::Failed + } + }, + }); + } + } + ThreadItem::McpToolCall { + id, + server, + tool, + arguments, + result, + error, + duration_ms, + .. + } => { + self.on_mcp_tool_call_end(codex_protocol::protocol::McpToolCallEndEvent { + call_id: id, + invocation: codex_protocol::protocol::McpInvocation { + server, + tool, + arguments: Some(arguments), + }, + duration: Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64), + result: match (result, error) { + (_, Some(error)) => Err(error.message), + (Some(result), None) => Ok(codex_protocol::mcp::CallToolResult { + content: result.content, + structured_content: result.structured_content, + is_error: Some(false), + meta: None, + }), + (None, None) => Err("MCP tool call completed without a result".to_string()), + }, + }); + } + ThreadItem::WebSearch { id, query, action } => { + self.on_web_search_begin(WebSearchBeginEvent { + call_id: id.clone(), + }); + self.on_web_search_end(WebSearchEndEvent { + call_id: id, + query, + action: action + .map(web_search_action_to_core) + .unwrap_or(codex_protocol::models::WebSearchAction::Other), + }); + } + ThreadItem::ImageView { id, path } => { + self.on_view_image_tool_call(ViewImageToolCallEvent { + call_id: id, + path: path.into(), + }); + } + ThreadItem::ImageGeneration { + id, + status, + revised_prompt, + result, + saved_path, + } => { + self.on_image_generation_end(ImageGenerationEndEvent { + call_id: id, + result, + revised_prompt, + status, + saved_path, + }); + } + ThreadItem::EnteredReviewMode { review, .. } => { + self.add_to_history(history_cell::new_review_status_line(format!( + ">> Code review started: {review} <<" + ))); + if !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + } + self.is_review_mode = true; + } + ThreadItem::ExitedReviewMode { review, .. } => { + self.on_agent_message(review); + self.is_review_mode = false; + } + ThreadItem::ContextCompaction { .. } => { + self.on_agent_message("Context compacted".to_owned()); + } + ThreadItem::HookPrompt { .. } => {} + ThreadItem::CollabAgentToolCall { + id, + tool, + status, + sender_thread_id, + receiver_thread_ids, + prompt, + model, + reasoning_effort, + agents_states, + } => self.on_collab_agent_tool_call(ThreadItem::CollabAgentToolCall { + id, + tool, + status, + sender_thread_id, + receiver_thread_ids, + prompt, + model, + reasoning_effort, + agents_states, + }), + ThreadItem::DynamicToolCall { .. } => {} + } + + if matches!(replay_kind, Some(ReplayKind::ThreadSnapshot)) && turn_id.is_empty() { + self.request_redraw(); + } + } + + pub(crate) fn handle_server_request( + &mut self, + request: ServerRequest, + replay_kind: Option, + ) { + let id = request.id().to_string(); + match request { + ServerRequest::CommandExecutionRequestApproval { params, .. } => { + self.on_exec_approval_request(id, exec_approval_request_from_params(params)); + } + ServerRequest::FileChangeRequestApproval { params, .. } => { + self.on_apply_patch_approval_request( + id, + patch_approval_request_from_params(params), + ); + } + ServerRequest::McpServerElicitationRequest { request_id, params } => { + self.on_mcp_server_elicitation_request( + app_server_request_id_to_mcp_request_id(&request_id), + params, + ); + } + ServerRequest::PermissionsRequestApproval { params, .. } => { + self.on_request_permissions(request_permissions_from_params(params)); + } + ServerRequest::ToolRequestUserInput { params, .. } => { + self.on_request_user_input(request_user_input_from_params(params)); + } + ServerRequest::DynamicToolCall { .. } + | ServerRequest::ChatgptAuthTokensRefresh { .. } + | ServerRequest::ApplyPatchApproval { .. } + | ServerRequest::ExecCommandApproval { .. } => { + if replay_kind.is_none() { + self.add_error_message(APP_SERVER_TUI_STUB_MESSAGE.to_string()); + } + } + } + } + + pub(crate) fn handle_server_notification( + &mut self, + notification: ServerNotification, + replay_kind: Option, + ) { + let from_replay = replay_kind.is_some(); + let is_resume_initial_replay = + matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)); + let is_retry_error = matches!( + ¬ification, + ServerNotification::Error(ErrorNotification { + will_retry: true, + .. + }) + ); + if !is_resume_initial_replay && !is_retry_error { + self.restore_retry_status_header_if_present(); + } + match notification { + ServerNotification::ThreadTokenUsageUpdated(notification) => { + self.set_token_info(Some(token_usage_info_from_app_server( + notification.token_usage, + ))); + } + ServerNotification::ThreadNameUpdated(notification) => { + match ThreadId::from_string(¬ification.thread_id) { + Ok(thread_id) => self.on_thread_name_updated( + codex_protocol::protocol::ThreadNameUpdatedEvent { + thread_id, + thread_name: notification.thread_name, + }, + ), + Err(err) => { + tracing::warn!( + thread_id = notification.thread_id, + error = %err, + "ignoring app-server ThreadNameUpdated with invalid thread_id" + ); + } + } + } + ServerNotification::TurnStarted(_) => { + self.last_non_retry_error = None; + if !matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)) { + self.on_task_started(); + } + } + ServerNotification::TurnCompleted(notification) => { + self.handle_turn_completed_notification(notification, replay_kind); + } + ServerNotification::ItemStarted(notification) => { + self.handle_item_started_notification(notification); + } + ServerNotification::ItemCompleted(notification) => { + self.handle_item_completed_notification(notification, replay_kind); + } + ServerNotification::AgentMessageDelta(notification) => { + self.on_agent_message_delta(notification.delta); + } + ServerNotification::PlanDelta(notification) => self.on_plan_delta(notification.delta), + ServerNotification::ReasoningSummaryTextDelta(notification) => { + self.on_agent_reasoning_delta(notification.delta); + } + ServerNotification::ReasoningTextDelta(notification) => { + if self.config.show_raw_agent_reasoning { + self.on_agent_reasoning_delta(notification.delta); + } + } + ServerNotification::ReasoningSummaryPartAdded(_) => self.on_reasoning_section_break(), + ServerNotification::TerminalInteraction(notification) => { + self.on_terminal_interaction(TerminalInteractionEvent { + call_id: notification.item_id, + process_id: notification.process_id, + stdin: notification.stdin, + }) + } + ServerNotification::CommandExecutionOutputDelta(notification) => { + self.on_exec_command_output_delta(ExecCommandOutputDeltaEvent { + call_id: notification.item_id, + stream: codex_protocol::protocol::ExecOutputStream::Stdout, + chunk: notification.delta.into_bytes(), + }); + } + ServerNotification::FileChangeOutputDelta(notification) => { + self.on_patch_apply_output_delta(notification.item_id, notification.delta); + } + ServerNotification::TurnDiffUpdated(notification) => { + self.on_turn_diff(notification.diff) + } + ServerNotification::TurnPlanUpdated(notification) => { + self.on_plan_update(UpdatePlanArgs { + explanation: notification.explanation, + plan: notification + .plan + .into_iter() + .map(|step| UpdatePlanItemArg { + step: step.step, + status: match step.status { + TurnPlanStepStatus::Pending => UpdatePlanItemStatus::Pending, + TurnPlanStepStatus::InProgress => UpdatePlanItemStatus::InProgress, + TurnPlanStepStatus::Completed => UpdatePlanItemStatus::Completed, + }, + }) + .collect(), + }) + } + ServerNotification::HookStarted(notification) => { + if let Some(run) = convert_via_json(notification.run) { + self.on_hook_started(codex_protocol::protocol::HookStartedEvent { + turn_id: notification.turn_id, + run, + }); + } + } + ServerNotification::HookCompleted(notification) => { + if let Some(run) = convert_via_json(notification.run) { + self.on_hook_completed(codex_protocol::protocol::HookCompletedEvent { + turn_id: notification.turn_id, + run, + }); + } + } + ServerNotification::Error(notification) => { + if notification.will_retry { + if !from_replay { + self.on_stream_error( + notification.error.message, + notification.error.additional_details, + ); + } + } else { + self.last_non_retry_error = Some(( + notification.turn_id.clone(), + notification.error.message.clone(), + )); + self.handle_non_retry_error( + notification.error.message, + notification.error.codex_error_info, + ); + } + } + ServerNotification::SkillsChanged(_) => { + self.submit_op(AppCommand::list_skills( + Vec::new(), + /*force_reload*/ true, + )); + } + ServerNotification::ModelRerouted(_) => {} + ServerNotification::DeprecationNotice(notification) => { + self.on_deprecation_notice(DeprecationNoticeEvent { + summary: notification.summary, + details: notification.details, + }) + } + ServerNotification::ConfigWarning(notification) => self.on_warning( + notification + .details + .map(|details| format!("{}: {details}", notification.summary)) + .unwrap_or(notification.summary), + ), + ServerNotification::ItemGuardianApprovalReviewStarted(notification) => { + self.on_guardian_review_notification( + notification.target_item_id, + notification.turn_id, + notification.review, + notification.action, + ); + } + ServerNotification::ItemGuardianApprovalReviewCompleted(notification) => { + self.on_guardian_review_notification( + notification.target_item_id, + notification.turn_id, + notification.review, + notification.action, + ); + } + ServerNotification::ThreadClosed(_) => { + if !from_replay { + self.on_shutdown_complete(); + } + } + ServerNotification::ThreadRealtimeStarted(notification) => { + if !from_replay { + self.on_realtime_conversation_started( + codex_protocol::protocol::RealtimeConversationStartedEvent { + session_id: notification.session_id, + version: notification.version, + }, + ); + } + } + ServerNotification::ThreadRealtimeItemAdded(notification) => { + if !from_replay { + self.on_realtime_conversation_realtime( + codex_protocol::protocol::RealtimeConversationRealtimeEvent { + payload: codex_protocol::protocol::RealtimeEvent::ConversationItemAdded( + notification.item, + ), + }, + ); + } + } + ServerNotification::ThreadRealtimeOutputAudioDelta(notification) => { + if !from_replay { + self.on_realtime_conversation_realtime( + codex_protocol::protocol::RealtimeConversationRealtimeEvent { + payload: codex_protocol::protocol::RealtimeEvent::AudioOut( + notification.audio.into(), + ), + }, + ); + } + } + ServerNotification::ThreadRealtimeError(notification) => { + if !from_replay { + self.on_realtime_conversation_realtime( + codex_protocol::protocol::RealtimeConversationRealtimeEvent { + payload: codex_protocol::protocol::RealtimeEvent::Error( + notification.message, + ), + }, + ); + } + } + ServerNotification::ThreadRealtimeClosed(notification) => { + if !from_replay { + self.on_realtime_conversation_closed( + codex_protocol::protocol::RealtimeConversationClosedEvent { + reason: notification.reason, + }, + ); + } + } + ServerNotification::ServerRequestResolved(_) + | ServerNotification::AccountUpdated(_) + | ServerNotification::AccountRateLimitsUpdated(_) + | ServerNotification::ThreadStarted(_) + | ServerNotification::ThreadStatusChanged(_) + | ServerNotification::ThreadArchived(_) + | ServerNotification::ThreadUnarchived(_) + | ServerNotification::RawResponseItemCompleted(_) + | ServerNotification::CommandExecOutputDelta(_) + | ServerNotification::McpToolCallProgress(_) + | ServerNotification::McpServerStatusUpdated(_) + | ServerNotification::McpServerOauthLoginCompleted(_) + | ServerNotification::AppListUpdated(_) + | ServerNotification::ContextCompacted(_) + | ServerNotification::FuzzyFileSearchSessionUpdated(_) + | ServerNotification::FuzzyFileSearchSessionCompleted(_) + | ServerNotification::ThreadRealtimeTranscriptUpdated(_) + | ServerNotification::WindowsWorldWritableWarning(_) + | ServerNotification::WindowsSandboxSetupCompleted(_) + | ServerNotification::AccountLoginCompleted(_) => {} + } + } + + pub(crate) fn handle_skills_list_response(&mut self, response: ListSkillsResponseEvent) { + self.on_list_skills(response); + } + + pub(crate) fn handle_thread_rolled_back(&mut self) { + self.last_copyable_output = None; + } + + fn on_mcp_server_elicitation_request( + &mut self, + request_id: codex_protocol::mcp::RequestId, + params: codex_app_server_protocol::McpServerElicitationRequestParams, + ) { + let request = codex_protocol::approvals::ElicitationRequestEvent { + turn_id: params.turn_id, + server_name: params.server_name, + id: request_id, + request: match params.request { + codex_app_server_protocol::McpServerElicitationRequest::Form { + meta, + message, + requested_schema, + } => codex_protocol::approvals::ElicitationRequest::Form { + meta, + message, + requested_schema: serde_json::to_value(requested_schema) + .unwrap_or(serde_json::Value::Null), + }, + codex_app_server_protocol::McpServerElicitationRequest::Url { + meta, + message, + url, + elicitation_id, + } => codex_protocol::approvals::ElicitationRequest::Url { + meta, + message, + url, + elicitation_id, + }, + }, + }; + self.on_elicitation_request(request); + } + + fn handle_turn_completed_notification( + &mut self, + notification: TurnCompletedNotification, + replay_kind: Option, + ) { + match notification.turn.status { + TurnStatus::Completed => { + self.last_non_retry_error = None; + self.on_task_complete(/*last_agent_message*/ None, replay_kind.is_some()) + } + TurnStatus::Interrupted => { + self.last_non_retry_error = None; + self.on_interrupted_turn(TurnAbortReason::Interrupted); + } + TurnStatus::Failed => { + if let Some(error) = notification.turn.error { + if self.last_non_retry_error.as_ref() + == Some(&(notification.turn.id.clone(), error.message.clone())) + { + self.last_non_retry_error = None; + } else { + self.handle_non_retry_error(error.message, error.codex_error_info); + } + } else { + self.last_non_retry_error = None; + self.finalize_turn(); + self.request_redraw(); + self.maybe_send_next_queued_input(); + } + } + TurnStatus::InProgress => {} + } + } + + fn handle_item_started_notification(&mut self, notification: ItemStartedNotification) { + match notification.item { + ThreadItem::CommandExecution { + id, + command, + cwd, + process_id, + source, + command_actions, + .. + } => { + self.on_exec_command_begin(ExecCommandBeginEvent { + call_id: id, + process_id, + turn_id: notification.turn_id, + command: vec![command], + cwd, + parsed_cmd: command_actions + .into_iter() + .map(codex_app_server_protocol::CommandAction::into_core) + .collect(), + source: source.to_core(), + interaction_input: None, + }); + } + ThreadItem::FileChange { id, changes, .. } => { + self.on_patch_apply_begin(PatchApplyBeginEvent { + call_id: id, + turn_id: notification.turn_id, + auto_approved: false, + changes: app_server_patch_changes_to_core(changes), + }); + } + ThreadItem::McpToolCall { + id, + server, + tool, + arguments, + .. + } => { + self.on_mcp_tool_call_begin(McpToolCallBeginEvent { + call_id: id, + invocation: codex_protocol::protocol::McpInvocation { + server, + tool, + arguments: Some(arguments), + }, + }); + } + ThreadItem::WebSearch { id, .. } => { + self.on_web_search_begin(WebSearchBeginEvent { call_id: id }); + } + ThreadItem::ImageGeneration { id, .. } => { + self.on_image_generation_begin(ImageGenerationBeginEvent { call_id: id }); + } + ThreadItem::CollabAgentToolCall { + id, + tool, + status, + sender_thread_id, + receiver_thread_ids, + prompt, + model, + reasoning_effort, + agents_states, + } => self.on_collab_agent_tool_call(ThreadItem::CollabAgentToolCall { + id, + tool, + status, + sender_thread_id, + receiver_thread_ids, + prompt, + model, + reasoning_effort, + agents_states, + }), + ThreadItem::EnteredReviewMode { review, .. } => { + self.add_to_history(history_cell::new_review_status_line(format!( + ">> Code review started: {review} <<" + ))); + self.is_review_mode = true; + } + _ => {} + } + } + + fn handle_item_completed_notification( + &mut self, + notification: ItemCompletedNotification, + replay_kind: Option, + ) { + self.handle_thread_item( + notification.item, + notification.turn_id, + replay_kind.map_or(ThreadItemRenderSource::Live, ThreadItemRenderSource::Replay), + ); + } + + fn on_patch_apply_output_delta(&mut self, _item_id: String, _delta: String) {} + + fn on_guardian_review_notification( + &mut self, + id: String, + turn_id: String, + review: codex_app_server_protocol::GuardianApprovalReview, + action: Option, + ) { + self.on_guardian_assessment(GuardianAssessmentEvent { + id, + turn_id, + status: match review.status { + codex_app_server_protocol::GuardianApprovalReviewStatus::InProgress => { + GuardianAssessmentStatus::InProgress + } + codex_app_server_protocol::GuardianApprovalReviewStatus::Approved => { + GuardianAssessmentStatus::Approved + } + codex_app_server_protocol::GuardianApprovalReviewStatus::Denied => { + GuardianAssessmentStatus::Denied + } + codex_app_server_protocol::GuardianApprovalReviewStatus::Aborted => { + GuardianAssessmentStatus::Aborted + } + }, + risk_score: review.risk_score, + risk_level: review.risk_level.map(|risk_level| match risk_level { + codex_app_server_protocol::GuardianRiskLevel::Low => { + codex_protocol::protocol::GuardianRiskLevel::Low + } + codex_app_server_protocol::GuardianRiskLevel::Medium => { + codex_protocol::protocol::GuardianRiskLevel::Medium + } + codex_app_server_protocol::GuardianRiskLevel::High => { + codex_protocol::protocol::GuardianRiskLevel::High + } + }), + rationale: review.rationale, + action, + }); + } + + #[cfg(test)] fn replay_initial_messages(&mut self, events: Vec) { for msg in events { if matches!( @@ -5069,11 +6464,13 @@ impl ChatWidget { } } + #[cfg(test)] pub(crate) fn handle_codex_event(&mut self, event: Event) { let Event { id, msg } = event; self.dispatch_event_msg(Some(id), msg, /*replay_kind*/ None); } + #[cfg(test)] pub(crate) fn handle_codex_event_replay(&mut self, event: Event) { let Event { msg, .. } = event; if matches!(msg, EventMsg::ShutdownComplete) { @@ -5087,6 +6484,7 @@ impl ChatWidget { /// `id` is `Some` for live events and `None` for replayed events from /// `replay_initial_messages()`. Callers should treat `None` as a "fake" id /// that must not be used to correlate follow-up actions. + #[cfg(test)] fn dispatch_event_msg( &mut self, id: Option, @@ -5149,7 +6547,9 @@ impl ChatWidget { } EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message, .. - }) => self.on_task_complete(last_agent_message, from_replay), + }) => { + self.on_task_complete(last_agent_message, from_replay); + } EventMsg::TokenCount(ev) => { self.set_token_info(ev.info); self.on_rate_limit_snapshot(ev.rate_limits); @@ -5161,8 +6561,13 @@ impl ChatWidget { message, codex_error_info, }) => { - if let Some(info) = codex_error_info - && let Some(kind) = rate_limit_error_kind(&info) + if codex_error_info + .as_ref() + .is_some_and(|info| self.handle_steer_rejected_error(info)) + { + } else if let Some(kind) = codex_error_info + .as_ref() + .and_then(core_rate_limit_error_kind) { match kind { RateLimitErrorKind::ServerOverloaded => { @@ -5222,7 +6627,7 @@ impl ChatWidget { EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev), EventMsg::WebSearchBegin(ev) => self.on_web_search_begin(ev), EventMsg::WebSearchEnd(ev) => self.on_web_search_end(ev), - EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev), + EventMsg::GetHistoryEntryResponse(ev) => self.handle_history_entry_response(ev), EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), EventMsg::ListCustomPromptsResponse(_) => { tracing::warn!( @@ -5230,7 +6635,6 @@ impl ChatWidget { ); } EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev), - EventMsg::ListRemoteSkillsResponse(_) | EventMsg::RemoteSkillDownloaded(_) => {} EventMsg::SkillsUpdateAvailable => { self.submit_op(AppCommand::list_skills( Vec::new(), @@ -5381,6 +6785,7 @@ impl ChatWidget { } } + #[cfg(test)] fn on_entered_review_mode(&mut self, review: ReviewRequest, from_replay: bool) { // Enter review mode and emit a concise banner if self.pre_review_token_info.is_none() { @@ -5399,6 +6804,7 @@ impl ChatWidget { self.request_redraw(); } + #[cfg(test)] fn on_exited_review_mode(&mut self, review: ExitedReviewModeEvent) { // Leave review mode; if output is present, flush pending stream + show results. if let Some(output) = review.review_output { @@ -5526,7 +6932,7 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } - if let Some(user_message) = self.queued_user_messages.pop_front() { + if let Some(user_message) = self.pop_next_queued_user_message() { self.submit_user_message(user_message); } // Update the list to reflect the remaining queued messages (if any). @@ -5545,14 +6951,38 @@ impl ChatWidget { .iter() .map(|steer| steer.user_message.text.clone()) .collect(); - self.bottom_pane - .set_pending_input_preview(queued_messages, pending_steers); + let rejected_steers: Vec = self + .rejected_steers_queue + .iter() + .map(|message| message.text.clone()) + .collect(); + self.bottom_pane.set_pending_input_preview( + queued_messages, + pending_steers, + rejected_steers, + ); } pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec) { self.bottom_pane.set_pending_thread_approvals(threads); } + pub(crate) fn set_thread_footer_hint_override(&mut self, items: Option>) { + self.bottom_pane.set_thread_footer_hint_override(items); + } + + pub(crate) fn clear_thread_rename_block(&mut self) { + self.thread_rename_block_message = None; + } + + pub(crate) fn set_thread_rename_block_message(&mut self, message: impl Into) { + self.thread_rename_block_message = Some(message.into()); + } + + pub(crate) fn set_next_fork_banner_parent_label(&mut self, label: Option) { + self.next_fork_banner_parent_label = label; + } + pub(crate) fn add_diff_in_progress(&mut self) { self.request_redraw(); } @@ -6219,7 +7649,7 @@ impl ChatWidget { }); } - #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] + #[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) => { @@ -6234,12 +7664,12 @@ impl ChatWidget { } } - #[cfg(any(target_os = "linux", not(feature = "voice-input")))] + #[cfg(target_os = "linux")] pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) { let _ = kind; } - #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] + #[cfg(not(target_os = "linux"))] fn open_realtime_audio_device_selection_with_names( &mut self, kind: RealtimeAudioDeviceKind, @@ -7476,8 +8906,11 @@ impl ChatWidget { return; } - self.session_telemetry - .counter("codex.windows_sandbox.elevated_prompt_shown", 1, &[]); + self.session_telemetry.counter( + "codex.windows_sandbox.elevated_prompt_shown", + /*inc*/ 1, + &[], + ); let mut header = ColumnRenderable::new(); header.push(*Box::new( @@ -7496,7 +8929,11 @@ impl ChatWidget { 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", 1, &[]); + accept_otel.counter( + "codex.windows_sandbox.elevated_prompt_accept", + /*inc*/ 1, + &[], + ); tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset: preset.clone(), }); @@ -7508,7 +8945,11 @@ impl ChatWidget { 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", 1, &[]); + legacy_otel.counter( + "codex.windows_sandbox.elevated_prompt_use_legacy", + /*inc*/ 1, + &[], + ); tx.send(AppEvent::BeginWindowsSandboxLegacySetup { preset: legacy_preset.clone(), }); @@ -7520,7 +8961,11 @@ impl ChatWidget { name: "Quit".to_string(), description: None, actions: vec![Box::new(move |tx| { - quit_otel.counter("codex.windows_sandbox.elevated_prompt_quit", 1, &[]); + quit_otel.counter( + "codex.windows_sandbox.elevated_prompt_quit", + /*inc*/ 1, + &[], + ); tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); })], dismiss_on_select: true, @@ -7570,7 +9015,11 @@ impl ChatWidget { let otel = self.session_telemetry.clone(); let preset = elevated_preset; move |tx| { - otel.counter("codex.windows_sandbox.fallback_retry_elevated", 1, &[]); + otel.counter( + "codex.windows_sandbox.fallback_retry_elevated", + /*inc*/ 1, + &[], + ); tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset: preset.clone(), }); @@ -7586,7 +9035,11 @@ impl ChatWidget { let otel = self.session_telemetry.clone(); let preset = legacy_preset; move |tx| { - otel.counter("codex.windows_sandbox.fallback_use_legacy", 1, &[]); + otel.counter( + "codex.windows_sandbox.fallback_use_legacy", + /*inc*/ 1, + &[], + ); tx.send(AppEvent::BeginWindowsSandboxLegacySetup { preset: preset.clone(), }); @@ -7599,7 +9052,11 @@ impl ChatWidget { name: "Quit".to_string(), description: None, actions: vec![Box::new(move |tx| { - quit_otel.counter("codex.windows_sandbox.fallback_prompt_quit", 1, &[]); + quit_otel.counter( + "codex.windows_sandbox.fallback_prompt_quit", + /*inc*/ 1, + &[], + ); tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); })], dismiss_on_select: true, @@ -7639,11 +9096,12 @@ impl ChatWidget { // 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( - false, + /*enabled*/ false, Some("Input disabled until setup completes.".to_string()), ); self.bottom_pane.ensure_status_indicator(); - self.bottom_pane.set_interrupt_hint_visible(false); + 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()), @@ -7659,7 +9117,8 @@ impl ChatWidget { #[cfg(target_os = "windows")] pub(crate) fn clear_windows_sandbox_setup_status(&mut self) { - self.bottom_pane.set_composer_input_enabled(true, None); + self.bottom_pane + .set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None); self.bottom_pane.hide_status_indicator(); self.request_redraw(); } @@ -7717,7 +9176,6 @@ impl ChatWidget { self.request_realtime_conversation_close(Some( "Realtime voice mode was closed because the feature was disabled.".to_string(), )); - self.reset_realtime_conversation_state(); } } if feature == Feature::FastMode { @@ -7727,6 +9185,7 @@ impl ChatWidget { self.sync_personality_command_enabled(); } if feature == Feature::Plugins { + self.sync_plugins_command_enabled(); self.refresh_plugin_mentions(); } if feature == Feature::PreventIdleSleep { @@ -7932,7 +9391,7 @@ impl ChatWidget { } pub(crate) fn realtime_conversation_is_live(&self) -> bool { - self.realtime_conversation.is_active() + self.realtime_conversation.is_live() } fn current_realtime_audio_device_name(&self, kind: RealtimeAudioDeviceKind) -> Option { @@ -7957,6 +9416,11 @@ impl ChatWidget { .set_personality_command_enabled(self.config.features.enabled(Feature::Personality)); } + fn sync_plugins_command_enabled(&mut self) { + self.bottom_pane + .set_plugins_command_enabled(self.config.features.enabled(Feature::Plugins)); + } + fn current_model_supports_personality(&self) -> bool { let model = self.current_model(); self.model_catalog @@ -8283,18 +9747,39 @@ impl ChatWidget { PlainHistoryCell::new(vec![line.into()]) } + /// Begin the asynchronous MCP inventory flow: show a loading spinner and + /// request the app-server fetch via `AppEvent::FetchMcpInventory`. + /// + /// The spinner lives in `active_cell` and is cleared by + /// [`clear_mcp_inventory_loading`] once the result arrives. pub(crate) fn add_mcp_output(&mut self) { - let mcp_manager = McpManager::new(Arc::new(PluginsManager::new( - self.config.codex_home.clone(), + self.flush_answer_stream_with_separator(); + self.flush_active_cell(); + self.active_cell = Some(Box::new(history_cell::new_mcp_inventory_loading( + self.config.animations, ))); - if mcp_manager - .effective_servers(&self.config, /*auth*/ None) - .is_empty() + self.bump_active_cell_revision(); + self.request_redraw(); + self.app_event_tx.send(AppEvent::FetchMcpInventory); + } + + /// Remove the MCP loading spinner if it is still the active cell. + /// + /// Uses `Any`-based type checking so that a late-arriving inventory result + /// does not accidentally clear an unrelated cell that was set in the meantime. + pub(crate) fn clear_mcp_inventory_loading(&mut self) { + let Some(active) = self.active_cell.as_ref() else { + return; + }; + if !active + .as_any() + .is::() { - self.add_to_history(history_cell::empty_mcp_output()); - } else { - self.add_app_server_stub_message("MCP tool inventory"); + return; } + self.active_cell = None; + self.bump_active_cell_revision(); + self.request_redraw(); } pub(crate) fn add_connectors_output(&mut self) { @@ -8530,10 +10015,20 @@ impl ChatWidget { /// 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. + /// /// If the same quit shortcut is pressed again before expiry, this 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 { @@ -8636,6 +10131,11 @@ impl ChatWidget { self.bottom_pane.composer_is_empty() } + #[cfg(test)] + pub(crate) fn is_task_running_for_test(&self) -> bool { + self.bottom_pane.is_task_running() + } + pub(crate) fn submit_user_message_with_mode( &mut self, text: String, @@ -8707,9 +10207,14 @@ impl ChatWidget { #[cfg(test)] pub(crate) fn queued_user_message_texts(&self) -> Vec { - self.queued_user_messages + self.rejected_steers_queue .iter() .map(|message| message.text.clone()) + .chain( + self.queued_user_messages + .iter() + .map(|message| message.text.clone()), + ) .collect() } @@ -8754,6 +10259,7 @@ impl ChatWidget { true } + #[cfg(test)] fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { self.add_to_history(history_cell::new_mcp_tools_output( &self.config, @@ -8863,7 +10369,7 @@ impl ChatWidget { self.bottom_pane.set_connectors_snapshot(Some(snapshot)); } - fn refresh_plugin_mentions(&mut self) { + pub(crate) fn refresh_plugin_mentions(&mut self) { if !self.config.features.enabled(Feature::Plugins) { self.bottom_pane.set_plugin_mentions(/*plugins*/ None); return; @@ -8876,6 +10382,11 @@ impl ChatWidget { self.bottom_pane.set_plugin_mentions(Some(plugins)); } + pub(crate) fn sync_plugin_mentions_config(&mut self, config: &Config) { + self.config.features = config.features.clone(); + self.config.config_layer_stack = config.config_layer_stack.clone(); + } + pub(crate) fn open_review_popup(&mut self) { let mut items: Vec = Vec::new(); @@ -8970,7 +10481,7 @@ impl ChatWidget { } pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) { - let commits = codex_core::git_info::recent_commits(cwd, /*limit*/ 100).await; + let commits = recent_commits(cwd, /*limit*/ 100).await; let mut items: Vec = Vec::with_capacity(commits.len()); for entry in commits { @@ -9342,7 +10853,9 @@ fn extract_first_bold(s: &str) -> Option { fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str { match event_name { + codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse", codex_protocol::protocol::HookEventName::SessionStart => "SessionStart", + codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", codex_protocol::protocol::HookEventName::Stop => "Stop", } } @@ -9350,7 +10863,7 @@ fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'st #[cfg(test)] pub(crate) fn show_review_commit_picker_with_entries( chat: &mut ChatWidget, - entries: Vec, + entries: Vec, ) { let mut items: Vec = Vec::with_capacity(entries.len()); for entry in entries { diff --git a/codex-rs/tui_app_server/src/chatwidget/plugins.rs b/codex-rs/tui_app_server/src/chatwidget/plugins.rs index 079f70e226..e44081f761 100644 --- a/codex-rs/tui_app_server/src/chatwidget/plugins.rs +++ b/codex-rs/tui_app_server/src/chatwidget/plugins.rs @@ -1,4 +1,6 @@ use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; use super::ChatWidget; use crate::app_event::AppEvent; @@ -7,6 +9,9 @@ use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::history_cell; use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::shimmer::shimmer_spans; +use crate::tui::FrameRequester; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallResponse; @@ -17,10 +22,76 @@ use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::PluginUninstallResponse; use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::WidgetRef; const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection"; +const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); +const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); + +struct DelayedLoadingHeader { + started_at: Instant, + frame_requester: FrameRequester, + animations_enabled: bool, + loading_text: String, + note: Option, +} + +impl DelayedLoadingHeader { + fn new( + frame_requester: FrameRequester, + animations_enabled: bool, + loading_text: String, + note: Option, + ) -> Self { + Self { + started_at: Instant::now(), + frame_requester, + animations_enabled, + loading_text, + note, + } + } +} + +impl Renderable for DelayedLoadingHeader { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + + let mut lines = Vec::with_capacity(3); + lines.push(Line::from("Plugins".bold())); + + let now = Instant::now(); + let elapsed = now.saturating_duration_since(self.started_at); + if elapsed < LOADING_ANIMATION_DELAY { + self.frame_requester + .schedule_frame_in(LOADING_ANIMATION_DELAY - elapsed); + lines.push(Line::from(self.loading_text.as_str().dim())); + } else if self.animations_enabled { + self.frame_requester + .schedule_frame_in(LOADING_ANIMATION_INTERVAL); + lines.push(Line::from(shimmer_spans(self.loading_text.as_str()))); + } else { + lines.push(Line::from(self.loading_text.as_str().dim())); + } + + if let Some(note) = &self.note { + lines.push(Line::from(note.as_str().dim())); + } + + Paragraph::new(lines).render_ref(area, buf); + } + + fn desired_height(&self, _width: u16) -> u16 { + 2 + u16::from(self.note.is_some()) + } +} #[derive(Debug, Clone, Default)] pub(super) enum PluginsCacheState { @@ -474,16 +545,14 @@ impl ChatWidget { } fn plugins_loading_popup_params(&self) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from("Loading available plugins...".dim())); - header.push(Line::from( - "Available marketplaces will appear here when ready.".dim(), - )); - SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + "Loading available plugins...".to_string(), + Some("This first pass shows the ChatGPT marketplace only.".to_string()), + )), items: vec![SelectionItem { name: "Loading plugins...".to_string(), description: Some("This updates when the marketplace list is ready.".to_string()), @@ -495,15 +564,14 @@ impl ChatWidget { } fn plugin_detail_loading_popup_params(&self, plugin_display_name: &str) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from( - format!("Loading details for {plugin_display_name}...").dim(), - )); - SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + format!("Loading details for {plugin_display_name}..."), + /*note*/ None, + )), items: vec![SelectionItem { name: "Loading plugin details...".to_string(), description: Some("This updates when plugin details load.".to_string()), diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap index 846c73fe47..f1f18c1eaf 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap @@ -4,6 +4,6 @@ expression: popup --- Plugins Loading available plugins... - Available marketplaces will appear here when ready. + This first pass shows the ChatGPT marketplace only. › Loading plugins... This updates when the marketplace list is ready.