Skip to main content

snip_it/ui/
mod.rs

1//! Terminal user interface for snippet selection.
2//!
3//! Provides the main TUI event loop with fuzzy search, syntax highlighting,
4//! visual multi-select mode, and keyboard navigation. Re-exports the theme
5//! system and variable prompting dialog.
6
7mod highlight;
8mod state;
9mod theme;
10mod variables;
11
12mod _generated_bundled_themes;
13
14pub use theme::get_theme;
15pub use variables::{VariablePromptResult, prompt_variables};
16
17use std::io;
18use std::sync::LazyLock;
19use std::time::{Duration, Instant};
20
21use crossterm::event::{self, Event as CEvent, KeyCode, KeyEventKind, KeyModifiers};
22use fuzzy_matcher::FuzzyMatcher;
23use fuzzy_matcher::skim::SkimMatcherV2;
24use ratatui::text::{Line, Span};
25use ratatui::{
26    layout::{Constraint, Direction, Layout},
27    style::Style as TuiStyle,
28    widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation},
29};
30
31use crate::clipboard;
32use crate::utils::extract_variables_for_display;
33use crate::utils::has_unmatched_angle_bracket;
34use crate::utils::strip_escape_sequences;
35
36/// RAII guard that disables mouse capture and restores the terminal when dropped.
37/// Ensures the terminal is always restored even on early return or panic.
38struct TerminalGuard;
39
40impl Drop for TerminalGuard {
41    fn drop(&mut self) {
42        let _ = crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture);
43        ratatui::restore();
44    }
45}
46
47use highlight::highlight_command;
48use state::{FilterState, SelectState, SortMode, is_ctrl_key};
49use theme::{style_fg, style_fg_bg};
50
51static TERMINATE: LazyLock<std::sync::Arc<std::sync::atomic::AtomicBool>> =
52    LazyLock::new(|| std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)));
53
54pub fn get_terminate() -> std::sync::Arc<std::sync::atomic::AtomicBool> {
55    TERMINATE.clone()
56}
57
58static MATCHER: LazyLock<SkimMatcherV2> = LazyLock::new(SkimMatcherV2::default);
59
60#[derive(Clone, Debug, PartialEq, Eq)]
61struct FilterRequest {
62    text: String,
63    text_lower: String,
64    include_tags: bool,
65    incremental: bool,
66}
67
68impl FilterRequest {
69    fn is_empty(&self) -> bool {
70        self.text.is_empty()
71    }
72
73    fn can_narrow_from(&self, previous: &Self) -> bool {
74        !previous.text.is_empty()
75            && self.incremental == previous.incremental
76            && self.include_tags == previous.include_tags
77            && self.text.starts_with(&previous.text)
78    }
79}
80
81fn current_filter_request(
82    filter: &str,
83    incremental_search: &str,
84    filter_state: &FilterState,
85    tag_filter_mode: bool,
86) -> FilterRequest {
87    if !incremental_search.is_empty() {
88        return FilterRequest {
89            text: incremental_search.to_string(),
90            text_lower: incremental_search.to_lowercase(),
91            include_tags: false,
92            incremental: true,
93        };
94    }
95
96    let has_main_filter = !filter.is_empty() || !filter_state.tag_filter_text.is_empty();
97    if !has_main_filter {
98        return FilterRequest {
99            text: String::new(),
100            text_lower: String::new(),
101            include_tags: false,
102            incremental: false,
103        };
104    }
105
106    let text = if tag_filter_mode {
107        &filter_state.tag_filter_text
108    } else {
109        filter
110    };
111    FilterRequest {
112        text: text.to_string(),
113        text_lower: text.to_lowercase(),
114        include_tags: tag_filter_mode || !filter_state.tag_filter_text.is_empty(),
115        incremental: false,
116    }
117}
118
119fn filter_request_would_narrow(
120    filter: &str,
121    incremental_search: &str,
122    filter_state: &FilterState,
123    tag_filter_mode: bool,
124    last_filter_request: Option<&FilterRequest>,
125) -> bool {
126    let request = current_filter_request(filter, incremental_search, filter_state, tag_filter_mode);
127    last_filter_request.is_some_and(|previous| request.can_narrow_from(previous))
128}
129
130fn filter_update_deadline_for_insert(
131    filter: &str,
132    incremental_search: &str,
133    filter_state: &FilterState,
134    tag_filter_mode: bool,
135    last_filter_request: Option<&FilterRequest>,
136) -> Option<Instant> {
137    if last_filter_request.is_none()
138        || filter_request_would_narrow(
139            filter,
140            incremental_search,
141            filter_state,
142            tag_filter_mode,
143            last_filter_request,
144        )
145    {
146        None
147    } else {
148        Some(Instant::now())
149    }
150}
151
152fn rebuild_filter_candidates(
153    candidates: &mut Vec<(usize, Option<i64>)>,
154    source_indices: impl Iterator<Item = usize>,
155    request: &FilterRequest,
156    all_display: &[String],
157    all_display_lower: &[String],
158    all_tags_search: &[String],
159) {
160    candidates.clear();
161
162    if request.is_empty() {
163        candidates.extend((0..all_display.len()).map(|i| (i, None)));
164        return;
165    }
166
167    for i in source_indices {
168        let Some(display) = all_display.get(i) else {
169            continue;
170        };
171        let is_exact_display = all_display_lower
172            .get(i)
173            .is_some_and(|display| display == &request.text_lower);
174        let tag_match = request.include_tags
175            && all_tags_search
176                .get(i)
177                .is_some_and(|snippet_tags| snippet_tags.contains(&request.text_lower));
178
179        if is_exact_display || tag_match {
180            candidates.push((i, Some(i64::MAX)));
181        } else if let Some(score) = MATCHER.fuzzy_match(display, &request.text) {
182            candidates.push((i, Some(score)));
183        }
184    }
185}
186
187fn sort_filtered_indices(
188    filtered: &mut [(usize, Option<i64>)],
189    filter_state: &FilterState,
190    snippets: &[crate::library::Snippet],
191    display_lower: &[String],
192    has_filter: bool,
193) {
194    if filter_state.sort_mode == SortMode::None && !has_filter {
195        return;
196    }
197
198    filtered.sort_unstable_by(|a, b| {
199        let score_cmp = match (a.1, b.1) {
200            (Some(sa), Some(sb)) => sb.cmp(&sa),
201            (Some(_), None) => std::cmp::Ordering::Less,
202            (None, Some(_)) => std::cmp::Ordering::Greater,
203            (None, None) => std::cmp::Ordering::Equal,
204        };
205
206        if score_cmp != std::cmp::Ordering::Equal || !has_filter {
207            let explicit_sort = match filter_state.sort_mode {
208                SortMode::Newest => {
209                    snippets
210                        .get(b.0)
211                        .zip(snippets.get(a.0))
212                        .map(|(b_snip, a_snip)| {
213                            b_snip
214                                .created_at
215                                .cmp(&a_snip.created_at)
216                                .then_with(|| b.0.cmp(&a.0))
217                        })
218                }
219                SortMode::Oldest => {
220                    snippets
221                        .get(a.0)
222                        .zip(snippets.get(b.0))
223                        .map(|(a_snip, b_snip)| {
224                            a_snip
225                                .created_at
226                                .cmp(&b_snip.created_at)
227                                .then_with(|| a.0.cmp(&b.0))
228                        })
229                }
230                _ => None,
231            };
232
233            let secondary = match filter_state.sort_mode {
234                SortMode::AlphaAsc => display_lower
235                    .get(a.0)
236                    .zip(display_lower.get(b.0))
237                    .map(|(a_display, b_display)| a_display.cmp(b_display)),
238                SortMode::AlphaDesc => display_lower
239                    .get(a.0)
240                    .zip(display_lower.get(b.0))
241                    .map(|(a_display, b_display)| b_display.cmp(a_display)),
242                _ => None,
243            };
244
245            match explicit_sort {
246                Some(c) if c != std::cmp::Ordering::Equal => c,
247                _ => secondary.unwrap_or(score_cmp),
248            }
249        } else {
250            score_cmp
251        }
252    });
253}
254
255fn debounce_elapsed(last_update: Option<Instant>, debounce_ms: u64) -> bool {
256    last_update.is_none_or(|t| t.elapsed() >= Duration::from_millis(debounce_ms))
257}
258
259fn pending_debounce_timeout(
260    dirty: bool,
261    last_update: Option<Instant>,
262    debounce_ms: u64,
263) -> Option<Duration> {
264    if !dirty {
265        return None;
266    }
267
268    let last_update = last_update?;
269    let debounce = Duration::from_millis(debounce_ms);
270    Some(debounce.saturating_sub(last_update.elapsed()))
271}
272
273fn next_event_poll_timeout(
274    filter_dirty: bool,
275    last_filter_update: Option<Instant>,
276    filter_debounce_ms: u64,
277    theme_dirty: bool,
278    last_theme_update: Option<Instant>,
279    theme_debounce_ms: u64,
280) -> Duration {
281    [
282        pending_debounce_timeout(filter_dirty, last_filter_update, filter_debounce_ms),
283        pending_debounce_timeout(theme_dirty, last_theme_update, theme_debounce_ms),
284    ]
285    .into_iter()
286    .flatten()
287    .min()
288    .unwrap_or_else(|| Duration::from_millis(200))
289}
290
291fn command_line_for_display(
292    idx: usize,
293    commands: &[String],
294    highlighted_commands: &[Option<Line<'static>>],
295    style: TuiStyle,
296) -> Line<'static> {
297    if let Some(Some(line)) = highlighted_commands.get(idx) {
298        return line.clone();
299    }
300
301    Line::from(Span::styled(
302        commands.get(idx).cloned().unwrap_or_default(),
303        style,
304    ))
305}
306
307fn warm_visible_highlights(
308    commands: &[String],
309    highlighted_commands: &mut [Option<Line<'static>>],
310    visible_indices: &[usize],
311    budget: Duration,
312) -> bool {
313    let started = Instant::now();
314    let mut warmed_any = false;
315    for &idx in visible_indices {
316        let Some(command) = commands.get(idx) else {
317            continue;
318        };
319        let Some(slot) = highlighted_commands.get_mut(idx) else {
320            continue;
321        };
322        if slot.is_some() {
323            continue;
324        }
325
326        *slot = Some(highlight_command(command));
327        warmed_any = true;
328        if started.elapsed() >= budget {
329            break;
330        }
331    }
332    warmed_any
333}
334
335fn extract_variables(command: &str) -> Vec<String> {
336    extract_variables_for_display(command)
337}
338
339#[derive(Clone, Debug)]
340struct SnippetPreview {
341    content: String,
342}
343
344impl SnippetPreview {
345    fn from_command(command: &str) -> Self {
346        let vars = extract_variables(command);
347        let has_unmatched = has_unmatched_angle_bracket(command);
348        let content = if !vars.is_empty() || has_unmatched {
349            let mut content = format!(
350                "{}\n\nVars: {}",
351                strip_escape_sequences(command),
352                vars.join(", ")
353            );
354            if has_unmatched {
355                content.push_str("\n\nWarning: unmatched '<' found - will be treated as literal");
356            }
357            content
358        } else {
359            strip_escape_sequences(command)
360        };
361
362        Self { content }
363    }
364}
365
366fn ensure_theme_picker_ready(
367    manager: &mut Option<theme::ThemeManager>,
368    list: &mut Vec<theme::ThemeInfo>,
369    filtered: &mut Vec<usize>,
370    active_idx: &mut usize,
371) -> io::Result<()> {
372    if manager.is_some() {
373        return Ok(());
374    }
375    let mut mgr =
376        theme::ThemeManager::new().map_err(|e| io::Error::other(format!("theme manager: {e}")))?;
377    mgr.init_themes_dir()
378        .map_err(|e| io::Error::other(format!("init themes: {e}")))?;
379    *list = mgr
380        .list_themes()
381        .map_err(|e| io::Error::other(format!("list themes: {e}")))?;
382    *filtered = (0..list.len()).collect();
383    *active_idx = mgr
384        .get_active_theme_name()
385        .as_ref()
386        .and_then(|n| list.iter().position(|t| &t.name == n))
387        .unwrap_or(0);
388    *manager = Some(mgr);
389    Ok(())
390}
391
392fn apply_theme_from_picker(
393    manager: &Option<theme::ThemeManager>,
394    list: &[theme::ThemeInfo],
395    filtered: &[usize],
396    active_idx: usize,
397) -> bool {
398    let Some(mgr) = manager else { return false };
399    let Some(&idx) = filtered.get(active_idx) else {
400        return false;
401    };
402    let Some(info) = list.get(idx) else {
403        return false;
404    };
405    match mgr.load_theme(&info.name) {
406        Ok(t) => {
407            theme::set_active_theme(t);
408            true
409        }
410        Err(e) => {
411            tracing::warn!("failed to load theme {}: {e}", info.name);
412            false
413        }
414    }
415}
416
417fn commit_theme_picker(
418    manager: &mut Option<theme::ThemeManager>,
419    list: &[theme::ThemeInfo],
420    filtered: &[usize],
421    active_idx: usize,
422) -> io::Result<()> {
423    if let Some(mgr) = manager.as_mut()
424        && let Some(&idx) = filtered.get(active_idx)
425        && let Some(info) = list.get(idx)
426    {
427        mgr.set_active_theme(&info.name)
428            .map_err(|e| io::Error::other(format!("save theme: {e}")))?;
429    }
430    Ok(())
431}
432
433fn cancel_theme_picker(manager: &Option<theme::ThemeManager>, original: Option<theme::Theme>) {
434    // Restore the in-memory theme that was active before the picker opened.
435    // We snapshot the actual `Theme` (not the persisted name) so cancellation
436    // works even when no theme has ever been saved to `themes.toml`.
437    if let Some(t) = original {
438        theme::set_active_theme(t);
439        return;
440    }
441    // Fallback: if the original was never captured (shouldn't happen in the
442    // real code path), try to restore from the persisted name.
443    if let Some(mgr) = manager
444        && let Some(name) = mgr.get_active_theme_name()
445        && let Ok(t) = mgr.load_theme(&name)
446    {
447        theme::set_active_theme(t);
448    }
449}
450
451pub struct SnippetListParams<'a> {
452    pub descriptions: &'a [String],
453    pub commands: &'a [String],
454    pub tags: &'a [Vec<String>],
455    pub is_search: bool,
456    pub initial_filter: Option<&'a str>,
457    pub folders: &'a [Vec<String>],
458    pub favorites: &'a [bool],
459    pub snippets: &'a [crate::library::Snippet],
460    pub original_indices: &'a [usize],
461}
462
463/// Action returned by the snippet selector after the user leaves the TUI.
464pub enum SnippetSelection {
465    /// Select a snippet for the command-specific action.
466    Selected(usize, Option<String>),
467    /// Delete a snippet after the user confirmed the delete dialog.
468    Delete(usize),
469}
470
471pub fn select_snippet(params: SnippetListParams) -> io::Result<Option<SnippetSelection>> {
472    select_snippet_inner(params)
473}
474
475#[allow(clippy::collapsible_match)]
476fn select_snippet_inner(params: SnippetListParams) -> io::Result<Option<SnippetSelection>> {
477    let SnippetListParams {
478        descriptions,
479        commands,
480        tags,
481        is_search,
482        initial_filter,
483        folders: _folders,
484        favorites,
485        snippets,
486        original_indices: _original_indices,
487    } = params;
488    // Enable mouse capture before initializing terminal
489    // Gracefully degrade if mouse capture is not supported (e.g., headless SSH)
490    if let Err(e) = crossterm::execute!(std::io::stdout(), crossterm::event::EnableMouseCapture) {
491        tracing::warn!(
492            "Mouse capture not available, continuing without mouse support: {}",
493            e
494        );
495    }
496    let mut terminal = ratatui::init();
497    let _guard = TerminalGuard; // Ensures terminal is restored on any exit path
498
499    // Highlight commands lazily so large libraries do not pay the full syntax
500    // highlighting cost before the first frame is shown.
501    let mut highlighted_commands: Vec<Option<Line<'static>>> = vec![None; commands.len()];
502    let mut snippet_previews: Vec<Option<SnippetPreview>> = vec![None; commands.len()];
503
504    let mut sel = SelectState::new();
505    let mut filter = initial_filter.map(String::from).unwrap_or_default();
506    let mut incremental_search = String::new();
507    // input_text tracks what user types in insert mode - displayed in filter input box, NOT in title bar
508    let mut input_text = String::new();
509    let mut filter_state = FilterState::default();
510    let mut filtered: Vec<usize> = (0..descriptions.len()).collect();
511    let mut insert_mode = true;
512    let mut tag_filter_mode = false;
513    let mut list_display_mode = 0;
514    let mut should_copy: Option<String> = None;
515    let mut copied_message: Option<(String, std::time::Instant)> = None;
516    let mut delete_confirmation: Option<usize> = None;
517    let mut visual_mode = false;
518    let mut visual_start = 0usize;
519    let mut visual_end = 0usize;
520    let mut pending_gg = false;
521    let mut pending_gg_time: Option<std::time::Instant> = None;
522    const GG_TIMEOUT_MS: u64 = 500;
523
524    // Theme picker state
525    let mut theme_picker_mode = false;
526    let mut theme_picker_insert_mode = false;
527    let mut theme_filter = String::new();
528    let mut theme_input_text = String::new();
529    let mut theme_filtered: Vec<usize> = Vec::new();
530    let mut theme_dirty = false;
531    let mut theme_active_idx: usize = 0;
532    let mut theme_picker_manager: Option<theme::ThemeManager> = None;
533    let mut theme_picker_list: Vec<theme::ThemeInfo> = Vec::new();
534    // Snapshot of the in-memory theme captured when the picker opens, so
535    // `cancel_theme_picker` can restore the user's previewed-from state
536    // unconditionally (even if no theme was ever persisted to `themes.toml`).
537    let mut theme_picker_original: Option<theme::Theme> = None;
538    let mut pending_rehighlight = false;
539    let mut last_theme_update: Option<std::time::Instant> = None;
540    const THEME_DEBOUNCE_MS: u64 = 100;
541
542    // Debounce filter updates to avoid fuzzy matching on every keystroke
543    let mut filter_dirty = !filter.is_empty();
544    let mut last_filter_update: Option<std::time::Instant> = None;
545    const FILTER_DEBOUNCE_MS: u64 = 35;
546    const HIGHLIGHT_WARM_BUDGET: Duration = Duration::from_millis(2);
547
548    // Mouse double-click tracking
549    let mut last_click_row: Option<u16> = None;
550    let mut last_click_time: Option<std::time::Instant> = None;
551    const DOUBLE_CLICK_DURATION_MS: u64 = 500;
552
553    let all_display: Vec<String> = descriptions
554        .iter()
555        .enumerate()
556        .map(|(i, _)| format!("[{}]: {}", descriptions[i], commands[i]))
557        .collect();
558    let all_display_lower: Vec<String> = all_display.iter().map(|d| d.to_lowercase()).collect();
559    let all_tags_search: Vec<String> = tags
560        .iter()
561        .map(|snippet_tags| snippet_tags.join("\n").to_lowercase())
562        .collect();
563    let mut filter_candidates: Vec<(usize, Option<i64>)> =
564        (0..all_display.len()).map(|i| (i, None)).collect();
565    let mut last_filter_request: Option<FilterRequest> = None;
566    let mut needs_redraw = true;
567    sel.update(filtered.len());
568
569    loop {
570        // Check for signal-induced termination (SIGINT/SIGTERM)
571        if TERMINATE.load(std::sync::atomic::Ordering::SeqCst) {
572            break;
573        }
574
575        // Lazy init theme picker
576        if theme_picker_mode && theme_picker_manager.is_none() {
577            if let Err(e) = ensure_theme_picker_ready(
578                &mut theme_picker_manager,
579                &mut theme_picker_list,
580                &mut theme_filtered,
581                &mut theme_active_idx,
582            ) {
583                tracing::warn!("theme picker init failed: {e}");
584                theme_picker_mode = false;
585                theme_picker_insert_mode = false;
586                theme_filter.clear();
587                theme_input_text.clear();
588                theme_dirty = false;
589                needs_redraw = true;
590            } else {
591                // Snapshot the current in-memory theme so cancel can restore it
592                // even when no theme is persisted to `themes.toml`.
593                theme_picker_original = Some(theme::get_theme());
594                if apply_theme_from_picker(
595                    &theme_picker_manager,
596                    &theme_picker_list,
597                    &theme_filtered,
598                    theme_active_idx,
599                ) {
600                    pending_rehighlight = true;
601                }
602                needs_redraw = true;
603            }
604        }
605
606        // Re-highlight on theme change
607        if pending_rehighlight {
608            highlighted_commands.fill_with(|| None);
609            pending_rehighlight = false;
610            needs_redraw = true;
611        }
612
613        // Debounced theme filter rebuild
614        let theme_debounce_elapsed = debounce_elapsed(last_theme_update, THEME_DEBOUNCE_MS);
615        if theme_dirty && theme_debounce_elapsed {
616            theme_filtered = if theme_filter.is_empty() {
617                (0..theme_picker_list.len()).collect()
618            } else {
619                theme_picker_list
620                    .iter()
621                    .enumerate()
622                    .filter_map(|(i, t)| {
623                        if MATCHER.fuzzy_match(&t.name, &theme_filter).is_some() {
624                            Some(i)
625                        } else {
626                            None
627                        }
628                    })
629                    .collect()
630            };
631            if theme_active_idx >= theme_filtered.len() {
632                theme_active_idx = theme_filtered.len().saturating_sub(1);
633            }
634            theme_dirty = false;
635            last_theme_update = None;
636            needs_redraw = true;
637        }
638
639        // Debounce: only recompute filtered list if enough time has passed since last filter change
640        let debounce_elapsed = debounce_elapsed(last_filter_update, FILTER_DEBOUNCE_MS);
641        let should_recompute = if filter_dirty {
642            // Always recompute immediately when filter becomes empty (backspace cleared filter)
643            // to avoid showing stale filtered results after the user clears input.
644            let filter_is_empty = filter.is_empty() && filter_state.tag_filter_text.is_empty();
645            if filter_is_empty || debounce_elapsed {
646                filter_dirty = false;
647                true
648            } else {
649                // Filter changed but debounce window not elapsed, skip this frame
650                false
651            }
652        } else {
653            // No filter change, use previous results
654            false
655        };
656
657        if should_recompute {
658            let filter_request = current_filter_request(
659                &filter,
660                &incremental_search,
661                &filter_state,
662                tag_filter_mode,
663            );
664            if last_filter_request.as_ref() != Some(&filter_request) {
665                if last_filter_request
666                    .as_ref()
667                    .is_some_and(|previous| filter_request.can_narrow_from(previous))
668                {
669                    rebuild_filter_candidates(
670                        &mut filter_candidates,
671                        filtered.iter().copied(),
672                        &filter_request,
673                        &all_display,
674                        &all_display_lower,
675                        &all_tags_search,
676                    );
677                } else {
678                    rebuild_filter_candidates(
679                        &mut filter_candidates,
680                        0..all_display.len(),
681                        &filter_request,
682                        &all_display,
683                        &all_display_lower,
684                        &all_tags_search,
685                    );
686                }
687            }
688
689            let has_filter = !filter_request.is_empty();
690            sort_filtered_indices(
691                &mut filter_candidates,
692                &filter_state,
693                snippets,
694                &all_display_lower,
695                has_filter,
696            );
697
698            filtered.clear();
699            filtered.extend(filter_candidates.iter().map(|(i, _)| *i));
700            last_filter_request = Some(filter_request);
701
702            sel.update(filtered.len());
703            needs_redraw = true;
704        } else {
705            sel.update(filtered.len());
706        } // end filter recompute
707
708        let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
709        let terminal_size = ratatui::layout::Rect::new(0, 0, cols, rows);
710        let list_area = {
711            let chunks = Layout::default()
712                .direction(Direction::Vertical)
713                .constraints([
714                    Constraint::Length(3),
715                    Constraint::Min(3),
716                    Constraint::Length(6),
717                    Constraint::Length(1),
718                ])
719                .split(terminal_size);
720            chunks[1]
721        };
722        let list_visible_rows = list_area.height.saturating_sub(2) as usize;
723        let list_offset = if list_visible_rows == 0 {
724            0
725        } else {
726            sel.selected
727                .saturating_sub(list_visible_rows.saturating_sub(1))
728        };
729        let list_end = (list_offset + list_visible_rows).min(filtered.len());
730        if needs_redraw {
731            needs_redraw = false;
732
733            // Filter indicator in title bar - ONLY shows incremental search (/), NOT the main filter.
734            // Main filter text is displayed in the filter input box below, not in the title.
735            // This ensures the input field position remains stable and text appears in the correct location.
736            let filter_indicator = if tag_filter_mode {
737                format!("[tag: {}]", filter_state.tag_filter_text)
738            } else if !insert_mode && !incremental_search.is_empty() {
739                format!("/{incremental_search}")
740            } else {
741                String::new()
742            };
743
744            let sort_indicator = match filter_state.sort_mode {
745                SortMode::None => String::new(),
746                SortMode::Newest => "[new]".to_string(),
747                SortMode::Oldest => "[old]".to_string(),
748                SortMode::AlphaAsc => "[a-z]".to_string(),
749                SortMode::AlphaDesc => "[z-a]".to_string(),
750            };
751
752            let draw_result = terminal.draw(|f| {
753                let size = f.area();
754                if size.width < 10 || size.height < 10 {
755                    let error_msg = "Terminal too small - resize to at least 10x10";
756                    let paragraph = Paragraph::new(error_msg)
757                        .centered()
758                        .block(Block::default().title("Error").borders(Borders::ALL));
759                    f.render_widget(paragraph, size);
760                    return;
761                }
762                {
763            let picker_mode = theme_picker_mode;
764            let picker_ins = theme_picker_insert_mode;
765            let picker_filter_text: &str = if picker_ins {
766                &theme_input_text
767            } else {
768                &theme_filter
769            };
770            let count = if picker_mode {
771                theme_filtered.len()
772            } else {
773                filtered.len()
774            };
775            let title_part = if picker_mode {
776                let active_name = theme_picker_manager
777                    .as_ref()
778                    .and_then(|m| m.get_active_theme_name())
779                    .unwrap_or_default();
780                let active_str = if active_name.is_empty() {
781                    String::new()
782                } else {
783                    format!(" [{active_name}]")
784                };
785                format!("Themes [{count}]{active_str} {filter_indicator}")
786            } else {
787                format!("Snippets [{count}] {filter_indicator}{sort_indicator}")
788            };
789            let separator = "─".repeat((size.width as usize).saturating_sub(title_part.len() + 8));
790            let theme = get_theme();
791            let title = Line::from(vec![
792                Span::styled(title_part.clone(), style_fg(theme.secondary)),
793                Span::raw(" "),
794                Span::styled(separator, style_fg(theme.border)),
795            ]);
796            let block = Block::default()
797                .title(title)
798                .borders(Borders::ALL)
799                .border_style(style_fg(theme.border))
800                .style(TuiStyle::default().bg(theme.background));
801            let input_block_title = if picker_mode {
802                "Theme Filter"
803            } else if tag_filter_mode {
804                "Tag Filter"
805            } else {
806                "Filter"
807            };
808            let input_block = Block::default()
809                .title(input_block_title)
810                .borders(Borders::ALL)
811                .border_style(style_fg(theme.border))
812                .title_style(style_fg(theme.secondary))
813                .style(TuiStyle::default().bg(theme.background));
814            let chunks = Layout::default()
815                .direction(Direction::Vertical)
816                .constraints([
817                    Constraint::Length(3),
818                    Constraint::Min(3),
819                    Constraint::Length(6),
820                    Constraint::Length(1),
821                ])
822                .split(size);
823
824            f.render_widget(input_block, chunks[0]);
825            // Filter text displayed in the filter input box (NOT in title bar).
826            // Insert mode: show input_text (what user is typing)
827            // Normal mode: show filter (applied filter)
828            // Tag filter mode: show tag_filter_text
829            // Picker mode: show theme_input_text in INS or theme_filter in NOR
830            let filter_text: &str = if picker_mode {
831                picker_filter_text
832            } else if tag_filter_mode {
833                if filter_state.tag_filter_text.is_empty() {
834                    ""
835                } else {
836                    &filter_state.tag_filter_text
837                }
838            } else if insert_mode {
839                &input_text
840            } else {
841                &filter
842            };
843            let filter_widget = Paragraph::new(filter_text)
844                .style(style_fg_bg(theme.text, theme.background));
845            f.render_widget(
846                filter_widget,
847                ratatui::layout::Rect::new(
848                    chunks[0].x + 1,
849                    chunks[0].y + 1,
850                    chunks[0].width - 2,
851                    1,
852                ),
853            );
854
855            if (picker_mode && picker_ins) || (!picker_mode && insert_mode) {
856                use unicode_width::UnicodeWidthStr;
857                let cursor_text: &str = if picker_mode {
858                    &theme_input_text
859                } else {
860                    &input_text
861                };
862                let cursor_x = chunks[0].x
863                    + 1
864                    + cursor_text.width().min(u16::MAX as usize) as u16;
865                let cursor_y = chunks[0].y + 1;
866                f.set_cursor_position((cursor_x, cursor_y));
867            }
868
869            if picker_mode {
870                // Theme picker list
871                let saved_name = theme_picker_manager
872                    .as_ref()
873                    .and_then(|m| m.get_active_theme_name());
874                let picker_items: Vec<ListItem> = theme_filtered
875                    .iter()
876                    .filter_map(|&idx| theme_picker_list.get(idx))
877                    .map(|info| {
878                        let is_active = saved_name.as_deref() == Some(info.name.as_str());
879                        let prefix = if is_active { "★ " } else { "  " };
880                        let style = if is_active {
881                            style_fg(theme.accent)
882                        } else {
883                            style_fg(theme.text)
884                        };
885                        let line = Line::from(vec![
886                            ratatui::text::Span::raw(prefix),
887                            ratatui::text::Span::styled(info.name.clone(), style),
888                        ]);
889                        ListItem::new(line)
890                    })
891                    .collect();
892                let picker_list = List::new(picker_items)
893                    .block(block)
894                    .style(TuiStyle::default().bg(theme.background))
895                    .highlight_style(
896                        ratatui::style::Style::default()
897                            .fg(theme.text)
898                            .bg(theme.selected_bg),
899                    )
900                    .highlight_symbol("â–¶ ");
901                let mut picker_list_state = ratatui::widgets::ListState::default();
902                picker_list_state.select(Some(theme_active_idx));
903                f.render_stateful_widget(picker_list, chunks[1], &mut picker_list_state);
904                let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
905                    .thumb_style(ratatui::style::Style::default().bg(theme.secondary));
906                let mut picker_scroll_state = ratatui::widgets::ScrollbarState::default()
907                    .content_length(theme_filtered.len())
908                    .position(theme_active_idx);
909                f.render_stateful_widget(scrollbar, chunks[1], &mut picker_scroll_state);
910
911                // Theme swatch preview
912                let preview_name = theme_filtered
913                    .get(theme_active_idx)
914                    .and_then(|&idx| theme_picker_list.get(idx))
915                    .map(|info| info.name.clone())
916                    .unwrap_or_else(|| "(no selection)".to_string());
917                let is_saved_active = saved_name.as_deref() == Some(preview_name.as_str());
918                let preview_title = format!("Preview: {preview_name}");
919                let preview_block = Block::default()
920                    .title(preview_title)
921                    .borders(Borders::ALL)
922                    .border_style(style_fg(theme.border))
923                    .title_style(style_fg(theme.secondary))
924                    .style(TuiStyle::default().bg(theme.background));
925
926                let swatch = |c: ratatui::style::Color| {
927                    ratatui::text::Span::styled("███", style_fg(c))
928                };
929                let label = |s: &str| -> ratatui::text::Span<'static> {
930                    ratatui::text::Span::styled(s.to_string(), style_fg(theme.text))
931                };
932                let dim = |s: &str| -> ratatui::text::Span<'static> {
933                    ratatui::text::Span::styled(s.to_string(), style_fg(theme.muted))
934                };
935
936                let mut preview_spans: Vec<ratatui::text::Span<'static>> = vec![
937                    swatch(theme.primary),
938                    label(" primary  "),
939                    swatch(theme.secondary),
940                    label(" tertiary  "),
941                    swatch(theme.accent),
942                    label(" accent"),
943                    ratatui::text::Span::raw("\n"),
944                    label("Background: "),
945                    swatch(theme.background),
946                    ratatui::text::Span::raw("\n"),
947                    label("> Selected: "),
948                    swatch(theme.selected_bg),
949                    ratatui::text::Span::raw("\n"),
950                    label("Text: "),
951                    swatch(theme.text),
952                    label("  Border: "),
953                    swatch(theme.border),
954                    label("  Muted: "),
955                    swatch(theme.muted),
956                    ratatui::text::Span::raw("\n"),
957                    label("String literal: "),
958                    swatch(theme.string_color),
959                    label("  Escape: "),
960                    swatch(theme.escape_color),
961                ];
962                if is_saved_active {
963                    preview_spans.push(ratatui::text::Span::raw("\n"));
964                    preview_spans.push(dim("(this is your saved active theme)"));
965                }
966
967                let preview_widget = Paragraph::new(Line::from(preview_spans))
968                    .block(preview_block)
969                    .style(style_fg_bg(theme.text, theme.background));
970                f.render_widget(preview_widget, chunks[2]);
971            } else {
972                // Snippet list (existing rendering)
973                let visible_filtered = &filtered[list_offset..list_end];
974                let items: Vec<ListItem> = visible_filtered
975                    .iter()
976                    .map(|idx| {
977                        let is_fav = *favorites.get(*idx).unwrap_or(&false);
978                        let fav_indicator = if is_fav { "★ " } else { "  " };
979
980                        let line = if list_display_mode == 1 {
981                            let desc = &descriptions[*idx];
982                            let cmd_spans = command_line_for_display(
983                                *idx,
984                                commands,
985                                &highlighted_commands,
986                                style_fg(theme.text),
987                            )
988                            .spans;
989
990                            let mut combined: Vec<ratatui::text::Span<'static>> =
991                                vec![ratatui::text::Span::styled(
992                                    format!("[{desc}] "),
993                                    style_fg(theme.text),
994                                )];
995                            combined.extend(cmd_spans);
996                            Line::from(combined)
997                        } else {
998                            command_line_for_display(
999                                *idx,
1000                                commands,
1001                                &highlighted_commands,
1002                                style_fg(theme.text),
1003                            )
1004                        };
1005
1006                        let mut spans = vec![ratatui::text::Span::raw(fav_indicator)];
1007                        spans.extend(line.spans);
1008                        let final_line = Line::from(spans);
1009
1010                        ListItem::new(final_line)
1011                    })
1012                    .collect();
1013                let list = List::new(items)
1014                    .block(block)
1015                    .style(TuiStyle::default().bg(theme.background))
1016                    .highlight_style(
1017                        ratatui::style::Style::default()
1018                            .fg(theme.text)
1019                            .bg(theme.selected_bg),
1020                    )
1021                    .highlight_symbol(if is_search { "" } else { "â–¶ " });
1022
1023                let mut visible_list_state = ratatui::widgets::ListState::default();
1024                visible_list_state.select(sel.selected.checked_sub(list_offset));
1025                f.render_stateful_widget(list, chunks[1], &mut visible_list_state);
1026                let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
1027                    .thumb_style(ratatui::style::Style::default().bg(theme.secondary));
1028                f.render_stateful_widget(scrollbar, chunks[1], &mut sel.scroll_state);
1029
1030                if sel.selected < filtered.len() {
1031                    let idx = filtered[sel.selected];
1032                    let snippet_cmd = &commands[idx];
1033                    let snippet_desc = &descriptions[idx];
1034
1035                    let preview_title = format!("Preview: {snippet_desc}");
1036                    let preview_block = Block::default()
1037                        .title(preview_title)
1038                        .borders(Borders::ALL)
1039                        .border_style(style_fg(theme.border))
1040                        .title_style(style_fg(theme.secondary))
1041                        .style(TuiStyle::default().bg(theme.background));
1042
1043                    let preview = snippet_previews
1044                        .get_mut(idx)
1045                        .and_then(|slot| {
1046                            if slot.is_none() {
1047                                *slot = Some(SnippetPreview::from_command(snippet_cmd));
1048                            }
1049                            slot.as_ref()
1050                        });
1051                    let preview_content = preview
1052                        .map(|preview| preview.content.as_str())
1053                        .unwrap_or(snippet_cmd.as_str());
1054
1055                    let preview_widget = Paragraph::new(preview_content)
1056                        .block(preview_block)
1057                        .style(style_fg_bg(theme.text, theme.background));
1058                    f.render_widget(preview_widget, chunks[2]);
1059                }
1060            }
1061
1062            let copied_desc = copied_message.as_ref().map(|(desc, _)| desc.clone());
1063            let mode_str = if insert_mode { "INS" } else { "NOR" };
1064            let tag_mode_str = if tag_filter_mode { " TAG" } else { "" };
1065
1066            let status_text: String = if picker_mode {
1067                if picker_ins {
1068                    "[THEME-INS] | esc: leave ins | enter: apply | up/down: navigate".to_string()
1069                } else {
1070                    "[THEME-NOR] | i: filter | enter: apply | up/down: preview | e/q: back"
1071                        .to_string()
1072                }
1073            } else if let Some(desc) = copied_desc {
1074                format!("[{mode_str}]{tag_mode_str} | {desc}")
1075            } else if is_search {
1076                if insert_mode {
1077                    format!(
1078                        "[{mode_str}]{tag_mode_str} | esc: normal | /: search | ctrl+u/d : page | n/o/a/z: sort | t: tags | x/c: clear | tab: mode"
1079                    )
1080                } else {
1081                    format!(
1082                        "[{mode_str}]{tag_mode_str} | i: insert | y/ctrl+c: copy | d: delete | ctrl+u/d : page | n/o/a/z: sort | t: tags | q: quit | x/c: clear | tab: mode"
1083                    )
1084                }
1085            } else if insert_mode {
1086                format!(
1087                    "[{mode_str}]{tag_mode_str} | esc: normal | enter: run | ctrl+u/d : page | n/o/a/z: sort | t: tags | x/c: clear | tab: mode"
1088                )
1089            } else {
1090                format!(
1091                    "[{mode_str}]{tag_mode_str} | i: insert | y: copy | d: delete | ctrl+u/d : page | n/o/a/z: sort | t: tags | q: quit | x/c: clear | tab: mode | double-click: run"
1092                )
1093            };
1094            let status_widget = Paragraph::new(status_text)
1095                .style(style_fg(theme.muted));
1096            let status_area = ratatui::layout::Rect::new(
1097                chunks[3].x + 1,
1098                chunks[3].y,
1099                chunks[3].width - 2,
1100                1,
1101            );
1102            f.render_widget(status_widget, status_area);
1103
1104            if let Some(idx) = delete_confirmation {
1105                let popup_width = size.width.saturating_sub(4).min(70);
1106                let popup_height = 7.min(size.height.saturating_sub(2));
1107                let popup_area = ratatui::layout::Rect::new(
1108                    size.x + (size.width.saturating_sub(popup_width)) / 2,
1109                    size.y + (size.height.saturating_sub(popup_height)) / 2,
1110                    popup_width,
1111                    popup_height,
1112                );
1113                let description = descriptions
1114                    .get(idx)
1115                    .map(String::as_str)
1116                    .unwrap_or("selected snippet");
1117                let confirmation = Paragraph::new(vec![
1118                    Line::from("Delete selected snippet?"),
1119                    Line::from(format!("\"{description}\"")),
1120                    Line::from(""),
1121                    Line::from("[y] yes   [n] no"),
1122                ])
1123                .centered()
1124                .block(
1125                    Block::default()
1126                        .title("Confirm delete")
1127                        .borders(Borders::ALL)
1128                        .border_style(style_fg(theme.accent))
1129                        .style(TuiStyle::default().bg(theme.background)),
1130                );
1131                f.render_widget(Clear, popup_area);
1132                f.render_widget(confirmation, popup_area);
1133            }
1134                }
1135            });
1136            if let Err(e) = draw_result {
1137                tracing::warn!("Terminal draw error: {}", e);
1138            }
1139
1140            if !theme_picker_mode
1141                && !event::poll(Duration::from_millis(0)).unwrap_or(false)
1142                && list_offset < list_end
1143                && warm_visible_highlights(
1144                    commands,
1145                    &mut highlighted_commands,
1146                    &filtered[list_offset..list_end],
1147                    HIGHLIGHT_WARM_BUDGET,
1148                )
1149            {
1150                needs_redraw = true;
1151            }
1152
1153            if needs_redraw {
1154                continue;
1155            }
1156        }
1157
1158        let poll_timeout = next_event_poll_timeout(
1159            filter_dirty,
1160            last_filter_update,
1161            FILTER_DEBOUNCE_MS,
1162            theme_dirty,
1163            last_theme_update,
1164            THEME_DEBOUNCE_MS,
1165        );
1166        let polled = event::poll(poll_timeout).unwrap_or(false);
1167        if polled {
1168            match event::read() {
1169                Ok(CEvent::Mouse(mouse_event)) => {
1170                    needs_redraw = true;
1171                    if !theme_picker_mode {
1172                        // Check for scroll events
1173                        if mouse_event.kind == crossterm::event::MouseEventKind::ScrollDown {
1174                            sel.move_down(filtered.len());
1175                        } else if mouse_event.kind == crossterm::event::MouseEventKind::ScrollUp {
1176                            sel.move_up();
1177                        }
1178                        // Handle click to select in list area
1179                        else if let crossterm::event::MouseEventKind::Up(
1180                            crossterm::event::MouseButton::Left,
1181                        ) = mouse_event.kind
1182                        {
1183                            let list_inner_y = list_area.y.saturating_add(1);
1184                            let list_inner_height = list_area.height.saturating_sub(2);
1185                            if mouse_event.row >= list_inner_y
1186                                && mouse_event.row < list_inner_y + list_inner_height
1187                                && mouse_event.row
1188                                    < list_inner_y
1189                                        + (list_end - list_offset).min(list_inner_height as usize)
1190                                            as u16
1191                            {
1192                                let clicked_row =
1193                                    list_offset + (mouse_event.row - list_inner_y) as usize;
1194
1195                                // Check for double-click (same row within time window)
1196                                let now = std::time::Instant::now();
1197                                let is_double_click = last_click_row == Some(mouse_event.row)
1198                                    && last_click_time
1199                                        .map(|t| {
1200                                            now.duration_since(t).as_millis()
1201                                                < DOUBLE_CLICK_DURATION_MS as u128
1202                                        })
1203                                        .unwrap_or(false);
1204
1205                                if is_double_click {
1206                                    // Double-click: run selected snippet
1207                                    break;
1208                                } else {
1209                                    // Single click: select item
1210                                    sel.selected = clicked_row;
1211                                    last_click_row = Some(mouse_event.row);
1212                                    last_click_time = Some(now);
1213                                }
1214                            } else {
1215                                // Clicked outside list, reset double-click state
1216                                last_click_row = None;
1217                                last_click_time = None;
1218                            }
1219                        }
1220                        sel.update(filtered.len());
1221                    }
1222                }
1223                Ok(CEvent::Key(key)) => {
1224                    if key.kind == KeyEventKind::Press {
1225                        needs_redraw = true;
1226                        if let Some((_, instant)) = copied_message
1227                            && instant.elapsed().as_secs() >= 3
1228                        {
1229                            copied_message = None;
1230                        }
1231
1232                        if theme_picker_mode {
1233                            if theme_picker_insert_mode {
1234                                match key.code {
1235                                    KeyCode::Esc => {
1236                                        if !theme_input_text.is_empty() {
1237                                            theme_filter = theme_input_text.clone();
1238                                            theme_input_text.clear();
1239                                        }
1240                                        theme_picker_insert_mode = false;
1241                                    }
1242                                    KeyCode::Down | KeyCode::Char('j')
1243                                        if theme_active_idx + 1 < theme_filtered.len() =>
1244                                    {
1245                                        theme_active_idx += 1;
1246                                        if apply_theme_from_picker(
1247                                            &theme_picker_manager,
1248                                            &theme_picker_list,
1249                                            &theme_filtered,
1250                                            theme_active_idx,
1251                                        ) {
1252                                            pending_rehighlight = true;
1253                                        }
1254                                    }
1255                                    KeyCode::Up | KeyCode::Char('k') if theme_active_idx > 0 => {
1256                                        theme_active_idx -= 1;
1257                                        if apply_theme_from_picker(
1258                                            &theme_picker_manager,
1259                                            &theme_picker_list,
1260                                            &theme_filtered,
1261                                            theme_active_idx,
1262                                        ) {
1263                                            pending_rehighlight = true;
1264                                        }
1265                                    }
1266                                    KeyCode::Backspace => {
1267                                        if !theme_input_text.is_empty() {
1268                                            theme_input_text.pop();
1269                                            theme_filter = theme_input_text.clone();
1270                                            theme_dirty = true;
1271                                            last_theme_update = Some(std::time::Instant::now());
1272                                        }
1273                                    }
1274                                    KeyCode::Char(c) => {
1275                                        theme_input_text.push(c);
1276                                        theme_filter = theme_input_text.clone();
1277                                        theme_dirty = true;
1278                                        last_theme_update = Some(std::time::Instant::now());
1279                                    }
1280                                    KeyCode::Enter => {
1281                                        if let Err(e) = commit_theme_picker(
1282                                            &mut theme_picker_manager,
1283                                            &theme_picker_list,
1284                                            &theme_filtered,
1285                                            theme_active_idx,
1286                                        ) {
1287                                            tracing::warn!("commit theme failed: {e}");
1288                                        }
1289                                        theme_picker_mode = false;
1290                                        theme_picker_insert_mode = false;
1291                                        theme_filter.clear();
1292                                        theme_input_text.clear();
1293                                        theme_dirty = false;
1294                                        pending_rehighlight = true;
1295                                    }
1296                                    _ => {}
1297                                }
1298                            } else {
1299                                match key.code {
1300                                    KeyCode::Char('i') => {
1301                                        theme_picker_insert_mode = true;
1302                                        theme_input_text = theme_filter.clone();
1303                                    }
1304                                    KeyCode::Char('q') | KeyCode::Esc => {
1305                                        cancel_theme_picker(
1306                                            &theme_picker_manager,
1307                                            theme_picker_original,
1308                                        );
1309                                        theme_picker_mode = false;
1310                                        theme_picker_insert_mode = false;
1311                                        theme_filter.clear();
1312                                        theme_input_text.clear();
1313                                        theme_dirty = false;
1314                                        pending_rehighlight = true;
1315                                    }
1316                                    KeyCode::Char('e') => {
1317                                        cancel_theme_picker(
1318                                            &theme_picker_manager,
1319                                            theme_picker_original,
1320                                        );
1321                                        theme_picker_mode = false;
1322                                        theme_picker_insert_mode = false;
1323                                        theme_filter.clear();
1324                                        theme_input_text.clear();
1325                                        theme_dirty = false;
1326                                        pending_rehighlight = true;
1327                                    }
1328                                    KeyCode::Enter => {
1329                                        if let Err(e) = commit_theme_picker(
1330                                            &mut theme_picker_manager,
1331                                            &theme_picker_list,
1332                                            &theme_filtered,
1333                                            theme_active_idx,
1334                                        ) {
1335                                            tracing::warn!("commit theme failed: {e}");
1336                                        }
1337                                        theme_picker_mode = false;
1338                                        theme_picker_insert_mode = false;
1339                                        theme_filter.clear();
1340                                        theme_input_text.clear();
1341                                        theme_dirty = false;
1342                                        pending_rehighlight = true;
1343                                    }
1344                                    KeyCode::Char('j') | KeyCode::Down
1345                                        if theme_active_idx + 1 < theme_filtered.len() =>
1346                                    {
1347                                        theme_active_idx += 1;
1348                                        if apply_theme_from_picker(
1349                                            &theme_picker_manager,
1350                                            &theme_picker_list,
1351                                            &theme_filtered,
1352                                            theme_active_idx,
1353                                        ) {
1354                                            pending_rehighlight = true;
1355                                        }
1356                                    }
1357                                    KeyCode::Char('k') | KeyCode::Up if theme_active_idx > 0 => {
1358                                        theme_active_idx -= 1;
1359                                        if apply_theme_from_picker(
1360                                            &theme_picker_manager,
1361                                            &theme_picker_list,
1362                                            &theme_filtered,
1363                                            theme_active_idx,
1364                                        ) {
1365                                            pending_rehighlight = true;
1366                                        }
1367                                    }
1368                                    KeyCode::Char('g') if is_ctrl_key(&key, 'g') => {
1369                                        theme_active_idx = 0;
1370                                        if apply_theme_from_picker(
1371                                            &theme_picker_manager,
1372                                            &theme_picker_list,
1373                                            &theme_filtered,
1374                                            theme_active_idx,
1375                                        ) {
1376                                            pending_rehighlight = true;
1377                                        }
1378                                    }
1379                                    KeyCode::Char('G') => {
1380                                        theme_active_idx = theme_filtered.len().saturating_sub(1);
1381                                        if apply_theme_from_picker(
1382                                            &theme_picker_manager,
1383                                            &theme_picker_list,
1384                                            &theme_filtered,
1385                                            theme_active_idx,
1386                                        ) {
1387                                            pending_rehighlight = true;
1388                                        }
1389                                    }
1390                                    KeyCode::PageDown | KeyCode::Char('d')
1391                                        if is_ctrl_key(&key, 'd') =>
1392                                    {
1393                                        theme_active_idx = (theme_active_idx + 10)
1394                                            .min(theme_filtered.len().saturating_sub(1));
1395                                        if apply_theme_from_picker(
1396                                            &theme_picker_manager,
1397                                            &theme_picker_list,
1398                                            &theme_filtered,
1399                                            theme_active_idx,
1400                                        ) {
1401                                            pending_rehighlight = true;
1402                                        }
1403                                    }
1404                                    KeyCode::PageUp | KeyCode::Char('u')
1405                                        if is_ctrl_key(&key, 'u') =>
1406                                    {
1407                                        theme_active_idx = theme_active_idx.saturating_sub(10);
1408                                        if apply_theme_from_picker(
1409                                            &theme_picker_manager,
1410                                            &theme_picker_list,
1411                                            &theme_filtered,
1412                                            theme_active_idx,
1413                                        ) {
1414                                            pending_rehighlight = true;
1415                                        }
1416                                    }
1417                                    _ => {}
1418                                }
1419                            }
1420                            continue;
1421                        }
1422
1423                        if let Some(idx) = delete_confirmation.take() {
1424                            if key.code == KeyCode::Char('y')
1425                                && !key.modifiers.contains(KeyModifiers::CONTROL)
1426                            {
1427                                return Ok(Some(SnippetSelection::Delete(idx)));
1428                            }
1429                            continue;
1430                        }
1431
1432                        if is_ctrl_key(&key, 'c') && sel.selected < filtered.len() {
1433                            let idx = filtered[sel.selected];
1434                            should_copy = Some(descriptions[idx].clone());
1435                            if !is_search {
1436                                break;
1437                            }
1438                        }
1439
1440                        // Visual mode is a normal-mode command. In INSERT mode, `v` and
1441                        // `V` must remain ordinary printable characters.
1442                        if !insert_mode && key.code == KeyCode::Char('v') && !visual_mode {
1443                            visual_mode = true;
1444                            visual_start = sel.selected;
1445                            visual_end = sel.selected;
1446                            continue;
1447                        }
1448
1449                        if !insert_mode && key.code == KeyCode::Char('V') && !visual_mode {
1450                            visual_mode = true;
1451                            visual_start = sel.selected;
1452                            visual_end = filtered.len().saturating_sub(1);
1453                            sel.selected = visual_end;
1454                            continue;
1455                        }
1456
1457                        if key.code == KeyCode::Esc && visual_mode {
1458                            visual_mode = false;
1459                            continue;
1460                        }
1461
1462                        if visual_mode {
1463                            match key.code {
1464                                KeyCode::Char('j') | KeyCode::Down
1465                                    if sel.selected + 1 < filtered.len() =>
1466                                {
1467                                    if sel.selected < visual_end {
1468                                        sel.selected += 1;
1469                                    } else {
1470                                        visual_end = (sel.selected + 1)
1471                                            .min(filtered.len().saturating_sub(1));
1472                                        sel.selected = visual_end;
1473                                    }
1474                                }
1475                                KeyCode::Char('k') | KeyCode::Up if sel.selected > 0 => {
1476                                    if sel.selected > visual_start {
1477                                        sel.selected -= 1;
1478                                    } else if visual_start > 0 {
1479                                        visual_start -= 1;
1480                                        sel.selected = visual_start;
1481                                    }
1482                                }
1483                                KeyCode::Char('y') => {
1484                                    if filtered.is_empty() {
1485                                        visual_mode = false;
1486                                        if !is_search {
1487                                            break;
1488                                        }
1489                                        continue;
1490                                    }
1491                                    let start = std::cmp::min(visual_start, visual_end)
1492                                        .min(filtered.len().saturating_sub(1));
1493                                    let end = std::cmp::max(visual_start, visual_end)
1494                                        .min(filtered.len().saturating_sub(1));
1495                                    let selected_items: Vec<String> = filtered
1496                                        .iter()
1497                                        .skip(start)
1498                                        .take(end - start + 1)
1499                                        .map(|idx| strip_escape_sequences(&commands[*idx]))
1500                                        .collect();
1501                                    let copy_text = selected_items.join("\n");
1502                                    match clipboard::copy_to_clipboard_auto(&copy_text) {
1503                                        Ok(()) => {
1504                                            should_copy = Some(format!(
1505                                                "{} snippets copied",
1506                                                end - start + 1
1507                                            ));
1508                                        }
1509                                        Err(e) => {
1510                                            tracing::warn!("Clipboard copy failed: {}", e);
1511                                            copied_message = Some((
1512                                                format!("Copy failed: {e}"),
1513                                                std::time::Instant::now(),
1514                                            ));
1515                                        }
1516                                    }
1517                                    if let Some(idx) = filtered.get(start) {
1518                                        let original_idx = *idx;
1519                                        if original_idx < snippets.len()
1520                                            && let Err(e) = crate::logging::audit_log(
1521                                                "copy",
1522                                                &snippets[original_idx],
1523                                                None,
1524                                            )
1525                                        {
1526                                            tracing::debug!("Audit log write failed: {}", e);
1527                                        }
1528                                    }
1529                                    visual_mode = false;
1530                                    if !is_search {
1531                                        break;
1532                                    }
1533                                }
1534                                _ => {}
1535                            }
1536                            sel.update(filtered.len());
1537                            continue;
1538                        }
1539
1540                        if is_ctrl_key(&key, 'f')
1541                            || is_ctrl_key(&key, 'd')
1542                            || key.code == KeyCode::PageDown
1543                        {
1544                            sel.selected =
1545                                (sel.selected + 10).min(filtered.len().saturating_sub(1));
1546                        }
1547
1548                        if is_ctrl_key(&key, 'b')
1549                            || is_ctrl_key(&key, 'u')
1550                            || key.code == KeyCode::PageUp
1551                        {
1552                            sel.selected = sel.selected.saturating_sub(10);
1553                        }
1554
1555                        if insert_mode {
1556                            match key.code {
1557                                KeyCode::Esc => {
1558                                    if tag_filter_mode {
1559                                        tag_filter_mode = false;
1560                                    } else if !input_text.is_empty() {
1561                                        filter = input_text.clone();
1562                                        input_text.clear();
1563                                        insert_mode = false;
1564                                        filter_dirty = true;
1565                                        last_filter_update = Some(std::time::Instant::now());
1566                                    } else if !incremental_search.is_empty() {
1567                                        incremental_search.clear();
1568                                        insert_mode = false;
1569                                        filter_dirty = true;
1570                                        last_filter_update = Some(std::time::Instant::now());
1571                                    } else {
1572                                        // Clear filter when exiting insert mode with empty input
1573                                        // to avoid stale filter state confusing the user
1574                                        filter.clear();
1575                                        insert_mode = false;
1576                                        filter_dirty = true;
1577                                        last_filter_update = Some(std::time::Instant::now());
1578                                    }
1579                                }
1580                                KeyCode::Down if sel.selected + 1 < filtered.len() => {
1581                                    sel.move_down(filtered.len())
1582                                }
1583                                KeyCode::Up if sel.selected > 0 => sel.move_up(),
1584                                KeyCode::Enter => {
1585                                    break;
1586                                }
1587                                KeyCode::Backspace => {
1588                                    if tag_filter_mode {
1589                                        filter_state.tag_filter_text.pop();
1590                                        filter_dirty = true;
1591                                        last_filter_update = Some(std::time::Instant::now());
1592                                    } else if insert_mode {
1593                                        input_text.pop();
1594                                        filter.pop();
1595                                        filter_dirty = true;
1596                                        last_filter_update = Some(std::time::Instant::now());
1597                                    } else if !incremental_search.is_empty() {
1598                                        incremental_search.pop();
1599                                        filter_dirty = true;
1600                                        last_filter_update = Some(std::time::Instant::now());
1601                                    } else {
1602                                        filter.pop();
1603                                        filter_dirty = true;
1604                                        last_filter_update = Some(std::time::Instant::now());
1605                                    }
1606                                    if !filtered.is_empty() {
1607                                        sel.move_to_top();
1608                                    }
1609                                }
1610                                KeyCode::Char(c)
1611                                    if !key.modifiers.contains(KeyModifiers::CONTROL) =>
1612                                {
1613                                    if tag_filter_mode {
1614                                        filter_state.tag_filter_text.push(c);
1615                                        filter_dirty = true;
1616                                    } else if insert_mode {
1617                                        input_text.push(c);
1618                                        filter.push(c);
1619                                        filter_dirty = true;
1620                                    } else if !incremental_search.is_empty() {
1621                                        incremental_search.push(c);
1622                                        filter_dirty = true;
1623                                    } else {
1624                                        filter.push(c);
1625                                        filter_dirty = true;
1626                                    }
1627                                    last_filter_update = filter_update_deadline_for_insert(
1628                                        &filter,
1629                                        &incremental_search,
1630                                        &filter_state,
1631                                        tag_filter_mode,
1632                                        last_filter_request.as_ref(),
1633                                    );
1634                                    if !filtered.is_empty() {
1635                                        sel.move_to_top();
1636                                    }
1637                                }
1638                                KeyCode::Tab => {
1639                                    list_display_mode = (list_display_mode + 1) % 2;
1640                                }
1641                                _ => {}
1642                            }
1643                        } else {
1644                            if key.code != KeyCode::Char('g') && !is_ctrl_key(&key, 'g') {
1645                                pending_gg = false;
1646                            }
1647                            match key.code {
1648                                KeyCode::Char('q') => {
1649                                    sel.selected = filtered.len();
1650                                    break;
1651                                }
1652                                KeyCode::Enter => {
1653                                    // Run selected snippet (exit loop with selection)
1654                                    break;
1655                                }
1656                                KeyCode::Char('i') => {
1657                                    insert_mode = true;
1658                                    input_text = filter.clone();
1659                                }
1660                                KeyCode::Char('y') => {
1661                                    if sel.selected < filtered.len() {
1662                                        let idx = filtered[sel.selected];
1663                                        should_copy = Some(descriptions[idx].clone());
1664                                        if !is_search {
1665                                            break;
1666                                        }
1667                                    }
1668                                }
1669                                KeyCode::Char('d')
1670                                    if !key.modifiers.contains(KeyModifiers::CONTROL)
1671                                        && sel.selected < filtered.len() =>
1672                                {
1673                                    delete_confirmation = Some(filtered[sel.selected]);
1674                                }
1675                                KeyCode::Tab => {
1676                                    list_display_mode = (list_display_mode + 1) % 2;
1677                                }
1678                                KeyCode::Char('g') if is_ctrl_key(&key, 'g') => {
1679                                    sel.move_to_top();
1680                                    pending_gg = false;
1681                                }
1682                                KeyCode::Char('g') if pending_gg => {
1683                                    let now = std::time::Instant::now();
1684                                    let is_expired = pending_gg_time
1685                                        .map(|t| {
1686                                            now.duration_since(t).as_millis()
1687                                                > GG_TIMEOUT_MS as u128
1688                                        })
1689                                        .unwrap_or(true);
1690                                    if is_expired {
1691                                        pending_gg = true;
1692                                        pending_gg_time = Some(now);
1693                                    } else {
1694                                        sel.move_to_top();
1695                                        pending_gg = false;
1696                                    }
1697                                }
1698                                KeyCode::Char('g') => {
1699                                    pending_gg = true;
1700                                    pending_gg_time = Some(std::time::Instant::now());
1701                                }
1702                                KeyCode::Char('G') => sel.move_to_bottom(filtered.len()),
1703                                KeyCode::Char('h') | KeyCode::Left if sel.selected > 0 => {
1704                                    sel.move_up()
1705                                }
1706                                KeyCode::Char('j') | KeyCode::Down
1707                                    if sel.selected + 1 < filtered.len() =>
1708                                {
1709                                    sel.move_down(filtered.len())
1710                                }
1711                                KeyCode::Char('k') | KeyCode::Up if sel.selected > 0 => {
1712                                    sel.move_up()
1713                                }
1714                                KeyCode::Char('l') | KeyCode::Right
1715                                    if sel.selected + 1 < filtered.len() =>
1716                                {
1717                                    sel.move_down(filtered.len())
1718                                }
1719                                KeyCode::Char('n') => {
1720                                    filter_state.toggle_sort_new();
1721                                    filter_dirty = true;
1722                                    last_filter_update = None;
1723                                }
1724                                KeyCode::Char('o') => {
1725                                    filter_state.toggle_sort_old();
1726                                    filter_dirty = true;
1727                                    last_filter_update = None;
1728                                }
1729                                KeyCode::Char('a') => {
1730                                    filter_state.toggle_sort_alpha();
1731                                    filter_dirty = true;
1732                                    last_filter_update = None;
1733                                }
1734                                KeyCode::Char('z') => {
1735                                    filter_state.toggle_sort_alpha_rev();
1736                                    filter_dirty = true;
1737                                    last_filter_update = None;
1738                                }
1739                                KeyCode::Char('x') | KeyCode::Char('c') => {
1740                                    filter.clear();
1741                                    incremental_search.clear();
1742                                    filter_state.tag_filter_text.clear();
1743                                    tag_filter_mode = false;
1744                                    filter_dirty = true;
1745                                    last_filter_update = Some(std::time::Instant::now());
1746                                }
1747                                KeyCode::Char('t') => {
1748                                    tag_filter_mode = !tag_filter_mode;
1749                                    if tag_filter_mode {
1750                                        filter_state.tag_filter_text.clear();
1751                                        filter_dirty = true;
1752                                        last_filter_update = Some(std::time::Instant::now());
1753                                    }
1754                                }
1755                                KeyCode::Char('/') => {
1756                                    insert_mode = true;
1757                                    incremental_search.clear();
1758                                    input_text.clear();
1759                                    filter.clear();
1760                                    filter_dirty = true;
1761                                    last_filter_update = Some(std::time::Instant::now());
1762                                }
1763                                KeyCode::Esc => {
1764                                    // Esc no longer quits - use q or Ctrl+C instead
1765                                }
1766                                KeyCode::Char('e') => {
1767                                    if !theme_picker_mode {
1768                                        theme_picker_mode = true;
1769                                    }
1770                                }
1771                                _ => {}
1772                            }
1773                        }
1774                    }
1775                }
1776                Ok(CEvent::Resize(_, _)) => {
1777                    // Terminal resize - redraw will happen on next iteration
1778                    needs_redraw = true;
1779                }
1780                Ok(_) => {}
1781                Err(e) => {
1782                    tracing::warn!("Event read error: {}", e);
1783                }
1784            }
1785        }
1786    }
1787    Ok(if !filtered.is_empty() && sel.selected < filtered.len() {
1788        Some(SnippetSelection::Selected(
1789            filtered[sel.selected],
1790            should_copy,
1791        ))
1792    } else {
1793        None
1794    })
1795}
1796
1797#[cfg(test)]
1798mod tests {
1799    use super::*;
1800
1801    #[test]
1802    fn test_terminate_functionality() {
1803        let terminate = get_terminate();
1804        terminate.store(false, std::sync::atomic::Ordering::SeqCst);
1805        assert!(!terminate.load(std::sync::atomic::Ordering::SeqCst));
1806
1807        terminate.store(true, std::sync::atomic::Ordering::SeqCst);
1808        assert!(terminate.load(std::sync::atomic::Ordering::SeqCst));
1809    }
1810
1811    #[test]
1812    fn filter_request_detects_incremental_narrowing() {
1813        let previous = FilterRequest {
1814            text: "git".to_string(),
1815            text_lower: "git".to_string(),
1816            include_tags: false,
1817            incremental: false,
1818        };
1819        let current = FilterRequest {
1820            text: "git st".to_string(),
1821            text_lower: "git st".to_string(),
1822            include_tags: false,
1823            incremental: false,
1824        };
1825        assert!(current.can_narrow_from(&previous));
1826
1827        let changed_mode = FilterRequest {
1828            text: "git st".to_string(),
1829            text_lower: "git st".to_string(),
1830            include_tags: true,
1831            incremental: false,
1832        };
1833        assert!(!changed_mode.can_narrow_from(&previous));
1834    }
1835
1836    #[test]
1837    fn rebuild_filter_candidates_matches_display_and_tags() {
1838        let all_display = vec![
1839            "[status]: git status".to_string(),
1840            "[logs]: journalctl -u snip".to_string(),
1841            "[deploy]: ./release".to_string(),
1842        ];
1843        let all_display_lower: Vec<String> = all_display.iter().map(|d| d.to_lowercase()).collect();
1844        let all_tags_search = vec![
1845            "git".to_string(),
1846            "systemd".to_string(),
1847            "release".to_string(),
1848        ];
1849        let all_indices = [0, 1, 2];
1850        let mut candidates = Vec::new();
1851
1852        rebuild_filter_candidates(
1853            &mut candidates,
1854            all_indices.iter().copied(),
1855            &FilterRequest {
1856                text: "systemd".to_string(),
1857                text_lower: "systemd".to_string(),
1858                include_tags: true,
1859                incremental: false,
1860            },
1861            &all_display,
1862            &all_display_lower,
1863            &all_tags_search,
1864        );
1865
1866        assert_eq!(candidates, vec![(1, Some(i64::MAX))]);
1867    }
1868
1869    #[test]
1870    fn current_filter_request_prefers_incremental_search() {
1871        let mut filter_state = FilterState {
1872            sort_mode: SortMode::None,
1873            tag_filter_text: "tag".to_string(),
1874        };
1875
1876        let request = current_filter_request("main", "inc", &filter_state, true);
1877        assert_eq!(
1878            request,
1879            FilterRequest {
1880                text: "inc".to_string(),
1881                text_lower: "inc".to_string(),
1882                include_tags: false,
1883                incremental: true,
1884            }
1885        );
1886
1887        filter_state.tag_filter_text.clear();
1888        let request = current_filter_request("main", "", &filter_state, false);
1889        assert_eq!(
1890            request,
1891            FilterRequest {
1892                text: "main".to_string(),
1893                text_lower: "main".to_string(),
1894                include_tags: false,
1895                incremental: false,
1896            }
1897        );
1898    }
1899
1900    #[test]
1901    fn filter_request_would_narrow_detects_typing_extension() {
1902        let filter_state = FilterState::default();
1903        let previous = FilterRequest {
1904            text: "git".to_string(),
1905            text_lower: "git".to_string(),
1906            include_tags: false,
1907            incremental: false,
1908        };
1909
1910        assert!(filter_request_would_narrow(
1911            "git s",
1912            "",
1913            &filter_state,
1914            false,
1915            Some(&previous)
1916        ));
1917        assert!(!filter_request_would_narrow(
1918            "gi",
1919            "",
1920            &filter_state,
1921            false,
1922            Some(&previous)
1923        ));
1924    }
1925
1926    #[test]
1927    fn next_event_poll_timeout_uses_pending_debounce_deadline() {
1928        let last_update = Instant::now();
1929        let timeout = next_event_poll_timeout(true, Some(last_update), 35, false, None, 100);
1930
1931        assert!(timeout <= Duration::from_millis(35));
1932    }
1933
1934    #[test]
1935    fn bundled_themes_decoder_roundtrip() {
1936        use super::_generated_bundled_themes::bundled_themes_decoded;
1937        let decoded = bundled_themes_decoded().unwrap();
1938        assert!(
1939            decoded.len() >= 40,
1940            "expected ~50 bundled themes, got {}",
1941            decoded.len()
1942        );
1943        for (name, content) in &decoded {
1944            let first_line = content.lines().next().unwrap_or("");
1945            assert!(
1946                first_line.trim() == "[general]",
1947                "theme {name:?} does not start with [general]: {first_line:?}"
1948            );
1949        }
1950        let default = super::_generated_bundled_themes::DEFAULT_BUNDLED;
1951        assert!(default.contains("[general]"));
1952        assert!(default.contains("[text]"));
1953        assert!(default.contains("[buffer]"));
1954    }
1955}