Files
llmx/codex-rs/tui/src/bottom_pane/command_popup.rs

318 lines
12 KiB
Rust
Raw Normal View History

use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
use codex_common::fuzzy_match::fuzzy_match;
use codex_protocol::custom_prompts::CustomPrompt;
use std::collections::HashSet;
/// A selectable item in the popup: either a built-in command or a user prompt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CommandItem {
Builtin(SlashCommand),
// Index into `prompts`
UserPrompt(usize),
}
pub(crate) struct CommandPopup {
command_filter: String,
builtins: Vec<(&'static str, SlashCommand)>,
prompts: Vec<CustomPrompt>,
state: ScrollState,
}
impl CommandPopup {
pub(crate) fn new(mut prompts: Vec<CustomPrompt>) -> Self {
let builtins = built_in_slash_commands();
// Exclude prompts that collide with builtin command names and sort by name.
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
prompts.retain(|p| !exclude.contains(&p.name));
prompts.sort_by(|a, b| a.name.cmp(&b.name));
Self {
command_filter: String::new(),
builtins,
prompts,
state: ScrollState::new(),
}
}
pub(crate) fn set_prompts(&mut self, mut prompts: Vec<CustomPrompt>) {
let exclude: HashSet<String> = self
.builtins
.iter()
.map(|(n, _)| (*n).to_string())
.collect();
prompts.retain(|p| !exclude.contains(&p.name));
prompts.sort_by(|a, b| a.name.cmp(&b.name));
self.prompts = prompts;
}
pub(crate) fn prompt_name(&self, idx: usize) -> Option<&str> {
self.prompts.get(idx).map(|p| p.name.as_str())
}
pub(crate) fn prompt_content(&self, idx: usize) -> Option<&str> {
self.prompts.get(idx).map(|p| p.content.as_str())
}
/// Update the filter string based on the current composer text. The text
/// passed in is expected to start with a leading '/'. Everything after the
/// *first* '/" on the *first* line becomes the active filter that is used
/// to narrow down the list of available commands.
pub(crate) fn on_composer_text_change(&mut self, text: String) {
let first_line = text.lines().next().unwrap_or("");
if let Some(stripped) = first_line.strip_prefix('/') {
// Extract the *first* token (sequence of non-whitespace
// characters) after the slash so that `/clear something` still
// shows the help for `/clear`.
let token = stripped.trim_start();
let cmd_token = token.split_whitespace().next().unwrap_or("");
// Update the filter keeping the original case (commands are all
// lower-case for now but this may change in the future).
self.command_filter = cmd_token.to_string();
} else {
// The composer no longer starts with '/'. Reset the filter so the
// popup shows the *full* command list if it is still displayed
// for some reason.
self.command_filter.clear();
}
// Reset or clamp selected index based on new filtered list.
let matches_len = self.filtered_items().len();
self.state.clamp_selection(matches_len);
self.state
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
}
2025-09-15 17:34:04 -07:00
/// Determine the preferred height of the popup for a given width.
/// Accounts for wrapped descriptions so that long tooltips don't overflow.
pub(crate) fn calculate_required_height(&self, width: u16) -> u16 {
use super::selection_popup_common::measure_rows_height;
let rows = self.rows_from_matches(self.filtered());
measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width)
}
/// Compute fuzzy-filtered matches over built-in commands and user prompts,
/// paired with optional highlight indices and score. Sorted by ascending
/// score, then by name for stability.
fn filtered(&self) -> Vec<(CommandItem, Option<Vec<usize>>, i32)> {
let filter = self.command_filter.trim();
let mut out: Vec<(CommandItem, Option<Vec<usize>>, i32)> = Vec::new();
if filter.is_empty() {
// Built-ins first, in presentation order.
for (_, cmd) in self.builtins.iter() {
out.push((CommandItem::Builtin(*cmd), None, 0));
}
// Then prompts, already sorted by name.
for idx in 0..self.prompts.len() {
out.push((CommandItem::UserPrompt(idx), None, 0));
}
return out;
}
for (_, cmd) in self.builtins.iter() {
if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) {
out.push((CommandItem::Builtin(*cmd), Some(indices), score));
}
}
for (idx, p) in self.prompts.iter().enumerate() {
if let Some((indices, score)) = fuzzy_match(&p.name, filter) {
out.push((CommandItem::UserPrompt(idx), Some(indices), score));
}
}
// When filtering, sort by ascending score and then by name for stability.
out.sort_by(|a, b| {
a.2.cmp(&b.2).then_with(|| {
let an = match a.0 {
CommandItem::Builtin(c) => c.command(),
CommandItem::UserPrompt(i) => &self.prompts[i].name,
};
let bn = match b.0 {
CommandItem::Builtin(c) => c.command(),
CommandItem::UserPrompt(i) => &self.prompts[i].name,
};
an.cmp(bn)
})
});
out
}
fn filtered_items(&self) -> Vec<CommandItem> {
self.filtered().into_iter().map(|(c, _, _)| c).collect()
}
fn rows_from_matches(
&self,
matches: Vec<(CommandItem, Option<Vec<usize>>, i32)>,
) -> Vec<GenericDisplayRow> {
matches
.into_iter()
.map(|(item, indices, _)| {
let (name, description) = match item {
CommandItem::Builtin(cmd) => {
(format!("/{}", cmd.command()), cmd.description().to_string())
}
CommandItem::UserPrompt(i) => (
format!("/{}", self.prompts[i].name),
"send saved prompt".to_string(),
),
};
GenericDisplayRow {
name,
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
is_current: false,
description: Some(description),
}
})
.collect()
}
/// Move the selection cursor one step up.
pub(crate) fn move_up(&mut self) {
let len = self.filtered_items().len();
self.state.move_up_wrap(len);
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
/// Move the selection cursor one step down.
pub(crate) fn move_down(&mut self) {
let matches_len = self.filtered_items().len();
self.state.move_down_wrap(matches_len);
self.state
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
}
/// Return currently selected command, if any.
pub(crate) fn selected_item(&self) -> Option<CommandItem> {
let matches = self.filtered_items();
self.state
.selected_idx
.and_then(|idx| matches.get(idx).copied())
}
}
impl WidgetRef for CommandPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let rows = self.rows_from_matches(self.filtered());
render_rows(
area,
buf,
&rows,
&self.state,
MAX_POPUP_ROWS,
"no matches",
update composer + user message styling (#4240) Changes: - the composer and user messages now have a colored background that stretches the entire width of the terminal. - the prompt character was changed from a cyan `▌` to a bold `›`. - the "working" shimmer now follows the "dark gray" color of the terminal, better matching the terminal's color scheme | Terminal + Background | Screenshot | |------------------------------|------------| | iTerm with dark bg | <img width="810" height="641" alt="Screenshot 2025-09-25 at 11 44 52 AM" src="https://github.com/user-attachments/assets/1317e579-64a9-4785-93e6-98b0258f5d92" /> | | iTerm with light bg | <img width="845" height="540" alt="Screenshot 2025-09-25 at 11 46 29 AM" src="https://github.com/user-attachments/assets/e671d490-c747-4460-af0b-3f8d7f7a6b8e" /> | | iTerm with color bg | <img width="825" height="564" alt="Screenshot 2025-09-25 at 11 47 12 AM" src="https://github.com/user-attachments/assets/141cda1b-1164-41d5-87da-3be11e6a3063" /> | | Terminal.app with dark bg | <img width="577" height="367" alt="Screenshot 2025-09-25 at 11 45 22 AM" src="https://github.com/user-attachments/assets/93fc4781-99f7-4ee7-9c8e-3db3cd854fe5" /> | | Terminal.app with light bg | <img width="577" height="367" alt="Screenshot 2025-09-25 at 11 46 04 AM" src="https://github.com/user-attachments/assets/19bf6a3c-91e0-447b-9667-b8033f512219" /> | | Terminal.app with color bg | <img width="577" height="367" alt="Screenshot 2025-09-25 at 11 45 50 AM" src="https://github.com/user-attachments/assets/dd7c4b5b-342e-4028-8140-f4e65752bd0b" /> |
2025-09-26 16:35:56 -07:00
false,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::string::ToString;
#[test]
fn filter_includes_init_when_typing_prefix() {
let mut popup = CommandPopup::new(Vec::new());
// Simulate the composer line starting with '/in' so the popup filters
// matching commands by prefix.
popup.on_composer_text_change("/in".to_string());
// Access the filtered list via the selected command and ensure that
// one of the matches is the new "init" command.
let matches = popup.filtered_items();
let has_init = matches.iter().any(|item| match item {
CommandItem::Builtin(cmd) => cmd.command() == "init",
CommandItem::UserPrompt(_) => false,
});
assert!(
has_init,
"expected '/init' to appear among filtered commands"
);
}
#[test]
fn selecting_init_by_exact_match() {
let mut popup = CommandPopup::new(Vec::new());
popup.on_composer_text_change("/init".to_string());
// When an exact match exists, the selected command should be that
// command by default.
let selected = popup.selected_item();
match selected {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"),
Some(CommandItem::UserPrompt(_)) => panic!("unexpected prompt selected for '/init'"),
None => panic!("expected a selected command for exact match"),
}
}
#[test]
fn model_is_first_suggestion_for_mo() {
let mut popup = CommandPopup::new(Vec::new());
popup.on_composer_text_change("/mo".to_string());
let matches = popup.filtered_items();
match matches.first() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"),
Some(CommandItem::UserPrompt(_)) => {
panic!("unexpected prompt ranked before '/model' for '/mo'")
}
None => panic!("expected at least one match for '/mo'"),
}
}
#[test]
fn prompt_discovery_lists_custom_prompts() {
let prompts = vec![
CustomPrompt {
name: "foo".to_string(),
path: "/tmp/foo.md".to_string().into(),
content: "hello from foo".to_string(),
},
CustomPrompt {
name: "bar".to_string(),
path: "/tmp/bar.md".to_string().into(),
content: "hello from bar".to_string(),
},
];
let popup = CommandPopup::new(prompts);
let items = popup.filtered_items();
let mut prompt_names: Vec<String> = items
.into_iter()
.filter_map(|it| match it {
CommandItem::UserPrompt(i) => popup.prompt_name(i).map(ToString::to_string),
_ => None,
})
.collect();
prompt_names.sort();
assert_eq!(prompt_names, vec!["bar".to_string(), "foo".to_string()]);
}
#[test]
fn prompt_name_collision_with_builtin_is_ignored() {
// Create a prompt named like a builtin (e.g. "init").
let popup = CommandPopup::new(vec![CustomPrompt {
name: "init".to_string(),
path: "/tmp/init.md".to_string().into(),
content: "should be ignored".to_string(),
}]);
let items = popup.filtered_items();
let has_collision_prompt = items.into_iter().any(|it| match it {
CommandItem::UserPrompt(i) => popup.prompt_name(i) == Some("init"),
_ => false,
});
assert!(
!has_collision_prompt,
"prompt with builtin name should be ignored"
);
}
}