use std::path::Path;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::commands::{self, user_registry};
use crate::localization::{Locale, tr};
use crate::tui::keybindings::KEYBINDINGS;
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
};
const MAX_COMMANDS: usize = 12;
const MAX_KEYBINDINGS: usize = 12;
pub struct TuiHelpTool;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CommandEntry {
command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
usage: Option<String>,
description: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
aliases: Vec<String>,
source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct KeybindingEntry {
chord: String,
description: String,
section: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct HelpOutput {
#[serde(default, skip_serializing_if = "Option::is_none")]
query: Option<String>,
commands: Vec<CommandEntry>,
keybindings: Vec<KeybindingEntry>,
#[serde(default, skip_serializing_if = "is_zero")]
omitted_commands: usize,
#[serde(default, skip_serializing_if = "is_zero")]
omitted_keybindings: usize,
note: String,
}
fn is_zero(value: &usize) -> bool {
*value == 0
}
#[async_trait]
impl ToolSpec for TuiHelpTool {
fn name(&self) -> &'static str {
"tui_help"
}
fn description(&self) -> &'static str {
"Look up CodeWhale slash commands and keyboard shortcuts. Pass a query to scope the answer; omit it for a compact cheatsheet."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Topic, command name, or keyword to scope the answer (for example \"mode\", \"compact\", \"Ctrl+R\"). Omit for a compact cheatsheet."
}
},
"required": [],
"additionalProperties": false
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
fn supports_parallel(&self) -> bool {
true
}
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
let query = match input.get("query") {
None | Some(Value::Null) => None,
Some(Value::String(query)) => Some(query.clone()),
Some(_) => return Err(ToolError::invalid_input("`query` must be a string")),
};
let help = build_help(ui_locale(), &context.workspace, query.as_deref());
ToolResult::json(&help).map_err(|e| ToolError::execution_failed(e.to_string()))
}
}
fn ui_locale() -> Locale {
crate::localization::resolve_locale(
&crate::settings::Settings::load_read_only()
.unwrap_or_default()
.locale,
)
}
fn build_help(locale: Locale, workspace: &Path, query: Option<&str>) -> HelpOutput {
let Some(query) = query.map(str::trim).filter(|query| !query.is_empty()) else {
return cheatsheet(locale, workspace);
};
let needle = query.to_lowercase();
let mut commands: Vec<(u8, CommandEntry)> = all_commands(locale, workspace)
.into_iter()
.filter_map(|entry| entry.rank(&needle).map(|rank| (rank, entry)))
.collect();
commands.sort_by_key(|(rank, _)| *rank);
let mut keybindings: Vec<(u8, KeybindingEntry)> = all_keybindings(locale)
.into_iter()
.filter_map(|entry| entry.rank(&needle).map(|rank| (rank, entry)))
.collect();
keybindings.sort_by_key(|(rank, _)| *rank);
let note = if commands.is_empty() && keybindings.is_empty() {
format!(
"No command or keybinding matches '{query}'. Call tui_help with no query for the cheatsheet."
)
} else {
format!("Commands and keybindings matching '{query}'.")
};
HelpOutput {
query: Some(query.to_string()),
omitted_commands: commands.len().saturating_sub(MAX_COMMANDS),
omitted_keybindings: keybindings.len().saturating_sub(MAX_KEYBINDINGS),
commands: take_entries(commands, MAX_COMMANDS),
keybindings: take_entries(keybindings, MAX_KEYBINDINGS),
note,
}
}
fn cheatsheet(locale: Locale, workspace: &Path) -> HelpOutput {
let mut entries = user_commands(workspace);
entries.extend(
commands::command_infos()
.into_iter()
.filter(|info| info.show_in_empty_discovery())
.map(|info| builtin_entry(info, locale)),
);
let keybindings = all_keybindings(locale);
HelpOutput {
query: None,
omitted_commands: entries.len().saturating_sub(MAX_COMMANDS),
omitted_keybindings: keybindings.len().saturating_sub(MAX_KEYBINDINGS),
commands: entries.into_iter().take(MAX_COMMANDS).collect(),
keybindings: keybindings.into_iter().take(MAX_KEYBINDINGS).collect(),
note: "Compact cheatsheet. Pass a query to reach advanced commands and the rest of the keybindings.".to_string(),
}
}
fn take_entries<T>(ranked: Vec<(u8, T)>, limit: usize) -> Vec<T> {
ranked
.into_iter()
.take(limit)
.map(|(_, entry)| entry)
.collect()
}
fn builtin_entry(info: &'static commands::traits::CommandInfo, locale: Locale) -> CommandEntry {
let command = format!("/{}", info.name);
CommandEntry {
usage: Some(info.usage.trim().to_string()).filter(|usage| *usage != command),
command,
description: info.description_for(locale).into_owned(),
aliases: info.aliases.iter().map(|alias| alias.to_string()).collect(),
source: "builtin".to_string(),
}
}
fn all_commands(locale: Locale, workspace: &Path) -> Vec<CommandEntry> {
let mut entries: Vec<CommandEntry> = commands::command_infos()
.into_iter()
.map(|info| builtin_entry(info, locale))
.collect();
entries.extend(user_commands(workspace));
entries
}
fn user_commands(workspace: &Path) -> Vec<CommandEntry> {
user_registry::with_registry_for_workspace(Some(workspace), |registry| {
let mut entries: Vec<CommandEntry> = registry
.iter()
.filter(|metadata| !metadata.hidden)
.map(|metadata| CommandEntry {
command: format!("/{}", metadata.name),
usage: metadata.display_usage().map(str::to_string),
description: metadata.description.clone().unwrap_or_default(),
aliases: metadata.aliases.clone(),
source: "user".to_string(),
})
.collect();
entries.sort_by(|a, b| a.command.cmp(&b.command));
entries
})
}
fn all_keybindings(locale: Locale) -> Vec<KeybindingEntry> {
KEYBINDINGS
.iter()
.map(|binding| KeybindingEntry {
chord: binding.chord.to_string(),
description: tr(locale, binding.description_id).into_owned(),
section: binding.section.label(locale).into_owned(),
})
.collect()
}
impl CommandEntry {
fn rank(&self, needle: &str) -> Option<u8> {
let name = self.command.trim_start_matches('/').to_lowercase();
if name == needle
|| self
.aliases
.iter()
.any(|alias| alias.to_lowercase() == needle)
{
return Some(0);
}
if name.starts_with(needle) {
return Some(1);
}
if name.contains(needle) {
return Some(2);
}
if self.description.to_lowercase().contains(needle)
|| self
.usage
.as_deref()
.is_some_and(|usage| usage.to_lowercase().contains(needle))
{
return Some(3);
}
None
}
}
impl KeybindingEntry {
fn rank(&self, needle: &str) -> Option<u8> {
if self.chord.to_lowercase().contains(needle) {
return Some(0);
}
if self.description.to_lowercase().contains(needle)
|| self.section.to_lowercase().contains(needle)
{
return Some(1);
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn scoped_query_answers_from_the_command_and_keybinding_registries() {
let tmp = tempdir().expect("tempdir");
let help = build_help(Locale::En, tmp.path(), Some("mode"));
let mode = help
.commands
.iter()
.find(|entry| entry.command == "/mode")
.unwrap_or_else(|| panic!("no /mode in {:?}", help.commands));
let info = commands::get_command_info("mode").expect("registered /mode");
assert_eq!(mode.usage.as_deref(), Some(info.usage));
assert_eq!(mode.description, info.description_for(Locale::En));
assert!(
help.keybindings
.iter()
.all(|entry| KEYBINDINGS.iter().any(|kb| kb.chord == entry.chord)),
"chords must come from the keybinding catalog: {:?}",
help.keybindings
);
assert!(
!help.keybindings.is_empty(),
"the Modes keybinding section should match 'mode'"
);
}
#[test]
fn exact_command_name_outranks_incidental_mentions() {
let tmp = tempdir().expect("tempdir");
let help = build_help(Locale::En, tmp.path(), Some("compact"));
assert_eq!(
help.commands.first().map(|entry| entry.command.as_str()),
Some("/compact")
);
}
#[test]
fn unmatched_query_returns_nothing_rather_than_everything() {
let tmp = tempdir().expect("tempdir");
let help = build_help(Locale::En, tmp.path(), Some("zzzz-no-such-topic"));
assert!(help.commands.is_empty());
assert!(help.keybindings.is_empty());
assert!(help.note.starts_with("No command or keybinding matches"));
}
#[test]
fn output_stays_bounded_and_reports_what_it_dropped() {
let tmp = tempdir().expect("tempdir");
for query in [None, Some("e")] {
let help = build_help(Locale::En, tmp.path(), query);
assert!(help.commands.len() <= MAX_COMMANDS, "{query:?}");
assert!(help.keybindings.len() <= MAX_KEYBINDINGS, "{query:?}");
}
let broad = build_help(Locale::En, tmp.path(), Some("e"));
assert!(
broad.omitted_commands > 0,
"a catalog-wide query must report the dropped matches"
);
}
#[test]
fn blank_query_falls_back_to_the_cheatsheet() {
let tmp = tempdir().expect("tempdir");
let help = build_help(Locale::En, tmp.path(), Some(" "));
assert!(help.query.is_none());
assert!(!help.commands.is_empty());
}
#[tokio::test]
async fn execute_returns_json_and_rejects_non_string_queries() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path());
let result = TuiHelpTool
.execute(json!({ "query": "mode" }), &ctx)
.await
.expect("execute");
assert!(result.success);
let parsed: HelpOutput =
serde_json::from_str(&result.content).expect("tool result should be json");
assert_eq!(parsed.query.as_deref(), Some("mode"));
let error = TuiHelpTool
.execute(json!({ "query": 7 }), &ctx)
.await
.expect_err("non-string query");
assert!(error.to_string().contains("`query` must be a string"));
}
}