From 11958221a3354fe7941671cc2c4a24ffd73c29a7 Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Thu, 29 Jan 2026 16:40:43 -0800 Subject: [PATCH] tui: add feature-gated /plan slash command to switch to Plan mode (#10103) ## Summary Adds a simple `/plan` slash command in the TUI that switches the active collaboration mode to Plan mode. The command is only available when the `collaboration_modes` feature is enabled. ## Changes - Add `plan_mask` helper in `codex-rs/tui/src/collaboration_modes.rs` - Add `SlashCommand::Plan` metadata in `codex-rs/tui/src/slash_command.rs` - Implement and hard-gate `/plan` dispatch in `codex-rs/tui/src/chatwidget.rs` - Hide `/plan` when collaboration modes are disabled in `codex-rs/tui/src/bottom_pane/slash_commands.rs` - Update command popup tests in `codex-rs/tui/src/bottom_pane/command_popup.rs` - Add a focused unit test for `/plan` in `codex-rs/tui/src/chatwidget/tests.rs` ## Behavior notes - `/plan` is now a no-op if `Feature::CollaborationModes` is disabled. - When enabled, `/plan` switches directly to Plan mode without opening the picker. ## Codex author `codex resume 019c05da-d7c3-7322-ae2c-3ca38d0ef702` --- codex-rs/tui/src/bottom_pane/command_popup.rs | 25 ++++++++++++++- .../tui/src/bottom_pane/slash_commands.rs | 5 ++- codex-rs/tui/src/chatwidget.rs | 31 ++++++++++++++----- codex-rs/tui/src/chatwidget/tests.rs | 13 ++++++++ codex-rs/tui/src/collaboration_modes.rs | 4 +++ codex-rs/tui/src/slash_command.rs | 3 ++ 6 files changed, 72 insertions(+), 9 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 621683923..48ee7d2d6 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -459,7 +459,7 @@ mod tests { #[test] fn collab_command_hidden_when_collaboration_modes_disabled() { let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); - popup.on_composer_text_change("/coll".to_string()); + popup.on_composer_text_change("/".to_string()); let cmds: Vec<&str> = popup .filtered_items() @@ -473,6 +473,10 @@ mod tests { !cmds.contains(&"collab"), "expected '/collab' to be hidden when collaboration modes are disabled, got {cmds:?}" ); + assert!( + !cmds.contains(&"plan"), + "expected '/plan' to be hidden when collaboration modes are disabled, got {cmds:?}" + ); } #[test] @@ -494,6 +498,25 @@ mod tests { } } + #[test] + fn plan_command_visible_when_collaboration_modes_enabled() { + let mut popup = CommandPopup::new( + Vec::new(), + CommandPopupFlags { + collaboration_modes_enabled: true, + connectors_enabled: false, + personality_command_enabled: true, + windows_degraded_sandbox_active: false, + }, + ); + popup.on_composer_text_change("/plan".to_string()); + + match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "plan"), + other => panic!("expected plan to be selected for exact match, got {other:?}"), + } + } + #[test] fn personality_command_hidden_when_disabled() { let mut popup = CommandPopup::new( diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 1efab04ec..34ad17330 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -18,7 +18,10 @@ pub(crate) fn builtins_for_input( built_in_slash_commands() .into_iter() .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) - .filter(|(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab) + .filter(|(_, cmd)| { + collaboration_modes_enabled + || !matches!(*cmd, SlashCommand::Collab | SlashCommand::Plan) + }) .filter(|(_, cmd)| connectors_enabled || *cmd != SlashCommand::Apps) .filter(|(_, cmd)| personality_command_enabled || *cmd != SlashCommand::Personality) .collect() diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 61006d80c..74200e470 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2668,10 +2668,29 @@ impl ChatWidget { SlashCommand::Personality => { self.open_personality_popup(); } - SlashCommand::Collab => { - if self.collaboration_modes_enabled() { - self.open_collaboration_modes_popup(); + SlashCommand::Plan => { + if !self.collaboration_modes_enabled() { + self.add_info_message( + "Collaboration modes are disabled.".to_string(), + Some("Enable collaboration modes to use /plan.".to_string()), + ); + return; } + if let Some(mask) = collaboration_modes::plan_mask(self.models_manager.as_ref()) { + self.set_collaboration_mask(mask); + } else { + self.add_info_message("Plan mode unavailable right now.".to_string(), None); + } + } + SlashCommand::Collab => { + if !self.collaboration_modes_enabled() { + self.add_info_message( + "Collaboration modes are disabled.".to_string(), + Some("Enable collaboration modes to use /collab.".to_string()), + ); + return; + } + self.open_collaboration_modes_popup(); } SlashCommand::Agent => { self.app_event_tx.send(AppEvent::OpenAgentPicker); @@ -2844,11 +2863,9 @@ impl ChatWidget { let trimmed = args.trim(); match cmd { - SlashCommand::Collab => { + SlashCommand::Collab | SlashCommand::Plan => { let _ = trimmed; - if self.collaboration_modes_enabled() { - self.open_collaboration_modes_popup(); - } + self.dispatch_command(cmd); } SlashCommand::Review if !trimmed.is_empty() => { self.submit_op(Op::Review { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 9859e9e56..d8f22dc8e 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2267,6 +2267,19 @@ async fn collab_slash_command_opens_picker_and_updates_mode() { } } +#[tokio::test] +async fn plan_slash_command_switches_to_plan_mode() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + let initial = chat.current_collaboration_mode().clone(); + + chat.dispatch_command(SlashCommand::Plan); + + assert!(rx.try_recv().is_err(), "plan should not emit an app event"); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); + assert_eq!(chat.current_collaboration_mode(), &initial); +} + #[tokio::test] async fn collaboration_modes_defaults_to_code_on_startup() { let codex_home = tempdir().expect("tempdir"); diff --git a/codex-rs/tui/src/collaboration_modes.rs b/codex-rs/tui/src/collaboration_modes.rs index 95908d8e9..e799ea9c5 100644 --- a/codex-rs/tui/src/collaboration_modes.rs +++ b/codex-rs/tui/src/collaboration_modes.rs @@ -59,3 +59,7 @@ pub(crate) fn next_mask( pub(crate) fn code_mask(models_manager: &ModelsManager) -> Option { mask_for_kind(models_manager, ModeKind::Code) } + +pub(crate) fn plan_mask(models_manager: &ModelsManager) -> Option { + mask_for_kind(models_manager, ModeKind::Plan) +} diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 9bab31461..9488577c1 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -25,6 +25,7 @@ pub enum SlashCommand { Fork, Init, Compact, + Plan, Collab, Agent, // Undo, @@ -63,6 +64,7 @@ impl SlashCommand { SlashCommand::Ps => "list background terminals", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Personality => "choose a communication style for Codex", + SlashCommand::Plan => "switch to Plan mode", SlashCommand::Collab => "change collaboration mode (experimental)", SlashCommand::Agent => "switch the active agent thread", SlashCommand::Approvals => "choose what Codex can do without approval", @@ -112,6 +114,7 @@ impl SlashCommand { | SlashCommand::Exit => true, SlashCommand::Rollout => true, SlashCommand::TestApproval => true, + SlashCommand::Plan => true, SlashCommand::Collab => true, SlashCommand::Agent => true, }