diff --git a/AGENTS.md b/AGENTS.md index 6e21be78f696..23f0bab387c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,8 @@ In the codex-rs folder where the rust code lives: `codex-rs/tui/src/bottom_pane/mod.rs`, and similarly central orchestration modules. - When extracting code from a large module, move the related tests and module/type docs toward the new implementation so the invariants stay close to the code that owns them. + - Avoid adding new standalone methods to `codex-rs/tui/src/chatwidget.rs` unless the change is + trivial; prefer new modules/files and keep `chatwidget.rs` focused on orchestration. - When running Rust commands (e.g. `just fix` or `cargo test`) be patient with the command and never try to kill them using the PID. Rust lock can make the execution slow, this is expected. Run `just fmt` (in `codex-rs` directory) automatically after you have finished making Rust code changes; do not ask for approval to run it. Additionally, run the tests: diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index dd0827822dcc..594b74792a87 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1085,13 +1085,13 @@ impl App { &self, tui: &mut tui::Tui, cfg: crate::legacy_core::config::Config, + initial_user_message: Option, ) -> crate::chatwidget::ChatWidgetInit { crate::chatwidget::ChatWidgetInit { config: cfg, frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), - // Fork/resume bootstraps here don't carry any prefilled message content. - initial_user_message: None, + initial_user_message, enhanced_keys_supported: self.enhanced_keys_supported, has_chatgpt_account: self.chat_widget.has_chatgpt_account(), model_catalog: self.model_catalog.clone(), @@ -3245,7 +3245,11 @@ impl App { self.active_thread_id = Some(thread_id); self.active_thread_rx = Some(receiver); - let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); + let init = self.chatwidget_init_for_forked_or_resumed_thread( + tui, + self.config.clone(), + /*initial_user_message*/ None, + ); self.replace_chat_widget(ChatWidget::new_with_app_event(init)); self.reset_for_thread_switch(tui)?; @@ -3306,9 +3310,11 @@ impl App { tui: &mut tui::Tui, app_server: &mut AppServerSession, session_start_source: Option, + initial_user_message: Option, ) { // Start a fresh in-memory session while preserving resumability via persisted rollout - // history. + // history. If an initial message is provided, `enqueue_primary_thread_session` suppresses it + // until the new session is configured and any replayed turns have been rendered. self.refresh_in_memory_config_from_disk_best_effort("starting a new thread") .await; let model = self.chat_widget.current_model().to_string(); @@ -3333,7 +3339,12 @@ impl App { { Ok(started) => { if let Err(err) = self - .replace_chat_widget_with_app_server_thread(tui, app_server, started) + .replace_chat_widget_with_app_server_thread( + tui, + app_server, + started, + initial_user_message, + ) .await { self.chat_widget.add_error_message(format!( @@ -3366,9 +3377,17 @@ impl App { tui: &mut tui::Tui, app_server: &mut AppServerSession, started: AppServerStartedThread, + initial_user_message: Option, ) -> Result<()> { + // Initial messages are for freshly attached primary threads only. Thread switches and + // resume/fork flows pass `None` so they cannot replay old history and then auto-submit a new + // user turn by accident. self.reset_thread_event_state(); - let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); + let init = self.chatwidget_init_for_forked_or_resumed_thread( + tui, + self.config.clone(), + initial_user_message, + ); self.replace_chat_widget(ChatWidget::new_with_app_event(init)); self.enqueue_primary_thread_session(started.session, started.turns) .await?; @@ -4123,7 +4142,9 @@ impl App { self.file_search .update_search_dir(self.config.cwd.to_path_buf()); match self - .replace_chat_widget_with_app_server_thread(tui, app_server, resumed) + .replace_chat_widget_with_app_server_thread( + tui, app_server, resumed, /*initial_user_message*/ None, + ) .await { Ok(()) => { @@ -4168,6 +4189,7 @@ impl App { AppEvent::NewSession => { self.start_fresh_session_with_summary_hint( tui, app_server, /*session_start_source*/ None, + /*initial_user_message*/ None, ) .await; } @@ -4179,6 +4201,23 @@ impl App { tui, app_server, Some(ThreadStartSource::Clear), + /*initial_user_message*/ None, + ) + .await; + } + AppEvent::ClearUiAndSubmitUserMessage { text } => { + self.clear_terminal_ui(tui, /*redraw_header*/ false)?; + self.reset_app_ui_state_after_clear(); + + self.start_fresh_session_with_summary_hint( + tui, + app_server, + Some(ThreadStartSource::Clear), + crate::chatwidget::create_initial_user_message( + Some(text), + Vec::new(), + Vec::new(), + ), ) .await; } @@ -4272,7 +4311,9 @@ impl App { Ok(forked) => { self.shutdown_current_thread(app_server).await; match self - .replace_chat_widget_with_app_server_thread(tui, app_server, forked) + .replace_chat_widget_with_app_server_thread( + tui, app_server, forked, /*initial_user_message*/ None, + ) .await { Ok(()) => { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0e71e1a432fe..a63110e26dcc 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -121,6 +121,14 @@ pub(crate) enum AppEvent { /// previous chat resumable. ClearUi, + /// Clear the current context, start a fresh session, and submit an initial user message. + /// + /// This is the Plan Mode handoff path: the previous thread remains resumable, but the model + /// sees only the explicit prompt carried in `text` once the new session is configured. + ClearUiAndSubmitUserMessage { + text: String, + }, + /// Open the resume picker inside the running TUI session. OpenResumePicker, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b8b5d90275f2..00d60dffd633 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -242,10 +242,6 @@ use tracing::debug; use tracing::warn; const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading"; -const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?"; -const PLAN_IMPLEMENTATION_YES: &str = "Yes, implement this plan"; -const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode"; -const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan."; const MULTI_AGENT_ENABLE_TITLE: &str = "Enable subagents?"; const MULTI_AGENT_ENABLE_YES: &str = "Yes, enable"; const MULTI_AGENT_ENABLE_NO: &str = "Not now"; @@ -360,6 +356,8 @@ use self::skills::find_app_mentions; use self::skills::find_skill_mentions_with_tool_mentions; mod plugins; use self::plugins::PluginsCacheState; +mod plan_implementation; +use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE; mod realtime; use self::realtime::RealtimeConversationUiState; use self::realtime::RenderedUserMessageEvent; @@ -784,6 +782,11 @@ pub(crate) struct ChatWidget { /// may still return the response from before the rollback. Keeping this as /// a single cache avoids coupling copy state to the backtrack transcript. last_agent_markdown: Option, + /// Raw markdown of the most recently completed proposed plan. + /// + /// This is cached only for the approval popup. It is reset at the start of each new task so the + /// fresh-context action cannot accidentally submit an older plan after a later turn begins. + latest_proposed_plan_markdown: Option, /// Whether this turn already produced a copyable response. /// /// `TurnComplete.last_agent_message` is a fallback source: use it only when no earlier @@ -2240,6 +2243,7 @@ impl ChatWidget { }; if !plan_text.trim().is_empty() { self.record_agent_markdown(&plan_text); + self.latest_proposed_plan_markdown = Some(plan_text.clone()); } // Plan commit ticks can hide the status row; remember whether we streamed plan output so // completion can restore it once stream queues are idle. @@ -2319,6 +2323,7 @@ impl ChatWidget { self.saw_copy_source_this_turn = false; self.saw_plan_update_this_turn = false; self.saw_plan_item_this_turn = false; + self.latest_proposed_plan_markdown = None; self.last_plan_progress = None; self.plan_delta_buffer.clear(); self.plan_item_active = false; @@ -2466,48 +2471,12 @@ impl ChatWidget { fn open_plan_implementation_prompt(&mut self) { let default_mask = collaboration_modes::default_mode_mask(self.model_catalog.as_ref()); - let (implement_actions, implement_disabled_reason) = match default_mask { - Some(mask) => { - let user_text = PLAN_IMPLEMENTATION_CODING_MESSAGE.to_string(); - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::SubmitUserMessageWithMode { - text: user_text.clone(), - collaboration_mode: mask.clone(), - }); - })]; - (actions, None) - } - None => (Vec::new(), Some("Default mode unavailable".to_string())), - }; - let items = vec![ - SelectionItem { - name: PLAN_IMPLEMENTATION_YES.to_string(), - description: Some("Switch to Default and start coding.".to_string()), - selected_description: None, - is_current: false, - actions: implement_actions, - disabled_reason: implement_disabled_reason, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: PLAN_IMPLEMENTATION_NO.to_string(), - description: Some("Continue planning with the model.".to_string()), - selected_description: None, - is_current: false, - actions: Vec::new(), - dismiss_on_select: true, - ..Default::default() - }, - ]; - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some(PLAN_IMPLEMENTATION_TITLE.to_string()), - subtitle: None, - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); + self.bottom_pane + .show_selection_view(plan_implementation::selection_view_params( + default_mask, + self.latest_proposed_plan_markdown.as_deref(), + )); self.notify(Notification::PlanModePrompt { title: PLAN_IMPLEMENTATION_TITLE.to_string(), }); @@ -4805,6 +4774,7 @@ impl ChatWidget { agent_turn_running: false, mcp_startup_status: None, last_agent_markdown: None, + latest_proposed_plan_markdown: None, saw_copy_source_this_turn: false, mcp_startup_expected_servers: None, mcp_startup_ignore_updates_until_next_start: false, diff --git a/codex-rs/tui/src/chatwidget/plan_implementation.rs b/codex-rs/tui/src/chatwidget/plan_implementation.rs new file mode 100644 index 000000000000..c5b253a8d199 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/plan_implementation.rs @@ -0,0 +1,103 @@ +use codex_protocol::config_types::CollaborationModeMask; + +use crate::app_event::AppEvent; +use crate::bottom_pane::SelectionAction; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; + +pub(super) const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?"; +const PLAN_IMPLEMENTATION_YES: &str = "Yes, implement this plan"; +const PLAN_IMPLEMENTATION_CLEAR_CONTEXT: &str = "Yes, clear context and implement"; +const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode"; +pub(super) const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan."; +pub(super) const PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX: &str = concat!( + "A previous agent produced the plan below to accomplish the user's task. ", + "Implement the plan in a fresh context. Treat the plan as the source of ", + "user intent, re-read files as needed, and carry the work through ", + "implementation and verification." +); +pub(super) const PLAN_IMPLEMENTATION_DEFAULT_UNAVAILABLE: &str = "Default mode unavailable"; +pub(super) const PLAN_IMPLEMENTATION_NO_APPROVED_PLAN: &str = "No approved plan available"; + +pub(super) fn selection_view_params( + default_mask: Option, + plan_markdown: Option<&str>, +) -> SelectionViewParams { + let (implement_actions, implement_disabled_reason) = match default_mask.clone() { + Some(mask) => { + let user_text = PLAN_IMPLEMENTATION_CODING_MESSAGE.to_string(); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::SubmitUserMessageWithMode { + text: user_text.clone(), + collaboration_mode: mask.clone(), + }); + })]; + (actions, None) + } + None => ( + Vec::new(), + Some(PLAN_IMPLEMENTATION_DEFAULT_UNAVAILABLE.to_string()), + ), + }; + + let (clear_context_actions, clear_context_disabled_reason) = match (default_mask, plan_markdown) + { + (None, _) => ( + Vec::new(), + Some(PLAN_IMPLEMENTATION_DEFAULT_UNAVAILABLE.to_string()), + ), + (Some(_), Some(plan_markdown)) if !plan_markdown.trim().is_empty() => { + let user_text = + format!("{PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX}\n\n{plan_markdown}"); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::ClearUiAndSubmitUserMessage { + text: user_text.clone(), + }); + })]; + (actions, None) + } + (Some(_), _) => ( + Vec::new(), + Some(PLAN_IMPLEMENTATION_NO_APPROVED_PLAN.to_string()), + ), + }; + + SelectionViewParams { + title: Some(PLAN_IMPLEMENTATION_TITLE.to_string()), + subtitle: None, + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + SelectionItem { + name: PLAN_IMPLEMENTATION_YES.to_string(), + description: Some("Switch to Default and start coding.".to_string()), + selected_description: None, + is_current: false, + actions: implement_actions, + disabled_reason: implement_disabled_reason, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: PLAN_IMPLEMENTATION_CLEAR_CONTEXT.to_string(), + description: Some("Fresh thread with this plan.".to_string()), + selected_description: None, + is_current: false, + actions: clear_context_actions, + disabled_reason: clear_context_disabled_reason, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: PLAN_IMPLEMENTATION_NO.to_string(), + description: Some("Continue planning with the model.".to_string()), + selected_description: None, + is_current: false, + actions: Vec::new(), + dismiss_on_select: true, + ..Default::default() + }, + ], + ..Default::default() + } +} diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap index d1d971e923ac..25897974fa64 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap @@ -4,7 +4,8 @@ expression: popup --- Implement this plan? -› 1. Yes, implement this plan Switch to Default and start coding. - 2. No, stay in Plan mode Continue planning with the model. +› 1. Yes, implement this plan Switch to Default and start coding. + 2. Yes, clear context and implement Fresh thread with this plan. + 3. No, stay in Plan mode Continue planning with the model. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap index 207f7fa1ce17..e8083a0e01bd 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap @@ -4,7 +4,8 @@ expression: popup --- Implement this plan? - 1. Yes, implement this plan Switch to Default and start coding. -› 2. No, stay in Plan mode Continue planning with the model. + 1. Yes, implement this plan Switch to Default and start coding. +› 2. Yes, clear context and implement Fresh thread with this plan. + 3. No, stay in Plan mode Continue planning with the model. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index d5c6a5e3b494..261e404bc727 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -208,6 +208,7 @@ pub(super) async fn make_chatwidget_manual( pending_guardian_review_status: PendingGuardianReviewStatus::default(), terminal_title_status_kind: TerminalTitleStatusKind::Working, last_agent_markdown: None, + latest_proposed_plan_markdown: None, saw_copy_source_this_turn: false, running_commands: HashMap::new(), collab_agent_metadata: HashMap::new(), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index ecdb15a398a6..8c0aa4568d5f 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -4,6 +4,7 @@ use pretty_assertions::assert_eq; #[tokio::test] async fn plan_implementation_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.on_plan_item_completed("- Step 1\n- Step 2\n".to_string()); chat.open_plan_implementation_prompt(); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -13,6 +14,7 @@ async fn plan_implementation_popup_snapshot() { #[tokio::test] async fn plan_implementation_popup_no_selected_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.on_plan_item_completed("- Step 1\n- Step 2\n".to_string()); chat.open_plan_implementation_prompt(); chat.handle_key_event(KeyEvent::from(KeyCode::Down)); @@ -35,10 +37,71 @@ async fn plan_implementation_popup_yes_emits_submit_message_event() { else { panic!("expected SubmitUserMessageWithMode, got {event:?}"); }; - assert_eq!(text, PLAN_IMPLEMENTATION_CODING_MESSAGE); + assert_eq!( + text, + plan_implementation::PLAN_IMPLEMENTATION_CODING_MESSAGE + ); assert_eq!(collaboration_mode.mode, Some(ModeKind::Default)); } +#[tokio::test] +async fn plan_implementation_popup_clear_context_emits_clear_submit_event() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + let plan_markdown = "- Step 1\n- Step 2\n"; + chat.on_plan_item_completed(plan_markdown.to_string()); + let _ = drain_insert_history(&mut rx); + chat.open_plan_implementation_prompt(); + + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let event = rx.try_recv().expect("expected AppEvent"); + let AppEvent::ClearUiAndSubmitUserMessage { text } = event else { + panic!("expected ClearUiAndSubmitUserMessage, got {event:?}"); + }; + assert_eq!( + text, + "A previous agent produced the plan below to accomplish the user's task. \ + Implement the plan in a fresh context. Treat the plan as the source of \ + user intent, re-read files as needed, and carry the work through \ + implementation and verification.\n\n- Step 1\n- Step 2\n" + ); +} + +#[tokio::test] +async fn plan_implementation_clear_context_requires_default_mode_and_plan() { + let (chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + let default_mask = collaboration_modes::default_mode_mask(chat.model_catalog.as_ref()) + .expect("expected default collaboration mode"); + + let params = + plan_implementation::selection_view_params(/*default_mask*/ None, Some("- Step\n")); + assert_eq!( + params.items[1].disabled_reason.as_deref(), + Some(plan_implementation::PLAN_IMPLEMENTATION_DEFAULT_UNAVAILABLE) + ); + + let params = plan_implementation::selection_view_params( + Some(default_mask.clone()), + /*plan_markdown*/ None, + ); + assert_eq!( + params.items[1].disabled_reason.as_deref(), + Some(plan_implementation::PLAN_IMPLEMENTATION_NO_APPROVED_PLAN) + ); + + let params = + plan_implementation::selection_view_params(Some(default_mask.clone()), Some(" \n")); + assert_eq!( + params.items[1].disabled_reason.as_deref(), + Some(plan_implementation::PLAN_IMPLEMENTATION_NO_APPROVED_PLAN) + ); + + let params = plan_implementation::selection_view_params(Some(default_mask), Some("- Step\n")); + assert_eq!(params.items[1].disabled_reason, None); + assert!(!params.items[1].actions.is_empty()); +} + #[tokio::test] async fn submit_user_message_with_mode_sets_coding_collaboration_mode() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await;