tenseleyflow/fackr / 3353e42

Browse files

feat: command palette (Ctrl+P) with fuzzy search

Add VSCode-style command palette accessible via Ctrl+P:
- ~30 commands across 9 categories (File, Edit, Search, Navigation,
Selection, View, LSP, Brackets, Help)
- Fuzzy matching with scoring for consecutive and word-boundary matches
- Sleek dark theme modal with rounded corners and category tags
- Keyboard navigation (Up/Down/PageUp/PageDown/Enter/Escape)
- Proper viewport scrolling after command execution
Authored by espadonne
SHA
3353e42f0a406156f7395ab625088efe450a1dfa
Parents
9ac1b8b
Tree
f35e706

2 changed files

StatusFile+-
M src/editor/state.rs 418 0
M src/render/screen.rs 194 0
src/editor/state.rsmodified
@@ -33,6 +33,103 @@ struct FortressEntry {
3333
     is_dir: bool,
3434
 }
3535
 
36
+/// A command in the command palette
37
+#[derive(Debug, Clone, PartialEq)]
38
+struct PaletteCommand {
39
+    /// Display name (e.g., "Save File")
40
+    name: &'static str,
41
+    /// Keyboard shortcut (e.g., "Ctrl+S")
42
+    shortcut: &'static str,
43
+    /// Category for grouping (e.g., "File", "Edit")
44
+    category: &'static str,
45
+    /// Unique command identifier
46
+    id: &'static str,
47
+    /// Fuzzy match score (computed during filtering)
48
+    score: i32,
49
+}
50
+
51
+impl PaletteCommand {
52
+    const fn new(name: &'static str, shortcut: &'static str, category: &'static str, id: &'static str) -> Self {
53
+        Self { name, shortcut, category, id, score: 0 }
54
+    }
55
+}
56
+
57
+/// All available commands for the command palette
58
+const ALL_COMMANDS: &[PaletteCommand] = &[
59
+    // File operations
60
+    PaletteCommand::new("Save File", "Ctrl+S", "File", "save"),
61
+    PaletteCommand::new("Save All", "Ctrl+Shift+S", "File", "save-all"),
62
+    PaletteCommand::new("Open File Browser", "Ctrl+O", "File", "open"),
63
+    PaletteCommand::new("New Tab", "Ctrl+T", "File", "new-tab"),
64
+    PaletteCommand::new("Close Tab", "Ctrl+W", "File", "close-tab"),
65
+    PaletteCommand::new("Next Tab", "Ctrl+Tab", "File", "next-tab"),
66
+    PaletteCommand::new("Previous Tab", "Ctrl+Shift+Tab", "File", "prev-tab"),
67
+    PaletteCommand::new("Quit", "Ctrl+Q", "File", "quit"),
68
+
69
+    // Edit operations
70
+    PaletteCommand::new("Undo", "Ctrl+Z", "Edit", "undo"),
71
+    PaletteCommand::new("Redo", "Ctrl+Shift+Z", "Edit", "redo"),
72
+    PaletteCommand::new("Cut", "Ctrl+X", "Edit", "cut"),
73
+    PaletteCommand::new("Copy", "Ctrl+C", "Edit", "copy"),
74
+    PaletteCommand::new("Paste", "Ctrl+V", "Edit", "paste"),
75
+    PaletteCommand::new("Select All", "Ctrl+A", "Edit", "select-all"),
76
+    PaletteCommand::new("Select Line", "Ctrl+L", "Edit", "select-line"),
77
+    PaletteCommand::new("Select Word", "Ctrl+D", "Edit", "select-word"),
78
+    PaletteCommand::new("Toggle Line Comment", "Ctrl+/", "Edit", "toggle-comment"),
79
+    PaletteCommand::new("Join Lines", "Ctrl+J", "Edit", "join-lines"),
80
+    PaletteCommand::new("Duplicate Line", "Alt+Shift+Down", "Edit", "duplicate-line"),
81
+    PaletteCommand::new("Move Line Up", "Alt+Up", "Edit", "move-line-up"),
82
+    PaletteCommand::new("Move Line Down", "Alt+Down", "Edit", "move-line-down"),
83
+    PaletteCommand::new("Delete Line", "Ctrl+Shift+K", "Edit", "delete-line"),
84
+    PaletteCommand::new("Indent", "Tab", "Edit", "indent"),
85
+    PaletteCommand::new("Outdent", "Shift+Tab", "Edit", "outdent"),
86
+    PaletteCommand::new("Transpose Characters", "Ctrl+T", "Edit", "transpose"),
87
+
88
+    // Search operations
89
+    PaletteCommand::new("Find", "Ctrl+F", "Search", "find"),
90
+    PaletteCommand::new("Find and Replace", "Ctrl+R", "Search", "replace"),
91
+    PaletteCommand::new("Find Next", "F3", "Search", "find-next"),
92
+    PaletteCommand::new("Find Previous", "Shift+F3", "Search", "find-prev"),
93
+    PaletteCommand::new("Search in Files", "F4", "Search", "search-files"),
94
+
95
+    // Navigation
96
+    PaletteCommand::new("Go to Line", "Ctrl+G", "Navigation", "goto-line"),
97
+    PaletteCommand::new("Go to Beginning of File", "Ctrl+Home", "Navigation", "goto-start"),
98
+    PaletteCommand::new("Go to End of File", "Ctrl+End", "Navigation", "goto-end"),
99
+    PaletteCommand::new("Go to Matching Bracket", "Ctrl+M", "Navigation", "goto-bracket"),
100
+    PaletteCommand::new("Page Up", "PageUp", "Navigation", "page-up"),
101
+    PaletteCommand::new("Page Down", "PageDown", "Navigation", "page-down"),
102
+
103
+    // Selection
104
+    PaletteCommand::new("Expand Selection to Brackets", "Ctrl+Shift+M", "Selection", "select-brackets"),
105
+    PaletteCommand::new("Add Cursor Above", "Ctrl+Alt+Up", "Selection", "cursor-above"),
106
+    PaletteCommand::new("Add Cursor Below", "Ctrl+Alt+Down", "Selection", "cursor-below"),
107
+
108
+    // View / Panes
109
+    PaletteCommand::new("Split Pane Vertical", "Ctrl+\\", "View", "split-vertical"),
110
+    PaletteCommand::new("Split Pane Horizontal", "Ctrl+Shift+\\", "View", "split-horizontal"),
111
+    PaletteCommand::new("Close Pane", "Ctrl+Shift+W", "View", "close-pane"),
112
+    PaletteCommand::new("Focus Next Pane", "Ctrl+Alt+Right", "View", "next-pane"),
113
+    PaletteCommand::new("Focus Previous Pane", "Ctrl+Alt+Left", "View", "prev-pane"),
114
+    PaletteCommand::new("Toggle File Explorer", "Ctrl+B", "View", "toggle-explorer"),
115
+
116
+    // LSP / Code Intelligence
117
+    PaletteCommand::new("Go to Definition", "F12", "LSP", "goto-definition"),
118
+    PaletteCommand::new("Find References", "Shift+F12", "LSP", "find-references"),
119
+    PaletteCommand::new("Rename Symbol", "F2", "LSP", "rename"),
120
+    PaletteCommand::new("Show Hover Info", "Ctrl+K Ctrl+I", "LSP", "hover"),
121
+    PaletteCommand::new("Trigger Completion", "Ctrl+Space", "LSP", "completion"),
122
+    PaletteCommand::new("LSP Server Manager", "Alt+M", "LSP", "server-manager"),
123
+
124
+    // Bracket/Quote operations
125
+    PaletteCommand::new("Jump to Bracket", "Alt+]", "Brackets", "jump-bracket"),
126
+    PaletteCommand::new("Cycle Bracket Type", "Alt+[", "Brackets", "cycle-brackets"),
127
+    PaletteCommand::new("Remove Surrounding", "Alt+Backspace", "Brackets", "remove-surrounding"),
128
+
129
+    // Help
130
+    PaletteCommand::new("Command Palette", "Ctrl+P", "Help", "command-palette"),
131
+];
132
+
36133
 /// Prompt state for quit confirmation
