1use crate::attachment::PromptAttachment;
2use crate::view::filterable_list::FilterableList;
3use crate::request::RequestId;
4use crate::view::list_view::ListView;
5use crate::view::selection::SelectionState;
6use crate::theme::Theme;
7use ratatui::text::Line;
8use ratatui::widgets::{Block, Borders};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct CommandEntry {
12 pub name: String,
13 pub description: String,
14 pub has_input: bool,
15 pub hint: Option<String>,
16 pub builtin: bool,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum CompletionEntry {
21 Command(CommandEntry),
22 File(PromptAttachment),
23}
24
25#[derive(Debug, Clone)]
27pub struct CompletionOverlay {
28 trigger: char,
29 entries: FilterableList<CompletionEntry>,
30 empty_message: &'static str,
31 pending_index: Option<RequestId>,
33}
34
35impl CompletionOverlay {
36 pub fn command(entries: Vec<CommandEntry>) -> Self {
37 Self::new('/', entries.into_iter().map(CompletionEntry::Command).collect(), "no matching commands")
38 }
39
40 pub fn file(request_id: RequestId) -> Self {
43 let mut overlay = Self::new('@', Vec::new(), "indexing files…");
44 overlay.pending_index = Some(request_id);
45 overlay
46 }
47
48 pub fn set_files(&mut self, request_id: RequestId, files: Vec<PromptAttachment>) {
51 if self.pending_index != Some(request_id) {
52 return;
53 }
54 self.pending_index = None;
55 self.empty_message = "no matching files";
56 let query = self.entries.query().to_string();
57 self.entries =
58 FilterableList::new(files.into_iter().map(CompletionEntry::File).collect(), CompletionEntry::match_key);
59 self.entries.set_query(query);
60 }
61
62 pub fn trigger(&self) -> char {
65 self.trigger
66 }
67
68 pub fn query(&self) -> &str {
69 self.entries.query()
70 }
71
72 pub fn set_query(&mut self, query: String) {
73 self.entries.set_query(query);
74 }
75
76 pub fn entries_mut(&mut self) -> &mut FilterableList<CompletionEntry> {
77 &mut self.entries
78 }
79
80 pub fn selected_command(&self) -> Option<CommandEntry> {
81 match self.entries.selected_entry()? {
82 CompletionEntry::Command(command) => Some(command.clone()),
83 CompletionEntry::File(_) => None,
84 }
85 }
86
87 pub fn selected_file(&self) -> Option<PromptAttachment> {
88 match self.entries.selected_entry()? {
89 CompletionEntry::File(file) => Some(file.clone()),
90 CompletionEntry::Command(_) => None,
91 }
92 }
93
94 pub fn row_count(&self, max_rows: usize) -> usize {
97 1 + self.entries.filtered_len().clamp(1, max_rows.max(1))
98 }
99
100 pub fn view<'a>(&'a mut self, theme: &'a Theme) -> (ListView<'a>, &'a mut SelectionState) {
101 let empty_message = self.empty_message;
102 let (view, selection) = self
103 .entries
104 .view(theme, |entry| Line::styled(format!(" {}", entry.label()), theme.text_secondary));
105 let view = view
106 .empty_message(empty_message)
107 .block(Block::new().borders(Borders::TOP).border_style(theme.muted));
108 (view, selection)
109 }
110
111 fn new(trigger: char, entries: Vec<CompletionEntry>, empty_message: &'static str) -> Self {
112 Self {
113 trigger,
114 entries: FilterableList::new(entries, CompletionEntry::match_key),
115 empty_message,
116 pending_index: None,
117 }
118 }
119}
120
121impl CompletionEntry {
122 fn label(&self) -> String {
123 match self {
124 Self::Command(command) => {
125 let hint = command.hint.as_deref().map_or_else(String::new, |hint| format!(" [{hint}]"));
126 format!("/{:<16} {}{hint}", command.name, command.description)
127 }
128 Self::File(file) => file.display_name.clone(),
129 }
130 }
131
132 fn match_key(&self) -> String {
133 match self {
134 Self::Command(command) => format!("{} {}", command.name, command.description),
135 Self::File(file) => file.display_name.clone(),
136 }
137 }
138}