返回 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::reasoning_preference::ReasoningEffort::Auto => {
74 "choose per turn".into()
75 }
76 crate::reasoning_preference::ReasoningEffort::Off => {
77 "no extra reasoning".into()
78 }
79 crate::reasoning_preference::ReasoningEffort::Minimal => {
80 "minimal reasoning".into()
81 }
82 crate::reasoning_preference::ReasoningEffort::Low => {
83 "lighter reasoning".into()
84 }
85 crate::reasoning_preference::ReasoningEffort::Medium => {
86 "balanced reasoning".into()
87 }
88 crate::reasoning_preference::ReasoningEffort::High => {
89 "deeper reasoning".into()
90 }
91 crate::reasoning_preference::ReasoningEffort::XHigh => {
92 "extra-high reasoning".into()
93 }
94 crate::reasoning_preference::ReasoningEffort::Ultra => {
95 "ultra reasoning".into()
96 }
97 crate::reasoning_preference::ReasoningEffort::Max => {
98 "maximum reasoning".into()
99 }
100 },
101 is_skill: false,
102 alias_hint: None,
103 });
104 }
105 }
106 if !effort_entries.is_empty() {
107 return effort_entries.into_iter().take(limit).collect();
108 }
109 }
110 slash_completion_hints_with_model_candidates(
111 &app.input,
112 limit,
113 &app.cached_skills,
114 app.ui_locale,
115 Some(&app.workspace),
116 &model_candidates,
117 )
118 }
119
120 /// Apply the currently-selected slash menu entry to the composer input.
121 /// Optionally appends a trailing space when the command takes arguments
122 /// so the user can type the rest without an extra keystroke.
123 pub fn apply_slash_menu_selection(
124 app: &mut App,
125 entries: &[SlashMenuEntry],
126 append_space: bool,
127 ) -> bool {
128 if entries.is_empty() {
129 return false;
130 }
131
132 let selected_idx = app.slash_menu_selected.min(entries.len().saturating_sub(1));
133 let selected = &entries[selected_idx];
134
135 if selected.is_skill
136 && let Some((byte_start, partial)) =
137 partial_inline_skill_mention_at_cursor(&app.input, app.cursor_position)
138 && let Some(skill_name) = skill_name_from_menu_entry(selected)
139 {
140 let trigger = app.input[byte_start..].chars().next().unwrap_or('/');
141 replace_inline_skill_mention(app, byte_start, trigger, &partial, &skill_name);
142 app.slash_menu_hidden = false;
143 app.status_message = Some(format!("Skill selected: {trigger}{skill_name}"));
144 return true;
145 }
146
147 let mut command = selected.name.clone();
148
149 let command_key = command.trim_start_matches('/');
150 let user_takes_arguments =
151 commands::user_registry::with_registry_for_workspace(Some(&app.workspace), |registry| {
152 registry
153 .get(command_key)
154 .map(|metadata| metadata.takes_arguments())
155 });
156 let takes_arguments = user_takes_arguments.unwrap_or_else(|| {
157 commands::get_command_info(command_key)
158 .is_some_and(|info| info.composer_wants_trailing_space())
159 });
160
161 if append_space
162 && !command.ends_with(' ')
163 && !command.contains(char::is_whitespace)
164 && takes_arguments
165 {
166 command.push(' ');
167 }
168
169 app.input = command;
170 app.cursor_position = app.input.chars().count();
171 app.slash_menu_hidden = false;
172 app.status_message = Some(format!("Command selected: {}", app.input.trim_end()));
173 true
174 }
175
176 /// Return the `/<skill>` or `$<skill>` token under the cursor when it is used as
177 /// an inline mention inside a normal message. A `/` or `$` at the start of the
178 /// composer, even after leading whitespace, remains reserved for slash commands
179 /// (handled by `slash_completion_hints`).
180 pub(crate) fn partial_inline_skill_mention_at_cursor(
181 input: &str,
182 cursor_chars: usize,
183 ) -> Option<(usize, String)> {
184 if looks_like_slash_command_input(input) {
185 return None;
186 }
187
188 let chars: Vec<char> = input.chars().collect();
189 if cursor_chars > chars.len() {
190 return None;
191 }
192
193 let mut start_chars = cursor_chars;
194 while start_chars > 0 {
195 let prev = chars[start_chars - 1];
196 if prev == '/' || prev == '$' {
197 start_chars -= 1;
198 break;
199 }
200 if prev.is_whitespace() {
201 return None;
202 }
203 start_chars -= 1;
204 }
205
206 if start_chars == cursor_chars {
207 return None;
208 }
209 let trigger = *chars.get(start_chars)?;
210 if trigger != '/' && trigger != '$' {
211 return None;
212 }
213 if !is_inline_skill_mention_start(&chars, start_chars) {
214 return None;
215 }
216
217 let byte_start: usize = chars[..start_chars].iter().map(|c| c.len_utf8()).sum();
218 if input[..byte_start].trim().is_empty() {
219 return None;
220 }
221
222 let mut end_chars = start_chars + 1;
223 while end_chars < chars.len() && !chars[end_chars].is_whitespace() {
224 end_chars += 1;
225 }
226 let partial: String = chars[start_chars + 1..end_chars].iter().collect();
227 if partial.contains('/') || partial.contains('$') {
228 return None;
229 }
230
231 Some((byte_start, partial))
232 }
233
234 fn is_inline_skill_mention_start(chars: &[char], idx: usize) -> bool {
235 if idx == 0 {
236 return false;
237 }
238 chars
239 .get(idx.saturating_sub(1))
240 .is_some_and(|ch| ch.is_whitespace() || matches!(ch, '(' | '[' | '{' | '<' | '"' | '\''))
241 }
242
243 fn skill_mention_entries(
244 partial: &str,
245 trigger: char,
246 limit: usize,
247 cached_skills: &[(String, String)],
248 ) -> Vec<SlashMenuEntry> {
249 if limit == 0 {
250 return Vec::new();
251 }
252 let partial_lower = partial.to_ascii_lowercase();
253 let mut entries = cached_skills
254 .iter()
255 .filter(|(skill_name, _)| skill_name.to_ascii_lowercase().starts_with(&partial_lower))
256 .map(|(skill_name, skill_desc)| SlashMenuEntry {
257 name: format!("{trigger}{skill_name}"),
258 description: skill_desc.clone(),
259 is_skill: true,
260 alias_hint: None,
261 })
262 .collect::<Vec<_>>();
263 entries.sort_by(|a, b| a.name.cmp(&b.name));
264 entries.dedup_by(|a, b| a.name == b.name);
265 entries.into_iter().take(limit).collect()
266 }
267
268 fn skill_name_from_menu_entry(entry: &SlashMenuEntry) -> Option<String> {
269 if !entry.is_skill {
270 return None;
271 }
272 if let Some(name) = entry.name.strip_prefix("/skill ") {
273 return Some(name.trim().to_string());
274 }
275 entry
276 .name
277 .strip_prefix('/')
278 .or_else(|| entry.name.strip_prefix('$'))
279 .map(str::trim)
280 .filter(|name| !name.is_empty())
281 .map(ToString::to_string)
282 }
283
284 fn replace_inline_skill_mention(
285 app: &mut App,
286 byte_start: usize,
287 trigger: char,
288 partial: &str,
289 skill_name: &str,
290 ) {
291 let original_token_len = trigger.len_utf8() + partial.len();
292 let original_token_end = byte_start + original_token_len;
293 let mut new_input =
294 String::with_capacity(app.input.len() - original_token_len + 1 + skill_name.len());
295 new_input.push_str(&app.input[..byte_start]);
296 new_input.push(trigger);
297 new_input.push_str(skill_name);
298 if original_token_end < app.input.len() {
299 new_input.push_str(&app.input[original_token_end..]);
300 }
301 let new_cursor_chars = app.input[..byte_start].chars().count() + 1 + skill_name.chars().count();
302 app.input = new_input;
303 app.cursor_position = new_cursor_chars;
304 }
305
306 /// Tab-completion for a slash-command-like input. Extends the input to the
307 /// longest unambiguous prefix; if exactly one command matches, completes it
308 /// fully (with trailing space). On ambiguity, posts a status hint listing
309 /// up to five candidates. Also considers skill names as completion candidates.
310 pub fn try_autocomplete_slash_command(app: &mut App) -> bool {
311 if !looks_like_slash_command_input(&app.input) {
312 return false;
313 }
314
315 let model_candidates = provider_scoped_model_completion_ids(app);
316 let candidates = slash_completion_hints_with_model_candidates(
317 &app.input,
318 128,
319 &app.cached_skills,
320 app.ui_locale,
321 Some(&app.workspace),
322 &model_candidates,
323 )
324 .into_iter()
325 .map(|entry| entry.name)
326 .collect::<Vec<_>>();
327
328 if candidates.is_empty() {
329 return false;
330 }
331
332 let prefix = app.input.trim_start_matches('/');
333 let refs: Vec<&str> = candidates
334 .iter()
335 .map(|name| name.trim_start_matches('/'))
336 .collect();
337 let shared = crate::tui::file_mention::longest_common_prefix(&refs);
338
339 if !shared.is_empty() && shared.len() > prefix.len() {
340 app.input = format!("/{shared}");
341 app.cursor_position = app.input.chars().count();
342 app.slash_menu_hidden = false;
343 app.status_message = Some(format!("Autocomplete: /{shared}"));
344 return true;
345 }
346
347 if candidates.len() == 1 {
348 let mut completed = candidates[0].clone();
349 if !completed.ends_with(' ') {
350 completed.push(' ');
351 }
352 app.input = completed.clone();
353 app.cursor_position = completed.chars().count();
354 app.slash_menu_hidden = false;
355 app.status_message = Some(format!("Command completed: {}", completed.trim_end()));
356 return true;
357 }
358
359 let preview = candidates
360 .iter()
361 .take(5)
362 .map(String::as_str)
363 .collect::<Vec<_>>()
364 .join(", ");
365 app.status_message = Some(format!("Suggestions: {preview}"));
366 true
367 }
368
368 lines RUST