1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
//! Command palette: a summonable, fuzzy-searchable launcher over every
//! dispatchable action.
//!
//! Ranking lives in [`fuzzy`] as pure functions so it can be tested without
//! standing up an egui context or a `WindowState`. The catalog join lives in
//! [`catalog`]. This module owns only the overlay's state machine and its
//! egui presentation, mirroring `crate::search::SearchUI`.
pub(crate) mod catalog;
pub(crate) mod fuzzy;
use catalog::{PaletteEntry, build_catalog};
use egui::{Context, Frame, Key, RichText, Window, epaint::Shadow};
/// Rows drawn before the list scrolls.
const VISIBLE_ROWS: usize = 12;
/// Summonable fuzzy launcher over the action catalog.
pub(crate) struct CommandPalette {
/// Whether the palette is currently on screen.
pub(crate) visible: bool,
/// Current filter text.
query: String,
/// Index into the *filtered* list, not the catalog.
selected: usize,
/// Latest plugin snapshot, replaced by every `open()`.
plugin_entries: Vec<PaletteEntry>,
/// Built-ins plus the current plugin snapshot, label-sorted. Rebuilt on
/// every `open()` so the merged view tracks the live plugin set; the
/// built-in action set itself is static for the process.
entries: Vec<PaletteEntry>,
/// Whether the text field should grab focus on the next frame.
request_focus: bool,
}
impl Default for CommandPalette {
fn default() -> Self {
Self::new()
}
}
impl CommandPalette {
/// Build the palette and its catalog.
pub(crate) fn new() -> Self {
Self {
visible: false,
query: String::new(),
selected: 0,
plugin_entries: Vec::new(),
entries: build_catalog(),
request_focus: false,
}
}
/// Show the palette with a fresh plugin snapshot, clearing any state from
/// the previous summon.
///
/// The merged view (built-ins + plugin rows) is rebuilt here, not at
/// construction, because the plugin set can change between summons; the
/// caller passes the snapshot it just read from the host.
pub(crate) fn open(&mut self, plugin_rows: Vec<PaletteEntry>) {
self.plugin_entries = plugin_rows;
let mut merged = build_catalog();
merged.extend(self.plugin_entries.iter().cloned());
// Priority first, then label: runtime rows carrying a boost (the
// agent-roster picker's blocked agents) lead the empty-query view,
// while the all-zero default set keeps today's label order exactly.
merged.sort_by(|a, b| {
b.priority
.cmp(&a.priority)
.then_with(|| a.label.cmp(&b.label))
});
self.entries = merged;
self.visible = true;
self.query.clear();
self.selected = 0;
self.request_focus = true;
}
/// Hide the palette.
pub(crate) fn close(&mut self) {
self.visible = false;
}
/// Flip visibility, resetting state when opening.
pub(crate) fn toggle(&mut self, plugin_rows: Vec<PaletteEntry>) {
if self.visible {
self.close();
} else {
self.open(plugin_rows);
}
}
/// Action ids matching `query`, best-first.
fn filtered_ids(&self, query: &str) -> Vec<&str> {
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()
}
/// Keep `selected` inside a filtered list of `len` rows.
fn clamp_selection(&mut self, len: usize) {
self.selected = self.selected.min(len.saturating_sub(1));
}
/// Action id of the top-ranked row for the current query.
///
/// Harness read: `--ui-test` scripts assert the ranking without standing
/// up an egui context (ranking is pure `fuzzy::rank` over the query).
pub(crate) fn top_action(&self) -> Option<&str> {
self.filtered_ids(&self.query).first().copied()
}
/// Draw the palette. Returns the chosen action id when a row is activated.
pub(crate) fn show(&mut self, ctx: &Context) -> Option<String> {
if !self.visible {
return None;
}
// Own the ids: a borrowing Vec<&str> from filtered_ids would pin the
// immutable self-borrow across every mutation below.
let matches: Vec<String> = self
.filtered_ids(&self.query)
.into_iter()
.map(str::to_string)
.collect();
self.clamp_selection(matches.len());
let mut chosen: Option<String> = None;
// Escape closes the palette here, on the egui side, because with the
// text field focused `is_egui_using_keyboard()` returns early in
// handle_key_event and the handle_command_palette_keys layer never
// sees the key. That layer remains the backstop for the unfocused
// case, and close() is idempotent, so both paths are safe together.
// consume_key keeps the Escape from also reaching other egui widgets.
if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)) {
self.close();
}
if ctx.input(|i| i.key_pressed(Key::ArrowDown)) && !matches.is_empty() {
self.selected = (self.selected + 1).min(matches.len() - 1);
}
if ctx.input(|i| i.key_pressed(Key::ArrowUp)) {
self.selected = self.selected.saturating_sub(1);
}
if ctx.input(|i| i.key_pressed(Key::Enter))
&& let Some(id) = matches.get(self.selected)
{
chosen = Some(id.clone());
}
Window::new("Command Palette")
.title_bar(false)
.resizable(false)
.collapsible(false)
.anchor(egui::Align2::CENTER_TOP, [0.0, 80.0])
.frame(Frame::popup(&ctx.global_style()).shadow(Shadow::default()))
.show(ctx, |ui| {
let field = ui.text_edit_singleline(&mut self.query);
if self.request_focus {
field.request_focus();
self.request_focus = false;
}
ui.separator();
for (row, action_id) in matches.iter().take(VISIBLE_ROWS).enumerate() {
let entry = self
.entries
.iter()
.find(|e| e.action_id == action_id.as_str())
.expect("filtered ids come from self.entries");
let selected = row == self.selected;
ui.horizontal(|ui| {
let label = if selected {
RichText::new(&entry.label).strong()
} else {
RichText::new(&entry.label)
};
if ui.selectable_label(selected, label).clicked() {
chosen = Some(entry.action_id.clone());
}
if let Some(chord) = entry.chord {
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| ui.weak(chord),
);
}
});
}
if matches.is_empty() {
ui.weak("No matching actions");
}
});
if chosen.is_some() {
self.close();
}
chosen
}
}
#[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();
palette.toggle(Vec::new());
assert!(palette.visible);
palette.toggle(Vec::new());
assert!(!palette.visible);
}
#[test]
fn opening_resets_query_and_selection() {
let mut palette = CommandPalette::new();
palette.open(Vec::new());
palette.query = "stale".to_string();
palette.selected = 7;
palette.close();
palette.open(Vec::new());
assert_eq!(
palette.query, "",
"a reopened palette must not show the last query"
);
assert_eq!(palette.selected, 0);
}
#[test]
fn open_merges_plugin_rows_into_a_label_sorted_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();
palette.open(plugin_palette_entries(&[plugin_row]));
let labels: Vec<&str> = palette.entries.iter().map(|e| e.label.as_str()).collect();
let mut sorted = labels.clone();
sorted.sort_unstable();
assert_eq!(
labels, sorted,
"the merged view must be label-ordered so the empty-query view \
stays stable"
);
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() {
// The agent-roster picker (A2b task 3) leads its blocked rows with
// priority 2; everything else is priority 0 and must keep the
// label-ordered view among themselves.
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();
palette.open(runtime_rows);
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<&str> = palette
.entries
.iter()
.filter(|e| e.priority == 0)
.map(|e| e.label.as_str())
.collect();
let mut sorted = zero_tier.clone();
sorted.sort_unstable();
assert_eq!(
zero_tier, sorted,
"priority-0 rows stay label-ordered among themselves"
);
}
#[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();
palette.open(snapshot_a);
palette.close();
palette.open(snapshot_b);
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"));
}
#[test]
fn show_holds_the_focused_escape_close() {
// With the palette's text field focused, is_egui_using_keyboard()
// returns early in handle_key_event, so the handle_command_palette_keys
// layer never sees Escape — show() is the only close path while
// typing. The layer stays as the unfocused backstop; close() is
// idempotent, so the two paths coexist safely. Measured pre-fix:
// Escape with the field focused left the palette open forever.
let source = include_str!("mod.rs");
// Assembled at runtime: a literal needle would appear in this test's
// own source and the scan would always find itself.
let needle = ["consume", "_key"].join("");
let input_call = ["input", "_mut"].join("");
assert!(
source.contains(&needle) && source.contains(&input_call),
"show() must close the palette on the egui-side Escape while \
typing; without it the palette cannot be dismissed while typing"
);
}
#[test]
fn palette_is_registered_as_modal() {
// The palette must be in any_modal_ui_visible(): that sum drives the
// modal guard keeping keystrokes off the PTY while an overlay is
// open. Unregistered, typed characters filtered the palette AND
// leaked to the shell (measured 2026-09-20, --ui-test pre-fix run).
let source = include_str!("../app/window_state/ui_query_helpers.rs");
let body = source
.split("fn any_modal_ui_visible")
.nth(1)
.expect("any_modal_ui_visible present in ui_query_helpers.rs");
let body = body.split('}').next().unwrap_or_default();
assert!(
body.contains("command_palette.visible"),
"command_palette missing from any_modal_ui_visible — typing in \
the palette leaks to the PTY"
);
}
#[test]
fn selection_clamps_to_the_filtered_length() {
let mut palette = CommandPalette::new();
palette.selected = 999;
palette.clamp_selection(3);
assert_eq!(palette.selected, 2, "selection clamps to last valid index");
palette.clamp_selection(0);
assert_eq!(palette.selected, 0, "an empty result list clamps to 0");
}
}