37134
 #[derive(Debug, Clone, PartialEq)]
38135
 enum PromptState {
@@ -98,6 +195,17 @@ enum PromptState {
98195
         /// Whether search is in progress
99196
         searching: bool,
100197
     },
198
+    /// Command palette (Ctrl+P)
199
+    CommandPalette {
200
+        /// Search/filter query (with > prefix)
201
+        query: String,
202
+        /// Filtered commands matching query
203
+        filtered: Vec<PaletteCommand>,
204
+        /// Currently selected index
205
+        selected_index: usize,
206
+        /// Scroll offset for long lists
207
+        scroll_offset: usize,
208
+    },
101209
 }
102210
 
103211
 /// A single result from multi-file search
@@ -1459,6 +1567,27 @@ impl Editor {
14591567
                 return Ok(()); // Modal handles cursor
14601568
             }
14611569
 
1570
+            // Render command palette if active
1571
+            if let PromptState::CommandPalette {
1572
+                ref query,
1573
+                ref filtered,
1574
+                selected_index,
1575
+                scroll_offset,
1576
+            } = self.prompt {
1577
+                // Convert commands to tuple format for render function
1578
+                let commands_tuples: Vec<(String, String, String, String)> = filtered
1579
+                    .iter()
1580
+                    .map(|c| (c.name.to_string(), c.shortcut.to_string(), c.category.to_string(), c.id.to_string()))
1581
+                    .collect();
1582
+                self.screen.render_command_palette(
1583
+                    query,
1584
+                    &commands_tuples,
1585
+                    selected_index,
1586
+                    scroll_offset,
1587
+                )?;
1588
+                return Ok(()); // Modal handles cursor
1589
+            }
1590
+
14621591
             // Render find/replace bar if active (replaces status bar)
