feat(tui): add clear-context plan implementation (#17499)

## TL;DR

- Adds a second Plan Mode handoff: implement the approved plan after
clearing context.
- Keeps the existing same-thread `Yes, implement this plan` action
unchanged.
- Reuses the `/clear` thread-start path and submits the approved plan as
the fresh thread's first prompt.
- Covers the new popup option, event plumbing, initial-message behavior,
and disabled states in TUI tests.

## Problem

Plan Mode already asks whether to implement an approved plan, but the
only affirmative path continues in the same thread. That is useful when
the planning conversation itself is still valuable, but it does not
support the workflow where exploratory planning context is discarded and
implementation starts from the final approved plan as the only
model-visible handoff.

<img width="1253" height="869" alt="image"
src="https://github.com/user-attachments/assets/90023d75-c330-4919-bed8-518671c3474b"
/>

## Mental model

There are now two implementation choices after a proposed plan. The
existing choice, `Yes, implement this plan`, is unchanged: it switches
to Default mode and submits `Implement the plan.` in the current thread.
The new choice, `Yes, clear context and implement`, treats the proposed
plan as a handoff artifact. It clears the UI/session context through the
same thread-start source used by `/clear`, then submits an initial
prompt containing the approved plan after the fresh thread is
configured.

The important distinction is that the new path is not compaction. The
model receives a deliberate implementation prompt built from the
approved plan markdown, not a summary of the previous planning
transcript. Both implementation choices require the Default
collaboration preset to be available, so the popup does not offer a
coding handoff when the fresh thread would fall back to another mode.

## Non-goals

This change does not alter `/clear`, `/compact`, or the existing
same-context Plan Mode implementation option. It does not add protocol
surface area or app-server schema changes. It also does not carry the
previous transcript path or a generated planning summary into the new
model context.

## Tradeoffs

The fresh-context option relies on the approved plan being sufficiently
complete. That matches the Plan Mode contract, but it means vague plans
will produce weaker implementation starts than a compacted transcript
would. The upside is that rejected ideas, exploratory dead ends, and
planning corrections do not leak into the implementation turn.

The current implementation stores the latest proposed plan in
`ChatWidget` rather than deriving it from history cells at selection
time. This keeps the popup action simple and deterministic, but it makes
the cache lifecycle important: it must be reset when a new task starts
so an old plan cannot be submitted later.

## Architecture

The TUI stores the most recent completed proposed-plan markdown when a
plan item completes. The Plan Mode approval popup uses that cache to
enable the fresh-context option and to build a first-turn prompt that
instructs the model to implement the approved plan in a fresh context.

Selecting the new option emits a TUI-internal
`ClearUiAndSubmitUserMessage` event. `App` handles that event by reusing
the existing clear flow: clear terminal state, reset app UI state, start
a new app-server thread with `ThreadStartSource::Clear`, and attach a
replacement `ChatWidget` with an initial user message. The existing
initial-message suppression in `enqueue_primary_thread_session` ensures
the prompt is submitted only after the new session is configured and any
startup replay is rendered.

## Observability

The previous thread remains resumable through the existing clear-session
summary hint. There is no new telemetry or protocol event for this path,
so debugging should start at the TUI event boundary: confirm the popup
emitted `ClearUiAndSubmitUserMessage`, confirm the app-server thread
start used `ThreadStartSource::Clear`, then confirm the fresh widget
submitted the initial user message after `SessionConfigured`.

## Tests

The Plan Mode popup snapshots cover the new option and preserve the
original option as the first/default action. Unit coverage verifies the
original same-context option still emits `SubmitUserMessageWithMode`,
the new option emits `ClearUiAndSubmitUserMessage` with the approved
plan embedded verbatim, and the clear-context option is disabled when
Default mode is unavailable or no approved plan exists. The broader
`codex-tui` test package passes with the updated fresh-thread
initial-message plumbing.
This commit is contained in:
Felipe Coury
2026-04-17 14:30:09 -03:00
committed by GitHub
Unverified
parent ea84537369
commit d3692b14c9
9 changed files with 248 additions and 58 deletions
+2
View File
@@ -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:
+49 -8
View File
@@ -1118,13 +1118,13 @@ impl App {
&self,
tui: &mut tui::Tui,
cfg: crate::legacy_core::config::Config,
initial_user_message: Option<crate::chatwidget::UserMessage>,
) -> 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(),
@@ -3418,7 +3418,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)?;
@@ -3479,9 +3483,11 @@ impl App {
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
session_start_source: Option<ThreadStartSource>,
initial_user_message: Option<crate::chatwidget::UserMessage>,
) {
// 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();
@@ -3507,7 +3513,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!(
@@ -3540,9 +3551,17 @@ impl App {
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
started: AppServerStartedThread,
initial_user_message: Option<crate::chatwidget::UserMessage>,
) -> 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?;
@@ -4312,7 +4331,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(()) => {
@@ -4357,6 +4378,7 @@ impl App {
AppEvent::NewSession => {
self.start_fresh_session_with_summary_hint(
tui, app_server, /*session_start_source*/ None,
/*initial_user_message*/ None,
)
.await;
}
@@ -4368,6 +4390,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;
}
@@ -4462,7 +4501,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(()) => {
+8
View File
@@ -122,6 +122,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,
+15 -45
View File
@@ -245,10 +245,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";
@@ -370,6 +366,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;
@@ -794,6 +792,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<String>,
/// 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<String>,
/// Whether this turn already produced a copyable response.
///
/// `TurnComplete.last_agent_message` is a fallback source: use it only when no earlier
@@ -2248,6 +2251,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.
@@ -2327,6 +2331,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;
@@ -2474,48 +2479,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<SelectionAction> = 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(),
});
@@ -4896,6 +4865,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,
@@ -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<CollaborationModeMask>,
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<SelectionAction> = 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<SelectionAction> = 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()
}
}
@@ -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
@@ -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
@@ -209,6 +209,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(),
+64 -1
View File
@@ -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;