diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 6fa946a5af..3c0ab2c74e 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -7,6 +7,13 @@ use crossterm::event::KeyEvent; use super::CancellationEvent; +/// Reason an active bottom-pane view finished. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ViewCompletion { + Accepted, + Cancelled, +} + /// Trait implemented by every view that can be shown in the bottom pane. pub(crate) trait BottomPaneView: Renderable { /// Handle a key event while the view is active. A redraw is always @@ -18,6 +25,19 @@ pub(crate) trait BottomPaneView: Renderable { false } + /// Return the completion reason once the view has finished. + fn completion(&self) -> Option { + None + } + + /// Return true when this view should be removed after a child view is accepted. + fn dismiss_after_child_accept(&self) -> bool { + false + } + + /// Clear any pending child-flow cleanup marker after a child view is cancelled. + fn clear_dismiss_after_child_accept(&mut self) {} + /// Stable identifier for views that need external refreshes while open. fn view_id(&self) -> Option<&'static str> { None diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs index 753aae51c7..bce9528fde 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs @@ -18,6 +18,7 @@ use super::popup_consts::standard_popup_hint_line; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; +use super::bottom_pane_view::ViewCompletion; use super::textarea::TextArea; use super::textarea::TextAreaState; @@ -34,7 +35,7 @@ pub(crate) struct CustomPromptView { // UI state textarea: TextArea, textarea_state: RefCell, - complete: bool, + completion: Option, } impl CustomPromptView { @@ -58,7 +59,7 @@ impl CustomPromptView { on_submit, textarea, textarea_state: RefCell::new(TextAreaState::default()), - complete: false, + completion: None, } } } @@ -79,7 +80,7 @@ impl BottomPaneView for CustomPromptView { let text = self.textarea.text().trim().to_string(); if !text.is_empty() { (self.on_submit)(text); - self.complete = true; + self.completion = Some(ViewCompletion::Accepted); } } KeyEvent { @@ -95,12 +96,16 @@ impl BottomPaneView for CustomPromptView { } fn on_ctrl_c(&mut self) -> CancellationEvent { - self.complete = true; + self.completion = Some(ViewCompletion::Cancelled); CancellationEvent::Handled } fn is_complete(&self) -> bool { - self.complete + self.completion.is_some() + } + + fn completion(&self) -> Option { + self.completion } fn handle_paste(&mut self, pasted: String) -> bool { diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index e3eecf2ed0..c95565455d 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -21,6 +21,7 @@ use crate::render::renderable::Renderable; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; +use super::bottom_pane_view::ViewCompletion; use super::popup_consts::MAX_POPUP_ROWS; use super::scroll_state::ScrollState; pub(crate) use super::selection_popup_common::ColumnWidthMode; @@ -121,6 +122,7 @@ pub(crate) struct SelectionItem { pub is_disabled: bool, pub actions: Vec, pub dismiss_on_select: bool, + pub dismiss_parent_on_child_accept: bool, pub search_value: Option, pub disabled_reason: Option, } @@ -211,7 +213,8 @@ pub(crate) struct ListSelectionView { footer_hint: Option>, items: Vec, state: ScrollState, - complete: bool, + completion: Option, + dismiss_after_child_accept: bool, app_event_tx: AppEventSender, is_searchable: bool, search_query: String, @@ -259,7 +262,8 @@ impl ListSelectionView { footer_hint: params.footer_hint, items: params.items, state: ScrollState::new(), - complete: false, + completion: None, + dismiss_after_child_accept: false, app_event_tx, is_searchable: params.is_searchable, search_query: String::new(), @@ -458,13 +462,15 @@ impl ListSelectionView { act(&self.app_event_tx); } if item.dismiss_on_select { - self.complete = true; + self.completion = Some(ViewCompletion::Accepted); + } else if item.dismiss_parent_on_child_accept { + self.dismiss_after_child_accept = true; } } else if selected_item.is_none() { if let Some(cb) = &self.on_cancel { cb(&self.app_event_tx); } - self.complete = true; + self.completion = Some(ViewCompletion::Cancelled); } } @@ -672,7 +678,19 @@ impl BottomPaneView for ListSelectionView { } fn is_complete(&self) -> bool { - self.complete + self.completion.is_some() + } + + fn completion(&self) -> Option { + self.completion + } + + fn dismiss_after_child_accept(&self) -> bool { + self.dismiss_after_child_accept + } + + fn clear_dismiss_after_child_accept(&mut self) { + self.dismiss_after_child_accept = false; } fn view_id(&self) -> Option<&'static str> { @@ -687,7 +705,7 @@ impl BottomPaneView for ListSelectionView { if let Some(cb) = &self.on_cancel { cb(&self.app_event_tx); } - self.complete = true; + self.completion = Some(ViewCompletion::Cancelled); CancellationEvent::Handled } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 91c8bd48eb..2600a90cd9 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -30,6 +30,7 @@ use crate::render::renderable::Renderable; use crate::render::renderable::RenderableItem; use crate::tui::FrameRequester; use bottom_pane_view::BottomPaneView; +use bottom_pane_view::ViewCompletion; use codex_features::Features; use codex_file_search::FileMatch; use codex_protocol::request_user_input::RequestUserInputEvent; @@ -380,8 +381,25 @@ impl BottomPane { self.request_redraw(); } - fn pop_active_view(&mut self) { + fn pop_active_view_with_completion(&mut self, completion: Option) { if self.view_stack.pop().is_some() { + match completion { + Some(ViewCompletion::Accepted) => { + while self + .view_stack + .last() + .is_some_and(|view| view.dismiss_after_child_accept()) + { + self.view_stack.pop(); + } + } + Some(ViewCompletion::Cancelled) => { + if let Some(view) = self.view_stack.last_mut() { + view.clear_dismiss_after_child_accept(); + } + } + None => {} + } self.on_view_stack_depth_decreased(); } } @@ -403,7 +421,7 @@ impl BottomPane { // We need three pieces of information after routing the key: // whether Esc completed the view, whether the view finished for any // reason, and whether a paste-burst timer should be scheduled. - let (ctrl_c_completed, view_complete, view_in_paste_burst) = { + let (ctrl_c_completed, view_complete, completion, view_in_paste_burst) = { let last_index = self.view_stack.len() - 1; let view = &mut self.view_stack[last_index]; let prefer_esc = @@ -413,22 +431,27 @@ impl BottomPane { && matches!(view.on_ctrl_c(), CancellationEvent::Handled) && view.is_complete(); if ctrl_c_completed { - (true, true, false) + (true, true, view.completion(), false) } else { view.handle_key_event(key_event); - (false, view.is_complete(), view.is_in_paste_burst()) + ( + false, + view.is_complete(), + view.completion(), + view.is_in_paste_burst(), + ) } }; if ctrl_c_completed { - self.pop_active_view(); + self.pop_active_view_with_completion(completion); if let Some(next_view) = self.view_stack.last() && next_view.is_in_paste_burst() { self.request_redraw_in(ChatComposer::recommended_paste_flush_delay()); } } else if view_complete { - self.pop_active_view(); + self.pop_active_view_with_completion(completion); } else if view_in_paste_burst { self.request_redraw_in(ChatComposer::recommended_paste_flush_delay()); } @@ -481,9 +504,10 @@ impl BottomPane { if let Some(view) = self.view_stack.last_mut() { let event = view.on_ctrl_c(); let view_complete = view.is_complete(); + let completion = view.completion(); if matches!(event, CancellationEvent::Handled) { if view_complete { - self.pop_active_view(); + self.pop_active_view_with_completion(completion); } self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c'))); self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index de71efb9d8..890489bc2c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -7953,6 +7953,7 @@ impl ChatWidget { is_default: preset.is_default, actions, dismiss_on_select: single_supported_effort, + dismiss_parent_on_child_accept: !single_supported_effort, ..Default::default() }); } @@ -10476,6 +10477,7 @@ impl ChatWidget { } })], dismiss_on_select: false, + dismiss_parent_on_child_accept: true, ..Default::default() }); @@ -10501,6 +10503,7 @@ impl ChatWidget { } })], dismiss_on_select: false, + dismiss_parent_on_child_accept: true, ..Default::default() }); @@ -10510,6 +10513,7 @@ impl ChatWidget { tx.send(AppEvent::OpenReviewCustomPrompt); })], dismiss_on_select: false, + dismiss_parent_on_child_accept: true, ..Default::default() });