Compare commits

...

1 Commits

Author SHA1 Message Date
Mike Starr
f50795fe5e Allow model switch while MCP servers are connecting 2026-02-12 12:30:35 -08:00
4 changed files with 147 additions and 3 deletions

View File

@@ -281,6 +281,7 @@ pub(crate) struct ChatComposer {
attached_images: Vec<AttachedImage>,
placeholder_text: String,
is_task_running: bool,
allow_model_while_task_running: bool,
/// When false, the composer is temporarily read-only (e.g. during sandbox setup).
input_enabled: bool,
input_disabled_placeholder: Option<String>,
@@ -383,6 +384,7 @@ impl ChatComposer {
attached_images: Vec::new(),
placeholder_text,
is_task_running: false,
allow_model_while_task_running: false,
input_enabled: true,
input_disabled_placeholder: None,
paste_burst: PasteBurst::default(),
@@ -2304,7 +2306,10 @@ impl ChatComposer {
}
fn reject_slash_command_if_unavailable(&self, cmd: SlashCommand) -> bool {
if !self.is_task_running || cmd.available_during_task() {
if !self.is_task_running
|| cmd.available_during_task()
|| (cmd == SlashCommand::Model && self.allow_model_while_task_running)
{
return false;
}
let message = format!(
@@ -3163,6 +3168,10 @@ impl ChatComposer {
self.is_task_running = running;
}
pub(crate) fn set_allow_model_while_task_running(&mut self, allow: bool) {
self.allow_model_while_task_running = allow;
}
pub(crate) fn set_context_window(&mut self, percent: Option<i64>, used_tokens: Option<i64>) {
if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens
{
@@ -5291,6 +5300,35 @@ mod tests {
assert!(found_error, "expected error history cell to be sent");
}
#[test]
fn slash_model_allowed_during_task_when_override_enabled() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_task_running(true);
composer.set_allow_model_while_task_running(true);
composer.textarea.set_text_clearing_elements("/model");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(InputResult::Command(SlashCommand::Model), result);
assert_eq!(
rx.try_recv().err(),
Some(tokio::sync::mpsc::error::TryRecvError::Empty)
);
}
#[test]
fn extract_args_supports_quoted_paths_single_arg() {
let args = extract_positional_args_for_prompt_line(

View File

@@ -618,6 +618,10 @@ impl BottomPane {
}
}
pub(crate) fn set_allow_model_while_task_running(&mut self, allow: bool) {
self.composer.set_allow_model_while_task_running(allow);
}
/// Hide the status indicator while leaving task-running state untouched.
pub(crate) fn hide_status_indicator(&mut self) {
if self.status.take().is_some() {

View File

@@ -784,10 +784,30 @@ impl ChatWidget {
/// The bottom pane only has one running flag, but this module treats it as a derived state of
/// both the agent turn lifecycle and MCP startup lifecycle.
fn update_task_running_state(&mut self) {
self.bottom_pane.set_allow_model_while_task_running(
self.mcp_startup_status.is_some() && !self.agent_turn_running,
);
self.bottom_pane
.set_task_running(self.agent_turn_running || self.mcp_startup_status.is_some());
}
fn slash_command_blocked_while_task_running(&self, cmd: SlashCommand) -> bool {
if cmd.available_during_task() || !self.bottom_pane.is_task_running() {
return false;
}
// During MCP startup we still show the task-running state, but /model is safe to open
// because no agent turn is executing yet.
if cmd == SlashCommand::Model
&& self.mcp_startup_status.is_some()
&& !self.agent_turn_running
{
return false;
}
true
}
fn restore_reasoning_status_header(&mut self) {
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
self.set_status_header(header);
@@ -3202,7 +3222,7 @@ impl ChatWidget {
}
fn dispatch_command(&mut self, cmd: SlashCommand) {
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
if self.slash_command_blocked_while_task_running(cmd) {
let message = format!(
"'/{}' is disabled while a task is in progress.",
cmd.command()
@@ -3471,7 +3491,7 @@ impl ChatWidget {
self.dispatch_command(cmd);
return;
}
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
if self.slash_command_blocked_while_task_running(cmd) {
let message = format!(
"'/{}' is disabled while a task is in progress.",
cmd.command()

View File

@@ -4498,6 +4498,88 @@ async fn disabled_slash_command_while_task_running_snapshot() {
assert_snapshot!(blob);
}
#[tokio::test]
async fn model_command_allowed_while_only_mcp_startup_is_running() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
let rollout_file = NamedTempFile::new().expect("rollout file");
let configured = codex_core::protocol::SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
drain_insert_history(&mut rx);
chat.handle_codex_event(Event {
id: "mcp-1".into(),
msg: EventMsg::McpStartupUpdate(McpStartupUpdateEvent {
server: "alpha".into(),
status: McpStartupStatus::Starting,
}),
});
assert!(chat.bottom_pane.is_task_running());
chat.dispatch_command(SlashCommand::Model);
let cells = drain_insert_history(&mut rx);
let contains_disabled_error = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.any(|text| text.contains("disabled while a task is in progress"));
assert!(
!contains_disabled_error,
"expected /model to be allowed during MCP startup when no turn is running"
);
}
#[tokio::test]
async fn model_command_still_blocked_during_agent_turn_even_with_mcp_startup() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_codex_event(Event {
id: "mcp-1".into(),
msg: EventMsg::McpStartupUpdate(McpStartupUpdateEvent {
server: "alpha".into(),
status: McpStartupStatus::Starting,
}),
});
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.dispatch_command(SlashCommand::Model);
let cells = drain_insert_history(&mut rx);
assert!(
cells
.iter()
.map(|lines| lines_to_single_string(lines))
.any(|text| text.contains("disabled while a task is in progress")),
"expected /model to remain blocked while an agent turn is running"
);
}
#[tokio::test]
async fn approvals_popup_shows_disabled_presets() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;