Skip to main content

coding_agent_search/ui/components/
palette.rs

1//! Command palette state and rendering (keyboard-first, fuzzy-ish search).
2//! Integration hooks live in `src/ui/app.rs`; this module stays side-effect free.
3//!
4//! # Interaction Contract
5//!
6//! | Trigger          | Behavior                                          |
7//! |------------------|---------------------------------------------------|
8//! | Ctrl+P / Alt+P   | Open palette → push focus trap GROUP_PALETTE       |
9//! | Esc              | Close palette → pop focus trap, discard query      |
10//! | Enter            | Execute selected action → close → dispatch CassMsg |
11//! | Up / k           | Move selection -1 (wraps)                         |
12//! | Down / j         | Move selection +1 (wraps)                         |
13//! | Ctrl+U           | Clear query                                       |
14//! | Any printable    | Append to query → refilter → reset selection to 0  |
15//! | Backspace        | Remove last char → refilter                       |
16//!
17//! # Action Groups
18//!
19//! Each [`PaletteAction`] belongs to exactly one [`PaletteGroup`]. Groups are
20//! used for categorical rendering (section headers, icons) and mapping validation.
21//!
22//! | Group       | Actions                                                    |
23//! |-------------|------------------------------------------------------------|
24//! | Chrome      | ToggleTheme, ToggleDensity, ToggleHelpStrip, OpenUpdate    |
25//! | Filter      | FilterAgent, FilterWorkspace, FilterToday/Week/CustomDate  |
26//! | View        | OpenSavedViews, SaveViewSlot, LoadViewSlot, BulkActions, ReloadIndex |
27//! | Analytics   | AnalyticsDashboard..AnalyticsCoverage                      |
28//! | Export      | ScreenshotHtml, ScreenshotSvg, ScreenshotText             |
29//! | Recording   | MacroRecordingToggle                                       |
30//! | Sources     | Sources                                                    |
31//!
32//! # Migration Target (FrankenTUI command_palette)
33//!
34//! Each action maps to exactly one `CassMsg` dispatch (or batch). The mapping
35//! table in [`PaletteAction::target_msg_name`] documents the concrete target
36//! for every variant, ensuring no action is lost during migration.
37//!
38//! # Filter Modes (F9 cycling)
39//!
40//! [`PaletteMatchMode`] cycles through All → Exact → Prefix → WordStart →
41//! Substring → Fuzzy → All. Each mode trades recall for precision: All shows
42//! every action (useful for browsing), while Exact/Prefix are fast for users who
43//! know what they want. The Bayesian scorer in `app.rs` combines match-mode
44//! evidence with recency and frequency priors.
45//!
46//! # Test Coverage
47//!
48//! 59 unit tests in this module cover: match mode cycling, action serialization
49//! round-trips, group membership exhaustiveness, and default_actions() stability.
50//! 12 regression tests in `app.rs` cover: lifecycle, dispatch coverage for all
51//! 28 action variants, boundary wrapping, rapid open/close, and selection clamping.
52
53use crate::ui::shortcuts;
54
55/// Match-type filter mode for the command palette.
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
57pub enum PaletteMatchMode {
58    #[default]
59    All,
60    Exact,
61    Prefix,
62    WordStart,
63    Substring,
64    Fuzzy,
65}
66
67impl PaletteMatchMode {
68    /// Advance to the next mode, wrapping around.
69    pub fn cycle(self) -> Self {
70        match self {
71            Self::All => Self::Exact,
72            Self::Exact => Self::Prefix,
73            Self::Prefix => Self::WordStart,
74            Self::WordStart => Self::Substring,
75            Self::Substring => Self::Fuzzy,
76            Self::Fuzzy => Self::All,
77        }
78    }
79
80    /// Short human-readable label for status display.
81    pub fn label(self) -> &'static str {
82        match self {
83            Self::All => "All",
84            Self::Exact => "Exact",
85            Self::Prefix => "Prefix",
86            Self::WordStart => "WordStart",
87            Self::Substring => "Substr",
88            Self::Fuzzy => "Fuzzy",
89        }
90    }
91}
92
93/// Simple fuzzy match: every character in `pattern` must appear in `text` in order.
94fn fuzzy_match(text: &str, pattern: &str) -> bool {
95    let mut chars = text.chars();
96    for p in pattern.chars() {
97        if !chars.any(|c| c == p) {
98            return false;
99        }
100    }
101    true
102}
103
104/// Categorical grouping for palette actions. Used for section headers,
105/// icons, and migration validation.
106#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
107pub enum PaletteGroup {
108    /// UI chrome toggles: theme, density, help strip, update banner.
109    Chrome,
110    /// Data filters: agent, workspace, time-range.
111    Filter,
112    /// View management: saved views, bulk actions, reload.
113    View,
114    /// Analytics surface navigation (8 sub-views).
115    Analytics,
116    /// Screenshot/export capture.
117    Export,
118    /// Macro recording toggle.
119    Recording,
120    /// Sources management.
121    Sources,
122}
123
124impl PaletteGroup {
125    /// Human-readable label for section headers in the palette.
126    pub fn label(&self) -> &'static str {
127        match self {
128            Self::Chrome => "Chrome",
129            Self::Filter => "Filters",
130            Self::View => "Views",
131            Self::Analytics => "Analytics",
132            Self::Export => "Export",
133            Self::Recording => "Recording",
134            Self::Sources => "Sources",
135        }
136    }
137
138    /// All groups in display order.
139    pub const ALL: &'static [PaletteGroup] = &[
140        PaletteGroup::Chrome,
141        PaletteGroup::Filter,
142        PaletteGroup::View,
143        PaletteGroup::Analytics,
144        PaletteGroup::Export,
145        PaletteGroup::Recording,
146        PaletteGroup::Sources,
147    ];
148}
149
150/// Action identifiers the palette can emit. These map to app-level commands.
151#[derive(Clone, Debug)]
152pub enum PaletteAction {
153    ToggleTheme,
154    ToggleDensity,
155    ToggleHelpStrip,
156    OpenUpdateBanner,
157    FilterAgent,
158    FilterWorkspace,
159    FilterToday,
160    FilterWeek,
161    FilterCustomDate,
162    OpenSavedViews,
163    SaveViewSlot(u8),
164    LoadViewSlot(u8),
165    OpenBulkActions,
166    ReloadIndex,
167    // -- Analytics surface ------------------------------------------------
168    AnalyticsDashboard,
169    AnalyticsExplorer,
170    AnalyticsHeatmap,
171    AnalyticsBreakdowns,
172    AnalyticsTools,
173    AnalyticsPlans,
174    AnalyticsCoverage,
175    // -- Screenshot export ------------------------------------------------
176    ScreenshotHtml,
177    ScreenshotSvg,
178    ScreenshotText,
179    // -- Macro recording --------------------------------------------------
180    MacroRecordingToggle,
181    // -- Sources management ------------------------------------------------
182    Sources,
183}
184
185impl PaletteAction {
186    /// Returns the categorical group this action belongs to.
187    pub fn group(&self) -> PaletteGroup {
188        match self {
189            Self::ToggleTheme
190            | Self::ToggleDensity
191            | Self::ToggleHelpStrip
192            | Self::OpenUpdateBanner => PaletteGroup::Chrome,
193            Self::FilterAgent
194            | Self::FilterWorkspace
195            | Self::FilterToday
196            | Self::FilterWeek
197            | Self::FilterCustomDate => PaletteGroup::Filter,
198            Self::OpenSavedViews
199            | Self::SaveViewSlot(_)
200            | Self::LoadViewSlot(_)
201            | Self::OpenBulkActions
202            | Self::ReloadIndex => PaletteGroup::View,
203            Self::AnalyticsDashboard
204            | Self::AnalyticsExplorer
205            | Self::AnalyticsHeatmap
206            | Self::AnalyticsBreakdowns
207            | Self::AnalyticsTools
208            | Self::AnalyticsPlans
209            | Self::AnalyticsCoverage => PaletteGroup::Analytics,
210            Self::ScreenshotHtml | Self::ScreenshotSvg | Self::ScreenshotText => {
211                PaletteGroup::Export
212            }
213            Self::MacroRecordingToggle => PaletteGroup::Recording,
214            Self::Sources => PaletteGroup::Sources,
215        }
216    }
217
218    /// Returns the CassMsg variant name this action dispatches to.
219    ///
220    /// This mapping table is the authoritative contract between palette actions
221    /// and app-level command dispatch. Every variant must have an explicit entry;
222    /// the match is exhaustive by design.
223    pub fn target_msg_name(&self) -> &'static str {
224        match self {
225            // Chrome
226            Self::ToggleTheme => "ThemeToggled",
227            Self::ToggleDensity => "DensityModeCycled",
228            Self::ToggleHelpStrip => "HelpPinToggled",
229            Self::OpenUpdateBanner => "update_info inline (no CassMsg)",
230            // Filter
231            Self::FilterAgent => "InputModeEntered(Agent)",
232            Self::FilterWorkspace => "InputModeEntered(Workspace)",
233            Self::FilterToday => "FilterTimeSet { from: start_of_day }",
234            Self::FilterWeek => "FilterTimeSet { from: week_ago }",
235            Self::FilterCustomDate => "InputModeEntered(CreatedFrom)",
236            // View
237            Self::OpenSavedViews => "SavedViewsOpened",
238            Self::SaveViewSlot(_) => "ViewSaved(slot)",
239            Self::LoadViewSlot(_) => "ViewLoaded(slot)",
240            Self::OpenBulkActions => "BulkActionsOpened",
241            Self::ReloadIndex => "IndexRefreshRequested",
242            // Analytics (all batch: AnalyticsEntered + AnalyticsViewChanged)
243            Self::AnalyticsDashboard => "batch[AnalyticsEntered, AnalyticsViewChanged(Dashboard)]",
244            Self::AnalyticsExplorer => "batch[AnalyticsEntered, AnalyticsViewChanged(Explorer)]",
245            Self::AnalyticsHeatmap => "batch[AnalyticsEntered, AnalyticsViewChanged(Heatmap)]",
246            Self::AnalyticsBreakdowns => {
247                "batch[AnalyticsEntered, AnalyticsViewChanged(Breakdowns)]"
248            }
249            Self::AnalyticsTools => "batch[AnalyticsEntered, AnalyticsViewChanged(Tools)]",
250            Self::AnalyticsPlans => "batch[AnalyticsEntered, AnalyticsViewChanged(Plans)]",
251            Self::AnalyticsCoverage => "batch[AnalyticsEntered, AnalyticsViewChanged(Coverage)]",
252            // Export
253            Self::ScreenshotHtml => "ScreenshotRequested(Html)",
254            Self::ScreenshotSvg => "ScreenshotRequested(Svg)",
255            Self::ScreenshotText => "ScreenshotRequested(Text)",
256            // Recording
257            Self::MacroRecordingToggle => "MacroRecordingToggled",
258            // Sources
259            Self::Sources => "SourcesEntered",
260        }
261    }
262}
263
264/// Semantic result of executing a palette action. Decoupled from `CassMsg`
265/// so that palette.rs stays side-effect free and doesn't depend on app.rs.
266///
267/// The app-level adapter (`palette_result_to_cmd` in app.rs) translates these
268/// into concrete `Cmd<CassMsg>` dispatches.
269#[derive(Clone, Debug, PartialEq)]
270pub enum PaletteResult {
271    /// Toggle the UI theme (light/dark).
272    ToggleTheme,
273    /// Cycle the density mode (compact/normal/relaxed).
274    CycleDensity,
275    /// Toggle the help strip visibility.
276    ToggleHelpStrip,
277    /// Open/check the update banner.
278    OpenUpdateBanner,
279    /// Enter an input mode for filtering.
280    EnterInputMode(InputModeTarget),
281    /// Set a time filter (epoch seconds).
282    SetTimeFilter { from: TimeFilterPreset },
283    /// Open the saved-views picker.
284    OpenSavedViews,
285    /// Save the current view to a numbered slot.
286    SaveViewSlot(u8),
287    /// Load a view from a numbered slot.
288    LoadViewSlot(u8),
289    /// Open the bulk-actions menu.
290    OpenBulkActions,
291    /// Reload/refresh the index.
292    ReloadIndex,
293    /// Navigate to an analytics sub-view (by name).
294    OpenAnalyticsView(AnalyticsTarget),
295    /// Request a screenshot export in the given format.
296    Screenshot(ScreenshotTarget),
297    /// Toggle macro recording on/off.
298    ToggleMacroRecording,
299    /// Enter sources management.
300    OpenSources,
301    /// No action (e.g. palette was empty when executed).
302    Noop,
303}
304
305/// Input mode the palette can request.
306#[derive(Clone, Copy, Debug, PartialEq, Eq)]
307pub enum InputModeTarget {
308    Agent,
309    Workspace,
310    CreatedFrom,
311}
312
313/// Time filter presets the palette can apply.
314#[derive(Clone, Copy, Debug, PartialEq, Eq)]
315pub enum TimeFilterPreset {
316    Today,
317    LastWeek,
318}
319
320/// Analytics sub-views addressable from the palette.
321#[derive(Clone, Copy, Debug, PartialEq, Eq)]
322pub enum AnalyticsTarget {
323    Dashboard,
324    Explorer,
325    Heatmap,
326    Breakdowns,
327    Tools,
328    Plans,
329    Coverage,
330}
331
332/// Screenshot export formats addressable from the palette.
333#[derive(Clone, Copy, Debug, PartialEq, Eq)]
334pub enum ScreenshotTarget {
335    Html,
336    Svg,
337    Text,
338}
339
340impl PaletteAction {
341    /// Dispatch this action to a side-effect-free [`PaletteResult`].
342    ///
343    /// This is the adapter layer: palette semantics → app-level intent,
344    /// without depending on CassMsg or ftui. The app translates
345    /// `PaletteResult` into concrete `Cmd<CassMsg>` via `palette_result_to_cmd`.
346    pub fn dispatch(&self) -> PaletteResult {
347        match self {
348            // Chrome
349            Self::ToggleTheme => PaletteResult::ToggleTheme,
350            Self::ToggleDensity => PaletteResult::CycleDensity,
351            Self::ToggleHelpStrip => PaletteResult::ToggleHelpStrip,
352            Self::OpenUpdateBanner => PaletteResult::OpenUpdateBanner,
353            // Filters
354            Self::FilterAgent => PaletteResult::EnterInputMode(InputModeTarget::Agent),
355            Self::FilterWorkspace => PaletteResult::EnterInputMode(InputModeTarget::Workspace),
356            Self::FilterToday => PaletteResult::SetTimeFilter {
357                from: TimeFilterPreset::Today,
358            },
359            Self::FilterWeek => PaletteResult::SetTimeFilter {
360                from: TimeFilterPreset::LastWeek,
361            },
362            Self::FilterCustomDate => PaletteResult::EnterInputMode(InputModeTarget::CreatedFrom),
363            // Views
364            Self::OpenSavedViews => PaletteResult::OpenSavedViews,
365            Self::SaveViewSlot(slot) => PaletteResult::SaveViewSlot(*slot),
366            Self::LoadViewSlot(slot) => PaletteResult::LoadViewSlot(*slot),
367            Self::OpenBulkActions => PaletteResult::OpenBulkActions,
368            Self::ReloadIndex => PaletteResult::ReloadIndex,
369            // Analytics
370            Self::AnalyticsDashboard => {
371                PaletteResult::OpenAnalyticsView(AnalyticsTarget::Dashboard)
372            }
373            Self::AnalyticsExplorer => PaletteResult::OpenAnalyticsView(AnalyticsTarget::Explorer),
374            Self::AnalyticsHeatmap => PaletteResult::OpenAnalyticsView(AnalyticsTarget::Heatmap),
375            Self::AnalyticsBreakdowns => {
376                PaletteResult::OpenAnalyticsView(AnalyticsTarget::Breakdowns)
377            }
378            Self::AnalyticsTools => PaletteResult::OpenAnalyticsView(AnalyticsTarget::Tools),
379            Self::AnalyticsPlans => PaletteResult::OpenAnalyticsView(AnalyticsTarget::Plans),
380            Self::AnalyticsCoverage => PaletteResult::OpenAnalyticsView(AnalyticsTarget::Coverage),
381            // Export
382            Self::ScreenshotHtml => PaletteResult::Screenshot(ScreenshotTarget::Html),
383            Self::ScreenshotSvg => PaletteResult::Screenshot(ScreenshotTarget::Svg),
384            Self::ScreenshotText => PaletteResult::Screenshot(ScreenshotTarget::Text),
385            // Recording
386            Self::MacroRecordingToggle => PaletteResult::ToggleMacroRecording,
387            // Sources
388            Self::Sources => PaletteResult::OpenSources,
389        }
390    }
391}
392
393/// Execute the currently selected palette action, returning a [`PaletteResult`].
394///
395/// Returns [`PaletteResult::Noop`] if the palette is empty or selection is out of bounds.
396pub fn execute_selected(state: &PaletteState) -> PaletteResult {
397    state
398        .filtered
399        .get(state.selected)
400        .map(|item| item.action.dispatch())
401        .unwrap_or(PaletteResult::Noop)
402}
403
404/// Generate a stable string ID for a palette action.
405///
406/// Used as the `ActionItem::id` when registering actions with the ftui
407/// CommandPalette widget, and for reverse-lookup on `Execute(id)`.
408pub fn action_id(action: &PaletteAction) -> String {
409    format!("{action:?}")
410}
411
412/// Find the [`PaletteAction`] whose [`action_id`] matches `id`.
413pub fn action_by_id(items: &[PaletteItem], id: &str) -> Option<PaletteAction> {
414    items
415        .iter()
416        .find(|item| action_id(&item.action) == id)
417        .map(|item| item.action.clone())
418}
419
420/// Render-ready descriptor for an action.
421#[derive(Clone, Debug)]
422pub struct PaletteItem {
423    pub action: PaletteAction,
424    pub label: String,
425    pub hint: String,
426}
427
428#[derive(Clone, Debug)]
429pub struct PaletteState {
430    pub open: bool,
431    pub query: String,
432    pub filtered: Vec<PaletteItem>,
433    pub all_actions: Vec<PaletteItem>,
434    pub selected: usize,
435    pub match_mode: PaletteMatchMode,
436}
437
438impl PaletteState {
439    pub fn new(actions: Vec<PaletteItem>) -> Self {
440        let filtered = actions.clone();
441        Self {
442            open: false,
443            query: String::new(),
444            filtered,
445            all_actions: actions,
446            selected: 0,
447            match_mode: PaletteMatchMode::default(),
448        }
449    }
450
451    /// Recompute filtered list respecting the current [`PaletteMatchMode`].
452    pub fn refilter(&mut self) {
453        if self.query.trim().is_empty() {
454            self.filtered = self.all_actions.clone();
455        } else {
456            let q = self.query.to_lowercase();
457            let matches = |text: &str| -> bool {
458                let t = text.to_lowercase();
459                match self.match_mode {
460                    PaletteMatchMode::All | PaletteMatchMode::Substring => t.contains(&q),
461                    PaletteMatchMode::Exact => t == q,
462                    PaletteMatchMode::Prefix => t.starts_with(&q),
463                    PaletteMatchMode::WordStart => {
464                        t.split_whitespace().any(|word| word.starts_with(&q))
465                    }
466                    PaletteMatchMode::Fuzzy => fuzzy_match(&t, &q),
467                }
468            };
469            self.filtered = self
470                .all_actions
471                .iter()
472                .filter(|a| matches(&a.label) || matches(&a.hint))
473                .cloned()
474                .collect();
475        }
476        if self.selected >= self.filtered.len() {
477            self.selected = self.filtered.len().saturating_sub(1);
478        }
479    }
480
481    pub fn move_selection(&mut self, delta: isize) {
482        if self.filtered.is_empty() {
483            self.selected = 0;
484            return;
485        }
486        let len = self.filtered.len() as isize;
487        let idx = (self.selected as isize + delta).rem_euclid(len);
488        self.selected = idx as usize;
489    }
490}
491
492/// Prebuilt action catalog with keyboard shortcut hints from [`shortcuts`].
493pub fn default_actions() -> Vec<PaletteItem> {
494    let mut items = vec![
495        item(
496            PaletteAction::OpenSavedViews,
497            "Saved views",
498            "List saved slots",
499        ),
500        item(
501            PaletteAction::ToggleDensity,
502            "Toggle density",
503            shortcuts::DENSITY,
504        ),
505        item(PaletteAction::ToggleTheme, "Toggle theme", shortcuts::THEME),
506        item(
507            PaletteAction::ToggleHelpStrip,
508            "Toggle help strip",
509            shortcuts::HELP,
510        ),
511        item(
512            PaletteAction::OpenUpdateBanner,
513            "Check updates",
514            "Show update assistant",
515        ),
516        item(
517            PaletteAction::FilterAgent,
518            "Filter: agent",
519            shortcuts::FILTER_AGENT,
520        ),
521        item(
522            PaletteAction::FilterWorkspace,
523            "Filter: workspace",
524            shortcuts::FILTER_WORKSPACE,
525        ),
526        item(
527            PaletteAction::FilterToday,
528            "Filter: today",
529            "Restrict to today",
530        ),
531        item(
532            PaletteAction::FilterWeek,
533            "Filter: last 7 days",
534            "Restrict to week",
535        ),
536        item(
537            PaletteAction::FilterCustomDate,
538            "Filter: date range",
539            shortcuts::FILTER_DATE_FROM,
540        ),
541        item(
542            PaletteAction::OpenBulkActions,
543            "Bulk actions",
544            shortcuts::BULK_MENU,
545        ),
546        item(
547            PaletteAction::ReloadIndex,
548            "Reload index/view",
549            shortcuts::REFRESH,
550        ),
551    ];
552    // -- Analytics surface commands ----------------------------------------
553    items.push(item(
554        PaletteAction::AnalyticsDashboard,
555        "Analytics: Dashboard",
556        "KPI overview",
557    ));
558    items.push(item(
559        PaletteAction::AnalyticsExplorer,
560        "Analytics: Explorer",
561        "Time-series explorer",
562    ));
563    items.push(item(
564        PaletteAction::AnalyticsHeatmap,
565        "Analytics: Heatmap",
566        "Calendar heatmap",
567    ));
568    items.push(item(
569        PaletteAction::AnalyticsBreakdowns,
570        "Analytics: Breakdowns",
571        "Agents/workspaces/sources",
572    ));
573    items.push(item(
574        PaletteAction::AnalyticsTools,
575        "Analytics: Tools",
576        "Per-tool usage",
577    ));
578    items.push(item(
579        PaletteAction::AnalyticsPlans,
580        "Analytics: Plans",
581        "Plan frequency + token share",
582    ));
583    items.push(item(
584        PaletteAction::AnalyticsCoverage,
585        "Analytics: Coverage",
586        "Token coverage diagnostics",
587    ));
588    // -- Screenshot export commands -----------------------------------------
589    items.push(item(
590        PaletteAction::ScreenshotHtml,
591        "Screenshot: HTML",
592        "Capture TUI as HTML",
593    ));
594    items.push(item(
595        PaletteAction::ScreenshotSvg,
596        "Screenshot: SVG",
597        "Capture TUI as SVG",
598    ));
599    items.push(item(
600        PaletteAction::ScreenshotText,
601        "Screenshot: Text",
602        "Capture TUI as plain text",
603    ));
604    // -- Macro recording commands -------------------------------------------
605    items.push(item(
606        PaletteAction::MacroRecordingToggle,
607        "Toggle macro recording",
608        "Alt+M",
609    ));
610    // -- Sources management ------------------------------------------------
611    items.push(item(
612        PaletteAction::Sources,
613        "Sources management",
614        "Ctrl+Shift+S",
615    ));
616    // Slots 1-9
617    for slot in 1..=9 {
618        items.push(item(
619            PaletteAction::SaveViewSlot(slot),
620            format!("Save view to slot {slot}"),
621            format!("Ctrl+{slot}"),
622        ));
623        items.push(item(
624            PaletteAction::LoadViewSlot(slot),
625            format!("Load view from slot {slot}"),
626            format!("Shift+{slot}"),
627        ));
628    }
629    items
630}
631
632fn item(action: PaletteAction, label: impl Into<String>, hint: impl Into<String>) -> PaletteItem {
633    PaletteItem {
634        action,
635        label: label.into(),
636        hint: hint.into(),
637    }
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643
644    // ==================== PaletteAction tests ====================
645
646    #[test]
647    fn test_palette_action_clone() {
648        let action = PaletteAction::ToggleTheme;
649        let cloned = action.clone();
650        assert!(matches!(cloned, PaletteAction::ToggleTheme));
651    }
652
653    #[test]
654    fn test_palette_action_debug() {
655        let action = PaletteAction::FilterAgent;
656        let debug_str = format!("{:?}", action);
657        assert!(debug_str.contains("FilterAgent"));
658    }
659
660    #[test]
661    fn test_palette_action_slot_variants() {
662        let save = PaletteAction::SaveViewSlot(5);
663        let load = PaletteAction::LoadViewSlot(3);
664
665        let save_debug = format!("{:?}", save);
666        let load_debug = format!("{:?}", load);
667
668        assert!(save_debug.contains("SaveViewSlot"));
669        assert!(save_debug.contains("5"));
670        assert!(load_debug.contains("LoadViewSlot"));
671        assert!(load_debug.contains("3"));
672    }
673
674    // ==================== PaletteItem tests ====================
675
676    #[test]
677    fn test_palette_item_creation() {
678        let item = PaletteItem {
679            action: PaletteAction::ToggleTheme,
680            label: "Toggle theme".to_string(),
681            hint: "Switch light/dark".to_string(),
682        };
683
684        assert_eq!(item.label, "Toggle theme");
685        assert_eq!(item.hint, "Switch light/dark");
686    }
687
688    #[test]
689    fn test_palette_item_clone() {
690        let item = PaletteItem {
691            action: PaletteAction::ReloadIndex,
692            label: "Reload".to_string(),
693            hint: "Refresh".to_string(),
694        };
695
696        let cloned = item.clone();
697        assert_eq!(cloned.label, item.label);
698        assert_eq!(cloned.hint, item.hint);
699    }
700
701    #[test]
702    fn test_palette_item_debug() {
703        let item = PaletteItem {
704            action: PaletteAction::FilterToday,
705            label: "Today".to_string(),
706            hint: "Show today".to_string(),
707        };
708
709        let debug_str = format!("{:?}", item);
710        assert!(debug_str.contains("PaletteItem"));
711        assert!(debug_str.contains("Today"));
712    }
713
714    // ==================== PaletteState::new tests ====================
715
716    #[test]
717    fn test_palette_state_new_empty() {
718        let state = PaletteState::new(vec![]);
719
720        assert!(!state.open);
721        assert!(state.query.is_empty());
722        assert!(state.filtered.is_empty());
723        assert!(state.all_actions.is_empty());
724        assert_eq!(state.selected, 0);
725    }
726
727    #[test]
728    fn test_palette_state_new_with_items() {
729        let items = vec![
730            item(PaletteAction::ToggleTheme, "Theme", "Switch"),
731            item(PaletteAction::ToggleDensity, "Density", "Change"),
732        ];
733        let state = PaletteState::new(items);
734
735        assert!(!state.open);
736        assert!(state.query.is_empty());
737        assert_eq!(state.filtered.len(), 2);
738        assert_eq!(state.all_actions.len(), 2);
739        assert_eq!(state.selected, 0);
740    }
741
742    #[test]
743    fn test_palette_state_filtered_matches_all_initially() {
744        let items = vec![
745            item(PaletteAction::FilterAgent, "Agent", "Set agent"),
746            item(PaletteAction::FilterWorkspace, "Workspace", "Set ws"),
747            item(PaletteAction::FilterToday, "Today", "Restrict"),
748        ];
749        let state = PaletteState::new(items);
750
751        assert_eq!(state.filtered.len(), state.all_actions.len());
752    }
753
754    // ==================== PaletteState::refilter tests ====================
755
756    #[test]
757    fn test_refilter_empty_query_shows_all() {
758        let items = vec![
759            item(PaletteAction::ToggleTheme, "Theme", "Switch"),
760            item(PaletteAction::ToggleDensity, "Density", "Change"),
761        ];
762        let mut state = PaletteState::new(items);
763        state.query = "".to_string();
764        state.refilter();
765
766        assert_eq!(state.filtered.len(), 2);
767    }
768
769    #[test]
770    fn test_refilter_whitespace_query_shows_all() {
771        let items = vec![
772            item(PaletteAction::ToggleTheme, "Theme", "Switch"),
773            item(PaletteAction::ToggleDensity, "Density", "Change"),
774        ];
775        let mut state = PaletteState::new(items);
776        state.query = "   ".to_string();
777        state.refilter();
778
779        assert_eq!(state.filtered.len(), 2);
780    }
781
782    #[test]
783    fn test_refilter_matches_label() {
784        let items = vec![
785            item(PaletteAction::ToggleTheme, "Toggle theme", "Switch"),
786            item(PaletteAction::FilterAgent, "Filter agent", "Set"),
787        ];
788        let mut state = PaletteState::new(items);
789        state.query = "theme".to_string();
790        state.refilter();
791
792        assert_eq!(state.filtered.len(), 1);
793        assert_eq!(state.filtered[0].label, "Toggle theme");
794    }
795
796    #[test]
797    fn test_refilter_matches_hint() {
798        let items = vec![
799            item(PaletteAction::ToggleTheme, "Theme", "Switch light/dark"),
800            item(PaletteAction::FilterAgent, "Agent", "Set filter"),
801        ];
802        let mut state = PaletteState::new(items);
803        state.query = "light".to_string();
804        state.refilter();
805
806        assert_eq!(state.filtered.len(), 1);
807        assert_eq!(state.filtered[0].label, "Theme");
808    }
809
810    #[test]
811    fn test_refilter_case_insensitive() {
812        let items = vec![
813            item(PaletteAction::ToggleTheme, "Toggle Theme", "Switch"),
814            item(PaletteAction::FilterAgent, "Filter Agent", "Set"),
815        ];
816        let mut state = PaletteState::new(items);
817        state.query = "THEME".to_string();
818        state.refilter();
819
820        assert_eq!(state.filtered.len(), 1);
821        assert_eq!(state.filtered[0].label, "Toggle Theme");
822    }
823
824    #[test]
825    fn test_refilter_no_matches() {
826        let items = vec![
827            item(PaletteAction::ToggleTheme, "Theme", "Switch"),
828            item(PaletteAction::FilterAgent, "Agent", "Set"),
829        ];
830        let mut state = PaletteState::new(items);
831        state.query = "xyz".to_string();
832        state.refilter();
833
834        assert!(state.filtered.is_empty());
835    }
836
837    #[test]
838    fn test_refilter_adjusts_selection_when_out_of_bounds() {
839        let items = vec![
840            item(PaletteAction::ToggleTheme, "Theme", "Switch"),
841            item(PaletteAction::FilterAgent, "Agent", "Set"),
842            item(PaletteAction::FilterWorkspace, "Workspace", "Set"),
843        ];
844        let mut state = PaletteState::new(items);
845        state.selected = 2;
846        state.query = "theme".to_string();
847        state.refilter();
848
849        assert!(state.selected < state.filtered.len() || state.filtered.is_empty());
850    }
851
852    #[test]
853    fn test_refilter_selection_stays_zero_when_empty() {
854        let items = vec![item(PaletteAction::ToggleTheme, "Theme", "Switch")];
855        let mut state = PaletteState::new(items);
856        state.selected = 0;
857        state.query = "nomatch".to_string();
858        state.refilter();
859
860        assert!(state.filtered.is_empty());
861        assert_eq!(state.selected, 0);
862    }
863
864    // ==================== PaletteState::move_selection tests ====================
865
866    #[test]
867    fn test_move_selection_down() {
868        let items = vec![
869            item(PaletteAction::ToggleTheme, "Theme", "A"),
870            item(PaletteAction::FilterAgent, "Agent", "B"),
871            item(PaletteAction::FilterWorkspace, "Workspace", "C"),
872        ];
873        let mut state = PaletteState::new(items);
874        assert_eq!(state.selected, 0);
875
876        state.move_selection(1);
877        assert_eq!(state.selected, 1);
878
879        state.move_selection(1);
880        assert_eq!(state.selected, 2);
881    }
882
883    #[test]
884    fn test_move_selection_up() {
885        let items = vec![
886            item(PaletteAction::ToggleTheme, "Theme", "A"),
887            item(PaletteAction::FilterAgent, "Agent", "B"),
888            item(PaletteAction::FilterWorkspace, "Workspace", "C"),
889        ];
890        let mut state = PaletteState::new(items);
891        state.selected = 2;
892
893        state.move_selection(-1);
894        assert_eq!(state.selected, 1);
895
896        state.move_selection(-1);
897        assert_eq!(state.selected, 0);
898    }
899
900    #[test]
901    fn test_move_selection_wraps_forward() {
902        let items = vec![
903            item(PaletteAction::ToggleTheme, "Theme", "A"),
904            item(PaletteAction::FilterAgent, "Agent", "B"),
905        ];
906        let mut state = PaletteState::new(items);
907        state.selected = 1;
908
909        state.move_selection(1);
910        assert_eq!(state.selected, 0);
911    }
912
913    #[test]
914    fn test_move_selection_wraps_backward() {
915        let items = vec![
916            item(PaletteAction::ToggleTheme, "Theme", "A"),
917            item(PaletteAction::FilterAgent, "Agent", "B"),
918        ];
919        let mut state = PaletteState::new(items);
920        state.selected = 0;
921
922        state.move_selection(-1);
923        assert_eq!(state.selected, 1);
924    }
925
926    #[test]
927    fn test_move_selection_empty_list() {
928        let mut state = PaletteState::new(vec![]);
929
930        state.move_selection(1);
931        assert_eq!(state.selected, 0);
932
933        state.move_selection(-1);
934        assert_eq!(state.selected, 0);
935    }
936
937    #[test]
938    fn test_move_selection_large_delta() {
939        let items = vec![
940            item(PaletteAction::ToggleTheme, "A", ""),
941            item(PaletteAction::FilterAgent, "B", ""),
942            item(PaletteAction::FilterWorkspace, "C", ""),
943        ];
944        let mut state = PaletteState::new(items);
945        state.selected = 0;
946
947        state.move_selection(5);
948        assert_eq!(state.selected, 2); // 5 % 3 = 2
949
950        state.move_selection(-7);
951        // 2 + (-7) = -5, rem_euclid(3) = 1
952        assert_eq!(state.selected, 1);
953    }
954
955    // ==================== default_actions tests ====================
956
957    #[test]
958    fn test_default_actions_not_empty() {
959        let actions = default_actions();
960        assert!(!actions.is_empty());
961    }
962
963    #[test]
964    fn test_default_actions_has_basic_items() {
965        let actions = default_actions();
966        let labels: Vec<&str> = actions.iter().map(|a| a.label.as_str()).collect();
967
968        assert!(labels.contains(&"Toggle theme"));
969        assert!(labels.contains(&"Toggle density"));
970        assert!(labels.contains(&"Filter: agent"));
971        assert!(labels.contains(&"Reload index/view"));
972    }
973
974    #[test]
975    fn test_default_actions_has_view_slots() {
976        let actions = default_actions();
977
978        for slot in 1..=9 {
979            let save_label = format!("Save view to slot {slot}");
980            let load_label = format!("Load view from slot {slot}");
981
982            assert!(
983                actions.iter().any(|a| a.label == save_label),
984                "Missing save slot {slot}"
985            );
986            assert!(
987                actions.iter().any(|a| a.label == load_label),
988                "Missing load slot {slot}"
989            );
990        }
991    }
992
993    #[test]
994    fn test_default_actions_all_have_labels_and_hints() {
995        let actions = default_actions();
996
997        for action in &actions {
998            assert!(!action.label.is_empty(), "Action has empty label");
999            assert!(!action.hint.is_empty(), "Action has empty hint");
1000        }
1001    }
1002
1003    // ==================== item helper tests ====================
1004
1005    #[test]
1006    fn test_item_helper_function() {
1007        let result = item(PaletteAction::ToggleTheme, "Label", "Hint");
1008
1009        assert_eq!(result.label, "Label");
1010        assert_eq!(result.hint, "Hint");
1011        assert!(matches!(result.action, PaletteAction::ToggleTheme));
1012    }
1013
1014    #[test]
1015    fn test_item_helper_with_string() {
1016        let result = item(
1017            PaletteAction::FilterAgent,
1018            String::from("My Label"),
1019            String::from("My Hint"),
1020        );
1021
1022        assert_eq!(result.label, "My Label");
1023        assert_eq!(result.hint, "My Hint");
1024    }
1025
1026    // ==================== PaletteGroup tests ====================
1027
1028    #[test]
1029    fn group_all_contains_seven_groups() {
1030        assert_eq!(PaletteGroup::ALL.len(), 7);
1031    }
1032
1033    #[test]
1034    fn group_labels_are_nonempty() {
1035        for g in PaletteGroup::ALL {
1036            assert!(!g.label().is_empty(), "{:?} has empty label", g);
1037        }
1038    }
1039
1040    #[test]
1041    fn every_action_has_a_group() {
1042        // Exhaustive: build every variant and assert group() doesn't panic.
1043        let all: Vec<PaletteAction> = vec![
1044            PaletteAction::ToggleTheme,
1045            PaletteAction::ToggleDensity,
1046            PaletteAction::ToggleHelpStrip,
1047            PaletteAction::OpenUpdateBanner,
1048            PaletteAction::FilterAgent,
1049            PaletteAction::FilterWorkspace,
1050            PaletteAction::FilterToday,
1051            PaletteAction::FilterWeek,
1052            PaletteAction::FilterCustomDate,
1053            PaletteAction::OpenSavedViews,
1054            PaletteAction::SaveViewSlot(1),
1055            PaletteAction::LoadViewSlot(1),
1056            PaletteAction::OpenBulkActions,
1057            PaletteAction::ReloadIndex,
1058            PaletteAction::AnalyticsDashboard,
1059            PaletteAction::AnalyticsExplorer,
1060            PaletteAction::AnalyticsHeatmap,
1061            PaletteAction::AnalyticsBreakdowns,
1062            PaletteAction::AnalyticsTools,
1063            PaletteAction::AnalyticsPlans,
1064            PaletteAction::AnalyticsCoverage,
1065            PaletteAction::ScreenshotHtml,
1066            PaletteAction::ScreenshotSvg,
1067            PaletteAction::ScreenshotText,
1068            PaletteAction::MacroRecordingToggle,
1069            PaletteAction::Sources,
1070        ];
1071        for action in &all {
1072            let _ = action.group(); // must not panic
1073        }
1074    }
1075
1076    #[test]
1077    fn every_action_has_a_target_msg() {
1078        let all: Vec<PaletteAction> = vec![
1079            PaletteAction::ToggleTheme,
1080            PaletteAction::ToggleDensity,
1081            PaletteAction::ToggleHelpStrip,
1082            PaletteAction::OpenUpdateBanner,
1083            PaletteAction::FilterAgent,
1084            PaletteAction::FilterWorkspace,
1085            PaletteAction::FilterToday,
1086            PaletteAction::FilterWeek,
1087            PaletteAction::FilterCustomDate,
1088            PaletteAction::OpenSavedViews,
1089            PaletteAction::SaveViewSlot(1),
1090            PaletteAction::LoadViewSlot(1),
1091            PaletteAction::OpenBulkActions,
1092            PaletteAction::ReloadIndex,
1093            PaletteAction::AnalyticsDashboard,
1094            PaletteAction::AnalyticsExplorer,
1095            PaletteAction::AnalyticsHeatmap,
1096            PaletteAction::AnalyticsBreakdowns,
1097            PaletteAction::AnalyticsTools,
1098            PaletteAction::AnalyticsPlans,
1099            PaletteAction::AnalyticsCoverage,
1100            PaletteAction::ScreenshotHtml,
1101            PaletteAction::ScreenshotSvg,
1102            PaletteAction::ScreenshotText,
1103            PaletteAction::MacroRecordingToggle,
1104            PaletteAction::Sources,
1105        ];
1106        for action in &all {
1107            let target = action.target_msg_name();
1108            assert!(!target.is_empty(), "{:?} has empty target_msg_name", action);
1109        }
1110    }
1111
1112    #[test]
1113    fn chrome_group_contains_expected_actions() {
1114        assert_eq!(PaletteAction::ToggleTheme.group(), PaletteGroup::Chrome);
1115        assert_eq!(PaletteAction::ToggleDensity.group(), PaletteGroup::Chrome);
1116        assert_eq!(PaletteAction::ToggleHelpStrip.group(), PaletteGroup::Chrome);
1117        assert_eq!(
1118            PaletteAction::OpenUpdateBanner.group(),
1119            PaletteGroup::Chrome
1120        );
1121    }
1122
1123    #[test]
1124    fn filter_group_contains_expected_actions() {
1125        assert_eq!(PaletteAction::FilterAgent.group(), PaletteGroup::Filter);
1126        assert_eq!(PaletteAction::FilterWorkspace.group(), PaletteGroup::Filter);
1127        assert_eq!(PaletteAction::FilterToday.group(), PaletteGroup::Filter);
1128        assert_eq!(PaletteAction::FilterWeek.group(), PaletteGroup::Filter);
1129        assert_eq!(
1130            PaletteAction::FilterCustomDate.group(),
1131            PaletteGroup::Filter
1132        );
1133    }
1134
1135    #[test]
1136    fn analytics_group_has_seven_variants() {
1137        let analytics: Vec<PaletteAction> = vec![
1138            PaletteAction::AnalyticsDashboard,
1139            PaletteAction::AnalyticsExplorer,
1140            PaletteAction::AnalyticsHeatmap,
1141            PaletteAction::AnalyticsBreakdowns,
1142            PaletteAction::AnalyticsTools,
1143            PaletteAction::AnalyticsPlans,
1144            PaletteAction::AnalyticsCoverage,
1145        ];
1146        assert_eq!(analytics.len(), 7);
1147        for a in &analytics {
1148            assert_eq!(a.group(), PaletteGroup::Analytics);
1149        }
1150    }
1151
1152    #[test]
1153    fn view_group_contains_expected_actions() {
1154        assert_eq!(PaletteAction::OpenSavedViews.group(), PaletteGroup::View);
1155        assert_eq!(PaletteAction::SaveViewSlot(3).group(), PaletteGroup::View);
1156        assert_eq!(PaletteAction::LoadViewSlot(5).group(), PaletteGroup::View);
1157        assert_eq!(PaletteAction::OpenBulkActions.group(), PaletteGroup::View);
1158        assert_eq!(PaletteAction::ReloadIndex.group(), PaletteGroup::View);
1159    }
1160
1161    #[test]
1162    fn export_group_contains_expected_actions() {
1163        assert_eq!(PaletteAction::ScreenshotHtml.group(), PaletteGroup::Export);
1164        assert_eq!(PaletteAction::ScreenshotSvg.group(), PaletteGroup::Export);
1165        assert_eq!(PaletteAction::ScreenshotText.group(), PaletteGroup::Export);
1166    }
1167
1168    #[test]
1169    fn default_actions_cover_all_groups() {
1170        let actions = default_actions();
1171        let mut groups_seen = std::collections::HashSet::new();
1172        for a in &actions {
1173            groups_seen.insert(a.action.group());
1174        }
1175        for g in PaletteGroup::ALL {
1176            assert!(
1177                groups_seen.contains(g),
1178                "Group {:?} not represented in default_actions()",
1179                g
1180            );
1181        }
1182    }
1183
1184    #[test]
1185    fn target_msg_names_are_distinct_per_non_slot_action() {
1186        // Non-slot actions should each have a unique target (slots share "ViewSaved(slot)").
1187        let non_slot: Vec<PaletteAction> = vec![
1188            PaletteAction::ToggleTheme,
1189            PaletteAction::ToggleDensity,
1190            PaletteAction::ToggleHelpStrip,
1191            PaletteAction::OpenUpdateBanner,
1192            PaletteAction::FilterAgent,
1193            PaletteAction::FilterWorkspace,
1194            PaletteAction::FilterToday,
1195            PaletteAction::FilterWeek,
1196            PaletteAction::FilterCustomDate,
1197            PaletteAction::OpenSavedViews,
1198            PaletteAction::OpenBulkActions,
1199            PaletteAction::ReloadIndex,
1200            PaletteAction::AnalyticsDashboard,
1201            PaletteAction::AnalyticsExplorer,
1202            PaletteAction::AnalyticsHeatmap,
1203            PaletteAction::AnalyticsBreakdowns,
1204            PaletteAction::AnalyticsTools,
1205            PaletteAction::AnalyticsPlans,
1206            PaletteAction::AnalyticsCoverage,
1207            PaletteAction::ScreenshotHtml,
1208            PaletteAction::ScreenshotSvg,
1209            PaletteAction::ScreenshotText,
1210            PaletteAction::MacroRecordingToggle,
1211            PaletteAction::Sources,
1212        ];
1213        let mut seen = std::collections::HashSet::new();
1214        for a in &non_slot {
1215            let name = a.target_msg_name();
1216            assert!(
1217                seen.insert(name),
1218                "Duplicate target_msg_name {:?} for {:?}",
1219                name,
1220                a
1221            );
1222        }
1223    }
1224
1225    // ==================== PaletteResult tests ====================
1226
1227    #[test]
1228    fn palette_result_clone_and_eq() {
1229        let r = PaletteResult::ToggleTheme;
1230        assert_eq!(r.clone(), PaletteResult::ToggleTheme);
1231    }
1232
1233    #[test]
1234    fn palette_result_debug_format() {
1235        let r = PaletteResult::EnterInputMode(InputModeTarget::Agent);
1236        let s = format!("{:?}", r);
1237        assert!(s.contains("EnterInputMode"));
1238        assert!(s.contains("Agent"));
1239    }
1240
1241    #[test]
1242    fn palette_result_noop_variant() {
1243        let r = PaletteResult::Noop;
1244        assert_eq!(r, PaletteResult::Noop);
1245    }
1246
1247    // ==================== dispatch() tests ====================
1248
1249    #[test]
1250    fn dispatch_chrome_actions() {
1251        assert_eq!(
1252            PaletteAction::ToggleTheme.dispatch(),
1253            PaletteResult::ToggleTheme
1254        );
1255        assert_eq!(
1256            PaletteAction::ToggleDensity.dispatch(),
1257            PaletteResult::CycleDensity
1258        );
1259        assert_eq!(
1260            PaletteAction::ToggleHelpStrip.dispatch(),
1261            PaletteResult::ToggleHelpStrip
1262        );
1263        assert_eq!(
1264            PaletteAction::OpenUpdateBanner.dispatch(),
1265            PaletteResult::OpenUpdateBanner
1266        );
1267    }
1268
1269    #[test]
1270    fn dispatch_filter_actions() {
1271        assert_eq!(
1272            PaletteAction::FilterAgent.dispatch(),
1273            PaletteResult::EnterInputMode(InputModeTarget::Agent)
1274        );
1275        assert_eq!(
1276            PaletteAction::FilterWorkspace.dispatch(),
1277            PaletteResult::EnterInputMode(InputModeTarget::Workspace)
1278        );
1279        assert_eq!(
1280            PaletteAction::FilterToday.dispatch(),
1281            PaletteResult::SetTimeFilter {
1282                from: TimeFilterPreset::Today
1283            }
1284        );
1285        assert_eq!(
1286            PaletteAction::FilterWeek.dispatch(),
1287            PaletteResult::SetTimeFilter {
1288                from: TimeFilterPreset::LastWeek
1289            }
1290        );
1291        assert_eq!(
1292            PaletteAction::FilterCustomDate.dispatch(),
1293            PaletteResult::EnterInputMode(InputModeTarget::CreatedFrom)
1294        );
1295    }
1296
1297    #[test]
1298    fn dispatch_view_actions() {
1299        assert_eq!(
1300            PaletteAction::OpenSavedViews.dispatch(),
1301            PaletteResult::OpenSavedViews
1302        );
1303        assert_eq!(
1304            PaletteAction::OpenBulkActions.dispatch(),
1305            PaletteResult::OpenBulkActions
1306        );
1307        assert_eq!(
1308            PaletteAction::ReloadIndex.dispatch(),
1309            PaletteResult::ReloadIndex
1310        );
1311    }
1312
1313    #[test]
1314    fn dispatch_slot_actions_preserve_slot_number() {
1315        for slot in 1..=9u8 {
1316            assert_eq!(
1317                PaletteAction::SaveViewSlot(slot).dispatch(),
1318                PaletteResult::SaveViewSlot(slot)
1319            );
1320            assert_eq!(
1321                PaletteAction::LoadViewSlot(slot).dispatch(),
1322                PaletteResult::LoadViewSlot(slot)
1323            );
1324        }
1325    }
1326
1327    #[test]
1328    fn dispatch_analytics_actions() {
1329        let cases = vec![
1330            (
1331                PaletteAction::AnalyticsDashboard,
1332                AnalyticsTarget::Dashboard,
1333            ),
1334            (PaletteAction::AnalyticsExplorer, AnalyticsTarget::Explorer),
1335            (PaletteAction::AnalyticsHeatmap, AnalyticsTarget::Heatmap),
1336            (
1337                PaletteAction::AnalyticsBreakdowns,
1338                AnalyticsTarget::Breakdowns,
1339            ),
1340            (PaletteAction::AnalyticsTools, AnalyticsTarget::Tools),
1341            (PaletteAction::AnalyticsPlans, AnalyticsTarget::Plans),
1342            (PaletteAction::AnalyticsCoverage, AnalyticsTarget::Coverage),
1343        ];
1344        for (action, expected_target) in cases {
1345            assert_eq!(
1346                action.dispatch(),
1347                PaletteResult::OpenAnalyticsView(expected_target),
1348                "dispatch mismatch for {:?}",
1349                expected_target
1350            );
1351        }
1352    }
1353
1354    #[test]
1355    fn dispatch_export_actions() {
1356        assert_eq!(
1357            PaletteAction::ScreenshotHtml.dispatch(),
1358            PaletteResult::Screenshot(ScreenshotTarget::Html)
1359        );
1360        assert_eq!(
1361            PaletteAction::ScreenshotSvg.dispatch(),
1362            PaletteResult::Screenshot(ScreenshotTarget::Svg)
1363        );
1364        assert_eq!(
1365            PaletteAction::ScreenshotText.dispatch(),
1366            PaletteResult::Screenshot(ScreenshotTarget::Text)
1367        );
1368    }
1369
1370    #[test]
1371    fn dispatch_recording_and_sources() {
1372        assert_eq!(
1373            PaletteAction::MacroRecordingToggle.dispatch(),
1374            PaletteResult::ToggleMacroRecording
1375        );
1376        assert_eq!(
1377            PaletteAction::Sources.dispatch(),
1378            PaletteResult::OpenSources
1379        );
1380    }
1381
1382    #[test]
1383    fn dispatch_exhaustive_all_26_actions() {
1384        // Every action variant must dispatch without panic and return non-Noop.
1385        let all: Vec<PaletteAction> = vec![
1386            PaletteAction::ToggleTheme,
1387            PaletteAction::ToggleDensity,
1388            PaletteAction::ToggleHelpStrip,
1389            PaletteAction::OpenUpdateBanner,
1390            PaletteAction::FilterAgent,
1391            PaletteAction::FilterWorkspace,
1392            PaletteAction::FilterToday,
1393            PaletteAction::FilterWeek,
1394            PaletteAction::FilterCustomDate,
1395            PaletteAction::OpenSavedViews,
1396            PaletteAction::SaveViewSlot(1),
1397            PaletteAction::LoadViewSlot(1),
1398            PaletteAction::OpenBulkActions,
1399            PaletteAction::ReloadIndex,
1400            PaletteAction::AnalyticsDashboard,
1401            PaletteAction::AnalyticsExplorer,
1402            PaletteAction::AnalyticsHeatmap,
1403            PaletteAction::AnalyticsBreakdowns,
1404            PaletteAction::AnalyticsTools,
1405            PaletteAction::AnalyticsPlans,
1406            PaletteAction::AnalyticsCoverage,
1407            PaletteAction::ScreenshotHtml,
1408            PaletteAction::ScreenshotSvg,
1409            PaletteAction::ScreenshotText,
1410            PaletteAction::MacroRecordingToggle,
1411            PaletteAction::Sources,
1412        ];
1413        for action in &all {
1414            let result = action.dispatch();
1415            assert_ne!(
1416                result,
1417                PaletteResult::Noop,
1418                "{:?} dispatched to Noop",
1419                action
1420            );
1421        }
1422    }
1423
1424    // ==================== execute_selected() tests ====================
1425
1426    #[test]
1427    fn execute_selected_returns_noop_on_empty_state() {
1428        let state = PaletteState::new(vec![]);
1429        assert_eq!(execute_selected(&state), PaletteResult::Noop);
1430    }
1431
1432    #[test]
1433    fn execute_selected_returns_noop_on_out_of_bounds() {
1434        let items = vec![item(PaletteAction::ToggleTheme, "Theme", "Toggle")];
1435        let mut state = PaletteState::new(items);
1436        state.selected = 5; // out of bounds
1437        assert_eq!(execute_selected(&state), PaletteResult::Noop);
1438    }
1439
1440    #[test]
1441    fn execute_selected_dispatches_first_item() {
1442        let items = vec![
1443            item(PaletteAction::ToggleTheme, "Theme", "Toggle"),
1444            item(PaletteAction::ReloadIndex, "Reload", "Refresh"),
1445        ];
1446        let state = PaletteState::new(items);
1447        assert_eq!(execute_selected(&state), PaletteResult::ToggleTheme);
1448    }
1449
1450    #[test]
1451    fn execute_selected_dispatches_second_item() {
1452        let items = vec![
1453            item(PaletteAction::ToggleTheme, "Theme", "Toggle"),
1454            item(PaletteAction::ReloadIndex, "Reload", "Refresh"),
1455        ];
1456        let mut state = PaletteState::new(items);
1457        state.selected = 1;
1458        assert_eq!(execute_selected(&state), PaletteResult::ReloadIndex);
1459    }
1460
1461    #[test]
1462    fn execute_selected_respects_filter() {
1463        let items = vec![
1464            item(PaletteAction::ToggleTheme, "Theme", "Toggle"),
1465            item(PaletteAction::ReloadIndex, "Reload", "Refresh"),
1466        ];
1467        let mut state = PaletteState::new(items);
1468        state.query = "reload".to_string();
1469        state.refilter();
1470        // After filtering, only "Reload" remains, selected=0.
1471        assert_eq!(execute_selected(&state), PaletteResult::ReloadIndex);
1472    }
1473
1474    #[test]
1475    fn execute_selected_noop_after_no_match_filter() {
1476        let items = vec![item(PaletteAction::ToggleTheme, "Theme", "Toggle")];
1477        let mut state = PaletteState::new(items);
1478        state.query = "zzz_no_match".to_string();
1479        state.refilter();
1480        assert_eq!(execute_selected(&state), PaletteResult::Noop);
1481    }
1482
1483    #[test]
1484    fn execute_selected_slot_preserves_value() {
1485        let items = vec![item(PaletteAction::SaveViewSlot(7), "Save 7", "Ctrl+7")];
1486        let state = PaletteState::new(items);
1487        assert_eq!(execute_selected(&state), PaletteResult::SaveViewSlot(7));
1488    }
1489
1490    // ==================== InputModeTarget/TimeFilterPreset/AnalyticsTarget/ScreenshotTarget ====================
1491
1492    #[test]
1493    fn supporting_enums_derive_traits() {
1494        // Clone + Copy + Debug + PartialEq + Eq
1495        let imt = InputModeTarget::Agent;
1496        assert_eq!(imt, imt);
1497        let _ = format!("{:?}", imt);
1498
1499        let tfp = TimeFilterPreset::Today;
1500        assert_eq!(tfp, tfp);
1501        let _ = format!("{:?}", tfp);
1502
1503        let at = AnalyticsTarget::Dashboard;
1504        assert_eq!(at, at);
1505        let _ = format!("{:?}", at);
1506
1507        let st = ScreenshotTarget::Html;
1508        assert_eq!(st, st);
1509        let _ = format!("{:?}", st);
1510    }
1511}