14631592
             if let PromptState::FindReplace {
14641593
                 ref find_query,
@@ -1741,6 +1870,8 @@ impl Editor {
17411870
             (Key::F(5), _) => self.open_goto_line(),
17421871
             // Multi-file search: F4
17431872
             (Key::F(4), _) => self.open_file_search(),
1873
+            // Command palette: Ctrl+P
1874
+            (Key::Char('p'), Modifiers { ctrl: true, .. }) => self.open_command_palette(),
17441875
 
17451876
             // === Editing ===
17461877
             (Key::Char(c), Modifiers { ctrl: false, alt: false, .. }) => {
@@ -4416,6 +4547,78 @@ impl Editor {
44164547
                     _ => {}
44174548
                 }
44184549
             }
4550
+            PromptState::CommandPalette {
4551
+                ref mut query,
4552
+                ref mut filtered,
4553
+                ref mut selected_index,
4554
+                ref mut scroll_offset,
4555
+            } => {
4556
+                match key {
4557
+                    Key::Escape => {
4558
+                        self.prompt = PromptState::None;
4559
+                    }
4560
+                    Key::Enter => {
4561
+                        // Execute selected command
4562
+                        if let Some(cmd) = filtered.get(*selected_index) {
4563
+                            let cmd_id = cmd.id.to_string();
4564
+                            self.prompt = PromptState::None;
4565
+                            self.execute_command(&cmd_id);
4566
+                            self.scroll_to_cursor(); // Ensure viewport follows cursor after command
4567
+                        } else {
4568
+                            self.prompt = PromptState::None;
4569
+                        }
4570
+                    }
4571
+                    Key::Up => {
4572
+                        if *selected_index > 0 {
4573
+                            *selected_index -= 1;
4574
+                            if *selected_index < *scroll_offset {
4575
+                                *scroll_offset = *selected_index;
4576
+                            }
4577
+                        }
4578
+                    }
4579
+                    Key::Down => {
4580
+                        if *selected_index + 1 < filtered.len() {
4581
+                            *selected_index += 1;
4582
+                            // Keep selected item visible (assume ~15 visible rows)
4583
+                            let visible_rows = 15;
4584
+                            if *selected_index >= *scroll_offset + visible_rows {
4585
+                                *scroll_offset = selected_index.saturating_sub(visible_rows - 1);
4586
+                            }
4587
+                        }
4588
+                    }
4589
+                    Key::PageUp => {
4590
+                        *selected_index = selected_index.saturating_sub(10);
4591
+                        if *selected_index < *scroll_offset {
4592
+                            *scroll_offset = *selected_index;
4593
+                        }
4594
+                    }
4595
+                    Key::PageDown => {
4596
+                        *selected_index = (*selected_index + 10).min(filtered.len().saturating_sub(1));
4597
+                        let visible_rows = 15;
4598
+                        if *selected_index >= *scroll_offset + visible_rows {
4599
+                            *scroll_offset = selected_index.saturating_sub(visible_rows - 1);
4600
+                        }
4601
+                    }
4602
+                    Key::Backspace => {
4603
+                        if !query.is_empty() {
4604
+                            query.pop();
4605
+                            *filtered = filter_commands(query);
4606
+                            *selected_index = 0;
4607
+                            *scroll_offset = 0;
4608
+                        }
4609
+                    }
4610
+                    Key::Char(c) => {
4611
+                        // Only accept printable characters
4612
+                        if !c.is_control() {
4613
+                            query.push(c);
4614
+                            *filtered = filter_commands(query);
4615
+                            *selected_index = 0;
4616
+                            *scroll_offset = 0;
4617
+                        }
4618
+                    }
4619
+                    _ => {}
4620
+                }
4621
+            }
44194622
             PromptState::None => {}
44204623
         }
44214624
         Ok(())
@@ -5141,6 +5344,221 @@ impl Editor {
51415344
         let viewport_height = self.screen.rows.saturating_sub(2) as usize;
51425345
         pane.viewport_line = target_line.saturating_sub(viewport_height / 2);
51435346
     }
5347
+
5348
+    // === Command Palette ===
5349
+
5350
+    /// Open the command palette
5351
+    fn open_command_palette(&mut self) {
5352
+        let filtered = filter_commands("");
5353
+        self.prompt = PromptState::CommandPalette {
5354
+            query: String::new(),
5355
+            filtered,
5356
+            selected_index: 0,
5357
+            scroll_offset: 0,
5358
+        };
5359
+    }
5360
+
5361
+    /// Execute a command by its ID
5362
+    fn execute_command(&mut self, command_id: &str) {
5363
+        match command_id {
5364
+            // File operations
5365
+            "save" => { let _ = self.save(); }
5366
+            "save-all" => { let _ = self.workspace.save_all(); }
5367
+            "open" => self.open_fortress(),
5368
+            "new-tab" => self.workspace.new_tab(),
5369
+            "close-tab" => self.close_pane(), // Close current pane/tab
5370
+            "next-tab" => self.workspace.next_tab(),
5371
+            "prev-tab" => self.workspace.prev_tab(),
5372
+            "quit" => self.try_quit(),
5373
+
5374
+            // Edit operations
5375
+            "undo" => self.undo(),
5376
+            "redo" => self.redo(),
5377
+            "cut" => self.cut(),
5378
+            "copy" => self.copy(),
5379
+            "paste" => self.paste(),
5380
+            "select-all" => {
5381
+                // Select all text in current buffer
5382
+                let line_count = self.buffer().line_count();
5383
+                let last_line = line_count.saturating_sub(1);
5384
+                let last_col = self.buffer().line_len(last_line);
5385
+                self.cursor_mut().anchor_line = 0;
5386
+                self.cursor_mut().anchor_col = 0;
5387
+                self.cursor_mut().line = last_line;
5388
+                self.cursor_mut().col = last_col;
5389
+                self.cursor_mut().selecting = true;
5390
+            }
5391
+            "select-line" => self.select_line(),
5392
+            "select-word" => self.select_word(),
5393
+            "toggle-comment" => self.toggle_line_comment(),
5394
+            "join-lines" => self.join_lines(),
5395
+            "duplicate-line" => self.duplicate_line_down(),
5396
+            "move-line-up" => self.move_line_up(),
5397
+            "move-line-down" => self.move_line_down(),
5398
+            "delete-line" => {
5399
+                // Delete the current line
5400
+                let line = self.cursor().line;
5401
+                let line_count = self.buffer().line_count();
5402
+                let line_start = self.buffer().line_col_to_char(line, 0);
5403
+                let line_end = if line + 1 < line_count {
5404
+                    self.buffer().line_col_to_char(line + 1, 0)
5405
+                } else {
5406
+                    self.buffer().len_chars()
5407
+                };
5408
+                if line_start < line_end {
5409
+                    self.buffer_mut().delete(line_start, line_end);
5410
+                    self.cursor_mut().col = 0;
5411
+                    self.cursor_mut().desired_col = 0;
5412
+                    // Clamp line if we deleted the last line
5413
+                    let new_line_count = self.buffer().line_count();
5414
+                    if self.cursor().line >= new_line_count {
5415
+                        self.cursor_mut().line = new_line_count.saturating_sub(1);
5416
+                    }
5417
+                }
5418
+            }
5419
+            "indent" => self.insert_tab(),
5420
+            "outdent" => self.dedent(),
5421
+            "transpose" => self.transpose_chars(),
5422
+
5423
+            // Search operations
5424
+            "find" => self.open_find(),
5425
+            "replace" => self.open_replace(),
5426
+            "find-next" => self.find_next(),
5427
+            "find-prev" => self.find_prev(),
5428
+            "search-files" => self.open_file_search(),
5429
+
5430
+            // Navigation
5431
+            "goto-line" => self.open_goto_line(),
5432
+            "goto-start" => {
5433
+                self.cursor_mut().line = 0;
5434
+                self.cursor_mut().col = 0;
5435
+                self.cursor_mut().desired_col = 0;
5436
+                self.cursor_mut().clear_selection();
5437
+            }
5438
+            "goto-end" => {
5439
+                let last_line = self.buffer().line_count().saturating_sub(1);
5440
+                let last_col = self.buffer().line_len(last_line);
5441
+                self.cursor_mut().line = last_line;
5442
+                self.cursor_mut().col = last_col;
5443
+                self.cursor_mut().desired_col = last_col;
5444
+                self.cursor_mut().clear_selection();
5445
+            }
5446
+            "goto-bracket" => self.jump_to_matching_bracket(),
5447
+            "page-up" => self.page_up(false),
5448
+            "page-down" => self.page_down(false),
5449
+
5450
+            // Selection
5451
+            "select-brackets" => self.jump_to_matching_bracket(), // TODO: implement select inside brackets
5452
+            "cursor-above" => self.add_cursor_above(),
5453
+            "cursor-below" => self.add_cursor_below(),
5454
+
5455
+            // View / Panes
5456
+            "split-vertical" => self.split_vertical(),
5457
+            "split-horizontal" => self.split_horizontal(),
5458
+            "close-pane" => self.close_pane(),
5459
+            "next-pane" => self.tab_mut().navigate_pane(PaneDirection::Right),
5460
+            "prev-pane" => self.tab_mut().navigate_pane(PaneDirection::Left),
5461
+            "toggle-explorer" => self.workspace.fuss.toggle(),
5462
+
5463
+            // LSP operations
5464
+            "goto-definition" => self.lsp_goto_definition(),
5465
+            "find-references" => self.lsp_find_references(),
5466
+            "rename" => self.lsp_rename(),
5467
+            "hover" => self.lsp_hover(),
5468
+            "completion" => self.filter_completions(),
5469
+            "server-manager" => self.toggle_server_manager(),
5470
+
5471
+            // Bracket/Quote operations
5472
+            "jump-bracket" => self.jump_to_matching_bracket(),
5473
+            "cycle-brackets" => self.cycle_brackets(),
5474
+            "remove-surrounding" => self.remove_surrounding(),
5475
+
5476
+            // Help
5477
+            "command-palette" => {} // Already open
5478
+
5479
+            _ => {
5480
+                self.message = Some(format!("Unknown command: {}", command_id));
5481
+            }
5482
+        }
5483
+    }
5484
+}
5485
+
5486
+/// Fuzzy match scoring for command palette
5487
+fn fuzzy_match_score(text: &str, pattern: &str) -> i32 {
5488
+    if pattern.is_empty() {
5489
+        return 100; // Empty pattern matches everything with base score
5490
+    }
5491
+
5492
+    let text_lower = text.to_lowercase();
5493
+    let pattern_lower = pattern.to_lowercase();
5494
+
5495
+    let mut score = 0i32;
5496
+    let mut pattern_idx = 0;
5497
+    let mut consecutive = 0;
5498
+    let pattern_chars: Vec<char> = pattern_lower.chars().collect();
5499
+    let text_chars: Vec<char> = text_lower.chars().collect();
5500
+
5501
+    if pattern_chars.is_empty() {
5502
+        return 100;
5503
+    }
5504
+
5505
+    for (i, &tc) in text_chars.iter().enumerate() {
5506
+        if pattern_idx >= pattern_chars.len() {
5507
+            break;
5508
+        }
5509
+
5510
+        if tc == pattern_chars[pattern_idx] {
5511
+            score += 10;
5512
+            consecutive += 1;
5513
+
5514
+            // Bonus for consecutive matches
5515
+            if consecutive > 1 {
5516
+                score += 5;
5517
+            }
5518
+
5519
+            // Bonus for matching at start or after space/separator
5520
+            if i == 0 || matches!(text_chars.get(i.wrapping_sub(1)), Some(' ' | ':' | '-' | '_' | '/')) {
5521
+                score += 15;
5522
+            }
5523
+
5524
+            pattern_idx += 1;
5525
+        } else {
5526
+            consecutive = 0;
5527
+        }
5528
+    }
5529
+
5530
+    // Only return positive score if all pattern characters matched
5531
+    if pattern_idx == pattern_chars.len() {
5532
+        score
5533
+    } else {
5534
+        0
5535
+    }
5536
+}
5537
+
5538
+/// Filter and sort commands by fuzzy match score
5539
+fn filter_commands(query: &str) -> Vec<PaletteCommand> {
5540
+    let mut filtered: Vec<PaletteCommand> = ALL_COMMANDS
5541
+        .iter()
5542
+        .filter_map(|cmd| {
5543
+            // Match against name, category, or command ID
5544
+            let name_score = fuzzy_match_score(cmd.name, query);
5545
+            let category_score = fuzzy_match_score(cmd.category, query) / 2; // Category match worth less
5546
+            let id_score = fuzzy_match_score(cmd.id, query) / 2;
5547
+
5548
+            let score = name_score.max(category_score).max(id_score);
5549
+            if score > 0 {
5550
+                let mut cmd = cmd.clone();
5551
+                cmd.score = score;
5552
+                Some(cmd)
5553
+            } else {
5554
+                None
5555
+            }
5556
+        })
5557
+        .collect();
5558
+
5559
+    // Sort by score descending
5560
+    filtered.sort_by(|a, b| b.score.cmp(&a.score));
5561
+    filtered
51445562
 }
