返回 CodeWhale
tui_help.rs
根目录 / crates / tui / src / tools / tui_help.rs
1 //! On-demand command and keybinding reference: `tui_help`.
2 //!
3 //! Every field is read back out of the registries the human-facing help
4 //! renders from — `commands::command_infos()`, the user-command registry, and
5 //! `tui::keybindings::KEYBINDINGS` — so the model-facing reference cannot
6 //! drift from `/help` and the help overlay.
7
8 use std::path::Path;
9
10 use async_trait::async_trait;
11 use serde::{Deserialize, Serialize};
12 use serde_json::{Value, json};
13
14 use crate::commands::{self, user_registry};
15 use crate::tui::keybindings::KEYBINDINGS;
16 use codewhale_localization::{Locale, tr};
17
18 use super::spec::{
19 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
20 };
21
22 /// Per-section cap. The command catalog is well over a hundred entries; an
23 /// unscoped dump would cost more context than any answer it contains.
24 const MAX_COMMANDS: usize = 12;
25 const MAX_KEYBINDINGS: usize = 12;
26
27 /// Tool for looking up slash commands and keybindings.
28 pub struct TuiHelpTool;
29
30 #[derive(Debug, Clone, Serialize, Deserialize)]
31 struct CommandEntry {
32 command: String,
33 /// Omitted when the usage string carries nothing beyond the command name.
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 usage: Option<String>,
36 description: String,
37 #[serde(default, skip_serializing_if = "Vec::is_empty")]
38 aliases: Vec<String>,
39 source: String,
40 }
41
42 #[derive(Debug, Clone, Serialize, Deserialize)]
43 struct KeybindingEntry {
44 chord: String,
45 description: String,
46 section: String,
47 }
48
49 #[derive(Debug, Clone, Serialize, Deserialize)]
50 struct HelpOutput {
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 query: Option<String>,
53 commands: Vec<CommandEntry>,
54 keybindings: Vec<KeybindingEntry>,
55 /// Matches dropped by the cap, so the model narrows the query instead of
56 /// assuming it saw everything.
57 #[serde(default, skip_serializing_if = "is_zero")]
58 omitted_commands: usize,
59 #[serde(default, skip_serializing_if = "is_zero")]
60 omitted_keybindings: usize,
61 note: String,
62 }
63
64 fn is_zero(value: &usize) -> bool {
65 *value == 0
66 }
67
68 #[async_trait]
69 impl ToolSpec for TuiHelpTool {
70 fn name(&self) -> &'static str {
71 "tui_help"
72 }
73
74 fn description(&self) -> &'static str {
75 "Look up Codewhale slash commands and keyboard shortcuts. Pass a query to scope the answer; omit it for a compact cheatsheet."
76 }
77
78 fn input_schema(&self) -> Value {
79 json!({
80 "type": "object",
81 "properties": {
82 "query": {
83 "type": "string",
84 "description": "Topic, command name, or keyword to scope the answer (for example \"mode\", \"compact\", \"Ctrl+R\"). Omit for a compact cheatsheet."
85 }
86 },
87 "required": [],
88 "additionalProperties": false
89 })
90 }
91
92 fn capabilities(&self) -> Vec<ToolCapability> {
93 vec![ToolCapability::ReadOnly]
94 }
95
96 fn approval_requirement(&self) -> ApprovalRequirement {
97 ApprovalRequirement::Auto
98 }
99
100 fn supports_parallel(&self) -> bool {
101 true
102 }
103
104 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
105 let query = match input.get("query") {
106 None | Some(Value::Null) => None,
107 Some(Value::String(query)) => Some(query.clone()),
108 Some(_) => return Err(ToolError::invalid_input("`query` must be a string")),
109 };
110
111 let help = build_help(ui_locale(), &context.workspace, query.as_deref());
112 ToolResult::json(&help).map_err(|e| ToolError::execution_failed(e.to_string()))
113 }
114 }
115
116 // === Helpers ===
117
118 /// The reference is rendered from the same localized strings the overlay uses,
119 /// so it follows the configured UI locale rather than the model's language.
120 /// `load_read_only` keeps the lookup free of the legacy-settings migration
121 /// write that `Settings::load` performs.
122 fn ui_locale() -> Locale {
123 codewhale_localization::resolve_locale(
124 &crate::settings::Settings::load_read_only()
125 .unwrap_or_default()
126 .locale,
127 )
128 }
129
130 fn build_help(locale: Locale, workspace: &Path, query: Option<&str>) -> HelpOutput {
131 let Some(query) = query.map(str::trim).filter(|query| !query.is_empty()) else {
132 return cheatsheet(locale, workspace);
133 };
134 let needle = query.to_lowercase();
135
136 let mut commands: Vec<(u8, CommandEntry)> = all_commands(locale, workspace)
137 .into_iter()
138 .filter_map(|entry| entry.rank(&needle).map(|rank| (rank, entry)))
139 .collect();
140 commands.sort_by_key(|(rank, _)| *rank);
141
142 let mut keybindings: Vec<(u8, KeybindingEntry)> = all_keybindings(locale)
143 .into_iter()
144 .filter_map(|entry| entry.rank(&needle).map(|rank| (rank, entry)))
145 .collect();
146 keybindings.sort_by_key(|(rank, _)| *rank);
147
148 let note = if commands.is_empty() && keybindings.is_empty() {
149 format!(
150 "No command or keybinding matches '{query}'. Call tui_help with no query for the cheatsheet."
151 )
152 } else {
153 format!("Commands and keybindings matching '{query}'.")
154 };
155
156 HelpOutput {
157 query: Some(query.to_string()),
158 omitted_commands: commands.len().saturating_sub(MAX_COMMANDS),
159 omitted_keybindings: keybindings.len().saturating_sub(MAX_KEYBINDINGS),
160 commands: take_entries(commands, MAX_COMMANDS),
161 keybindings: take_entries(keybindings, MAX_KEYBINDINGS),
162 note,
163 }
164 }
165
166 /// Unscoped answer: the commands the help overlay shows at its root, capped
167 /// like every other answer.
168 fn cheatsheet(locale: Locale, workspace: &Path) -> HelpOutput {
169 // User commands lead: they are workspace-specific and unguessable, where a
170 // built-in is stable enough to be worth querying by name.
171 let mut entries = user_commands(workspace);
172 entries.extend(
173 commands::command_infos()
174 .into_iter()
175 .filter(|info| info.show_in_empty_discovery())
176 .map(|info| builtin_entry(info, locale)),
177 );
178 let keybindings = all_keybindings(locale);
179
180 HelpOutput {
181 query: None,
182 omitted_commands: entries.len().saturating_sub(MAX_COMMANDS),
183 omitted_keybindings: keybindings.len().saturating_sub(MAX_KEYBINDINGS),
184 commands: entries.into_iter().take(MAX_COMMANDS).collect(),
185 keybindings: keybindings.into_iter().take(MAX_KEYBINDINGS).collect(),
186 note: "Compact cheatsheet. Pass a query to reach advanced commands and the rest of the keybindings.".to_string(),
187 }
188 }
189
190 fn take_entries<T>(ranked: Vec<(u8, T)>, limit: usize) -> Vec<T> {
191 ranked
192 .into_iter()
193 .take(limit)
194 .map(|(_, entry)| entry)
195 .collect()
196 }
197
198 fn builtin_entry(info: &'static commands::traits::CommandInfo, locale: Locale) -> CommandEntry {
199 let command = format!("/{}", info.name);
200 CommandEntry {
201 usage: Some(info.usage.trim().to_string()).filter(|usage| *usage != command),
202 command,
203 description: info.description_for(locale).into_owned(),
204 aliases: info.aliases.iter().map(|alias| alias.to_string()).collect(),
205 source: "builtin".to_string(),
206 }
207 }
208
209 fn all_commands(locale: Locale, workspace: &Path) -> Vec<CommandEntry> {
210 let mut entries: Vec<CommandEntry> = commands::command_infos()
211 .into_iter()
212 // Unlisted commands still run when typed; help does not teach them.
213 .filter(|info| !info.is_unlisted())
214 .map(|info| builtin_entry(info, locale))
215 .collect();
216 entries.extend(user_commands(workspace));
217 entries
218 }
219
220 /// The user registry is keyed by a hash map, so sort for a stable answer.
221 fn user_commands(workspace: &Path) -> Vec<CommandEntry> {
222 user_registry::with_registry_for_workspace(Some(workspace), |registry| {
223 let mut entries: Vec<CommandEntry> = registry
224 .iter()
225 .filter(|metadata| !metadata.hidden)
226 .map(|metadata| CommandEntry {
227 command: format!("/{}", metadata.name),
228 usage: metadata.display_usage().map(str::to_string),
229 description: metadata.description.clone().unwrap_or_default(),
230 aliases: metadata.aliases.clone(),
231 source: "user".to_string(),
232 })
233 .collect();
234 entries.sort_by(|a, b| a.command.cmp(&b.command));
235 entries
236 })
237 }
238
239 fn all_keybindings(locale: Locale) -> Vec<KeybindingEntry> {
240 KEYBINDINGS
241 .iter()
242 .map(|binding| KeybindingEntry {
243 chord: binding.chord.to_string(),
244 description: tr(locale, binding.description_id).into_owned(),
245 section: binding.section.label(locale).into_owned(),
246 })
247 .collect()
248 }
249
250 impl CommandEntry {
251 /// Relevance rank, lowest first; `None` when the entry does not match.
252 fn rank(&self, needle: &str) -> Option<u8> {
253 let name = self.command.trim_start_matches('/').to_lowercase();
254 if name == needle
255 || self
256 .aliases
257 .iter()
258 .any(|alias| alias.to_lowercase() == needle)
259 {
260 return Some(0);
261 }
262 if name.starts_with(needle) {
263 return Some(1);
264 }
265 if name.contains(needle) {
266 return Some(2);
267 }
268 if self.description.to_lowercase().contains(needle)
269 || self
270 .usage
271 .as_deref()
272 .is_some_and(|usage| usage.to_lowercase().contains(needle))
273 {
274 return Some(3);
275 }
276 None
277 }
278 }
279
280 impl KeybindingEntry {
281 fn rank(&self, needle: &str) -> Option<u8> {
282 if self.chord.to_lowercase().contains(needle) {
283 return Some(0);
284 }
285 if self.description.to_lowercase().contains(needle)
286 || self.section.to_lowercase().contains(needle)
287 {
288 return Some(1);
289 }
290 None
291 }
292 }
293
294 #[cfg(test)]
295 mod tests {
296 use super::*;
297 use tempfile::tempdir;
298
299 #[test]
300 fn scoped_query_answers_from_the_command_and_keybinding_registries() {
301 let tmp = tempdir().expect("tempdir");
302 let help = build_help(Locale::En, tmp.path(), Some("mode"));
303
304 let mode = help
305 .commands
306 .iter()
307 .find(|entry| entry.command == "/mode")
308 .unwrap_or_else(|| panic!("no /mode in {:?}", help.commands));
309 let info = commands::get_command_info("mode").expect("registered /mode");
310 assert_eq!(mode.usage.as_deref(), Some(info.usage));
311 assert_eq!(mode.description, info.description_for(Locale::En));
312
313 assert!(
314 help.keybindings
315 .iter()
316 .all(|entry| KEYBINDINGS.iter().any(|kb| kb.chord == entry.chord)),
317 "chords must come from the keybinding catalog: {:?}",
318 help.keybindings
319 );
320 assert!(
321 !help.keybindings.is_empty(),
322 "the Modes keybinding section should match 'mode'"
323 );
324 }
325
326 #[test]
327 fn exact_command_name_outranks_incidental_mentions() {
328 let tmp = tempdir().expect("tempdir");
329 let help = build_help(Locale::En, tmp.path(), Some("compact"));
330
331 assert_eq!(
332 help.commands.first().map(|entry| entry.command.as_str()),
333 Some("/compact")
334 );
335 }
336
337 #[test]
338 fn unmatched_query_returns_nothing_rather_than_everything() {
339 let tmp = tempdir().expect("tempdir");
340 let help = build_help(Locale::En, tmp.path(), Some("zzzz-no-such-topic"));
341
342 assert!(help.commands.is_empty());
343 assert!(help.keybindings.is_empty());
344 assert!(help.note.starts_with("No command or keybinding matches"));
345 }
346
347 #[test]
348 fn output_stays_bounded_and_reports_what_it_dropped() {
349 let tmp = tempdir().expect("tempdir");
350 // A query every entry matches on the description axis would otherwise
351 // dump the whole catalog.
352 for query in [None, Some("e")] {
353 let help = build_help(Locale::En, tmp.path(), query);
354 assert!(help.commands.len() <= MAX_COMMANDS, "{query:?}");
355 assert!(help.keybindings.len() <= MAX_KEYBINDINGS, "{query:?}");
356 }
357
358 let broad = build_help(Locale::En, tmp.path(), Some("e"));
359 assert!(
360 broad.omitted_commands > 0,
361 "a catalog-wide query must report the dropped matches"
362 );
363 }
364
365 #[test]
366 fn blank_query_falls_back_to_the_cheatsheet() {
367 let tmp = tempdir().expect("tempdir");
368 let help = build_help(Locale::En, tmp.path(), Some(" "));
369
370 assert!(help.query.is_none());
371 assert!(!help.commands.is_empty());
372 }
373
374 #[tokio::test]
375 async fn execute_returns_json_and_rejects_non_string_queries() {
376 let tmp = tempdir().expect("tempdir");
377 let ctx = ToolContext::new(tmp.path());
378
379 let result = TuiHelpTool
380 .execute(json!({ "query": "mode" }), &ctx)
381 .await
382 .expect("execute");
383 assert!(result.success);
384 let parsed: HelpOutput =
385 serde_json::from_str(&result.content).expect("tool result should be json");
386 assert_eq!(parsed.query.as_deref(), Some("mode"));
387
388 let error = TuiHelpTool
389 .execute(json!({ "query": 7 }), &ctx)
390 .await
391 .expect_err("non-string query");
392 assert!(error.to_string().contains("`query` must be a string"));
393 }
394 }
395
395 lines RUST