Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
57 changes: 49 additions & 8 deletions codex-rs/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1085,13 +1085,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(),
Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -3306,9 +3310,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();
Expand All @@ -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!(
Expand Down Expand Up @@ -3366,9 +3377,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?;
Expand Down Expand Up @@ -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(()) => {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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(()) => {
Expand Down
8 changes: 8 additions & 0 deletions codex-rs/tui/src/app_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down
60 changes: 15 additions & 45 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<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(),
});
Expand Down Expand Up @@ -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,
Expand Down
103 changes: 103 additions & 0 deletions codex-rs/tui/src/chatwidget/plan_implementation.rs
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions codex-rs/tui/src/chatwidget/tests/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading
Loading