diff --git a/.gitignore b/.gitignore index 9cf0fff3..943745e5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ target/ *.gif *.svg docker_build/ +.vscode/ diff --git a/README.md b/README.md index 91841ed3..13440ba2 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,13 @@ I'd recommend [setting up a mouse mode widget](#mouse-mode-widget) to know when Flyline extends Bash's tab completion feature in many ways. Note that you will need to have [set up completions in normal Bash first](https://github.com/scop/bash-completion). + +### Intellisense style auto suggestions +Flyline can automatically start tab completion suggestions as you type. This demo shows auto-started suggestions, confirming a suggestion, dismissing with Escape, and submitting the command. + +[![Auto tab completion demo](https://github.com/HalFrgrd/flyline/releases/download/assets/demo_auto_tab_completion.gif)](https://github.com/HalFrgrd/evp) + + ### Fuzzy tab completion search When you're presented with suggestions, you can type to fuzzily search through the list: @@ -383,6 +390,7 @@ If a suggestion contains a tab character, flyline displays the contents after th [![Tab completion easing demo](https://github.com/HalFrgrd/flyline/releases/download/assets/demo_tab_completion_easing.gif)](https://github.com/HalFrgrd/evp) + ANSI styling is supported in descriptions: any ANSI colour/style escape codes embedded in the tab-separated description text will be rendered as ratatui styled spans. Descriptions for files are the time since last modified. @@ -430,6 +438,7 @@ if [[ -n "${COPILOT_TERMINAL:-}" ]]; then RPS1='' flyline set-cursor --backend terminal --interpolate none flyline editor --show-inline-history false + flyline suggestions --auto-suggest false fi ``` and set this in your `settings.json`: diff --git a/docker-bake.hcl b/docker-bake.hcl index 13448e50..21ccf810 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -200,6 +200,11 @@ target "demo-tab-completion-easing-extracted-gif" { dockerfile = "docker/demo_tab_completion_easing.Dockerfile" } +target "demo-auto-tab-completion-extracted-gif" { + inherits = ["_demo-base"] + dockerfile = "docker/demo_auto_tab_completion.Dockerfile" +} + group "demos" { targets = [ "demo-overview-extracted-gif", @@ -211,7 +216,8 @@ group "demos" { "demo-ls-colors-extracted-gif", "demo-fuzzy-history-extracted-gif", "demo-inline-history-extracted-gif", - "demo-tab-completion-easing-extracted-gif" + "demo-tab-completion-easing-extracted-gif", + "demo-auto-tab-completion-extracted-gif" ] } diff --git a/docker/demo_auto_tab_completion.Dockerfile b/docker/demo_auto_tab_completion.Dockerfile new file mode 100644 index 00000000..40bfe60c --- /dev/null +++ b/docker/demo_auto_tab_completion.Dockerfile @@ -0,0 +1,8 @@ +FROM demo-base AS gif-builder + +COPY tapes/demo_auto_tab_completion.tape . + +RUN faketime @1771881894 /home/john/bin/evp demo_auto_tab_completion.tape + +FROM scratch +COPY --from=gif-builder /app/*.svg / diff --git a/src/active_suggestions.rs b/src/active_suggestions.rs index d5727abc..c24d0a7b 100644 --- a/src/active_suggestions.rs +++ b/src/active_suggestions.rs @@ -808,11 +808,10 @@ pub struct ActiveSuggestions { /// fuzzy matching, rendering, and acceptance logic. pub processed_suggestions: Vec, pub filtered_suggestions: Vec, - /// 2-D position of the currently-selected suggestion within the grid. - /// `selected_col * last_num_rows_per_col + selected_row` gives the 1-D - /// index into `filtered_suggestions`. - pub selected_row: usize, - pub selected_col: usize, + /// 2-D position of the currently-selected suggestion within the grid as + /// `(selected_col, selected_row)`. `None` means there is no active + /// selection (used for auto-started suggestions). + pub selected_coord: Option<(usize, usize)>, pub original_word_under_cursor: SubString, pub word_under_cursor: SubString, /// Number of suggestion rows per column as used in the last rendered @@ -832,6 +831,8 @@ pub struct ActiveSuggestions { /// How long it took to generate the completions. pub load_time: std::time::Duration, pub comp_type: tab_completion_context::CompType, + /// Whether this tab completion was auto-initiated. + pub auto_started: bool, } impl ActiveSuggestions { @@ -839,6 +840,7 @@ impl ActiveSuggestions { builder: ActiveSuggestionsBuilder, word_under_cursor: SubString, load_time: std::time::Duration, + auto_started: bool, ) -> Self { let ActiveSuggestionsBuilder { processed: processed_suggestions, @@ -854,8 +856,7 @@ impl ActiveSuggestions { unprocessed_suggestions, processed_suggestions, filtered_suggestions: vec![], - selected_row: 0, - selected_col: 0, + selected_coord: if auto_started { None } else { Some((0, 0)) }, original_word_under_cursor: word_under_cursor.clone(), word_under_cursor: word_under_cursor.clone(), last_num_rows_per_col: 0, @@ -865,6 +866,7 @@ impl ActiveSuggestions { fuzzy_matcher: ArinaeMatcher::new(skim::CaseMatching::Smart, true), load_time, comp_type, + auto_started, }; active_sug.update_fuzzy_filtered(); @@ -904,20 +906,23 @@ impl ActiveSuggestions { } /// Return the flat (1-D) index of the currently-selected suggestion. - pub fn current_1d_index(&self) -> usize { - self.selected_col - .saturating_mul(self.last_num_rows_per_col) - .saturating_add(self.selected_row) + pub fn current_1d_index(&self) -> Option { + self.selected_coord.map(|(selected_col, selected_row)| { + selected_col + .saturating_mul(self.last_num_rows_per_col) + .saturating_add(selected_row) + }) } /// Set the selected position from a flat (1-D) suggestion index. pub fn set_selected_by_idx(&mut self, idx: usize) { if self.last_num_rows_per_col == 0 { - self.selected_row = idx; - self.selected_col = 0; + self.selected_coord = Some((0, idx)); } else { - self.selected_col = idx / self.last_num_rows_per_col; - self.selected_row = idx % self.last_num_rows_per_col; + self.selected_coord = Some(( + idx / self.last_num_rows_per_col, + idx % self.last_num_rows_per_col, + )); } self.clamp_selection(); } @@ -926,15 +931,16 @@ impl ActiveSuggestions { fn clamp_selection(&mut self) { let n = self.filtered_suggestions.len(); if n == 0 { - self.selected_row = 0; - self.selected_col = 0; + self.selected_coord = None; return; } + let Some(current_idx) = self.current_1d_index() else { + return; + }; // If the 2-D position points past the end of `filtered_suggestions`, // wrap to index 0. - if self.current_1d_index() >= n { - self.selected_row = 0; - self.selected_col = 0; + if current_idx >= n { + self.selected_coord = Some((0, 0)); } } @@ -943,13 +949,17 @@ impl ActiveSuggestions { if n == 0 || self.last_num_rows_per_col == 0 { return; } - let next_col = self.selected_col + 1; - let next_idx = next_col * self.last_num_rows_per_col + self.selected_row; + let Some((selected_col, selected_row)) = self.selected_coord else { + self.selected_coord = Some((0, 0)); + return; + }; + let next_col = selected_col + 1; + let next_idx = next_col * self.last_num_rows_per_col + selected_row; if next_idx < n { - self.selected_col = next_col; + self.selected_coord = Some((next_col, selected_row)); } else { // No suggestion exists at (selected_row, next_col) → wrap to col 0. - self.selected_col = 0; + self.selected_coord = Some((0, selected_row)); } } @@ -958,17 +968,25 @@ impl ActiveSuggestions { if n == 0 || self.last_num_rows_per_col == 0 { return; } - if self.selected_col > 0 { - self.selected_col -= 1; + let Some((selected_col, selected_row)) = self.selected_coord else { + self.selected_coord = Some((0, 0)); + return; + }; + if selected_col > 0 { + self.selected_coord = Some((selected_col - 1, selected_row)); } else { // Wrap to the last column. let last_col = (n - 1) / self.last_num_rows_per_col; - self.selected_col = last_col; // If (selected_row, last_col) is beyond the last suggestion, // clamp the row to the last item in that column. - let idx = last_col * self.last_num_rows_per_col + self.selected_row; + let idx = last_col * self.last_num_rows_per_col + selected_row; if idx >= n { - self.selected_row = n - 1 - last_col * self.last_num_rows_per_col; + self.selected_coord = Some(( + last_col, + n - 1 - last_col * self.last_num_rows_per_col, + )); + } else { + self.selected_coord = Some((last_col, selected_row)); } } } @@ -978,22 +996,24 @@ impl ActiveSuggestions { if n == 0 || self.last_num_rows_per_col == 0 { return; } - let next_row = self.selected_row + 1; - let next_idx = self.selected_col * self.last_num_rows_per_col + next_row; + let Some((selected_col, selected_row)) = self.selected_coord else { + self.selected_coord = Some((0, 0)); + return; + }; + let next_row = selected_row + 1; + let next_idx = selected_col * self.last_num_rows_per_col + next_row; if next_row < self.last_num_rows_per_col && next_idx < n { // Normal case: move down within the same column. - self.selected_row = next_row; + self.selected_coord = Some((selected_col, next_row)); } else { // At the bottom of this column: move to the top of the next column. - let next_col = self.selected_col + 1; + let next_col = selected_col + 1; let next_col_start = next_col * self.last_num_rows_per_col; if next_col_start < n { - self.selected_col = next_col; - self.selected_row = 0; + self.selected_coord = Some((next_col, 0)); } else { // Wrap to the very first suggestion. - self.selected_col = 0; - self.selected_row = 0; + self.selected_coord = Some((0, 0)); } } } @@ -1003,23 +1023,25 @@ impl ActiveSuggestions { if n == 0 || self.last_num_rows_per_col == 0 { return; } - if self.selected_row > 0 { + let Some((selected_col, selected_row)) = self.selected_coord else { + self.selected_coord = Some((0, 0)); + return; + }; + if selected_row > 0 { // Normal case: move up within the same column. - self.selected_row -= 1; - } else if self.selected_col > 0 { + self.selected_coord = Some((selected_col, selected_row - 1)); + } else if selected_col > 0 { // At the top of this column: move to the bottom of the previous column. - let prev_col = self.selected_col - 1; + let prev_col = selected_col - 1; let col_start = prev_col * self.last_num_rows_per_col; let col_end = (col_start + self.last_num_rows_per_col).min(n); - self.selected_col = prev_col; - self.selected_row = col_end - col_start - 1; + self.selected_coord = Some((prev_col, col_end - col_start - 1)); } else { // At the top of column 0: wrap to the last populated row of the last column. let last_col = (n - 1) / self.last_num_rows_per_col; let col_start = last_col * self.last_num_rows_per_col; let col_end = (col_start + self.last_num_rows_per_col).min(n); - self.selected_col = last_col; - self.selected_row = col_end - col_start - 1; + self.selected_coord = Some((last_col, col_end - col_start - 1)); } } @@ -1030,6 +1052,7 @@ impl ActiveSuggestions { max_rows: usize, max_width: usize, palette: &Palette, + max_num_cols: Option, ) -> Vec { // Try to convert another chunk of unprocessed suggestions before // rendering so they become available in subsequent frames. @@ -1039,6 +1062,7 @@ impl ActiveSuggestions { } let selected_1d = self.current_1d_index(); + let selected_col = self.selected_coord.map(|(c, _)| c).unwrap_or(0); let n = self.filtered_suggestions.len(); if n == 0 || max_rows == 0 { self.last_num_data_cols = 0; @@ -1054,13 +1078,20 @@ impl ActiveSuggestions { let mut grid: Vec = vec![]; let mut untruncated_total_width: usize = 0; - let max_col_index = (n - 1) / max_rows; + let mut max_col_index = (n - 1) / max_rows; + if let Some(max_cols) = max_num_cols { + if max_cols == 0 { + self.last_num_data_cols = 0; + return vec![]; + } + max_col_index = max_col_index.min(max_cols - 1); + } self.last_num_data_cols = max_col_index + 1; self.col_window_to_show.update_max_index(max_col_index + 1); self.col_window_to_show .update_window_size(self.last_num_visible_cols.max(1)); - self.col_window_to_show.move_index_to(self.selected_col); + self.col_window_to_show.move_index_to(selected_col); // First round: try and fit as many columns as possible with their full untruncated width. for col_idx in self.col_window_to_show.get_window_range().start..=max_col_index { @@ -1079,7 +1110,7 @@ impl ActiveSuggestions { palette, frame_index, ); - let is_selected_entry = filtered_idx == selected_1d; + let is_selected_entry = selected_1d == Some(filtered_idx); (formatted, is_selected_entry) }) @@ -1100,9 +1131,9 @@ impl ActiveSuggestions { global_col_idx: col_idx, items: col_items, width: untruncated_col_width, - is_selected_col: col_idx == self.selected_col, + is_selected_col: col_idx == selected_col, }); - if untruncated_total_width > max_width && col_idx >= self.selected_col { + if untruncated_total_width > max_width && col_idx >= selected_col { break; } } @@ -1117,12 +1148,12 @@ impl ActiveSuggestions { // 3) columns to the right of selected, moving outward .sorted_by_key(|col_info| { let col_idx = col_info.global_col_idx; - if col_idx == self.selected_col { + if col_idx == selected_col { (0usize, 0usize) - } else if col_idx < self.selected_col { - (1usize, self.selected_col - col_idx) + } else if col_idx < selected_col { + (1usize, selected_col - col_idx) } else { - (2usize, col_idx - self.selected_col) + (2usize, col_idx - selected_col) } }) .enumerate() @@ -1239,16 +1270,35 @@ impl ActiveSuggestions { .sort_by(|a, b| b.score.cmp(&a.score)); // Reset selected position if needed - if self.current_1d_index() >= self.filtered_suggestions.len() - && !self.filtered_suggestions.is_empty() + if self.filtered_suggestions.is_empty() { + self.selected_coord = None; + return; + } + + if self.current_1d_index().is_none() { + if !self.auto_started { + self.selected_coord = Some((0, 0)); + } + return; + } + + if self + .current_1d_index() + .is_some_and(|idx| idx >= self.filtered_suggestions.len()) { - self.selected_row = 0; - self.selected_col = 0; + self.selected_coord = if self.auto_started { + None + } else { + Some((0, 0)) + }; } } pub fn accept_selected_filtered_item(&mut self, buffer: &mut TextBuffer) { - let selected_idx = self.current_1d_index(); + let Some(selected_idx) = self.current_1d_index() else { + log::warn!("No selected suggestion to accept"); + return; + }; let Some(filtered_item) = self.filtered_suggestions.get(selected_idx) else { log::warn!("No suggestion at selected index {}", selected_idx); diff --git a/src/app/actions.rs b/src/app/actions.rs index 9ac1df62..f64d9afd 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -35,6 +35,8 @@ enum ContextVar { TabCompletion, #[strum(message = "Tab completion overlay is active and has at least one candidate")] TabCompletionAvailable, + #[strum(message = "Tab completion overlay has at least one candidate and a selected entry")] + TabCompletionEntrySelected, #[strum(message = "Tab completion overlay is active and has exactly one filtered candidate")] TabCompletionOneResult, #[strum(message = "Tab completion overlay is showing more than one column of candidates")] @@ -43,6 +45,8 @@ enum ContextVar { TabCompletionNoFilteredResults, #[strum(message = "Tab completion overlay is active and has no candidates at all")] TabCompletionNoResults, + #[strum(message = "Tab completion was triggered by the user (not auto-started)")] + UserTriggeredSuggestions, #[strum(message = "Waiting for the agent mode subprocess to finish")] AgentModeWaiting, #[strum(message = "Agent mode finished and is showing a list of selectable suggestions")] @@ -92,6 +96,12 @@ impl ContextVar { ContentMode::TabCompletion(active_suggestions) if active_suggestions.filtered_suggestions_len() > 0 ), + ContextVar::TabCompletionEntrySelected => matches!( + &app.content_mode, + ContentMode::TabCompletion(active_suggestions) + if active_suggestions.filtered_suggestions_len() > 0 + && active_suggestions.selected_coord.is_some() + ), ContextVar::TabCompletionOneResult => matches!( &app.content_mode, ContentMode::TabCompletion(active_suggestions) @@ -112,6 +122,11 @@ impl ContextVar { ContentMode::TabCompletion(active_suggestions) if active_suggestions.all_suggestions_len() == 0 ), + ContextVar::UserTriggeredSuggestions => matches!( + &app.content_mode, + ContentMode::TabCompletion(active_suggestions) + if !active_suggestions.auto_started + ), ContextVar::AgentModeWaiting => { matches!(app.content_mode, ContentMode::AgentModeWaiting { .. }) } @@ -616,7 +631,7 @@ impl Action { ); if no_suggestions { app.content_mode = ContentMode::Normal; - app.start_tab_complete(); + app.start_tab_complete(false); } else if let ContentMode::TabCompletion(active_suggestions) = &mut app.content_mode { active_suggestions.on_tab(false); @@ -700,7 +715,7 @@ impl Action { Action::InsertNewline => { app.buffer.insert_newline(); } - Action::RunTabCompletion => app.start_tab_complete(), + Action::RunTabCompletion => app.start_tab_complete(false), Action::ToggleMouse => { if matches!( app.settings.mouse_mode, @@ -1016,6 +1031,22 @@ impl Action { } } Action::EscapeToNormalMode => { + // Capture the word-under-cursor when dismissing tab completion, so we don't + // auto-suggest on the same word the user just dismissed. + match &app.content_mode { + ContentMode::TabCompletion(active_suggestions) => { + app.dismissed_tab_completion_wuc = + Some(active_suggestions.word_under_cursor.s.to_string()); + } + ContentMode::TabCompletionWaiting { wuc_substring, .. } => { + app.dismissed_tab_completion_wuc = Some(wuc_substring.s.to_string()); + } + _ => { + // Not tab completion; just clear the dismissed field. + app.dismissed_tab_completion_wuc = None; + } + } + app.buffer.clear_selection(); app.content_mode = ContentMode::Normal; } @@ -1806,8 +1837,8 @@ fn capitalize_first(s: &str) -> String { /// https://en.wikipedia.org/wiki/Table_of_keyboard_shortcuts#Command_line_shortcuts /// /// Meta vs Alt: -/// On iterm2, there is a seetitng in Porfiles->Keys->Left option key. -/// Choices are Normal or (Set high bit (not recommended) or Esc+). +/// On iterm2, there is a setting in Profiles->Keys->Left option key. +/// Choices are Normal or Set high bit (not recommended) or Esc+. /// Set high bit gives you a warning: "You have chosen to have an option key as Meta. This is /// useful for backward compatibility with old applications. The "Esc+" option is recommended for most users" /// In text_buffer.rs, I check if either of them are set for maximal compatibility. @@ -1891,7 +1922,7 @@ static DEFAULT_BINDINGS: LazyLock<[Binding; 86]> = LazyLock::new(|| { ), Binding::new( &expand_variations![KC::Enter.into()], - ContextVar::TabCompletionAvailable.into(), + ContextVar::TabCompletionEntrySelected.into(), Action::TabCompletionAcceptEntry, ), Binding::new( @@ -2137,8 +2168,6 @@ static DEFAULT_BINDINGS: LazyLock<[Binding; 86]> = LazyLock::new(|| { ContextVar::Always.into(), Action::DeleteRight, ), - // PromptCwdEdit Home/End/Alt+Left/Ctrl+Left/Alt+Right/Ctrl+Right must appear before - // the corresponding Default/InlineHistoryAcceptable bindings. Binding::new( &expand_variations![KC::Home.into()], ContextVar::PromptDirSelection.into(), @@ -2234,7 +2263,7 @@ static DEFAULT_BINDINGS: LazyLock<[Binding; 86]> = LazyLock::new(|| { &expand_variations![KC::Right.into(), KC::End.into()], (ContextVar::InlineSuggestionAvailable + ContextVar::CursorAtEnd - + !ContextVar::TabCompletion) + + !ContextVar::TabCompletionMultiColAvailable) .into(), Action::InlineSuggestionAccept, ), @@ -3230,14 +3259,15 @@ mod tests { #[test] fn test_context_expr_parse_new_vars() { let e = ContextExpr::try_from( - "bufferIsEmpty+tabCompletionOneResult+tabCompletionNoFilteredResults+tabCompletionNoResults+multilineBuffer", + "bufferIsEmpty+tabCompletionEntrySelected+tabCompletionOneResult+tabCompletionNoFilteredResults+tabCompletionNoResults+multilineBuffer", ) .unwrap(); assert!(e.literals[0].var == ContextVar::BufferIsEmpty); - assert!(e.literals[1].var == ContextVar::TabCompletionOneResult); - assert!(e.literals[2].var == ContextVar::TabCompletionNoFilteredResults); - assert!(e.literals[3].var == ContextVar::TabCompletionNoResults); - assert!(e.literals[4].var == ContextVar::MultilineBuffer); + assert!(e.literals[1].var == ContextVar::TabCompletionEntrySelected); + assert!(e.literals[2].var == ContextVar::TabCompletionOneResult); + assert!(e.literals[3].var == ContextVar::TabCompletionNoFilteredResults); + assert!(e.literals[4].var == ContextVar::TabCompletionNoResults); + assert!(e.literals[5].var == ContextVar::MultilineBuffer); } #[test] diff --git a/src/app/mod.rs b/src/app/mod.rs index 2432a001..993cc00d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -291,6 +291,7 @@ enum ContentMode { handle: TabCompletionHandle, wuc_substring: SubString, start_time: std::time::Instant, + auto_started: bool, }, /// AI command is running as a child process. The child is polled each /// event-loop iteration with `try_wait`; on drop it is killed and reaped. @@ -432,6 +433,9 @@ pub(crate) struct App<'a> { /// Buffer contents at the time the user last dismissed the inline suggestion. /// While the buffer equals this value the suggestion is suppressed. dismissed_inline_suggestion_buffer: Option, + /// Word-under-cursor at the time the user dismissed tab completion with Escape. + /// While the new word-under-cursor equals this value, auto-suggest is suppressed. + dismissed_tab_completion_wuc: Option, mouse_state: MouseState, content_mode: ContentMode, last_contents: Option, @@ -487,6 +491,7 @@ impl<'a> App<'a> { buffer_before_history_navigation: None, inline_history_suggestion: None, dismissed_inline_suggestion_buffer: None, + dismissed_tab_completion_wuc: None, mouse_state: time_it!( "startup: mouse state", MouseState::initialize(&settings.mouse_mode) @@ -1214,7 +1219,7 @@ impl<'a> App<'a> { /// Poll the tab-completion background thread; returns `true` if a redraw is needed. fn poll_tab_completion(&mut self) -> bool { - if let ContentMode::TabCompletionWaiting { ref handle, .. } = self.content_mode { + if let ContentMode::TabCompletionWaiting { ref handle, auto_started, .. } = self.content_mode { match handle.receiver.try_recv() { Ok(Some((builder, elapsed))) => { // Take ownership of wuc_substring from the waiting state. @@ -1222,7 +1227,7 @@ impl<'a> App<'a> { ContentMode::TabCompletionWaiting { wuc_substring, .. } => wuc_substring, _ => unreachable!(), }; - self.finish_tab_complete(builder, wuc, elapsed); + self.finish_tab_complete(builder, wuc, elapsed, auto_started); self.on_possible_buffer_change(); return true; } @@ -1454,6 +1459,22 @@ impl<'a> App<'a> { } } + // Evaluate the lazy word-under-cursor once to avoid borrow checker issues. + let new_wuc_s = new_wuc.s.to_string(); + + if self.settings.auto_suggest && matches!(self.content_mode, ContentMode::Normal) { + // Only auto-suggest if the word-under-cursor differs from the word the user + // just dismissed by pressing Escape. This prevents re-triggering on the same word. + let should_auto_suggest = match &self.dismissed_tab_completion_wuc { + None => true, + Some(dismissed_wuc) => dismissed_wuc != &new_wuc_s, + }; + + if should_auto_suggest { + self.start_tab_complete(true); + } + } + let new_tokens = dparser::DParser::parse_and_transfer_auto_inserted_flags( self.buffer.buffer(), &self.dparser_tokens_cache, @@ -1466,6 +1487,14 @@ impl<'a> App<'a> { let history_buffer = self.buffer_for_history().to_owned(); + // If the word-under-cursor has changed since the user dismissed tab completion, re-enable auto-suggest. + if match &self.dismissed_tab_completion_wuc { + None => false, + Some(dismissed_wuc) => dismissed_wuc != &new_wuc_s, + } { + self.dismissed_tab_completion_wuc = None; + } + // If the buffer has changed since the user dismissed the suggestion, re-enable it. if self .dismissed_inline_suggestion_buffer @@ -2085,29 +2114,63 @@ impl<'a> App<'a> { if active_suggestions.all_suggestions_len() > 0 { let grid_start_row = content.cursor_position().row; - let num_rows_for_suggestions = rows_left_before_end_of_screen.clamp(2, 15); + let max_rows = self.settings.num_suggestion_rows.max(2); + let num_rows_for_suggestions = + rows_left_before_end_of_screen.clamp(2, max_rows); let mut selected_grid_row: Option = None; + // For auto-started suggestions, use a narrower single-column layout positioned under the cursor + let grid_width = if active_suggestions.auto_started { + let cursor_col = cursor_pos_maybe.map_or(0, |pos| pos.col as usize); + // Allow up to 40 chars max for the suggestion, constrained by available terminal width + (width as usize).saturating_sub(cursor_col).min(40) + } else { + width as usize + }; + let grid = active_suggestions.into_grid( num_rows_for_suggestions as usize, - width as usize, + grid_width, &self.settings.colour_palette, + if active_suggestions.auto_started { Some(1) } else { None }, ); + // After grid is created, compute left padding to prevent wrapping + let left_padding = if active_suggestions.auto_started { + let cursor_col = cursor_pos_maybe.map_or(0, |pos| pos.col as usize); + let actual_grid_width = grid.get(0).map_or(0, |col| { + col.items.iter().map(|(f, _)| f.display_width).max().unwrap_or(0).min(grid_width) + }); + // Ensure the suggestion + padding doesn't exceed terminal width + let max_padding = (width as usize).saturating_sub(actual_grid_width); + cursor_col.min(max_padding) + } else { + 0 + }; + let num_rows = grid.get(0).map_or(0, |col| col.items.len()); for row_idx in 0..num_rows { - for (is_first, _, col) in grid.iter().flag_first_last() { + // Add left padding for auto-started suggestions + if active_suggestions.auto_started && left_padding > 0 { + content.write_tagged_span(&TaggedSpan::new( + Span::raw(" ".repeat(left_padding)), + Tag::TabSuggestion, + )); + } + + for (col_idx, col) in grid.iter().enumerate() { if let Some((formatted, is_selected)) = col.items.get(row_idx) { - if !is_first { + if col_idx > 0 && !active_suggestions.auto_started { content.write_tagged_span(&TaggedSpan::new( Span::raw(" ".repeat(COLUMN_PADDING)), Tag::TabSuggestion, )); } - let formatted_suggestion = - formatted.render(col.width, *is_selected); + + let formatted_suggestion = formatted.render(col.width, *is_selected); + let tag = Tag::Suggestion(formatted.suggestion_idx); for span in formatted_suggestion { content.write_tagged_span(&TaggedSpan::new(span, tag)); @@ -2125,29 +2188,37 @@ impl<'a> App<'a> { } } - let pos_string = if active_suggestions.last_num_data_cols > 1 { - format!( - "({}, {})", - active_suggestions.selected_col, active_suggestions.selected_row - ) - } else { - format!("{}", active_suggestions.current_1d_index()) - }; + // Only show position info for user-triggered suggestions (not auto-started) + if !active_suggestions.auto_started { + let pos_string = if active_suggestions.last_num_data_cols > 1 { + match active_suggestions.selected_coord { + Some((selected_col, selected_row)) => { + format!("({}, {})", selected_col, selected_row) + } + None => "(none)".to_string(), + } + } else { + active_suggestions + .current_1d_index() + .map(|idx| idx.to_string()) + .unwrap_or_else(|| "none".to_string()) + }; - content.write_tagged_span(&TaggedSpan::new( - Span::styled( - format!( - "# Pos: {}; Filtered: {}/{}; {} ({:.1}ms)", - pos_string, - active_suggestions.filtered_suggestions_len(), - active_suggestions.all_suggestions_len(), - active_suggestions.comp_type.display_name(), - active_suggestions.load_time.as_secs_f32() * 1000.0, + content.write_tagged_span(&TaggedSpan::new( + Span::styled( + format!( + "# Pos: {}; Filtered: {}/{}; {} ({:.1}ms)", + pos_string, + active_suggestions.filtered_suggestions_len(), + active_suggestions.all_suggestions_len(), + active_suggestions.comp_type.display_name(), + active_suggestions.load_time.as_secs_f32() * 1000.0, + ), + self.settings.colour_palette.secondary_text(), ), - self.settings.colour_palette.secondary_text(), - ), - Tag::TabSuggestion, - )); + Tag::TabSuggestion, + )); + } } ContentMode::TabCompletionWaiting { start_time, .. } if self.mode.is_running() => { content.newline(); diff --git a/src/app/tab_completion.rs b/src/app/tab_completion.rs index 349127d6..a08bb1c0 100644 --- a/src/app/tab_completion.rs +++ b/src/app/tab_completion.rs @@ -192,8 +192,9 @@ fn run_flyline_compspec( /// expectations are deterministic. pub(crate) fn gen_completions_internal( completion_context: &tab_completion_context::CompletionContext, + auto_started: bool, ) -> Option { - let mut builder = gen_completions_uncomitted(completion_context)?; + let mut builder = gen_completions_uncomitted(completion_context, auto_started)?; let all_processed = if cfg!(test) { // Tests demand determinism: process everything and always compute @@ -217,12 +218,13 @@ pub(crate) fn gen_completions_internal( fn gen_completions_uncomitted( completion_context: &tab_completion_context::CompletionContext, + auto_started: bool, ) -> Option { log::debug!("Completion context: {:#?}", completion_context); let word_under_cursor = &completion_context.word_under_cursor; - for comp_type in &completion_context.comp_types { + for comp_type in &completion_context.comp_types() { log::debug!("Processing completion type: {:?}", comp_type); match comp_type { CompType::None => { @@ -392,6 +394,10 @@ fn gen_completions_uncomitted( } } CompType::GlobExpansion => { + if auto_started { + log::debug!("Skipping GlobExpansion because auto_started is true"); + continue; + } log::debug!("CompType::GlobExpansion for {}", word_under_cursor.as_ref()); let (completions, comp_res_flags) = tab_complete_glob_expansion( word_under_cursor.as_ref(), @@ -457,6 +463,10 @@ fn gen_completions_uncomitted( } } CompType::FilenameExpansion => { + if auto_started { + log::debug!("Skipping FilenameExpansion because auto_started is true"); + continue; + } log::debug!( "CompType::FilenameExpansion for: {}", word_under_cursor.as_ref() @@ -484,6 +494,10 @@ fn gen_completions_uncomitted( } } CompType::FuzzyFilenameExpansion => { + if auto_started { + log::debug!("Skipping FuzzyFilenameExpansion because auto_started is true"); + continue; + } log::debug!( "CompType::FuzzyFilenameExpansion for: {}", word_under_cursor.as_ref() @@ -976,25 +990,31 @@ pub(crate) fn apply_tab_complete_to_buffer( impl App<'_> { /// Apply the results of tab completion generation (Phase 2 & 3: common /// prefix insertion and handing suggestions to the UI). - pub(crate) fn finish_tab_complete( + pub fn finish_tab_complete( &mut self, builder: ActiveSuggestionsBuilder, wuc_substring: SubString, load_time: std::time::Duration, + auto_started: bool, ) { - let outcome = apply_tab_complete_to_buffer(&mut self.buffer, &builder, &wuc_substring); - match outcome { - TabCompleteBufferOutcome::SoloAccepted => { - self.content_mode = ContentMode::Normal; - } - TabCompleteBufferOutcome::Pending { final_wuc } => { - let suggestions = ActiveSuggestions::new(builder, final_wuc, load_time); - self.content_mode = ContentMode::TabCompletion(Box::new(suggestions)); + if auto_started { + let suggestions = ActiveSuggestions::new(builder, wuc_substring, load_time, auto_started); + self.content_mode = ContentMode::TabCompletion(Box::new(suggestions)); + } else { + let outcome = apply_tab_complete_to_buffer(&mut self.buffer, &builder, &wuc_substring); + match outcome { + TabCompleteBufferOutcome::SoloAccepted => { + self.content_mode = ContentMode::Normal; + } + TabCompleteBufferOutcome::Pending { final_wuc } => { + let suggestions = ActiveSuggestions::new(builder, final_wuc, load_time, auto_started); + self.content_mode = ContentMode::TabCompletion(Box::new(suggestions)); + } } } } - pub fn start_tab_complete(&mut self) { + pub fn start_tab_complete(&mut self, auto_started: bool) { // Phase 1: compute the completion context and generate suggestions. // We store word_under_cursor as an owned SubString so we can use it // after the immutable-borrow block ends. @@ -1015,7 +1035,7 @@ impl App<'_> { let thread_handle = std::thread::spawn(move || { let thread_start = std::time::Instant::now(); - let result = gen_completions_internal(&completion_context_owned); + let result = gen_completions_internal(&completion_context_owned, auto_started); let elapsed = thread_start.elapsed(); if result.is_none() { log::debug!( @@ -1034,7 +1054,7 @@ impl App<'_> { // Block for up to 100ms waiting for the thread to finish. match rx.recv_timeout(std::time::Duration::from_millis(100)) { Ok(Some((builder, elapsed))) => { - self.finish_tab_complete(builder, wuc_substring, elapsed); + self.finish_tab_complete(builder, wuc_substring, elapsed, auto_started); } Ok(None) => { // No suggestions generated. @@ -1042,6 +1062,7 @@ impl App<'_> { ActiveSuggestionsBuilder::new(), wuc_substring, start_time.elapsed(), + auto_started, ); } Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { @@ -1053,6 +1074,7 @@ impl App<'_> { }, wuc_substring, start_time, + auto_started, }; } Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { @@ -1121,7 +1143,7 @@ mod tab_completion_tests { ) -> Option<(ActiveSuggestionsBuilder, CompletionContext<'static>)> { crate::logging::init_for_tests_once(); let comp_context = get_completion_context(buffer.buffer(), buffer.cursor_byte_pos()); - let Some(builder) = gen_completions_internal(&comp_context) else { + let Some(builder) = gen_completions_internal(&comp_context, false) else { return None; }; Some((builder, comp_context.into_owned())) @@ -1149,7 +1171,7 @@ mod tab_completion_tests { } else { panic!("Expected pending outcome with suggestions"); }; - ActiveSuggestions::new(builder, final_wuc, std::time::Duration::from_secs(0)) + ActiveSuggestions::new(builder, final_wuc, std::time::Duration::from_secs(0), false) } fn assert_completions(command: &str, expected: &[ProcessedSuggestion]) { @@ -1309,7 +1331,7 @@ mod tab_completion_tests { let comp_context = get_completion_context(buffer.buffer(), buffer.cursor_byte_pos()); let wuc = comp_context.word_under_cursor.clone(); - let builder = gen_completions_internal(&comp_context).expect("some completions"); + let builder = gen_completions_internal(&comp_context, false).expect("some completions"); assert_eq!(builder.comp_type, CompType::CommandComp { command_word: "gd".to_string() }); assert_eq!(builder.len(), 1, "expected solo suggestion, got {:?}", builder.processed); let outcome = apply_tab_complete_to_buffer(&mut buffer, &builder, &wuc); diff --git a/src/cli.rs b/src/cli.rs index f78f9959..4fd5ab01 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -493,6 +493,21 @@ enum Commands { #[arg(long = "select-with-mouse", default_missing_value = "true", num_args = 0..=1)] select_with_mouse: Option, }, + /// Configure suggestion behavior. + /// + /// Examples: + /// flyline suggestions --auto-suggest false + /// flyline suggestions --num-suggestion-rows 10 + /// flyline suggestions --auto-suggest true --num-suggestion-rows 12 + #[command(name = "suggestions", verbatim_doc_comment)] + Suggestions { + /// Enable or disable auto-suggest (inline history suggestions). + #[arg(long = "auto-suggest", default_missing_value = "true", num_args = 0..=1)] + auto_suggest: Option, + /// Maximum number of suggestion rows to render for tab-completion lists. + #[arg(long = "num-suggestion-rows", value_name = "NUM")] + num_suggestion_rows: Option, + }, /// Run a command with --help, parse the output, and print a Bash completion /// script to stdout. /// @@ -1147,7 +1162,7 @@ impl Flyline { self.settings.auto_close_chars = enabled; } if let Some(enabled) = show_inline_history { - log::info!("Inline history suggestions set to {}", enabled); + log::info!("Auto-suggest set to {}", enabled); self.settings.show_inline_history = enabled; } if let Some(enabled) = select_with_mouse { @@ -1155,6 +1170,24 @@ impl Flyline { self.settings.select_with_mouse = enabled; } } + Some(Commands::Suggestions { + auto_suggest, + num_suggestion_rows, + }) => { + if let Some(enabled) = auto_suggest { + log::info!("Auto-suggest set to {}", enabled); + self.settings.auto_suggest = enabled; + } + if let Some(num) = num_suggestion_rows { + if num == 0 { + return_usage_error!( + "flyline suggestions: --num-suggestion-rows must be greater than 0" + ); + } + log::info!("Suggestion row limit set to {}", num); + self.settings.num_suggestion_rows = num; + } + } Some(Commands::CompSpecSynthesis { command }) => { let prev_sigchld = unsafe { libc::signal(libc::SIGCHLD, libc::SIG_DFL) }; let result = generate_completion_script(&command, clap_complete::Shell::Bash); diff --git a/src/settings.rs b/src/settings.rs index f76c191c..c9e3959d 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -175,6 +175,10 @@ pub struct Settings { pub show_animations: bool, /// Whether to show inline history suggestions. pub show_inline_history: bool, + /// Whether to show inline history suggestions (auto-suggest). + pub auto_suggest: bool, + /// Maximum number of suggestion rows to render for tab-completion lists. + pub num_suggestion_rows: u16, /// Whether to automatically close opening characters (e.g., parentheses, brackets, quotes). pub auto_close_chars: bool, /// Whether mouse clicks and drags on the command buffer change the cursor @@ -232,6 +236,8 @@ impl Default for Settings { run_tutorial: false, tutorial_step: TutorialStep::default(), show_animations: true, + auto_suggest: true, + num_suggestion_rows: 15, show_inline_history: true, auto_close_chars: true, select_with_mouse: true, diff --git a/src/tab_completion_context.rs b/src/tab_completion_context.rs index 5860d3f1..862fee18 100644 --- a/src/tab_completion_context.rs +++ b/src/tab_completion_context.rs @@ -57,7 +57,6 @@ pub struct CompletionContext<'a> { pub context: SubString, pub cursor_byte_pos: usize, pub word_under_cursor: SubString, - pub comp_types: Vec, } impl<'a> CompletionContext<'a> { @@ -75,14 +74,12 @@ impl<'a> CompletionContext<'a> { } let context = SubString::new(buffer, context).unwrap(); - let comp_types = Self::comp_types_for(&context, cursor_byte_pos, &word_under_cursor); CompletionContext { buffer: Cow::Borrowed(buffer), context, cursor_byte_pos, word_under_cursor, - comp_types, } } @@ -92,7 +89,6 @@ impl<'a> CompletionContext<'a> { context: SubString::new(buffer, &buffer[0..0]).unwrap(), cursor_byte_pos: 0, word_under_cursor: SubString::new(buffer, &buffer[0..0]).unwrap(), - comp_types: vec![CompType::FirstWord, CompType::FuzzyFirstWord], } } @@ -102,7 +98,6 @@ impl<'a> CompletionContext<'a> { context: self.context, cursor_byte_pos: self.cursor_byte_pos, word_under_cursor: self.word_under_cursor.to_owned(), - comp_types: self.comp_types.clone(), } } @@ -181,6 +176,10 @@ impl<'a> CompletionContext<'a> { Self::context_until_cursor_for(&self.context, self.cursor_byte_pos) } + pub fn comp_types(&self) -> Vec { + Self::comp_types_for(&self.context, self.cursor_byte_pos, &self.word_under_cursor) + } + pub fn cursor_byte_pos_context_relative(&self) -> usize { self.cursor_byte_pos.saturating_sub(self.context.start) } @@ -218,7 +217,6 @@ impl<'a> CompletionContext<'a> { context: self.context.clone(), cursor_byte_pos, word_under_cursor: self.word_under_cursor.clone(), - comp_types: self.comp_types.clone(), } } @@ -248,7 +246,6 @@ impl<'a> CompletionContext<'a> { context, cursor_byte_pos, word_under_cursor, - comp_types: self.comp_types.clone(), } } @@ -267,7 +264,7 @@ impl<'a> CompletionContext<'a> { /// length delta if it was at or after the end of the old wuc — otherwise /// the cursor is placed at the end of the new wuc. /// - /// `comp_types` is intentionally not recomputed; callers are expected to + /// `comp_types` is intentionally not recomputed here; callers are expected to /// use this for short-lived rewrites (e.g. running bash completion /// against a broader prefix) where the original comp-type pipeline still /// applies. @@ -305,7 +302,6 @@ impl<'a> CompletionContext<'a> { context: new_context, cursor_byte_pos, word_under_cursor: new_word_under_cursor, - comp_types: self.comp_types.clone(), } } } @@ -473,7 +469,7 @@ mod tests { assert_eq!(res.context_until_cursor(), "git com"); assert_eq!(res.context, "git commi café"); - match res.comp_types.first().unwrap() { + match res.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(command_word, "git"); assert_eq!(res.word_under_cursor.as_ref(), "commi"); @@ -488,7 +484,7 @@ mod tests { assert_eq!(res.context_until_cursor(), "cd a"); assert_eq!(res.context, "cd a b"); - match res.comp_types.first().unwrap() { + match res.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(command_word, "cd"); assert_eq!(res.word_under_cursor.as_ref(), "a"); @@ -503,7 +499,7 @@ mod tests { assert_eq!(res.context_until_cursor(), "cd "); assert_eq!(res.context, "cd "); - match res.comp_types.first().unwrap() { + match res.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(command_word, "cd"); assert_eq!(res.word_under_cursor.as_ref(), ""); @@ -533,9 +529,9 @@ mod tests { ); assert_eq!(expanded.word_under_cursor.as_ref(), "ban"); assert_eq!( - expanded.comp_types.first().unwrap(), + expanded.comp_types().first().unwrap(), &CompType::CommandComp { - command_word: "fl_comp_alias".to_string() + command_word: "fl_comp_util".to_string() } ); } @@ -592,8 +588,6 @@ mod tests { replaced.word_under_cursor_end_context_relative(), "git c".len() ); - // comp_types are preserved verbatim - assert_eq!(replaced.comp_types, res.comp_types); } #[test] @@ -643,23 +637,12 @@ mod tests { ); } - #[test] - fn test_with_wuc_replaced_does_not_update_comp_types() { - // Even though the new wuc looks like it would change which comp_types - // are produced (e.g. "$" would normally trigger EnvVariable), the - // method intentionally does not recompute comp_types. - let res = run_inline(r#"git com█mit"#); - let original_comp_types = res.comp_types.clone(); - let replaced = res.with_wuc_replaced("$"); - assert_eq!(replaced.comp_types, original_comp_types); - } - #[test] fn test_with_assignment_basic() { let res = run_inline(r#"A=b █ls -la"#); assert_eq!(res.context, "ls -la"); assert_eq!(res.context_until_cursor(), ""); - match res.comp_types.first().unwrap() { + match res.comp_types().first().unwrap() { CompType::FirstWord => { assert_eq!(res.word_under_cursor.as_ref(), "ls"); } @@ -768,7 +751,7 @@ mod tests { assert_eq!(res.context, "echo $(git rev-parse HEAD) résumé"); assert_eq!(res.word_under_cursor.as_ref(), "résumé"); assert_eq!( - res.comp_types, + res.comp_types(), vec![ CompType::CommandComp { command_word: "echo".to_string() @@ -1018,7 +1001,7 @@ mod tests { assert_eq!(res.context_until_cursor(), "x"); assert_eq!(res.word_under_cursor.as_ref(), "x"); assert_eq!( - res.comp_types, + res.comp_types(), vec![ CompType::FirstWord, CompType::FuzzyFirstWord, @@ -1036,7 +1019,7 @@ mod tests { assert_eq!(res.context_until_cursor(), "diff"); assert_eq!(res.word_under_cursor.as_ref(), "diff"); assert_eq!( - res.comp_types, + res.comp_types(), vec![ CompType::FirstWord, CompType::FuzzyFirstWord, @@ -1054,7 +1037,7 @@ mod tests { assert_eq!(res.context_until_cursor(), "echo"); assert_eq!(res.word_under_cursor.as_ref(), "echo"); assert_eq!( - res.comp_types, + res.comp_types(), vec![ CompType::FirstWord, CompType::FuzzyFirstWord, @@ -1072,7 +1055,7 @@ mod tests { assert_eq!(res.context_until_cursor(), "tee"); assert_eq!(res.word_under_cursor.as_ref(), "tee"); assert_eq!( - res.comp_types, + res.comp_types(), vec![ CompType::FirstWord, CompType::FuzzyFirstWord, @@ -1090,7 +1073,7 @@ mod tests { assert_eq!(res.context_until_cursor(), "diff file"); assert_eq!(res.word_under_cursor.as_ref(), "file"); assert_eq!( - res.comp_types, + res.comp_types(), vec![ CompType::CommandComp { command_word: "diff".to_string() @@ -1205,7 +1188,7 @@ mod tests { fn test_completion_context_cursor_at_start_of_line() { // Cursor at position 0 (start of line) let ctx = run_inline("█café --option 🎯"); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::FirstWord => { assert_eq!(ctx.word_under_cursor.as_ref(), "café"); } @@ -1217,7 +1200,7 @@ mod tests { fn test_completion_context_cursor_in_first_word() { // Cursor in the middle of first word with non-ASCII let ctx = run_inline("caf█é --option 🎯"); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::FirstWord => { assert_eq!(ctx.word_under_cursor.as_ref(), "café"); } @@ -1230,7 +1213,7 @@ mod tests { // Cursor after first word that contains emoji let ctx = run_inline("🚀rock█et --verbose naïve"); dbg!(&ctx); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::FirstWord => { assert_eq!(ctx.word_under_cursor.as_ref(), "🚀rocket"); } @@ -1243,7 +1226,7 @@ mod tests { // Cursor at end of line with non-ASCII let ctx = run_inline("echo 'Tëst message' résumé 📄█"); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(ctx.context, "echo 'Tëst message' résumé 📄"); assert_eq!(command_word, "echo"); @@ -1258,7 +1241,7 @@ mod tests { // Cursor in middle of word with unicode characters let ctx = run_inline("ls --sïze caf█é 日本語"); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(ctx.context, "ls --sïze café 日本語"); assert_eq!(command_word, "ls"); @@ -1272,7 +1255,7 @@ mod tests { fn test_completion_context_cursor_at_start_chinese_chars() { // Cursor at start with Chinese characters let ctx = run_inline("█文件 --option värde"); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::FirstWord => { assert_eq!(ctx.word_under_cursor.as_ref(), "文件"); } @@ -1285,7 +1268,7 @@ mod tests { // Cursor in middle of Chinese word let ctx = run_inline("git 提█交 --mëssage 'hëllo'"); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(ctx.context, "git 提交 --mëssage 'hëllo'"); assert_eq!(command_word, "git"); @@ -1300,7 +1283,7 @@ mod tests { // Cursor at end with Arabic text let ctx = run_inline("cat مرحبا --öption 🔥█"); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(ctx.context, "cat مرحبا --öption 🔥"); assert_eq!(command_word, "cat"); @@ -1315,7 +1298,7 @@ mod tests { // Cursor in middle of Cyrillic word let ctx = run_inline("ls фай█л --süze привет 🎯"); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(ctx.context, "ls файл --süze привет 🎯"); assert_eq!(command_word, "ls"); @@ -1330,7 +1313,7 @@ mod tests { // Cursor on blank space with mixed scripts let ctx = run_inline("grep 'pättërn' █файл.txt 日本語 🚀"); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(ctx.context, "grep 'pättërn' файл.txt 日本語 🚀"); assert_eq!(command_word, "grep"); @@ -1344,7 +1327,7 @@ mod tests { fn test_completion_context_start_emoji_only() { // Cursor at start of emoji-only command let ctx = run_inline("█🎉 🎊 🎈 --flâg"); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::FirstWord => { assert_eq!(ctx.word_under_cursor.as_ref(), "🎉"); } @@ -1357,7 +1340,7 @@ mod tests { // Cursor at end with heavily accented text let ctx = run_inline("find . -näme 'fîlé' -type f 🔍█"); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(ctx.context, "find . -näme 'fîlé' -type f 🔍"); assert_eq!(command_word, "find"); @@ -1372,7 +1355,7 @@ mod tests { // Cursor on space between multibyte characters let ctx = run_inline("écho 'mëssagé' █文件 🎨"); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(ctx.context, "écho 'mëssagé' 文件 🎨"); assert_eq!(command_word, "écho"); @@ -1387,7 +1370,7 @@ mod tests { // Cursor in middle of Thai text let ctx = run_inline("cat ไฟ█ล์ --öption วันนี้ 🌟"); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(ctx.context, "cat ไฟล์ --öption วันนี้ 🌟"); assert_eq!(command_word, "cat"); @@ -1404,7 +1387,7 @@ mod tests { // Example: "cd fo[cursor] bar" - word_under_cursor should be "fo", not "" let ctx = run_inline("cd fo█ bar"); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(command_word, "cd"); assert_eq!(ctx.word_under_cursor.as_ref(), "fo"); @@ -1418,7 +1401,7 @@ mod tests { // Cursor in the middle of "foo" when "bar" follows let ctx = run_inline("cd f█oo bar"); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(command_word, "cd"); assert_eq!(ctx.word_under_cursor.as_ref(), "foo"); @@ -1431,7 +1414,7 @@ mod tests { fn test_word_with_double_quote_1() { let ctx = run_inline(r#"cd "foo█"#); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(command_word, "cd"); assert_eq!(ctx.word_under_cursor.as_ref(), "\"foo"); @@ -1445,7 +1428,7 @@ mod tests { fn test_word_with_double_quote_2() { let ctx = run_inline(r#"cd "foo asdf█"#); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(command_word, "cd"); assert_eq!(ctx.word_under_cursor.as_ref(), "\"foo asdf"); @@ -1458,7 +1441,7 @@ mod tests { fn test_word_with_double_quote_3() { let ctx = run_inline(r#"cd "foo █"#); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(command_word, "cd"); assert_eq!(ctx.word_under_cursor.as_ref(), "\"foo "); @@ -1471,7 +1454,7 @@ mod tests { fn test_word_with_double_quote_4() { let ctx = run_inline(r#"echo && cd "foo █"#); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(command_word, "cd"); assert_eq!(ctx.word_under_cursor.as_ref(), "\"foo "); @@ -1484,7 +1467,7 @@ mod tests { fn test_word_with_single_quote_1() { let ctx = run_inline(r#"cd 'foo█"#); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(command_word, "cd"); assert_eq!(ctx.word_under_cursor.as_ref(), "'foo"); @@ -1497,7 +1480,7 @@ mod tests { fn test_word_with_single_quote_2() { let ctx = run_inline(r#"cd 'foo asdf█"#); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(command_word, "cd"); assert_eq!(ctx.word_under_cursor.as_ref(), "'foo asdf"); @@ -1510,7 +1493,7 @@ mod tests { fn test_word_with_single_quote_3() { let ctx = run_inline(r#"echo && cd 'foo asdf█"#); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(command_word, "cd"); assert_eq!(ctx.word_under_cursor.as_ref(), "'foo asdf"); @@ -1523,7 +1506,7 @@ mod tests { fn test_word_with_backslash_1() { let ctx = run_inline(r#"echo && cd foo\█"#); - match ctx.comp_types.first().unwrap() { + match ctx.comp_types().first().unwrap() { CompType::CommandComp { command_word } => { assert_eq!(command_word, "cd"); assert_eq!(ctx.word_under_cursor.as_ref(), "foo\\"); @@ -1538,7 +1521,7 @@ mod tests { assert_eq!(ctx.word_under_cursor.as_ref(), "foo\\ "); assert_eq!( - ctx.comp_types, + ctx.comp_types(), vec![ CompType::CommandComp { command_word: "cd".to_string() @@ -1558,7 +1541,7 @@ mod tests { assert_eq!(ctx.word_under_cursor.as_ref(), ""); assert_eq!( - ctx.comp_types, + ctx.comp_types(), vec![ CompType::CommandComp { command_word: "echo".to_string() @@ -1578,7 +1561,7 @@ mod tests { assert_eq!(ctx.word_under_cursor.as_ref(), "$HOM"); assert_eq!( - ctx.comp_types, + ctx.comp_types(), vec![ CompType::CommandComp { command_word: "echo".to_string() @@ -1594,7 +1577,7 @@ mod tests { assert_eq!(ctx.word_under_cursor.as_ref(), "$HOM"); assert_eq!( - ctx.comp_types, + ctx.comp_types(), vec![ CompType::CommandComp { command_word: "echo".to_string() @@ -1610,7 +1593,7 @@ mod tests { assert_eq!(ctx.word_under_cursor.as_ref(), "\"$HOME/abc"); assert_eq!( - ctx.comp_types, + ctx.comp_types(), vec![ CompType::CommandComp { command_word: "echo".to_string() @@ -1627,7 +1610,7 @@ mod tests { assert_eq!(ctx.word_under_cursor.as_ref(), "$HOM"); assert_eq!( - ctx.comp_types, + ctx.comp_types(), vec![ CompType::CommandComp { command_word: "echo".to_string() @@ -1643,7 +1626,7 @@ mod tests { assert_eq!(ctx.word_under_cursor.as_ref(), "$HOM"); assert_eq!( - ctx.comp_types, + ctx.comp_types(), vec![ CompType::CommandComp { command_word: "echo".to_string() @@ -1660,7 +1643,7 @@ mod tests { assert_eq!(ctx.word_under_cursor.as_ref(), "$HOME/"); assert_eq!( - ctx.comp_types, + ctx.comp_types(), vec![ CompType::FirstWord, CompType::FuzzyFirstWord, @@ -1680,7 +1663,7 @@ mod tests { ); assert_eq!( - ctx.comp_types, + ctx.comp_types(), vec![ CompType::CommandComp { command_word: "ll".to_string() @@ -1697,7 +1680,7 @@ mod tests { assert_eq!(ctx.word_under_cursor.as_ref(), "./{foo,bar}.txt"); assert_eq!( - ctx.comp_types, + ctx.comp_types(), vec![ CompType::CommandComp { command_word: "echo".to_string() @@ -1710,7 +1693,7 @@ mod tests { assert_eq!(ctx.word_under_cursor.as_ref(), "./foo*"); assert_eq!( - ctx.comp_types, + ctx.comp_types(), vec![ CompType::CommandComp { command_word: "echo".to_string() @@ -1726,7 +1709,7 @@ mod tests { assert_eq!(ctx.word_under_cursor.as_ref(), r"./foo\*"); assert_eq!( - ctx.comp_types, + ctx.comp_types(), vec![ CompType::CommandComp { command_word: "echo".to_string() @@ -1740,7 +1723,7 @@ mod tests { assert_eq!(ctx.word_under_cursor.as_ref(), "./foo{bar}.txt"); assert_eq!( - ctx.comp_types, + ctx.comp_types(), vec![ CompType::CommandComp { command_word: "echo".to_string() diff --git a/tapes/demo_auto_tab_completion.tape b/tapes/demo_auto_tab_completion.tape new file mode 100644 index 00000000..3af61115 --- /dev/null +++ b/tapes/demo_auto_tab_completion.tape @@ -0,0 +1,37 @@ +Output demo_auto_tab_completion.svg + +Source demo_settings.tape +Set Height 600 +Set Width 1800 +Source demo_setup.tape + +Hide +Type "flyline suggestions --auto-suggest true" +Enter +Show + + +Type "flyli" +Tab +Sleep 600ms +Enter +Sleep 900ms + +Type "set-st" +Tab +Sleep 600ms +Enter +Sleep 900ms + +Type `bas` +Tab +Sleep 600ms +Enter +Sleep 900ms + +Type "red" +Sleep 800ms +Escape +Sleep 400ms +Enter +Sleep 1000ms \ No newline at end of file