diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e33e93402..b361425ae 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -272,7 +272,6 @@ async fn handle_model_migration_prompt_if_needed( from_model: model.to_string(), to_model: target_model.clone(), }); - config.model = Some(target_model.clone()); let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping && let Some(reasoning_effort) = config.model_reasoning_effort @@ -285,8 +284,8 @@ async fn handle_model_migration_prompt_if_needed( config.model_reasoning_effort }; + config.model = Some(target_model.clone()); config.model_reasoning_effort = mapped_effort; - app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort)); app_event_tx.send(AppEvent::PersistModelSelection { @@ -321,7 +320,6 @@ pub(crate) struct App { pub(crate) auth_manager: Arc, /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, - pub(crate) current_model: String, pub(crate) active_profile: Option, pub(crate) file_search: FileSearchManager, @@ -381,7 +379,7 @@ impl App { models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, - model: Some(self.current_model.clone()), + model: Some(self.chat_widget.current_model().to_string()), } } @@ -459,7 +457,7 @@ impl App { models_manager: thread_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, - model: config.model.clone(), + model: Some(model.clone()), }; ChatWidget::new(init, thread_manager.clone()) } @@ -531,7 +529,6 @@ impl App { chat_widget, auth_manager: auth_manager.clone(), config, - current_model: model.clone(), active_profile, file_search, enhanced_keys_supported, @@ -694,13 +691,9 @@ impl App { } async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { - let model_info = self - .server - .get_models_manager() - .get_model_info(self.current_model.as_str(), &self.config) - .await; match event { AppEvent::NewSession => { + let model = self.chat_widget.current_model().to_string(); let summary = session_summary(self.chat_widget.token_usage(), self.chat_widget.thread_id()); self.shutdown_current_thread().await; @@ -718,10 +711,9 @@ impl App { models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, - model: Some(self.current_model.clone()), + model: Some(model), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); - self.current_model = model_info.slug.clone(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { @@ -766,7 +758,6 @@ impl App { resumed.thread, resumed.session_configured, ); - self.current_model = model_info.slug.clone(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -816,7 +807,6 @@ impl App { forked.thread, forked.session_configured, ); - self.current_model = model_info.slug.clone(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -995,7 +985,11 @@ impl App { } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); - self.current_model = model; + } + AppEvent::UpdateCollaborationMode(mode) => { + let model = mode.model().to_string(); + self.chat_widget.set_collaboration_mode(mode); + self.chat_widget.set_model(&model); } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); @@ -1545,8 +1539,10 @@ impl App { } fn on_update_reasoning_effort(&mut self, effort: Option) { - self.chat_widget.set_reasoning_effort(effort); + // TODO(aibrahim): Remove this and don't use config as a state object. + // Instead, explicitly pass the stored collaboration mode's effort into new sessions. self.config.model_reasoning_effort = effort; + self.chat_widget.set_reasoning_effort(effort); } async fn launch_external_editor(&mut self, tui: &mut tui::Tui) { @@ -1748,7 +1744,6 @@ mod tests { async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let current_model = "gpt-5.2-codex".to_string(); let server = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -1763,7 +1758,6 @@ mod tests { chat_widget, auth_manager, config, - current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -1790,7 +1784,6 @@ mod tests { ) { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let current_model = "gpt-5.2-codex".to_string(); let server = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -1806,7 +1799,6 @@ mod tests { chat_widget, auth_manager, config, - current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -1991,20 +1983,19 @@ mod tests { } #[tokio::test] - async fn update_reasoning_effort_updates_config() { + async fn update_reasoning_effort_updates_collaboration_mode() { let mut app = make_test_app().await; - app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); assert_eq!( - app.config.model_reasoning_effort, + app.chat_widget.current_reasoning_effort(), Some(ReasoningEffortConfig::High) ); assert_eq!( - app.chat_widget.config_ref().model_reasoning_effort, + app.config.model_reasoning_effort, Some(ReasoningEffortConfig::High) ); } @@ -2044,9 +2035,14 @@ mod tests { }; Arc::new(new_session_info( app.chat_widget.config_ref(), - app.current_model.as_str(), + app.chat_widget.current_model(), event, is_first, + app.chat_widget + .config_ref() + .features + .enabled(codex_core::features::Feature::CollaborationModes), + app.chat_widget.stored_collaboration_mode().clone(), )) as Arc }; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index f34e1d7d5..0b2617416 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -23,6 +23,7 @@ use crate::history_cell::HistoryCell; use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ReasoningEffort; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -102,6 +103,9 @@ pub(crate) enum AppEvent { /// Update the current model slug in the running app and widget. UpdateModel(String), + /// Update the current collaboration mode in the running app and widget. + UpdateCollaborationMode(CollaborationMode), + /// Persist the selected model and reasoning effort to the appropriate config. PersistModelSelection { model: String, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3f610c4fa..48ed806d3 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -92,6 +92,8 @@ use codex_core::skills::model::SkillMetadata; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::user_input::TextElement; @@ -374,8 +376,6 @@ pub(crate) enum ExternalEditorState { Active, } -type CollaborationModeSelection = collaboration_modes::Selection; - /// Maintains the per-session UI state and interaction state machines for the chat screen. /// /// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming @@ -404,12 +404,12 @@ pub(crate) struct ChatWidget { /// where the overlay may briefly treat new tail content as already cached. active_cell_revision: u64, config: Config, - model: Option, - /// Current UI selection for collaboration modes. + /// Stored collaboration mode with model and reasoning effort. /// - /// This selection is only meaningful when `Feature::CollaborationModes` is enabled; when the - /// feature is disabled, the value is effectively inert. - collaboration_mode: CollaborationModeSelection, + /// When collaboration modes feature is enabled, this is initialized to the first preset. + /// When disabled, this is Custom. The model and reasoning effort are stored here instead of + /// being read from config or current_model. + stored_collaboration_mode: CollaborationMode, auth_manager: Arc, models_manager: Arc, session_header: SessionHeader, @@ -709,13 +709,24 @@ impl ChatWidget { self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); - self.model = Some(model_for_header.clone()); self.session_header.set_model(&model_for_header); + // Only update stored collaboration settings when collaboration modes are disabled. + // When enabled, we preserve the selected variant (Plan/Pair/Execute/Custom) and its + // instructions as-is; the session configured event should not override it. + if !self.collaboration_modes_enabled() { + self.stored_collaboration_mode = self.stored_collaboration_mode.with_updates( + Some(model_for_header.clone()), + Some(event.reasoning_effort), + None, + ); + } let session_info_cell = history_cell::new_session_info( &self.config, &model_for_header, event, self.show_welcome_banner, + self.collaboration_modes_enabled(), + self.stored_collaboration_mode.clone(), ); self.apply_session_info_cell(session_info_cell); @@ -963,7 +974,7 @@ impl ChatWidget { if high_usage && !self.rate_limit_switch_prompt_hidden() - && self.current_model() != Some(NUDGE_MODEL_SLUG) + && self.current_model() != NUDGE_MODEL_SLUG && !matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Shown @@ -1789,19 +1800,38 @@ impl ChatWidget { is_first_run, model, } = common; - let mut config = config; let model = model.filter(|m| !m.trim().is_empty()); + let mut config = config; config.model = model.clone(); let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager); - let model_for_header = config - .model + let model_for_header = model .clone() .unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); + let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: model_for_header.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: model_for_header.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }; + let active_cell = if model.is_none() { - Some(Self::placeholder_session_header_cell(&config)) + Some(Self::placeholder_session_header_cell( + &config, + config.features.enabled(Feature::CollaborationModes), + stored_collaboration_mode.clone(), + )) } else { None }; @@ -1823,8 +1853,7 @@ impl ChatWidget { active_cell, active_cell_revision: 0, config, - model, - collaboration_mode: CollaborationModeSelection::default(), + stored_collaboration_mode, auth_manager, models_manager, session_header: SessionHeader::new(model_for_header), @@ -1905,6 +1934,22 @@ impl ChatWidget { let codex_op_tx = spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); + let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }; + let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -1922,8 +1967,7 @@ impl ChatWidget { active_cell: None, active_cell_revision: 0, config, - model: Some(header_model.clone()), - collaboration_mode: CollaborationModeSelection::default(), + stored_collaboration_mode, auth_manager, models_manager, session_header: SessionHeader::new(header_model), @@ -2204,7 +2248,7 @@ impl ChatWidget { } SlashCommand::Collab => { if self.collaboration_modes_enabled() { - self.cycle_collaboration_mode(); + self.open_collaboration_modes_popup(); } } SlashCommand::Approvals => { @@ -2363,14 +2407,10 @@ impl ChatWidget { let trimmed = args.trim(); match cmd { - SlashCommand::Collab if !trimmed.is_empty() => { - if let Some(selection) = collaboration_modes::parse_selection(trimmed) { - self.set_collaboration_mode(selection); - } else { - self.add_error_message(format!( - "Unknown collaboration mode '{trimmed}'. Try: plan, pair, execute." - )); - self.request_redraw(); + SlashCommand::Collab => { + let _ = trimmed; + if self.collaboration_modes_enabled() { + self.open_collaboration_modes_popup(); } } SlashCommand::Review if !trimmed.is_empty() => { @@ -2450,13 +2490,12 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { - let Some(model) = self.current_model().or(self.config.model.as_deref()) else { - tracing::warn!("cannot submit user message before model is known; queueing"); + if !self.is_session_configured() { + tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); self.refresh_queued_user_messages(); return; - }; - let model = model.to_string(); + } let UserMessage { text, @@ -2510,24 +2549,18 @@ impl ChatWidget { } } - let collaboration_mode = self.collaboration_modes_enabled().then(|| { - collaboration_modes::resolve_mode_or_fallback( - self.models_manager.as_ref(), - self.collaboration_mode, - model.as_str(), - self.config.model_reasoning_effort, - ) - }); let op = Op::UserTurn { items, cwd: self.config.cwd.clone(), approval_policy: self.config.approval_policy.value(), sandbox_policy: self.config.sandbox_policy.get().clone(), - model, - effort: self.config.model_reasoning_effort, + model: self.stored_collaboration_mode.model().to_string(), + effort: self.stored_collaboration_mode.reasoning_effort(), summary: self.config.model_reasoning_summary, final_output_json_schema: None, - collaboration_mode, + collaboration_mode: self + .collaboration_modes_enabled() + .then(|| self.stored_collaboration_mode.clone()), }; self.codex_op_tx.send(op).unwrap_or_else(|e| { @@ -2868,11 +2901,8 @@ impl ChatWidget { let total_usage = token_info .map(|ti| &ti.total_token_usage) .unwrap_or(&default_usage); - let collaboration_mode = if self.collaboration_modes_enabled() { - Some(self.collaboration_mode.label()) - } else { - None - }; + let collaboration_mode = self.collaboration_mode_label(); + let reasoning_effort_override = Some(self.stored_collaboration_mode.reasoning_effort()); self.add_to_history(crate::status::new_status_output( &self.config, self.auth_manager.as_ref(), @@ -2885,6 +2915,7 @@ impl ChatWidget { Local::now(), self.model_display_name(), collaboration_mode, + reasoning_effort_override, )); } @@ -3108,7 +3139,7 @@ impl ChatWidget { let current_model = self.current_model(); let current_label = presets .iter() - .find(|preset| Some(preset.model.as_str()) == current_model) + .find(|preset| preset.model.as_str() == current_model) .map(|preset| preset.display_name.to_string()) .unwrap_or_else(|| self.model_display_name().to_string()); @@ -3136,7 +3167,7 @@ impl ChatWidget { SelectionItem { name: preset.display_name.clone(), description, - is_current: Some(model.as_str()) == current_model, + is_current: model.as_str() == current_model, is_default: preset.is_default, actions, dismiss_on_select: true, @@ -3206,7 +3237,7 @@ impl ChatWidget { for preset in presets.into_iter() { let description = (!preset.description.is_empty()).then_some(preset.description.to_string()); - let is_current = Some(preset.model.as_str()) == self.current_model(); + let is_current = preset.model.as_str() == self.current_model(); let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; let preset_for_action = preset.clone(); let actions: Vec = vec![Box::new(move |tx| { @@ -3238,6 +3269,49 @@ impl ChatWidget { }); } + pub(crate) fn open_collaboration_modes_popup(&mut self) { + let presets = self.models_manager.list_collaboration_modes(); + if presets.is_empty() { + self.add_info_message( + "No collaboration modes are available right now.".to_string(), + None, + ); + return; + } + + let items: Vec = presets + .into_iter() + .map(|preset| { + let name = match preset { + CollaborationMode::Plan(_) => "Plan", + CollaborationMode::PairProgramming(_) => "Pair Programming", + CollaborationMode::Execute(_) => "Execute", + CollaborationMode::Custom(_) => "Custom", + }; + let is_current = + collaboration_modes::same_variant(&self.stored_collaboration_mode, &preset); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::UpdateCollaborationMode(preset.clone())); + })]; + SelectionItem { + name: name.to_string(), + is_current, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select Collaboration Mode".to_string()), + subtitle: Some("Pick a collaboration preset.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + fn model_selection_actions( model_for_action: String, effort_for_action: Option, @@ -3333,9 +3407,9 @@ impl ChatWidget { .or(Some(default_effort)); let model_slug = preset.model.to_string(); - let is_current_model = self.current_model() == Some(preset.model.as_str()); + let is_current_model = self.current_model() == preset.model.as_str(); let highlight_choice = if is_current_model { - self.config.model_reasoning_effort + self.stored_collaboration_mode.reasoning_effort() } else { default_choice }; @@ -4134,8 +4208,21 @@ impl ChatWidget { } if feature == Feature::Steer { self.bottom_pane.set_steer_enabled(enabled); - } else if feature == Feature::CollaborationModes { + } + if feature == Feature::CollaborationModes { self.bottom_pane.set_collaboration_modes_enabled(enabled); + let settings = match &self.stored_collaboration_mode { + CollaborationMode::Plan(settings) + | CollaborationMode::PairProgramming(settings) + | CollaborationMode::Execute(settings) + | CollaborationMode::Custom(settings) => settings.clone(), + }; + self.stored_collaboration_mode = if enabled { + collaboration_modes::default_mode(self.models_manager.as_ref()) + .unwrap_or(CollaborationMode::Custom(settings)) + } else { + CollaborationMode::Custom(settings) + }; } } @@ -4162,44 +4249,37 @@ impl ChatWidget { .unwrap_or(false) } - /// Set the reasoning effort in the widget's config copy. + /// Set the reasoning effort in the stored collaboration mode. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { - self.config.model_reasoning_effort = effort; + self.stored_collaboration_mode = + self.stored_collaboration_mode + .with_updates(None, Some(effort), None); } - /// Set the model in the widget's config copy. + /// Set the model in the widget's config copy and stored collaboration mode. pub(crate) fn set_model(&mut self, model: &str) { self.session_header.set_model(model); - self.model = Some(model.to_string()); + self.stored_collaboration_mode = + self.stored_collaboration_mode + .with_updates(Some(model.to_string()), None, None); } - fn cycle_collaboration_mode(&mut self) { - if !self.collaboration_modes_enabled() { - return; - } - let next = self.collaboration_mode.next(); - self.set_collaboration_mode(next); + pub(crate) fn current_model(&self) -> &str { + self.stored_collaboration_mode.model() } - /// Update the selected collaboration mode. - /// - /// When collaboration modes are enabled, the current selection is attached to *every* - /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. - fn set_collaboration_mode(&mut self, selection: CollaborationModeSelection) { - if !self.collaboration_modes_enabled() { - return; - } - const FLASH_DURATION: Duration = Duration::from_secs(1); - - self.collaboration_mode = selection; - - let flash = collaboration_modes::flash_line(selection); - self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); - self.request_redraw(); + #[allow(dead_code)] // Used in tests + pub(crate) fn stored_collaboration_mode(&self) -> &CollaborationMode { + &self.stored_collaboration_mode } - fn current_model(&self) -> Option<&str> { - self.model.as_deref() + #[cfg(test)] + pub(crate) fn current_reasoning_effort(&self) -> Option { + self.stored_collaboration_mode.reasoning_effort() + } + + fn is_session_configured(&self) -> bool { + self.thread_id.is_some() } fn collaboration_modes_enabled(&self) -> bool { @@ -4207,11 +4287,72 @@ impl ChatWidget { } fn model_display_name(&self) -> &str { - self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) + let model = self.current_model(); + if model.is_empty() { + DEFAULT_MODEL_DISPLAY_NAME + } else { + model + } + } + + /// Get the label for the current collaboration mode. + fn collaboration_mode_label(&self) -> Option<&'static str> { + if !self.collaboration_modes_enabled() { + return None; + } + match &self.stored_collaboration_mode { + CollaborationMode::Plan(_) => Some("Plan"), + CollaborationMode::PairProgramming(_) => Some("Pair Programming"), + CollaborationMode::Execute(_) => Some("Execute"), + CollaborationMode::Custom(_) => None, + } + } + + /// Cycle to the next collaboration mode variant (Plan -> PairProgramming -> Execute -> Plan). + fn cycle_collaboration_mode(&mut self) { + if !self.collaboration_modes_enabled() { + return; + } + + if let Some(next_mode) = collaboration_modes::next_mode( + self.models_manager.as_ref(), + &self.stored_collaboration_mode, + ) { + self.set_collaboration_mode(next_mode); + } + } + + /// Update the stored collaboration mode. + /// + /// When collaboration modes are enabled, the current mode is attached to *every* + /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. + pub(crate) fn set_collaboration_mode(&mut self, mode: CollaborationMode) { + if !self.collaboration_modes_enabled() { + return; + } + + self.stored_collaboration_mode = mode; + + let label = self.collaboration_mode_label(); + if let Some(label) = label { + let flash = Line::from(vec![ + label.bold(), + " (".dim(), + key_hint::shift(KeyCode::Tab).into(), + " to change mode)".dim(), + ]); + const FLASH_DURATION: Duration = Duration::from_secs(2); + self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); + } + self.request_redraw(); } /// Build a placeholder header cell while the session is configuring. - fn placeholder_session_header_cell(config: &Config) -> Box { + fn placeholder_session_header_cell( + config: &Config, + is_collaboration: bool, + collaboration_mode: CollaborationMode, + ) -> Box { let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC); Box::new(history_cell::SessionHeaderHistoryCell::new_with_style( DEFAULT_MODEL_DISPLAY_NAME.to_string(), @@ -4219,6 +4360,8 @@ impl ChatWidget { None, config.cwd.clone(), CODEX_CLI_VERSION, + is_collaboration, + collaboration_mode, )) } @@ -4618,10 +4761,6 @@ impl ChatWidget { self.thread_id } - fn is_session_configured(&self) -> bool { - self.thread_id.is_some() - } - pub(crate) fn rollout_path(&self) -> Option { self.current_rollout_path.clone() } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index b864a7b3e..46f5c3be9 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -756,8 +756,27 @@ async fn make_chatwidget_manual( skills: None, }); bottom.set_steer_enabled(true); + bottom.set_collaboration_modes_enabled(cfg.features.enabled(Feature::CollaborationModes)); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let codex_home = cfg.codex_home.clone(); + let models_manager = Arc::new(ModelsManager::new(codex_home, auth_manager.clone())); + let collaboration_modes_enabled = cfg.features.enabled(Feature::CollaborationModes); + let reasoning_effort = None; + let stored_collaboration_mode = if collaboration_modes_enabled { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: resolved_model.clone(), + reasoning_effort, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: resolved_model.clone(), + reasoning_effort, + developer_instructions: None, + }) + }; let widget = ChatWidget { app_event_tx, codex_op_tx: op_tx, @@ -765,10 +784,9 @@ async fn make_chatwidget_manual( active_cell: None, active_cell_revision: 0, config: cfg, - model: Some(resolved_model.clone()), - collaboration_mode: CollaborationModeSelection::default(), - auth_manager: auth_manager.clone(), - models_manager: Arc::new(ModelsManager::new(codex_home, auth_manager)), + stored_collaboration_mode, + auth_manager, + models_manager, session_header: SessionHeader::new(resolved_model), initial_user_message: None, token_info: None, @@ -1909,75 +1927,66 @@ async fn slash_init_skips_when_project_doc_exists() { ); } -#[test] -fn parse_collaboration_mode_selection_accepts_common_aliases() { - assert_eq!( - collaboration_modes::parse_selection("plan"), - Some(CollaborationModeSelection::Plan) - ); - assert_eq!( - collaboration_modes::parse_selection("PAIR"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection("pair_programming"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection("pp"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection(" exec "), - Some(CollaborationModeSelection::Execute) - ); - assert_eq!( - collaboration_modes::parse_selection("execute"), - Some(CollaborationModeSelection::Execute) - ); - assert_eq!(collaboration_modes::parse_selection("unknown"), None); -} - #[tokio::test] async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; chat.set_feature_enabled(Feature::CollaborationModes, false); - let initial = chat.collaboration_mode; + let initial = chat.stored_collaboration_mode.clone(); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, initial); + assert_eq!(chat.stored_collaboration_mode, initial); chat.set_feature_enabled(Feature::CollaborationModes, true); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Execute); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::Execute(_) + )); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::Plan(_) + )); chat.on_task_started(); + let before = chat.stored_collaboration_mode.clone(); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + assert_eq!(chat.stored_collaboration_mode, before); } #[tokio::test] -async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; +async fn collab_slash_command_opens_picker_and_updates_mode() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.dispatch_command_with_args(SlashCommand::Collab, "plan".to_string()); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + chat.dispatch_command(SlashCommand::Collab); + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains("Select Collaboration Mode"), + "expected collaboration picker: {popup}" + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let selected_mode = match rx.try_recv() { + Ok(AppEvent::UpdateCollaborationMode(mode)) => mode, + other => panic!("expected UpdateCollaborationMode event, got {other:?}"), + }; + chat.set_collaboration_mode(selected_mode); chat.bottom_pane .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::Plan(_)), + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } chat.bottom_pane @@ -1985,10 +1994,12 @@ async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::Plan(_)), + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } } @@ -2006,10 +2017,22 @@ async fn collab_mode_defaults_to_pair_programming_when_enabled() { collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } } +#[tokio::test] +async fn collab_mode_enabling_sets_pair_programming_default() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::PairProgramming(_) + )); +} + #[tokio::test] async fn slash_quit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2722,7 +2745,7 @@ async fn model_reasoning_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); - chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); @@ -2736,7 +2759,7 @@ async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); - chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); diff --git a/codex-rs/tui/src/collaboration_modes.rs b/codex-rs/tui/src/collaboration_modes.rs index bdd1d8b63..f48ad92c9 100644 --- a/codex-rs/tui/src/collaboration_modes.rs +++ b/codex-rs/tui/src/collaboration_modes.rs @@ -1,135 +1,49 @@ -//! Collaboration mode selection + rendering helpers for the TUI. -//! -//! This module is intentionally UI-focused: -//! - It owns the user-facing set of selectable collaboration modes and how they cycle. -//! - It parses `/collab ` arguments into a selection. -//! - It resolves a `Selection` to a concrete `codex_protocol::config_types::CollaborationMode` by -//! picking from the `ModelsManager` builtin collaboration presets. -//! - It builds the small footer "flash" line shown after changing modes. -//! -//! The `ChatWidget` owns the session state and decides *when* selection/mode changes are allowed -//! (feature flag, task running, modals open, etc.). This module just provides the building blocks. - -use crate::key_hint; use codex_core::models_manager::manager::ModelsManager; use codex_protocol::config_types::CollaborationMode; -use codex_protocol::config_types::Settings; -use codex_protocol::openai_models::ReasoningEffort; -use crossterm::event::KeyCode; -use ratatui::style::Stylize; -use ratatui::text::Line; -/// The user-facing collaboration mode choices supported by the TUI. -/// -/// This is distinct from `CollaborationMode`: it represents a stable UI selection and the cycling -/// order, while `CollaborationMode` can carry nested settings/prompt configuration. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(crate) enum Selection { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ModeKind { Plan, - #[default] PairProgramming, Execute, + Custom, } -impl Selection { - /// Cycle to the next selection. - /// - /// The TUI cycles through a small, fixed set of presets. - pub(crate) fn next(self) -> Self { - match self { - Self::Plan => Self::PairProgramming, - Self::PairProgramming => Self::Execute, - Self::Execute => Self::Plan, - } - } - - /// User-facing label used in UI surfaces like `/status` and the footer flash. - pub(crate) fn label(self) -> &'static str { - match self { - Self::Plan => "Plan", - Self::PairProgramming => "Pair Programming", - Self::Execute => "Execute", - } +fn mode_kind(mode: &CollaborationMode) -> ModeKind { + match mode { + CollaborationMode::Plan(_) => ModeKind::Plan, + CollaborationMode::PairProgramming(_) => ModeKind::PairProgramming, + CollaborationMode::Execute(_) => ModeKind::Execute, + CollaborationMode::Custom(_) => ModeKind::Custom, } } -/// Parse a user argument (e.g. `/collab plan`, `/collab pair_programming`) into a selection. -/// -/// The parser is forgiving: it strips whitespace, `-`, and `_`, and matches case-insensitively. -pub(crate) fn parse_selection(input: &str) -> Option { - let normalized: String = input - .chars() - .filter(|c| !c.is_ascii_whitespace() && *c != '-' && *c != '_') - .flat_map(char::to_lowercase) - .collect(); - - match normalized.as_str() { - "plan" => Some(Selection::Plan), - "pair" | "pairprogramming" | "pp" => Some(Selection::PairProgramming), - "execute" | "exec" => Some(Selection::Execute), - _ => None, - } +pub(crate) fn default_mode(models_manager: &ModelsManager) -> Option { + let presets = models_manager.list_collaboration_modes(); + presets + .iter() + .find(|preset| matches!(preset, CollaborationMode::PairProgramming(_))) + .cloned() + .or_else(|| presets.into_iter().next()) } -/// Resolve a selection to a concrete collaboration mode preset. -/// -/// `ModelsManager::list_collaboration_modes()` is expected to return a builtin set of presets; this -/// function selects the first preset of the desired variant. -pub(crate) fn resolve_mode( +pub(crate) fn same_variant(a: &CollaborationMode, b: &CollaborationMode) -> bool { + mode_kind(a) == mode_kind(b) +} + +/// Cycle to the next collaboration mode preset in list order. +pub(crate) fn next_mode( models_manager: &ModelsManager, - selection: Selection, + current: &CollaborationMode, ) -> Option { - match selection { - Selection::Plan => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::Plan(_))), - Selection::PairProgramming => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::PairProgramming(_))), - Selection::Execute => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::Execute(_))), + let presets = models_manager.list_collaboration_modes(); + if presets.is_empty() { + return None; } -} - -/// Resolve a selection to a concrete collaboration mode preset, falling back to a synthesized mode -/// when the desired preset is unavailable. -/// -/// This keeps the TUI behavior stable when collaboration presets are missing (for example, when -/// running in offline/unit-test contexts): if the feature flag is enabled, every submission carries -/// an explicit collaboration mode so core doesn't fall back to `Custom`. -pub(crate) fn resolve_mode_or_fallback( - models_manager: &ModelsManager, - selection: Selection, - fallback_model: &str, - fallback_effort: Option, -) -> CollaborationMode { - resolve_mode(models_manager, selection).unwrap_or_else(|| { - let settings = Settings { - model: fallback_model.to_string(), - reasoning_effort: fallback_effort, - developer_instructions: None, - }; - - match selection { - Selection::Plan => CollaborationMode::Plan(settings), - Selection::PairProgramming => CollaborationMode::PairProgramming(settings), - Selection::Execute => CollaborationMode::Execute(settings), - } - }) -} - -/// Build a 1-line footer "flash" that is shown after switching modes. -/// -/// The `ChatWidget` controls when to show this and how long it should remain visible. -pub(crate) fn flash_line(selection: Selection) -> Line<'static> { - Line::from(vec![ - selection.label().bold(), - " (".dim(), - key_hint::shift(KeyCode::Tab).into(), - " to change mode)".dim(), - ]) + let current_kind = mode_kind(current); + let next_index = presets + .iter() + .position(|preset| mode_kind(preset) == current_kind) + .map_or(0, |idx| (idx + 1) % presets.len()); + presets.get(next_index).cloned() } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index fd09fe89d..157ccb97a 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -19,6 +19,7 @@ use crate::exec_cell::output_lines; use crate::exec_cell::spinner; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; +use crate::key_hint; use crate::live_wrap::take_prefix_by_width; use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; @@ -43,11 +44,13 @@ use codex_core::protocol::FileChange; use codex_core::protocol::McpAuthStatus; use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::user_input::TextElement; +use crossterm::event::KeyCode; use image::DynamicImage; use image::ImageReader; use mcp_types::EmbeddedResourceResource; @@ -903,6 +906,8 @@ pub(crate) fn new_session_info( requested_model: &str, event: SessionConfiguredEvent, is_first_event: bool, + is_collaboration: bool, + collaboration_mode: CollaborationMode, ) -> SessionInfoCell { let SessionConfiguredEvent { model, @@ -915,6 +920,8 @@ pub(crate) fn new_session_info( reasoning_effort, config.cwd.clone(), CODEX_CLI_VERSION, + is_collaboration, + collaboration_mode, ); let mut parts: Vec> = vec![Box::new(header)]; @@ -991,6 +998,8 @@ pub(crate) struct SessionHeaderHistoryCell { model_style: Style, reasoning_effort: Option, directory: PathBuf, + is_collaboration: bool, + collaboration_mode: CollaborationMode, } impl SessionHeaderHistoryCell { @@ -999,6 +1008,8 @@ impl SessionHeaderHistoryCell { reasoning_effort: Option, directory: PathBuf, version: &'static str, + is_collaboration: bool, + collaboration_mode: CollaborationMode, ) -> Self { Self::new_with_style( model, @@ -1006,6 +1017,8 @@ impl SessionHeaderHistoryCell { reasoning_effort, directory, version, + is_collaboration, + collaboration_mode, ) } @@ -1015,6 +1028,8 @@ impl SessionHeaderHistoryCell { reasoning_effort: Option, directory: PathBuf, version: &'static str, + is_collaboration: bool, + collaboration_mode: CollaborationMode, ) -> Self { Self { version, @@ -1022,6 +1037,20 @@ impl SessionHeaderHistoryCell { model_style, reasoning_effort, directory, + is_collaboration, + collaboration_mode, + } + } + + fn collaboration_mode_label(&self) -> Option<&'static str> { + if !self.is_collaboration { + return None; + } + match &self.collaboration_mode { + CollaborationMode::Plan(_) => Some("Plan"), + CollaborationMode::PairProgramming(_) => Some("Pair Programming"), + CollaborationMode::Execute(_) => Some("Execute"), + CollaborationMode::Custom(_) => None, } } @@ -1082,25 +1111,49 @@ impl HistoryCell for SessionHeaderHistoryCell { const CHANGE_MODEL_HINT_COMMAND: &str = "/model"; const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change"; + const CHANGE_MODE_HINT_EXPLANATION: &str = " to change mode"; const DIR_LABEL: &str = "directory:"; let label_width = DIR_LABEL.len(); - let model_label = format!( - "{model_label:> = vec![ - Span::from(format!("{model_label} ")).dim(), - Span::styled(self.model.clone(), self.model_style), - ]; - if let Some(reasoning) = reasoning_label { - model_spans.push(Span::from(" ")); - model_spans.push(Span::from(reasoning)); - } - model_spans.push(" ".dim()); - model_spans.push(CHANGE_MODEL_HINT_COMMAND.cyan()); - model_spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim()); + + let model_spans: Vec> = if self.is_collaboration { + // Render collaboration mode instead of model + let collab_label = format!( + "{collab_label: = key_hint::shift(KeyCode::Tab).into(); + spans.push(shift_tab_span.cyan()); + spans.push(CHANGE_MODE_HINT_EXPLANATION.dim()); + spans + } else { + // Render model as before + let model_label = format!( + "{model_label:, model_name: &str, collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let card = StatusHistoryCell::new( @@ -99,6 +102,7 @@ pub(crate) fn new_status_output( now, model_name, collaboration_mode, + reasoning_effort_override, ); CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) @@ -118,8 +122,29 @@ impl StatusHistoryCell { now: DateTime, model_name: &str, collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, ) -> Self { - let config_entries = create_config_summary_entries(config, model_name); + let mut config_entries = vec![ + ("workdir", config.cwd.display().to_string()), + ("model", model_name.to_string()), + ("provider", config.model_provider_id.clone()), + ("approval", config.approval_policy.value().to_string()), + ( + "sandbox", + summarize_sandbox_policy(config.sandbox_policy.get()), + ), + ]; + if config.model_provider.wire_api == WireApi::Responses { + let effort_value = reasoning_effort_override + .unwrap_or(None) + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "none".to_string()); + config_entries.push(("reasoning effort", effort_value)); + config_entries.push(( + "reasoning summaries", + config.model_reasoning_summary.to_string(), + )); + } let (model_name, model_details) = compose_model_display(model_name, &config_entries); let approval = config_entries .iter() diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index bc421da42..92ed34081 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -95,7 +95,6 @@ async fn status_snapshot_includes_reasoning_details() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; config .sandbox_policy @@ -141,6 +140,7 @@ async fn status_snapshot_includes_reasoning_details() { let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); let token_info = token_info_for(&model_slug, &config, &usage); + let reasoning_effort_override = Some(Some(ReasoningEffort::High)); let composite = new_status_output( &config, &auth_manager, @@ -153,6 +153,7 @@ async fn status_snapshot_includes_reasoning_details() { captured_at, &model_slug, None, + reasoning_effort_override, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -205,6 +206,7 @@ async fn status_snapshot_includes_forked_from() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -263,6 +265,7 @@ async fn status_snapshot_includes_monthly_limit() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -309,6 +312,7 @@ async fn status_snapshot_shows_unlimited_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -354,6 +358,7 @@ async fn status_snapshot_shows_positive_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -399,6 +404,7 @@ async fn status_snapshot_hides_zero_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -442,6 +448,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -485,6 +492,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { now, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); @@ -500,7 +508,6 @@ async fn status_snapshot_truncates_in_narrow_terminal() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; config.cwd = PathBuf::from("/workspace/tests"); @@ -531,6 +538,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); let token_info = token_info_for(&model_slug, &config, &usage); + let reasoning_effort_override = Some(Some(ReasoningEffort::High)); let composite = new_status_output( &config, &auth_manager, @@ -543,6 +551,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { captured_at, &model_slug, None, + reasoning_effort_override, ); let mut rendered_lines = render_lines(&composite.display_lines(70)); if cfg!(windows) { @@ -590,6 +599,7 @@ async fn status_snapshot_shows_missing_limits_message() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -655,6 +665,7 @@ async fn status_snapshot_includes_credits_and_limits() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -708,6 +719,7 @@ async fn status_snapshot_shows_empty_limits_message() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -770,6 +782,7 @@ async fn status_snapshot_shows_stale_limits_message() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -836,6 +849,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -892,6 +906,7 @@ async fn status_context_window_uses_last_usage() { now, &model_slug, None, + None, ); let rendered_lines = render_lines(&composite.display_lines(80)); let context_line = rendered_lines diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 99fc2e188..920d57145 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -311,7 +311,6 @@ async fn handle_model_migration_prompt_if_needed( from_model: model.to_string(), to_model: target_model.clone(), }); - config.model = Some(target_model.clone()); let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping && let Some(reasoning_effort) = config.model_reasoning_effort @@ -324,8 +323,8 @@ async fn handle_model_migration_prompt_if_needed( config.model_reasoning_effort }; + config.model = Some(target_model.clone()); config.model_reasoning_effort = mapped_effort; - app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort)); app_event_tx.send(AppEvent::PersistModelSelection { @@ -361,7 +360,6 @@ pub(crate) struct App { pub(crate) auth_manager: Arc, /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, - pub(crate) current_model: String, pub(crate) active_profile: Option, pub(crate) file_search: FileSearchManager, @@ -439,7 +437,7 @@ impl App { models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, - model: Some(self.current_model.clone()), + model: Some(self.chat_widget.current_model().to_string()), } } @@ -517,7 +515,7 @@ impl App { models_manager: thread_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, - model: config.model.clone(), + model: Some(model.clone()), }; ChatWidget::new(init, thread_manager.clone()) } @@ -605,7 +603,6 @@ impl App { chat_widget, auth_manager: auth_manager.clone(), config, - current_model: model.clone(), active_profile, file_search, enhanced_keys_supported, @@ -1474,6 +1471,9 @@ impl App { async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { AppEvent::NewSession => { + let model = self.chat_widget.current_model().to_string(); + // Propagate selected model into config before spawning thread + self.config.model = Some(model.clone()); let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.conversation_id(), @@ -1493,7 +1493,7 @@ impl App { models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, - model: Some(self.current_model.clone()), + model: Some(model), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); if let Some(summary) = summary { @@ -1713,7 +1713,11 @@ impl App { } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); - self.current_model = model; + } + AppEvent::UpdateCollaborationMode(mode) => { + let model = mode.model().to_string(); + self.chat_widget.set_collaboration_mode(mode); + self.chat_widget.set_model(&model); } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); @@ -2138,8 +2142,10 @@ impl App { } fn on_update_reasoning_effort(&mut self, effort: Option) { - self.chat_widget.set_reasoning_effort(effort); + // TODO(aibrahim): Remove this and don't use config as a state object. + // Instead, explicitly pass the stored collaboration mode's effort into new sessions. self.config.model_reasoning_effort = effort; + self.chat_widget.set_reasoning_effort(effort); } async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { @@ -2360,7 +2366,6 @@ mod tests { async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let current_model = "gpt-5.2-codex".to_string(); let server = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -2375,7 +2380,6 @@ mod tests { chat_widget, auth_manager, config, - current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -2413,7 +2417,6 @@ mod tests { ) { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let current_model = "gpt-5.2-codex".to_string(); let server = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -2429,7 +2432,6 @@ mod tests { chat_widget, auth_manager, config, - current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -2628,20 +2630,19 @@ mod tests { } #[tokio::test] - async fn update_reasoning_effort_updates_config() { + async fn update_reasoning_effort_updates_collaboration_mode() { let mut app = make_test_app().await; - app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); assert_eq!( - app.config.model_reasoning_effort, + app.chat_widget.current_reasoning_effort(), Some(ReasoningEffortConfig::High) ); assert_eq!( - app.chat_widget.config_ref().model_reasoning_effort, + app.config.model_reasoning_effort, Some(ReasoningEffortConfig::High) ); } @@ -2681,7 +2682,7 @@ mod tests { }; Arc::new(new_session_info( app.chat_widget.config_ref(), - app.current_model.as_str(), + app.chat_widget.current_model(), event, is_first, )) as Arc diff --git a/codex-rs/tui2/src/app_event.rs b/codex-rs/tui2/src/app_event.rs index b2a70cae1..ff3f0e387 100644 --- a/codex-rs/tui2/src/app_event.rs +++ b/codex-rs/tui2/src/app_event.rs @@ -21,6 +21,7 @@ use crate::history_cell::HistoryCell; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ReasoningEffort; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -96,6 +97,9 @@ pub(crate) enum AppEvent { /// Update the current model slug in the running app and widget. UpdateModel(String), + /// Update the current collaboration mode in the running app and widget. + UpdateCollaborationMode(CollaborationMode), + /// Persist the selected model and reasoning effort to the appropriate config. PersistModelSelection { model: String, diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 6c11077c8..c4cdf7669 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -90,6 +90,8 @@ use codex_core::skills::model::SkillMetadata; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::user_input::TextElement; @@ -319,8 +321,6 @@ enum RateLimitSwitchPromptState { Shown, } -type CollaborationModeSelection = collaboration_modes::Selection; - /// Maintains the per-session UI state and interaction state machines for the chat screen. /// /// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming @@ -349,12 +349,12 @@ pub(crate) struct ChatWidget { /// where the overlay may briefly treat new tail content as already cached. active_cell_revision: u64, config: Config, - model: Option, - /// Current UI selection for collaboration modes. + /// Stored collaboration mode with model and reasoning effort. /// - /// This selection is only meaningful when `Feature::CollaborationModes` is enabled; when the - /// feature is disabled, the value is effectively inert. - collaboration_mode: CollaborationModeSelection, + /// When collaboration modes feature is enabled, this is initialized to the first preset. + /// When disabled, this is Custom. The model and reasoning effort are stored here instead of + /// being read from config or current_model. + stored_collaboration_mode: CollaborationMode, auth_manager: Arc, models_manager: Arc, session_header: SessionHeader, @@ -631,8 +631,17 @@ impl ChatWidget { self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); - self.model = Some(model_for_header.clone()); self.session_header.set_model(&model_for_header); + // Only update stored collaboration settings when collaboration modes are disabled. + // When enabled, we preserve the selected variant (Plan/Pair/Execute/Custom) and its + // instructions as-is; the session configured event should not override it. + if !self.collaboration_modes_enabled() { + self.stored_collaboration_mode = self.stored_collaboration_mode.with_updates( + Some(model_for_header.clone()), + Some(event.reasoning_effort), + None, + ); + } let session_info_cell = history_cell::new_session_info( &self.config, &model_for_header, @@ -876,7 +885,7 @@ impl ChatWidget { if high_usage && !self.rate_limit_switch_prompt_hidden() - && self.current_model() != Some(NUDGE_MODEL_SLUG) + && self.current_model() != NUDGE_MODEL_SLUG && !matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Shown @@ -1591,15 +1600,14 @@ impl ChatWidget { is_first_run, model, } = common; - let mut config = config; let model = model.filter(|m| !m.trim().is_empty()); + let mut config = config; config.model = model.clone(); let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager); - let model_for_header = config - .model + let model_for_header = model .clone() .unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); let active_cell = if model.is_none() { @@ -1608,6 +1616,22 @@ impl ChatWidget { None }; + let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: model_for_header.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: model_for_header.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }; + let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -1625,8 +1649,7 @@ impl ChatWidget { active_cell, active_cell_revision: 0, config, - model, - collaboration_mode: CollaborationModeSelection::default(), + stored_collaboration_mode, auth_manager, models_manager, session_header: SessionHeader::new(model_for_header), @@ -1684,7 +1707,7 @@ impl ChatWidget { session_configured: codex_core::protocol::SessionConfiguredEvent, ) -> Self { let ChatWidgetInit { - mut config, + config, frame_requester, app_event_tx, initial_user_message, @@ -1696,7 +1719,7 @@ impl ChatWidget { .. } = common; let model = model.filter(|m| !m.trim().is_empty()); - config.model = model.clone(); + let config = config; let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); @@ -1705,6 +1728,22 @@ impl ChatWidget { let codex_op_tx = spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); + let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }; + let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -1722,8 +1761,7 @@ impl ChatWidget { active_cell: None, active_cell_revision: 0, config, - model: Some(header_model.clone()), - collaboration_mode: CollaborationModeSelection::default(), + stored_collaboration_mode, auth_manager, models_manager, session_header: SessionHeader::new(header_model), @@ -1976,7 +2014,7 @@ impl ChatWidget { } SlashCommand::Collab => { if self.collaboration_modes_enabled() { - self.cycle_collaboration_mode(); + self.open_collaboration_modes_popup(); } } SlashCommand::Approvals => { @@ -2144,13 +2182,8 @@ impl ChatWidget { return; } - if let Some(selection) = collaboration_modes::parse_selection(trimmed) { - self.set_collaboration_mode(selection); - } else if !trimmed.is_empty() { - self.add_error_message(format!( - "Unknown collaboration mode '{trimmed}'. Try: plan, pair, execute." - )); - } + let _ = trimmed; + self.open_collaboration_modes_popup(); } _ => self.dispatch_command(cmd), } @@ -2217,13 +2250,12 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { - let Some(model) = self.current_model().or(self.config.model.as_deref()) else { - tracing::warn!("cannot submit user message before model is known; queueing"); + if !self.is_session_configured() { + tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); self.refresh_queued_user_messages(); return; - }; - let model = model.to_string(); + } let UserMessage { text, @@ -2277,24 +2309,18 @@ impl ChatWidget { } } - let collaboration_mode = self.collaboration_modes_enabled().then(|| { - collaboration_modes::resolve_mode_or_fallback( - self.models_manager.as_ref(), - self.collaboration_mode, - model.as_str(), - self.config.model_reasoning_effort, - ) - }); let op = Op::UserTurn { items, cwd: self.config.cwd.clone(), approval_policy: self.config.approval_policy.value(), sandbox_policy: self.config.sandbox_policy.get().clone(), - model, - effort: self.config.model_reasoning_effort, + model: self.stored_collaboration_mode.model().to_string(), + effort: self.stored_collaboration_mode.reasoning_effort(), summary: self.config.model_reasoning_summary, final_output_json_schema: None, - collaboration_mode, + collaboration_mode: self + .collaboration_modes_enabled() + .then(|| self.stored_collaboration_mode.clone()), }; if !self.agent_turn_running { @@ -2634,6 +2660,7 @@ impl ChatWidget { let total_usage = token_info .map(|ti| &ti.total_token_usage) .unwrap_or(&default_usage); + let reasoning_effort_override = Some(self.stored_collaboration_mode.reasoning_effort()); self.add_to_history(crate::status::new_status_output( &self.config, self.auth_manager.as_ref(), @@ -2645,8 +2672,8 @@ impl ChatWidget { self.plan_type, Local::now(), self.model_display_name(), - self.collaboration_modes_enabled() - .then_some(self.collaboration_mode.label()), + self.collaboration_mode_label(), + reasoning_effort_override, )); } fn stop_rate_limit_poller(&mut self) { @@ -2821,7 +2848,7 @@ impl ChatWidget { let current_model = self.current_model(); let current_label = presets .iter() - .find(|preset| Some(preset.model.as_str()) == current_model) + .find(|preset| preset.model.as_str() == current_model) .map(|preset| preset.display_name.to_string()) .unwrap_or_else(|| self.model_display_name().to_string()); @@ -2849,7 +2876,7 @@ impl ChatWidget { SelectionItem { name: preset.display_name.clone(), description, - is_current: Some(model.as_str()) == current_model, + is_current: model.as_str() == current_model, is_default: preset.is_default, actions, dismiss_on_select: true, @@ -2916,7 +2943,7 @@ impl ChatWidget { for preset in presets.into_iter() { let description = (!preset.description.is_empty()).then_some(preset.description.to_string()); - let is_current = Some(preset.model.as_str()) == self.current_model(); + let is_current = preset.model.as_str() == self.current_model(); let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; let preset_for_action = preset.clone(); let actions: Vec = vec![Box::new(move |tx| { @@ -2948,6 +2975,49 @@ impl ChatWidget { }); } + pub(crate) fn open_collaboration_modes_popup(&mut self) { + let presets = self.models_manager.list_collaboration_modes(); + if presets.is_empty() { + self.add_info_message( + "No collaboration modes are available right now.".to_string(), + None, + ); + return; + } + + let items: Vec = presets + .into_iter() + .map(|preset| { + let name = match preset { + CollaborationMode::Plan(_) => "Plan", + CollaborationMode::PairProgramming(_) => "Pair Programming", + CollaborationMode::Execute(_) => "Execute", + CollaborationMode::Custom(_) => "Custom", + }; + let is_current = + collaboration_modes::same_variant(&self.stored_collaboration_mode, &preset); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::UpdateCollaborationMode(preset.clone())); + })]; + SelectionItem { + name: name.to_string(), + is_current, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select Collaboration Mode".to_string()), + subtitle: Some("Pick a collaboration preset.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + fn model_selection_actions( model_for_action: String, effort_for_action: Option, @@ -3043,9 +3113,9 @@ impl ChatWidget { .or(Some(default_effort)); let model_slug = preset.model.to_string(); - let is_current_model = self.current_model() == Some(preset.model.as_str()); + let is_current_model = self.current_model() == preset.model.as_str(); let highlight_choice = if is_current_model { - self.config.model_reasoning_effort + self.stored_collaboration_mode.reasoning_effort() } else { default_choice }; @@ -3824,6 +3894,18 @@ impl ChatWidget { } if feature == Feature::CollaborationModes { self.bottom_pane.set_collaboration_modes_enabled(enabled); + let settings = match &self.stored_collaboration_mode { + CollaborationMode::Plan(settings) + | CollaborationMode::PairProgramming(settings) + | CollaborationMode::Execute(settings) + | CollaborationMode::Custom(settings) => settings.clone(), + }; + self.stored_collaboration_mode = if enabled { + collaboration_modes::default_mode(self.models_manager.as_ref()) + .unwrap_or(CollaborationMode::Custom(settings)) + } else { + CollaborationMode::Custom(settings) + }; } } @@ -3850,19 +3932,32 @@ impl ChatWidget { .unwrap_or(false) } - /// Set the reasoning effort in the widget's config copy. + /// Set the reasoning effort in the stored collaboration mode. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { - self.config.model_reasoning_effort = effort; + self.stored_collaboration_mode = + self.stored_collaboration_mode + .with_updates(None, Some(effort), None); } - /// Set the model in the widget's config copy. + /// Set the model in the widget's config copy and stored collaboration mode. pub(crate) fn set_model(&mut self, model: &str) { self.session_header.set_model(model); - self.model = Some(model.to_string()); + self.stored_collaboration_mode = + self.stored_collaboration_mode + .with_updates(Some(model.to_string()), None, None); } - fn current_model(&self) -> Option<&str> { - self.model.as_deref() + pub(crate) fn current_model(&self) -> &str { + self.stored_collaboration_mode.model() + } + + #[cfg(test)] + pub(crate) fn current_reasoning_effort(&self) -> Option { + self.stored_collaboration_mode.reasoning_effort() + } + + fn is_session_configured(&self) -> bool { + self.conversation_id.is_some() } fn collaboration_modes_enabled(&self) -> bool { @@ -3870,31 +3965,63 @@ impl ChatWidget { } fn model_display_name(&self) -> &str { - self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) + let model = self.current_model(); + if model.is_empty() { + DEFAULT_MODEL_DISPLAY_NAME + } else { + model + } } + /// Get the label for the current collaboration mode. + fn collaboration_mode_label(&self) -> Option<&'static str> { + if !self.collaboration_modes_enabled() { + return None; + } + match &self.stored_collaboration_mode { + CollaborationMode::Plan(_) => Some("Plan"), + CollaborationMode::PairProgramming(_) => Some("Pair Programming"), + CollaborationMode::Execute(_) => Some("Execute"), + CollaborationMode::Custom(_) => None, + } + } + + /// Cycle to the next collaboration mode variant (Plan -> PairProgramming -> Execute -> Plan). fn cycle_collaboration_mode(&mut self) { if !self.collaboration_modes_enabled() { return; } - let next = self.collaboration_mode.next(); - self.set_collaboration_mode(next); + if let Some(next_mode) = collaboration_modes::next_mode( + self.models_manager.as_ref(), + &self.stored_collaboration_mode, + ) { + self.set_collaboration_mode(next_mode); + } } - /// Update the selected collaboration mode. + /// Update the stored collaboration mode. /// - /// When collaboration modes are enabled, the current selection is attached to *every* + /// When collaboration modes are enabled, the current mode is attached to *every* /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. - fn set_collaboration_mode(&mut self, selection: CollaborationModeSelection) { + pub(crate) fn set_collaboration_mode(&mut self, mode: CollaborationMode) { if !self.collaboration_modes_enabled() { return; } - self.collaboration_mode = selection; - let flash = collaboration_modes::flash_line(selection); - const FLASH_DURATION: Duration = Duration::from_secs(2); - self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); + self.stored_collaboration_mode = mode; + + let label = self.collaboration_mode_label(); + if let Some(label) = label { + let flash = Line::from(vec![ + label.bold(), + " (".dim(), + key_hint::shift(KeyCode::Tab).into(), + " to change mode)".dim(), + ]); + const FLASH_DURATION: Duration = Duration::from_secs(2); + self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); + } self.request_redraw(); } @@ -4337,10 +4464,6 @@ impl ChatWidget { self.current_rollout_path.clone() } - fn is_session_configured(&self) -> bool { - self.conversation_id.is_some() - } - /// Returns a cache key describing the current in-flight active cell for the transcript overlay. /// /// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 9d5d7e67b..f1cf4c32e 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -59,6 +59,7 @@ use codex_core::protocol::WarningEvent; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::parse_command::ParsedCommand; @@ -692,13 +693,14 @@ async fn helpers_are_available_and_do_not_panic() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let cfg = test_config().await; + let model = cfg.model.clone(); let thread_manager = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("test"), cfg.model_provider.clone(), )); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let init = ChatWidgetInit { - config: cfg.clone(), + config: cfg, frame_requester: FrameRequester::test_dummy(), app_event_tx: tx, initial_user_message: None, @@ -707,7 +709,7 @@ async fn helpers_are_available_and_do_not_panic() { models_manager: thread_manager.get_models_manager(), feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, - model: cfg.model, + model, }; let mut w = ChatWidget::new(init, thread_manager); // Basic construction sanity. @@ -746,6 +748,24 @@ async fn make_chatwidget_manual( bottom.set_collaboration_modes_enabled(cfg.features.enabled(Feature::CollaborationModes)); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let codex_home = cfg.codex_home.clone(); + let models_manager = Arc::new(ModelsManager::new(codex_home, auth_manager.clone())); + let collaboration_modes_enabled = cfg.features.enabled(Feature::CollaborationModes); + let reasoning_effort = None; + let stored_collaboration_mode = if collaboration_modes_enabled { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: resolved_model.clone(), + reasoning_effort, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: resolved_model.clone(), + reasoning_effort, + developer_instructions: None, + }) + }; let widget = ChatWidget { app_event_tx, codex_op_tx: op_tx, @@ -753,10 +773,9 @@ async fn make_chatwidget_manual( active_cell: None, active_cell_revision: 0, config: cfg, - model: Some(resolved_model.clone()), - collaboration_mode: CollaborationModeSelection::default(), - auth_manager: auth_manager.clone(), - models_manager: Arc::new(ModelsManager::new(codex_home, auth_manager)), + stored_collaboration_mode, + auth_manager, + models_manager, session_header: SessionHeader::new(resolved_model), initial_user_message: None, token_info: None, @@ -1689,75 +1708,66 @@ async fn slash_init_skips_when_project_doc_exists() { ); } -#[test] -fn parse_collaboration_mode_selection_accepts_common_aliases() { - assert_eq!( - collaboration_modes::parse_selection("plan"), - Some(CollaborationModeSelection::Plan) - ); - assert_eq!( - collaboration_modes::parse_selection("PAIR"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection("pair_programming"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection("pp"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection(" exec "), - Some(CollaborationModeSelection::Execute) - ); - assert_eq!( - collaboration_modes::parse_selection("execute"), - Some(CollaborationModeSelection::Execute) - ); - assert_eq!(collaboration_modes::parse_selection("unknown"), None); -} - #[tokio::test] async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; chat.set_feature_enabled(Feature::CollaborationModes, false); - let initial = chat.collaboration_mode; + let initial = chat.stored_collaboration_mode.clone(); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, initial); + assert_eq!(chat.stored_collaboration_mode, initial); chat.set_feature_enabled(Feature::CollaborationModes, true); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Execute); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::Execute(_) + )); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::Plan(_) + )); chat.on_task_started(); + let before = chat.stored_collaboration_mode.clone(); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + assert_eq!(chat.stored_collaboration_mode, before); } #[tokio::test] -async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; +async fn collab_slash_command_opens_picker_and_updates_mode() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.conversation_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.dispatch_command_with_args(SlashCommand::Collab, "plan".to_string()); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + chat.dispatch_command(SlashCommand::Collab); + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains("Select Collaboration Mode"), + "expected collaboration picker: {popup}" + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let selected_mode = match rx.try_recv() { + Ok(AppEvent::UpdateCollaborationMode(mode)) => mode, + other => panic!("expected UpdateCollaborationMode event, got {other:?}"), + }; + chat.set_collaboration_mode(selected_mode); chat.bottom_pane .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::Plan(_)), + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } chat.bottom_pane @@ -1765,10 +1775,12 @@ async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::Plan(_)), + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } } @@ -1786,10 +1798,22 @@ async fn collab_mode_defaults_to_pair_programming_when_enabled() { collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } } +#[tokio::test] +async fn collab_mode_enabling_sets_pair_programming_default() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::PairProgramming(_) + )); +} + #[tokio::test] async fn slash_quit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2436,7 +2460,7 @@ async fn model_reasoning_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); - chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); @@ -2450,7 +2474,7 @@ async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); - chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); diff --git a/codex-rs/tui2/src/collaboration_modes.rs b/codex-rs/tui2/src/collaboration_modes.rs index bdd1d8b63..f48ad92c9 100644 --- a/codex-rs/tui2/src/collaboration_modes.rs +++ b/codex-rs/tui2/src/collaboration_modes.rs @@ -1,135 +1,49 @@ -//! Collaboration mode selection + rendering helpers for the TUI. -//! -//! This module is intentionally UI-focused: -//! - It owns the user-facing set of selectable collaboration modes and how they cycle. -//! - It parses `/collab ` arguments into a selection. -//! - It resolves a `Selection` to a concrete `codex_protocol::config_types::CollaborationMode` by -//! picking from the `ModelsManager` builtin collaboration presets. -//! - It builds the small footer "flash" line shown after changing modes. -//! -//! The `ChatWidget` owns the session state and decides *when* selection/mode changes are allowed -//! (feature flag, task running, modals open, etc.). This module just provides the building blocks. - -use crate::key_hint; use codex_core::models_manager::manager::ModelsManager; use codex_protocol::config_types::CollaborationMode; -use codex_protocol::config_types::Settings; -use codex_protocol::openai_models::ReasoningEffort; -use crossterm::event::KeyCode; -use ratatui::style::Stylize; -use ratatui::text::Line; -/// The user-facing collaboration mode choices supported by the TUI. -/// -/// This is distinct from `CollaborationMode`: it represents a stable UI selection and the cycling -/// order, while `CollaborationMode` can carry nested settings/prompt configuration. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(crate) enum Selection { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ModeKind { Plan, - #[default] PairProgramming, Execute, + Custom, } -impl Selection { - /// Cycle to the next selection. - /// - /// The TUI cycles through a small, fixed set of presets. - pub(crate) fn next(self) -> Self { - match self { - Self::Plan => Self::PairProgramming, - Self::PairProgramming => Self::Execute, - Self::Execute => Self::Plan, - } - } - - /// User-facing label used in UI surfaces like `/status` and the footer flash. - pub(crate) fn label(self) -> &'static str { - match self { - Self::Plan => "Plan", - Self::PairProgramming => "Pair Programming", - Self::Execute => "Execute", - } +fn mode_kind(mode: &CollaborationMode) -> ModeKind { + match mode { + CollaborationMode::Plan(_) => ModeKind::Plan, + CollaborationMode::PairProgramming(_) => ModeKind::PairProgramming, + CollaborationMode::Execute(_) => ModeKind::Execute, + CollaborationMode::Custom(_) => ModeKind::Custom, } } -/// Parse a user argument (e.g. `/collab plan`, `/collab pair_programming`) into a selection. -/// -/// The parser is forgiving: it strips whitespace, `-`, and `_`, and matches case-insensitively. -pub(crate) fn parse_selection(input: &str) -> Option { - let normalized: String = input - .chars() - .filter(|c| !c.is_ascii_whitespace() && *c != '-' && *c != '_') - .flat_map(char::to_lowercase) - .collect(); - - match normalized.as_str() { - "plan" => Some(Selection::Plan), - "pair" | "pairprogramming" | "pp" => Some(Selection::PairProgramming), - "execute" | "exec" => Some(Selection::Execute), - _ => None, - } +pub(crate) fn default_mode(models_manager: &ModelsManager) -> Option { + let presets = models_manager.list_collaboration_modes(); + presets + .iter() + .find(|preset| matches!(preset, CollaborationMode::PairProgramming(_))) + .cloned() + .or_else(|| presets.into_iter().next()) } -/// Resolve a selection to a concrete collaboration mode preset. -/// -/// `ModelsManager::list_collaboration_modes()` is expected to return a builtin set of presets; this -/// function selects the first preset of the desired variant. -pub(crate) fn resolve_mode( +pub(crate) fn same_variant(a: &CollaborationMode, b: &CollaborationMode) -> bool { + mode_kind(a) == mode_kind(b) +} + +/// Cycle to the next collaboration mode preset in list order. +pub(crate) fn next_mode( models_manager: &ModelsManager, - selection: Selection, + current: &CollaborationMode, ) -> Option { - match selection { - Selection::Plan => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::Plan(_))), - Selection::PairProgramming => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::PairProgramming(_))), - Selection::Execute => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::Execute(_))), + let presets = models_manager.list_collaboration_modes(); + if presets.is_empty() { + return None; } -} - -/// Resolve a selection to a concrete collaboration mode preset, falling back to a synthesized mode -/// when the desired preset is unavailable. -/// -/// This keeps the TUI behavior stable when collaboration presets are missing (for example, when -/// running in offline/unit-test contexts): if the feature flag is enabled, every submission carries -/// an explicit collaboration mode so core doesn't fall back to `Custom`. -pub(crate) fn resolve_mode_or_fallback( - models_manager: &ModelsManager, - selection: Selection, - fallback_model: &str, - fallback_effort: Option, -) -> CollaborationMode { - resolve_mode(models_manager, selection).unwrap_or_else(|| { - let settings = Settings { - model: fallback_model.to_string(), - reasoning_effort: fallback_effort, - developer_instructions: None, - }; - - match selection { - Selection::Plan => CollaborationMode::Plan(settings), - Selection::PairProgramming => CollaborationMode::PairProgramming(settings), - Selection::Execute => CollaborationMode::Execute(settings), - } - }) -} - -/// Build a 1-line footer "flash" that is shown after switching modes. -/// -/// The `ChatWidget` controls when to show this and how long it should remain visible. -pub(crate) fn flash_line(selection: Selection) -> Line<'static> { - Line::from(vec![ - selection.label().bold(), - " (".dim(), - key_hint::shift(KeyCode::Tab).into(), - " to change mode)".dim(), - ]) + let current_kind = mode_kind(current); + let next_index = presets + .iter() + .position(|preset| mode_kind(preset) == current_kind) + .map_or(0, |idx| (idx + 1) % presets.len()); + presets.get(next_index).cloned() } diff --git a/codex-rs/tui2/src/status/card.rs b/codex-rs/tui2/src/status/card.rs index 7988c7d0f..1de70f145 100644 --- a/codex-rs/tui2/src/status/card.rs +++ b/codex-rs/tui2/src/status/card.rs @@ -5,7 +5,8 @@ use crate::history_cell::with_border_with_inner_width; use crate::version::CODEX_CLI_VERSION; use chrono::DateTime; use chrono::Local; -use codex_common::create_config_summary_entries; +use codex_common::summarize_sandbox_policy; +use codex_core::WireApi; use codex_core::config::Config; use codex_core::protocol::NetworkAccess; use codex_core::protocol::SandboxPolicy; @@ -13,6 +14,7 @@ use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; +use codex_protocol::openai_models::ReasoningEffort; use ratatui::prelude::*; use ratatui::style::Stylize; use std::collections::BTreeSet; @@ -85,6 +87,7 @@ pub(crate) fn new_status_output( now: DateTime, model_name: &str, collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let card = StatusHistoryCell::new( @@ -99,6 +102,7 @@ pub(crate) fn new_status_output( now, model_name, collaboration_mode, + reasoning_effort_override, ); CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) @@ -118,8 +122,29 @@ impl StatusHistoryCell { now: DateTime, model_name: &str, collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, ) -> Self { - let config_entries = create_config_summary_entries(config, model_name); + let mut config_entries = vec![ + ("workdir", config.cwd.display().to_string()), + ("model", model_name.to_string()), + ("provider", config.model_provider_id.clone()), + ("approval", config.approval_policy.value().to_string()), + ( + "sandbox", + summarize_sandbox_policy(config.sandbox_policy.get()), + ), + ]; + if config.model_provider.wire_api == WireApi::Responses { + let effort_value = reasoning_effort_override + .unwrap_or(None) + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "none".to_string()); + config_entries.push(("reasoning effort", effort_value)); + config_entries.push(( + "reasoning summaries", + config.model_reasoning_summary.to_string(), + )); + } let (model_name, model_details) = compose_model_display(model_name, &config_entries); let approval = config_entries .iter() diff --git a/codex-rs/tui2/src/status/tests.rs b/codex-rs/tui2/src/status/tests.rs index 9d0f6d083..8f24af59d 100644 --- a/codex-rs/tui2/src/status/tests.rs +++ b/codex-rs/tui2/src/status/tests.rs @@ -95,7 +95,6 @@ async fn status_snapshot_includes_reasoning_details() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; config .sandbox_policy @@ -141,6 +140,7 @@ async fn status_snapshot_includes_reasoning_details() { let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); let token_info = token_info_for(&model_slug, &config, &usage); + let reasoning_effort_override = Some(Some(ReasoningEffort::High)); let composite = new_status_output( &config, &auth_manager, @@ -153,6 +153,7 @@ async fn status_snapshot_includes_reasoning_details() { captured_at, &model_slug, None, + reasoning_effort_override, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -205,6 +206,7 @@ async fn status_snapshot_includes_forked_from() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -263,6 +265,7 @@ async fn status_snapshot_includes_monthly_limit() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -308,6 +311,7 @@ async fn status_snapshot_shows_unlimited_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -353,6 +357,7 @@ async fn status_snapshot_shows_positive_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -398,6 +403,7 @@ async fn status_snapshot_hides_zero_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -441,6 +447,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -484,6 +491,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { now, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); @@ -499,7 +507,6 @@ async fn status_snapshot_truncates_in_narrow_terminal() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; config.cwd = PathBuf::from("/workspace/tests"); @@ -530,6 +537,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); let token_info = token_info_for(&model_slug, &config, &usage); + let reasoning_effort_override = Some(Some(ReasoningEffort::High)); let composite = new_status_output( &config, &auth_manager, @@ -542,6 +550,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { captured_at, &model_slug, None, + reasoning_effort_override, ); let mut rendered_lines = render_lines(&composite.display_lines(70)); if cfg!(windows) { @@ -589,6 +598,7 @@ async fn status_snapshot_shows_missing_limits_message() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -653,6 +663,7 @@ async fn status_snapshot_includes_credits_and_limits() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -706,6 +717,7 @@ async fn status_snapshot_shows_empty_limits_message() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -768,6 +780,7 @@ async fn status_snapshot_shows_stale_limits_message() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -834,6 +847,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -890,6 +904,7 @@ async fn status_context_window_uses_last_usage() { now, &model_slug, None, + None, ); let rendered_lines = render_lines(&composite.display_lines(80)); let context_line = rendered_lines