From d1bbae8fe3537bc070b902cce6f15087ae7c1f0e Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Sat, 29 Aug 2026 19:53:44 +0530 Subject: [PATCH 1/6] add slash command completion - register a `/` backend so commands complete like `@` paths, each listed with its description; candidates come from `SlashCommand` itself - items carry `Accept`, so Enter runs a command outright while a path accepted inside a command line stays a path - popup height tracks its contents instead of always reserving five rows - popup keys are decided by one pure `PopupAction::of` rather than split across the event dispatch --- crates/alan/src/core/completion/commands.rs | 127 +++++++++++ crates/alan/src/core/completion/mod.rs | 25 +- crates/alan/src/core/completion/paths.rs | 5 +- crates/alan/src/core/controller.rs | 7 +- crates/alan/src/core/mod.rs | 2 +- crates/alan/src/views/components/footer.rs | 4 +- crates/alan/src/views/components/popup.rs | 80 +++++-- crates/alan/src/views/mod.rs | 241 +++++++++++++++++--- 8 files changed, 419 insertions(+), 72 deletions(-) create mode 100644 crates/alan/src/core/completion/commands.rs diff --git a/crates/alan/src/core/completion/commands.rs b/crates/alan/src/core/completion/commands.rs new file mode 100644 index 0000000..ca74275 --- /dev/null +++ b/crates/alan/src/core/completion/commands.rs @@ -0,0 +1,127 @@ +//! Slash-command completion. +//! +//! Candidates come from [`SlashCommand`] itself, so the popup cannot offer a +//! command that does not exist. + +use super::{ + Accept, CompletionBackend, CompletionItem, CompletionRequest, CompletionResult, + CompletionStatus, ranked_items, +}; +use crate::core::SlashCommand; +use strum::IntoEnumIterator; + +pub struct Commands { + names: Vec, +} + +impl Default for Commands { + fn default() -> Self { + Self { + names: SlashCommand::iter() + .map(|command| <&'static str>::from(command).to_owned()) + .collect(), + } + } +} + +impl CompletionBackend for Commands { + fn trigger(&self) -> char { + '/' + } + + /// Commands take no arguments, so one opens the line or is not a command at + /// all: anywhere else a `/` is a path separator, as in `explain /usr/bin`. + fn complete(&self, request: &CompletionRequest) -> Option { + // The range starts after the one-byte trigger, so 1 is column 0. + if request.range.start != 1 { + return None; + } + + Some(CompletionResult { + range: request.range.clone(), + status: CompletionStatus::Ready, + items: ranked_items(&request.pattern, &self.names, |name| CompletionItem { + display: describe_slash_command(name), + replacement: name.to_owned(), + accept: Accept::Complete, + }), + }) + } +} + +/// `/help — list the available commands`. Parsing the name back is what keeps +/// the description tied to the variant rather than to a second list. +fn describe_slash_command(name: &str) -> String { + let Ok(command) = name.parse::() else { + return format!("/{name}"); + }; + format!("{} — {}", command.name(), command.description()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::completion::CompletionController; + + fn engine() -> CompletionController { + CompletionController::new(vec![Box::new(Commands::default())]) + } + + #[test] + fn a_lone_slash_lists_every_command() { + let mut engine = engine(); + engine.sync("/", 1); + + assert!(engine.is_open()); + assert_eq!(engine.item_count(), SlashCommand::iter().count()); + } + + #[test] + fn a_pattern_narrows_to_the_matching_commands() { + let mut engine = engine(); + engine.sync("/he", 3); + + let items = engine.items(0, engine.item_count()); + assert_eq!(items.len(), 1); + assert_eq!(items[0].replacement, "help"); + } + + /// Guards the name-to-variant lookup in [`describe`], without which the + /// item would fall back to a bare `/help`. + #[test] + fn an_item_is_displayed_with_its_description() { + let mut engine = engine(); + engine.sync("/help", 5); + + assert_eq!( + engine.items(0, 1)[0].display, + format!("/help — {}", SlashCommand::Help.description()) + ); + } + + /// Everywhere but the first column a `/` is a path separator. + #[test] + fn a_slash_inside_the_line_is_not_a_command() { + let mut engine = engine(); + engine.sync("explain /usr", 12); + + assert!(!engine.is_open()); + } + + /// The trigger survives the replacement, so the item carries the bare name. + #[test] + fn accepting_replaces_the_name_and_keeps_the_slash() { + let mut engine = engine(); + engine.sync("/he", 3); + + let (item, range) = engine.accept().unwrap(); + assert_eq!(item.replacement, "help"); + assert_eq!(range, 1..3); + assert_eq!( + item.accept, + Accept::Complete, + "a command is the whole input" + ); + assert!(!engine.is_open()); + } +} diff --git a/crates/alan/src/core/completion/mod.rs b/crates/alan/src/core/completion/mod.rs index 7806aeb..f170ec6 100644 --- a/crates/alan/src/core/completion/mod.rs +++ b/crates/alan/src/core/completion/mod.rs @@ -4,11 +4,13 @@ //! the line itself. Ranking is not their concern either: [`matcher`] orders //! every backend the same way. +mod commands; mod matcher; mod paths; mod token; use super::Poll; +pub use commands::Commands; pub use paths::Paths; use std::collections::HashMap; use std::ops::Range; @@ -28,8 +30,15 @@ pub struct CompletionRequest { #[derive(Debug, Clone, PartialEq, Eq)] pub struct CompletionItem { pub display: String, - /// Text substituted for [`CompletionResult::range`]. pub replacement: String, + pub accept: Accept, +} + +/// What accepting an item leaves the input in. Set by the backend that offered the item +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Accept { + Insert, + Complete, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -183,11 +192,17 @@ impl CompletionController { active.selected = (active.selected as isize + delta).clamp(0, max) as usize; } + /// The item accepting would take, which is also what makes an empty popup + /// distinguishable from one with something to offer. + pub fn highlighted(&self) -> Option<&CompletionItem> { + let active = self.active.as_ref()?; + active.result.items.get(active.selected) + } + /// The highlighted item and the byte range of the line it overwrites. pub fn accept(&mut self) -> Option<(CompletionItem, Range)> { - let active = self.active.as_ref()?; - let item = active.result.items.get(active.selected)?.clone(); - let range = active.result.range.clone(); + let item = self.highlighted()?.clone(); + let range = self.active.as_ref()?.result.range.clone(); self.active = None; Some((item, range)) } @@ -265,7 +280,7 @@ mod tests { #[test] fn an_unclaimed_trigger_opens_nothing() { let mut engine = engine(&["src/main.rs"]); - engine.sync("/help", 5); + engine.sync("#tag", 4); assert!(!engine.is_open()); } diff --git a/crates/alan/src/core/completion/paths.rs b/crates/alan/src/core/completion/paths.rs index fff1006..6a08bae 100644 --- a/crates/alan/src/core/completion/paths.rs +++ b/crates/alan/src/core/completion/paths.rs @@ -6,8 +6,8 @@ //! index is served stale rather than waited on. use super::{ - CompletionBackend, CompletionItem, CompletionRequest, CompletionResult, CompletionStatus, - ranked_items, + Accept, CompletionBackend, CompletionItem, CompletionRequest, CompletionResult, + CompletionStatus, ranked_items, }; use crate::core::Poll; use futures_util::FutureExt; @@ -96,6 +96,7 @@ impl CompletionBackend for Paths { items: ranked_items(&request.pattern, &self.index, |path| CompletionItem { display: path.to_owned(), replacement: path.to_owned(), + accept: Accept::Insert, }), }) } diff --git a/crates/alan/src/core/controller.rs b/crates/alan/src/core/controller.rs index e0e910c..f75ca63 100644 --- a/crates/alan/src/core/controller.rs +++ b/crates/alan/src/core/controller.rs @@ -3,7 +3,7 @@ use super::action::{Command, ImageAttachment}; use super::chat::{ChatController, Entry}; use super::command::SlashCommand; -use super::completion::{CompletionController, Paths}; +use super::completion::{Commands, CompletionController, Paths}; use super::login::{LoginController, LoginState}; use agent::Agent; use llm::Usage; @@ -76,7 +76,10 @@ impl Controller { Self { chat: ChatController::new(agent), login: LoginController::new(providers, credentials), - completion: CompletionController::new(vec![Box::new(Paths::default())]), + completion: CompletionController::new(vec![ + Box::new(Paths::default()), + Box::new(Commands::default()), + ]), overlay: Overlay::None, } } diff --git a/crates/alan/src/core/mod.rs b/crates/alan/src/core/mod.rs index 7afe386..18e6791 100644 --- a/crates/alan/src/core/mod.rs +++ b/crates/alan/src/core/mod.rs @@ -10,6 +10,6 @@ pub mod login; pub use action::{Action, Command, ImageAttachment}; pub use chat::Entry; pub use command::SlashCommand; -pub use completion::{CompletionController, CompletionItem, CompletionStatus}; +pub use completion::{Accept, CompletionController, CompletionItem, CompletionStatus}; pub use controller::{Activity, Controller, Overlay, Poll}; pub use login::LoginState; diff --git a/crates/alan/src/views/components/footer.rs b/crates/alan/src/views/components/footer.rs index 0200174..bae4a01 100644 --- a/crates/alan/src/views/components/footer.rs +++ b/crates/alan/src/views/components/footer.rs @@ -165,7 +165,9 @@ impl Component for Footer { if let Some(position) = state.cursor_screen_position() { frame.set_cursor_position(position); } - if let Some(popup_area) = PopupList::area_above(area, frame.area()) { + let completion = controller.completion(); + let rows = PopupList::required_rows(completion.status(), completion.item_count()); + if let Some(popup_area) = PopupList::area_above(area, frame.area(), rows) { self.popup.render(frame, popup_area, controller, state); } } diff --git a/crates/alan/src/views/components/popup.rs b/crates/alan/src/views/components/popup.rs index 4f2f224..07f28de 100644 --- a/crates/alan/src/views/components/popup.rs +++ b/crates/alan/src/views/components/popup.rs @@ -8,24 +8,46 @@ use ratatui::style::Style; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, Padding, Paragraph}; -/// Fixed height of the completion popup, including its padding. -const POPUP_ROWS: u16 = 7; -/// Candidates visible inside that height. +/// Candidates shown at once, past which the list scrolls. const VISIBLE_ROWS: usize = 5; +/// Blank space the popup's block keeps around its content +const CONTENT_PADDING: Padding = Padding::new(2, 2, 1, 1); + /// Generic list popup rendered above the prompt. Currently used for /// `@`-path completion; reusable for any short list anchored at the prompt. #[derive(Debug, Default)] pub struct PopupList; impl PopupList { - /// Popup area sitting directly above `prompt`, spanning the frame width. + /// The status shown in place of candidates, if any. + fn message(status: CompletionStatus, item_count: usize) -> Option { + match status { + CompletionStatus::Loading => Some("Loading…".to_owned()), + CompletionStatus::Error(error) => Some(error), + CompletionStatus::Ready if item_count == 0 => Some("No matches".to_owned()), + CompletionStatus::Ready => None, + } + } + + /// Rows the content will occupy, so the box never reserves space for + /// candidates that are not there. + pub fn required_rows(status: CompletionStatus, item_count: usize) -> u16 { + if Self::message(status, item_count).is_some() { + return 1; + } + item_count.min(VISIBLE_ROWS) as u16 + } + + /// Popup area sitting directly above `prompt`, spanning the frame width and + /// tall enough for `rows` of content plus its padding. /// /// Anchored to the prompt rather than the cursor so it never covers the /// status line, which is what describes the keys the popup has taken. /// Returns `None` when there is no room above. - pub fn area_above(prompt: Rect, frame_area: Rect) -> Option { - let top = prompt.y.checked_sub(POPUP_ROWS)?; + pub fn area_above(prompt: Rect, frame_area: Rect, rows: u16) -> Option { + let height = rows.saturating_add(CONTENT_PADDING.top + CONTENT_PADDING.bottom); + let top = prompt.y.checked_sub(height)?; if top < frame_area.y || prompt.y > frame_area.bottom() { return None; } @@ -33,7 +55,7 @@ impl PopupList { x: frame_area.x, y: top, width: frame_area.width, - height: POPUP_ROWS, + height, }) } } @@ -50,19 +72,11 @@ impl Component for PopupList { if !completion.is_open() || area.is_empty() { return; } - let message = match completion.status() { - CompletionStatus::Loading => Some("Loading…".to_owned()), - CompletionStatus::Error(error) => Some(error), - CompletionStatus::Ready if completion.item_count() == 0 => { - Some("No matches".to_owned()) - } - CompletionStatus::Ready => None, - }; - if let Some(message) = message { + if let Some(message) = Self::message(completion.status(), completion.item_count()) { frame.render_widget( Paragraph::new(message) .style(Style::default().bg(theme::EDITOR_BG)) - .block(Block::default().padding(Padding::new(2, 2, 1, 1))), + .block(Block::default().padding(CONTENT_PADDING)), area, ); return; @@ -83,7 +97,7 @@ impl Component for PopupList { frame.render_widget( Paragraph::new(Text::from(lines)) .style(Style::default().bg(theme::EDITOR_BG)) - .block(Block::default().padding(Padding::new(2, 2, 1, 1))), + .block(Block::default().padding(CONTENT_PADDING)), area, ); } @@ -113,9 +127,12 @@ mod tests { let frame = Rect::new(0, 0, 80, 24); let prompt = Rect::new(0, 16, 80, 8); - let area = PopupList::area_above(prompt, frame).unwrap(); + let area = PopupList::area_above(prompt, frame, VISIBLE_ROWS as u16).unwrap(); - assert_eq!(area.height, POPUP_ROWS); + assert_eq!( + area.height, + VISIBLE_ROWS as u16 + CONTENT_PADDING.top + CONTENT_PADDING.bottom + ); assert_eq!(area.bottom(), prompt.y); assert!(area.bottom() <= prompt.y, "overlaps the prompt"); } @@ -123,10 +140,25 @@ mod tests { #[test] fn no_room_above_the_prompt_means_no_popup() { let frame = Rect::new(0, 0, 80, 24); - // Not enough rows above the prompt for the fixed height. - assert!(PopupList::area_above(Rect::new(0, 3, 80, 8), frame).is_none()); - assert!(PopupList::area_above(Rect::new(0, 0, 80, 8), frame).is_none()); + let rows = VISIBLE_ROWS as u16; + // Not enough rows above the prompt for the height asked for. + assert!(PopupList::area_above(Rect::new(0, 3, 80, 8), frame, rows).is_none()); + assert!(PopupList::area_above(Rect::new(0, 0, 80, 8), frame, rows).is_none()); // Prompt off the bottom of the frame. - assert!(PopupList::area_above(Rect::new(0, 25, 80, 8), frame).is_none()); + assert!(PopupList::area_above(Rect::new(0, 25, 80, 8), frame, rows).is_none()); + } + + /// The box reserves exactly what [`PopupList::render`] will draw, so a + /// short list leaves no dead rows and a long one does not run off-screen. + #[test] + fn required_rows_tracks_what_will_be_drawn() { + use CompletionStatus::{Error, Loading, Ready}; + + assert_eq!(PopupList::required_rows(Ready, 3), 3); + assert_eq!(PopupList::required_rows(Ready, 99), VISIBLE_ROWS as u16); + // A status message is one row however many candidates sit behind it. + assert_eq!(PopupList::required_rows(Ready, 0), 1); + assert_eq!(PopupList::required_rows(Loading, 9), 1); + assert_eq!(PopupList::required_rows(Error("nope".into()), 9), 1); } } diff --git a/crates/alan/src/views/mod.rs b/crates/alan/src/views/mod.rs index 6969b50..5fe7586 100644 --- a/crates/alan/src/views/mod.rs +++ b/crates/alan/src/views/mod.rs @@ -9,7 +9,8 @@ pub mod selection; mod theme; use crate::core::{ - Action, Command, CompletionController, Controller, ImageAttachment, Overlay, Poll, SlashCommand, + Accept, Action, Command, CompletionController, Controller, ImageAttachment, Overlay, Poll, + SlashCommand, }; use base64::Engine; use components::{Chat, Footer, Header, LoginOverlay}; @@ -158,10 +159,9 @@ impl UiState { } if let Event::Key(key) = &event && completion.is_open() - && (completion.item_count() > 0 || !matches!(key.code, KeyCode::Enter | KeyCode::Tab)) - && self.handle_completion_key(*key, completion) + && let Some(action) = PopupAction::of(*key, completion) { - return None; + return self.apply_completion_popup(action, completion); } self.handle_editor_event(event, rendered_lines, completion) } @@ -283,47 +283,45 @@ impl UiState { ); } - /// Navigation and acceptance keys while the completion popup is open. - /// Returns true only when completion consumed the key. - fn handle_completion_key( + /// Carry out what [`PopupAction::of`] decided. Only accepting can finish + /// the input, so only accepting can produce a command. + fn apply_completion_popup( &mut self, - key: KeyEvent, + action: PopupAction, completion: &mut CompletionController, - ) -> bool { - match key.code { - KeyCode::Up => { - completion.move_selection(-1); - self.dirty = true; - true - } - KeyCode::Down => { - completion.move_selection(1); - self.dirty = true; - true - } - KeyCode::Enter | KeyCode::Tab if key.modifiers.is_empty() => { - let Some((item, range)) = completion.accept() else { - return false; - }; - let separate = self.needs_separator_after(range.end); - self.replace_range(range, &item.replacement); - // An accepted mention is finished. Without a separator the next - // keystroke lands inside the token and reopens the popup. - if separate { - self.editor.insert_str(" "); - } - self.dirty = true; - true + ) -> Option { + self.dirty = true; + match action { + PopupAction::Move(delta) => { + completion.move_selection(delta); + None } - KeyCode::Esc => { + PopupAction::Dismiss => { completion.dismiss(); - self.dirty = true; - true + None + } + PopupAction::Take { submit } => { + let (item, range) = completion.accept()?; + self.insert_completion(&item.replacement, range); + // Taking an item can complete a command name, and the editor + // event that would otherwise restyle the line never runs. + self.sync_command_highlight(); + submit.then(|| self.submit_editor_or_accept()).flatten() } - _ => false, } } + /// Overwrite the completed token with `replacement`. + fn insert_completion(&mut self, replacement: &str, range: std::ops::Range) { + let separate = self.needs_separator_after(range.end); + self.replace_range(range, replacement); + if separate { + self.editor.insert_str(" "); + } + + self.dirty = true; + } + /// Whether byte `at` on the cursor's line is not already followed by /// whitespace, so an accepted completion needs one adding. fn needs_separator_after(&self, at: usize) -> bool { @@ -661,6 +659,13 @@ fn completion_with(index: &[&str]) -> CompletionController { ))]) } +#[cfg(test)] +fn completion_with_commands() -> CompletionController { + use crate::core::completion::Commands; + + CompletionController::new(vec![Box::new(Commands::default())]) +} + #[cfg(test)] impl UiState { /// Test shim for the pre-completion call signature. @@ -670,6 +675,43 @@ impl UiState { } } +/// What an open completion popup does with a key. +/// +/// Decided before anything is mutated, so [`PopupAction::of`] returning `None` +/// is the whole answer to "the editor should see this key" — nothing further +/// down has to report back that it declined. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PopupAction { + Move(isize), + /// Put the highlighted item in the buffer, then submit if `submit`. + Take { + submit: bool, + }, + Dismiss, +} + +impl PopupAction { + /// `None` leaves the key to the editor. + fn of(key: KeyEvent, completion: &CompletionController) -> Option { + match key.code { + KeyCode::Up => Some(Self::Move(-1)), + KeyCode::Down => Some(Self::Move(1)), + KeyCode::Esc => Some(Self::Dismiss), + KeyCode::Enter | KeyCode::Tab if key.modifiers.is_empty() => { + // Nothing highlighted means nothing to take, so the key stays + // the editor's: Enter submits and Tab indents. + let item = completion.highlighted()?; + // Tab leaves room to keep typing; Enter only submits an item + // that is a whole input on its own. + Some(Self::Take { + submit: key.code == KeyCode::Enter && item.accept == Accept::Complete, + }) + } + _ => None, + } + } +} + fn is_multiline_enter(key: KeyEvent) -> bool { matches!(key.code, KeyCode::Char('\n' | '\r')) || (matches!(key.code, KeyCode::Char('j' | 'm')) @@ -1127,6 +1169,131 @@ mod tests { assert!(!completion.is_open()); } + /// An open popup showing no matches has nothing to accept, so Enter is the + /// editor's and submits the line. + #[test] + fn enter_submits_when_the_popup_has_no_matches() { + let mut state = UiState::new(); + let mut completion = completion_with(&["alpha.txt"]); + + for character in "@zzz".chars() { + state.handle_event(key(KeyCode::Char(character)), &[], &mut completion); + } + assert!(completion.is_open()); + assert_eq!(completion.item_count(), 0); + + let command = state.handle_event(key(KeyCode::Enter), &[], &mut completion); + + assert!(matches!( + command, + Some(Command::Submit { text, .. }) if text == "@zzz" + )); + } + + /// Only Enter on a whole input submits. Tab never does, and neither does + /// Enter on a path, which is only ever part of a prompt. + #[test] + fn only_enter_on_a_whole_input_submits() { + let plain = |code| KeyEvent::new(code, KeyModifiers::NONE); + + let mut commands = completion_with_commands(); + commands.sync("/he", 3); + assert_eq!( + PopupAction::of(plain(KeyCode::Enter), &commands), + Some(PopupAction::Take { submit: true }) + ); + assert_eq!( + PopupAction::of(plain(KeyCode::Tab), &commands), + Some(PopupAction::Take { submit: false }) + ); + + let mut paths = completion_with(&["src/main.rs"]); + paths.sync("/plan @src", 10); + assert_eq!( + PopupAction::of(plain(KeyCode::Enter), &paths), + Some(PopupAction::Take { submit: false }) + ); + } + + /// Keys the popup declines stay the editor's, which is what keeps Enter + /// submitting, Tab indenting, and Shift+Tab toggling plan mode. + #[test] + fn the_popup_declines_keys_it_cannot_act_on() { + let plain = |code| KeyEvent::new(code, KeyModifiers::NONE); + let mut completion = completion_with_commands(); + + completion.sync("/zzz", 4); + assert_eq!(completion.item_count(), 0, "nothing to accept"); + assert_eq!(PopupAction::of(plain(KeyCode::Enter), &completion), None); + assert_eq!(PopupAction::of(plain(KeyCode::Tab), &completion), None); + + completion.sync("/he", 3); + let shift_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT); + assert_eq!(PopupAction::of(shift_tab, &completion), None); + assert_eq!( + PopupAction::of(plain(KeyCode::Esc), &completion), + Some(PopupAction::Dismiss) + ); + } + + /// Picking a command is the whole input, so one Enter runs it. + #[test] + fn slash_completion_runs_on_a_single_enter() { + let mut state = UiState::new(); + let mut completion = completion_with_commands(); + + for character in "/he".chars() { + state.handle_event(key(KeyCode::Char(character)), &[], &mut completion); + } + assert!(completion.is_open()); + + let command = state.handle_event(key(KeyCode::Enter), &[], &mut completion); + + let Some(Command::Submit { text, .. }) = command else { + panic!("expected a submit, got {command:?}"); + }; + // The accepted name carries a trailing separator, so it has to parse + // with one. + assert_eq!(SlashCommand::parse(&text), Some(SlashCommand::Help)); + assert!(!completion.is_open()); + } + + /// Tab completes the name without running it, which is what leaves room to + /// type an argument after a command that grows one. + #[test] + fn tab_completes_a_command_without_running_it() { + let mut state = UiState::new(); + let mut completion = completion_with_commands(); + + for character in "/he".chars() { + state.handle_event(key(KeyCode::Char(character)), &[], &mut completion); + } + let command = state.handle_event(key(KeyCode::Tab), &[], &mut completion); + + assert_eq!(command, None); + assert_eq!(state.editor_text(), "/help "); + } + + /// The `runs` flag belongs to the backend that offered the item, so a path + /// accepted inside a command line inserts itself and nothing more. + #[test] + fn accepting_a_path_inside_a_command_does_not_run_the_command() { + let mut state = UiState::new(); + let mut completion = completion_with(&["src/main.rs"]); + + for character in "/plan @src".chars() { + state.handle_event(key(KeyCode::Char(character)), &[], &mut completion); + } + assert!(completion.is_open()); + + let command = state.handle_event(key(KeyCode::Enter), &[], &mut completion); + + assert_eq!(command, None, "accepting a mention must not run /plan"); + // The line parses as `/plan`, so the flag rather than the text is what + // keeps it from running. + assert_eq!(state.editor_text(), "/plan @src/main.rs "); + } + /// `TextArea::cursor()` reports columns in characters while the line is /// sliced by byte offset, so a multi-byte character between `@` and the /// cursor must not put the two out of step. From db67fff272431d489883ffb26efdf3988e44c795 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Sat, 29 Aug 2026 20:03:44 +0530 Subject: [PATCH 2/6] rename highlighted to selected_item Pairs with the existing selected() index, and frees up highlight for the text styling it already means elsewhere in the view. --- crates/alan/src/core/completion/commands.rs | 4 ---- crates/alan/src/core/completion/mod.rs | 10 +++++----- crates/alan/src/views/mod.rs | 8 ++++---- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/crates/alan/src/core/completion/commands.rs b/crates/alan/src/core/completion/commands.rs index ca74275..0bd5a87 100644 --- a/crates/alan/src/core/completion/commands.rs +++ b/crates/alan/src/core/completion/commands.rs @@ -29,8 +29,6 @@ impl CompletionBackend for Commands { '/' } - /// Commands take no arguments, so one opens the line or is not a command at - /// all: anywhere else a `/` is a path separator, as in `explain /usr/bin`. fn complete(&self, request: &CompletionRequest) -> Option { // The range starts after the one-byte trigger, so 1 is column 0. if request.range.start != 1 { @@ -49,8 +47,6 @@ impl CompletionBackend for Commands { } } -/// `/help — list the available commands`. Parsing the name back is what keeps -/// the description tied to the variant rather than to a second list. fn describe_slash_command(name: &str) -> String { let Ok(command) = name.parse::() else { return format!("/{name}"); diff --git a/crates/alan/src/core/completion/mod.rs b/crates/alan/src/core/completion/mod.rs index f170ec6..191d254 100644 --- a/crates/alan/src/core/completion/mod.rs +++ b/crates/alan/src/core/completion/mod.rs @@ -192,16 +192,16 @@ impl CompletionController { active.selected = (active.selected as isize + delta).clamp(0, max) as usize; } - /// The item accepting would take, which is also what makes an empty popup - /// distinguishable from one with something to offer. - pub fn highlighted(&self) -> Option<&CompletionItem> { + /// The item at [`Self::selected`], which is `None` when the popup has + /// nothing to offer. + pub fn selected_item(&self) -> Option<&CompletionItem> { let active = self.active.as_ref()?; active.result.items.get(active.selected) } - /// The highlighted item and the byte range of the line it overwrites. + /// The selected item and the byte range of the line it overwrites. pub fn accept(&mut self) -> Option<(CompletionItem, Range)> { - let item = self.highlighted()?.clone(); + let item = self.selected_item()?.clone(); let range = self.active.as_ref()?.result.range.clone(); self.active = None; Some((item, range)) diff --git a/crates/alan/src/views/mod.rs b/crates/alan/src/views/mod.rs index 5fe7586..bf89498 100644 --- a/crates/alan/src/views/mod.rs +++ b/crates/alan/src/views/mod.rs @@ -683,7 +683,7 @@ impl UiState { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PopupAction { Move(isize), - /// Put the highlighted item in the buffer, then submit if `submit`. + /// Put the selected item in the buffer, then submit if `submit`. Take { submit: bool, }, @@ -698,9 +698,9 @@ impl PopupAction { KeyCode::Down => Some(Self::Move(1)), KeyCode::Esc => Some(Self::Dismiss), KeyCode::Enter | KeyCode::Tab if key.modifiers.is_empty() => { - // Nothing highlighted means nothing to take, so the key stays - // the editor's: Enter submits and Tab indents. - let item = completion.highlighted()?; + // Nothing selected means nothing to take, so the key stays the + // editor's: Enter submits and Tab indents. + let item = completion.selected_item()?; // Tab leaves room to keep typing; Enter only submits an item // that is a whole input on its own. Some(Self::Take { From 83fc7ffe0c6bd44f19e75640e7e8e22c37c9f19f Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Sat, 29 Aug 2026 20:05:45 +0530 Subject: [PATCH 3/6] rename popup message to placeholder Names what it stands in for rather than its return type, and the doc now says what None means. --- crates/alan/src/views/components/popup.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/alan/src/views/components/popup.rs b/crates/alan/src/views/components/popup.rs index 07f28de..f7b8821 100644 --- a/crates/alan/src/views/components/popup.rs +++ b/crates/alan/src/views/components/popup.rs @@ -20,8 +20,10 @@ const CONTENT_PADDING: Padding = Padding::new(2, 2, 1, 1); pub struct PopupList; impl PopupList { - /// The status shown in place of candidates, if any. - fn message(status: CompletionStatus, item_count: usize) -> Option { + /// The one line drawn in place of the candidate list: the scan running, + /// the error that stopped it, or nothing matching what was typed. `None` + /// means the candidates themselves are what gets drawn. + fn placeholder(status: CompletionStatus, item_count: usize) -> Option { match status { CompletionStatus::Loading => Some("Loading…".to_owned()), CompletionStatus::Error(error) => Some(error), @@ -33,7 +35,7 @@ impl PopupList { /// Rows the content will occupy, so the box never reserves space for /// candidates that are not there. pub fn required_rows(status: CompletionStatus, item_count: usize) -> u16 { - if Self::message(status, item_count).is_some() { + if Self::placeholder(status, item_count).is_some() { return 1; } item_count.min(VISIBLE_ROWS) as u16 @@ -72,9 +74,9 @@ impl Component for PopupList { if !completion.is_open() || area.is_empty() { return; } - if let Some(message) = Self::message(completion.status(), completion.item_count()) { + if let Some(placeholder) = Self::placeholder(completion.status(), completion.item_count()) { frame.render_widget( - Paragraph::new(message) + Paragraph::new(placeholder) .style(Style::default().bg(theme::EDITOR_BG)) .block(Block::default().padding(CONTENT_PADDING)), area, @@ -156,7 +158,7 @@ mod tests { assert_eq!(PopupList::required_rows(Ready, 3), 3); assert_eq!(PopupList::required_rows(Ready, 99), VISIBLE_ROWS as u16); - // A status message is one row however many candidates sit behind it. + // A placeholder is one row however many candidates sit behind it. assert_eq!(PopupList::required_rows(Ready, 0), 1); assert_eq!(PopupList::required_rows(Loading, 9), 1); assert_eq!(PopupList::required_rows(Error("nope".into()), 9), 1); From 1529c45741285fa7b7fae112481de71a0794cb08 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Sat, 29 Aug 2026 20:09:51 +0530 Subject: [PATCH 4/6] pass the selected item to PopupAction::of The key decision only ever read selected_item, so taking it directly makes of a pure function of the key and that item, and its tests stop needing a controller. --- crates/alan/src/views/mod.rs | 59 +++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/crates/alan/src/views/mod.rs b/crates/alan/src/views/mod.rs index bf89498..c63c94a 100644 --- a/crates/alan/src/views/mod.rs +++ b/crates/alan/src/views/mod.rs @@ -9,8 +9,8 @@ pub mod selection; mod theme; use crate::core::{ - Accept, Action, Command, CompletionController, Controller, ImageAttachment, Overlay, Poll, - SlashCommand, + Accept, Action, Command, CompletionController, CompletionItem, Controller, ImageAttachment, + Overlay, Poll, SlashCommand, }; use base64::Engine; use components::{Chat, Footer, Header, LoginOverlay}; @@ -159,7 +159,7 @@ impl UiState { } if let Event::Key(key) = &event && completion.is_open() - && let Some(action) = PopupAction::of(*key, completion) + && let Some(action) = PopupAction::of(*key, completion.selected_item()) { return self.apply_completion_popup(action, completion); } @@ -692,17 +692,13 @@ enum PopupAction { impl PopupAction { /// `None` leaves the key to the editor. - fn of(key: KeyEvent, completion: &CompletionController) -> Option { + fn of(key: KeyEvent, selected: Option<&CompletionItem>) -> Option { match key.code { KeyCode::Up => Some(Self::Move(-1)), KeyCode::Down => Some(Self::Move(1)), KeyCode::Esc => Some(Self::Dismiss), KeyCode::Enter | KeyCode::Tab if key.modifiers.is_empty() => { - // Nothing selected means nothing to take, so the key stays the - // editor's: Enter submits and Tab indents. - let item = completion.selected_item()?; - // Tab leaves room to keep typing; Enter only submits an item - // that is a whole input on its own. + let item = selected?; Some(Self::Take { submit: key.code == KeyCode::Enter && item.accept == Accept::Complete, }) @@ -1190,27 +1186,36 @@ mod tests { )); } + fn plain(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + /// Only the `accept` field decides anything, so the text is left empty. + fn item(accept: Accept) -> CompletionItem { + CompletionItem { + display: String::new(), + replacement: String::new(), + accept, + } + } + /// Only Enter on a whole input submits. Tab never does, and neither does /// Enter on a path, which is only ever part of a prompt. #[test] fn only_enter_on_a_whole_input_submits() { - let plain = |code| KeyEvent::new(code, KeyModifiers::NONE); - - let mut commands = completion_with_commands(); - commands.sync("/he", 3); + let command = item(Accept::Complete); assert_eq!( - PopupAction::of(plain(KeyCode::Enter), &commands), + PopupAction::of(plain(KeyCode::Enter), Some(&command)), Some(PopupAction::Take { submit: true }) ); assert_eq!( - PopupAction::of(plain(KeyCode::Tab), &commands), + PopupAction::of(plain(KeyCode::Tab), Some(&command)), Some(PopupAction::Take { submit: false }) ); - let mut paths = completion_with(&["src/main.rs"]); - paths.sync("/plan @src", 10); + let path = item(Accept::Insert); assert_eq!( - PopupAction::of(plain(KeyCode::Enter), &paths), + PopupAction::of(plain(KeyCode::Enter), Some(&path)), Some(PopupAction::Take { submit: false }) ); } @@ -1219,19 +1224,17 @@ mod tests { /// submitting, Tab indenting, and Shift+Tab toggling plan mode. #[test] fn the_popup_declines_keys_it_cannot_act_on() { - let plain = |code| KeyEvent::new(code, KeyModifiers::NONE); - let mut completion = completion_with_commands(); + // Nothing selected leaves the accepting keys to the editor. + assert_eq!(PopupAction::of(plain(KeyCode::Enter), None), None); + assert_eq!(PopupAction::of(plain(KeyCode::Tab), None), None); - completion.sync("/zzz", 4); - assert_eq!(completion.item_count(), 0, "nothing to accept"); - assert_eq!(PopupAction::of(plain(KeyCode::Enter), &completion), None); - assert_eq!(PopupAction::of(plain(KeyCode::Tab), &completion), None); - - completion.sync("/he", 3); + let selected = item(Accept::Complete); let shift_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT); - assert_eq!(PopupAction::of(shift_tab, &completion), None); + assert_eq!(PopupAction::of(shift_tab, Some(&selected)), None); + + // Dismissing never depends on there being something to take. assert_eq!( - PopupAction::of(plain(KeyCode::Esc), &completion), + PopupAction::of(plain(KeyCode::Esc), None), Some(PopupAction::Dismiss) ); } From c17d8044ac6ec8958cb58240dd184b031cf0d81d Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Sat, 29 Aug 2026 20:23:32 +0530 Subject: [PATCH 5/6] only offer commands on the first line A command is the whole input, so a `/` opening a continuation line is prose. Offering one there let the popup take keys the editor needed: Up and Down moved the selection instead of moving between lines, Tab inserted a command name instead of indenting, and Enter rewrote the line before submitting, so `hello\n/he` went out as `hello\n/help `. `CompletionRequest` now carries the row, which is what lets `Commands` decline anywhere but the start of the buffer. --- crates/alan/src/core/completion/commands.rs | 25 ++++++++++----- crates/alan/src/core/completion/mod.rs | 34 ++++++++++++--------- crates/alan/src/core/completion/paths.rs | 3 +- crates/alan/src/views/mod.rs | 30 +++++++++++++++++- 4 files changed, 68 insertions(+), 24 deletions(-) diff --git a/crates/alan/src/core/completion/commands.rs b/crates/alan/src/core/completion/commands.rs index 0bd5a87..8689a5c 100644 --- a/crates/alan/src/core/completion/commands.rs +++ b/crates/alan/src/core/completion/commands.rs @@ -30,8 +30,9 @@ impl CompletionBackend for Commands { } fn complete(&self, request: &CompletionRequest) -> Option { - // The range starts after the one-byte trigger, so 1 is column 0. - if request.range.start != 1 { + // A command is the whole input, so it can only open the buffer. The + // range starts after the one-byte trigger, so 1 is column 0. + if request.row != 0 || request.range.start != 1 { return None; } @@ -66,7 +67,7 @@ mod tests { #[test] fn a_lone_slash_lists_every_command() { let mut engine = engine(); - engine.sync("/", 1); + engine.sync("/", 1, 0); assert!(engine.is_open()); assert_eq!(engine.item_count(), SlashCommand::iter().count()); @@ -75,7 +76,7 @@ mod tests { #[test] fn a_pattern_narrows_to_the_matching_commands() { let mut engine = engine(); - engine.sync("/he", 3); + engine.sync("/he", 3, 0); let items = engine.items(0, engine.item_count()); assert_eq!(items.len(), 1); @@ -87,7 +88,7 @@ mod tests { #[test] fn an_item_is_displayed_with_its_description() { let mut engine = engine(); - engine.sync("/help", 5); + engine.sync("/help", 5, 0); assert_eq!( engine.items(0, 1)[0].display, @@ -99,7 +100,17 @@ mod tests { #[test] fn a_slash_inside_the_line_is_not_a_command() { let mut engine = engine(); - engine.sync("explain /usr", 12); + engine.sync("explain /usr", 12, 0); + + assert!(!engine.is_open()); + } + + /// A command is the whole input, so a `/` opening a continuation line is + /// prose. Offering one there would submit the half-written prompt around it. + #[test] + fn a_slash_opening_a_later_line_is_not_a_command() { + let mut engine = engine(); + engine.sync("/he", 3, 1); assert!(!engine.is_open()); } @@ -108,7 +119,7 @@ mod tests { #[test] fn accepting_replaces_the_name_and_keeps_the_slash() { let mut engine = engine(); - engine.sync("/he", 3); + engine.sync("/he", 3, 0); let (item, range) = engine.accept().unwrap(); assert_eq!(item.replacement, "help"); diff --git a/crates/alan/src/core/completion/mod.rs b/crates/alan/src/core/completion/mod.rs index 191d254..acd93a2 100644 --- a/crates/alan/src/core/completion/mod.rs +++ b/crates/alan/src/core/completion/mod.rs @@ -25,6 +25,8 @@ pub struct CompletionRequest { pub pattern: String, /// Bytes of the line the pattern occupies, which accepting overwrites. pub range: Range, + /// Line of the buffer the token sits on, which is what tells a backend + pub row: usize, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -113,14 +115,15 @@ impl CompletionController { } } - /// Re-evaluate after every editor change. - pub fn sync(&mut self, line: &str, cursor: usize) { - self.active = self.claim(line, cursor); + /// Re-evaluate after every editor change. `line` is the one the cursor is + /// on and `row` is where it sits in the buffer. + pub fn sync(&mut self, line: &str, cursor: usize, row: usize) { + self.active = self.claim(line, cursor, row); } /// Any step failing means no completion applies here: no token under the /// cursor, no backend for its trigger, or the backend declining. - fn claim(&mut self, line: &str, cursor: usize) -> Option { + fn claim(&mut self, line: &str, cursor: usize, row: usize) -> Option { let token = token::at(line, cursor)?; // Read before the backend is borrowed mutably. let switching = self.active.as_ref().map(|active| active.trigger) != Some(token.trigger); @@ -134,6 +137,7 @@ impl CompletionController { let request = CompletionRequest { pattern: line[token.range.clone()].to_owned(), range: token.range, + row, }; let result = backend.complete(&request)?; @@ -280,7 +284,7 @@ mod tests { #[test] fn an_unclaimed_trigger_opens_nothing() { let mut engine = engine(&["src/main.rs"]); - engine.sync("#tag", 4); + engine.sync("#tag", 4, 0); assert!(!engine.is_open()); } @@ -297,7 +301,7 @@ mod tests { #[test] fn an_at_token_opens_completion_anywhere_in_the_line() { let mut engine = engine(&["src/main.rs", "docs/"]); - engine.sync("explain @mai", 12); + engine.sync("explain @mai", 12, 0); assert!(engine.is_open()); assert_eq!(displayed(&engine), ["src/main.rs"]); @@ -306,8 +310,8 @@ mod tests { #[test] fn plain_text_closes_the_popup() { let mut engine = engine(&["src/main.rs"]); - engine.sync("@src", 4); - engine.sync("hello", 5); + engine.sync("@src", 4, 0); + engine.sync("hello", 5, 0); assert!(!engine.is_open()); } @@ -315,7 +319,7 @@ mod tests { #[test] fn selection_stays_inside_the_items() { let mut engine = engine(&["a.txt", "b.txt"]); - engine.sync("@", 1); + engine.sync("@", 1, 0); assert_eq!(engine.item_count(), 2); engine.move_selection(50); @@ -328,7 +332,7 @@ mod tests { #[test] fn accepting_reports_the_range_it_overwrites() { let mut engine = engine(&["src/main.rs"]); - engine.sync("explain @mai", 12); + engine.sync("explain @mai", 12, 0); let (item, range) = engine.accept().unwrap(); assert_eq!(item.replacement, "src/main.rs"); @@ -341,7 +345,7 @@ mod tests { #[test] fn accepting_a_directory_closes_the_popup() { let mut engine = engine(&["crates/", "crates/alan/"]); - engine.sync("@crat", 5); + engine.sync("@crat", 5, 0); let (item, range) = engine.accept().unwrap(); assert_eq!(item.replacement, "crates/"); @@ -353,11 +357,11 @@ mod tests { #[test] fn typing_past_a_directory_reopens_the_popup() { let mut engine = engine(&["crates/", "crates/alan/main.rs"]); - engine.sync("@crates/", 8); + engine.sync("@crates/", 8, 0); engine.accept(); assert!(!engine.is_open()); - engine.sync("@crates/m", 9); + engine.sync("@crates/m", 9, 0); assert!(engine.is_open()); assert_eq!(displayed(&engine), ["crates/alan/main.rs"]); @@ -366,7 +370,7 @@ mod tests { #[test] fn accepting_nothing_when_no_candidate_matched() { let mut engine = engine(&["src/main.rs"]); - engine.sync("@zzz", 4); + engine.sync("@zzz", 4, 0); assert_eq!(engine.item_count(), 0); assert!(engine.accept().is_none()); @@ -375,7 +379,7 @@ mod tests { #[test] fn items_are_bounded_by_the_window_asked_for() { let mut engine = engine(&["a.txt", "b.txt", "c.txt"]); - engine.sync("@", 1); + engine.sync("@", 1, 0); assert_eq!(engine.items(0, 2).len(), 2); assert_eq!(engine.items(2, 5).len(), 1); diff --git a/crates/alan/src/core/completion/paths.rs b/crates/alan/src/core/completion/paths.rs index 6a08bae..14d7708 100644 --- a/crates/alan/src/core/completion/paths.rs +++ b/crates/alan/src/core/completion/paths.rs @@ -387,7 +387,7 @@ mod tests { let mut completion = CompletionController::new(vec![Box::new(Paths::new(root.clone()))]); - completion.sync("@mai", 4); + completion.sync("@mai", 4, 0); assert!(completion.is_open()); assert_eq!(completion.item_count(), 0); @@ -414,6 +414,7 @@ mod tests { CompletionRequest { pattern: pattern.to_owned(), range, + row: 0, } } diff --git a/crates/alan/src/views/mod.rs b/crates/alan/src/views/mod.rs index c63c94a..d18545d 100644 --- a/crates/alan/src/views/mod.rs +++ b/crates/alan/src/views/mod.rs @@ -370,7 +370,7 @@ impl UiState { let (row, col) = self.editor.cursor(); let line = self.editor.lines().get(row).map_or("", String::as_str); let cursor = Self::char_offset(line, col.min(line.chars().count())); - completion.sync(line, cursor); + completion.sync(line, cursor, row); } fn handle_mouse_event( @@ -1239,6 +1239,34 @@ mod tests { ); } + /// A `/` opening a continuation line is prose, not a command, so the popup + /// stays shut and the text submitted is the text that was typed. + #[test] + fn a_slash_on_a_later_line_is_left_alone() { + let mut state = UiState::new(); + let mut completion = completion_with_commands(); + + for character in "hello".chars() { + state.handle_event(key(KeyCode::Char(character)), &[], &mut completion); + } + state.handle_event( + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)), + &[], + &mut completion, + ); + for character in "/he".chars() { + state.handle_event(key(KeyCode::Char(character)), &[], &mut completion); + } + assert!(!completion.is_open()); + + let command = state.handle_event(key(KeyCode::Enter), &[], &mut completion); + + assert!(matches!( + command, + Some(Command::Submit { text, .. }) if text == "hello\n/he" + )); + } + /// Picking a command is the whole input, so one Enter runs it. #[test] fn slash_completion_runs_on_a_single_enter() { From f0c29e3ea5c669a98809245504e1982873768481 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Sun, 30 Aug 2026 13:22:20 +0530 Subject: [PATCH 6/6] hold commands as their enum, not their names `ranked_items` narrowed the candidates to `&[String]` even though `rank_all` was already generic, so the backend had to store names and parse them back to reach a description. Widening it lets `Commands` hold `SlashCommand` directly and drops the round trip. --- crates/alan/src/core/command.rs | 6 ++++++ crates/alan/src/core/completion/commands.rs | 19 +++++-------------- crates/alan/src/core/completion/mod.rs | 5 +++-- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/crates/alan/src/core/command.rs b/crates/alan/src/core/command.rs index 3b71966..ef7d614 100644 --- a/crates/alan/src/core/command.rs +++ b/crates/alan/src/core/command.rs @@ -55,6 +55,12 @@ impl SlashCommand { } } +impl AsRef for SlashCommand { + fn as_ref(&self) -> &str { + self.into() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/alan/src/core/completion/commands.rs b/crates/alan/src/core/completion/commands.rs index 8689a5c..a84f671 100644 --- a/crates/alan/src/core/completion/commands.rs +++ b/crates/alan/src/core/completion/commands.rs @@ -11,15 +11,13 @@ use crate::core::SlashCommand; use strum::IntoEnumIterator; pub struct Commands { - names: Vec, + commands: Vec, } impl Default for Commands { fn default() -> Self { Self { - names: SlashCommand::iter() - .map(|command| <&'static str>::from(command).to_owned()) - .collect(), + commands: SlashCommand::iter().collect(), } } } @@ -39,22 +37,15 @@ impl CompletionBackend for Commands { Some(CompletionResult { range: request.range.clone(), status: CompletionStatus::Ready, - items: ranked_items(&request.pattern, &self.names, |name| CompletionItem { - display: describe_slash_command(name), - replacement: name.to_owned(), + items: ranked_items(&request.pattern, &self.commands, |command| CompletionItem { + display: format!("{} — {}", command.name(), command.description()), + replacement: command.as_ref().to_owned(), accept: Accept::Complete, }), }) } } -fn describe_slash_command(name: &str) -> String { - let Ok(command) = name.parse::() else { - return format!("/{name}"); - }; - format!("{} — {}", command.name(), command.description()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/alan/src/core/completion/mod.rs b/crates/alan/src/core/completion/mod.rs index acd93a2..f810ef7 100644 --- a/crates/alan/src/core/completion/mod.rs +++ b/crates/alan/src/core/completion/mod.rs @@ -250,9 +250,10 @@ impl CompletionController { } /// Shared so no backend can invent its own ordering. -fn ranked_items(pattern: &str, candidates: &[String], item: F) -> Vec +fn ranked_items(pattern: &str, candidates: &[C], item: F) -> Vec where - F: Fn(&str) -> CompletionItem, + C: AsRef, + F: Fn(&C) -> CompletionItem, { matcher::rank_all(pattern, candidates) .into_iter()