diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 356e25ed3a..774c3adb43 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1113,6 +1113,37 @@ impl Session { fn show_raw_agent_reasoning(&self) -> bool { self.services.show_raw_agent_reasoning } + + pub async fn set_plan_mode_enabled(&self, enabled: bool) { + let mut state = self.state.lock().await; + state.plan_mode = enabled; + } + + async fn maybe_prefix_plan_change_input(&self, items: Vec) -> Vec { + let (should_emit, current) = { + let mut state = self.state.lock().await; + let current = state.plan_mode; + let changed = state.last_emitted_plan_mode != Some(current); + if changed { + state.last_emitted_plan_mode = Some(current); + } + (changed, current) + }; + if should_emit { + let text = if current { + "User engaged plan mode. *DO NOT* write any code or make changes unless the user has given approval AND plan mode is disabled. In this mode, first create a plan of action, then show it to the user and wait for their approval before executing any patch calls. Even if user explicitly asks you to write code, you should *still wait* until you get the \"Plan mode disengaged\" system message.".to_string() + } else { + "User disengaged plan mode. You may now write code or make changes.".to_string() + }; + let item = ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { text }], + }; + self.record_conversation_items(&[item]).await; + } + items + } } impl Drop for Session { @@ -1143,6 +1174,7 @@ async fn submission_loop( model, effort, summary, + plan_mode, } => { // Recalculate the persistent turn context with provided overrides. let prev = Arc::clone(&turn_context); @@ -1229,12 +1261,20 @@ async fn submission_loop( ))]) .await; } + + // Persist the plan mode state only; a user-visible message is emitted + // the next time user input is sent. + if let Some(enabled) = plan_mode { + sess.set_plan_mode_enabled(enabled).await; + } } Op::UserInput { items } => { turn_context .client .get_otel_event_manager() .user_prompt(&items); + // If plan mode changed since last emission, prefix a user message first. + let items = sess.maybe_prefix_plan_change_input(items).await; // attempt to inject input into current task if let Err(items) = sess.inject_input(items).await { // no current task, spawn a new one @@ -3203,4 +3243,146 @@ mod tests { pretty_assertions::assert_eq!(exec_output.metadata, ResponseExecMetadata { exit_code: 0 }); assert!(exec_output.output.contains("hi")); } + + #[tokio::test] + async fn plan_mode_prefixes_once_on_enable_and_skips_after() { + use crate::protocol::InputItem; + let (session, _tc) = make_session_and_context(); + let sess = Arc::new(session); + + // Enable plan mode before first send + sess.set_plan_mode_enabled(true).await; + + // First send should be prefixed with ENGAGED line + let items = vec![InputItem::Text { + text: "hi".to_string(), + }]; + let out = sess.maybe_prefix_plan_change_input(items).await; + // Items are unchanged; notice is a separate developer message in history. + assert_eq!(out.len(), 1, "user input should be unchanged"); + match &out[0] { + InputItem::Text { text } => assert_eq!(text, "hi"), + _ => panic!("expected original user text only"), + } + // Find developer notice in history + let history = sess.history_snapshot().await; + let dev = history + .iter() + .rev() + .find_map(|ri| match ri { + ResponseItem::Message { role, content, .. } if role == "developer" => { + let full = content + .iter() + .filter_map(|c| match c { + ContentItem::InputText { text } => Some(text), + _ => None, + }) + .cloned() + .collect::>() + .join(""); + Some(full) + } + _ => None, + }) + .expect("developer notice in history"); + let lower = dev.to_lowercase(); + assert!(dev.contains("")); + assert!(lower.contains("plan mode")); + assert!(lower.contains("engag"), "expected engaged text: {dev}"); + + // Second send without change should not get prefixed + let items2 = vec![InputItem::Text { + text: "hello".to_string(), + }]; + let out2 = sess.maybe_prefix_plan_change_input(items2).await; + assert_eq!(out2.len(), 1, "no prefix after no change"); + match &out2[0] { + InputItem::Text { text } => assert_eq!(text, "hello"), + _ => panic!("expected original user text only"), + } + } + + #[tokio::test] + async fn plan_mode_prefixes_once_on_disable() { + use crate::protocol::InputItem; + let (session, _tc) = make_session_and_context(); + let sess = Arc::new(session); + + // Enable then emit once + sess.set_plan_mode_enabled(true).await; + let _ = sess + .maybe_prefix_plan_change_input(vec![InputItem::Text { + text: "first".to_string(), + }]) + .await; + + // Disable plan mode; next send should be prefixed with DISENGAGED + sess.set_plan_mode_enabled(false).await; + let out = sess + .maybe_prefix_plan_change_input(vec![InputItem::Text { + text: "second".to_string(), + }]) + .await; + assert_eq!(out.len(), 1, "user input should be unchanged"); + let history = sess.history_snapshot().await; + let notices: Vec = history + .iter() + .filter_map(|ri| match ri { + ResponseItem::Message { role, content, .. } if role == "developer" => Some( + content + .iter() + .filter_map(|c| match c { + ContentItem::InputText { text } => Some(text), + _ => None, + }) + .cloned() + .collect::>() + .join(""), + ), + _ => None, + }) + .collect(); + assert!(notices.len() >= 2, "expected two developer notices"); + let last = notices.last().unwrap(); + let lower = last.to_lowercase(); + assert!(last.contains("")); + assert!(lower.contains("plan mode")); + assert!( + lower.contains("disengag"), + "expected disengaged text: {last}" + ); + + // Next send with no change should not be prefixed + let out2 = sess + .maybe_prefix_plan_change_input(vec![InputItem::Text { + text: "third".to_string(), + }]) + .await; + assert_eq!(out2.len(), 1, "no prefix after disable state unchanged"); + } + + #[tokio::test] + async fn plan_mode_multiple_toggles_before_send_emits_once() { + use crate::protocol::InputItem; + let (session, _tc) = make_session_and_context(); + let sess = Arc::new(session); + + // Toggle several times before sending; final state true + sess.set_plan_mode_enabled(true).await; + sess.set_plan_mode_enabled(false).await; + sess.set_plan_mode_enabled(true).await; + + let out = sess + .maybe_prefix_plan_change_input(vec![InputItem::Text { + text: "go".to_string(), + }]) + .await; + assert_eq!(out.len(), 1, "user input should be unchanged"); + let history = sess.history_snapshot().await; + let dev_count = history + .iter() + .filter(|ri| matches!(ri, ResponseItem::Message { role, .. } if role == "developer")) + .count(); + assert!(dev_count >= 1, "expected a developer notice recorded"); + } } diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 8310d91c0c..2518f6a98d 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -13,6 +13,9 @@ pub(crate) struct SessionState { pub(crate) history: ConversationHistory, pub(crate) token_info: Option, pub(crate) latest_rate_limits: Option, + // Tracks current Plan Mode and the last state for which a notice was emitted. + pub(crate) plan_mode: bool, + pub(crate) last_emitted_plan_mode: Option, } impl SessionState { diff --git a/codex-rs/core/tests/suite/model_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs index a186c13ef3..6c096f9917 100644 --- a/codex-rs/core/tests/suite/model_overrides.rs +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -38,6 +38,7 @@ async fn override_turn_context_does_not_persist_when_config_exists() { model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::High)), summary: None, + plan_mode: None, }) .await .expect("submit override"); @@ -78,6 +79,7 @@ async fn override_turn_context_does_not_create_config_file() { model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::Medium)), summary: None, + plan_mode: None, }) .await .expect("submit override"); diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 9ca0cc9369..aa2eb590ac 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -442,6 +442,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() { model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::High)), summary: Some(ReasoningSummary::Detailed), + plan_mode: None, }) .await .unwrap(); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 3b6520be67..08105c4191 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -125,6 +125,12 @@ pub enum Op { /// Updated reasoning summary preference (honored only for reasoning-capable models). #[serde(skip_serializing_if = "Option::is_none")] summary: Option, + + /// Toggle Plan Mode for subsequent turns. When enabled, the server will + /// append a Plan Mode instruction to the session's user instructions; + /// when disabled, the server will remove the appended instruction if present. + #[serde(skip_serializing_if = "Option::is_none")] + plan_mode: Option, }, /// Approve a command execution diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 2574ded461..394bdc378a 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -109,6 +109,7 @@ pub(crate) struct ChatComposer { footer_mode: FooterMode, footer_hint_override: Option>, context_window_percent: Option, + plan_mode: bool, } /// Popup state – at most one can be visible at any time. @@ -152,12 +153,17 @@ impl ChatComposer { footer_mode: FooterMode::ShortcutPrompt, footer_hint_override: None, context_window_percent: None, + plan_mode: false, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); this } + pub(crate) fn set_plan_mode(&mut self, enabled: bool) { + self.plan_mode = enabled; + } + pub fn desired_height(&self, width: u16) -> u16 { let footer_props = self.footer_props(); let footer_hint_height = self @@ -1337,6 +1343,7 @@ impl ChatComposer { use_shift_enter_hint: self.use_shift_enter_hint, is_task_running: self.is_task_running, context_window_percent: self.context_window_percent, + plan_mode: self.plan_mode, } } @@ -1345,8 +1352,18 @@ impl ChatComposer { FooterMode::EscHint => FooterMode::EscHint, FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay, FooterMode::CtrlCReminder => FooterMode::CtrlCReminder, - FooterMode::ShortcutPrompt if self.ctrl_c_quit_hint => FooterMode::CtrlCReminder, - FooterMode::ShortcutPrompt if !self.is_empty() => FooterMode::Empty, + FooterMode::ShortcutPrompt => { + if self.ctrl_c_quit_hint { + FooterMode::CtrlCReminder + } else if self.plan_mode { + // Keep footer visible for Plan Mode regardless of textbox content. + FooterMode::ShortcutPrompt + } else if !self.is_empty() { + FooterMode::Empty + } else { + FooterMode::ShortcutPrompt + } + } other => other, } } diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index b4c5617ddf..a625958061 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -18,6 +18,7 @@ pub(crate) struct FooterProps { pub(crate) use_shift_enter_hint: bool, pub(crate) is_task_running: bool, pub(crate) context_window_percent: Option, + pub(crate) plan_mode: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -77,7 +78,9 @@ fn footer_lines(props: FooterProps) -> Vec> { is_task_running: props.is_task_running, })], FooterMode::ShortcutPrompt => { - if props.is_task_running { + if props.plan_mode { + vec![plan_mode_line()] + } else if props.is_task_running { vec![context_window_line(props.context_window_percent)] } else { vec![Line::from(vec![ @@ -233,6 +236,10 @@ fn context_window_line(percent: Option) -> Line<'static> { Line::from(spans) } +fn plan_mode_line() -> Line<'static> { + Line::from(vec![">> Plan Mode".cyan()]) +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ShortcutId { Commands, @@ -407,6 +414,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, context_window_percent: None, + plan_mode: false, }, ); @@ -418,6 +426,7 @@ mod tests { use_shift_enter_hint: true, is_task_running: false, context_window_percent: None, + plan_mode: false, }, ); @@ -429,6 +438,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, context_window_percent: None, + plan_mode: false, }, ); @@ -440,6 +450,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, context_window_percent: None, + plan_mode: false, }, ); @@ -451,6 +462,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, context_window_percent: None, + plan_mode: false, }, ); @@ -462,6 +474,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, context_window_percent: None, + plan_mode: false, }, ); @@ -473,6 +486,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, context_window_percent: Some(72), + plan_mode: false, }, ); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index db13a041cd..70fd8de1f4 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -264,6 +264,11 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn set_plan_mode(&mut self, enabled: bool) { + self.composer.set_plan_mode(enabled); + self.request_redraw(); + } + /// Replace the composer text with `text`. pub(crate) fn set_composer_text(&mut self, text: String) { self.composer.set_text_content(text); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2237c678c7..8efec7fde8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -260,6 +260,10 @@ pub(crate) struct ChatWidget { needs_final_message_separator: bool, last_rendered_width: std::cell::Cell>, + + // When true, Plan Mode is active and the composer shows a hint; core gets a + // context override to add plan-mode instructions. + plan_mode: bool, } struct UserMessage { @@ -938,6 +942,7 @@ impl ChatWidget { ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + plan_mode: false, } } @@ -1001,6 +1006,7 @@ impl ChatWidget { ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + plan_mode: false, } } @@ -1023,6 +1029,15 @@ impl ChatWidget { self.on_ctrl_c(); return; } + // Shift+Tab toggles Plan Mode. + KeyEvent { + code: KeyCode::BackTab, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.set_plan_mode(!self.plan_mode); + return; + } KeyEvent { code: KeyCode::Char('v'), modifiers: KeyModifiers::CONTROL, @@ -1078,6 +1093,28 @@ impl ChatWidget { } } + fn set_plan_mode(&mut self, enabled: bool) { + if self.plan_mode == enabled { + return; + } + self.plan_mode = enabled; + // Update composer UI hint + self.bottom_pane.set_plan_mode(enabled); + // Inform core to update user instructions for subsequent turns + self.codex_op_tx + .send(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + plan_mode: Some(enabled), + }) + .unwrap_or_else(|e| tracing::error!("failed to send plan mode override: {e}")); + self.request_redraw(); + } + pub(crate) fn attach_image( &mut self, path: PathBuf, @@ -1732,6 +1769,7 @@ impl ChatWidget { model: Some(model_for_action.clone()), effort: Some(effort_for_action), summary: None, + plan_mode: None, })); tx.send(AppEvent::UpdateModel(model_for_action.clone())); tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); @@ -1788,6 +1826,7 @@ impl ChatWidget { model: None, effort: None, summary: None, + plan_mode: None, })); tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); tx.send(AppEvent::UpdateSandboxPolicy(sandbox.clone())); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 2055445059..2925c5ce9a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -287,6 +287,7 @@ fn make_chatwidget_manual() -> ( ghost_snapshots_disabled: false, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + plan_mode: false, }; (widget, rx, op_rx) }