| 1 | //! Custom hinter that combines history and completion suggestions |
| 2 | //! |
| 3 | //! Unlike DefaultHinter which only uses history, this hinter also provides |
| 4 | //! ghost text suggestions from completions when there's a single match. |
| 5 | |
| 6 | use crate::completer::RushCompleter; |
| 7 | use nu_ansi_term::{Color, Style}; |
| 8 | use reedline::{Completer, Hinter, History}; |
| 9 | |
| 10 | /// A hinter that provides suggestions from both history and completions |
| 11 | pub struct RushHinter { |
| 12 | style: Style, |
| 13 | current_hint: String, |
| 14 | min_chars: usize, |
| 15 | } |
| 16 | |
| 17 | impl RushHinter { |
| 18 | pub fn new() -> Self { |
| 19 | Self { |
| 20 | style: Style::new().fg(Color::DarkGray), |
| 21 | current_hint: String::new(), |
| 22 | min_chars: 1, |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | /// Set the style for the hint text |
| 27 | pub fn with_style(mut self, style: Style) -> Self { |
| 28 | self.style = style; |
| 29 | self |
| 30 | } |
| 31 | |
| 32 | /// Set minimum characters before showing hints |
| 33 | pub fn with_min_chars(mut self, min_chars: usize) -> Self { |
| 34 | self.min_chars = min_chars; |
| 35 | self |
| 36 | } |
| 37 | |
| 38 | /// Get hint from history (matching prefix) |
| 39 | fn get_history_hint(&self, line: &str, history: &dyn History) -> Option<String> { |
| 40 | if line.is_empty() { |
| 41 | return None; |
| 42 | } |
| 43 | |
| 44 | // Search history for commands starting with current line |
| 45 | let search = history |
| 46 | .search(reedline::SearchQuery::last_with_prefix( |
| 47 | line.to_string(), |
| 48 | None, // No session filter |
| 49 | )) |
| 50 | .ok()?; |
| 51 | |
| 52 | // Return the first match's suffix (part after current input) |
| 53 | search.first().and_then(|entry| { |
| 54 | let cmd = &entry.command_line; |
| 55 | if cmd.starts_with(line) && cmd.len() > line.len() { |
| 56 | Some(cmd[line.len()..].to_string()) |
| 57 | } else { |
| 58 | None |
| 59 | } |
| 60 | }) |
| 61 | } |
| 62 | |
| 63 | /// Get hint from completions |
| 64 | /// Shows hint for single match, or common prefix for multiple matches |
| 65 | fn get_completion_hint(&self, line: &str, pos: usize) -> Option<String> { |
| 66 | let mut completer = RushCompleter::new(); |
| 67 | let suggestions = completer.complete(line, pos); |
| 68 | |
| 69 | if suggestions.is_empty() { |
| 70 | return None; |
| 71 | } |
| 72 | |
| 73 | // Get the current word being completed |
| 74 | let current_word_start = suggestions[0].span.start; |
| 75 | let current_word = &line[current_word_start..pos]; |
| 76 | |
| 77 | if suggestions.len() == 1 { |
| 78 | // Single match - show full completion as hint |
| 79 | let suggestion = &suggestions[0]; |
| 80 | if suggestion.value.starts_with(current_word) { |
| 81 | let hint = &suggestion.value[current_word.len()..]; |
| 82 | if !hint.is_empty() { |
| 83 | return Some(hint.to_string()); |
| 84 | } |
| 85 | } |
| 86 | } else { |
| 87 | // Multiple matches - find common prefix beyond current input |
| 88 | let first = &suggestions[0].value; |
| 89 | let common_prefix = suggestions.iter().skip(1).fold(first.clone(), |acc, s| { |
| 90 | acc.chars() |
| 91 | .zip(s.value.chars()) |
| 92 | .take_while(|(a, b)| a == b) |
| 93 | .map(|(a, _)| a) |
| 94 | .collect() |
| 95 | }); |
| 96 | |
| 97 | // Only show hint if common prefix extends beyond what user typed |
| 98 | if common_prefix.len() > current_word.len() && common_prefix.starts_with(current_word) { |
| 99 | let hint = &common_prefix[current_word.len()..]; |
| 100 | if !hint.is_empty() { |
| 101 | return Some(hint.to_string()); |
| 102 | } |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | None |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | impl Default for RushHinter { |
| 111 | fn default() -> Self { |
| 112 | Self::new() |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | impl Hinter for RushHinter { |
| 117 | fn handle( |
| 118 | &mut self, |
| 119 | line: &str, |
| 120 | pos: usize, |
| 121 | history: &dyn History, |
| 122 | use_ansi_coloring: bool, |
| 123 | _cwd: &str, |
| 124 | ) -> String { |
| 125 | self.current_hint.clear(); |
| 126 | |
| 127 | // Don't show hints for very short input |
| 128 | if line.len() < self.min_chars { |
| 129 | return String::new(); |
| 130 | } |
| 131 | |
| 132 | // Only provide hints when cursor is at end of line |
| 133 | if pos != line.len() { |
| 134 | return String::new(); |
| 135 | } |
| 136 | |
| 137 | // First try history hints (higher priority) |
| 138 | if let Some(hint) = self.get_history_hint(line, history) { |
| 139 | self.current_hint = hint.clone(); |
| 140 | return if use_ansi_coloring { |
| 141 | self.style.paint(&hint).to_string() |
| 142 | } else { |
| 143 | hint |
| 144 | }; |
| 145 | } |
| 146 | |
| 147 | // Fall back to completion hints |
| 148 | if let Some(hint) = self.get_completion_hint(line, pos) { |
| 149 | self.current_hint = hint.clone(); |
| 150 | return if use_ansi_coloring { |
| 151 | self.style.paint(&hint).to_string() |
| 152 | } else { |
| 153 | hint |
| 154 | }; |
| 155 | } |
| 156 | |
| 157 | String::new() |
| 158 | } |
| 159 | |
| 160 | fn complete_hint(&self) -> String { |
| 161 | self.current_hint.clone() |
| 162 | } |
| 163 | |
| 164 | fn next_hint_token(&self) -> String { |
| 165 | // Return the first word/token of the hint for incremental completion |
| 166 | let hint = &self.current_hint; |
| 167 | |
| 168 | // Find first whitespace or end of string |
| 169 | if let Some(space_pos) = hint.find(char::is_whitespace) { |
| 170 | hint[..space_pos].to_string() |
| 171 | } else { |
| 172 | hint.clone() |
| 173 | } |
| 174 | } |
| 175 | } |
| 176 |