diff --git a/crates/tui/src/commands/groups/config/mod.rs b/crates/tui/src/commands/groups/config/mod.rs index 7bb5ad98..f3287333 100644 --- a/crates/tui/src/commands/groups/config/mod.rs +++ b/crates/tui/src/commands/groups/config/mod.rs @@ -5,8 +5,135 @@ pub mod config; mod status; use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandGroup, CommandInfo, FunctionCommand}; +use crate::localization::MessageId; use crate::tui::app::App; +pub struct ConfigCommands; + +impl CommandGroup for ConfigCommands { + fn commands(&self) -> Vec> { + vec![ + Box::new(FunctionCommand::new(&CONFIG_INFO, run_config)), + Box::new(FunctionCommand::new(&SIDEBAR_INFO, run_sidebar)), + Box::new(FunctionCommand::new(&SETTINGS_INFO, run_settings)), + Box::new(FunctionCommand::new(&STATUS_INFO, run_status)), + Box::new(FunctionCommand::new(&STATUSLINE_INFO, run_statusline)), + Box::new(FunctionCommand::new(&MODE_INFO, run_mode)), + Box::new(FunctionCommand::new(&THEME_INFO, run_theme)), + Box::new(FunctionCommand::new(&VERBOSE_INFO, run_verbose)), + Box::new(FunctionCommand::new(&TRUST_INFO, run_trust)), + Box::new(FunctionCommand::new(&LOGOUT_INFO, run_logout)), + Box::new(FunctionCommand::new(&SLOP_INFO, run_slop)), + ] + } +} + +static CONFIG_INFO: CommandInfo = CommandInfo { + name: "config", + aliases: &[], + usage: "/config", + description_id: MessageId::CmdConfigDescription, +}; +static SIDEBAR_INFO: CommandInfo = CommandInfo { + name: "sidebar", + aliases: &[], + usage: "/sidebar [on|off|auto|work|tasks|agents|context] [--save]", + description_id: MessageId::CmdSidebarDescription, +}; +static SETTINGS_INFO: CommandInfo = CommandInfo { + name: "settings", + aliases: &[], + usage: "/settings", + description_id: MessageId::CmdSettingsDescription, +}; +static STATUS_INFO: CommandInfo = CommandInfo { + name: "status", + aliases: &[], + usage: "/status", + description_id: MessageId::CmdStatusDescription, +}; +static STATUSLINE_INFO: CommandInfo = CommandInfo { + name: "statusline", + aliases: &[], + usage: "/statusline", + description_id: MessageId::CmdStatuslineDescription, +}; +static MODE_INFO: CommandInfo = CommandInfo { + name: "mode", + aliases: &["jihua", "zidong"], + usage: "/mode [agent|plan|yolo|1|2|3]", + description_id: MessageId::CmdModeDescription, +}; +static THEME_INFO: CommandInfo = CommandInfo { + name: "theme", + aliases: &[], + usage: "/theme [name]", + description_id: MessageId::CmdThemeDescription, +}; +static VERBOSE_INFO: CommandInfo = CommandInfo { + name: "verbose", + aliases: &[], + usage: "/verbose [on|off]", + description_id: MessageId::CmdVerboseDescription, +}; +static TRUST_INFO: CommandInfo = CommandInfo { + name: "trust", + aliases: &["xinren"], + usage: "/trust [on|off|add |remove |list]", + description_id: MessageId::CmdTrustDescription, +}; +static LOGOUT_INFO: CommandInfo = CommandInfo { + name: "logout", + aliases: &[], + usage: "/logout", + description_id: MessageId::CmdLogoutDescription, +}; +static SLOP_INFO: CommandInfo = CommandInfo { + name: "slop", + aliases: &["canzha"], + usage: "/slop [query|export]", + description_id: MessageId::CmdSlopDescription, +}; + +fn run_registered(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { + dispatch(app, name, arg).expect("registered config command should dispatch") +} + +fn run_config(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "config", arg) +} +fn run_sidebar(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "sidebar", arg) +} +fn run_settings(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "settings", arg) +} +fn run_status(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "status", arg) +} +fn run_statusline(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "statusline", arg) +} +fn run_mode(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "mode", arg) +} +fn run_theme(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "theme", arg) +} +fn run_verbose(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "verbose", arg) +} +fn run_trust(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "trust", arg) +} +fn run_logout(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "logout", arg) +} +fn run_slop(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "slop", arg) +} + pub(in crate::commands) fn dispatch( app: &mut App, command: &str, diff --git a/crates/tui/src/commands/groups/core/mod.rs b/crates/tui/src/commands/groups/core/mod.rs index e3cf56f5..ea3d1adc 100644 --- a/crates/tui/src/commands/groups/core/mod.rs +++ b/crates/tui/src/commands/groups/core/mod.rs @@ -14,8 +14,225 @@ mod stash; pub(in crate::commands) use self::core::reset_conversation_state; use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandGroup, CommandInfo, FunctionCommand}; +use crate::localization::MessageId; use crate::tui::app::{App, AppAction}; +pub struct CoreCommands; + +impl CommandGroup for CoreCommands { + fn commands(&self) -> Vec> { + vec![ + Box::new(FunctionCommand::new(&ANCHOR_INFO, run_anchor)), + Box::new(FunctionCommand::new(&HELP_INFO, run_help)), + Box::new(FunctionCommand::new(&CLEAR_INFO, run_clear)), + Box::new(FunctionCommand::new(&EXIT_INFO, run_exit)), + Box::new(FunctionCommand::new(&MODEL_INFO, run_model)), + Box::new(FunctionCommand::new(&MODELS_INFO, run_models)), + Box::new(FunctionCommand::new(&PROVIDER_INFO, run_provider)), + Box::new(FunctionCommand::new(&QUEUE_INFO, run_queue)), + Box::new(FunctionCommand::new(&STASH_INFO, run_stash)), + Box::new(FunctionCommand::new(&HOOKS_INFO, run_hooks)), + Box::new(FunctionCommand::new(&SUBAGENTS_INFO, run_subagents)), + Box::new(FunctionCommand::new(&AGENT_INFO, run_agent)), + Box::new(FunctionCommand::new(&LINKS_INFO, run_links)), + Box::new(FunctionCommand::new(&FEEDBACK_INFO, run_feedback)), + Box::new(FunctionCommand::new(&HF_INFO, run_hf)), + Box::new(FunctionCommand::new(&HOME_INFO, run_home)), + Box::new(FunctionCommand::new(&WORKSPACE_INFO, run_workspace)), + Box::new(FunctionCommand::new(&PROFILE_INFO, run_profile)), + Box::new(FunctionCommand::new(&RLM_INFO, run_rlm)), + Box::new(FunctionCommand::new(&TRANSLATE_INFO, run_translate)), + ] + } +} + +static ANCHOR_INFO: CommandInfo = CommandInfo { + name: "anchor", + aliases: &["maodian"], + usage: "/anchor | /anchor list | /anchor remove ", + description_id: MessageId::CmdAnchorDescription, +}; +static HELP_INFO: CommandInfo = CommandInfo { + name: "help", + aliases: &["?", "bangzhu", "帮助"], + usage: "/help [command]", + description_id: MessageId::CmdHelpDescription, +}; +static CLEAR_INFO: CommandInfo = CommandInfo { + name: "clear", + aliases: &["qingping"], + usage: "/clear", + description_id: MessageId::CmdClearDescription, +}; +static EXIT_INFO: CommandInfo = CommandInfo { + name: "exit", + aliases: &["quit", "q", "tuichu"], + usage: "/exit", + description_id: MessageId::CmdExitDescription, +}; +static MODEL_INFO: CommandInfo = CommandInfo { + name: "model", + aliases: &["moxing"], + usage: "/model [name]", + description_id: MessageId::CmdModelDescription, +}; +static MODELS_INFO: CommandInfo = CommandInfo { + name: "models", + aliases: &["moxingliebiao"], + usage: "/models", + description_id: MessageId::CmdModelsDescription, +}; +static PROVIDER_INFO: CommandInfo = CommandInfo { + name: "provider", + aliases: &[], + usage: "/provider [name] [model]", + description_id: MessageId::CmdProviderDescription, +}; +static QUEUE_INFO: CommandInfo = CommandInfo { + name: "queue", + aliases: &["queued"], + usage: "/queue [list|edit |drop |clear]", + description_id: MessageId::CmdQueueDescription, +}; +static STASH_INFO: CommandInfo = CommandInfo { + name: "stash", + aliases: &["park"], + usage: "/stash [list|pop|clear]", + description_id: MessageId::CmdStashDescription, +}; +static HOOKS_INFO: CommandInfo = CommandInfo { + name: "hooks", + aliases: &["hook", "gouzi"], + usage: "/hooks [list|events]", + description_id: MessageId::CmdHooksDescription, +}; +static SUBAGENTS_INFO: CommandInfo = CommandInfo { + name: "subagents", + aliases: &["agents", "zhinengti"], + usage: "/subagents", + description_id: MessageId::CmdSubagentsDescription, +}; +static AGENT_INFO: CommandInfo = CommandInfo { + name: "agent", + aliases: &["daili"], + usage: "/agent [N] ", + description_id: MessageId::CmdAgentDescription, +}; +static LINKS_INFO: CommandInfo = CommandInfo { + name: "links", + aliases: &["dashboard", "api", "lianjie"], + usage: "/links", + description_id: MessageId::CmdLinksDescription, +}; +static FEEDBACK_INFO: CommandInfo = CommandInfo { + name: "feedback", + aliases: &[], + usage: "/feedback [bug|feature|security]", + description_id: MessageId::CmdFeedbackDescription, +}; +static HF_INFO: CommandInfo = CommandInfo { + name: "hf", + aliases: &["huggingface"], + usage: "/hf [mcp |concepts]", + description_id: MessageId::CmdHfDescription, +}; +static HOME_INFO: CommandInfo = CommandInfo { + name: "home", + aliases: &["stats", "overview", "zhuye", "shouye"], + usage: "/home", + description_id: MessageId::CmdHomeDescription, +}; +static WORKSPACE_INFO: CommandInfo = CommandInfo { + name: "workspace", + aliases: &["cwd"], + usage: "/workspace [path]", + description_id: MessageId::CmdWorkspaceDescription, +}; +static PROFILE_INFO: CommandInfo = CommandInfo { + name: "profile", + aliases: &["dangan"], + usage: "/profile ", + description_id: MessageId::CmdHelpDescription, +}; +static RLM_INFO: CommandInfo = CommandInfo { + name: "rlm", + aliases: &["recursive", "digui"], + usage: "/rlm [N] ", + description_id: MessageId::CmdRlmDescription, +}; +static TRANSLATE_INFO: CommandInfo = CommandInfo { + name: "translate", + aliases: &["translation", "transale"], + usage: "/translate", + description_id: MessageId::CmdTranslateDescription, +}; + +fn run_registered(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { + dispatch(app, name, arg).expect("registered core command should dispatch") +} + +fn run_anchor(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "anchor", arg) +} +fn run_help(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "help", arg) +} +fn run_clear(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "clear", arg) +} +fn run_exit(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "exit", arg) +} +fn run_model(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "model", arg) +} +fn run_models(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "models", arg) +} +fn run_provider(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "provider", arg) +} +fn run_queue(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "queue", arg) +} +fn run_stash(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "stash", arg) +} +fn run_hooks(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "hooks", arg) +} +fn run_subagents(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "subagents", arg) +} +fn run_agent(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "agent", arg) +} +fn run_links(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "links", arg) +} +fn run_feedback(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "feedback", arg) +} +fn run_hf(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "hf", arg) +} +fn run_home(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "home", arg) +} +fn run_workspace(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "workspace", arg) +} +fn run_profile(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "profile", arg) +} +fn run_rlm(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "rlm", arg) +} +fn run_translate(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "translate", arg) +} + pub(in crate::commands) fn dispatch( app: &mut App, command: &str, diff --git a/crates/tui/src/commands/groups/debug/mod.rs b/crates/tui/src/commands/groups/debug/mod.rs index ff121637..46b811a3 100644 --- a/crates/tui/src/commands/groups/debug/mod.rs +++ b/crates/tui/src/commands/groups/debug/mod.rs @@ -7,8 +7,135 @@ mod change; mod debug; use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandGroup, CommandInfo, FunctionCommand}; +use crate::localization::MessageId; use crate::tui::app::App; +pub struct DebugCommands; + +impl CommandGroup for DebugCommands { + fn commands(&self) -> Vec> { + vec![ + Box::new(FunctionCommand::new(&TOKENS_INFO, run_tokens)), + Box::new(FunctionCommand::new(&COST_INFO, run_cost)), + Box::new(FunctionCommand::new(&BALANCE_INFO, run_balance)), + Box::new(FunctionCommand::new(&CACHE_INFO, run_cache)), + Box::new(FunctionCommand::new(&CHANGE_INFO, run_change)), + Box::new(FunctionCommand::new(&SYSTEM_INFO, run_system)), + Box::new(FunctionCommand::new(&CONTEXT_INFO, run_context)), + Box::new(FunctionCommand::new(&EDIT_INFO, run_edit)), + Box::new(FunctionCommand::new(&DIFF_INFO, run_diff)), + Box::new(FunctionCommand::new(&UNDO_INFO, run_undo)), + Box::new(FunctionCommand::new(&RETRY_INFO, run_retry)), + ] + } +} + +static TOKENS_INFO: CommandInfo = CommandInfo { + name: "tokens", + aliases: &[], + usage: "/tokens", + description_id: MessageId::CmdTokensDescription, +}; +static COST_INFO: CommandInfo = CommandInfo { + name: "cost", + aliases: &[], + usage: "/cost", + description_id: MessageId::CmdCostDescription, +}; +static BALANCE_INFO: CommandInfo = CommandInfo { + name: "balance", + aliases: &[], + usage: "/balance", + description_id: MessageId::CmdBalanceDescription, +}; +static CACHE_INFO: CommandInfo = CommandInfo { + name: "cache", + aliases: &[], + usage: "/cache [count|inspect|stats|zones|warmup]", + description_id: MessageId::CmdCacheDescription, +}; +static CHANGE_INFO: CommandInfo = CommandInfo { + name: "change", + aliases: &[], + usage: "/change [version]", + description_id: MessageId::CmdChangeDescription, +}; +static SYSTEM_INFO: CommandInfo = CommandInfo { + name: "system", + aliases: &["xitong"], + usage: "/system", + description_id: MessageId::CmdSystemDescription, +}; +static CONTEXT_INFO: CommandInfo = CommandInfo { + name: "context", + aliases: &["ctx"], + usage: "/context [report|json|summary]", + description_id: MessageId::CmdContextDescription, +}; +static EDIT_INFO: CommandInfo = CommandInfo { + name: "edit", + aliases: &[], + usage: "/edit", + description_id: MessageId::CmdEditDescription, +}; +static DIFF_INFO: CommandInfo = CommandInfo { + name: "diff", + aliases: &[], + usage: "/diff", + description_id: MessageId::CmdDiffDescription, +}; +static UNDO_INFO: CommandInfo = CommandInfo { + name: "undo", + aliases: &[], + usage: "/undo", + description_id: MessageId::CmdUndoDescription, +}; +static RETRY_INFO: CommandInfo = CommandInfo { + name: "retry", + aliases: &["chongshi"], + usage: "/retry", + description_id: MessageId::CmdRetryDescription, +}; + +fn run_registered(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { + dispatch(app, name, arg).expect("registered debug command should dispatch") +} + +fn run_tokens(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "tokens", arg) +} +fn run_cost(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "cost", arg) +} +fn run_balance(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "balance", arg) +} +fn run_cache(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "cache", arg) +} +fn run_change(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "change", arg) +} +fn run_system(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "system", arg) +} +fn run_context(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "context", arg) +} +fn run_edit(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "edit", arg) +} +fn run_diff(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "diff", arg) +} +fn run_undo(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "undo", arg) +} +fn run_retry(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "retry", arg) +} + pub(in crate::commands) fn dispatch( app: &mut App, command: &str, diff --git a/crates/tui/src/commands/groups/memory/mod.rs b/crates/tui/src/commands/groups/memory/mod.rs index bc066c3b..0cfb0570 100644 --- a/crates/tui/src/commands/groups/memory/mod.rs +++ b/crates/tui/src/commands/groups/memory/mod.rs @@ -5,8 +5,45 @@ mod memory; mod note; use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandGroup, CommandInfo, FunctionCommand}; +use crate::localization::MessageId; use crate::tui::app::App; +pub struct MemoryCommands; + +impl CommandGroup for MemoryCommands { + fn commands(&self) -> Vec> { + vec![ + Box::new(FunctionCommand::new(&NOTE_INFO, run_note)), + Box::new(FunctionCommand::new(&MEMORY_INFO, run_memory)), + ] + } +} + +static NOTE_INFO: CommandInfo = CommandInfo { + name: "note", + aliases: &[], + usage: "/note [add|list|show|edit|remove|clear|path]", + description_id: MessageId::CmdNoteDescription, +}; +static MEMORY_INFO: CommandInfo = CommandInfo { + name: "memory", + aliases: &[], + usage: "/memory [show|path|clear|edit|help]", + description_id: MessageId::CmdMemoryDescription, +}; + +fn run_registered(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { + dispatch(app, name, arg).expect("registered memory command should dispatch") +} + +fn run_note(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "note", arg) +} +fn run_memory(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "memory", arg) +} + pub(in crate::commands) fn dispatch( app: &mut App, command: &str, diff --git a/crates/tui/src/commands/groups/mod.rs b/crates/tui/src/commands/groups/mod.rs index cae969f0..f483acde 100644 --- a/crates/tui/src/commands/groups/mod.rs +++ b/crates/tui/src/commands/groups/mod.rs @@ -1,10 +1,9 @@ //! Group-owned built-in command areas. //! -//! Each group module owns the handler files for its command area and -//! exposes a `dispatch` slice that claims the command names it owns and -//! returns `None` for everything else. `commands::execute` chains the -//! group dispatchers in order, so a command name must be claimed by -//! exactly one group. +//! Each group module registers command objects into the central command +//! registry. Command implementation functions still live with their owning +//! groups, while dispatch, palette metadata, and help lookup all read from the +//! same registry surface. pub mod config; pub mod core; @@ -14,3 +13,18 @@ pub mod project; pub mod session; pub mod skills; pub mod utility; + +use crate::commands::traits::CommandGroup; + +pub fn all_command_groups() -> Vec<&'static dyn CommandGroup> { + vec![ + &core::CoreCommands, + &session::SessionCommands, + &config::ConfigCommands, + &debug::DebugCommands, + &project::ProjectCommands, + &skills::SkillsCommands, + &memory::MemoryCommands, + &utility::UtilityCommands, + ] +} diff --git a/crates/tui/src/commands/groups/project/mod.rs b/crates/tui/src/commands/groups/project/mod.rs index bb148361..70a1f18b 100644 --- a/crates/tui/src/commands/groups/project/mod.rs +++ b/crates/tui/src/commands/groups/project/mod.rs @@ -5,8 +5,65 @@ mod init; pub mod share; use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandGroup, CommandInfo, FunctionCommand}; +use crate::localization::MessageId; use crate::tui::app::App; +pub struct ProjectCommands; + +impl CommandGroup for ProjectCommands { + fn commands(&self) -> Vec> { + vec![ + Box::new(FunctionCommand::new(&INIT_INFO, run_init)), + Box::new(FunctionCommand::new(&LSP_INFO, run_lsp)), + Box::new(FunctionCommand::new(&SHARE_INFO, run_share)), + Box::new(FunctionCommand::new(&GOAL_INFO, run_goal)), + ] + } +} + +static INIT_INFO: CommandInfo = CommandInfo { + name: "init", + aliases: &[], + usage: "/init", + description_id: MessageId::CmdInitDescription, +}; +static LSP_INFO: CommandInfo = CommandInfo { + name: "lsp", + aliases: &[], + usage: "/lsp [on|off|status]", + description_id: MessageId::CmdLspDescription, +}; +static SHARE_INFO: CommandInfo = CommandInfo { + name: "share", + aliases: &[], + usage: "/share", + description_id: MessageId::CmdShareDescription, +}; +static GOAL_INFO: CommandInfo = CommandInfo { + name: "goal", + aliases: &["hunt", "mubiao", "狩猎"], + usage: "/goal [objective|clear|pause|resume|complete|blocked] [budget: N]", + description_id: MessageId::CmdGoalDescription, +}; + +fn run_registered(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { + dispatch(app, name, arg).expect("registered project command should dispatch") +} + +fn run_init(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "init", arg) +} +fn run_lsp(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "lsp", arg) +} +fn run_share(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "share", arg) +} +fn run_goal(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "goal", arg) +} + pub(in crate::commands) fn dispatch( app: &mut App, command: &str, diff --git a/crates/tui/src/commands/groups/session/mod.rs b/crates/tui/src/commands/groups/session/mod.rs index 581dc93e..82df00e6 100644 --- a/crates/tui/src/commands/groups/session/mod.rs +++ b/crates/tui/src/commands/groups/session/mod.rs @@ -8,8 +8,125 @@ mod session; use std::fmt::Write as _; use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandGroup, CommandInfo, FunctionCommand}; +use crate::localization::MessageId; use crate::tui::app::{App, AppAction}; +pub struct SessionCommands; + +impl CommandGroup for SessionCommands { + fn commands(&self) -> Vec> { + vec![ + Box::new(FunctionCommand::new(&RENAME_INFO, run_rename)), + Box::new(FunctionCommand::new(&SAVE_INFO, run_save)), + Box::new(FunctionCommand::new(&FORK_INFO, run_fork)), + Box::new(FunctionCommand::new(&NEW_INFO, run_new)), + Box::new(FunctionCommand::new(&SESSIONS_INFO, run_sessions)), + Box::new(FunctionCommand::new(&LOAD_INFO, run_load)), + Box::new(FunctionCommand::new(&COMPACT_INFO, run_compact)), + Box::new(FunctionCommand::new(&PURGE_INFO, run_purge)), + Box::new(FunctionCommand::new(&RELAY_INFO, run_relay)), + Box::new(FunctionCommand::new(&EXPORT_INFO, run_export)), + ] + } +} + +static RENAME_INFO: CommandInfo = CommandInfo { + name: "rename", + aliases: &["gaiming", "chongmingming"], + usage: "/rename ", + description_id: MessageId::CmdRenameDescription, +}; +static SAVE_INFO: CommandInfo = CommandInfo { + name: "save", + aliases: &[], + usage: "/save [path]", + description_id: MessageId::CmdSaveDescription, +}; +static FORK_INFO: CommandInfo = CommandInfo { + name: "fork", + aliases: &["branch"], + usage: "/fork", + description_id: MessageId::CmdForkDescription, +}; +static NEW_INFO: CommandInfo = CommandInfo { + name: "new", + aliases: &[], + usage: "/new [--force]", + description_id: MessageId::CmdNewDescription, +}; +static SESSIONS_INFO: CommandInfo = CommandInfo { + name: "sessions", + aliases: &["resume"], + usage: "/sessions [show|prune ]", + description_id: MessageId::CmdSessionsDescription, +}; +static LOAD_INFO: CommandInfo = CommandInfo { + name: "load", + aliases: &["jiazai"], + usage: "/load [path]", + description_id: MessageId::CmdLoadDescription, +}; +static COMPACT_INFO: CommandInfo = CommandInfo { + name: "compact", + aliases: &["yasuo"], + usage: "/compact", + description_id: MessageId::CmdCompactDescription, +}; +static PURGE_INFO: CommandInfo = CommandInfo { + name: "purge", + aliases: &["qingchu"], + usage: "/purge", + description_id: MessageId::CmdPurgeDescription, +}; +static RELAY_INFO: CommandInfo = CommandInfo { + name: "relay", + aliases: &["batonpass", "接力"], + usage: "/relay [focus]", + description_id: MessageId::CmdRelayDescription, +}; +static EXPORT_INFO: CommandInfo = CommandInfo { + name: "export", + aliases: &["daochu"], + usage: "/export [path]", + description_id: MessageId::CmdExportDescription, +}; + +fn run_registered(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { + dispatch(app, name, arg).expect("registered session command should dispatch") +} + +fn run_rename(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "rename", arg) +} +fn run_save(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "save", arg) +} +fn run_fork(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "fork", arg) +} +fn run_new(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "new", arg) +} +fn run_sessions(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "sessions", arg) +} +fn run_load(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "load", arg) +} +fn run_compact(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "compact", arg) +} +fn run_purge(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "purge", arg) +} +fn run_relay(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "relay", arg) +} +fn run_export(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "export", arg) +} + pub(in crate::commands) fn dispatch( app: &mut App, command: &str, diff --git a/crates/tui/src/commands/groups/skills/mod.rs b/crates/tui/src/commands/groups/skills/mod.rs index ec7cd231..787a30dd 100644 --- a/crates/tui/src/commands/groups/skills/mod.rs +++ b/crates/tui/src/commands/groups/skills/mod.rs @@ -8,8 +8,65 @@ mod skills; pub(in crate::commands) use self::skills::run_skill_by_name; use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandGroup, CommandInfo, FunctionCommand}; +use crate::localization::MessageId; use crate::tui::app::App; +pub struct SkillsCommands; + +impl CommandGroup for SkillsCommands { + fn commands(&self) -> Vec> { + vec![ + Box::new(FunctionCommand::new(&SKILLS_INFO, run_skills)), + Box::new(FunctionCommand::new(&SKILL_INFO, run_skill)), + Box::new(FunctionCommand::new(&REVIEW_INFO, run_review)), + Box::new(FunctionCommand::new(&RESTORE_INFO, run_restore)), + ] + } +} + +static SKILLS_INFO: CommandInfo = CommandInfo { + name: "skills", + aliases: &["jinengliebiao"], + usage: "/skills [--remote|sync|]", + description_id: MessageId::CmdSkillsDescription, +}; +static SKILL_INFO: CommandInfo = CommandInfo { + name: "skill", + aliases: &["jineng"], + usage: "/skill |update |uninstall |trust >", + description_id: MessageId::CmdSkillDescription, +}; +static REVIEW_INFO: CommandInfo = CommandInfo { + name: "review", + aliases: &["shencha"], + usage: "/review ", + description_id: MessageId::CmdReviewDescription, +}; +static RESTORE_INFO: CommandInfo = CommandInfo { + name: "restore", + aliases: &[], + usage: "/restore [N|list [N]]", + description_id: MessageId::CmdRestoreDescription, +}; + +fn run_registered(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { + dispatch(app, name, arg).expect("registered skills command should dispatch") +} + +fn run_skills(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "skills", arg) +} +fn run_skill(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "skill", arg) +} +fn run_review(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "review", arg) +} +fn run_restore(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "restore", arg) +} + pub(in crate::commands) fn dispatch( app: &mut App, command: &str, diff --git a/crates/tui/src/commands/groups/utility/mod.rs b/crates/tui/src/commands/groups/utility/mod.rs index c3b05e38..f46f42b3 100644 --- a/crates/tui/src/commands/groups/utility/mod.rs +++ b/crates/tui/src/commands/groups/utility/mod.rs @@ -8,8 +8,75 @@ mod network; mod task; use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandGroup, CommandInfo, FunctionCommand}; +use crate::localization::MessageId; use crate::tui::app::App; +pub struct UtilityCommands; + +impl CommandGroup for UtilityCommands { + fn commands(&self) -> Vec> { + vec![ + Box::new(FunctionCommand::new(&ATTACH_INFO, run_attach)), + Box::new(FunctionCommand::new(&TASK_INFO, run_task)), + Box::new(FunctionCommand::new(&JOBS_INFO, run_jobs)), + Box::new(FunctionCommand::new(&MCP_INFO, run_mcp)), + Box::new(FunctionCommand::new(&NETWORK_INFO, run_network)), + ] + } +} + +static ATTACH_INFO: CommandInfo = CommandInfo { + name: "attach", + aliases: &["image", "media", "fujian"], + usage: "/attach ", + description_id: MessageId::CmdAttachDescription, +}; +static TASK_INFO: CommandInfo = CommandInfo { + name: "task", + aliases: &["tasks"], + usage: "/task [add |list|show |cancel ]", + description_id: MessageId::CmdTaskDescription, +}; +static JOBS_INFO: CommandInfo = CommandInfo { + name: "jobs", + aliases: &["job", "zuoye"], + usage: "/jobs [list|show |poll |wait |stdin |cancel ]", + description_id: MessageId::CmdJobsDescription, +}; +static MCP_INFO: CommandInfo = CommandInfo { + name: "mcp", + aliases: &[], + usage: "/mcp [init|add stdio [args...]|add http |enable |disable |remove |validate|reload]", + description_id: MessageId::CmdMcpDescription, +}; +static NETWORK_INFO: CommandInfo = CommandInfo { + name: "network", + aliases: &[], + usage: "/network [list|allow |deny |remove |default ]", + description_id: MessageId::CmdNetworkDescription, +}; + +fn run_registered(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { + dispatch(app, name, arg).expect("registered utility command should dispatch") +} + +fn run_attach(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "attach", arg) +} +fn run_task(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "task", arg) +} +fn run_jobs(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "jobs", arg) +} +fn run_mcp(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "mcp", arg) +} +fn run_network(app: &mut App, arg: Option<&str>) -> CommandResult { + run_registered(app, "network", arg) +} + pub(in crate::commands) fn dispatch( app: &mut App, command: &str, diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index d8000cb0..8e6072d2 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -1,19 +1,18 @@ //! Slash command registry and dispatch system //! //! This module provides a modular command system inspired by Codex-rs. -//! Commands are organized by category and dispatched through a central registry. -//! Built-in handlers live in group-owned areas under [`groups`]; this module -//! keeps parsing, registry metadata, user-command precedence, and the +//! Commands are organized by category and dispatched through a central strategy +//! registry. Built-in handlers live in group-owned areas under [`groups`]; this +//! module keeps registry construction, user-command precedence, and the //! fall-through behaviour. mod groups; -mod parse; -mod registry; +pub mod traits; pub mod user_commands; -use parse::parse_slash_command; -use registry::suggest_command_names; -pub use registry::{COMMANDS, get_command_info}; +use std::sync::OnceLock; + +pub use traits::CommandInfo; // Long-standing public paths that predate the group layout. pub use groups::project::share; @@ -78,37 +77,68 @@ impl CommandResult { } } +static REGISTRY: OnceLock = OnceLock::new(); + +fn build_registry() -> traits::CommandRegistry { + let mut registry = traits::CommandRegistry::empty(); + for group in groups::all_command_groups() { + registry.register_group(group); + } + registry +} + +pub fn registry() -> &'static traits::CommandRegistry { + REGISTRY.get_or_init(build_registry) +} + +pub fn command_infos() -> Vec<&'static CommandInfo> { + registry().infos() +} + +pub fn get_command_info(name: &str) -> Option<&'static CommandInfo> { + registry().get_info(name) +} + /// Execute a slash command pub fn execute(cmd: &str, app: &mut App) -> CommandResult { - let parsed = parse_slash_command(cmd); - let command = parsed.name.as_str(); - let arg = parsed.arg; + let trimmed = cmd.trim(); + let parts: Vec<&str> = trimmed.splitn(2, char::is_whitespace).collect(); + let command = parts + .first() + .copied() + .unwrap_or_default() + .trim_start_matches('/') + .to_ascii_lowercase(); + let arg = parts + .get(1) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()); // Check user-defined commands FIRST so they can override built-ins. - if let Some(result) = user_commands::try_dispatch_user_command(app, cmd.trim()) { + if let Some(result) = user_commands::try_dispatch_user_command(app, trimmed) { return result; } - // Built-in commands are owned by their group areas; each group claims - // the names it owns and returns None for everything else. - type GroupDispatcher = fn(&mut App, &str, Option<&str>) -> Option; - let group_dispatchers: [GroupDispatcher; 8] = [ - groups::core::dispatch, - groups::session::dispatch, - groups::config::dispatch, - groups::debug::dispatch, - groups::project::dispatch, - groups::skills::dispatch, - groups::memory::dispatch, - groups::utility::dispatch, - ]; - for dispatch in group_dispatchers { - if let Some(result) = dispatch(app, command, arg) { - return result; + // Compatibility aliases whose historical behavior also supplied an arg. + match command.as_str() { + "jihua" => { + return groups::config::dispatch(app, "jihua", arg).unwrap_or_else(|| { + CommandResult::error("The /jihua alias could not be dispatched.") + }); } + "zidong" => { + return groups::config::dispatch(app, "zidong", arg).unwrap_or_else(|| { + CommandResult::error("The /zidong alias could not be dispatched.") + }); + } + _ => {} } - match command { + if let Some(command_object) = registry().get(command.as_str()) { + return command_object.execute(app, arg); + } + + match command.as_str() { // Legacy command migrations (kept out of registry/autocomplete intentionally). "set" => CommandResult::error( "The /set command was retired. Use /config to edit settings and /settings to inspect current values.", @@ -120,10 +150,10 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { _ => { // Third source: skills (lowest precedence after native and user-config). // Try to run a skill whose name matches the command. - if let Some(result) = groups::skills::run_skill_by_name(app, command, arg) { + if let Some(result) = groups::skills::run_skill_by_name(app, command.as_str(), arg) { return result; } - let suggestions = suggest_command_names(command, 3); + let suggestions = suggest_command_names(command.as_str(), 3); if suggestions.is_empty() { CommandResult::error(format!( "Unknown command: /{command}. Type /help for available commands." @@ -151,6 +181,86 @@ pub fn switch_mode(app: &mut App, mode: crate::tui::app::AppMode) -> String { groups::config::config::switch_mode(app, mode) } +fn edit_distance(a: &str, b: &str) -> usize { + if a == b { + return 0; + } + if a.is_empty() { + return b.chars().count(); + } + if b.is_empty() { + return a.chars().count(); + } + + let b_chars: Vec = b.chars().collect(); + let mut previous: Vec = (0..=b_chars.len()).collect(); + let mut current = vec![0usize; b_chars.len() + 1]; + + for (i, a_ch) in a.chars().enumerate() { + current[0] = i + 1; + for (j, b_ch) in b_chars.iter().enumerate() { + let cost = if a_ch == *b_ch { 0 } else { 1 }; + let delete = previous[j + 1] + 1; + let insert = current[j] + 1; + let substitute = previous[j] + cost; + current[j + 1] = delete.min(insert).min(substitute); + } + std::mem::swap(&mut previous, &mut current); + } + + previous[b_chars.len()] +} + +fn suggest_command_names(input: &str, limit: usize) -> Vec { + let query = input.trim().to_ascii_lowercase(); + if query.is_empty() || limit == 0 { + return Vec::new(); + } + + 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 let Some((rank, distance)) = best { + scored.push((rank, distance, command.name.to_string())); + } + } + + scored.sort_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| a.1.cmp(&b.1)) + .then_with(|| a.2.cmp(&b.2)) + }); + scored + .into_iter() + .take(limit) + .map(|(_, _, name)| name) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -191,9 +301,9 @@ mod tests { #[test] fn command_registry_contains_config_and_links_but_not_set_or_deepseek() { - assert!(COMMANDS.iter().any(|cmd| cmd.name == "config")); - let sidebar = COMMANDS - .iter() + assert!(command_infos().iter().any(|cmd| cmd.name == "config")); + let sidebar = command_infos() + .into_iter() .find(|cmd| cmd.name == "sidebar") .expect("sidebar command should exist"); assert_eq!(sidebar.description_id, MessageId::CmdSidebarDescription); @@ -202,23 +312,23 @@ mod tests { .description_for(Locale::En) .contains("right sidebar") ); - assert!(COMMANDS.iter().any(|cmd| cmd.name == "links")); - let hf = COMMANDS - .iter() + assert!(command_infos().iter().any(|cmd| cmd.name == "links")); + let hf = command_infos() + .into_iter() .find(|cmd| cmd.name == "hf") .expect("hf command should exist"); assert_eq!(hf.aliases, &["huggingface"]); assert_eq!(hf.description_id, MessageId::CmdHfDescription); assert!(hf.description_for(Locale::En).contains("Hugging Face")); - assert!(COMMANDS.iter().any(|cmd| cmd.name == "memory")); - assert!(!COMMANDS.iter().any(|cmd| cmd.name == "set")); - assert!(!COMMANDS.iter().any(|cmd| cmd.name == "deepseek")); + assert!(command_infos().iter().any(|cmd| cmd.name == "memory")); + assert!(!command_infos().iter().any(|cmd| cmd.name == "set")); + assert!(!command_infos().iter().any(|cmd| cmd.name == "deepseek")); } #[test] fn links_command_has_dashboard_and_api_aliases() { - let links = COMMANDS - .iter() + let links = command_infos() + .into_iter() .find(|cmd| cmd.name == "links") .expect("links command should exist"); assert_eq!(links.aliases, &["dashboard", "api", "lianjie"]); @@ -324,8 +434,8 @@ mod tests { #[test] fn relay_command_has_bilingual_aliases() { - let relay = COMMANDS - .iter() + let relay = command_infos() + .into_iter() .find(|cmd| cmd.name == "relay") .expect("relay command should exist"); assert_eq!(relay.aliases, &["batonpass", "接力"]); @@ -344,7 +454,7 @@ mod tests { #[test] fn command_registry_has_unique_names_and_aliases() { let mut names = std::collections::BTreeSet::new(); - for command in COMMANDS { + for command in command_infos() { assert!( names.insert(command.name), "duplicate command name /{}", @@ -353,7 +463,7 @@ mod tests { } let mut aliases = std::collections::BTreeSet::new(); - for command in COMMANDS { + for command in command_infos() { for alias in command.aliases { assert!( !names.contains(alias), @@ -366,7 +476,7 @@ mod tests { #[test] fn command_registry_metadata_is_complete_and_palette_safe() { - for command in COMMANDS { + for command in command_infos() { assert!(!command.name.is_empty(), "command name must not be empty"); assert_eq!( command.name.trim(), @@ -445,7 +555,7 @@ mod tests { #[test] fn command_info_resolves_canonical_names_and_aliases() { - for command in COMMANDS { + for command in command_infos() { for lookup in [command.name.to_string(), format!("/{}", command.name)] { let resolved = get_command_info(&lookup) .unwrap_or_else(|| panic!("{lookup:?} should resolve to /{}", command.name)); @@ -466,7 +576,7 @@ mod tests { #[test] fn every_registered_command_has_a_help_topic() { let mut app = create_test_app(); - for command in COMMANDS { + for command in command_infos() { let result = execute(&format!("/help {}", command.name), &mut app); assert!( !result.is_error, @@ -492,8 +602,8 @@ mod tests { #[test] fn context_command_opens_inspector_and_keeps_ctx_alias() { - let context = COMMANDS - .iter() + let context = command_infos() + .into_iter() .find(|cmd| cmd.name == "context") .expect("context command should exist"); assert_eq!(context.aliases, &["ctx"]); @@ -729,10 +839,10 @@ mod tests { (app, tmpdir, guard) } - /// Smoke test: every entry in `COMMANDS` must dispatch to a real handler. + /// Smoke test: every entry in `command_infos()` must dispatch to a real handler. /// A dispatch miss surfaces as the fall-through `Unknown command:` error /// message in `execute`. This catches the case where a new command is - /// added to `COMMANDS` (so it shows up in `/help` and the palette) but + /// added to `command_infos()` (so it shows up in `/help` and the palette) but /// the matching arm in `execute` is forgotten — the user would type the /// command, see it autocomplete, and then get an unhelpful "did you /// mean" suggestion. Also catches panics in handlers because the test @@ -838,17 +948,17 @@ mod tests { assert!(tokens.contains("deepseek-v4-pro")); } - /// Smoke test: every entry in `COMMANDS` must dispatch to a real handler. + /// Smoke test: every entry in `command_infos()` must dispatch to a real handler. /// A dispatch miss surfaces as the fall-through `Unknown command:` error /// message in `execute`. This catches the case where a new command is - /// added to `COMMANDS` (so it shows up in `/help` and the palette) but + /// added to `command_infos()` (so it shows up in `/help` and the palette) but /// the matching arm in `execute` is forgotten — the user would type the /// command, see it autocomplete, and then get an unhelpful "did you /// mean" suggestion. Also catches panics in handlers because the test /// runner unwinds the panic and reports the offending command. #[test] fn every_registered_command_dispatches_to_a_handler() { - for command in COMMANDS { + for command in command_infos() { if skip_in_dispatch_smoke(command.name) { continue; } @@ -869,7 +979,7 @@ mod tests { /// just because the registry lists it as an alias of `/exit`. #[test] fn every_command_alias_dispatches_to_a_handler() { - for command in COMMANDS { + for command in command_infos() { if skip_in_dispatch_smoke(command.name) { continue; } diff --git a/crates/tui/src/commands/parse.rs b/crates/tui/src/commands/parse.rs deleted file mode 100644 index c6cccc48..00000000 --- a/crates/tui/src/commands/parse.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! Slash command input parsing helpers. - -pub(super) struct ParsedCommand<'a> { - pub(super) name: String, - pub(super) arg: Option<&'a str>, -} - -pub(super) fn parse_slash_command(cmd: &str) -> ParsedCommand<'_> { - let trimmed = cmd.trim(); - let mut parts = trimmed.splitn(2, ' '); - let raw_command = parts.next().unwrap_or_default(); - let name = raw_command - .strip_prefix('/') - .unwrap_or(raw_command) - .to_lowercase(); - let arg = parts.next().map(str::trim); - - ParsedCommand { name, arg } -} diff --git a/crates/tui/src/commands/registry.rs b/crates/tui/src/commands/registry.rs deleted file mode 100644 index 51e0e91f..00000000 --- a/crates/tui/src/commands/registry.rs +++ /dev/null @@ -1,549 +0,0 @@ -//! Command registry metadata and lookup helpers. - -use crate::localization::{Locale, MessageId, tr}; - -/// Command metadata for help and autocomplete. -/// -/// The English description lives in `localization::english` (private), keyed -/// by `description_id`. Callers resolve a localized description through -/// [`CommandInfo::description_for`] which delegates to -/// [`crate::localization::tr`]. -#[derive(Debug, Clone, Copy)] -pub struct CommandInfo { - pub name: &'static str, - pub aliases: &'static [&'static str], - pub usage: &'static str, - pub description_id: MessageId, -} - -impl CommandInfo { - pub fn requires_argument(&self) -> bool { - self.usage.contains('<') || self.usage.contains('[') - } - - pub fn palette_command(&self) -> String { - if self.requires_argument() { - format!("/{} ", self.name) - } else { - format!("/{}", self.name) - } - } - - pub fn description_for(&self, locale: Locale) -> &'static str { - tr(locale, self.description_id) - } - - pub fn palette_description_for(&self, locale: Locale) -> String { - let desc = self.description_for(locale); - if self.aliases.is_empty() { - desc.to_string() - } else { - format!("{} aliases: {}", desc, self.aliases.join(", ")) - } - } -} - -/// All registered commands -pub const COMMANDS: &[CommandInfo] = &[ - // Core commands - CommandInfo { - name: "anchor", - aliases: &["maodian"], - usage: "/anchor | /anchor list | /anchor remove ", - description_id: MessageId::CmdAnchorDescription, - }, - CommandInfo { - name: "help", - aliases: &["?", "bangzhu", "帮助"], - usage: "/help [command]", - description_id: MessageId::CmdHelpDescription, - }, - CommandInfo { - name: "clear", - aliases: &["qingping"], - usage: "/clear", - description_id: MessageId::CmdClearDescription, - }, - CommandInfo { - name: "exit", - aliases: &["quit", "q", "tuichu"], - usage: "/exit", - description_id: MessageId::CmdExitDescription, - }, - CommandInfo { - name: "model", - aliases: &["moxing"], - usage: "/model [name]", - description_id: MessageId::CmdModelDescription, - }, - CommandInfo { - name: "models", - aliases: &["moxingliebiao"], - usage: "/models", - description_id: MessageId::CmdModelsDescription, - }, - CommandInfo { - name: "provider", - aliases: &[], - usage: "/provider [name] [model]", - description_id: MessageId::CmdProviderDescription, - }, - CommandInfo { - name: "queue", - aliases: &["queued"], - usage: "/queue [list|edit |drop |clear]", - description_id: MessageId::CmdQueueDescription, - }, - CommandInfo { - name: "stash", - aliases: &["park"], - usage: "/stash [list|pop|clear]", - description_id: MessageId::CmdStashDescription, - }, - CommandInfo { - name: "hooks", - aliases: &["hook", "gouzi"], - usage: "/hooks [list|events]", - description_id: MessageId::CmdHooksDescription, - }, - CommandInfo { - name: "subagents", - aliases: &["agents", "zhinengti"], - usage: "/subagents", - description_id: MessageId::CmdSubagentsDescription, - }, - CommandInfo { - name: "agent", - aliases: &["daili"], - usage: "/agent [N] ", - description_id: MessageId::CmdAgentDescription, - }, - CommandInfo { - name: "links", - aliases: &["dashboard", "api", "lianjie"], - usage: "/links", - description_id: MessageId::CmdLinksDescription, - }, - CommandInfo { - name: "feedback", - aliases: &[], - usage: "/feedback [bug|feature|security]", - description_id: MessageId::CmdFeedbackDescription, - }, - CommandInfo { - name: "hf", - aliases: &["huggingface"], - usage: "/hf [mcp |concepts]", - description_id: MessageId::CmdHfDescription, - }, - CommandInfo { - name: "home", - aliases: &["stats", "overview", "zhuye", "shouye"], - usage: "/home", - description_id: MessageId::CmdHomeDescription, - }, - CommandInfo { - name: "workspace", - aliases: &["cwd"], - usage: "/workspace [path]", - description_id: MessageId::CmdWorkspaceDescription, - }, - CommandInfo { - name: "note", - aliases: &[], - usage: "/note [add|list|show|edit|remove|clear|path]", - description_id: MessageId::CmdNoteDescription, - }, - CommandInfo { - name: "memory", - aliases: &[], - usage: "/memory [show|path|clear|edit|help]", - description_id: MessageId::CmdMemoryDescription, - }, - CommandInfo { - name: "attach", - aliases: &["image", "media", "fujian"], - usage: "/attach ", - description_id: MessageId::CmdAttachDescription, - }, - CommandInfo { - name: "task", - aliases: &["tasks"], - usage: "/task [add |list|show |cancel ]", - description_id: MessageId::CmdTaskDescription, - }, - CommandInfo { - name: "jobs", - aliases: &["job", "zuoye"], - usage: "/jobs [list|show |poll |wait |stdin |cancel ]", - description_id: MessageId::CmdJobsDescription, - }, - CommandInfo { - name: "mcp", - aliases: &[], - usage: "/mcp [init|add stdio [args...]|add http |enable |disable |remove |validate|reload]", - description_id: MessageId::CmdMcpDescription, - }, - CommandInfo { - name: "network", - aliases: &[], - usage: "/network [list|allow |deny |remove |default ]", - description_id: MessageId::CmdNetworkDescription, - }, - // Session commands - CommandInfo { - name: "rename", - aliases: &["gaiming", "chongmingming"], - usage: "/rename ", - description_id: MessageId::CmdRenameDescription, - }, - CommandInfo { - name: "save", - aliases: &[], - usage: "/save [path]", - description_id: MessageId::CmdSaveDescription, - }, - CommandInfo { - name: "fork", - aliases: &["branch"], - usage: "/fork", - description_id: MessageId::CmdForkDescription, - }, - CommandInfo { - name: "new", - aliases: &[], - usage: "/new [--force]", - description_id: MessageId::CmdNewDescription, - }, - CommandInfo { - name: "sessions", - aliases: &["resume"], - usage: "/sessions [show|prune ]", - description_id: MessageId::CmdSessionsDescription, - }, - CommandInfo { - name: "load", - aliases: &["jiazai"], - usage: "/load [path]", - description_id: MessageId::CmdLoadDescription, - }, - CommandInfo { - name: "compact", - aliases: &["yasuo"], - usage: "/compact", - description_id: MessageId::CmdCompactDescription, - }, - CommandInfo { - name: "purge", - aliases: &["qingchu"], - usage: "/purge", - description_id: MessageId::CmdPurgeDescription, - }, - CommandInfo { - name: "relay", - aliases: &["batonpass", "接力"], - usage: "/relay [focus]", - description_id: MessageId::CmdRelayDescription, - }, - CommandInfo { - name: "context", - aliases: &["ctx"], - usage: "/context [report|json|summary]", - description_id: MessageId::CmdContextDescription, - }, - CommandInfo { - name: "export", - aliases: &["daochu"], - usage: "/export [path]", - description_id: MessageId::CmdExportDescription, - }, - // Config commands - CommandInfo { - name: "config", - aliases: &[], - usage: "/config", - description_id: MessageId::CmdConfigDescription, - }, - CommandInfo { - name: "sidebar", - aliases: &[], - usage: "/sidebar [on|off|auto|work|tasks|agents|context] [--save]", - description_id: MessageId::CmdSidebarDescription, - }, - CommandInfo { - name: "mode", - aliases: &["jihua", "zidong"], - usage: "/mode [agent|plan|yolo|1|2|3]", - description_id: MessageId::CmdModeDescription, - }, - CommandInfo { - name: "theme", - aliases: &[], - usage: "/theme [name]", - description_id: MessageId::CmdThemeDescription, - }, - CommandInfo { - name: "verbose", - aliases: &[], - usage: "/verbose [on|off]", - description_id: MessageId::CmdVerboseDescription, - }, - CommandInfo { - name: "trust", - aliases: &["xinren"], - usage: "/trust [on|off|add |remove |list]", - description_id: MessageId::CmdTrustDescription, - }, - CommandInfo { - name: "logout", - aliases: &[], - usage: "/logout", - description_id: MessageId::CmdLogoutDescription, - }, - // Debug commands - CommandInfo { - name: "tokens", - aliases: &[], - usage: "/tokens", - description_id: MessageId::CmdTokensDescription, - }, - CommandInfo { - name: "translate", - aliases: &["translation", "transale"], - usage: "/translate", - description_id: MessageId::CmdTranslateDescription, - }, - CommandInfo { - name: "system", - aliases: &["xitong"], - usage: "/system", - description_id: MessageId::CmdSystemDescription, - }, - CommandInfo { - name: "edit", - aliases: &[], - usage: "/edit", - description_id: MessageId::CmdEditDescription, - }, - CommandInfo { - name: "diff", - aliases: &[], - usage: "/diff", - description_id: MessageId::CmdDiffDescription, - }, - CommandInfo { - name: "change", - aliases: &[], - usage: "/change [version]", - description_id: MessageId::CmdChangeDescription, - }, - CommandInfo { - name: "undo", - aliases: &[], - usage: "/undo", - description_id: MessageId::CmdUndoDescription, - }, - CommandInfo { - name: "retry", - aliases: &["chongshi"], - usage: "/retry", - description_id: MessageId::CmdRetryDescription, - }, - CommandInfo { - name: "init", - aliases: &[], - usage: "/init", - description_id: MessageId::CmdInitDescription, - }, - CommandInfo { - name: "lsp", - aliases: &[], - usage: "/lsp [on|off|status]", - description_id: MessageId::CmdLspDescription, - }, - CommandInfo { - name: "share", - aliases: &[], - usage: "/share", - description_id: MessageId::CmdShareDescription, - }, - CommandInfo { - name: "goal", - aliases: &["hunt", "mubiao", "狩猎"], - usage: "/goal [objective|clear|pause|resume|complete|blocked] [budget: N]", - description_id: MessageId::CmdGoalDescription, - }, - CommandInfo { - name: "settings", - aliases: &[], - usage: "/settings", - description_id: MessageId::CmdSettingsDescription, - }, - CommandInfo { - name: "status", - aliases: &[], - usage: "/status", - description_id: MessageId::CmdStatusDescription, - }, - CommandInfo { - name: "statusline", - aliases: &[], - usage: "/statusline", - description_id: MessageId::CmdStatuslineDescription, - }, - // Skills commands - CommandInfo { - name: "skills", - aliases: &["jinengliebiao"], - usage: "/skills [--remote|sync|]", - description_id: MessageId::CmdSkillsDescription, - }, - CommandInfo { - name: "skill", - aliases: &["jineng"], - usage: "/skill |update |uninstall |trust >", - description_id: MessageId::CmdSkillDescription, - }, - CommandInfo { - name: "review", - aliases: &["shencha"], - usage: "/review ", - description_id: MessageId::CmdReviewDescription, - }, - CommandInfo { - name: "restore", - aliases: &[], - usage: "/restore [N|list [N]]", - description_id: MessageId::CmdRestoreDescription, - }, - // RLM command - CommandInfo { - name: "rlm", - aliases: &["recursive", "digui"], - usage: "/rlm [N] ", - description_id: MessageId::CmdRlmDescription, - }, - // Debug/cost command - CommandInfo { - name: "cost", - aliases: &[], - usage: "/cost", - description_id: MessageId::CmdCostDescription, - }, - // Balance query (#2019) - CommandInfo { - name: "balance", - aliases: &[], - usage: "/balance", - description_id: MessageId::CmdBalanceDescription, - }, - // Profile switching (#390) - CommandInfo { - name: "profile", - aliases: &["dangan"], - usage: "/profile ", - description_id: MessageId::CmdHelpDescription, // reuse for now - }, - // Cache telemetry (#263) - CommandInfo { - name: "cache", - aliases: &[], - usage: "/cache [count|inspect|stats|zones|warmup]", - description_id: MessageId::CmdCacheDescription, - }, - // Slop Ledger (#2127) - CommandInfo { - name: "slop", - aliases: &["canzha"], - usage: "/slop [query|export]", - description_id: MessageId::CmdSlopDescription, - }, -]; - -/// Get command info by name or alias -pub fn get_command_info(name: &str) -> Option<&'static CommandInfo> { - let name = name.strip_prefix('/').unwrap_or(name); - COMMANDS - .iter() - .find(|cmd| cmd.name == name || cmd.aliases.contains(&name)) -} - -fn edit_distance(a: &str, b: &str) -> usize { - if a == b { - return 0; - } - if a.is_empty() { - return b.chars().count(); - } - if b.is_empty() { - return a.chars().count(); - } - - let b_chars: Vec = b.chars().collect(); - let mut prev: Vec = (0..=b_chars.len()).collect(); - let mut curr = vec![0usize; b_chars.len() + 1]; - - for (i, a_ch) in a.chars().enumerate() { - curr[0] = i + 1; - for (j, b_ch) in b_chars.iter().enumerate() { - let cost = if a_ch == *b_ch { 0 } else { 1 }; - let delete = prev[j + 1] + 1; - let insert = curr[j] + 1; - let substitute = prev[j] + cost; - curr[j + 1] = delete.min(insert).min(substitute); - } - std::mem::swap(&mut prev, &mut curr); - } - - prev[b_chars.len()] -} - -pub(super) fn suggest_command_names(input: &str, limit: usize) -> Vec { - let query = input.trim().to_ascii_lowercase(); - if query.is_empty() || limit == 0 { - return Vec::new(); - } - - let mut scored: Vec<(u8, usize, String)> = Vec::new(); - for command in COMMANDS { - 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 let Some((rank, distance)) = best { - scored.push((rank, distance, command.name.to_string())); - } - } - - scored.sort_by(|a, b| { - a.0.cmp(&b.0) - .then_with(|| a.1.cmp(&b.1)) - .then_with(|| a.2.cmp(&b.2)) - }); - scored - .into_iter() - .take(limit) - .map(|(_, _, name)| name) - .collect() -} diff --git a/crates/tui/src/commands/traits.rs b/crates/tui/src/commands/traits.rs new file mode 100644 index 00000000..ec041f29 --- /dev/null +++ b/crates/tui/src/commands/traits.rs @@ -0,0 +1,125 @@ +//! Command traits and registry support. + +use std::collections::HashMap; + +use crate::localization::{Locale, MessageId, tr}; +use crate::tui::app::App; + +use super::CommandResult; + +#[derive(Debug, Clone, Copy)] +pub struct CommandInfo { + pub name: &'static str, + pub aliases: &'static [&'static str], + pub usage: &'static str, + pub description_id: MessageId, +} + +impl CommandInfo { + pub fn requires_argument(&self) -> bool { + self.usage.contains('<') || self.usage.contains('[') + } + + pub fn palette_command(&self) -> String { + if self.requires_argument() { + format!("/{} ", self.name) + } else { + format!("/{}", self.name) + } + } + + pub fn description_for(&self, locale: Locale) -> &'static str { + tr(locale, self.description_id) + } + + pub fn palette_description_for(&self, locale: Locale) -> String { + let desc = self.description_for(locale); + if self.aliases.is_empty() { + desc.to_string() + } else { + format!("{} aliases: {}", desc, self.aliases.join(", ")) + } + } +} + +pub trait Command: Send + Sync { + fn info(&self) -> &'static CommandInfo; + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult; +} + +pub trait CommandGroup: Send + Sync { + fn commands(&self) -> Vec>; +} + +pub(crate) type CommandHandler = fn(&mut App, Option<&str>) -> CommandResult; + +pub(crate) struct FunctionCommand { + info: &'static CommandInfo, + handler: CommandHandler, +} + +impl FunctionCommand { + pub(crate) const fn new(info: &'static CommandInfo, handler: CommandHandler) -> Self { + Self { info, handler } + } +} + +impl Command for FunctionCommand { + fn info(&self) -> &'static CommandInfo { + self.info + } + + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + (self.handler)(app, args) + } +} + +pub struct CommandRegistry { + commands: Vec>, + name_to_index: HashMap<&'static str, usize>, +} + +impl CommandRegistry { + pub fn empty() -> Self { + Self { + commands: Vec::new(), + name_to_index: HashMap::new(), + } + } + + pub fn register(&mut self, command: Box) { + let index = self.commands.len(); + let info = command.info(); + self.name_to_index.insert(info.name, index); + for alias in info.aliases { + self.name_to_index.insert(alias, index); + } + self.commands.push(command); + } + + pub fn register_group(&mut self, group: &dyn CommandGroup) { + for command in group.commands() { + self.register(command); + } + } + + pub fn get(&self, name: &str) -> Option<&dyn Command> { + let name = name.strip_prefix('/').unwrap_or(name); + self.name_to_index + .get(name) + .and_then(|index| self.commands.get(*index)) + .map(Box::as_ref) + } + + pub fn get_info(&self, name: &str) -> Option<&'static CommandInfo> { + self.get(name).map(Command::info) + } + + pub fn iter(&self) -> impl Iterator { + self.commands.iter().map(Box::as_ref) + } + + pub fn infos(&self) -> Vec<&'static CommandInfo> { + self.iter().map(Command::info).collect() + } +} diff --git a/crates/tui/src/tui/command_palette.rs b/crates/tui/src/tui/command_palette.rs index cfffcee3..5ed222cb 100644 --- a/crates/tui/src/tui/command_palette.rs +++ b/crates/tui/src/tui/command_palette.rs @@ -55,7 +55,7 @@ pub fn build_entries( ) -> Vec { let mut entries = Vec::new(); - for command in commands::COMMANDS { + for command in commands::command_infos() { let mut description = command.palette_description_for(locale); if command.requires_argument() { description.push_str(" "); @@ -1163,9 +1163,9 @@ mod tests { .iter() .filter(|entry| entry.section == PaletteSection::Command) .collect::>(); - assert_eq!(command_entries.len(), commands::COMMANDS.len()); + assert_eq!(command_entries.len(), commands::command_infos().len()); - for command in commands::COMMANDS { + for command in commands::command_infos() { let label = format!("/{}", command.name); let matching = command_entries .iter() diff --git a/crates/tui/src/tui/views/help.rs b/crates/tui/src/tui/views/help.rs index 778264e8..ca2dc3ed 100644 --- a/crates/tui/src/tui/views/help.rs +++ b/crates/tui/src/tui/views/help.rs @@ -2,7 +2,7 @@ //! //! Renders two stacked sections — *Slash commands* and *Keybindings* — with //! a live substring filter applied as the user types in the search box. The -//! command list is sourced from [`crate::commands::COMMANDS`] and the +//! command list is sourced from [`crate::commands::command_infos()`] and the //! keybinding list from [`crate::tui::keybindings::KEYBINDINGS`] so neither //! can drift from the wired-up handlers. //! @@ -202,7 +202,7 @@ impl HelpView { fn build_entries(locale: Locale) -> Vec { let mut entries = Vec::new(); - for command in commands::COMMANDS { + for command in commands::command_infos() { let label = format!("/{}", command.name); let localized = command.description_for(locale); let description = if command.aliases.is_empty() { @@ -515,7 +515,7 @@ mod tests { fn empty_filter_lists_all_entries() { let view = HelpView::new(); // Total = registered slash commands + catalogued keybindings. - let expected = commands::COMMANDS.len() + KEYBINDINGS.len(); + let expected = commands::command_infos().len() + KEYBINDINGS.len(); assert_eq!(view.filtered.len(), expected); assert_eq!(view.entries.len(), expected); } diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 19de3a3a..8788ab65 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -2313,7 +2313,7 @@ pub(crate) fn slash_completion_hints( // ── Phase 2: contains (substring) matches ───────────────────────── // Medium priority — broader catching. if completing_skill_arg.is_none() { - for cmd in commands::COMMANDS { + for cmd in commands::command_infos() { let name = format!("/{}", cmd.name); if seen.contains(&name) { continue; @@ -2340,7 +2340,7 @@ pub(crate) fn slash_completion_hints( // ── Phase 3: fuzzy subsequence matches ──────────────────────────── // Lowest priority — characters in order, not necessarily consecutive. if completing_skill_arg.is_none() { - for cmd in commands::COMMANDS { + for cmd in commands::command_infos() { let name = format!("/{}", cmd.name); if seen.contains(&name) { continue; @@ -2457,7 +2457,7 @@ fn all_command_names_matching_loaded( user_commands: &[(String, String)], ) -> Vec { let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase(); - let mut result: Vec = commands::COMMANDS + let mut result: Vec = commands::command_infos() .iter() .filter(|cmd| { cmd.name.starts_with(&prefix) || cmd.aliases.iter().any(|a| a.starts_with(&prefix))