scrollable slash menu, esc to exit

This commit is contained in:
easong-openai
2025-08-01 04:33:01 -07:00
parent e2c994e32a
commit 1aad659eba
2 changed files with 64 additions and 7 deletions
+44 -1
View File
@@ -43,6 +43,10 @@ pub(crate) struct ChatComposer<'a> {
ctrl_c_quit_hint: bool,
use_shift_enter_hint: bool,
dismissed_file_popup_token: Option<String>,
// 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<String>,
current_file_query: Option<String>,
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<String> = 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);
}
}
}
}
}
+20 -6
View File
@@ -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<Row> = 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::<Vec<(usize, &SlashCommand)>>();
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),