diff --git a/crates/tui/locales/en.json b/crates/tui/locales/en.json index 0afc385a9..53f0185a2 100644 --- a/crates/tui/locales/en.json +++ b/crates/tui/locales/en.json @@ -200,7 +200,7 @@ "CmdSidebarDescription": "Toggle or focus the right sidebar", "CmdSkillDescription": "Activate a skill, or install/update/uninstall/trust a community skill", "CmdSkillsDescription": "List local skills (filter by `/skills `; --remote browses the curated registry)", - "CmdSlopDescription": "Inspect or export the debt ledger", + "CmdDebtDescription": "Inspect or export the debt ledger", "CmdStashDescription": "Park or restore a composer draft (Ctrl+S sends queued follow-up first; otherwise stash, /stash list/pop)", "CmdStatusDescription": "Show runtime session status", "CmdStatuslineDescription": "Configure which items appear in the footer", diff --git a/crates/tui/locales/es-419.json b/crates/tui/locales/es-419.json index 24c243cda..39b35b85c 100644 --- a/crates/tui/locales/es-419.json +++ b/crates/tui/locales/es-419.json @@ -200,7 +200,7 @@ "CmdSidebarDescription": "Alternar o enfocar la barra lateral derecha", "CmdSkillDescription": "Activar una skill, o instalar/actualizar/desinstalar/confiar en una skill de la comunidad", "CmdSkillsDescription": "Listar skills locales (filtra con `/skills `; --remote navega el registro curado)", - "CmdSlopDescription": "Inspeccionar o exportar el libro de deuda técnica", + "CmdDebtDescription": "Inspeccionar o exportar el libro de deuda técnica", "CmdStashDescription": "Estacionar o restaurar borrador del compositor (Ctrl+S estaciona, /stash list|pop)", "CmdStatusDescription": "Mostrar el estado de la sesión en ejecución", "CmdStatuslineDescription": "Configurar qué elementos aparecen en el pie de página", diff --git a/crates/tui/locales/ja.json b/crates/tui/locales/ja.json index 90bbc9812..a28a5657d 100644 --- a/crates/tui/locales/ja.json +++ b/crates/tui/locales/ja.json @@ -200,7 +200,7 @@ "CmdSidebarDescription": "右サイドバーの表示/フォーカスを切り替え", "CmdSkillDescription": "スキルを有効化、またはコミュニティスキルをインストール/更新/アンインストール/信頼", "CmdSkillsDescription": "ローカルスキルを一覧表示(`/skills ` で絞り込み、--remote で精選レジストリを参照)", - "CmdSlopDescription": "負債台帳を確認またはエクスポート", + "CmdDebtDescription": "負債台帳を確認またはエクスポート", "CmdStashDescription": "コンポーザーの下書きを退避/復元(Ctrl+S で退避、/stash list|pop)", "CmdStatusDescription": "実行中のセッション状態を表示", "CmdStatuslineDescription": "フッターに表示する項目を設定", diff --git a/crates/tui/locales/ko.json b/crates/tui/locales/ko.json index 046a013ba..005c6a1b5 100644 --- a/crates/tui/locales/ko.json +++ b/crates/tui/locales/ko.json @@ -200,7 +200,7 @@ "CmdSidebarDescription": "오른쪽 사이드바를 전환하거나 포커스를 옮깁니다", "CmdSkillDescription": "스킬을 활성화하거나 커뮤니티 스킬을 설치/업데이트/제거/신뢰합니다", "CmdSkillsDescription": "로컬 스킬 목록을 표시합니다 (`/skills <접두사>`로 필터링, --remote는 큐레이션된 레지스트리 조회)", - "CmdSlopDescription": "부채 원장을 확인하거나 내보냅니다", + "CmdDebtDescription": "부채 원장을 확인하거나 내보냅니다", "CmdStashDescription": "입력창 초안을 보관하거나 복원합니다 (Ctrl+S는 대기 중인 후속 메시지를 먼저 전송, 그 외에는 보관, /stash list/pop)", "CmdStatusDescription": "런타임 세션 상태를 표시합니다", "CmdStatuslineDescription": "하단 표시줄에 표시할 항목을 설정합니다", diff --git a/crates/tui/locales/pt-BR.json b/crates/tui/locales/pt-BR.json index f5e7bd961..4b1736fc3 100644 --- a/crates/tui/locales/pt-BR.json +++ b/crates/tui/locales/pt-BR.json @@ -200,7 +200,7 @@ "CmdSidebarDescription": "Alternar ou focar a barra lateral direita", "CmdSkillDescription": "Ativar uma skill, ou instalar/atualizar/desinstalar/confiar em uma skill da comunidade", "CmdSkillsDescription": "Listar skills locais (filtre com `/skills `; --remote navega pelo registro curado)", - "CmdSlopDescription": "Inspecionar ou exportar o livro de dívida técnica", + "CmdDebtDescription": "Inspecionar ou exportar o livro de dívida técnica", "CmdStashDescription": "Estacionar ou restaurar rascunho do compositor (Ctrl+S estaciona, /stash list|pop)", "CmdStatusDescription": "Exibir o status da sessão em execução", "CmdStatuslineDescription": "Configurar quais itens aparecem no rodapé", diff --git a/crates/tui/locales/vi.json b/crates/tui/locales/vi.json index 604bcfef7..352ab8916 100644 --- a/crates/tui/locales/vi.json +++ b/crates/tui/locales/vi.json @@ -200,7 +200,7 @@ "CmdSidebarDescription": "Bật/tắt hoặc lấy nét thanh bên phải", "CmdSkillDescription": "Kích hoạt một kỹ năng, hoặc cài đặt/cập nhật/gỡ bỏ/tin cậy một kỹ năng cộng đồng", "CmdSkillsDescription": "Liệt kê các kỹ năng cục bộ (lọc bằng `/skills `; --remote để duyệt kho lưu trữ được kiểm duyệt)", - "CmdSlopDescription": "Kiểm tra hoặc xuất sổ nợ kỹ thuật", + "CmdDebtDescription": "Kiểm tra hoặc xuất sổ nợ kỹ thuật", "CmdStashDescription": "Tạm cất hoặc khôi phục bản nháp (Ctrl+S để cất, /stash list/pop để xem/lấy ra)", "CmdStatusDescription": "Hiển thị trạng thái thời gian chạy của phiên", "CmdStatuslineDescription": "Cấu hình các mục hiển thị ở thanh trạng thái dưới cùng", diff --git a/crates/tui/locales/zh-Hans.json b/crates/tui/locales/zh-Hans.json index e7d086688..c104d3785 100644 --- a/crates/tui/locales/zh-Hans.json +++ b/crates/tui/locales/zh-Hans.json @@ -200,7 +200,7 @@ "CmdSidebarDescription": "切换或聚焦右侧边栏", "CmdSkillDescription": "激活技能,或安装/更新/卸载/信任社区技能", "CmdSkillsDescription": "列出本地技能(用 `/skills ` 按名称前缀过滤,--remote 浏览精选注册表)", - "CmdSlopDescription": "检查或导出债务账本", + "CmdDebtDescription": "检查或导出债务账本", "CmdStashDescription": "暂存或恢复输入草稿(Ctrl+S 暂存,/stash list|pop)", "CmdStatusDescription": "显示当前运行状态", "CmdStatuslineDescription": "配置底栏要显示哪些条目", diff --git a/crates/tui/src/commands/groups/config/mod.rs b/crates/tui/src/commands/groups/config/mod.rs index 8bdef7f69..ce00aa231 100644 --- a/crates/tui/src/commands/groups/config/mod.rs +++ b/crates/tui/src/commands/groups/config/mod.rs @@ -103,9 +103,9 @@ static LOGOUT_INFO: CommandInfo = CommandInfo { }; static DEBT_INFO: CommandInfo = CommandInfo { name: "debt", - aliases: &["cleanup"], + aliases: &["cleanup", "slop", "canzha"], usage: "/debt [query|export]", - description_id: MessageId::CmdSlopDescription, + description_id: MessageId::CmdDebtDescription, }; fn run_registered(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { diff --git a/crates/tui/src/commands/groups/core/core.rs b/crates/tui/src/commands/groups/core/core.rs index 0660083c3..0d6ad8e63 100644 --- a/crates/tui/src/commands/groups/core/core.rs +++ b/crates/tui/src/commands/groups/core/core.rs @@ -17,6 +17,16 @@ use super::CommandResult; /// Show help information pub fn help(app: &mut App, topic: Option<&str>) -> CommandResult { if let Some(topic) = topic { + let user_commands = crate::commands::user_registry::with_registry_for_workspace( + Some(&app.workspace), + Clone::clone, + ); + if user_commands.get(topic).is_some() { + return CommandResult::error( + tr(app.ui_locale, MessageId::HelpUnknownCommand).replace("{topic}", topic), + ); + } + // Show help for specific command if let Some(cmd) = crate::commands::get_command_info(topic) { let mut help = format!( @@ -26,12 +36,18 @@ pub fn help(app: &mut App, topic: Option<&str>) -> CommandResult { tr(app.ui_locale, MessageId::HelpUsageLabel), cmd.usage ); - if !cmd.aliases.is_empty() { + let visible_aliases = cmd + .aliases + .iter() + .filter(|alias| user_commands.get(alias).is_none()) + .copied() + .collect::>(); + if !visible_aliases.is_empty() { let _ = write!( help, "\n {} {}", tr(app.ui_locale, MessageId::HelpAliasesLabel), - cmd.aliases.join(", ") + visible_aliases.join(", ") ); } return CommandResult::message(help); @@ -43,7 +59,8 @@ pub fn help(app: &mut App, topic: Option<&str>) -> CommandResult { // Show help overlay if app.view_stack.top_kind() != Some(ModalKind::Help) { - app.view_stack.push(HelpView::new_for_locale(app.ui_locale)); + let help = HelpView::new_for_workspace(app.ui_locale, &app.workspace); + app.view_stack.push(help); } CommandResult::ok() } diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index f53f70b6d..916c25446 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -149,8 +149,10 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { return result; } - // Permanent backward-compatible aliases. They predate the group-owned - // registry and remain documented in docs/architecture/command-dispatch.md. + // Permanent backward-compatible mode aliases. They select a fixed mode + // rather than the canonical `/mode` behavior, so they still dispatch + // before registry lookup. Ordinary compatibility aliases belong in their + // command's `CommandInfo` metadata. match command.as_str() { "jihua" => { return groups::config::dispatch(app, "jihua", arg).unwrap_or_else(|| { @@ -162,11 +164,6 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { CommandResult::error("The /zidong alias could not be dispatched.") }); } - "slop" | "canzha" => { - return groups::config::dispatch(app, "debt", arg).unwrap_or_else(|| { - CommandResult::error("The /debt command could not be dispatched.") - }); - } _ => {} } @@ -193,7 +190,10 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { if let Some(result) = groups::skills::run_skill_by_name(app, command.as_str(), arg) { return result; } - let suggestions = suggest_command_names(command.as_str(), 3); + let suggestions = + user_registry::with_registry_for_workspace(Some(&app.workspace), |user_commands| { + suggest_command_names(command.as_str(), 3, user_commands) + }); if suggestions.is_empty() { CommandResult::error(format!( "Unknown command: /{command}. Type /help for available commands." @@ -251,7 +251,42 @@ fn edit_distance(a: &str, b: &str) -> usize { previous[b_chars.len()] } -fn suggest_command_names(input: &str, limit: usize) -> Vec { +fn suggestion_score<'a>( + query: &str, + candidates: impl IntoIterator, +) -> Option<(u8, usize)> { + let mut best: Option<(u8, usize)> = None; + for candidate in candidates { + let prefix_match = candidate.starts_with(query) || query.starts_with(candidate); + let contains_match = candidate.contains(query) || query.contains(candidate); + let distance = edit_distance(candidate, query); + let close_typo = distance <= 2; + if !(prefix_match || contains_match || close_typo) { + continue; + } + + let rank = if prefix_match { + 0 + } else if contains_match { + 1 + } else { + 2 + }; + + match best { + Some((best_rank, best_distance)) + if rank > best_rank || (rank == best_rank && distance >= best_distance) => {} + _ => best = Some((rank, distance)), + } + } + best +} + +fn suggest_command_names( + input: &str, + limit: usize, + user_commands: &user_registry::UserCommandRegistry, +) -> Vec { let query = input.trim().to_ascii_lowercase(); if query.is_empty() || limit == 0 { return Vec::new(); @@ -259,32 +294,17 @@ fn suggest_command_names(input: &str, limit: usize) -> Vec { let mut scored: Vec<(u8, usize, String)> = Vec::new(); for command in registry().infos() { - let mut best: Option<(u8, usize)> = None; - for candidate in std::iter::once(command.name).chain(command.aliases.iter().copied()) { - let prefix_match = candidate.starts_with(&query) || query.starts_with(candidate); - let contains_match = candidate.contains(&query) || query.contains(candidate); - let distance = edit_distance(candidate, &query); - let close_typo = distance <= 2; - if !(prefix_match || contains_match || close_typo) { - continue; - } - - let rank = if prefix_match { - 0 - } else if contains_match { - 1 - } else { - 2 - }; - - match best { - Some((best_rank, best_distance)) - if rank > best_rank || (rank == best_rank && distance >= best_distance) => {} - _ => best = Some((rank, distance)), - } + if user_commands.get(command.name).is_some() { + continue; } - - if let Some((rank, distance)) = best { + let candidates = std::iter::once(command.name).chain( + command + .aliases + .iter() + .copied() + .filter(|alias| user_commands.get(alias).is_none()), + ); + if let Some((rank, distance)) = suggestion_score(&query, candidates) { scored.push((rank, distance, command.name.to_string())); } } @@ -433,6 +453,94 @@ mod tests { assert_eq!(links.aliases, &["dashboard", "api", "lianjie"]); } + #[test] + fn debt_compat_aliases_use_registry_discovery_and_help() { + let debt = get_command_info("debt").expect("debt command should be registered"); + assert_eq!(debt.aliases, &["cleanup", "slop", "canzha"]); + assert_eq!(debt.description_id, MessageId::CmdDebtDescription); + + for alias in ["slop", "canzha"] { + let resolved = get_command_info(alias) + .unwrap_or_else(|| panic!("/{alias} should resolve through the registry")); + assert_eq!(resolved.name, "debt"); + + let mut app = create_test_app(); + let result = execute(&format!("/help {alias}"), &mut app); + assert!(!result.is_error, "/help {alias} returned {result:?}"); + let message = result + .message + .unwrap_or_else(|| panic!("/help {alias} should return text")); + assert!( + message.starts_with("debt\n"), + "unexpected help: {message:?}" + ); + assert!( + message.contains("cleanup, slop, canzha"), + "help should list every debt alias: {message:?}" + ); + } + + let user_commands = user_registry::UserCommandRegistry::new(); + assert!( + suggest_command_names("slpo", 3, &user_commands) + .iter() + .any(|name| name == "debt"), + "typo suggestions should consider the /slop alias" + ); + } + + #[test] + fn debt_alias_help_and_suggestions_respect_user_command_shadows() { + let temp = tempdir().unwrap(); + let commands_dir = temp.path().join(".codewhale").join("commands"); + std::fs::create_dir_all(&commands_dir).unwrap(); + std::fs::write( + commands_dir.join("slop.md"), + "---\ndescription: Custom slop workflow\nargument-hint: \n---\ncustom slop $ARGUMENTS", + ) + .unwrap(); + std::fs::write( + commands_dir.join("custom-debt.md"), + "---\ndescription: Custom debt alias\nalias: canzha\n---\ncustom debt $ARGUMENTS", + ) + .unwrap(); + + let mut app = create_test_app(); + app.workspace = temp.path().to_path_buf(); + + for alias in ["slop", "canzha"] { + let result = execute(&format!("/help {alias}"), &mut app); + assert!(result.is_error, "/help {alias} returned {result:?}"); + let message = result.message.expect("shadowed alias help should error"); + assert!(message.contains(alias), "{message:?}"); + assert!(!message.contains("debt"), "{message:?}"); + } + + let debt_help = execute("/help debt", &mut app); + assert!(!debt_help.is_error); + let debt_message = debt_help + .message + .expect("canonical debt help should render"); + assert!(debt_message.contains("cleanup"), "{debt_message:?}"); + assert!(!debt_message.contains("slop"), "{debt_message:?}"); + assert!(!debt_message.contains("canzha"), "{debt_message:?}"); + + let slop_typo = execute("/slpo", &mut app); + let slop_typo_message = slop_typo.message.expect("typo should return guidance"); + assert!(!slop_typo_message.contains("/debt"), "{slop_typo_message}"); + + let canzha_typo = execute("/canzhaa", &mut app); + let canzha_typo_message = canzha_typo.message.expect("typo should return guidance"); + assert!( + !canzha_typo_message.contains("/debt"), + "{canzha_typo_message}" + ); + + let debt_typo = execute("/detb", &mut app); + let debt_typo_message = debt_typo.message.expect("typo should return guidance"); + assert!(debt_typo_message.contains("/debt"), "{debt_typo_message}"); + } + #[test] fn transcript_command_is_discoverable_and_opens_live_overlay() { let transcript = command_infos() diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index f791e3921..7ebedec98 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -291,7 +291,7 @@ pub enum MessageId { CmdSidebarDescription, CmdSkillDescription, CmdSkillsDescription, - CmdSlopDescription, + CmdDebtDescription, CmdStashDescription, CmdStatusDescription, CmdStatuslineDescription, @@ -1360,7 +1360,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CmdSidebarDescription, MessageId::CmdSkillDescription, MessageId::CmdSkillsDescription, - MessageId::CmdSlopDescription, + MessageId::CmdDebtDescription, MessageId::CmdStashDescription, MessageId::CmdStatusDescription, MessageId::CmdStatuslineDescription, diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index b659d55e3..e0080ed4a 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -1321,7 +1321,8 @@ pub(crate) fn handle_context_menu_action(app: &mut App, action: ContextMenuActio open_context_inspector(app); } ContextMenuAction::OpenHelp => { - app.view_stack.push(HelpView::new_for_locale(app.ui_locale)); + let help = HelpView::new_for_workspace(app.ui_locale, &app.workspace); + app.view_stack.push(help); } ContextMenuAction::OpenFileAtLine { cell_index } => { let width = app diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 076834d1c..73dc40a48 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -4845,8 +4845,8 @@ async fn run_event_loop( if app.view_stack.top_kind() == Some(ModalKind::Help) { app.view_stack.pop(); } else { - app.view_stack - .push(HelpView::new_for_shortcuts(app.ui_locale)); + let help = HelpView::new_for_shortcuts(app.ui_locale, &app.workspace); + app.view_stack.push(help); } continue; } diff --git a/crates/tui/src/tui/views/help.rs b/crates/tui/src/tui/views/help.rs index 9d19de7b1..7d1cfe0ce 100644 --- a/crates/tui/src/tui/views/help.rs +++ b/crates/tui/src/tui/views/help.rs @@ -17,6 +17,7 @@ use std::borrow::Cow; use std::cell::RefCell; +use std::path::Path; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ @@ -119,13 +120,30 @@ impl HelpView { Self::new_with_ordering(locale, HelpOrdering::CommandsFirst) } + pub fn new_for_workspace(locale: Locale, workspace: &Path) -> Self { + commands::user_registry::with_registry_for_workspace(Some(workspace), |registry| { + Self::new_with_registry(locale, HelpOrdering::CommandsFirst, registry) + }) + } + /// Open Help as the keyboard reference promised by shell shortcut hints. - pub fn new_for_shortcuts(locale: Locale) -> Self { - Self::new_with_ordering(locale, HelpOrdering::KeybindingsFirst) + pub fn new_for_shortcuts(locale: Locale, workspace: &Path) -> Self { + commands::user_registry::with_registry_for_workspace(Some(workspace), |registry| { + Self::new_with_registry(locale, HelpOrdering::KeybindingsFirst, registry) + }) } fn new_with_ordering(locale: Locale, ordering: HelpOrdering) -> Self { - let entries = build_entries(locale); + let registry = commands::user_registry::UserCommandRegistry::new(); + Self::new_with_registry(locale, ordering, ®istry) + } + + fn new_with_registry( + locale: Locale, + ordering: HelpOrdering, + registry: &commands::user_registry::UserCommandRegistry, + ) -> Self { + let entries = build_entries(locale, registry); let mut view = Self { locale, ordering, @@ -235,20 +253,31 @@ impl HelpView { } } -fn build_entries(locale: Locale) -> Vec { +fn build_entries( + locale: Locale, + registry: &commands::user_registry::UserCommandRegistry, +) -> Vec { let mut entries = Vec::new(); for command in commands::command_infos() { + if registry.get(command.name).is_some() { + continue; + } let label = format!("/{}", command.name); let localized = command.description_for(locale); - let description = if command.aliases.is_empty() { + let visible_aliases = command + .aliases + .iter() + .copied() + .filter(|alias| registry.get(alias).is_none()) + .collect::>(); + let description = if visible_aliases.is_empty() { localized.to_string() } else { format!( "{} (aliases: {})", localized, - command - .aliases + visible_aliases .iter() .map(|a| format!("/{a}")) .collect::>() @@ -585,11 +614,63 @@ mod tests { assert_eq!(commands.ordering, HelpOrdering::CommandsFirst); assert_eq!(first_filtered_section(&commands), HelpSection::Command); - let shortcuts = HelpView::new_for_shortcuts(Locale::En); + let shortcuts = HelpView::new_with_ordering(Locale::En, HelpOrdering::KeybindingsFirst); assert_eq!(shortcuts.ordering, HelpOrdering::KeybindingsFirst); assert_eq!(first_filtered_section(&shortcuts), HelpSection::Keybinding); } + #[test] + fn workspace_help_hides_user_shadowed_debt_aliases_from_copy_and_search() { + let tmp = tempfile::TempDir::new().unwrap(); + let commands_dir = tmp.path().join(".codewhale").join("commands"); + std::fs::create_dir_all(&commands_dir).unwrap(); + std::fs::write( + commands_dir.join("slop.md"), + "---\ndescription: Internal cleanup\nhidden: true\n---\ninternal cleanup", + ) + .unwrap(); + std::fs::write( + commands_dir.join("custom-debt.md"), + "---\ndescription: Custom debt flow\nalias: canzha\n---\ncustom debt", + ) + .unwrap(); + + for (term, mut view) in [ + ("slop", HelpView::new_for_workspace(Locale::En, tmp.path())), + ( + "canzha", + HelpView::new_for_shortcuts(Locale::En, tmp.path()), + ), + ] { + let debt = view + .entries + .iter() + .find(|entry| entry.label == "/debt") + .expect("canonical /debt help should remain visible"); + assert!(debt.description.contains("/cleanup")); + assert!(!debt.description.contains("/slop")); + assert!(!debt.description.contains("/canzha")); + + type_filter(&mut view, term); + assert!( + view.filtered + .iter() + .all(|idx| view.entries[*idx].label != "/debt") + ); + } + } + + #[test] + fn help_hides_builtins_with_shadowed_canonical_names() { + let registry = commands::user_registry::UserCommandRegistry::from_loaded(vec![( + "debt".to_string(), + "---\ndescription: Custom debt\n---\ncustom debt".to_string(), + )]); + let entries = build_entries(Locale::En, ®istry); + + assert!(entries.iter().all(|entry| entry.label != "/debt")); + } + #[test] fn substring_filter_narrows_to_command() { let mut view = HelpView::new(); @@ -899,7 +980,8 @@ mod tests { #[test] fn localized_help_keybinding_descriptions_use_zh_hans() { - let entries = build_entries(Locale::ZhHans); + let registry = commands::user_registry::UserCommandRegistry::new(); + let entries = build_entries(Locale::ZhHans, ®istry); let kb_entries: Vec<_> = entries .iter() .filter(|e| e.section == HelpSection::Keybinding) @@ -942,7 +1024,10 @@ mod tests { } let mut stack = ViewStack::new(); - stack.push(HelpView::new_for_shortcuts(Locale::En)); + stack.push(HelpView::new_with_ordering( + Locale::En, + HelpOrdering::KeybindingsFirst, + )); stack.render(area, &mut buf); let rows: Vec = (0..h) diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 35315eed0..a4ad29f3c 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -3579,7 +3579,7 @@ pub(crate) fn slash_completion_hints_with_model_candidates( command_key, &prefix_lower, locale, - &user_commands, + &all_user_commands, ); } @@ -3608,7 +3608,7 @@ pub(crate) fn slash_completion_hints_with_model_candidates( cmd.name, &prefix_lower, locale, - &user_commands, + &all_user_commands, ); } } @@ -3626,7 +3626,7 @@ pub(crate) fn slash_completion_hints_with_model_candidates( &cmd.name, &prefix_lower, locale, - &user_commands, + &all_user_commands, ); } } @@ -3655,7 +3655,7 @@ pub(crate) fn slash_completion_hints_with_model_candidates( cmd.name, &prefix_lower, locale, - &user_commands, + &all_user_commands, ); } } @@ -3676,7 +3676,7 @@ pub(crate) fn slash_completion_hints_with_model_candidates( &cmd.name, &prefix_lower, locale, - &user_commands, + &all_user_commands, ); } } @@ -3917,24 +3917,29 @@ fn push_command_entry( }; (description, alias_hint) } else if let Some(info) = commands::get_command_info(command_key) { + let unshadowed_aliases = info + .aliases + .iter() + .copied() + .filter(|alias| !user_command_shadows_builtin_alias(alias, user_commands)) + .collect::>(); let hint = if !command_key.to_ascii_lowercase().starts_with(prefix_lower) { - info.aliases + unshadowed_aliases .iter() + .copied() .find(|a| { a.to_ascii_lowercase().starts_with(prefix_lower) || a.to_ascii_lowercase().contains(prefix_lower) || fuzzy_chars_in_order(prefix_lower, &a.to_ascii_lowercase()) }) - .map(|a| a.to_string()) + .map(str::to_string) } else { None }; // Omit aliases already shown in the label (`/clear or /qingping`) so // the description does not repeat them (#3990). - let remaining_aliases: Vec<&str> = info - .aliases - .iter() - .copied() + let remaining_aliases: Vec<&str> = unshadowed_aliases + .into_iter() .filter(|alias| hint.as_deref() != Some(*alias)) .collect(); let desc = if remaining_aliases.is_empty() { @@ -4911,6 +4916,25 @@ mod tests { assert!(hints.iter().any(|hint| hint.name == "/links")); } + #[test] + fn slash_completion_hints_discover_debt_from_compat_aliases() { + for alias in ["slop", "canzha"] { + let hints = slash_completion_hints( + &format!("/{alias}"), + 128, + &[], + Locale::En, + None, + ApiProvider::Deepseek, + ); + let debt = hints + .iter() + .find(|hint| hint.name == "/debt") + .unwrap_or_else(|| panic!("/debt should appear for /{alias}")); + assert_eq!(debt.alias_hint.as_deref(), Some(alias)); + } + } + #[test] fn slash_completion_hints_rank_exact_alias_above_prefix_alias() { // `/q` should rank `/exit` (exact alias `q`) above `/clear` (alias @@ -5142,9 +5166,15 @@ mod tests { ApiProvider::Deepseek, ); + let attach = canonical_hints + .iter() + .find(|hint| hint.name == "/attach") + .expect( + "canonical /attach should remain visible when only its /image alias is shadowed", + ); assert!( - canonical_hints.iter().any(|hint| hint.name == "/attach"), - "canonical /attach should remain visible when only its /image alias is shadowed" + !attach.description.contains("/image"), + "canonical completion must not advertise a user-shadowed alias" ); let alias_hints = slash_completion_hints( @@ -5166,6 +5196,41 @@ mod tests { ); } + #[test] + fn slash_completion_hints_hide_shadowed_debt_aliases_from_canonical_copy() { + let tmp = tempfile::TempDir::new().unwrap(); + let commands_dir = tmp.path().join(".codewhale").join("commands"); + std::fs::create_dir_all(&commands_dir).unwrap(); + std::fs::write( + commands_dir.join("slop.md"), + "---\ndescription: Internal cleanup\nhidden: true\n---\ninternal cleanup", + ) + .unwrap(); + std::fs::write( + commands_dir.join("custom-debt.md"), + "---\ndescription: Custom debt flow\nalias: canzha\n---\ncustom debt", + ) + .unwrap(); + + let hints = slash_completion_hints( + "/debt", + 128, + &[], + Locale::En, + Some(tmp.path()), + ApiProvider::Deepseek, + ); + let debt = hints + .iter() + .find(|hint| hint.name == "/debt") + .expect("canonical /debt should remain visible when only aliases are shadowed"); + + assert_eq!(debt.alias_hint, None); + assert!(debt.description.contains("/cleanup")); + assert!(!debt.description.contains("/slop")); + assert!(!debt.description.contains("/canzha")); + } + #[test] fn slash_completion_hints_prefer_user_metadata_for_shadowed_builtin() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/docs/architecture/command-dispatch.md b/docs/architecture/command-dispatch.md index c16ebc4e9..105ee95d8 100644 --- a/docs/architecture/command-dispatch.md +++ b/docs/architecture/command-dispatch.md @@ -22,8 +22,8 @@ intentional: |------|--------|----------| | 0 | `$skill` compatibility | `$name` is resolved as `/skill name` before slash parsing. | | 1 | User commands | `user_registry::try_dispatch()` checks workspace and global markdown commands first, so user commands can shadow built-ins. | -| 2 | Permanent compatibility aliases | `/jihua` and `/zidong` route through config mode dispatch; `/slop` and `/canzha` dispatch directly to `/debt`. All predate the group-owned registry and bypass the built-in `CommandRegistry`. | -| 3 | Built-in registry | `CommandRegistry` resolves group-owned built-in commands by canonical name or alias. | +| 2 | Permanent mode compatibility aliases | `/jihua` and `/zidong` route through config mode dispatch so each selects its fixed legacy mode. They remain registered aliases for discovery, but bypass normal `/mode` execution. | +| 3 | Built-in registry | `CommandRegistry` resolves group-owned built-in commands by canonical name or alias, including `/slop` and `/canzha` as aliases of `/debt`. | | 4 | Legacy migration hints | Retired commands such as `/set` and `/deepseek` return targeted replacement guidance. | | 5 | Skills fallback | If no command matches, a skill with the same name may run before unknown-command suggestions are shown. | @@ -84,7 +84,7 @@ count, allowed tools, pause state, todos, and plan state. | Exception | Rationale | |-----------|-----------| -| `/jihua`, `/zidong`, `/slop`, `/canzha` | Backward-compatible dispatch aliases that predate the group-owned registry. `/jihua` and `/zidong` route through config mode dispatch; `/slop` and `/canzha` dispatch directly to `/debt`. | +| `/jihua`, `/zidong` | Backward-compatible mode aliases that predate the group-owned registry. They route through config mode dispatch to preserve their fixed mode selection. | | `/set` and `/deepseek` migration hints | Retired commands kept only as direct typed guidance. They are excluded from registry and autocomplete. | | `#[allow(clippy::module_inception)]` in matching group modules | Group directories intentionally contain same-named child modules such as `core/core.rs`. | | `user_commands.rs` lower layer | The registry owns runtime behavior, while this module remains the shared filesystem and parser layer. | diff --git a/docs/architecture/pr-issue-evidence-prep.md b/docs/architecture/pr-issue-evidence-prep.md index ab65e406e..b52df538e 100644 --- a/docs/architecture/pr-issue-evidence-prep.md +++ b/docs/architecture/pr-issue-evidence-prep.md @@ -41,7 +41,6 @@ tree by running the documented commands. | Config group-local metadata | Config `mod.rs` keeps 11 `CommandInfo` statics and dispatch — permanent structure, not cleanup scope | | Debug group-local metadata | Debug `mod.rs` keeps 11 `CommandInfo` statics and dispatch — permanent structure, not cleanup scope | | `/jihua`, `/zidong` | Chinese-language back-compat aliases for `/mode` — predate group-owned registry | -| `/slop`, `/canzha` | Typed-only aliases for `/debt` — predate group-owned registry | | `/set`, `/deepseek` migration hints | Retired commands, direct typed guidance only, excluded from registry/completion | | `$skill` prefix | Non-slash compatibility syntax, predates EPIC-002 | | Skill-name fallback | Slash commands fall back to skill dispatch after built-ins and user commands |