Files
llmx/codex-rs/tui/src/bottom_pane/command_popup.rs
2025-05-29 14:57:55 -07:00

198 lines
6.7 KiB
Rust

use std::collections::HashMap;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Cell;
use ratatui::widgets::Row;
use ratatui::widgets::Table;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
const MAX_POPUP_ROWS: usize = 5;
/// Ideally this is enough to show the longest command name.
const FIRST_COLUMN_WIDTH: u16 = 20;
use ratatui::style::Modifier;
pub(crate) struct CommandPopup {
command_filter: String,
all_commands: HashMap<&'static str, SlashCommand>,
selected_idx: Option<usize>,
}
impl CommandPopup {
pub(crate) fn new() -> Self {
Self {
command_filter: String::new(),
all_commands: built_in_slash_commands(),
selected_idx: None,
}
}
/// 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_commands().len();
self.selected_idx = match matches_len {
0 => None,
_ => Some(self.selected_idx.unwrap_or(0).min(matches_len - 1)),
};
}
/// Determine the preferred height of the popup. This is the number of
/// rows required to show **at most** `MAX_POPUP_ROWS` commands plus the
/// table/border overhead (one line at the top and one at the bottom).
pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 {
let matches = self.filtered_commands();
let row_count = matches.len().clamp(1, MAX_POPUP_ROWS) as u16;
// Account for the border added by the Block that wraps the table.
// 2 = one line at the top, one at the bottom.
row_count + 2
}
/// Return the list of commands that match the current filter. Matching is
/// performed using a *prefix* comparison on the command name.
fn filtered_commands(&self) -> Vec<&SlashCommand> {
let mut cmds: Vec<&SlashCommand> = self
.all_commands
.values()
.filter(|cmd| {
if self.command_filter.is_empty() {
true
} else {
cmd.command()
.starts_with(&self.command_filter.to_ascii_lowercase())
}
})
.collect();
// Sort the commands alphabetically so the order is stable and
// predictable.
cmds.sort_by(|a, b| a.command().cmp(b.command()));
cmds
}
/// Move the selection cursor one step up.
pub(crate) fn move_up(&mut self) {
if let Some(len) = self.filtered_commands().len().checked_sub(1) {
if len == usize::MAX {
return;
}
}
if let Some(idx) = self.selected_idx {
if idx > 0 {
self.selected_idx = Some(idx - 1);
}
} else if !self.filtered_commands().is_empty() {
self.selected_idx = Some(0);
}
}
/// Move the selection cursor one step down.
pub(crate) fn move_down(&mut self) {
let matches_len = self.filtered_commands().len();
if matches_len == 0 {
self.selected_idx = None;
return;
}
match self.selected_idx {
Some(idx) if idx + 1 < matches_len => {
self.selected_idx = Some(idx + 1);
}
None => {
self.selected_idx = Some(0);
}
_ => {}
}
}
/// Return currently selected command, if any.
pub(crate) fn selected_command(&self) -> Option<&SlashCommand> {
let matches = self.filtered_commands();
self.selected_idx.and_then(|idx| matches.get(idx).copied())
}
}
impl WidgetRef for CommandPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
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() {
rows.push(Row::new(vec![
Cell::from(""),
Cell::from("No matching commands").add_modifier(Modifier::ITALIC),
]));
} else {
let default_style = Style::default();
let command_style = Style::default().fg(Color::LightBlue);
for (idx, cmd) in visible_matches.iter().enumerate() {
let (cmd_style, desc_style) = if Some(idx) == self.selected_idx {
(
command_style.bg(Color::DarkGray),
default_style.bg(Color::DarkGray),
)
} else {
(command_style, default_style)
};
rows.push(Row::new(vec![
Cell::from(format!("/{}", cmd.command())).style(cmd_style),
Cell::from(cmd.description().to_string()).style(desc_style),
]));
}
}
use ratatui::layout::Constraint;
let table = Table::new(
rows,
[Constraint::Length(FIRST_COLUMN_WIDTH), Constraint::Min(10)],
)
.column_spacing(0)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded),
);
table.render(area, buf);
}
}