pub(crate) mod catalog;
pub(crate) mod fuzzy;
pub(crate) mod meta;
use crate::app::overlay::picker::{self, ListConfig, ListNav, ListOutcome};
use catalog::{PaletteEntry, build_catalog, chord_display};
use egui::{Context, RichText};
use par_term_keybindings::KeybindingRegistry;
const VISIBLE_ROWS: usize = 12;
const MAX_RECENTS: usize = 5;
pub(crate) struct CommandPalette {
pub(crate) visible: bool,
query: String,
nav: ListNav,
plugin_entries: Vec<PaletteEntry>,
entries: Vec<PaletteEntry>,
recent: Vec<String>,
request_focus: bool,
toggle_chord: Option<String>,
}
impl Default for CommandPalette {
fn default() -> Self {
Self::new()
}
}
impl CommandPalette {
pub(crate) fn new() -> Self {
Self {
visible: false,
query: String::new(),
nav: ListNav::default(),
plugin_entries: Vec::new(),
entries: browse_sorted(build_catalog()),
recent: Vec::new(),
request_focus: false,
toggle_chord: None,
}
}
pub(crate) fn open(&mut self, plugin_rows: Vec<PaletteEntry>, registry: &KeybindingRegistry) {
self.plugin_entries = plugin_rows;
let mut merged = build_catalog();
merged.extend(self.plugin_entries.iter().cloned());
let mut merged = browse_sorted(merged);
for entry in &mut merged {
entry.chord = registry
.chord_for_action(&entry.action_id)
.map(|combo| chord_display(&combo));
}
self.entries = merged;
self.toggle_chord = registry
.chord_for_action("toggle_command_palette")
.map(|combo| chord_display(&combo));
self.visible = true;
self.query.clear();
self.nav.reset();
self.request_focus = true;
}
pub(crate) fn set_query(&mut self, query: &str) {
self.query = query.to_string();
self.nav.reset();
}
pub(crate) fn close(&mut self) {
self.visible = false;
}
pub(crate) fn toggle(&mut self, plugin_rows: Vec<PaletteEntry>, registry: &KeybindingRegistry) {
if self.visible {
self.close();
} else {
self.open(plugin_rows, registry);
}
}
fn record_use(&mut self, action_id: &str) {
self.recent.retain(|id| id != action_id);
self.recent.insert(0, action_id.to_string());
self.recent.truncate(MAX_RECENTS);
}
fn filtered_ids(&self, query: &str) -> Vec<&str> {
if query.is_empty() {
return self.browse_order();
}
let pairs: Vec<(&str, &str)> = self
.entries
.iter()
.map(|e| (e.action_id.as_str(), e.label.as_str()))
.collect();
fuzzy::rank(query, &pairs)
.into_iter()
.map(|(id, _)| *id)
.collect()
}
fn browse_order(&self) -> Vec<&str> {
let boosted = self.entries.iter().filter(|e| e.priority > 0);
let recents = self.recent.iter().filter_map(|id| {
self.entries
.iter()
.find(|e| e.priority == 0 && e.action_id == *id)
});
let rest = self
.entries
.iter()
.filter(|e| e.priority == 0 && !self.recent.contains(&e.action_id));
boosted
.chain(recents)
.chain(rest)
.map(|e| e.action_id.as_str())
.collect()
}
pub(crate) fn top_action(&self) -> Option<&str> {
self.filtered_ids(&self.query).first().copied()
}
#[cfg(test)]
pub(crate) fn row(&self, action_id: &str) -> Option<&PaletteEntry> {
self.entries.iter().find(|e| e.action_id == action_id)
}
pub(crate) fn selected_index(&self) -> usize {
self.nav.selected
}
pub(crate) fn selected_row_is_visible(&self) -> bool {
self.nav.selected_is_visible(VISIBLE_ROWS)
}
pub(crate) fn show(&mut self, ctx: &Context) -> Option<String> {
if !self.visible {
return None;
}
let matches: Vec<String> = self
.filtered_ids(&self.query)
.into_iter()
.map(str::to_string)
.collect();
let entries = &self.entries;
let config = ListConfig {
id: "Command Palette",
hint: "Type a command",
visible_rows: VISIBLE_ROWS,
width: crate::app::overlay::theme::WIDTH_LARGE,
empty_text: "No matching actions",
enter_verb: "run",
toggle_chord: self.toggle_chord.as_deref(),
alternates: false,
alternate_labels: None,
extra_keys: &[],
multi_select: false,
};
let (outcome, query_changed) = picker::show_list(
ctx,
&config,
&mut self.query,
&mut self.request_focus,
&mut self.nav,
matches.len(),
|ui, index, selected| {
entries
.iter()
.find(|e| e.action_id == matches[index])
.is_some_and(|entry| draw_row(ui, entry, selected))
},
);
if query_changed {
self.nav.reset();
}
match outcome {
ListOutcome::Chosen { index, .. } => {
self.close();
let chosen = matches.get(index).cloned();
if let Some(id) = &chosen {
self.record_use(id);
}
chosen
}
ListOutcome::Closed => {
self.close();
None
}
ListOutcome::Open | ListOutcome::ToggleMark(_) => None,
}
}
}
fn browse_sorted(mut entries: Vec<PaletteEntry>) -> Vec<PaletteEntry> {
let category_rank = |entry: &PaletteEntry| {
let category = meta::category(&entry.action_id);
meta::CATEGORIES
.iter()
.position(|c| *c == category)
.unwrap_or(meta::CATEGORIES.len())
};
entries.sort_by(|a, b| {
b.priority
.cmp(&a.priority)
.then_with(|| category_rank(a).cmp(&category_rank(b)))
.then_with(|| a.label.cmp(&b.label))
});
entries
}
fn draw_row(ui: &mut egui::Ui, entry: &PaletteEntry, selected: bool) -> bool {
let mut clicked = false;
ui.horizontal(|ui| {
let label = if selected {
RichText::new(&entry.label).strong()
} else {
RichText::new(&entry.label)
};
clicked = ui.selectable_label(selected, label).clicked();
if let Some(chord) = &entry.chord {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.weak(chord.as_str())
});
}
});
let category = meta::category(&entry.action_id);
let line = match meta::description(&entry.action_id) {
Some(description) => format!("{category} · {description}"),
None => category.to_string(),
};
ui.label(RichText::new(line).weak().small());
clicked
}
#[cfg(test)]
mod tests {
use super::*;
use catalog::plugin_palette_entries;
use par_term_scripting::plugin_manager::PluginActionRow;
#[test]
fn starts_hidden() {
assert!(!CommandPalette::new().visible);
}
#[test]
fn toggle_flips_visibility() {
let mut palette = CommandPalette::new();
let registry = KeybindingRegistry::new();
palette.toggle(Vec::new(), ®istry);
assert!(palette.visible);
palette.toggle(Vec::new(), ®istry);
assert!(!palette.visible);
}
#[test]
fn opening_resets_query_and_selection() {
let mut palette = CommandPalette::new();
let registry = KeybindingRegistry::new();
palette.open(Vec::new(), ®istry);
palette.query = "stale".to_string();
palette.nav.selected = 7;
palette.close();
palette.open(Vec::new(), ®istry);
assert_eq!(
palette.query, "",
"a reopened palette must not show the last query"
);
assert_eq!(palette.nav.selected, 0);
}
fn assert_grouped_by_category(entries: &[&PaletteEntry]) {
let keys: Vec<(usize, &str)> = entries
.iter()
.map(|e| {
let c = meta::category(&e.action_id);
(
meta::CATEGORIES.iter().position(|x| *x == c).unwrap(),
e.label.as_str(),
)
})
.collect();
let mut sorted = keys.clone();
sorted.sort();
assert_eq!(
keys, sorted,
"unboosted rows group by category, label-sorted within a group, \
so the empty-query view stays stable"
);
}
#[test]
fn open_merges_plugin_rows_into_the_category_grouped_view() {
let plugin_row = PluginActionRow {
wire_id: "plugin-action:com.example.demo:aaaa".to_string(),
label: "Aaaa First Plugin Action · Demo".to_string(),
};
let mut palette = CommandPalette::new();
let registry = KeybindingRegistry::new();
palette.open(plugin_palette_entries(&[plugin_row]), ®istry);
assert_grouped_by_category(&palette.entries.iter().collect::<Vec<_>>());
assert!(
palette
.entries
.iter()
.any(|e| e.action_id == "plugin-action:com.example.demo:aaaa"),
"the plugin row must be present alongside the built-ins"
);
}
#[test]
fn open_places_priority_rows_ahead_of_the_label_sort() {
let runtime_rows = vec![
PaletteEntry {
action_id: "agent-roster-focus:7".to_string(),
label: "kimi: working".to_string(),
chord: None,
priority: 1,
},
PaletteEntry {
action_id: "agent-roster-focus:3".to_string(),
label: "claude: blocked".to_string(),
chord: None,
priority: 2,
},
];
let mut palette = CommandPalette::new();
let registry = KeybindingRegistry::new();
palette.open(runtime_rows, ®istry);
assert_eq!(
palette.entries.first().map(|e| e.action_id.as_str()),
Some("agent-roster-focus:3"),
"the blocked agent row leads the empty-query view"
);
let zero_tier: Vec<&PaletteEntry> =
palette.entries.iter().filter(|e| e.priority == 0).collect();
assert_grouped_by_category(&zero_tier);
}
#[test]
fn recent_actions_lead_an_empty_query_below_boosted_rows() {
let blocked = PaletteEntry {
action_id: "agent-roster-focus:3".to_string(),
label: "claude: blocked".to_string(),
chord: None,
priority: 2,
};
let mut palette = CommandPalette::new();
let registry = KeybindingRegistry::new();
palette.record_use("toggle_fullscreen");
palette.record_use("split_right");
palette.record_use("toggle_fullscreen");
palette.open(vec![blocked], ®istry);
let ids = palette.filtered_ids("");
assert_eq!(
&ids[..3],
["agent-roster-focus:3", "toggle_fullscreen", "split_right"],
"boosted, then recents most-recent-first (deduplicated)"
);
assert_eq!(
ids.iter().filter(|id| **id == "split_right").count(),
1,
"a recent is not listed twice"
);
assert_eq!(
palette.filtered_ids("fullscr").first(),
Some(&"toggle_fullscreen")
);
}
#[test]
fn a_recent_whose_row_is_gone_is_skipped() {
let mut palette = CommandPalette::new();
palette.record_use("triage-crash:99");
palette.open(vec![], &KeybindingRegistry::new());
assert!(!palette.filtered_ids("").contains(&"triage-crash:99"));
}
#[test]
fn running_a_row_records_it_as_recent() {
let ctx = Context::default();
let mut palette = CommandPalette::new();
palette.open(Vec::new(), &KeybindingRegistry::new());
frame(&ctx, &mut palette, &[]);
frame(&ctx, &mut palette, &[Key::ArrowDown; 5]);
let chosen = frame(&ctx, &mut palette, &[Key::Enter]).expect("Enter runs a row");
palette.open(Vec::new(), &KeybindingRegistry::new());
assert_eq!(palette.filtered_ids("").first(), Some(&chosen.as_str()));
assert_eq!(palette.recent.len(), 1);
for _ in 0..(MAX_RECENTS + 3) {
palette.record_use(&format!("x{}", palette.recent.len()));
}
assert_eq!(palette.recent.len(), MAX_RECENTS, "recents are capped");
}
#[test]
fn every_row_draws_a_description_line() {
let ctx = Context::default();
let mut palette = CommandPalette::new();
palette.open(Vec::new(), &KeybindingRegistry::new());
frame(&ctx, &mut palette, &[]);
let id = egui::Id::new(Some(std::borrow::Cow::Borrowed("Command Palette")));
let rect = ctx.memory(|m| m.area_rect(id)).expect("palette drew");
assert!(
rect.max.y <= 800.0,
"12 rows with description lines fit on screen: {rect:?}"
);
assert!(
rect.height() > 12.0 * 30.0,
"each row is two lines tall (label + description): {rect:?}"
);
}
#[test]
fn reopening_with_a_fresh_snapshot_replaces_stale_plugin_rows() {
let snapshot_a = plugin_palette_entries(&[PluginActionRow {
wire_id: "plugin-action:com.old:act".to_string(),
label: "Old Action · Old Plugin".to_string(),
}]);
let snapshot_b = plugin_palette_entries(&[PluginActionRow {
wire_id: "plugin-action:com.new:act".to_string(),
label: "New Action · New Plugin".to_string(),
}]);
let mut palette = CommandPalette::new();
let registry = KeybindingRegistry::new();
palette.open(snapshot_a, ®istry);
palette.close();
palette.open(snapshot_b, ®istry);
let ids = palette.filtered_ids("");
assert!(
ids.contains(&"plugin-action:com.new:act"),
"the fresh snapshot's rows must be in the filtered view"
);
assert!(
!ids.contains(&"plugin-action:com.old:act"),
"a stale plugin row must not survive a reopen with a fresh snapshot"
);
}
#[test]
fn filtered_entries_narrow_with_the_query() {
let palette = CommandPalette::new();
let all = palette.filtered_ids("");
let narrowed = palette.filtered_ids("fullscr");
assert!(!all.is_empty());
assert!(
narrowed.len() < all.len(),
"a specific query must narrow the list"
);
assert!(narrowed.contains(&"toggle_fullscreen"));
}
fn frame(ctx: &Context, palette: &mut CommandPalette, keys: &[Key]) -> Option<String> {
let events = keys
.iter()
.map(|&key| egui::Event::Key {
key,
physical_key: None,
pressed: true,
repeat: false,
modifiers: Default::default(),
})
.collect();
let mut chosen = None;
let mut out = ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(1200.0, 800.0),
)),
events,
..Default::default()
},
|ui| chosen = palette.show(ui.ctx()),
);
out.textures_delta.clear();
chosen
}
use egui::Key;
#[test]
fn show_holds_the_focused_escape_close() {
let ctx = Context::default();
let mut palette = CommandPalette::new();
palette.open(Vec::new(), &KeybindingRegistry::new());
frame(&ctx, &mut palette, &[]);
assert_eq!(frame(&ctx, &mut palette, &[Key::Escape]), None);
assert!(!palette.visible, "Escape must close the focused palette");
}
#[test]
fn arrowing_to_the_20th_match_keeps_it_drawn_through_show() {
let ctx = Context::default();
let mut palette = CommandPalette::new();
palette.open(Vec::new(), &KeybindingRegistry::new());
frame(&ctx, &mut palette, &[]);
frame(&ctx, &mut palette, &[Key::ArrowDown; 19]);
assert_eq!(palette.selected_index(), 19);
assert!(palette.selected_row_is_visible());
let expected = palette.filtered_ids("")[19].to_string();
assert_eq!(frame(&ctx, &mut palette, &[Key::Enter]), Some(expected));
}
#[test]
fn page_and_home_end_keys_move_by_the_window() {
let ctx = Context::default();
let mut palette = CommandPalette::new();
palette.open(Vec::new(), &KeybindingRegistry::new());
let len = palette.filtered_ids("").len();
frame(&ctx, &mut palette, &[]);
frame(&ctx, &mut palette, &[Key::PageDown]);
assert_eq!(palette.selected_index(), VISIBLE_ROWS);
frame(&ctx, &mut palette, &[Key::End]);
assert_eq!(palette.selected_index(), len - 1);
assert!(palette.selected_row_is_visible());
frame(&ctx, &mut palette, &[Key::Home]);
assert_eq!(palette.selected_index(), 0);
frame(&ctx, &mut palette, &[Key::PageUp]);
assert_eq!(palette.selected_index(), 0, "PageUp at the top stays put");
}
#[test]
fn palette_is_registered_as_modal() {
assert!(crate::app::overlay::OverlayId::CommandPalette.guards_terminal());
}
#[test]
fn opening_resets_the_scroll_window() {
let mut palette = CommandPalette::new();
let registry = KeybindingRegistry::new();
palette.open(Vec::new(), ®istry);
palette.nav.selected = 30;
palette.nav.scroll_offset = 19;
palette.close();
palette.open(Vec::new(), ®istry);
assert_eq!(
palette.nav,
ListNav::default(),
"a reopened palette starts the window at the top"
);
}
#[test]
fn palette_advertises_the_live_chord_after_a_rebind() {
let registry = KeybindingRegistry::from_config(&[par_term_config::KeyBinding {
key: "F9".to_string(),
action: "toggle_fullscreen".to_string(),
}]);
let mut palette = CommandPalette::new();
palette.open(vec![], ®istry);
let entry = palette
.entries
.iter()
.find(|e| e.action_id == "toggle_fullscreen")
.expect("toggle_fullscreen is in the catalog");
assert_eq!(entry.chord.as_deref(), Some("F9"));
}
#[test]
fn the_footer_chord_is_read_from_the_live_registry_at_open() {
let registry = KeybindingRegistry::from_config(&[par_term_config::KeyBinding {
key: "F8".to_string(),
action: "toggle_command_palette".to_string(),
}]);
let mut palette = CommandPalette::new();
palette.open(vec![], ®istry);
assert_eq!(palette.toggle_chord.as_deref(), Some("F8"));
palette.close();
palette.open(vec![], &KeybindingRegistry::new());
assert_eq!(palette.toggle_chord, None);
}
#[test]
fn palette_hides_a_default_chord_claimed_by_pass_to_terminal() {
let registry = KeybindingRegistry::from_config(&[par_term_config::KeyBinding {
key: "F11".to_string(),
action: par_term_keybindings::PASS_TO_TERMINAL.to_string(),
}]);
let mut palette = CommandPalette::new();
palette.open(vec![], ®istry);
let entry = palette
.entries
.iter()
.find(|e| e.action_id == "toggle_fullscreen")
.expect("toggle_fullscreen is in the catalog");
assert_eq!(entry.chord, None);
}
#[test]
fn palette_advertises_defaults_through_a_merged_registry() {
let registry = KeybindingRegistry::from_config(&par_term_config::defaults::keybindings());
let mut palette = CommandPalette::new();
palette.open(vec![], ®istry);
let entry = palette
.entries
.iter()
.find(|e| e.action_id == "toggle_fullscreen")
.expect("toggle_fullscreen is in the catalog");
assert_eq!(entry.chord.as_deref(), Some("F11"));
}
}