fix: model menu pop (#18154)

Fix the `/model` menu looping on itself
This commit is contained in:
jif-oai
2026-04-16 18:02:02 +01:00
committed by GitHub
parent 3a4fa77ad7
commit baaf42b2e4
5 changed files with 89 additions and 18 deletions

View File

@@ -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<ViewCompletion> {
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

View File

@@ -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<TextAreaState>,
complete: bool,
completion: Option<ViewCompletion>,
}
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<ViewCompletion> {
self.completion
}
fn handle_paste(&mut self, pasted: String) -> bool {

View File

@@ -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<SelectionAction>,
pub dismiss_on_select: bool,
pub dismiss_parent_on_child_accept: bool,
pub search_value: Option<String>,
pub disabled_reason: Option<String>,
}
@@ -211,7 +213,8 @@ pub(crate) struct ListSelectionView {
footer_hint: Option<Line<'static>>,
items: Vec<SelectionItem>,
state: ScrollState,
complete: bool,
completion: Option<ViewCompletion>,
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<ViewCompletion> {
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
}
}

View File

@@ -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<ViewCompletion>) {
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();

View File

@@ -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()
});