diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 7e187bec8b..bdd8d4bee9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -43,6 +43,10 @@ pub(crate) struct ChatComposer<'a> { ctrl_c_quit_hint: bool, use_shift_enter_hint: bool, dismissed_file_popup_token: Option, + // When set, suppress reopening the slash-command popup while the first-line + // '/token' remains equal to this value. Cleared when the first line no longer + // starts with '/'. + dismissed_command_popup_token: Option, current_file_query: Option, pending_pastes: Vec<(String, String)>, } @@ -74,6 +78,7 @@ impl ChatComposer<'_> { ctrl_c_quit_hint: false, use_shift_enter_hint, dismissed_file_popup_token: None, + dismissed_command_popup_token: None, current_file_query: None, pending_pastes: Vec::new(), }; @@ -216,6 +221,23 @@ impl ChatComposer<'_> { popup.move_down(); (InputResult::None, true) } + Input { key: Key::Esc, .. } => { + // Hide popup without modifying text. Remember current '/token' so + // we don't immediately reopen until the token changes. + let first_line = self + .textarea + .lines() + .first() + .map(|s| s.as_str()) + .unwrap_or(""); + if let Some(stripped) = first_line.strip_prefix('/') { + let token = stripped.trim_start(); + let cmd_token = token.split_whitespace().next().unwrap_or(""); + self.dismissed_command_popup_token = Some(cmd_token.to_string()); + } + self.active_popup = ActivePopup::None; + (InputResult::None, true) + } Input { key: Key::Tab, .. } => { if let Some(cmd) = popup.selected_command() { let first_line = self @@ -590,21 +612,42 @@ impl ChatComposer<'_> { .unwrap_or(""); let input_starts_with_slash = first_line.starts_with('/'); + // Determine the current '/token' on the first line (may be empty). + let current_cmd_token: Option = if input_starts_with_slash { + if let Some(stripped) = first_line.strip_prefix('/') { + let token = stripped.trim_start(); + Some(token.split_whitespace().next().unwrap_or("").to_string()) + } else { + Some(String::new()) + } + } else { + None + }; match &mut self.active_popup { ActivePopup::Command(popup) => { if input_starts_with_slash { popup.on_composer_text_change(first_line.to_string()); } else { self.active_popup = ActivePopup::None; + self.dismissed_command_popup_token = None; } } _ => { if input_starts_with_slash { + // Avoid reopening immediately after user pressed Esc until + // the '/token' changes. + if self + .dismissed_command_popup_token + .as_ref() + .is_some_and(|t| Some(t) == current_cmd_token.as_ref()) + { + return; + } let mut command_popup = CommandPopup::new(); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); } - } + } } } diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 364a8472dc..cb9ff38cf1 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -123,10 +123,13 @@ impl CommandPopup { Some(idx) if idx + 1 < matches_len => { self.selected_idx = Some(idx + 1); } + Some(_idx_last) => { + // Wrap around when moving down from the last item. + self.selected_idx = Some(0); + } None => { self.selected_idx = Some(0); } - _ => {} } } @@ -142,10 +145,21 @@ impl WidgetRef for CommandPopup { let matches = self.filtered_commands(); let mut rows: Vec = Vec::new(); - let visible_matches: Vec<&SlashCommand> = - matches.into_iter().take(MAX_POPUP_ROWS).collect(); - if visible_matches.is_empty() { + let start_idx = match (self.selected_idx, matches.len()) { + (Some(sel), len) if len > 0 => sel.saturating_sub(MAX_POPUP_ROWS.saturating_sub(1)), + _ => 0, + }; + + let enumerated_visible = matches + .iter() + .enumerate() + .skip(start_idx) + .take(MAX_POPUP_ROWS) + .map(|(i, cmd)| (i, *cmd)) + .collect::>(); + + if enumerated_visible.is_empty() { rows.push(Row::new(vec![ Cell::from(""), Cell::from("No matching commands").add_modifier(Modifier::ITALIC), @@ -153,10 +167,10 @@ impl WidgetRef for CommandPopup { } else { let default_style = Style::default(); let command_style = Style::default().fg(Color::LightBlue); - for (idx, cmd) in visible_matches.iter().enumerate() { + for (global_idx, cmd) in enumerated_visible.into_iter() { rows.push(Row::new(vec![ Cell::from(Line::from(vec![ - if Some(idx) == self.selected_idx { + if Some(global_idx) == self.selected_idx { Span::styled( "›", Style::default().bg(Color::DarkGray).fg(Color::LightCyan),