返回 CodeWhale
slash_menu.rs
根目录 / crates / tui / src / tui / slash_menu.rs
1 //! Slash-command autocomplete + popup-menu helpers.
2 //!
3 //! Extracted from `tui/ui.rs` (P1.2). The on-screen popup itself is rendered
4 //! by the composer widget; these helpers source the entries, apply a
5 //! selection, and handle Tab-completion when the popup isn't open.
6 //!
7 //! Intentionally separate from `tui::file_mention` even though both surface
8 //! a similar popup — the trigger characters, ranking, and post-selection
9 //! behaviour differ enough to keep them apart.
10
11 use crate::commands;
12
13 use super::app::{App, looks_like_slash_command_input};
14 use super::model_picker::provider_scoped_model_completion_ids;
15 use super::widgets::SlashMenuEntry;
16 use super::widgets::slash_completion_hints_with_model_candidates;
17
18 /// Return the slash-menu entries the composer should display, honouring
19 /// `slash_menu_hidden` (set when the user dismisses the popup with Esc).
20 pub fn visible_slash_menu_entries(app: &App, limit: usize) -> Vec<SlashMenuEntry> {
21 if app.slash_menu_hidden {
22 return Vec::new();
23 }
24 if let Some((byte_start, partial)) =
25 partial_inline_skill_mention_at_cursor(&app.input, app.cursor_position)
26 {
27 let trigger = app.input[byte_start..].chars().next().unwrap_or('/');
28 return skill_mention_entries(&partial, trigger, limit, &app.cached_skills);
29 }
30 if !looks_like_slash_command_input(&app.input) {
31 return Vec::new();
32 }
33 // Building the cross-provider model inventory is unnecessary while the
34 // user is merely typing `/model`; command-name completion needs no model
35 // rows. Only pay that cost once an argument prefix exists.
36 let trimmed = app.input.trim_start();
37 let needs_model_candidates = trimmed
38 .strip_prefix("/model")
39 .is_some_and(|rest| rest.starts_with(char::is_whitespace));
40 let model_candidates = if needs_model_candidates {
41 provider_scoped_model_completion_ids(app)
42 } else {
43 Vec::new()
44 };
45 // Effort completions are per-model: only show levels the current model supports
46 let lower = trimmed.to_ascii_lowercase();
47 if lower.starts_with("/effort ") || lower.starts_with("/thinking ") {
48 let arg_prefix = if lower.starts_with("/effort ") {
49 trimmed[8..].trim_start()
50 } else {
51 trimmed[10..].trim_start()
52 };
53 let provider = app.api_provider;
54 let base_url = app.active_route_base_url.clone();
55 let wire_model = app.model.clone();
56 let available = crate::tui::model_picker::picker_efforts_for_route(
57 provider,
58 &base_url,
59 &wire_model,
60 app.auto_model,
61 );
62 let mut effort_entries: Vec<SlashMenuEntry> = Vec::new();
63 for eff in available {
64 let label = eff.display_label_for_provider(provider).to_string();
65 if label
66 .to_ascii_lowercase()
67 .starts_with(&arg_prefix.to_ascii_lowercase())
68 || arg_prefix.is_empty()
69 {
70 effort_entries.push(SlashMenuEntry {
71 name: format!("/effort {}", label),
72 description: match eff {
73 crate::tui::app::ReasoningEffort::Auto => "choose per turn".into(),
74 crate::tui::app::ReasoningEffort::Off => "no extra reasoning".into(),
75 crate::tui::app::ReasoningEffort::Minimal => "minimal reasoning".into(),
76 crate::tui::app::ReasoningEffort::Low => "lighter reasoning".into(),
77 crate::tui::app::ReasoningEffort::Medium => "balanced reasoning".into(),
78 crate::tui::app::ReasoningEffort::High => "deeper reasoning".into(),
79 crate::tui::app::ReasoningEffort::XHigh => "extra-high reasoning".into(),
80 crate::tui::app::ReasoningEffort::Ultra => "ultra reasoning".into(),
81 crate::tui::app::ReasoningEffort::Max => "maximum reasoning".into(),
82 },
83 is_skill: false,
84 alias_hint: None,
85 });
86 }
87 }
88 if !effort_entries.is_empty() {
89 return effort_entries.into_iter().take(limit).collect();
90 }
91 }
92 slash_completion_hints_with_model_candidates(
93 &app.input,
94 limit,
95 &app.cached_skills,
96 app.ui_locale,
97 Some(&app.workspace),
98 &model_candidates,
99 )
100 }
101
102 /// Apply the currently-selected slash menu entry to the composer input.
103 /// Optionally appends a trailing space when the command takes arguments
104 /// so the user can type the rest without an extra keystroke.
105 pub fn apply_slash_menu_selection(
106 app: &mut App,
107 entries: &[SlashMenuEntry],
108 append_space: bool,
109 ) -> bool {
110 if entries.is_empty() {
111 return false;
112 }
113
114 let selected_idx = app.slash_menu_selected.min(entries.len().saturating_sub(1));
115 let selected = &entries[selected_idx];
116
117 if selected.is_skill
118 && let Some((byte_start, partial)) =
119 partial_inline_skill_mention_at_cursor(&app.input, app.cursor_position)
120 && let Some(skill_name) = skill_name_from_menu_entry(selected)
121 {
122 let trigger = app.input[byte_start..].chars().next().unwrap_or('/');
123 replace_inline_skill_mention(app, byte_start, trigger, &partial, &skill_name);
124 app.slash_menu_hidden = false;
125 app.status_message = Some(format!("Skill selected: {trigger}{skill_name}"));
126 return true;
127 }
128
129 let mut command = selected.name.clone();
130
131 let command_key = command.trim_start_matches('/');
132 let user_takes_arguments =
133 commands::user_registry::with_registry_for_workspace(Some(&app.workspace), |registry| {
134 registry
135 .get(command_key)
136 .map(|metadata| metadata.takes_arguments())
137 });
138 let takes_arguments = user_takes_arguments.unwrap_or_else(|| {
139 commands::get_command_info(command_key)
140 .is_some_and(|info| info.composer_wants_trailing_space())
141 });
142
143 if append_space
144 && !command.ends_with(' ')
145 && !command.contains(char::is_whitespace)
146 && takes_arguments
147 {
148 command.push(' ');
149 }
150
151 app.input = command;
152 app.cursor_position = app.input.chars().count();
153 app.slash_menu_hidden = false;
154 app.status_message = Some(format!("Command selected: {}", app.input.trim_end()));
155 true
156 }
157
158 /// Return the `/<skill>` or `$<skill>` token under the cursor when it is used as
159 /// an inline mention inside a normal message. A `/` or `$` at the start of the
160 /// composer, even after leading whitespace, remains reserved for slash commands
161 /// (handled by `slash_completion_hints`).
162 pub(crate) fn partial_inline_skill_mention_at_cursor(
163 input: &str,
164 cursor_chars: usize,
165 ) -> Option<(usize, String)> {
166 if looks_like_slash_command_input(input) {
167 return None;
168 }
169
170 let chars: Vec<char> = input.chars().collect();
171 if cursor_chars > chars.len() {
172 return None;
173 }
174
175 let mut start_chars = cursor_chars;
176 while start_chars > 0 {
177 let prev = chars[start_chars - 1];
178 if prev == '/' || prev == '$' {
179 start_chars -= 1;
180 break;
181 }
182 if prev.is_whitespace() {
183 return None;
184 }
185 start_chars -= 1;
186 }
187
188 if start_chars == cursor_chars {
189 return None;
190 }
191 let trigger = *chars.get(start_chars)?;
192 if trigger != '/' && trigger != '$' {
193 return None;
194 }
195 if !is_inline_skill_mention_start(&chars, start_chars) {
196 return None;
197 }
198
199 let byte_start: usize = chars[..start_chars].iter().map(|c| c.len_utf8()).sum();
200 if input[..byte_start].trim().is_empty() {
201 return None;
202 }
203
204 let mut end_chars = start_chars + 1;
205 while end_chars < chars.len() && !chars[end_chars].is_whitespace() {
206 end_chars += 1;
207 }
208 let partial: String = chars[start_chars + 1..end_chars].iter().collect();
209 if partial.contains('/') || partial.contains('$') {
210 return None;
211 }
212
213 Some((byte_start, partial))
214 }
215
216 fn is_inline_skill_mention_start(chars: &[char], idx: usize) -> bool {
217 if idx == 0 {
218 return false;
219 }
220 chars
221 .get(idx.saturating_sub(1))
222 .is_some_and(|ch| ch.is_whitespace() || matches!(ch, '(' | '[' | '{' | '<' | '"' | '\''))
223 }
224
225 fn skill_mention_entries(
226 partial: &str,
227 trigger: char,
228 limit: usize,
229 cached_skills: &[(String, String)],
230 ) -> Vec<SlashMenuEntry> {
231 if limit == 0 {
232 return Vec::new();
233 }
234 let partial_lower = partial.to_ascii_lowercase();
235 let mut entries = cached_skills
236 .iter()
237 .filter(|(skill_name, _)| skill_name.to_ascii_lowercase().starts_with(&partial_lower))
238 .map(|(skill_name, skill_desc)| SlashMenuEntry {
239 name: format!("{trigger}{skill_name}"),
240 description: skill_desc.clone(),
241 is_skill: true,
242 alias_hint: None,
243 })
244 .collect::<Vec<_>>();
245 entries.sort_by(|a, b| a.name.cmp(&b.name));
246 entries.dedup_by(|a, b| a.name == b.name);
247 entries.into_iter().take(limit).collect()
248 }
249
250 fn skill_name_from_menu_entry(entry: &SlashMenuEntry) -> Option<String> {
251 if !entry.is_skill {
252 return None;
253 }
254 if let Some(name) = entry.name.strip_prefix("/skill ") {
255 return Some(name.trim().to_string());
256 }
257 entry
258 .name
259 .strip_prefix('/')
260 .or_else(|| entry.name.strip_prefix('$'))
261 .map(str::trim)
262 .filter(|name| !name.is_empty())
263 .map(ToString::to_string)
264 }
265
266 fn replace_inline_skill_mention(
267 app: &mut App,
268 byte_start: usize,
269 trigger: char,
270 partial: &str,
271 skill_name: &str,
272 ) {
273 let original_token_len = trigger.len_utf8() + partial.len();
274 let original_token_end = byte_start + original_token_len;
275 let mut new_input =
276 String::with_capacity(app.input.len() - original_token_len + 1 + skill_name.len());
277 new_input.push_str(&app.input[..byte_start]);
278 new_input.push(trigger);
279 new_input.push_str(skill_name);
280 if original_token_end < app.input.len() {
281 new_input.push_str(&app.input[original_token_end..]);
282 }
283 let new_cursor_chars = app.input[..byte_start].chars().count() + 1 + skill_name.chars().count();
284 app.input = new_input;
285 app.cursor_position = new_cursor_chars;
286 }
287
288 /// Tab-completion for a slash-command-like input. Extends the input to the
289 /// longest unambiguous prefix; if exactly one command matches, completes it
290 /// fully (with trailing space). On ambiguity, posts a status hint listing
291 /// up to five candidates. Also considers skill names as completion candidates.
292 pub fn try_autocomplete_slash_command(app: &mut App) -> bool {
293 if !looks_like_slash_command_input(&app.input) {
294 return false;
295 }
296
297 let model_candidates = provider_scoped_model_completion_ids(app);
298 let candidates = slash_completion_hints_with_model_candidates(
299 &app.input,
300 128,
301 &app.cached_skills,
302 app.ui_locale,
303 Some(&app.workspace),
304 &model_candidates,
305 )
306 .into_iter()
307 .map(|entry| entry.name)
308 .collect::<Vec<_>>();
309
310 if candidates.is_empty() {
311 return false;
312 }
313
314 let prefix = app.input.trim_start_matches('/');
315 let refs: Vec<&str> = candidates
316 .iter()
317 .map(|name| name.trim_start_matches('/'))
318 .collect();
319 let shared = crate::tui::file_mention::longest_common_prefix(&refs);
320
321 if !shared.is_empty() && shared.len() > prefix.len() {
322 app.input = format!("/{shared}");
323 app.cursor_position = app.input.chars().count();
324 app.slash_menu_hidden = false;
325 app.status_message = Some(format!("Autocomplete: /{shared}"));
326 return true;
327 }
328
329 if candidates.len() == 1 {
330 let mut completed = candidates[0].clone();
331 if !completed.ends_with(' ') {
332 completed.push(' ');
333 }
334 app.input = completed.clone();
335 app.cursor_position = completed.chars().count();
336 app.slash_menu_hidden = false;
337 app.status_message = Some(format!("Command completed: {}", completed.trim_end()));
338 return true;
339 }
340
341 let preview = candidates
342 .iter()
343 .take(5)
344 .map(String::as_str)
345 .collect::<Vec<_>>()
346 .join(", ");
347 app.status_message = Some(format!("Suggestions: {preview}"));
348 true
349 }
350
350 lines RUST