mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Tui: use collaboration mode instead of model and effort (#9507)
- Only use collaboration modes in the tui state to track model and effort. - No behavior change without the collaboration modes flag. - Change model and effort on /model, /collab (behind a flag), and shift+tab (behind flag)
This commit is contained in:
committed by
GitHub
Unverified
parent
7b27aa7707
commit
5ae6e70801
+22
-26
@@ -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<AuthManager>,
|
||||
/// 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<String>,
|
||||
|
||||
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<AppRunControl> {
|
||||
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<Line<'static>> = 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<Line<'static>> =
|
||||
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<Line<'static>> =
|
||||
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<ReasoningEffortConfig>) {
|
||||
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<dyn HistoryCell>
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+224
-85
@@ -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<String>,
|
||||
/// 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<AuthManager>,
|
||||
models_manager: Arc<ModelsManager>,
|
||||
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<SelectionAction> = 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<SelectionItem> = 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<SelectionAction> = 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<ReasoningEffortConfig>,
|
||||
@@ -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<ReasoningEffortConfig>) {
|
||||
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<ReasoningEffortConfig> {
|
||||
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<dyn HistoryCell> {
|
||||
fn placeholder_session_header_cell(
|
||||
config: &Config,
|
||||
is_collaboration: bool,
|
||||
collaboration_mode: CollaborationMode,
|
||||
) -> Box<dyn HistoryCell> {
|
||||
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<PathBuf> {
|
||||
self.current_rollout_path.clone()
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <mode>` 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<Selection> {
|
||||
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<CollaborationMode> {
|
||||
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<CollaborationMode> {
|
||||
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<ReasoningEffort>,
|
||||
) -> 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()
|
||||
}
|
||||
|
||||
@@ -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<Box<dyn HistoryCell>> = vec![Box::new(header)];
|
||||
|
||||
@@ -991,6 +998,8 @@ pub(crate) struct SessionHeaderHistoryCell {
|
||||
model_style: Style,
|
||||
reasoning_effort: Option<ReasoningEffortConfig>,
|
||||
directory: PathBuf,
|
||||
is_collaboration: bool,
|
||||
collaboration_mode: CollaborationMode,
|
||||
}
|
||||
|
||||
impl SessionHeaderHistoryCell {
|
||||
@@ -999,6 +1008,8 @@ impl SessionHeaderHistoryCell {
|
||||
reasoning_effort: Option<ReasoningEffortConfig>,
|
||||
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<ReasoningEffortConfig>,
|
||||
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:<label_width$}",
|
||||
model_label = "model:",
|
||||
label_width = label_width
|
||||
);
|
||||
let reasoning_label = self.reasoning_label();
|
||||
let mut model_spans: Vec<Span<'static>> = 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<Span<'static>> = if self.is_collaboration {
|
||||
// Render collaboration mode instead of model
|
||||
let collab_label = format!(
|
||||
"{collab_label:<label_width$}",
|
||||
collab_label = "mode:",
|
||||
label_width = label_width
|
||||
);
|
||||
let mut spans = vec![Span::from(format!("{collab_label} ")).dim()];
|
||||
if let Some(mode_label) = self.collaboration_mode_label() {
|
||||
spans.push(Span::styled(mode_label.to_string(), self.model_style));
|
||||
} else {
|
||||
spans.push(Span::styled("Custom", self.model_style));
|
||||
}
|
||||
spans.push(" ".dim());
|
||||
let shift_tab_span: Span<'static> = 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:<label_width$}",
|
||||
model_label = "model:",
|
||||
label_width = label_width
|
||||
);
|
||||
let reasoning_label = self.reasoning_label();
|
||||
let mut spans = vec![
|
||||
Span::from(format!("{model_label} ")).dim(),
|
||||
Span::styled(self.model.clone(), self.model_style),
|
||||
];
|
||||
if let Some(reasoning) = reasoning_label {
|
||||
spans.push(Span::from(" "));
|
||||
spans.push(Span::from(reasoning));
|
||||
}
|
||||
spans.push(" ".dim());
|
||||
spans.push(CHANGE_MODEL_HINT_COMMAND.cyan());
|
||||
spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim());
|
||||
spans
|
||||
};
|
||||
|
||||
let dir_label = format!("{DIR_LABEL:<label_width$}");
|
||||
let dir_prefix = format!("{dir_label} ");
|
||||
@@ -1828,6 +1881,8 @@ mod tests {
|
||||
use codex_core::config::types::McpServerConfig;
|
||||
use codex_core::config::types::McpServerTransportConfig;
|
||||
use codex_core::protocol::McpAuthStatus;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use dirs::home_dir;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -2284,6 +2339,12 @@ mod tests {
|
||||
Some(ReasoningEffortConfig::High),
|
||||
std::env::temp_dir(),
|
||||
"test",
|
||||
false,
|
||||
CollaborationMode::Custom(Settings {
|
||||
model: "gpt-4o".to_string(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::High),
|
||||
developer_instructions: None,
|
||||
}),
|
||||
);
|
||||
|
||||
let lines = render_lines(&cell.display_lines(80));
|
||||
|
||||
@@ -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<Local>,
|
||||
model_name: &str,
|
||||
collaboration_mode: Option<&str>,
|
||||
reasoning_effort_override: Option<Option<ReasoningEffort>>,
|
||||
) -> 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<Local>,
|
||||
model_name: &str,
|
||||
collaboration_mode: Option<&str>,
|
||||
reasoning_effort_override: Option<Option<ReasoningEffort>>,
|
||||
) -> 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()
|
||||
|
||||
@@ -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
|
||||
|
||||
+19
-18
@@ -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<AuthManager>,
|
||||
/// 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<String>,
|
||||
|
||||
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<AppRunControl> {
|
||||
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<ReasoningEffortConfig>) {
|
||||
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<dyn HistoryCell>
|
||||
|
||||
@@ -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,
|
||||
|
||||
+191
-68
@@ -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<String>,
|
||||
/// 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<AuthManager>,
|
||||
models_manager: Arc<ModelsManager>,
|
||||
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<SelectionAction> = 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<SelectionItem> = 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<SelectionAction> = 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<ReasoningEffortConfig>,
|
||||
@@ -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<ReasoningEffortConfig>) {
|
||||
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<ReasoningEffortConfig> {
|
||||
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
|
||||
|
||||
@@ -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::<AppEvent>();
|
||||
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);
|
||||
|
||||
@@ -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 <mode>` 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<Selection> {
|
||||
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<CollaborationMode> {
|
||||
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<CollaborationMode> {
|
||||
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<ReasoningEffort>,
|
||||
) -> 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()
|
||||
}
|
||||
|
||||
@@ -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<Local>,
|
||||
model_name: &str,
|
||||
collaboration_mode: Option<&str>,
|
||||
reasoning_effort_override: Option<Option<ReasoningEffort>>,
|
||||
) -> 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<Local>,
|
||||
model_name: &str,
|
||||
collaboration_mode: Option<&str>,
|
||||
reasoning_effort_override: Option<Option<ReasoningEffort>>,
|
||||
) -> 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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user