51455563
 
51465564
 impl Drop for Editor {
src/render/screen.rsmodified
@@ -2516,6 +2516,200 @@ impl Screen {
25162516
         Ok(())
25172517
     }
25182518
 
2519
+    /// Render the command palette modal (Ctrl+P)
2520
+    pub fn render_command_palette(
2521
+        &mut self,
2522
+        query: &str,
2523
+        commands: &[(String, String, String, String)], // (name, shortcut, category, id)
2524
+        selected_index: usize,
2525
+        scroll_offset: usize,
2526
+    ) -> Result<()> {
2527
+        let (width, height) = (self.cols as usize, self.rows as usize);
2528
+
2529
+        // Modal dimensions - centered at top like VSCode
2530
+        let modal_width = 60.min(width - 4);
2531
+        let modal_height = 20.min(height - 4);
2532
+        let start_col = (width.saturating_sub(modal_width)) / 2;
2533
+        let start_row = 2; // Near top of screen
2534
+
2535
+        // Colors - sleek dark theme
2536
+        let bg = Color::AnsiValue(236);
2537
+        let border_color = Color::AnsiValue(240);
2538
+        let _header_color = Color::Cyan; // reserved for future header styling
2539
+        let category_color = Color::AnsiValue(243);
2540
+        let name_color = Color::White;
2541
+        let shortcut_color = Color::AnsiValue(245);
2542
+        let selected_bg = Color::AnsiValue(24); // Blue highlight
2543
+        let selected_name = Color::White;
2544
+        let input_bg = Color::AnsiValue(238);
2545
+        let prompt_color = Color::Yellow;
2546
+
2547
+        // Draw top border with subtle styling
2548
+        execute!(
2549
+            self.stdout,
2550
+            MoveTo(start_col as u16, start_row as u16),
2551
+            SetBackgroundColor(bg),
2552
+            SetForegroundColor(border_color),
2553
+            Print(format!("╭{:─<width$}╮", "", width = modal_width.saturating_sub(2))),
2554
+            ResetColor,
2555
+        )?;
2556
+
2557
+        // Draw search input row with > prefix
2558
+        let display_query = if query.is_empty() { "" } else { query };
2559
+        let input_display_width = modal_width.saturating_sub(6);
2560
+        execute!(
2561
+            self.stdout,
2562
+            MoveTo(start_col as u16, (start_row + 1) as u16),
2563
+            SetBackgroundColor(bg),
2564
+            SetForegroundColor(border_color),
2565
+            Print("│ "),
2566
+            SetForegroundColor(prompt_color),
2567
+            SetAttribute(crossterm::style::Attribute::Bold),
2568
+            Print(">"),
2569
+            SetAttribute(crossterm::style::Attribute::Reset),
2570
+            SetBackgroundColor(input_bg),
2571
+            SetForegroundColor(Color::White),
2572
+            Print(format!(" {:<width$}", display_query, width = input_display_width - 1)),
2573
+            SetBackgroundColor(bg),
2574
+            SetForegroundColor(border_color),
2575
+            Print(" │"),
2576
+            ResetColor,
2577
+        )?;
2578
+
2579
+        // Draw separator
2580
+        execute!(
2581
+            self.stdout,
2582
+            MoveTo(start_col as u16, (start_row + 2) as u16),
2583
+            SetBackgroundColor(bg),
2584
+            SetForegroundColor(border_color),
2585
+            Print(format!("├{:─<width$}┤", "", width = modal_width.saturating_sub(2))),
2586
+            ResetColor,
2587
+        )?;
2588
+
2589
+        // Calculate visible range
2590
+        let visible_rows = modal_height.saturating_sub(5);
2591
+
2592
+        // Adjust scroll offset for visibility
2593
+        let scroll = if selected_index < scroll_offset {
2594
+            selected_index
2595
+        } else if selected_index >= scroll_offset + visible_rows {
2596
+            selected_index - visible_rows + 1
2597
+        } else {
2598
+            scroll_offset
2599
+        };
2600
+
2601
+        // Draw commands
2602
+        for (display_idx, (name, shortcut, category, _id)) in commands.iter().enumerate().skip(scroll).take(visible_rows) {
2603
+            let row = (start_row + 3 + display_idx - scroll) as u16;
2604
+            let is_selected = display_idx == selected_index;
2605
+
2606
+            let item_bg = if is_selected { selected_bg } else { bg };
2607
+            let item_name_color = if is_selected { selected_name } else { name_color };
2608
+
2609
+            // Format: [Category] Name          Shortcut
2610
+            let category_prefix = if category.is_empty() {
2611
+                String::new()
2612
+            } else {
2613
+                format!("[{}] ", category)
2614
+            };
2615
+
2616
+            let shortcut_display = shortcut.as_str();
2617
+            let name_width = modal_width.saturating_sub(4 + category_prefix.len() + shortcut_display.len() + 2);
2618
+
2619
+            // Truncate name if needed
2620
+            let display_name = if name.len() > name_width {
2621
+                format!("{}…", &name[..name_width.saturating_sub(1)])
2622
+            } else {
2623
+                name.clone()
2624
+            };
2625
+
2626
+            execute!(
2627
+                self.stdout,
2628
+                MoveTo(start_col as u16, row),
2629
+                SetBackgroundColor(item_bg),
2630
+                SetForegroundColor(border_color),
2631
+                Print("│ "),
2632
+                SetForegroundColor(category_color),
2633
+                Print(&category_prefix),
2634
+                SetForegroundColor(item_name_color),
2635
+            )?;
2636
+
2637
+            // Print name with padding
2638
+            let name_padding = name_width.saturating_sub(display_name.len());
2639
+            execute!(
2640
+                self.stdout,
2641
+                Print(&display_name),
2642
+                Print(format!("{:width$}", "", width = name_padding)),
2643
+                SetForegroundColor(shortcut_color),
2644
+                Print(format!(" {}", shortcut_display)),
2645
+                SetForegroundColor(border_color),
2646
+                Print(" │"),
2647
+                ResetColor,
2648
+            )?;
2649
+        }
2650
+
2651
+        // Fill remaining rows
2652
+        let items_drawn = commands.len().saturating_sub(scroll).min(visible_rows);
2653
+        for i in items_drawn..visible_rows {
2654
+            let row = (start_row + 3 + i) as u16;
2655
+            execute!(
2656
+                self.stdout,
2657
+                MoveTo(start_col as u16, row),
2658
+                SetBackgroundColor(bg),
2659
+                SetForegroundColor(border_color),
2660
+                Print(format!("│{:width$}│", "", width = modal_width.saturating_sub(2))),
2661
+                ResetColor,
2662
+            )?;
2663
+        }
2664
+
2665
+        // Draw help text row
2666
+        let help_row = (start_row + 3 + visible_rows) as u16;
2667
+        let help_text = "↑↓:select  Enter:run  Esc:close";
2668
+        let result_count = if commands.is_empty() {
2669
+            "No matches".to_string()
2670
+        } else {
2671
+            format!("{} commands", commands.len())
2672
+        };
2673
+        execute!(
2674
+            self.stdout,
2675
+            MoveTo(start_col as u16, help_row),
2676
+            SetBackgroundColor(bg),
2677
+            SetForegroundColor(border_color),
2678
+            Print("├"),
2679
+            SetForegroundColor(Color::AnsiValue(243)),
2680
+            Print(format!(" {} ", result_count)),
2681
+            SetForegroundColor(border_color),
2682
+            Print(format!("{:─<width$}", "", width = modal_width.saturating_sub(result_count.len() + 4))),
2683
+            Print("┤"),
2684
+            ResetColor,
2685
+        )?;
2686
+
2687
+        // Draw bottom border
2688
+        execute!(
2689
+            self.stdout,
2690
+            MoveTo(start_col as u16, help_row + 1),
2691
+            SetBackgroundColor(bg),
2692
+            SetForegroundColor(border_color),
2693
+            Print(format!("╰{:─<width$}╯", "", width = modal_width.saturating_sub(2))),
2694
+            ResetColor,
2695
+        )?;
2696
+
2697
+        // Show help in lighter text at bottom
2698
+        execute!(
2699
+            self.stdout,
2700
+            MoveTo(start_col as u16, help_row + 2),
2701
+            SetForegroundColor(Color::AnsiValue(243)),
2702
+            Print(format!("{:^width$}", help_text, width = modal_width)),
2703
+            ResetColor,
2704
+        )?;
2705
+
2706
+        // Hide cursor when in modal
2707
+        execute!(self.stdout, Hide)?;
2708
+
2709
+        self.stdout.flush()?;
2710
+        Ok(())
2711
+    }
2712
+
25192713
     /// Render the LSP references panel (sidebar style)
25202714
     pub fn render_references_panel(
25212715
         &mut self,