From 3c9a180b936e2204d1ace9b3418215d8c647a21c Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 11 Apr 2026 19:03:40 -0300 Subject: [PATCH 1/8] feat(tui): add clear context plan implementation Add a second Plan Mode approval option that starts a fresh cleared thread and submits the approved plan as the initial implementation prompt. Keep the existing same-context implementation option first and unchanged while reusing the established clear-session flow for the new path. --- codex-rs/tui/src/app.rs | 51 ++++++++++++++++--- codex-rs/tui/src/app_event.rs | 5 ++ codex-rs/tui/src/chatwidget.rs | 34 +++++++++++++ ...get__tests__plan_implementation_popup.snap | 5 +- ...plan_implementation_popup_no_selected.snap | 5 +- codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 23 +++++++++ 7 files changed, 113 insertions(+), 11 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index dd0827822dcc..3fc2639fca68 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,6 +3310,7 @@ 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. @@ -3333,7 +3338,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 +3376,14 @@ impl App { tui: &mut tui::Tui, app_server: &mut AppServerSession, started: AppServerStartedThread, + initial_user_message: Option, ) -> Result<()> { 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 +4138,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 +4185,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 +4197,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 +4307,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..2800021332c7 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -121,6 +121,11 @@ pub(crate) enum AppEvent { /// previous chat resumable. ClearUi, + /// Clear the current context, start a fresh session, and submit an initial user message. + 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..5bb1f1b094c4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -244,8 +244,10 @@ 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_CLEAR_CONTEXT: &str = "Yes, clear context and implement"; const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode"; const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan."; +const PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX: &str = "Implement the approved plan below in a fresh context. Treat the plan as the\nsource of user intent, re-read files as needed, and carry the work through\nimplementation and verification."; const MULTI_AGENT_ENABLE_TITLE: &str = "Enable subagents?"; const MULTI_AGENT_ENABLE_YES: &str = "Yes, enable"; const MULTI_AGENT_ENABLE_NO: &str = "Not now"; @@ -256,6 +258,10 @@ const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and P const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection"; const TUI_STUB_MESSAGE: &str = "Not available in TUI yet."; +fn plan_implementation_clear_context_message(plan_markdown: &str) -> String { + format!("{PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX}\n\n{plan_markdown}") +} + /// Choose the keybinding used to edit the most-recently queued message. /// /// Apple Terminal, Warp, and VSCode integrated terminals intercept or silently @@ -784,6 +790,8 @@ 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. + 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 +2248,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 +2328,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; @@ -2479,6 +2489,19 @@ impl ChatWidget { } None => (Vec::new(), Some("Default mode unavailable".to_string())), }; + let (clear_context_actions, clear_context_disabled_reason) = + match self.latest_proposed_plan_markdown.as_deref() { + Some(plan_markdown) if !plan_markdown.trim().is_empty() => { + let user_text = plan_implementation_clear_context_message(plan_markdown); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::ClearUiAndSubmitUserMessage { + text: user_text.clone(), + }); + })]; + (actions, None) + } + _ => (Vec::new(), Some("No approved plan available".to_string())), + }; let items = vec![ SelectionItem { name: PLAN_IMPLEMENTATION_YES.to_string(), @@ -2490,6 +2513,16 @@ impl ChatWidget { 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()), @@ -4805,6 +4838,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/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..cc2c5d3b2365 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)); @@ -39,6 +41,27 @@ async fn plan_implementation_popup_yes_emits_submit_message_event() { 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, + plan_implementation_clear_context_message(plan_markdown) + ); +} + #[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; From 91347b9209e2b492f3510babd4989361758d4eb4 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 11 Apr 2026 20:50:38 -0300 Subject: [PATCH 2/8] docs(tui): clarify plan handoff context Document the fresh-context Plan Mode handoff so reviewers can see that only the approved plan is carried into the new thread. Call out the initial-message timing contract that prevents replayed history from racing with the implementation prompt. --- codex-rs/tui/src/app.rs | 6 +++++- codex-rs/tui/src/app_event.rs | 3 +++ codex-rs/tui/src/chatwidget.rs | 8 ++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3fc2639fca68..594b74792a87 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3313,7 +3313,8 @@ impl App { 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(); @@ -3378,6 +3379,9 @@ impl App { 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, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 2800021332c7..a63110e26dcc 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -122,6 +122,9 @@ pub(crate) enum AppEvent { 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, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5bb1f1b094c4..7e3c7343a1e0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -258,6 +258,11 @@ const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and P const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection"; const TUI_STUB_MESSAGE: &str = "Not available in TUI yet."; +/// Builds the first prompt for implementing an approved plan in a fresh thread. +/// +/// The prompt deliberately carries only the approved plan, not a compacted transcript. Adding +/// planning-session history here would make the option behave like a partial resume instead of a +/// context clear, which can reintroduce rejected approaches into the implementation turn. fn plan_implementation_clear_context_message(plan_markdown: &str) -> String { format!("{PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX}\n\n{plan_markdown}") } @@ -791,6 +796,9 @@ pub(crate) struct ChatWidget { /// 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. /// From 23dffb357fe206e6270a55ab2c26da588609bdeb Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 11 Apr 2026 20:50:45 -0300 Subject: [PATCH 3/8] fix(tui): gate clear context on default mode Require the same Default-mode availability for the fresh-context implementation path as the existing same-thread implementation action. This keeps the Plan Mode approval popup from offering a coding handoff when the new thread would fall back to another visible mode. --- codex-rs/tui/src/chatwidget.rs | 30 ++++++++++++++++--- .../tui/src/chatwidget/tests/plan_mode.rs | 26 ++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 7e3c7343a1e0..3fbea19379ab 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -248,6 +248,8 @@ const PLAN_IMPLEMENTATION_CLEAR_CONTEXT: &str = "Yes, clear context and implemen const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode"; const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan."; const PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX: &str = "Implement the approved plan below in a fresh context. Treat the plan as the\nsource of user intent, re-read files as needed, and carry the work through\nimplementation and verification."; +const PLAN_IMPLEMENTATION_DEFAULT_UNAVAILABLE: &str = "Default mode unavailable"; +const PLAN_IMPLEMENTATION_NO_APPROVED_PLAN: &str = "No approved plan available"; const MULTI_AGENT_ENABLE_TITLE: &str = "Enable subagents?"; const MULTI_AGENT_ENABLE_YES: &str = "Yes, enable"; const MULTI_AGENT_ENABLE_NO: &str = "Not now"; @@ -267,6 +269,19 @@ fn plan_implementation_clear_context_message(plan_markdown: &str) -> String { format!("{PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX}\n\n{plan_markdown}") } +fn plan_implementation_clear_context_plan<'a>( + default_mask_available: bool, + plan_markdown: Option<&'a str>, +) -> Result<&'a str, &'static str> { + if !default_mask_available { + return Err(PLAN_IMPLEMENTATION_DEFAULT_UNAVAILABLE); + } + match plan_markdown { + Some(plan_markdown) if !plan_markdown.trim().is_empty() => Ok(plan_markdown), + _ => Err(PLAN_IMPLEMENTATION_NO_APPROVED_PLAN), + } +} + /// Choose the keybinding used to edit the most-recently queued message. /// /// Apple Terminal, Warp, and VSCode integrated terminals intercept or silently @@ -2484,6 +2499,7 @@ impl ChatWidget { fn open_plan_implementation_prompt(&mut self) { let default_mask = collaboration_modes::default_mode_mask(self.model_catalog.as_ref()); + let default_mask_available = default_mask.is_some(); let (implement_actions, implement_disabled_reason) = match default_mask { Some(mask) => { let user_text = PLAN_IMPLEMENTATION_CODING_MESSAGE.to_string(); @@ -2495,11 +2511,17 @@ impl ChatWidget { })]; (actions, None) } - None => (Vec::new(), Some("Default mode unavailable".to_string())), + None => ( + Vec::new(), + Some(PLAN_IMPLEMENTATION_DEFAULT_UNAVAILABLE.to_string()), + ), }; let (clear_context_actions, clear_context_disabled_reason) = - match self.latest_proposed_plan_markdown.as_deref() { - Some(plan_markdown) if !plan_markdown.trim().is_empty() => { + match plan_implementation_clear_context_plan( + default_mask_available, + self.latest_proposed_plan_markdown.as_deref(), + ) { + Ok(plan_markdown) => { let user_text = plan_implementation_clear_context_message(plan_markdown); let actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::ClearUiAndSubmitUserMessage { @@ -2508,7 +2530,7 @@ impl ChatWidget { })]; (actions, None) } - _ => (Vec::new(), Some("No approved plan available".to_string())), + Err(reason) => (Vec::new(), Some(reason.to_string())), }; let items = vec![ SelectionItem { diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index cc2c5d3b2365..6bf085090624 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -62,6 +62,32 @@ async fn plan_implementation_popup_clear_context_emits_clear_submit_event() { ); } +#[test] +fn plan_implementation_clear_context_requires_default_mode_and_plan() { + assert_eq!( + plan_implementation_clear_context_plan( + /*default_mask_available*/ false, + Some("- Step\n") + ), + Err(PLAN_IMPLEMENTATION_DEFAULT_UNAVAILABLE) + ); + assert_eq!( + plan_implementation_clear_context_plan(/*default_mask_available*/ true, None), + Err(PLAN_IMPLEMENTATION_NO_APPROVED_PLAN) + ); + assert_eq!( + plan_implementation_clear_context_plan(/*default_mask_available*/ true, Some(" \n")), + Err(PLAN_IMPLEMENTATION_NO_APPROVED_PLAN) + ); + assert_eq!( + plan_implementation_clear_context_plan( + /*default_mask_available*/ true, + Some("- Step\n") + ), + Ok("- Step\n") + ); +} + #[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; From f13e0e9f6efe9b6bdf704cfb1a61933bd071aa70 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 11 Apr 2026 21:17:13 -0300 Subject: [PATCH 4/8] test(tui): annotate plan handoff test argument Add the required `plan_markdown` argument comment in the Plan Mode regression test so the argument-comment lint can pass. This keeps the Default-mode availability coverage unchanged while satisfying the repo's literal argument convention. --- codex-rs/tui/src/chatwidget/tests/plan_mode.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 6bf085090624..eba8d07a1075 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -72,7 +72,9 @@ fn plan_implementation_clear_context_requires_default_mode_and_plan() { Err(PLAN_IMPLEMENTATION_DEFAULT_UNAVAILABLE) ); assert_eq!( - plan_implementation_clear_context_plan(/*default_mask_available*/ true, None), + plan_implementation_clear_context_plan( + /*default_mask_available*/ true, /*plan_markdown*/ None + ), Err(PLAN_IMPLEMENTATION_NO_APPROVED_PLAN) ); assert_eq!( From 63eaa20d826b8ce332e2237886fb86d328b4321d Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 11 Apr 2026 21:25:19 -0300 Subject: [PATCH 5/8] fix(tui): elide plan handoff helper lifetime Remove the explicit lifetime from the clear-context plan helper so Clippy accepts the TUI target under the Bazel lint configuration. The helper still returns the approved plan borrowed from the cached plan markdown; this only lets Rust infer the same relationship. --- codex-rs/tui/src/chatwidget.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3fbea19379ab..2adc1e3a1732 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -269,10 +269,10 @@ fn plan_implementation_clear_context_message(plan_markdown: &str) -> String { format!("{PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX}\n\n{plan_markdown}") } -fn plan_implementation_clear_context_plan<'a>( +fn plan_implementation_clear_context_plan( default_mask_available: bool, - plan_markdown: Option<&'a str>, -) -> Result<&'a str, &'static str> { + plan_markdown: Option<&str>, +) -> Result<&str, &'static str> { if !default_mask_available { return Err(PLAN_IMPLEMENTATION_DEFAULT_UNAVAILABLE); } From 17fede2bc9b906636085d9d0d2bae7e003520c12 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 11 Apr 2026 21:37:28 -0300 Subject: [PATCH 6/8] docs: add explicit instructions to avoid growing chatwidget.rs --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) 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: From 7ac7b74765221e0b8a7aa53e37c98df6bdd84608 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 11 Apr 2026 21:38:29 -0300 Subject: [PATCH 7/8] refactor(tui): move plan handoff popup out of chatwidget Extract the Plan Mode implementation popup construction into a focused `chatwidget` submodule so the large widget file stays on orchestration. The refactor preserves the existing popup behavior while keeping the clear-context handoff prompt and disabled-state checks close together. --- codex-rs/tui/src/chatwidget.rs | 108 ++---------------- .../tui/src/chatwidget/plan_implementation.rs | 98 ++++++++++++++++ .../tui/src/chatwidget/tests/plan_mode.rs | 54 +++++---- 3 files changed, 138 insertions(+), 122 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/plan_implementation.rs diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2adc1e3a1732..00d60dffd633 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -242,14 +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_CLEAR_CONTEXT: &str = "Yes, clear context and implement"; -const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode"; -const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan."; -const PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX: &str = "Implement the approved plan below in a fresh context. Treat the plan as the\nsource of user intent, re-read files as needed, and carry the work through\nimplementation and verification."; -const PLAN_IMPLEMENTATION_DEFAULT_UNAVAILABLE: &str = "Default mode unavailable"; -const PLAN_IMPLEMENTATION_NO_APPROVED_PLAN: &str = "No approved plan available"; const MULTI_AGENT_ENABLE_TITLE: &str = "Enable subagents?"; const MULTI_AGENT_ENABLE_YES: &str = "Yes, enable"; const MULTI_AGENT_ENABLE_NO: &str = "Not now"; @@ -260,28 +252,6 @@ const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and P const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection"; const TUI_STUB_MESSAGE: &str = "Not available in TUI yet."; -/// Builds the first prompt for implementing an approved plan in a fresh thread. -/// -/// The prompt deliberately carries only the approved plan, not a compacted transcript. Adding -/// planning-session history here would make the option behave like a partial resume instead of a -/// context clear, which can reintroduce rejected approaches into the implementation turn. -fn plan_implementation_clear_context_message(plan_markdown: &str) -> String { - format!("{PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX}\n\n{plan_markdown}") -} - -fn plan_implementation_clear_context_plan( - default_mask_available: bool, - plan_markdown: Option<&str>, -) -> Result<&str, &'static str> { - if !default_mask_available { - return Err(PLAN_IMPLEMENTATION_DEFAULT_UNAVAILABLE); - } - match plan_markdown { - Some(plan_markdown) if !plan_markdown.trim().is_empty() => Ok(plan_markdown), - _ => Err(PLAN_IMPLEMENTATION_NO_APPROVED_PLAN), - } -} - /// Choose the keybinding used to edit the most-recently queued message. /// /// Apple Terminal, Warp, and VSCode integrated terminals intercept or silently @@ -386,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; @@ -2499,78 +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 default_mask_available = default_mask.is_some(); - 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(PLAN_IMPLEMENTATION_DEFAULT_UNAVAILABLE.to_string()), - ), - }; - let (clear_context_actions, clear_context_disabled_reason) = - match plan_implementation_clear_context_plan( - default_mask_available, - self.latest_proposed_plan_markdown.as_deref(), - ) { - Ok(plan_markdown) => { - let user_text = plan_implementation_clear_context_message(plan_markdown); - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::ClearUiAndSubmitUserMessage { - text: user_text.clone(), - }); - })]; - (actions, None) - } - Err(reason) => (Vec::new(), Some(reason.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_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() - }, - ]; - 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(), }); 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..b1ba0cea3bee --- /dev/null +++ b/codex-rs/tui/src/chatwidget/plan_implementation.rs @@ -0,0 +1,98 @@ +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 = "Implement the approved plan below in a fresh context. Treat the plan as the\nsource of user intent, re-read files as needed, and carry the work through\nimplementation 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/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index eba8d07a1075..210b5de5e210 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -37,7 +37,10 @@ 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)); } @@ -58,36 +61,45 @@ async fn plan_implementation_popup_clear_context_emits_clear_submit_event() { }; assert_eq!( text, - plan_implementation_clear_context_message(plan_markdown) + format!( + "{prefix}\n\n{plan_markdown}", + prefix = plan_implementation::PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX + ) ); } -#[test] -fn plan_implementation_clear_context_requires_default_mode_and_plan() { +#[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!( - plan_implementation_clear_context_plan( - /*default_mask_available*/ false, - Some("- Step\n") - ), - Err(PLAN_IMPLEMENTATION_DEFAULT_UNAVAILABLE) + params.items[1].disabled_reason.as_deref(), + Some(plan_implementation::PLAN_IMPLEMENTATION_DEFAULT_UNAVAILABLE) ); - assert_eq!( - plan_implementation_clear_context_plan( - /*default_mask_available*/ true, /*plan_markdown*/ None - ), - Err(PLAN_IMPLEMENTATION_NO_APPROVED_PLAN) + + let params = plan_implementation::selection_view_params( + Some(default_mask.clone()), + /*plan_markdown*/ None, ); assert_eq!( - plan_implementation_clear_context_plan(/*default_mask_available*/ true, Some(" \n")), - Err(PLAN_IMPLEMENTATION_NO_APPROVED_PLAN) + 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!( - plan_implementation_clear_context_plan( - /*default_mask_available*/ true, - Some("- Step\n") - ), - Ok("- Step\n") + 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] From cbf22dcf48c6ce585935d57f757160cf2ef1b53c Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 17 Apr 2026 14:07:28 -0300 Subject: [PATCH 8/8] fix(tui): clarify fresh plan handoff prompt Frame the fresh-context implementation prompt as a prior agent plan handoff so the model sees the plan as task intent. --- codex-rs/tui/src/chatwidget/plan_implementation.rs | 7 ++++++- codex-rs/tui/src/chatwidget/tests/plan_mode.rs | 8 ++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/plan_implementation.rs b/codex-rs/tui/src/chatwidget/plan_implementation.rs index b1ba0cea3bee..c5b253a8d199 100644 --- a/codex-rs/tui/src/chatwidget/plan_implementation.rs +++ b/codex-rs/tui/src/chatwidget/plan_implementation.rs @@ -11,7 +11,12 @@ 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 = "Implement the approved plan below in a fresh context. Treat the plan as the\nsource of user intent, re-read files as needed, and carry the work through\nimplementation and verification."; +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"; diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 210b5de5e210..8c0aa4568d5f 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -61,10 +61,10 @@ async fn plan_implementation_popup_clear_context_emits_clear_submit_event() { }; assert_eq!( text, - format!( - "{prefix}\n\n{plan_markdown}", - prefix = plan_implementation::PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX - ) + "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" ); }