From 96bb4c6b80b4a91a5aa88e0fadba19d79956541b Mon Sep 17 00:00:00 2001 From: limityan Date: Thu, 16 Jul 2026 17:05:50 +0800 Subject: [PATCH] refactor(cli): centralize TUI actions and key bindings --- src/apps/cli/src/actions.rs | 2204 ++++++++++++++++++++++++ src/apps/cli/src/commands.rs | 247 --- src/apps/cli/src/config.rs | 99 +- src/apps/cli/src/main.rs | 5 +- src/apps/cli/src/modes/chat.rs | 667 +++---- src/apps/cli/src/ui/chat/popups.rs | 6 +- src/apps/cli/src/ui/chat/render.rs | 114 +- src/apps/cli/src/ui/chat/state.rs | 17 +- src/apps/cli/src/ui/command_menu.rs | 144 +- src/apps/cli/src/ui/command_palette.rs | 326 ++-- src/apps/cli/src/ui/startup.rs | 383 ++-- src/apps/cli/src/ui/widgets.rs | 17 +- 12 files changed, 3158 insertions(+), 1071 deletions(-) create mode 100644 src/apps/cli/src/actions.rs delete mode 100644 src/apps/cli/src/commands.rs diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs new file mode 100644 index 0000000000..afdf2e593b --- /dev/null +++ b/src/apps/cli/src/actions.rs @@ -0,0 +1,2204 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::config::ShortcutsConfig; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ActionContext { + Startup, + Chat, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ActionAvailability { + Always, + Idle, + Processing, + Popup, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ActionState { + pub context: ActionContext, + pub is_processing: bool, + pub popup_open: bool, +} + +impl ActionState { + pub(crate) const fn startup(popup_open: bool) -> Self { + Self { + context: ActionContext::Startup, + is_processing: false, + popup_open, + } + } + + pub(crate) const fn chat(is_processing: bool, popup_open: bool) -> Self { + Self { + context: ActionContext::Chat, + is_processing, + popup_open, + } + } +} + +const STARTUP_ACTION_STATES: &[ActionState] = + &[ActionState::startup(false), ActionState::startup(true)]; +const CHAT_ACTION_STATES: &[ActionState] = &[ + ActionState::chat(false, false), + ActionState::chat(true, false), + ActionState::chat(false, true), + ActionState::chat(true, true), +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ActionHandler { + Help, + ClearConversation, + OpenAgentSelector, + SwitchAgent, + SwitchAgentReverse, + SelectModel, + SelectTheme, + AddModel, + NewSession, + Sessions, + Skills, + ReloadSkills, + Subagents, + McpServers, + AcpHelp, + Init, + History, + Usage, + Exit, + Login, + Logout, + OpenPalette, + SubmitInput, + Interrupt, + ClosePopups, + NavigateBack, + InsertNewline, + Paste, + ToggleFocusedTool, + PreviousTool, + NextTool, + HistoryPrevious, + HistoryNext, + JumpTop, + JumpBottom, + ClearInput, + ToggleBrowse, + ScrollUp, + ScrollDown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ShortcutField { + SendMessage, + Interrupt, + Menu, +} + +impl ShortcutField { + fn value(self, shortcuts: &ShortcutsConfig) -> Option<&str> { + match self { + Self::SendMessage => shortcuts.send_message.as_deref(), + Self::Interrupt => shortcuts.interrupt.as_deref(), + Self::Menu => shortcuts.menu.as_deref(), + } + } + + const fn source(self) -> &'static str { + match self { + Self::SendMessage => "shortcuts.send_message", + Self::Interrupt => "shortcuts.interrupt", + Self::Menu => "shortcuts.menu", + } + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct PaletteSpec { + pub group: &'static str, + pub suggested: bool, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct ActionSpec { + pub id: &'static str, + pub name: &'static str, + pub aliases: &'static [&'static str], + pub description: &'static str, + pub contexts: &'static [ActionContext], + pub availability: ActionAvailability, + pub handler: ActionHandler, + pub default_bindings: &'static [&'static str], + fallback_bindings: &'static [&'static str], + shortcut_field: Option, + pub palette: Option, + pub shortcut_label: Option<&'static str>, + slash_on_startup: bool, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct ActionProjection { + pub id: &'static str, + pub name: &'static str, + pub description: &'static str, + pub palette_group: Option<&'static str>, + pub suggested: bool, +} + +const CHAT: &[ActionContext] = &[ActionContext::Chat]; +const BOTH: &[ActionContext] = &[ActionContext::Startup, ActionContext::Chat]; + +const fn palette(group: &'static str, suggested: bool) -> Option { + Some(PaletteSpec { group, suggested }) +} + +static ACTION_SPECS: &[ActionSpec] = &[ + ActionSpec { + id: "help", + name: "Help", + aliases: &["/help"], + description: "Show keyboard shortcuts", + contexts: BOTH, + availability: ActionAvailability::Always, + handler: ActionHandler::Help, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("System", false), + shortcut_label: None, + slash_on_startup: true, + }, + ActionSpec { + id: "clear_conversation", + name: "Clear conversation", + aliases: &["/clear"], + description: "Clear conversation", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::ClearConversation, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: None, + slash_on_startup: false, + }, + ActionSpec { + id: "switch_agent", + name: "Switch agent", + aliases: &["/agents"], + description: "Switch agent mode", + contexts: BOTH, + availability: ActionAvailability::Idle, + handler: ActionHandler::OpenAgentSelector, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Agent", true), + shortcut_label: None, + slash_on_startup: true, + }, + ActionSpec { + id: "cycle_agent", + name: "Cycle agent", + aliases: &[], + description: "Switch to the next agent mode", + contexts: BOTH, + availability: ActionAvailability::Idle, + handler: ActionHandler::SwitchAgent, + default_bindings: &["Tab"], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: Some("Switch Agent"), + slash_on_startup: false, + }, + ActionSpec { + id: "switch_agent_reverse", + name: "Switch agent backwards", + aliases: &[], + description: "Switch to the previous agent mode", + contexts: BOTH, + availability: ActionAvailability::Idle, + handler: ActionHandler::SwitchAgentReverse, + default_bindings: &["Shift+Tab"], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: None, + slash_on_startup: false, + }, + ActionSpec { + id: "select_model", + name: "Select model", + aliases: &["/models"], + description: "Select AI model for all modes", + contexts: BOTH, + availability: ActionAvailability::Always, + handler: ActionHandler::SelectModel, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Models", true), + shortcut_label: None, + slash_on_startup: true, + }, + ActionSpec { + id: "theme", + name: "Theme", + aliases: &["/theme"], + description: "Switch UI theme", + contexts: BOTH, + availability: ActionAvailability::Always, + handler: ActionHandler::SelectTheme, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Appearance", true), + shortcut_label: None, + slash_on_startup: true, + }, + ActionSpec { + id: "add_model", + name: "Add model", + aliases: &["/connect"], + description: "Add a new AI model configuration", + contexts: BOTH, + availability: ActionAvailability::Always, + handler: ActionHandler::AddModel, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Models", false), + shortcut_label: None, + slash_on_startup: true, + }, + ActionSpec { + id: "new_session", + name: "New session", + aliases: &["/new"], + description: "Start a new conversation", + contexts: BOTH, + availability: ActionAvailability::Idle, + handler: ActionHandler::NewSession, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Session", true), + shortcut_label: None, + slash_on_startup: false, + }, + ActionSpec { + id: "sessions", + name: "Sessions", + aliases: &["/sessions"], + description: "Browse and switch sessions", + contexts: BOTH, + availability: ActionAvailability::Idle, + handler: ActionHandler::Sessions, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Session", false), + shortcut_label: None, + slash_on_startup: true, + }, + ActionSpec { + id: "skills", + name: "Skills", + aliases: &["/skills"], + description: "List and configure skills", + contexts: BOTH, + availability: ActionAvailability::Always, + handler: ActionHandler::Skills, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Prompt", false), + shortcut_label: None, + slash_on_startup: true, + }, + ActionSpec { + id: "reload_skills", + name: "Reload skills", + aliases: &["/reload-skills"], + description: "Re-scan skill directories without restarting", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::ReloadSkills, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: None, + slash_on_startup: false, + }, + ActionSpec { + id: "subagents", + name: "Subagents", + aliases: &["/subagents"], + description: "List and configure subagents", + contexts: BOTH, + availability: ActionAvailability::Always, + handler: ActionHandler::Subagents, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Prompt", false), + shortcut_label: None, + slash_on_startup: true, + }, + ActionSpec { + id: "mcp_servers", + name: "MCP servers", + aliases: &["/mcps"], + description: "Manage MCP servers", + contexts: BOTH, + availability: ActionAvailability::Always, + handler: ActionHandler::McpServers, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("MCP", false), + shortcut_label: None, + slash_on_startup: true, + }, + ActionSpec { + id: "acp_help", + name: "ACP setup", + aliases: &["/acp"], + description: "Show ACP server setup", + contexts: BOTH, + availability: ActionAvailability::Always, + handler: ActionHandler::AcpHelp, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: None, + slash_on_startup: true, + }, + ActionSpec { + id: "init", + name: "Initialize repository", + aliases: &["/init"], + description: "Explore repo and generate AGENTS.md", + contexts: BOTH, + availability: ActionAvailability::Idle, + handler: ActionHandler::Init, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: None, + slash_on_startup: true, + }, + ActionSpec { + id: "history", + name: "History", + aliases: &["/history"], + description: "Show history", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::History, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: None, + slash_on_startup: false, + }, + ActionSpec { + id: "usage", + name: "Usage report", + aliases: &["/usage"], + description: "Generate a usage report for the current session", + contexts: BOTH, + availability: ActionAvailability::Idle, + handler: ActionHandler::Usage, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Session", true), + shortcut_label: None, + slash_on_startup: true, + }, + ActionSpec { + id: "exit", + name: "Exit the app", + aliases: &["/exit"], + description: "Quit the application", + contexts: BOTH, + availability: ActionAvailability::Always, + handler: ActionHandler::Exit, + default_bindings: &[], + fallback_bindings: &["Ctrl+C"], + shortcut_field: None, + palette: palette("System", false), + shortcut_label: Some("Quit"), + slash_on_startup: true, + }, + ActionSpec { + id: "login", + name: "Login", + aliases: &["/login"], + description: "Account login / status", + contexts: BOTH, + availability: ActionAvailability::Idle, + handler: ActionHandler::Login, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Account", false), + shortcut_label: None, + slash_on_startup: true, + }, + ActionSpec { + id: "logout", + name: "Logout", + aliases: &["/logout"], + description: "Log out of BitFun account", + contexts: BOTH, + availability: ActionAvailability::Always, + handler: ActionHandler::Logout, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Account", false), + shortcut_label: None, + slash_on_startup: true, + }, + ActionSpec { + id: "open_palette", + name: "Command Palette", + aliases: &[], + description: "Open the command palette", + contexts: BOTH, + availability: ActionAvailability::Always, + handler: ActionHandler::OpenPalette, + default_bindings: &["Ctrl+P"], + fallback_bindings: &[], + shortcut_field: Some(ShortcutField::Menu), + palette: None, + shortcut_label: Some("Commands"), + slash_on_startup: false, + }, + ActionSpec { + id: "submit_input", + name: "Send message", + aliases: &[], + description: "Submit the current input", + contexts: BOTH, + availability: ActionAvailability::Always, + handler: ActionHandler::SubmitInput, + default_bindings: &["Enter"], + fallback_bindings: &[], + shortcut_field: Some(ShortcutField::SendMessage), + palette: None, + shortcut_label: Some("Send"), + slash_on_startup: false, + }, + ActionSpec { + id: "interrupt", + name: "Interrupt", + aliases: &[], + description: "Cancel the active turn", + contexts: CHAT, + availability: ActionAvailability::Processing, + handler: ActionHandler::Interrupt, + default_bindings: &[], + fallback_bindings: &["Esc", "Ctrl+C"], + shortcut_field: Some(ShortcutField::Interrupt), + palette: None, + shortcut_label: Some("Interrupt"), + slash_on_startup: false, + }, + ActionSpec { + id: "close_popups", + name: "Close all popups", + aliases: &[], + description: "Close all open TUI popups", + contexts: BOTH, + availability: ActionAvailability::Popup, + handler: ActionHandler::ClosePopups, + default_bindings: &[], + fallback_bindings: &["Ctrl+W"], + shortcut_field: None, + palette: None, + shortcut_label: Some("Close All Popups"), + slash_on_startup: false, + }, + ActionSpec { + id: "navigate_back", + name: "Back", + aliases: &[], + description: "Close the current TUI popup", + contexts: BOTH, + availability: ActionAvailability::Popup, + handler: ActionHandler::NavigateBack, + default_bindings: &[], + fallback_bindings: &["Esc"], + shortcut_field: None, + palette: None, + shortcut_label: Some("Back"), + slash_on_startup: false, + }, + ActionSpec { + id: "insert_newline", + name: "Insert newline", + aliases: &[], + description: "Insert a newline without submitting", + contexts: BOTH, + availability: ActionAvailability::Always, + handler: ActionHandler::InsertNewline, + default_bindings: &["Alt+Enter"], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: Some("Newline"), + slash_on_startup: false, + }, + ActionSpec { + id: "paste", + name: "Paste", + aliases: &[], + description: "Paste clipboard text", + contexts: BOTH, + availability: ActionAvailability::Always, + handler: ActionHandler::Paste, + default_bindings: &["Ctrl+V"], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: None, + slash_on_startup: false, + }, + ActionSpec { + id: "toggle_focused_tool", + name: "Expand or collapse tool", + aliases: &[], + description: "Expand or collapse the focused tool", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::ToggleFocusedTool, + default_bindings: &["Ctrl+O"], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: Some("Expand / Collapse Tool"), + slash_on_startup: false, + }, + ActionSpec { + id: "previous_tool", + name: "Previous tool", + aliases: &[], + description: "Focus the previous tool", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::PreviousTool, + default_bindings: &["Ctrl+J"], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: Some("Previous Tool"), + slash_on_startup: false, + }, + ActionSpec { + id: "next_tool", + name: "Next tool", + aliases: &[], + description: "Focus the next tool", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::NextTool, + default_bindings: &["Ctrl+K"], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: Some("Next Tool"), + slash_on_startup: false, + }, + ActionSpec { + id: "history_previous", + name: "Previous input", + aliases: &[], + description: "Select the previous input history entry", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::HistoryPrevious, + default_bindings: &["Up"], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: Some("Previous Input"), + slash_on_startup: false, + }, + ActionSpec { + id: "history_next", + name: "Next input", + aliases: &[], + description: "Select the next input history entry", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::HistoryNext, + default_bindings: &["Down"], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: Some("Next Input"), + slash_on_startup: false, + }, + ActionSpec { + id: "jump_top", + name: "Jump to top", + aliases: &[], + description: "Jump to the top of the conversation", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::JumpTop, + default_bindings: &["Ctrl+Home"], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: Some("Jump to Top"), + slash_on_startup: false, + }, + ActionSpec { + id: "jump_bottom", + name: "Jump to bottom", + aliases: &[], + description: "Jump to the bottom of the conversation", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::JumpBottom, + default_bindings: &["Ctrl+End"], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: Some("Jump to Bottom"), + slash_on_startup: false, + }, + ActionSpec { + id: "clear_input", + name: "Clear input", + aliases: &[], + description: "Clear the current input", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::ClearInput, + default_bindings: &["Ctrl+U"], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: Some("Clear Input"), + slash_on_startup: false, + }, + ActionSpec { + id: "toggle_browse", + name: "Toggle browse mode", + aliases: &[], + description: "Toggle conversation browse mode", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::ToggleBrowse, + default_bindings: &["Ctrl+E"], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: Some("Browse"), + slash_on_startup: false, + }, + ActionSpec { + id: "scroll_up", + name: "Scroll messages up", + aliases: &[], + description: "Scroll the conversation up", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::ScrollUp, + default_bindings: &["PageUp"], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: Some("Scroll Messages Up"), + slash_on_startup: false, + }, + ActionSpec { + id: "scroll_down", + name: "Scroll messages down", + aliases: &[], + description: "Scroll the conversation down", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::ScrollDown, + default_bindings: &["PageDown"], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: Some("Scroll Messages Down"), + slash_on_startup: false, + }, +]; + +impl ActionSpec { + fn supports_context(&self, context: ActionContext) -> bool { + self.contexts.contains(&context) + } + + pub(crate) fn available(&self, state: ActionState) -> bool { + if !self.supports_context(state.context) { + return false; + } + match self.availability { + ActionAvailability::Always => true, + ActionAvailability::Idle => !state.is_processing, + ActionAvailability::Processing => state.is_processing, + ActionAvailability::Popup => state.popup_open, + } + } + + pub(crate) fn unavailable_message(&self, state: ActionState) -> String { + match self.availability { + ActionAvailability::Idle if state.is_processing => format!( + "{} is unavailable while a turn is processing. Use the interrupt shortcut first.", + self.name + ), + ActionAvailability::Processing => { + format!( + "{} is available only while a turn is processing.", + self.name + ) + } + ActionAvailability::Popup => { + format!("{} is available only while a popup is open.", self.name) + } + _ => format!("{} is unavailable here.", self.name), + } + } +} + +#[cfg(test)] +pub(crate) fn action_specs() -> &'static [ActionSpec] { + ACTION_SPECS +} + +pub(crate) fn action_by_id(id: &str, context: ActionContext) -> Option<&'static ActionSpec> { + ACTION_SPECS + .iter() + .find(|spec| spec.id == id && spec.supports_context(context)) +} + +pub(crate) fn action_for_alias(alias: &str, context: ActionContext) -> Option<&'static ActionSpec> { + ACTION_SPECS.iter().find(|spec| { + spec.supports_context(context) + && spec + .aliases + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(alias)) + }) +} + +pub(crate) fn slash_actions(state: ActionState) -> Vec { + ACTION_SPECS + .iter() + .filter(|spec| { + spec.available(state) + && !spec.aliases.is_empty() + && (state.context != ActionContext::Startup || spec.slash_on_startup) + }) + .flat_map(|spec| { + spec.aliases.iter().map(|alias| ActionProjection { + id: spec.id, + name: alias, + description: spec.description, + palette_group: None, + suggested: false, + }) + }) + .collect() +} + +pub(crate) fn palette_actions(state: ActionState) -> Vec { + ACTION_SPECS + .iter() + .filter_map(|spec| { + let palette = spec.palette?; + spec.available(state).then_some(ActionProjection { + id: spec.id, + name: spec.name, + description: spec.description, + palette_group: Some(palette.group), + suggested: palette.suggested, + }) + }) + .collect() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct KeyChord { + code: KeyCode, + modifiers: KeyModifiers, + display: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ModifierMatch { + Exact, + Any, + ContainsExpected, + WithoutAlt, +} + +impl KeyChord { + fn parse(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err("binding is empty".to_string()); + } + + let parts: Vec<&str> = value.split('+').map(str::trim).collect(); + let Some(key_name) = parts.last().copied() else { + return Err("binding is empty".to_string()); + }; + if key_name.is_empty() { + return Err("binding has no key".to_string()); + } + + let mut modifiers = KeyModifiers::NONE; + for modifier in &parts[..parts.len().saturating_sub(1)] { + match modifier.to_ascii_lowercase().as_str() { + "ctrl" | "control" => modifiers.insert(KeyModifiers::CONTROL), + "alt" | "option" => modifiers.insert(KeyModifiers::ALT), + "shift" => modifiers.insert(KeyModifiers::SHIFT), + "super" | "cmd" | "command" => modifiers.insert(KeyModifiers::SUPER), + other => return Err(format!("unsupported modifier `{other}`")), + } + } + + let normalized = key_name.to_ascii_lowercase(); + let code = match normalized.as_str() { + "enter" | "return" | "↵" => KeyCode::Enter, + "esc" | "escape" => KeyCode::Esc, + "tab" if modifiers.contains(KeyModifiers::SHIFT) => KeyCode::BackTab, + "tab" => KeyCode::Tab, + "backtab" => { + modifiers.insert(KeyModifiers::SHIFT); + KeyCode::BackTab + } + "up" | "↑" => KeyCode::Up, + "down" | "↓" => KeyCode::Down, + "left" | "←" => KeyCode::Left, + "right" | "→" => KeyCode::Right, + "pageup" | "pgup" => KeyCode::PageUp, + "pagedown" | "pgdown" | "pgdn" => KeyCode::PageDown, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "backspace" => KeyCode::Backspace, + "delete" | "del" => KeyCode::Delete, + "space" => KeyCode::Char(' '), + _ if normalized.chars().count() == 1 => { + KeyCode::Char(normalized.chars().next().unwrap()) + } + _ if normalized.starts_with('f') => { + let number = normalized[1..] + .parse::() + .map_err(|_| format!("unsupported key `{key_name}`"))?; + if !(1..=12).contains(&number) { + return Err(format!("unsupported key `{key_name}`")); + } + KeyCode::F(number) + } + _ => return Err(format!("unsupported key `{key_name}`")), + }; + + let display = Self::display_for(&code, modifiers); + Ok(Self { + code, + modifiers, + display, + }) + } + + fn matches(&self, key: KeyEvent, modifier_match: ModifierMatch) -> bool { + let relevant = KeyModifiers::CONTROL + | KeyModifiers::ALT + | KeyModifiers::SHIFT + | KeyModifiers::SUPER + | KeyModifiers::HYPER + | KeyModifiers::META; + let modifiers = key.modifiers & relevant; + let code_matches = match (&self.code, key.code) { + (KeyCode::Char(expected), KeyCode::Char(actual)) => { + expected.eq_ignore_ascii_case(&actual) + } + (expected, actual) => *expected == actual, + }; + let modifiers_match = match modifier_match { + ModifierMatch::Exact => self.modifiers == modifiers, + ModifierMatch::Any => true, + ModifierMatch::ContainsExpected => modifiers.contains(self.modifiers), + ModifierMatch::WithoutAlt => !modifiers.contains(KeyModifiers::ALT), + }; + code_matches && modifiers_match + } + + fn display_for(code: &KeyCode, modifiers: KeyModifiers) -> String { + let mut parts = Vec::new(); + if modifiers.contains(KeyModifiers::CONTROL) { + parts.push("Ctrl".to_string()); + } + if modifiers.contains(KeyModifiers::ALT) { + parts.push("Alt".to_string()); + } + if modifiers.contains(KeyModifiers::SHIFT) && !matches!(code, KeyCode::BackTab) { + parts.push("Shift".to_string()); + } + if modifiers.contains(KeyModifiers::SUPER) { + parts.push("Super".to_string()); + } + let key = match code { + KeyCode::Enter => "Enter".to_string(), + KeyCode::Esc => "Esc".to_string(), + KeyCode::Tab => "Tab".to_string(), + KeyCode::BackTab => "Shift+Tab".to_string(), + KeyCode::Up => "↑".to_string(), + KeyCode::Down => "↓".to_string(), + KeyCode::Left => "←".to_string(), + KeyCode::Right => "→".to_string(), + KeyCode::PageUp => "PageUp".to_string(), + KeyCode::PageDown => "PageDown".to_string(), + KeyCode::Home => "Home".to_string(), + KeyCode::End => "End".to_string(), + KeyCode::Backspace => "Backspace".to_string(), + KeyCode::Delete => "Delete".to_string(), + KeyCode::Char(' ') => "Space".to_string(), + KeyCode::Char(character) => character.to_ascii_uppercase().to_string(), + KeyCode::F(number) => format!("F{number}"), + other => format!("{other:?}"), + }; + parts.push(key); + parts.join("+") + } +} + +#[derive(Debug, Clone)] +struct BindingPolicy { + availability: ActionAvailability, + modifier_match: ModifierMatch, + reserved: bool, + above_modals: bool, +} + +#[derive(Debug, Clone)] +struct ResolvedBinding { + spec: &'static ActionSpec, + chord: KeyChord, + source: String, + policy: BindingPolicy, +} + +impl ResolvedBinding { + fn available(&self, state: ActionState, above_modals: bool) -> bool { + if !self.spec.supports_context(state.context) { + return false; + } + if state.popup_open + && !above_modals + && self.policy.availability != ActionAvailability::Popup + { + return false; + } + match self.policy.availability { + ActionAvailability::Always => true, + ActionAvailability::Idle => !state.is_processing, + ActionAvailability::Processing => state.is_processing, + ActionAvailability::Popup => state.popup_open, + } + } +} + +#[derive(Debug, Clone)] +struct KeymapDiagnostic { + message: String, + contexts: Vec, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct ResolvedKeymap { + bindings: Vec, + diagnostics: Vec, +} + +impl ResolvedKeymap { + pub(crate) fn new(shortcuts: &ShortcutsConfig) -> Self { + let mut keymap = Self::default(); + + for spec in ACTION_SPECS { + for binding in spec.fallback_bindings { + let availability = match spec.handler { + ActionHandler::Exit => ActionAvailability::Idle, + _ => spec.availability, + }; + keymap.push_binding( + spec, + binding, + "BitFun safety".to_string(), + BindingPolicy { + availability, + modifier_match: built_in_modifier_match(spec, binding), + reserved: true, + above_modals: binding.eq_ignore_ascii_case("Ctrl+C"), + }, + ); + } + } + + let mut valid_overrides = Vec::new(); + for field in [ + ShortcutField::SendMessage, + ShortcutField::Interrupt, + ShortcutField::Menu, + ] { + let Some(value) = field.value(shortcuts) else { + continue; + }; + let Some(spec) = ACTION_SPECS + .iter() + .find(|spec| spec.shortcut_field == Some(field)) + else { + continue; + }; + match KeyChord::parse(value) { + Ok(chord) => { + valid_overrides.push(field); + keymap.push_resolved_binding(ResolvedBinding { + spec, + chord, + source: field.source().to_string(), + policy: BindingPolicy { + availability: spec.availability, + modifier_match: ModifierMatch::Exact, + reserved: false, + above_modals: false, + }, + }); + } + Err(error) => keymap.push_diagnostic( + format!( + "Invalid {} ({}); using BitFun default", + field.source(), + binding_error_summary(&error) + ), + spec.contexts.to_vec(), + ), + } + } + + for spec in ACTION_SPECS { + let overridden = spec + .shortcut_field + .is_some_and(|field| valid_overrides.contains(&field)); + if overridden { + continue; + } + for binding in spec.default_bindings { + keymap.push_binding( + spec, + binding, + "BitFun default".to_string(), + BindingPolicy { + availability: spec.availability, + modifier_match: built_in_modifier_match(spec, binding), + reserved: false, + above_modals: false, + }, + ); + } + } + + keymap + } + + /// Resolve a key to its registry entry without executing product behavior. + pub(crate) fn resolve(&self, key: KeyEvent, state: ActionState) -> Option<&'static ActionSpec> { + self.resolve_binding_index(key, state) + .map(|index| self.bindings[index].spec) + } + + fn resolve_binding_index(&self, key: KeyEvent, state: ActionState) -> Option { + if state.popup_open { + if let Some((index, _)) = self.bindings.iter().enumerate().find(|(_, binding)| { + binding.policy.availability == ActionAvailability::Popup + && binding.available(state, false) + && binding_matches(binding, key) + }) { + return Some(index); + } + } + self.bindings + .iter() + .enumerate() + .find(|(_, binding)| binding.available(state, false) && binding_matches(binding, key)) + .map(|(index, _)| index) + } + + /// Resolve reserved keys while a normal popup owns the input focus. + pub(crate) fn resolve_reserved( + &self, + key: KeyEvent, + state: ActionState, + ) -> Option<&'static ActionSpec> { + if state.popup_open { + if let Some(binding) = self.bindings.iter().find(|binding| { + binding.policy.reserved + && binding.policy.availability == ActionAvailability::Popup + && binding.available(state, true) + && binding.chord.matches(key, binding.policy.modifier_match) + }) { + return Some(binding.spec); + } + } + self.bindings + .iter() + .find(|binding| { + binding.policy.reserved + && binding.available(state, true) + && binding.chord.matches(key, binding.policy.modifier_match) + }) + .map(|binding| binding.spec) + } + + /// Resolve only Ctrl+C, which must remain available above permission, + /// question, and popup handlers. + pub(crate) fn resolve_modal_safe( + &self, + key: KeyEvent, + state: ActionState, + ) -> Option<&'static ActionSpec> { + self.bindings + .iter() + .find(|binding| { + binding.policy.above_modals + && binding.available(state, true) + && binding.chord.matches(key, binding.policy.modifier_match) + }) + .map(|binding| binding.spec) + } + + #[cfg(test)] + pub(crate) fn diagnostics(&self) -> Vec<&str> { + self.diagnostics + .iter() + .map(|diagnostic| diagnostic.message.as_str()) + .collect() + } + + pub(crate) fn help_text(&self, state: ActionState) -> String { + let groups: &[(&[&str], &str)] = match state.context { + ActionContext::Startup => &[ + (&["submit_input", "insert_newline"], "Send / Newline"), + (&["cycle_agent", "switch_agent_reverse"], "Switch Agent"), + (&["open_palette"], "Command Palette"), + (&["exit"], "Quit"), + ], + ActionContext::Chat => &[ + (&["submit_input", "insert_newline"], "Send / Newline"), + (&["cycle_agent", "switch_agent_reverse"], "Switch Agent"), + (&["open_palette"], "Command Palette"), + (&["previous_tool", "next_tool"], "Prev / Next Tool"), + (&["toggle_focused_tool"], "Expand / Collapse Tool"), + (&["toggle_browse"], "Toggle Browse Mode"), + (&["history_previous", "history_next"], "Input History"), + (&["scroll_up", "scroll_down"], "Scroll Messages"), + (&["jump_top", "jump_bottom"], "Jump to Top / Bottom"), + (&["clear_input"], "Clear Input"), + (&["interrupt"], "Interrupt"), + (&["exit"], "Quit"), + ], + }; + + let mut rows = Vec::new(); + for (action_ids, label) in groups { + let mut active = Vec::new(); + for action_id in *action_ids { + let keys = self.keys_for_state(&[action_id], state); + if keys.is_empty() { + continue; + } + let action_label = ACTION_SPECS + .iter() + .find(|spec| spec.id == *action_id) + .and_then(|spec| spec.shortcut_label) + .unwrap_or(label); + active.push((keys, action_label)); + } + if active.len() == 1 { + let (keys, action_label) = active.pop().unwrap(); + let row_label = if action_ids.len() == 1 { + *label + } else { + action_label + }; + rows.push((keys.join(" / "), row_label.to_string())); + } else if !active.is_empty() { + let keys = active + .into_iter() + .flat_map(|(keys, _)| keys) + .collect::>(); + rows.push((keys.join(" / "), (*label).to_string())); + } + } + + let popup_state = ActionState { + popup_open: true, + ..state + }; + let recovery_keys = self.keys_for_state(&["close_popups", "navigate_back"], popup_state); + if !recovery_keys.is_empty() { + rows.push(( + recovery_keys.join(" / "), + "Close All Popups / Back".to_string(), + )); + } + + let width = rows.iter().map(|(keys, _)| keys.len()).max().unwrap_or(0); + let mut output = String::from("Keyboard Shortcuts\n─────────────────────────────────\n"); + for (keys, label) in rows { + output.push_str(&format!("{keys: available_lines { + break; + } + for (index, line) in lines.iter().enumerate() { + output.push_str(if index == 0 { "- " } else { " " }); + output.push_str(line); + output.push('\n'); + } + available_lines -= lines.len(); + shown += 1; + } + if notices.len() > shown && available_lines > 0 { + output.push_str(&format!( + "- {} more shortcut notices\n", + notices.len() - shown + )); + } + } + output.trim_end().to_string() + } + + pub(crate) fn compact_hints(&self, state: ActionState) -> Vec<(String, &'static str)> { + let mut hints = Vec::new(); + for id in ["cycle_agent", "insert_newline", "open_palette"] { + self.push_compact_hint(&mut hints, id, state); + } + + let mut history_keys = self.keys_for_state(&["history_previous"], state); + history_keys.extend(self.keys_for_state(&["history_next"], state)); + history_keys.dedup(); + if !history_keys.is_empty() { + hints.push((history_keys.join(""), "History")); + } + + self.push_compact_hint(&mut hints, "toggle_browse", state); + self.push_compact_hint( + &mut hints, + if state.is_processing { + "interrupt" + } else { + "exit" + }, + state, + ); + hints + } + + fn push_compact_hint( + &self, + hints: &mut Vec<(String, &'static str)>, + id: &str, + state: ActionState, + ) { + let Some(spec) = ACTION_SPECS.iter().find(|spec| spec.id == id) else { + return; + }; + let Some(label) = spec.shortcut_label else { + return; + }; + let mut keys = self.keys_for_state(&[id], state); + if id == "interrupt" { + keys.truncate(1); + } + for key in &mut keys { + if key == "Alt+Enter" { + *key = "Alt+↵".to_string(); + } + } + if !keys.is_empty() { + hints.push((keys.join("/"), label)); + } + } + + #[cfg(test)] + fn keys_for(&self, action_id: &str, context: ActionContext) -> Vec { + let mut keys = Vec::new(); + for (index, binding) in self.bindings.iter().enumerate() { + if binding.spec.id == action_id + && binding.spec.supports_context(context) + && self.binding_is_effective(index, context) + && !keys.contains(&binding.chord.display) + { + keys.push(binding.chord.display.clone()); + } + } + keys + } + + #[cfg(test)] + fn binding_is_effective(&self, index: usize, context: ActionContext) -> bool { + let binding = &self.bindings[index]; + let states = match context { + ActionContext::Startup => STARTUP_ACTION_STATES, + ActionContext::Chat => CHAT_ACTION_STATES, + }; + + states.iter().copied().any(|state| { + binding.available(state, false) && self.canonical_binding_is_effective_at(index, state) + }) + } + + fn canonical_binding_is_effective_at(&self, index: usize, state: ActionState) -> bool { + let binding = &self.bindings[index]; + let key = KeyEvent::new(binding.chord.code, binding.chord.modifiers); + binding_matches(binding, key) && self.resolve_binding_index(key, state) == Some(index) + } + + fn keys_for_state(&self, action_ids: &[&str], state: ActionState) -> Vec { + let mut keys = Vec::new(); + for (index, binding) in self.bindings.iter().enumerate() { + if action_ids.contains(&binding.spec.id) + && binding.available(state, false) + && self.canonical_binding_is_effective_at(index, state) + && !keys.contains(&binding.chord.display) + { + keys.push(binding.chord.display.clone()); + } + } + keys + } + + fn diagnostics_for(&self, context: ActionContext) -> Vec<&str> { + self.diagnostics + .iter() + .filter(|diagnostic| diagnostic.contexts.contains(&context)) + .map(|diagnostic| diagnostic.message.as_str()) + .collect() + } + + fn push_binding( + &mut self, + spec: &'static ActionSpec, + binding: &str, + source: String, + policy: BindingPolicy, + ) { + match KeyChord::parse(binding) { + Ok(chord) => self.push_resolved_binding(ResolvedBinding { + spec, + chord, + source, + policy, + }), + Err(error) => self.push_diagnostic( + format!( + "Invalid built-in binding `{binding}` for {}: {error}", + spec.id + ), + spec.contexts.to_vec(), + ), + } + } + + fn push_resolved_binding(&mut self, binding: ResolvedBinding) { + if self.bindings.iter().any(|existing| { + existing.spec.handler == binding.spec.handler && existing.chord == binding.chord + }) { + return; + } + + let conflicts = self + .bindings + .iter() + .filter(|existing| { + existing.spec.handler != binding.spec.handler + && bindings_share_input(existing, &binding) + && availability_overlaps(existing, &binding) + }) + .map(|winner| { + let contexts = overlapping_contexts(winner, &binding); + let message = format!( + "{}: {} ({})\nignored: {} ({})", + conflict_display(winner, &binding), + notice_label(winner.spec), + winner.source, + notice_label(binding.spec), + binding.source + ); + KeymapDiagnostic { message, contexts } + }) + .collect::>(); + self.diagnostics.extend(conflicts); + + self.bindings.push(binding); + } + + fn push_diagnostic(&mut self, message: String, contexts: Vec) { + self.diagnostics + .push(KeymapDiagnostic { message, contexts }); + } +} + +fn built_in_modifier_match(spec: &ActionSpec, binding: &str) -> ModifierMatch { + match (spec.id, binding) { + ("submit_input", "Enter") => ModifierMatch::WithoutAlt, + ("insert_newline", "Alt+Enter") => ModifierMatch::ContainsExpected, + ("cycle_agent", "Tab") + | ("switch_agent_reverse", "Shift+Tab") + | ("scroll_up", "PageUp") + | ("scroll_down", "PageDown") + | ("interrupt", "Esc") + | ("navigate_back", "Esc") => ModifierMatch::Any, + _ => ModifierMatch::Exact, + } +} + +fn binding_matches(binding: &ResolvedBinding, key: KeyEvent) -> bool { + binding.chord.matches(key, binding.policy.modifier_match) +} + +fn relevant_modifier_variants() -> impl Iterator { + const MODIFIERS: [KeyModifiers; 6] = [ + KeyModifiers::CONTROL, + KeyModifiers::ALT, + KeyModifiers::SHIFT, + KeyModifiers::SUPER, + KeyModifiers::HYPER, + KeyModifiers::META, + ]; + + (0u8..64).map(|mask| { + MODIFIERS + .iter() + .enumerate() + .fold(KeyModifiers::NONE, |mut value, (index, modifier)| { + if mask & (1 << index) != 0 { + value.insert(*modifier); + } + value + }) + }) +} + +fn bindings_share_input(left: &ResolvedBinding, right: &ResolvedBinding) -> bool { + relevant_modifier_variants().any(|modifiers| { + let key = KeyEvent::new(left.chord.code, modifiers); + binding_matches(left, key) && binding_matches(right, key) + }) +} + +fn conflict_display<'a>(left: &'a ResolvedBinding, right: &'a ResolvedBinding) -> &'a str { + match ( + left.policy.modifier_match == ModifierMatch::Exact, + right.policy.modifier_match == ModifierMatch::Exact, + ) { + (true, false) => &left.chord.display, + (false, true) => &right.chord.display, + _ => &right.chord.display, + } +} + +fn notice_label(spec: &ActionSpec) -> &str { + spec.shortcut_label.unwrap_or(spec.name) +} + +fn availability_overlaps(left: &ResolvedBinding, right: &ResolvedBinding) -> bool { + STARTUP_ACTION_STATES + .iter() + .chain(CHAT_ACTION_STATES) + .copied() + .any(|state| left.available(state, false) && right.available(state, false)) +} + +fn overlapping_contexts(left: &ResolvedBinding, right: &ResolvedBinding) -> Vec { + [ActionContext::Startup, ActionContext::Chat] + .into_iter() + .filter(|context| { + let states = match context { + ActionContext::Startup => STARTUP_ACTION_STATES, + ActionContext::Chat => CHAT_ACTION_STATES, + }; + states + .iter() + .copied() + .any(|state| left.available(state, false) && right.available(state, false)) + }) + .collect() +} + +fn wrap_help_notice(value: &str, max_chars: usize) -> Vec { + value + .lines() + .flat_map(|line| { + let chars = line.chars().collect::>(); + chars + .chunks(max_chars.max(1)) + .map(|chunk| chunk.iter().collect::()) + .collect::>() + }) + .collect() +} + +fn binding_error_summary(error: &str) -> &str { + if error.starts_with("unsupported modifier") { + "unsupported modifier" + } else if error.starts_with("unsupported key") { + "unsupported key" + } else { + error + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + use super::*; + + fn resolve_id( + keymap: &ResolvedKeymap, + key: KeyEvent, + state: ActionState, + ) -> Option<&'static str> { + keymap.resolve(key, state).map(|action| action.id) + } + + #[test] + fn registry_has_unique_stable_ids_and_aliases() { + let specs = action_specs(); + assert!(!specs.is_empty(), "the action registry must not be empty"); + + let mut ids = HashSet::new(); + let mut aliases = HashSet::new(); + for spec in specs { + assert!(ids.insert(spec.id), "duplicate action id: {}", spec.id); + for alias in spec.aliases { + assert!( + aliases.insert(alias.to_ascii_lowercase()), + "duplicate action alias: {alias}" + ); + } + } + } + + #[test] + fn slash_and_palette_project_the_same_handler() { + let state = ActionState::chat(false, false); + let slash = slash_actions(state); + let palette = palette_actions(state); + assert!(!slash.is_empty()); + assert!(!palette.is_empty()); + + for palette_action in palette { + assert!(action_by_id(palette_action.id, ActionContext::Chat).is_some()); + } + } + + #[test] + fn agent_selector_and_agent_cycle_are_distinct_actions() { + let slash = action_for_alias("/agents", ActionContext::Chat).unwrap(); + assert_eq!(slash.handler, ActionHandler::OpenAgentSelector); + + let keymap = ResolvedKeymap::new(&ShortcutsConfig::default()); + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), + ActionState::chat(false, false), + ), + Some("cycle_agent") + ); + } + + #[test] + fn no_config_uses_current_real_dispatch_defaults() { + let keymap = ResolvedKeymap::new(&ShortcutsConfig::default()); + + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ActionState::chat(false, false), + ), + Some("submit_input") + ); + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL), + ActionState::startup(false), + ), + Some("open_palette") + ); + } + + #[test] + fn explicit_non_default_binding_resolves_real_key_event() { + let shortcuts = ShortcutsConfig { + send_message: Some("Ctrl+S".to_string()), + interrupt: None, + menu: None, + }; + let keymap = ResolvedKeymap::new(&shortcuts); + + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL), + ActionState::chat(false, false), + ), + Some("submit_input") + ); + } + + #[test] + fn all_explicit_non_default_fields_override_non_reserved_defaults() { + let shortcuts = ShortcutsConfig { + send_message: Some("Ctrl+S".to_string()), + interrupt: Some("Ctrl+X".to_string()), + menu: Some("Alt+M".to_string()), + }; + let keymap = ResolvedKeymap::new(&shortcuts); + + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL), + ActionState::chat(false, false), + ), + Some("submit_input") + ); + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + ActionState::chat(true, false), + ), + Some("interrupt") + ); + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::Char('m'), KeyModifiers::ALT), + ActionState::startup(false), + ), + Some("open_palette") + ); + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ActionState::chat(false, false), + ), + None, + "an explicit send binding must replace the built-in Enter binding" + ); + } + + #[test] + fn user_binding_conflicts_are_deterministic_and_explainable() { + let shortcuts = ShortcutsConfig { + send_message: Some("Ctrl+D".to_string()), + interrupt: None, + menu: Some("Ctrl+D".to_string()), + }; + let keymap = ResolvedKeymap::new(&shortcuts); + + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), + ActionState::chat(false, false), + ), + Some("submit_input"), + "field order is the documented deterministic tie-breaker" + ); + let diagnostic = keymap.diagnostics().join("\n"); + assert!(diagnostic.contains("shortcuts.send_message")); + assert!(diagnostic.contains("shortcuts.menu")); + } + + #[test] + fn invalid_user_binding_falls_back_to_builtin_and_is_reported() { + let shortcuts = ShortcutsConfig { + send_message: Some("Ctrl+DefinitelyNotAKey".to_string()), + interrupt: None, + menu: None, + }; + let keymap = ResolvedKeymap::new(&shortcuts); + + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ActionState::chat(false, false), + ), + Some("submit_input") + ); + assert!(keymap + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.contains("Invalid shortcuts.send_message"))); + } + + #[test] + fn safety_binding_takes_priority_and_conflict_reports_both_sources() { + let shortcuts = ShortcutsConfig { + send_message: None, + interrupt: None, + menu: Some("Ctrl+C".to_string()), + }; + let keymap = ResolvedKeymap::new(&shortcuts); + + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ActionState::startup(false), + ), + Some("exit") + ); + let diagnostic = keymap.diagnostics().join("\n"); + assert!(diagnostic.contains("BitFun safety")); + assert!(diagnostic.contains("shortcuts.menu")); + assert!(diagnostic.contains("Quit")); + assert!(diagnostic.contains("Interrupt")); + assert!( + keymap + .keys_for("open_palette", ActionContext::Startup) + .is_empty(), + "help must not advertise a binding that can never win" + ); + } + + #[test] + fn popup_and_turn_recovery_bindings_remain_contextual_fallbacks() { + let shortcuts = ShortcutsConfig { + send_message: Some("Esc".to_string()), + interrupt: None, + menu: Some("Ctrl+W".to_string()), + }; + let keymap = ResolvedKeymap::new(&shortcuts); + + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ActionState::chat(false, false), + ), + Some("submit_input") + ); + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ActionState::chat(true, false), + ), + Some("interrupt") + ); + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ActionState::chat(false, true), + ), + Some("navigate_back") + ); + assert_eq!( + keymap + .resolve_reserved( + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ActionState::chat(true, true), + ) + .map(|action| action.id), + Some("navigate_back"), + "popup-local Esc must remain Back even while a turn is processing" + ); + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL), + ActionState::startup(false), + ), + Some("open_palette") + ); + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL), + ActionState::startup(true), + ), + Some("close_popups") + ); + } + + #[test] + fn reserved_ctrl_c_remains_available_above_modal_layers() { + let keymap = ResolvedKeymap::new(&ShortcutsConfig::default()); + let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + + assert_eq!( + keymap + .resolve_modal_safe(ctrl_c, ActionState::startup(true)) + .map(|action| action.id), + Some("exit") + ); + assert_eq!( + keymap + .resolve_modal_safe(ctrl_c, ActionState::chat(true, true)) + .map(|action| action.id), + Some("interrupt") + ); + } + + #[test] + fn action_availability_is_enforced_at_the_dispatch_boundary() { + let switch_agent = action_by_id("switch_agent", ActionContext::Chat).unwrap(); + let exit = action_by_id("exit", ActionContext::Chat).unwrap(); + + assert!(!switch_agent.available(ActionState::chat(true, false))); + assert!(exit.available(ActionState::chat(true, false))); + } + + #[test] + fn built_in_key_matching_preserves_legacy_modifier_behavior() { + let keymap = ResolvedKeymap::new(&ShortcutsConfig::default()); + let idle = ActionState::chat(false, false); + + for modifiers in [KeyModifiers::SHIFT, KeyModifiers::CONTROL] { + assert_eq!( + resolve_id(&keymap, KeyEvent::new(KeyCode::Enter, modifiers), idle), + Some("submit_input") + ); + } + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT | KeyModifiers::SHIFT), + idle, + ), + Some("insert_newline") + ); + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::Tab, KeyModifiers::CONTROL), + idle, + ), + Some("cycle_agent") + ); + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::PageDown, KeyModifiers::SHIFT), + idle, + ), + Some("scroll_down") + ); + } + + #[test] + fn modifier_overlap_uses_the_same_runtime_and_help_semantics() { + let keymap = ResolvedKeymap::new(&ShortcutsConfig { + send_message: None, + interrupt: None, + menu: Some("Ctrl+Esc".to_string()), + }); + let ctrl_esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::CONTROL); + + assert_eq!( + resolve_id(&keymap, ctrl_esc, ActionState::chat(true, false)), + Some("interrupt") + ); + let processing_help = keymap.help_text(ActionState::chat(true, false)); + assert!(!processing_help + .lines() + .any(|line| { line.contains("Ctrl+Esc") && line.contains("Command Palette") })); + let idle_help = keymap.help_text(ActionState::chat(false, false)); + assert!(idle_help + .lines() + .any(|line| { line.contains("Ctrl+Esc") && line.contains("Command Palette") })); + + let diagnostic = keymap.diagnostics().join("\n"); + assert!(diagnostic.contains("Ctrl+Esc")); + assert!(diagnostic.contains("shortcuts.menu")); + assert!(diagnostic.contains("BitFun safety")); + } + + #[test] + fn partial_modifier_overlap_is_reported_without_hiding_effective_keys() { + let keymap = ResolvedKeymap::new(&ShortcutsConfig { + send_message: None, + interrupt: None, + menu: Some("Ctrl+Tab".to_string()), + }); + + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::Tab, KeyModifiers::CONTROL), + ActionState::chat(false, false), + ), + Some("open_palette") + ); + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), + ActionState::chat(false, false), + ), + Some("cycle_agent") + ); + let help = keymap.help_text(ActionState::chat(false, false)); + assert!(help.contains("Ctrl+Tab")); + assert!(help.contains("Tab")); + assert!(keymap.diagnostics().join("\n").contains("Ctrl+Tab")); + } + + #[test] + fn help_does_not_advertise_a_shadowed_canonical_key() { + for binding in ["Enter", "Tab"] { + let keymap = ResolvedKeymap::new(&ShortcutsConfig { + send_message: None, + interrupt: None, + menu: Some(binding.to_string()), + }); + let code = if binding == "Enter" { + KeyCode::Enter + } else { + KeyCode::Tab + }; + + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(code, KeyModifiers::NONE), + ActionState::chat(false, false), + ), + Some("open_palette") + ); + let help = keymap.help_text(ActionState::chat(false, false)); + let shadowed_action = if binding == "Enter" { + "submit_input" + } else { + "cycle_agent" + }; + assert!( + keymap + .keys_for(shadowed_action, ActionContext::Chat) + .is_empty(), + "{binding} must not be advertised for both actions:\n{help}" + ); + } + } + + #[test] + fn help_uses_single_action_labels_when_only_one_group_key_remains() { + let cases = [ + ( + ShortcutsConfig { + send_message: Some("Ctrl+C".to_string()), + interrupt: None, + menu: None, + }, + "Alt+Enter", + "Newline", + "Send / Newline", + ), + ( + ShortcutsConfig { + send_message: None, + interrupt: None, + menu: Some("Alt+Enter".to_string()), + }, + "Enter", + "Send", + "Send / Newline", + ), + ( + ShortcutsConfig { + send_message: None, + interrupt: None, + menu: Some("Ctrl+J".to_string()), + }, + "Ctrl+K", + "Next Tool", + "Prev / Next Tool", + ), + ( + ShortcutsConfig { + send_message: None, + interrupt: None, + menu: Some("Ctrl+Home".to_string()), + }, + "Ctrl+End", + "Jump to Bottom", + "Jump to Top / Bottom", + ), + ]; + + for (shortcuts, key, expected_label, combined_label) in cases { + let help = ResolvedKeymap::new(&shortcuts).help_text(ActionState::chat(false, false)); + let shortcuts_section = help.split("Shortcut notices").next().unwrap(); + let row = shortcuts_section + .lines() + .find(|line| line.contains(key)) + .unwrap_or_else(|| panic!("missing {key} row:\n{help}")); + assert!(row.contains(expected_label), "{row}"); + assert!(!row.contains(combined_label), "{row}"); + } + } + + #[test] + fn help_exposes_custom_send_and_popup_recovery_keys() { + let keymap = ResolvedKeymap::new(&ShortcutsConfig { + send_message: Some("Ctrl+S".to_string()), + interrupt: None, + menu: None, + }); + let help = keymap.help_text(ActionState::chat(false, false)); + + assert!(help + .lines() + .any(|line| line.contains("Ctrl+S") && line.contains("Send"))); + assert!(help.lines().any(|line| { + line.contains("Ctrl+W / Esc") && line.contains("Close All Popups / Back") + })); + } + + #[test] + fn processing_slash_projection_omits_init() { + let ids = slash_actions(ActionState::chat(true, false)) + .into_iter() + .map(|action| action.id) + .collect::>(); + + assert!(!ids.contains(&"init")); + } + + #[test] + fn hints_follow_the_current_turn_state() { + let keymap = ResolvedKeymap::new(&ShortcutsConfig::default()); + let idle = keymap.compact_hints(ActionState::chat(false, false)); + let processing = keymap.compact_hints(ActionState::chat(true, false)); + + assert!(idle.iter().any(|(_, label)| *label == "Quit")); + assert!(!idle.iter().any(|(_, label)| *label == "Interrupt")); + assert!(processing.iter().any(|(_, label)| *label == "Interrupt")); + assert!(!processing.iter().any(|(_, label)| *label == "Quit")); + } + + #[test] + fn default_chat_help_fits_an_80_by_24_popup() { + let keymap = ResolvedKeymap::new(&ShortcutsConfig::default()); + let help = keymap.help_text(ActionState::chat(false, false)); + + assert!(help.lines().count() <= 19, "{help}"); + assert!( + help.lines().all(|line| line.chars().count() <= 74), + "{help}" + ); + } + + #[test] + fn conflicting_shortcut_notices_still_fit_an_80_by_24_popup() { + let keymap = ResolvedKeymap::new(&ShortcutsConfig { + send_message: Some("Ctrl+C".to_string()), + interrupt: Some("Ctrl+C".to_string()), + menu: Some("Ctrl+C".to_string()), + }); + let help = keymap.help_text(ActionState::chat(false, false)); + + assert!(help.lines().count() <= 19, "{help}"); + assert!( + help.lines().all(|line| line.chars().count() <= 74), + "{help}" + ); + assert!(help.contains("BitFun safety")); + } + + #[test] + fn shortcut_notices_use_user_facing_names_and_keep_both_sources() { + let keymap = ResolvedKeymap::new(&ShortcutsConfig { + send_message: Some("Ctrl+P".to_string()), + interrupt: None, + menu: None, + }); + let help = keymap.help_text(ActionState::chat(false, false)); + + assert!(help.contains("shortcuts.send_message"), "{help}"); + assert!(help.contains("BitFun default"), "{help}"); + assert!(help.contains("Commands"), "{help}"); + assert!(!help.contains("open_palette"), "{help}"); + assert!(help.lines().all(|line| line.chars().count() <= 74)); + } + + #[test] + fn long_valid_conflict_keeps_both_sources_without_exceeding_help_bounds() { + let chord = "Ctrl+Alt+Shift+Super+F12"; + let keymap = ResolvedKeymap::new(&ShortcutsConfig { + send_message: Some(chord.to_string()), + interrupt: None, + menu: Some(chord.to_string()), + }); + let help = keymap.help_text(ActionState::chat(false, false)); + + assert!(help.contains(chord), "{help}"); + assert!(help.contains("shortcuts.send_message"), "{help}"); + assert!(help.contains("shortcuts.menu"), "{help}"); + assert!(!help.contains("..."), "{help}"); + assert!(help.lines().count() <= 19, "{help}"); + assert!(help.lines().all(|line| line.chars().count() <= 74)); + } + + #[test] + fn long_invalid_binding_keeps_field_and_fallback_visible() { + let keymap = ResolvedKeymap::new(&ShortcutsConfig { + send_message: Some(format!("Ctrl+{}", "X".repeat(512))), + interrupt: None, + menu: None, + }); + let help = keymap.help_text(ActionState::chat(false, false)); + + assert!(help.contains("Invalid shortcuts.send_message"), "{help}"); + assert!(help.contains("unsupported key"), "{help}"); + assert!(help.contains("using BitFun default"), "{help}"); + assert!(!help.contains("more shortcut notices"), "{help}"); + assert!(help.lines().count() <= 19, "{help}"); + assert!(help.lines().all(|line| line.chars().count() <= 74)); + } +} diff --git a/src/apps/cli/src/commands.rs b/src/apps/cli/src/commands.rs deleted file mode 100644 index 3a47b8e449..0000000000 --- a/src/apps/cli/src/commands.rs +++ /dev/null @@ -1,247 +0,0 @@ -/// CLI slash command definitions - -#[derive(Debug, Clone, Copy)] -pub(crate) struct CommandSpec { - pub name: &'static str, - pub description: &'static str, -} - -/// All commands (available in chat mode) -pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[ - CommandSpec { - name: "/help", - description: "Show help", - }, - CommandSpec { - name: "/clear", - description: "Clear conversation", - }, - CommandSpec { - name: "/agents", - description: "Switch agent mode", - }, - CommandSpec { - name: "/models", - description: "Select model for all modes", - }, - CommandSpec { - name: "/theme", - description: "Switch UI theme", - }, - CommandSpec { - name: "/connect", - description: "Add a new AI model configuration", - }, - CommandSpec { - name: "/new", - description: "New session", - }, - CommandSpec { - name: "/sessions", - description: "Switch session", - }, - CommandSpec { - name: "/skills", - description: "List and configure skills", - }, - CommandSpec { - name: "/reload-skills", - description: "Re-scan skill directories without restarting", - }, - CommandSpec { - name: "/subagents", - description: "List and configure subagents", - }, - CommandSpec { - name: "/mcps", - description: "Manage MCP servers", - }, - CommandSpec { - name: "/acp", - description: "Show ACP server setup", - }, - CommandSpec { - name: "/init", - description: "Explore repo and generate AGENTS.md", - }, - CommandSpec { - name: "/history", - description: "Show history", - }, - CommandSpec { - name: "/usage", - description: "Generate session usage report", - }, - CommandSpec { - name: "/exit", - description: "Exit the app", - }, - CommandSpec { - name: "/login", - description: "Account login / status (sync progress when signed in)", - }, - CommandSpec { - name: "/logout", - description: "Log out of BitFun account", - }, -]; - -/// Commands available on the startup page -pub(crate) const STARTUP_COMMAND_SPECS: &[CommandSpec] = &[ - CommandSpec { - name: "/help", - description: "Show keyboard shortcuts", - }, - CommandSpec { - name: "/sessions", - description: "Browse and continue sessions", - }, - CommandSpec { - name: "/models", - description: "Select model for all modes", - }, - CommandSpec { - name: "/theme", - description: "Switch UI theme", - }, - CommandSpec { - name: "/connect", - description: "Add a new AI model configuration", - }, - CommandSpec { - name: "/agents", - description: "Switch agent mode", - }, - CommandSpec { - name: "/skills", - description: "List and configure skills", - }, - CommandSpec { - name: "/subagents", - description: "List and configure subagents", - }, - CommandSpec { - name: "/mcps", - description: "Manage MCP servers", - }, - CommandSpec { - name: "/acp", - description: "Show ACP server setup", - }, - CommandSpec { - name: "/login", - description: "Account login / status (sync progress when signed in)", - }, - CommandSpec { - name: "/logout", - description: "Log out of BitFun account", - }, - CommandSpec { - name: "/init", - description: "Explore repo and generate AGENTS.md", - }, - CommandSpec { - name: "/usage", - description: "Generate session usage report", - }, - CommandSpec { - name: "/exit", - description: "Exit the app", - }, -]; - -pub(crate) fn match_substring_in( - query: &str, - commands: &'static [CommandSpec], -) -> Vec<&'static CommandSpec> { - if query.is_empty() { - return Vec::new(); - } - let q = query.strip_prefix('/').unwrap_or(query).to_lowercase(); - if q.is_empty() { - return Vec::new(); - } - commands - .iter() - .filter(|spec| { - spec.name - .strip_prefix('/') - .unwrap_or(spec.name) - .to_lowercase() - .contains(&q) - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_empty_query_returns_empty() { - let result = match_substring_in("", COMMAND_SPECS); - assert!(result.is_empty()); - } - - #[test] - fn test_exact_match() { - let result = match_substring_in("/help", COMMAND_SPECS); - assert_eq!(result.len(), 1); - assert_eq!(result[0].name, "/help"); - } - - #[test] - fn test_prefix_match() { - let result = match_substring_in("/he", COMMAND_SPECS); - assert_eq!(result.len(), 2); - let names: Vec<&str> = result.iter().map(|s| s.name).collect(); - assert!(names.contains(&"/help")); - assert!(names.contains(&"/theme")); - } - - #[test] - fn test_substring_match() { - let result = match_substring_in("/age", COMMAND_SPECS); - let names: Vec<&str> = result.iter().map(|s| s.name).collect(); - assert!(names.contains(&"/usage")); - } - - #[test] - fn test_mid_string_match() { - let result = match_substring_in("/usa", COMMAND_SPECS); - assert_eq!(result.len(), 1); - assert_eq!(result[0].name, "/usage"); - } - - #[test] - fn test_multiple_substring_matches() { - let result = match_substring_in("/s", COMMAND_SPECS); - let names: Vec<&str> = result.iter().map(|s| s.name).collect(); - assert!(names.contains(&"/sessions")); - assert!(names.contains(&"/skills")); - assert!(names.contains(&"/subagents")); - assert!(names.contains(&"/mcps")); - assert!(names.contains(&"/usage")); - assert!(names.contains(&"/models")); - assert!(names.contains(&"/history")); - } - - #[test] - fn test_no_match() { - let result = match_substring_in("/zzz", COMMAND_SPECS); - assert!(result.is_empty()); - } - - #[test] - fn test_slash_only_returns_empty() { - let result = match_substring_in("/", COMMAND_SPECS); - assert!(result.is_empty()); - } - - #[test] - fn test_case_insensitive() { - let result = match_substring_in("/HELP", COMMAND_SPECS); - assert_eq!(result.len(), 1); - assert_eq!(result[0].name, "/help"); - } -} diff --git a/src/apps/cli/src/config.rs b/src/apps/cli/src/config.rs index c9ea73d012..b816852179 100644 --- a/src/apps/cli/src/config.rs +++ b/src/apps/cli/src/config.rs @@ -58,15 +58,18 @@ pub(crate) struct WorkspaceConfig { pub exclude_patterns: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] pub(crate) struct ShortcutsConfig { - /// Send message - pub send_message: String, - /// Interrupt - pub interrupt: String, - /// Menu - pub menu: String, + /// Explicit legacy override for sending the current input. + #[serde(skip_serializing_if = "Option::is_none")] + pub send_message: Option, + /// Explicit legacy override for interrupting the active turn. + #[serde(skip_serializing_if = "Option::is_none")] + pub interrupt: Option, + /// Explicit legacy override for opening the command palette. + #[serde(skip_serializing_if = "Option::is_none")] + pub menu: Option, } impl Default for UiConfig { @@ -105,17 +108,19 @@ impl Default for WorkspaceConfig { } } -impl Default for ShortcutsConfig { - fn default() -> Self { - Self { - send_message: "Ctrl+D".to_string(), - interrupt: "Ctrl+C".to_string(), - menu: "Esc".to_string(), +impl CliConfig { + fn normalize_legacy_shortcuts(&mut self) { + // Older releases generated these values on first launch even though the + // runtime did not dispatch through them. Only the complete generated + // tuple is identifiable as legacy output; mixed values are user choices. + if self.shortcuts.send_message.as_deref() == Some("Ctrl+D") + && self.shortcuts.interrupt.as_deref() == Some("Ctrl+C") + && self.shortcuts.menu.as_deref() == Some("Esc") + { + self.shortcuts = ShortcutsConfig::default(); } } -} -impl CliConfig { fn resolve_config_dir() -> Result { let e2e_storage_guard = matches!( std::env::var("BITFUN_E2E_STORAGE_GUARD").ok().as_deref(), @@ -149,13 +154,12 @@ impl CliConfig { if !config_path.exists() { tracing::info!("Config file not found, using defaults"); - let config = Self::default(); - config.save()?; - return Ok(config); + return Ok(Self::default()); } let content = fs::read_to_string(&config_path)?; - let config: Self = toml::from_str(&content)?; + let mut config: Self = toml::from_str(&content)?; + config.normalize_legacy_shortcuts(); tracing::info!("Loaded config: {:?}", config_path); Ok(config) } @@ -204,8 +208,59 @@ mod tests { config.workspace.exclude_patterns, ["node_modules", ".git", "target", "dist"] ); - assert_eq!(config.shortcuts.send_message, "Ctrl+D"); - assert_eq!(config.shortcuts.interrupt, "Ctrl+C"); - assert_eq!(config.shortcuts.menu, "Esc"); + assert_eq!(config.shortcuts.send_message, None); + assert_eq!(config.shortcuts.interrupt, None); + assert_eq!(config.shortcuts.menu, None); + } + + #[test] + fn missing_shortcut_fields_are_not_user_choices() { + let config: CliConfig = toml::from_str("[shortcuts]\n").unwrap(); + + assert_eq!(config.shortcuts.send_message, None); + assert_eq!(config.shortcuts.interrupt, None); + assert_eq!(config.shortcuts.menu, None); + } + + #[test] + fn legacy_generated_shortcuts_are_not_treated_as_user_choices() { + let mut config: CliConfig = toml::from_str( + "[shortcuts]\nsend_message = \"Ctrl+D\"\ninterrupt = \"Ctrl+C\"\nmenu = \"Esc\"\n", + ) + .unwrap(); + + config.normalize_legacy_shortcuts(); + + assert_eq!(config.shortcuts.send_message, None); + assert_eq!(config.shortcuts.interrupt, None); + assert_eq!(config.shortcuts.menu, None); + } + + #[test] + fn partial_legacy_shortcut_values_remain_explicit_user_choices() { + let mut config: CliConfig = toml::from_str( + "[shortcuts]\nsend_message = \"Ctrl+D\"\ninterrupt = \"Ctrl+X\"\nmenu = \"Esc\"\n", + ) + .unwrap(); + + config.normalize_legacy_shortcuts(); + + assert_eq!(config.shortcuts.send_message.as_deref(), Some("Ctrl+D")); + assert_eq!(config.shortcuts.interrupt.as_deref(), Some("Ctrl+X")); + assert_eq!(config.shortcuts.menu.as_deref(), Some("Esc")); + } + + #[test] + fn legacy_shortcut_values_that_deviate_from_generated_defaults_are_preserved() { + let mut config: CliConfig = toml::from_str( + "[shortcuts]\nsend_message = \"Ctrl+S\"\ninterrupt = \"Ctrl+X\"\nmenu = \"Alt+M\"\n", + ) + .unwrap(); + + config.normalize_legacy_shortcuts(); + + assert_eq!(config.shortcuts.send_message.as_deref(), Some("Ctrl+S")); + assert_eq!(config.shortcuts.interrupt.as_deref(), Some("Ctrl+X")); + assert_eq!(config.shortcuts.menu.as_deref(), Some("Alt+M")); } } diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 5cbf2e1c7c..987f59a03e 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -7,10 +7,10 @@ mod account; mod account_sync; mod acp_cli; +mod actions; mod agent; #[allow(dead_code)] mod chat_state; -mod commands; mod config; mod diagnostics; mod logging; @@ -557,7 +557,7 @@ async fn shutdown_mcp_servers() { /// Run the full interactive TUI flow: loading screen → startup page → chat async fn run_interactive( - _config: CliConfig, + config: CliConfig, default_agent: String, _workspace_str: String, ) -> Result<()> { @@ -595,6 +595,7 @@ async fn run_interactive( // 4. Show startup page (with full command support) let mut startup_page = StartupPage::new( + config, runtime.agent_runtime().clone(), runtime.compatibility().clone(), default_agent, diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index b22d0cc759..54adab76d7 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -15,6 +15,10 @@ use tokio::sync::broadcast::error::TryRecvError; use bitfun_events::{AgenticEvent, ToolEventData}; +use crate::actions::{ + action_by_id, action_for_alias, ActionContext, ActionHandler, ActionSpec, ActionState, + ResolvedKeymap, +}; use crate::agent::{core_adapter::CoreAgentAdapter, Agent}; use crate::chat_state::ChatState; use crate::config::CliConfig; @@ -55,23 +59,6 @@ use bitfun_core::service::session_usage::{ render_usage_report_markdown, SessionUsageReportRequest, }; -/// Keyboard shortcuts help text -const KEYBOARD_SHORTCUTS_HELP: &str = "\ -Keyboard Shortcuts\n\ -─────────────────────────────────\n\ -Tab / Shift+Tab Switch Agent\n\ -Ctrl+P Command Palette\n\ -Ctrl+J / Ctrl+K Prev / Next Tool\n\ -Ctrl+O Expand / Collapse Tool\n\ -Ctrl+E Toggle Browse Mode\n\ -↑ / ↓ Input History\n\ -PageUp / PageDown Scroll Messages\n\ -Ctrl+Home / End Jump to Top / Bottom\n\ -Ctrl+U Clear Input\n\ -Esc Back / Interrupt\n\ -Ctrl+W Close All Windows\n\ -Ctrl+C Quit"; - /// Spinner/UI redraw interval while a turn is processing. const SPINNER_REDRAW_INTERVAL_MS: u64 = 100; /// Coalesce rapid resize bursts to reduce flicker during window drag. @@ -149,6 +136,7 @@ struct ChatEventContext<'a> { pub(crate) struct ChatMode { config: CliConfig, + keymap: ResolvedKeymap, /// Current agent type (e.g. "agentic", "plan", "debug") agent_type: String, workspace: Option, @@ -184,8 +172,10 @@ impl ChatMode { workspace.clone().map(PathBuf::from), )); + let keymap = ResolvedKeymap::new(&config.shortcuts); Self { config, + keymap, agent_type, workspace, agent, @@ -478,7 +468,8 @@ impl ChatMode { (false, EffectiveColorScheme::Truecolor) => Theme::dark(), }; let theme = self.resolve_configured_theme(base, appearance, scheme); - let mut chat_view = ChatView::new(theme); + let shortcut_hints = self.keymap.compact_hints(ActionState::chat(false, false)); + let mut chat_view = ChatView::new(theme, shortcut_hints); // Create or restore core session let rt_handle = tokio::runtime::Handle::current(); @@ -594,6 +585,11 @@ impl ChatMode { let resize_redraw_debounce = Duration::from_millis(RESIZE_REDRAW_DEBOUNCE_MS); while !should_quit { + chat_view.set_action_state( + ActionState::chat(chat_state.is_processing, false), + &self.keymap, + ); + // Coalesce rapid resize bursts before invalidating caches and redrawing. if let Some(last_resize_at) = pending_resize_at { if last_resize_at.elapsed() >= resize_redraw_debounce { @@ -1068,6 +1064,12 @@ impl ChatMode { return Ok(None); } + let modal_state = + ActionState::chat(chat_state.is_processing, self.any_popup_visible(chat_view)); + if let Some(action) = self.keymap.resolve_modal_safe(key, modal_state) { + return self.dispatch_action(action, modal_state, chat_view, chat_state, rt_handle); + } + // ── Permission prompt intercepts all keys when active ── if let Some(ref mut prompt) = chat_state.permission_prompt { let action = prompt.handle_key_event(key); @@ -1173,18 +1175,11 @@ impl ChatMode { // ── Normal key handling ── - // Global popup navigation: Ctrl+W closes all popups, Esc navigates back + // Host recovery keys win over configured actions while a popup is open. if self.any_popup_visible(chat_view) { - match (key.code, key.modifiers) { - (KeyCode::Char('w'), KeyModifiers::CONTROL) => { - self.close_all_popups(chat_view); - return Ok(None); - } - (KeyCode::Esc, _) => { - self.navigate_back(chat_view); - return Ok(None); - } - _ => {} + let state = ActionState::chat(chat_state.is_processing, true); + if let Some(action) = self.keymap.resolve_reserved(key, state) { + return self.dispatch_action(action, state, chat_view, chat_state, rt_handle); } } @@ -1201,7 +1196,8 @@ impl ChatMode { PaletteAction::Execute(id) => { return self.handle_palette_action(&id, chat_view, chat_state, rt_handle); } - PaletteAction::Dismiss | PaletteAction::None => {} + PaletteAction::Dismiss => self.navigate_back(chat_view), + PaletteAction::None => {} } return Ok(None); } @@ -1402,103 +1398,20 @@ impl ChatMode { return self.handle_login_form_action(action, chat_view, chat_state, rt_handle); } - match (key.code, key.modifiers) { - // Ctrl+V: read clipboard directly (reliable paste on Windows where - // bracketed paste is broken — crossterm issue #962) - (KeyCode::Char('v'), KeyModifiers::CONTROL) => { - match Clipboard::new().and_then(|mut cb| cb.get_text()) { - Ok(text) if !text.is_empty() => { - let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); - for c in normalized.chars() { - chat_view.handle_char(c); - } - } - _ => {} - } - } - - (KeyCode::Char('c'), KeyModifiers::CONTROL) => { - // If processing, cancel the current turn instead of quitting - if chat_state.is_processing { - tracing::info!("User requested cancellation"); - let agent = self.agent.clone(); - tokio::task::block_in_place(|| { - rt_handle.block_on(async move { - if let Err(e) = agent.cancel_current_turn().await { - tracing::error!("Failed to cancel turn: {}", e); - } - }) - }); - chat_view.set_status(Some("Cancelling...".to_string())); - return Ok(None); - } - tracing::info!("User requested quit"); - return Ok(Some(ChatExitReason::Quit)); - } - - (KeyCode::Char('p'), KeyModifiers::CONTROL) => { - chat_view.show_command_palette(); - return Ok(None); - } - - // Alt+Enter: insert newline in input - (KeyCode::Enter, m) if m.contains(KeyModifiers::ALT) => { - chat_view.handle_newline(); - } - - (KeyCode::Enter, _) => { - if let Some(cmd) = chat_view.apply_command_menu_selection() { - let cmd_result = self.handle_command(&cmd, chat_view, chat_state, rt_handle)?; - return Ok(cmd_result); - } - - if chat_state.is_processing { - let trimmed = chat_view.input_text().trim(); - if trimmed.starts_with('/') { - if let Some(input) = chat_view.send_input() { - let cmd_result = - self.handle_command(&input, chat_view, chat_state, rt_handle)?; - return Ok(cmd_result); - } - } else if !trimmed.is_empty() { - chat_view.set_status(Some( - "Currently processing. Type a /command, or press Ctrl+C to cancel." - .to_string(), - )); - } - return Ok(None); - } - - if let Some(input) = chat_view.send_input() { - tracing::info!("User input: {}", input); - - if input.starts_with('/') { - let cmd_result = - self.handle_command(&input, chat_view, chat_state, rt_handle)?; - return Ok(cmd_result); - } - - // Send message to agent - let display_name = agent_display_name(&self.agent_type); - chat_view.set_status(Some(format!("{} is thinking...", display_name))); - - let agent = self.agent.clone(); - let input_clone = input.clone(); - let agent_type = self.agent_type.clone(); - match tokio::task::block_in_place(|| { - rt_handle.block_on(agent.send_message(input_clone, &agent_type)) - }) { - Ok(turn_id) => { - tracing::info!("Started turn: {}", turn_id); - } - Err(e) => { - tracing::error!("Failed to send message: {}", e); - chat_view.set_status(Some(format!("Error: {}", e))); - } - } - } - } + if let Some(action) = self + .keymap + .resolve(key, ActionState::chat(chat_state.is_processing, false)) + { + return self.dispatch_action( + action, + ActionState::chat(chat_state.is_processing, false), + chat_view, + chat_state, + rt_handle, + ); + } + match (key.code, key.modifiers) { (KeyCode::Backspace, _) => { chat_view.handle_backspace(); } @@ -1510,48 +1423,6 @@ impl ChatMode { chat_view.move_cursor_right(); } - // Ctrl+O: toggle expand/collapse on focused block tool - (KeyCode::Char('o'), KeyModifiers::CONTROL) => { - chat_view.toggle_focused_tool_expand(chat_state); - } - - // Ctrl+J: focus previous block tool (up) - (KeyCode::Char('j'), KeyModifiers::CONTROL) => { - chat_view.cycle_block_tool_focus_prev(chat_state); - } - - // Ctrl+K: focus next block tool (down) - (KeyCode::Char('k'), KeyModifiers::CONTROL) => { - chat_view.cycle_block_tool_focus_next(chat_state); - } - - // ↑↓: input history only. Conversation scrolling stays on PageUp/PageDown or mouse. - (KeyCode::Up, KeyModifiers::NONE) => { - if chat_view.command_menu_visible() { - chat_view.command_menu_up(); - } else { - chat_view.history_prev(); - } - } - (KeyCode::Down, KeyModifiers::NONE) => { - if chat_view.command_menu_visible() { - chat_view.command_menu_down(); - } else { - chat_view.history_next(); - } - } - - (KeyCode::Home, KeyModifiers::CONTROL) => { - let total = chat_view.count_message_lines(chat_state); - chat_view.scroll_to_top(total); - chat_view.set_status(Some("Jumped to conversation top".to_string())); - } - - (KeyCode::End, KeyModifiers::CONTROL) => { - chat_view.scroll_to_bottom(); - chat_view.set_status(Some("Jumped to conversation bottom".to_string())); - } - (KeyCode::Home, _) => { chat_view.set_cursor_home(); } @@ -1560,61 +1431,13 @@ impl ChatMode { chat_view.set_cursor_end(); } - (KeyCode::Char('u'), KeyModifiers::CONTROL) => { - chat_view.clear_input(); - } - - (KeyCode::Char('e'), KeyModifiers::CONTROL) => { - chat_view.toggle_browse_mode(); - let status_msg = if chat_view.browse_mode { - "Entered browse mode, use PageUp/PageDown or mouse wheel to scroll conversation" - } else { - "Exited browse mode" - }; - chat_view.set_status(Some(status_msg.to_string())); - } - - (KeyCode::PageUp, _) => { - let total = chat_view.count_message_lines(chat_state); - chat_view.scroll_up(10, total); - } - - (KeyCode::PageDown, _) => { - chat_view.scroll_down(10); - } - (KeyCode::Esc, _) => { - if chat_state.is_processing { - tracing::info!("User requested cancellation (Esc)"); - let agent = self.agent.clone(); - tokio::task::block_in_place(|| { - rt_handle.block_on(async move { - if let Err(e) = agent.cancel_current_turn().await { - tracing::error!("Failed to cancel turn: {}", e); - } - }) - }); - chat_view.set_status(Some("Cancelling...".to_string())); - return Ok(None); - } if chat_view.browse_mode { chat_view.scroll_to_bottom(); chat_view.set_status(Some("Exited browse mode".to_string())); } } - (KeyCode::Tab, _) => { - if !chat_state.is_processing { - self.cycle_agent(chat_view, chat_state, rt_handle); - } - } - - (KeyCode::BackTab, _) => { - if !chat_state.is_processing { - self.cycle_agent_reverse(chat_view, chat_state, rt_handle); - } - } - (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) if !c.is_control() && c != '\u{0}' => { @@ -1703,7 +1526,8 @@ impl ChatMode { ); } } - PaletteAction::Dismiss | PaletteAction::None => {} + PaletteAction::Dismiss => context.this.navigate_back(context.chat_view), + PaletteAction::None => {} } } else if context.chat_view.provider_selector_captures_mouse(&mouse) { if let Some(selection) = @@ -1780,9 +1604,9 @@ impl ChatMode { _ => {} } } - if let Some(cmd) = context.chat_view.take_pending_command() { - if let Some(reason) = context.this.handle_command( - &cmd, + if let Some(action_id) = context.chat_view.take_pending_command() { + if let Some(reason) = context.this.handle_action_id( + &action_id, context.chat_view, context.chat_state, context.rt_handle, @@ -1848,84 +1672,27 @@ impl ChatMode { if !keep_in_stack { chat_view.hide_command_palette(); } + self.handle_action_id(action_id, chat_view, chat_state, rt_handle) + } - match action_id { - // Session group - "new_session" => { - if chat_state.is_processing { - chat_view.set_status(Some( - "Cannot start a new session while processing. Press Ctrl+C to cancel first." - .to_string(), - )); - return Ok(None); - } - return Ok(Some(ChatExitReason::NewSession)); - } - "sessions" => { - if chat_state.is_processing { - chat_view.set_status(Some( - "Cannot switch sessions while processing. Press Ctrl+C to cancel first." - .to_string(), - )); - return Ok(None); - } - self.show_session_selector(chat_view, chat_state, rt_handle); - } - "usage" => { - self.show_usage_report(chat_view, chat_state, rt_handle); - } - // Prompt group - "skills" => { - self.show_skill_selector(chat_view, chat_state, rt_handle); - } - "subagents" => { - self.show_subagent_selector(chat_view, chat_state, rt_handle); - } - // Models group - "select_model" => { - self.show_model_selector(chat_view, chat_state, rt_handle); - } - "add_model" => { - chat_view.show_provider_selector(); - } - // Agent group - "switch_agent" => { - self.show_agent_selector(chat_view, chat_state, rt_handle); - } - // MCP group - "mcp_servers" => { - self.show_mcp_selector(chat_view, chat_state, rt_handle); - } - // Account group - "login" => { - return self.handle_command("/login", chat_view, chat_state, rt_handle); - } - "logout" => { - return self.handle_command("/logout", chat_view, chat_state, rt_handle); - } - // System group - "help" => { - chat_view.show_info_popup(KEYBOARD_SHORTCUTS_HELP.to_string()); - } - "exit" => { - if chat_state.is_processing { - tracing::info!("User requested cancellation via palette exit"); - let agent = self.agent.clone(); - tokio::task::block_in_place(|| { - rt_handle.block_on(async move { - if let Err(e) = agent.cancel_current_turn().await { - tracing::error!("Failed to cancel turn: {}", e); - } - }) - }); - } - return Ok(Some(ChatExitReason::Quit)); - } - _ => { - chat_view.set_status(Some(format!("Unknown palette action: {}", action_id))); - } - } - Ok(None) + fn handle_action_id( + &mut self, + action_id: &str, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> Result> { + let Some(action) = action_by_id(action_id, ActionContext::Chat) else { + chat_view.set_status(Some(format!("Unknown action: {action_id}"))); + return Ok(None); + }; + self.dispatch_action( + action, + ActionState::chat(chat_state.is_processing, false), + chat_view, + chat_state, + rt_handle, + ) } /// Handle shortcut commands @@ -1941,33 +1708,60 @@ impl ChatMode { return Ok(None); } - match parts[0] { - "/help" => { - chat_view.show_info_popup(KEYBOARD_SHORTCUTS_HELP.to_string()); + let Some(action) = action_for_alias(parts[0], ActionContext::Chat) else { + chat_state.add_system_message(format!( + "Unknown command: {}\nUse /help to see available commands", + parts[0] + )); + return Ok(None); + }; + self.dispatch_action( + action, + ActionState::chat(chat_state.is_processing, false), + chat_view, + chat_state, + rt_handle, + ) + } + + fn dispatch_action( + &mut self, + action: &'static ActionSpec, + state: ActionState, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> Result> { + if !action.available(state) { + chat_view.set_status(Some(action.unavailable_message(state))); + return Ok(None); + } + + match action.handler { + ActionHandler::Help => { + chat_view.show_info_popup(self.keymap.help_text(state)); } - "/clear" => { + ActionHandler::ClearConversation => { if chat_state.is_processing { - tracing::info!("User requested cancellation via /clear"); - let agent = self.agent.clone(); - tokio::task::block_in_place(|| { - rt_handle.block_on(async move { - if let Err(e) = agent.cancel_current_turn().await { - tracing::error!("Failed to cancel turn: {}", e); - } - }) - }); + self.cancel_active_turn(chat_view, rt_handle); } chat_state.clear_messages(); chat_view.clear_screen(); chat_view.set_status(Some("Conversation cleared".to_string())); } - "/agents" => { + ActionHandler::OpenAgentSelector => { self.show_agent_selector(chat_view, chat_state, rt_handle); } - "/models" => { + ActionHandler::SwitchAgent => { + self.cycle_agent(chat_view, chat_state, rt_handle); + } + ActionHandler::SwitchAgentReverse => { + self.cycle_agent_reverse(chat_view, chat_state, rt_handle); + } + ActionHandler::SelectModel => { self.show_model_selector(chat_view, chat_state, rt_handle); } - "/theme" => { + ActionHandler::SelectTheme => { let themes = self.list_available_themes(); chat_view.begin_theme_preview(); chat_view.show_theme_selector(themes, Some(self.config.ui.theme_id.clone())); @@ -1975,131 +1769,193 @@ impl ChatMode { "Theme selector: ↑↓ preview, Enter apply, Esc cancel".to_string(), )); } - "/connect" => { - chat_view.show_provider_selector(); - } - "/new" => { - if chat_state.is_processing { - chat_view.set_status(Some( - "Cannot start a new session while processing. Press Ctrl+C to cancel first." - .to_string(), - )); - return Ok(None); - } + ActionHandler::AddModel => chat_view.show_provider_selector(), + ActionHandler::NewSession => { return Ok(Some(ChatExitReason::NewSession)); } - "/sessions" => { - if chat_state.is_processing { - chat_view.set_status(Some( - "Cannot switch sessions while processing. Press Ctrl+C to cancel first." - .to_string(), - )); - return Ok(None); - } + ActionHandler::Sessions => { self.show_session_selector(chat_view, chat_state, rt_handle); } - "/mcps" => { + ActionHandler::Skills => { + self.show_skill_selector(chat_view, chat_state, rt_handle); + } + ActionHandler::ReloadSkills => { + self.reload_skills_from_disk(chat_view, chat_state, rt_handle); + } + ActionHandler::Subagents => { + self.show_subagent_selector(chat_view, chat_state, rt_handle); + } + ActionHandler::McpServers => { self.show_mcp_selector(chat_view, chat_state, rt_handle); } - "/acp" => { + ActionHandler::AcpHelp => { chat_state.add_system_message(crate::acp_cli::acp_help_text("bitfun-cli")); chat_view.set_status(Some( "ACP setup added to the conversation. You can keep typing.".to_string(), )); } - "/usage" => { - self.show_usage_report(chat_view, chat_state, rt_handle); - } - "/init" => match crate::prompts::get_cli_prompt("init") { + ActionHandler::Init => match crate::prompts::get_cli_prompt("init") { Some(prompt) => { - self.send_message_to_agent( - prompt.to_string(), - chat_view, - chat_state, - rt_handle, - ); - } - None => { - chat_state.add_system_message( - "Init prompt not found. Please create prompts/init.md in the CLI crate." - .to_string(), - ); + self.send_message_to_agent(prompt.to_string(), chat_view, chat_state, rt_handle) } + None => chat_state.add_system_message( + "Init prompt not found. Please create prompts/init.md in the CLI crate." + .to_string(), + ), }, - "/skills" => { - self.show_skill_selector(chat_view, chat_state, rt_handle); - } - "/reload-skills" => { - self.reload_skills_from_disk(chat_view, chat_state, rt_handle); - } - "/subagents" => { - self.show_subagent_selector(chat_view, chat_state, rt_handle); - } - "/history" => { + ActionHandler::History => { chat_state.add_system_message(format!( "Current session statistics:\n\ - • Messages: {}\n\ - • Tool calls: {}\n\ - • Tokens: {}", + • Messages: {}\n\ + • Tool calls: {}\n\ + • Tokens: {}", chat_state.metadata.message_count, chat_state.metadata.tool_calls, chat_state.metadata.total_tokens )); } - "/exit" => { + ActionHandler::Usage => self.show_usage_report(chat_view, chat_state, rt_handle), + ActionHandler::Exit => { if chat_state.is_processing { - tracing::info!("User requested cancellation via /exit"); - let agent = self.agent.clone(); - tokio::task::block_in_place(|| { - rt_handle.block_on(async move { - if let Err(e) = agent.cancel_current_turn().await { - tracing::error!("Failed to cancel turn: {}", e); - } - }) - }); + self.cancel_active_turn(chat_view, rt_handle); } return Ok(Some(ChatExitReason::Quit)); } - "/login" => { - if chat_state.is_processing { - chat_view.set_status(Some( - "Wait until the session is idle before using /login.".to_string(), - )); - return Ok(None); - } + ActionHandler::Login => { self.close_all_popups(chat_view); self.open_login_or_account_panel(chat_view, chat_state, rt_handle); } - "/logout" => { - let logged_in = tokio::task::block_in_place(|| { - rt_handle.block_on(crate::account::is_logged_in()) - }); - if !logged_in { - chat_state.add_system_message("Not logged in.".to_string()); + ActionHandler::Logout => self.logout(chat_state, rt_handle), + ActionHandler::OpenPalette => chat_view.show_command_palette(state), + ActionHandler::SubmitInput => { + return self.submit_input(chat_view, chat_state, rt_handle); + } + ActionHandler::Interrupt => self.cancel_active_turn(chat_view, rt_handle), + ActionHandler::ClosePopups => self.close_all_popups(chat_view), + ActionHandler::NavigateBack => self.navigate_back(chat_view), + ActionHandler::InsertNewline => chat_view.handle_newline(), + ActionHandler::Paste => self.paste_clipboard(chat_view), + ActionHandler::ToggleFocusedTool => { + chat_view.toggle_focused_tool_expand(chat_state); + } + ActionHandler::PreviousTool => { + chat_view.cycle_block_tool_focus_prev(chat_state); + } + ActionHandler::NextTool => { + chat_view.cycle_block_tool_focus_next(chat_state); + } + ActionHandler::HistoryPrevious => { + if chat_view.command_menu_visible() { + chat_view.command_menu_up(); } else { - match tokio::task::block_in_place(|| { - rt_handle.block_on(crate::account::logout()) - }) { - Ok(()) => { - chat_state.add_system_message("Logged out.".to_string()); - } - Err(e) => { - chat_state.add_system_message(format!("Logout failed: {e}")); - } - } + chat_view.history_prev(); } } - _ => { - chat_state.add_system_message(format!( - "Unknown command: {}\nUse /help to see available commands", - parts[0] + ActionHandler::HistoryNext => { + if chat_view.command_menu_visible() { + chat_view.command_menu_down(); + } else { + chat_view.history_next(); + } + } + ActionHandler::JumpTop => { + let total = chat_view.count_message_lines(chat_state); + chat_view.scroll_to_top(total); + chat_view.set_status(Some("Jumped to conversation top".to_string())); + } + ActionHandler::JumpBottom => { + chat_view.scroll_to_bottom(); + chat_view.set_status(Some("Jumped to conversation bottom".to_string())); + } + ActionHandler::ClearInput => chat_view.clear_input(), + ActionHandler::ToggleBrowse => { + chat_view.toggle_browse_mode(); + let status = if chat_view.browse_mode { + "Entered browse mode, use PageUp/PageDown or mouse wheel to scroll conversation" + } else { + "Exited browse mode" + }; + chat_view.set_status(Some(status.to_string())); + } + ActionHandler::ScrollUp => { + let total = chat_view.count_message_lines(chat_state); + chat_view.scroll_up(10, total); + } + ActionHandler::ScrollDown => chat_view.scroll_down(10), + } + Ok(None) + } + + fn submit_input( + &mut self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> Result> { + if let Some(action_id) = chat_view.apply_command_menu_selection() { + return self.handle_action_id(&action_id, chat_view, chat_state, rt_handle); + } + + if chat_state.is_processing { + let trimmed = chat_view.input_text().trim(); + if trimmed.starts_with('/') { + if let Some(input) = chat_view.send_input() { + return self.handle_command(&input, chat_view, chat_state, rt_handle); + } + } else if !trimmed.is_empty() { + chat_view.set_status(Some( + "Currently processing. Type a /command, or use the interrupt shortcut." + .to_string(), )); } + return Ok(None); } + if let Some(input) = chat_view.send_input() { + tracing::info!("User input: {}", input); + if input.starts_with('/') { + return self.handle_command(&input, chat_view, chat_state, rt_handle); + } + self.send_message_to_agent(input, chat_view, chat_state, rt_handle); + } Ok(None) } + fn cancel_active_turn(&self, chat_view: &mut ChatView, rt_handle: &tokio::runtime::Handle) { + tracing::info!("User requested cancellation"); + let agent = self.agent.clone(); + tokio::task::block_in_place(|| { + rt_handle.block_on(async move { + if let Err(error) = agent.cancel_current_turn().await { + tracing::error!("Failed to cancel turn: {}", error); + } + }) + }); + chat_view.set_status(Some("Cancelling...".to_string())); + } + + fn paste_clipboard(&self, chat_view: &mut ChatView) { + if let Ok(text) = Clipboard::new().and_then(|mut clipboard| clipboard.get_text()) { + let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); + for character in normalized.chars() { + chat_view.handle_char(character); + } + } + } + + fn logout(&self, chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle) { + let logged_in = + tokio::task::block_in_place(|| rt_handle.block_on(crate::account::is_logged_in())); + if !logged_in { + chat_state.add_system_message("Not logged in.".to_string()); + return; + } + match tokio::task::block_in_place(|| rt_handle.block_on(crate::account::logout())) { + Ok(()) => chat_state.add_system_message("Logged out.".to_string()), + Err(error) => chat_state.add_system_message(format!("Logout failed: {error}")), + } + } + fn show_usage_report( &self, chat_view: &mut ChatView, @@ -3929,7 +3785,9 @@ mod tests { use tokio::sync::broadcast::error::TryRecvError; use super::{agent_event_stream_failure, mark_active_turn_failed}; + use crate::actions::{ActionState, ResolvedKeymap}; use crate::chat_state::ChatState; + use crate::config::ShortcutsConfig; #[test] fn agent_event_stream_failure_ignores_empty_queue() { @@ -3966,4 +3824,13 @@ mod tests { assert_eq!(state.current_turn_id(), None); assert!(!state.is_processing); } + + #[test] + fn shortcut_registry_contract_help_uses_resolved_keymap() { + let keymap = ResolvedKeymap::new(&ShortcutsConfig::default()); + + let help = keymap.help_text(ActionState::chat(false, false)); + assert!(help.contains("Ctrl+P")); + assert!(help.contains("Command Palette")); + } } diff --git a/src/apps/cli/src/ui/chat/popups.rs b/src/apps/cli/src/ui/chat/popups.rs index 1caf7aa3a3..03d595db75 100644 --- a/src/apps/cli/src/ui/chat/popups.rs +++ b/src/apps/cli/src/ui/chat/popups.rs @@ -16,8 +16,8 @@ impl ChatView { // ============ Command palette methods ============ - pub(crate) fn show_command_palette(&mut self) { - self.command_palette.show(); + pub(crate) fn show_command_palette(&mut self, action_state: crate::actions::ActionState) { + self.command_palette.show(action_state); self.popup_stack.push(PopupType::CommandPalette); } @@ -26,7 +26,7 @@ impl ChatView { } pub(crate) fn reshow_command_palette(&mut self) { - self.command_palette.show(); + self.command_palette.reshow(); } pub(crate) fn command_palette_visible(&self) -> bool { diff --git a/src/apps/cli/src/ui/chat/render.rs b/src/apps/cli/src/ui/chat/render.rs index 3dd108a520..dad28c5197 100644 --- a/src/apps/cli/src/ui/chat/render.rs +++ b/src/apps/cli/src/ui/chat/render.rs @@ -1,15 +1,5 @@ -const CHAT_SHORTCUTS: [(&str, &str); 7] = [ - ("Tab", "Switch Agent"), - ("Alt+\u{21b5}", "Newline"), - ("Ctrl+P", "Commands"), - ("\u{2191}\u{2193}", "History"), - ("Ctrl+E", "Browse"), - ("Esc", "Interrupt"), - ("Ctrl+C", "Quit"), -]; - fn build_shortcut_display( - shortcuts: &[(&'static str, &'static str)], + shortcuts: &[(String, &'static str)], style: Style, ) -> (Vec>, String) { let mut spans = Vec::new(); @@ -21,13 +11,38 @@ fn build_shortcut_display( } let key_text = format!("[{key}]"); spans.push(Span::styled(key_text.clone(), style)); - spans.push(Span::styled(*description, style)); + spans.push(Span::styled((*description).to_string(), style)); text.push_str(&key_text); text.push_str(description); } (spans, text) } +fn build_shortcut_display_for_width( + shortcuts: &[(String, &'static str)], + style: Style, + max_width: usize, +) -> (Vec>, String) { + let (_, full_text) = build_shortcut_display(shortcuts, style); + if UnicodeWidthStr::width(full_text.as_str()) <= max_width || shortcuts.len() <= 1 { + return build_shortcut_display(shortcuts, style); + } + + let last = shortcuts.last().expect("checked non-empty shortcuts"); + let last_width = UnicodeWidthStr::width(format!("[{}]{}", last.0, last.1).as_str()); + let mut used_width = last_width; + let mut visible = Vec::new(); + for hint in &shortcuts[..shortcuts.len() - 1] { + let hint_width = UnicodeWidthStr::width(format!("[{}]{}", hint.0, hint.1).as_str()); + if used_width + 1 + hint_width <= max_width { + visible.push(hint.clone()); + used_width += 1 + hint_width; + } + } + visible.push(last.clone()); + build_shortcut_display(&visible, style) +} + impl ChatView { /// Render interface pub(crate) fn render(&mut self, frame: &mut Frame, chat_state: &ChatState) { @@ -45,8 +60,12 @@ impl ChatView { let input_height = content_lines + 2; // +2 for top/bottom borders // Calculate shortcuts area height based on content - let shortcuts_height = - Self::calculate_shortcuts_height(size.width, chat_state, self.browse_mode); + let shortcuts_height = Self::calculate_shortcuts_height( + size.width, + chat_state, + self.browse_mode, + &self.shortcut_hints, + ); // Status area can grow for long status messages to avoid horizontal truncation. let raw_status_height = Self::calculate_status_height(size.width, chat_state, self.status.as_deref()); @@ -902,16 +921,17 @@ impl ChatView { ]; // Build right side shortcuts with proper styling - let (right_spans, right_text) = build_shortcut_display(&CHAT_SHORTCUTS, muted); + let (full_right_spans, full_right_text) = + build_shortcut_display(&self.shortcut_hints, muted); // Render lines based on available width let available_width = area.width as usize; let left_line = Line::from(left_spans); - let right_line = Line::from(right_spans); + let full_right_line = Line::from(full_right_spans); // Calculate widths using unicode_width let left_width = UnicodeWidthStr::width(left_text.as_str()); - let right_width = UnicodeWidthStr::width(right_text.as_str()); + let right_width = UnicodeWidthStr::width(full_right_text.as_str()); let mut lines = Vec::new(); @@ -921,12 +941,14 @@ impl ChatView { let mut combined_spans = Vec::new(); combined_spans.extend(left_line.spans); combined_spans.push(Span::raw(" ".repeat(gap))); - combined_spans.extend(right_line.spans); + combined_spans.extend(full_right_line.spans); lines.push(Line::from(combined_spans)); } else { // Need multiple lines: render left and right separately + let (right_spans, _) = + build_shortcut_display_for_width(&self.shortcut_hints, muted, available_width); lines.push(left_line); - lines.push(right_line); + lines.push(Line::from(right_spans)); } let paragraph = Paragraph::new(lines); @@ -938,14 +960,15 @@ impl ChatView { available_width: u16, chat_state: &ChatState, browse_mode: bool, + shortcut_hints: &[(String, &'static str)], ) -> u16 { let mode_text = if browse_mode { " Browse " } else { " Chat " }; let left_text = format!("{} | Model: {}", mode_text, chat_state.current_model_name); - let right_text = "[Tab]Switch Agent [Alt+\u{21b5}]Newline [Ctrl+P]Commands [\u{2191}\u{2193}]History [Ctrl+E]Browse [Esc]Interrupt [Ctrl+C]Quit"; + let (_, right_text) = build_shortcut_display(shortcut_hints, Style::default()); let left_width = UnicodeWidthStr::width(left_text.as_str()); - let right_width = UnicodeWidthStr::width(right_text); + let right_width = UnicodeWidthStr::width(right_text.as_str()); // If both fit on one line (with at least 2 spaces gap), height is 1 if left_width + right_width + 2 <= available_width as usize { @@ -996,17 +1019,21 @@ impl ChatView { #[cfg(test)] mod shortcut_contract_tests { use super::*; + use crate::actions::{ActionState, ResolvedKeymap}; + use crate::config::ShortcutsConfig; use ratatui::style::Color; #[test] fn chat_shortcuts_keep_visible_order_and_muted_style() { let muted = Style::default().fg(Color::DarkGray); - let (spans, text) = build_shortcut_display(&CHAT_SHORTCUTS, muted); + let keymap = ResolvedKeymap::new(&ShortcutsConfig::default()); + let shortcuts = keymap.compact_hints(ActionState::chat(false, false)); + let (spans, text) = build_shortcut_display(&shortcuts, muted); assert_eq!( text, - "[Tab]Switch Agent [Alt+↵]Newline [Ctrl+P]Commands [↑↓]History [Ctrl+E]Browse [Esc]Interrupt [Ctrl+C]Quit" + "[Tab]Switch Agent [Alt+↵]Newline [Ctrl+P]Commands [↑↓]History [Ctrl+E]Browse [Ctrl+C]Quit" ); assert_eq!( spans @@ -1029,13 +1056,48 @@ mod shortcut_contract_tests { "[Ctrl+E]", "Browse", " ", - "[Esc]", - "Interrupt", - " ", "[Ctrl+C]", "Quit", ] ); assert!(spans.iter().all(|span| span.style == muted)); } + + #[test] + fn shortcut_registry_contract_footer_uses_resolved_keymap() { + let shortcuts = ResolvedKeymap::new(&ShortcutsConfig::default()) + .compact_hints(ActionState::chat(false, false)); + let (_, text) = build_shortcut_display(&shortcuts, Style::default()); + assert!(text.contains("[Ctrl+P]Commands")); + } + + #[test] + fn processing_footer_shows_interrupt_without_quit() { + let shortcuts = ResolvedKeymap::new(&ShortcutsConfig::default()) + .compact_hints(ActionState::chat(true, false)); + let (_, text) = build_shortcut_display(&shortcuts, Style::default()); + + assert!(text.contains("[Esc]Interrupt")); + assert!(!text.contains("Quit")); + assert!(!text.contains("Switch Agent")); + } + + #[test] + fn narrow_footer_keeps_the_recovery_hint_visible() { + let idle = ResolvedKeymap::new(&ShortcutsConfig::default()) + .compact_hints(ActionState::chat(false, false)); + let (_, idle_text) = build_shortcut_display_for_width(&idle, Style::default(), 80); + assert!(idle_text.contains("[Ctrl+C]Quit"), "{idle_text}"); + assert!(UnicodeWidthStr::width(idle_text.as_str()) <= 80); + + let processing = ResolvedKeymap::new(&ShortcutsConfig::default()) + .compact_hints(ActionState::chat(true, false)); + let (_, processing_text) = + build_shortcut_display_for_width(&processing, Style::default(), 80); + assert!( + processing_text.contains("[Esc]Interrupt"), + "{processing_text}" + ); + assert!(UnicodeWidthStr::width(processing_text.as_str()) <= 80); + } } diff --git a/src/apps/cli/src/ui/chat/state.rs b/src/apps/cli/src/ui/chat/state.rs index 462d6069d4..0973be34df 100644 --- a/src/apps/cli/src/ui/chat/state.rs +++ b/src/apps/cli/src/ui/chat/state.rs @@ -27,6 +27,7 @@ use super::text_input::TextInput; use super::theme::{StyleKind, Theme}; use super::theme_selector::{ThemeItem, ThemeSelectorState}; use super::widgets::Spinner; +use crate::actions::{ActionState, ResolvedKeymap}; use crate::chat_state::{ChatMessage, ChatState, FlowItem, MessageRole}; /// Types of popups that can be shown in the ChatView @@ -118,6 +119,8 @@ pub(crate) struct ChatView { command_menu: CommandMenuState, /// Command palette state (Ctrl+P) command_palette: CommandPaletteState, + /// Footer hints derived from the resolved CLI action bindings. + shortcut_hints: Vec<(String, &'static str)>, /// List scroll state list_state: ListState, /// Whether to auto-scroll to bottom @@ -233,15 +236,16 @@ pub(crate) struct ChatView { impl ChatView { /// Create new Chat view - pub(crate) fn new(theme: Theme) -> Self { + pub(crate) fn new(theme: Theme, shortcut_hints: Vec<(String, &'static str)>) -> Self { let markdown_renderer = MarkdownRenderer::new(theme.clone()); Self { spinner: Spinner::new(theme.style(StyleKind::Primary)), markdown_renderer, theme, text_input: TextInput::new(), - command_menu: CommandMenuState::new(), + command_menu: CommandMenuState::new(ActionState::chat(false, false)), command_palette: CommandPaletteState::new(), + shortcut_hints, list_state: ListState::default(), auto_scroll: true, status: None, @@ -289,4 +293,13 @@ impl ChatView { render_cache: HashMap::new(), } } + + pub(crate) fn set_action_state(&mut self, state: ActionState, keymap: &ResolvedKeymap) { + self.shortcut_hints = keymap.compact_hints(state); + self.command_palette.set_action_state(state); + if self.command_menu.set_action_state(state) { + self.command_menu + .update(&self.text_input.input, self.text_input.cursor); + } + } } diff --git a/src/apps/cli/src/ui/command_menu.rs b/src/apps/cli/src/ui/command_menu.rs index 9758800c06..d4883de17c 100644 --- a/src/apps/cli/src/ui/command_menu.rs +++ b/src/apps/cli/src/ui/command_menu.rs @@ -8,11 +8,12 @@ use ratatui::{ Frame, }; -use crate::commands::{match_substring_in, CommandSpec, COMMAND_SPECS}; +use crate::actions::{slash_actions, ActionProjection, ActionState}; use crate::ui::theme::{StyleKind, Theme}; pub(super) struct CommandMenuState { - items: Vec<&'static CommandSpec>, + action_state: ActionState, + items: Vec, list_state: ListState, visible: bool, suppressed: bool, @@ -21,8 +22,9 @@ pub(super) struct CommandMenuState { } impl CommandMenuState { - pub(super) fn new() -> Self { + pub(super) fn new(action_state: ActionState) -> Self { Self { + action_state, items: Vec::new(), list_state: ListState::default(), visible: false, @@ -33,15 +35,6 @@ impl CommandMenuState { } pub(super) fn update(&mut self, input: &str, cursor: usize) { - self.update_with_commands(input, cursor, COMMAND_SPECS); - } - - pub(super) fn update_with_commands( - &mut self, - input: &str, - cursor: usize, - commands: &'static [CommandSpec], - ) { if self.suppressed && input == self.last_input { return; } @@ -51,6 +44,7 @@ impl CommandMenuState { } self.last_input = input.to_string(); + let selected_id = self.selected_item().map(|item| item.id.to_string()); if !input.starts_with('/') || !self.cursor_in_command(input, cursor) { self.hide(); @@ -58,18 +52,36 @@ impl CommandMenuState { } let query = input.split_whitespace().next().unwrap_or(""); + let mut commands = slash_actions(self.action_state); if query == "/" { - self.items = commands.iter().collect(); + self.items = commands; } else { - self.items = match_substring_in(query, commands); + let normalized = query + .strip_prefix('/') + .unwrap_or(query) + .to_ascii_lowercase(); + commands.retain(|spec| { + spec.name + .strip_prefix('/') + .unwrap_or(spec.name) + .to_ascii_lowercase() + .contains(&normalized) + }); + self.items = commands; } self.items.sort_by_key(|spec| spec.name); self.visible = !self.items.is_empty(); if self.visible { - let selected = self.list_state.selected().unwrap_or(0); - let clamped = selected.min(self.items.len().saturating_sub(1)); - self.list_state.select(Some(clamped)); + let selected = selected_id + .and_then(|id| self.items.iter().position(|item| item.id == id)) + .unwrap_or_else(|| { + self.list_state + .selected() + .unwrap_or(0) + .min(self.items.len().saturating_sub(1)) + }); + self.list_state.select(Some(selected)); } else { self.list_state.select(None); } @@ -105,7 +117,7 @@ impl CommandMenuState { } let selected = self.selected_item()?; - let command = selected.name.to_string(); + let command = selected.id.to_string(); self.suppress(); Some(command) } @@ -219,9 +231,9 @@ impl CommandMenuState { && mouse.row < area.y.saturating_add(area.height) } - fn selected_item(&self) -> Option<&CommandSpec> { + fn selected_item(&self) -> Option<&ActionProjection> { let idx = self.list_state.selected().unwrap_or(0); - self.items.get(idx).copied() + self.items.get(idx) } fn suppress(&mut self) { @@ -273,4 +285,96 @@ impl CommandMenuState { None => true, } } + + pub(super) fn set_action_state(&mut self, action_state: ActionState) -> bool { + if self.action_state == action_state { + return false; + } + self.action_state = action_state; + true + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::KeyModifiers; + + use super::*; + + fn names(menu: &CommandMenuState) -> Vec<&str> { + menu.items.iter().map(|item| item.name).collect() + } + + #[test] + fn chat_menu_keeps_substring_matching() { + let mut menu = CommandMenuState::new(ActionState::chat(false, false)); + menu.update("/he", 3); + + assert_eq!(names(&menu), ["/help", "/theme"]); + } + + #[test] + fn slash_lists_all_actions_for_the_current_context() { + let mut chat = CommandMenuState::new(ActionState::chat(false, false)); + chat.update("/", 1); + assert!(names(&chat).contains(&"/clear")); + assert!(names(&chat).contains(&"/new")); + + let mut startup = CommandMenuState::new(ActionState::startup(false)); + startup.update("/", 1); + assert!(!names(&startup).contains(&"/clear")); + assert!(!names(&startup).contains(&"/new")); + assert!(names(&startup).contains(&"/sessions")); + } + + #[test] + fn processing_chat_hides_idle_only_actions() { + let mut menu = CommandMenuState::new(ActionState::chat(true, false)); + menu.update("/", 1); + + assert!(!names(&menu).contains(&"/agents")); + assert!(!names(&menu).contains(&"/new")); + assert!(names(&menu).contains(&"/help")); + } + + #[test] + fn selection_returns_the_stable_action_id() { + let mut menu = CommandMenuState::new(ActionState::chat(false, false)); + menu.update("/help", 5); + + assert_eq!(menu.apply_selection().as_deref(), Some("help")); + } + + #[test] + fn mouse_selection_returns_the_stable_action_id() { + let mut menu = CommandMenuState::new(ActionState::startup(false)); + menu.update("/help", 5); + menu.last_area = Some(Rect::new(5, 5, 30, 3)); + + let selected = menu.handle_mouse_event(&MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 6, + row: 6, + modifiers: KeyModifiers::NONE, + }); + + assert_eq!(selected.as_deref(), Some("help")); + } + + #[test] + fn state_refresh_preserves_the_selected_action_id() { + let mut menu = CommandMenuState::new(ActionState::chat(true, false)); + menu.update("/", 1); + let logout_index = menu + .items + .iter() + .position(|item| item.id == "logout") + .unwrap(); + menu.list_state.select(Some(logout_index)); + + assert!(menu.set_action_state(ActionState::chat(false, false))); + menu.update("/", 1); + + assert_eq!(menu.selected_item().map(|item| item.id), Some("logout")); + } } diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index c29b7839a0..e029047e68 100644 --- a/src/apps/cli/src/ui/command_palette.rs +++ b/src/apps/cli/src/ui/command_palette.rs @@ -12,6 +12,7 @@ use ratatui::{ Frame, }; +use crate::actions::{palette_actions, ActionState}; use crate::ui::theme::{StyleKind, Theme}; // ── Data types ── @@ -26,6 +27,7 @@ struct PaletteItem { } /// Action returned after handling a key event +#[derive(Debug, PartialEq, Eq)] pub(crate) enum PaletteAction { /// User confirmed selection — carries the item id Execute(String), @@ -37,138 +39,67 @@ pub(crate) enum PaletteAction { // ── Default palette items ── +const DEFAULT_ITEM_ORDER: &[&str] = &[ + "new_session", + "sessions", + "usage", + "skills", + "subagents", + "select_model", + "add_model", + "theme", + "switch_agent", + "mcp_servers", + "login", + "logout", + "help", + "exit", +]; + +const SUGGESTED_ITEM_ORDER: &[&str] = &[ + "select_model", + "switch_agent", + "theme", + "new_session", + "usage", +]; + +fn item_order(id: &str, order: &[&str]) -> usize { + order + .iter() + .position(|candidate| *candidate == id) + .unwrap_or(usize::MAX) +} + /// Build the default set of palette items (all groups) -fn default_palette_items() -> Vec { - vec![ - // Session group - PaletteItem { - id: "new_session".into(), - label: "New session".into(), - description: "Start a new conversation".into(), - group: "Session".into(), - }, - PaletteItem { - id: "sessions".into(), - label: "Sessions".into(), - description: "Browse and switch sessions".into(), - group: "Session".into(), - }, - PaletteItem { - id: "usage".into(), - label: "Usage report".into(), - description: "Generate a usage report for the current session".into(), - group: "Session".into(), - }, - // Prompt group - PaletteItem { - id: "skills".into(), - label: "Skills".into(), - description: "Browse and select available skills".into(), - group: "Prompt".into(), - }, - PaletteItem { - id: "subagents".into(), - label: "Subagents".into(), - description: "List and configure subagents".into(), - group: "Prompt".into(), - }, - // Models group - PaletteItem { - id: "select_model".into(), - label: "Select model".into(), - description: "Select AI model for all modes".into(), - group: "Models".into(), - }, - PaletteItem { - id: "add_model".into(), - label: "Add model".into(), - description: "Add a new AI model configuration".into(), - group: "Models".into(), - }, - // Appearance group - PaletteItem { - id: "theme".into(), - label: "Theme".into(), - description: "Switch UI theme".into(), - group: "Appearance".into(), - }, - // Agent group - PaletteItem { - id: "switch_agent".into(), - label: "Switch agent".into(), - description: "Switch agent mode".into(), - group: "Agent".into(), - }, - // MCP group - PaletteItem { - id: "mcp_servers".into(), - label: "MCP servers".into(), - description: "Manage MCP servers".into(), - group: "MCP".into(), - }, - // Account group - PaletteItem { - id: "login".into(), - label: "Login".into(), - description: "Log in to BitFun account".into(), - group: "Account".into(), - }, - PaletteItem { - id: "logout".into(), - label: "Logout".into(), - description: "Log out of BitFun account".into(), - group: "Account".into(), - }, - // System group - PaletteItem { - id: "help".into(), - label: "Help".into(), - description: "Show help information".into(), - group: "System".into(), - }, - PaletteItem { - id: "exit".into(), - label: "Exit the app".into(), - description: "Quit the application".into(), - group: "System".into(), - }, - ] +fn default_palette_items(action_state: ActionState) -> Vec { + let mut actions = palette_actions(action_state); + actions.sort_by_key(|action| item_order(action.id, DEFAULT_ITEM_ORDER)); + actions + .into_iter() + .map(|action| PaletteItem { + id: action.id.to_string(), + label: action.name.to_string(), + description: action.description.to_string(), + group: action.palette_group.unwrap_or("Other").to_string(), + }) + .collect() } /// Build suggested items -fn build_suggested_items() -> Vec { - vec![ - PaletteItem { - id: "select_model".into(), - label: "Select model".into(), - description: "Select AI model for all modes".into(), - group: "Suggested".into(), - }, - PaletteItem { - id: "switch_agent".into(), - label: "Switch agent".into(), - description: "Switch agent mode".into(), - group: "Suggested".into(), - }, - PaletteItem { - id: "theme".into(), - label: "Theme".into(), - description: "Switch UI theme".into(), - group: "Suggested".into(), - }, - PaletteItem { - id: "new_session".into(), - label: "New session".into(), - description: "Start a new conversation".into(), - group: "Suggested".into(), - }, - PaletteItem { - id: "usage".into(), - label: "Usage report".into(), - description: "Generate a usage report for the current session".into(), - group: "Suggested".into(), - }, - ] +fn build_suggested_items(action_state: ActionState) -> Vec { + let mut actions = palette_actions(action_state); + actions.sort_by_key(|action| item_order(action.id, SUGGESTED_ITEM_ORDER)); + actions + .into_iter() + .filter(|action| action.suggested) + .map(|action| PaletteItem { + id: action.id.to_string(), + label: action.name.to_string(), + description: action.description.to_string(), + group: "Suggested".to_string(), + }) + .collect() } // ── Flattened row for rendering ── @@ -185,6 +116,7 @@ enum PaletteRow { pub(super) struct CommandPaletteState { visible: bool, + action_state: Option, search_input: String, search_cursor: usize, @@ -210,6 +142,7 @@ impl CommandPaletteState { pub(super) fn new() -> Self { Self { visible: false, + action_state: None, search_input: String::new(), search_cursor: 0, all_items: Vec::new(), @@ -223,9 +156,10 @@ impl CommandPaletteState { } /// Show the command palette with the default items - pub(super) fn show(&mut self) { - let mut items = build_suggested_items(); - items.extend(default_palette_items()); + pub(super) fn show(&mut self, action_state: ActionState) { + self.action_state = Some(action_state); + let mut items = build_suggested_items(action_state); + items.extend(default_palette_items(action_state)); self.all_items = items; self.search_input.clear(); self.search_cursor = 0; @@ -235,6 +169,30 @@ impl CommandPaletteState { self.rebuild_filtered(); } + pub(super) fn set_action_state(&mut self, action_state: ActionState) { + if self.action_state == Some(action_state) { + return; + } + self.action_state = Some(action_state); + + let selected_id = self.confirm_selection(); + let mut items = build_suggested_items(action_state); + items.extend(default_palette_items(action_state)); + self.all_items = items; + self.rebuild_filtered(); + + if let Some(selected_id) = selected_id { + if let Some(index) = self + .selectable_items + .iter() + .position(|item_index| self.all_items[*item_index].id == selected_id) + { + self.selected_index = index; + self.ensure_selected_visible(); + } + } + } + /// Hide the command palette pub(super) fn hide(&mut self) { self.visible = false; @@ -388,10 +346,7 @@ impl CommandPaletteState { } match key.code { - KeyCode::Esc => { - self.hide(); - PaletteAction::Dismiss - } + KeyCode::Esc => PaletteAction::Dismiss, KeyCode::Enter => { if let Some(id) = self.confirm_selection() { // Don't hide here to support back navigation @@ -521,17 +476,13 @@ impl CommandPaletteState { if let Some(sel_idx) = self.selectable_index_at_row(mouse.row, &area) { self.selected_index = sel_idx; if let Some(id) = self.confirm_selection() { - self.hide(); return PaletteAction::Execute(id); } } PaletteAction::None } // Click outside popup — dismiss - MouseEventKind::Down(MouseButton::Left) if !in_popup => { - self.hide(); - PaletteAction::Dismiss - } + MouseEventKind::Down(MouseButton::Left) if !in_popup => PaletteAction::Dismiss, _ => PaletteAction::None, } } @@ -779,3 +730,98 @@ impl CommandPaletteState { .unwrap_or(self.search_input.len()) } } + +#[cfg(test)] +mod tests { + use crossterm::event::KeyModifiers; + + use super::*; + + #[test] + fn registry_projection_preserves_palette_order() { + let idle = ActionState::chat(false, false); + let ids = default_palette_items(idle) + .into_iter() + .map(|item| item.id) + .collect::>(); + assert_eq!(ids, DEFAULT_ITEM_ORDER); + + let suggested = build_suggested_items(idle) + .into_iter() + .map(|item| item.id) + .collect::>(); + assert_eq!(suggested, SUGGESTED_ITEM_ORDER); + } + + #[test] + fn processing_palette_omits_idle_only_actions() { + let ids = default_palette_items(ActionState::chat(true, false)) + .into_iter() + .map(|item| item.id) + .collect::>(); + + assert!(!ids.iter().any(|id| id == "switch_agent")); + assert!(!ids.iter().any(|id| id == "new_session")); + assert!(ids.iter().any(|id| id == "help")); + } + + #[test] + fn visible_palette_refreshes_for_turn_state_without_losing_search() { + let mut palette = CommandPaletteState::new(); + palette.show(ActionState::chat(false, false)); + palette.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + assert!(palette + .all_items + .iter() + .any(|item| item.id == "new_session")); + + palette.set_action_state(ActionState::chat(true, false)); + assert_eq!(palette.search_input, "n"); + assert!(!palette + .all_items + .iter() + .any(|item| item.id == "new_session")); + + palette.set_action_state(ActionState::chat(false, false)); + assert_eq!(palette.search_input, "n"); + assert!(palette + .all_items + .iter() + .any(|item| item.id == "new_session")); + } + + #[test] + fn hidden_palette_refreshes_before_back_navigation() { + let mut palette = CommandPaletteState::new(); + palette.show(ActionState::chat(true, false)); + assert!(!palette + .all_items + .iter() + .any(|item| item.id == "new_session")); + + palette.hide(); + palette.set_action_state(ActionState::chat(false, false)); + palette.reshow(); + + assert!(palette + .all_items + .iter() + .any(|item| item.id == "new_session")); + } + + #[test] + fn mouse_actions_leave_navigation_to_the_owner() { + let mut palette = CommandPaletteState::new(); + palette.show(ActionState::startup(false)); + palette.last_area = Some(Rect::new(10, 10, 40, 20)); + + let dismiss = palette.handle_mouse_event(&MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }); + assert_eq!(dismiss, PaletteAction::Dismiss); + assert!(palette.is_visible()); + } +} diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 5d2e451d5e..f69efd3afb 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -14,7 +14,10 @@ use super::theme::{ Appearance, EffectiveColorScheme, Theme, }; use super::theme_selector::{ThemeItem, ThemeSelectorState}; -use crate::commands::STARTUP_COMMAND_SPECS; +use crate::actions::{ + action_by_id, action_for_alias, ActionContext, ActionHandler, ActionSpec, ActionState, + ResolvedKeymap, +}; use crate::config::CliConfig; /// Startup page module /// @@ -106,16 +109,6 @@ pub(crate) enum StartupResult { Exit, } -/// Keyboard shortcuts help text for startup page -const KEYBOARD_SHORTCUTS_HELP: &str = "\ -Keyboard Shortcuts\n\ -─────────────────────────────────\n\ -Tab / Shift+Tab Switch Agent\n\ -Ctrl+P Command Palette\n\ -Esc Back / Interrupt\n\ -Ctrl+W Close All Windows\n\ -Ctrl+C Exit"; - /// Random tips shown on the startup page const TIPS: &[&str] = &[ "Type / for slash commands (e.g. /help, /login, /models)", @@ -173,6 +166,8 @@ pub(crate) struct StartupPage { theme: Theme, /// CLI config, including persisted theme preference. config: CliConfig, + /// Resolved host-owned action bindings for the current config. + keymap: ResolvedKeymap, /// Current tip text tip: &'static str, @@ -216,12 +211,12 @@ pub(crate) struct StartupPage { impl StartupPage { pub(crate) fn new( + config: CliConfig, agent_runtime: AgentRuntime, compatibility: CoreAgentRuntimeCompatibility, default_agent: String, workspace: Option, ) -> Self { - let config = CliConfig::load().unwrap_or_default(); let appearance = resolve_appearance(&config.ui.theme); let scheme = resolve_effective_color_scheme(&config.ui.color_scheme); let base_is_light = appearance.is_light(); @@ -253,12 +248,14 @@ impl StartupPage { .as_millis() as usize % TIPS.len(); + let keymap = ResolvedKeymap::new(&config.shortcuts); let mut page = Self { text_input: TextInput::new(), theme, config, + keymap, tip: TIPS[tip_index], - command_menu: CommandMenuState::new(), + command_menu: CommandMenuState::new(ActionState::startup(false)), command_palette: CommandPaletteState::new(), model_selector: ModelSelectorState::new(), agent_selector: AgentSelectorState::new(), @@ -393,7 +390,9 @@ impl StartupPage { } } for ev in non_key_events { - self.handle_non_key_event(ev, terminal)?; + if let Some(result) = self.handle_non_key_event(ev, terminal)? { + return Ok(result); + } } } else { for ev in events { @@ -407,7 +406,11 @@ impl StartupPage { } } other => { - self.handle_non_key_event(other, terminal)?; + if let Some(result) = + self.handle_non_key_event(other, terminal)? + { + return Ok(result); + } } } } @@ -421,13 +424,17 @@ impl StartupPage { &mut self, ev: Event, terminal: &mut Terminal, - ) -> Result<()> { + ) -> Result> { match ev { Event::Mouse(mouse) => { if self.command_palette.captures_mouse(&mouse) { let action = self.command_palette.handle_mouse_event(&mouse); - if let PaletteAction::Execute(id) = action { - let _ = self.handle_palette_action(&id); + match action { + PaletteAction::Execute(id) => { + return Ok(self.handle_palette_action(&id)); + } + PaletteAction::Dismiss => self.navigate_back(), + PaletteAction::None => {} } } else if self.theme_selector.captures_mouse(&mouse) { self.theme_selector.handle_mouse_event(&mouse); @@ -438,6 +445,12 @@ impl StartupPage { if let Some(selection) = self.provider_selector.handle_mouse_event(&mouse) { self.handle_provider_selection(selection); } + } else if self.command_menu.captures_mouse(&mouse) { + if let Some(action_id) = self.command_menu.handle_mouse_event(&mouse) { + self.text_input.clear(); + self.refresh_command_menu(); + return Ok(self.handle_palette_action(&action_id)); + } } } Event::Paste(text) => { @@ -454,7 +467,7 @@ impl StartupPage { } _ => {} } - Ok(()) + Ok(None) } // ======================== Rendering ======================== @@ -753,17 +766,23 @@ impl StartupPage { // Clear transient status on any key press self.status = None; + let modal_state = + ActionState::startup(self.info_popup.is_some() || self.any_popup_visible()); + if let Some(action) = self.keymap.resolve_modal_safe(key, modal_state) { + return self.dispatch_action(action, modal_state); + } + // ── Info popup intercepts all keys ── if self.info_popup.is_some() { self.info_popup = None; return None; } - // ── Global popup navigation: Ctrl+W closes all popups ── + // Host recovery keys win over configured actions while a popup is open. if self.any_popup_visible() { - if let (KeyCode::Char('w'), KeyModifiers::CONTROL) = (key.code, key.modifiers) { - self.close_all_popups(); - return None; + let state = ActionState::startup(true); + if let Some(action) = self.keymap.resolve_reserved(key, state) { + return self.dispatch_action(action, state); } } @@ -940,18 +959,6 @@ impl StartupPage { self.command_menu.move_down(); return None; } - KeyCode::Enter => { - if let Some(cmd) = self.command_menu.apply_selection() { - return self.handle_command(&cmd); - } - return None; - } - KeyCode::Esc => { - self.text_input.clear(); - self.command_menu - .update_with_commands("", 0, STARTUP_COMMAND_SPECS); - return None; - } _ => { // Fall through to normal input handling, which updates the menu } @@ -960,58 +967,17 @@ impl StartupPage { // ── Normal key handling ── - match (key.code, key.modifiers) { - (KeyCode::Char('c'), KeyModifiers::CONTROL) => { - return Some(StartupResult::Exit); - } - (KeyCode::Char('p'), KeyModifiers::CONTROL) => { - self.push_current_popup_to_stack(); - self.command_palette.show(); - return None; - } - (KeyCode::Char('v'), KeyModifiers::CONTROL) => { - if let Ok(mut clipboard) = arboard::Clipboard::new() { - if let Ok(text) = clipboard.get_text() { - self.text_input.insert_paste(&text); - self.refresh_command_menu(); - } - } - } - (KeyCode::Enter, m) if m.contains(KeyModifiers::ALT) => { - self.text_input.handle_newline(); - self.refresh_command_menu(); - } - (KeyCode::Enter, _) => { - if let Some(cmd) = self.command_menu.apply_selection() { - return self.handle_command(&cmd); - } + if let Some(action) = self.keymap.resolve(key, ActionState::startup(false)) { + return self.dispatch_action(action, ActionState::startup(false)); + } - if self.text_input.is_empty() { - return Some(StartupResult::NewSession { prompt: None }); - } - let trimmed = self.text_input.text().trim().to_string(); - if trimmed == "/exit" || trimmed == "exit" || trimmed == "quit" { - return Some(StartupResult::Exit); - } - if trimmed.starts_with('/') { - return self.handle_command(&trimmed); - } - return Some(StartupResult::NewSession { - prompt: Some(trimmed), - }); - } + match (key.code, key.modifiers) { (KeyCode::Esc, _) => { if !self.text_input.is_empty() { self.text_input.clear(); self.refresh_command_menu(); } } - (KeyCode::Tab, _) => { - self.cycle_agent(1); - } - (KeyCode::BackTab, _) => { - self.cycle_agent(-1); - } (KeyCode::Up, KeyModifiers::NONE) => { if !self.text_input.move_cursor_up() { self.text_input.set_cursor_home(); @@ -1056,157 +1022,162 @@ impl StartupPage { // ======================== Palette action execution ======================== fn handle_palette_action(&mut self, action_id: &str) -> Option { - match action_id { - // Session group - "new_session" => { - return Some(StartupResult::NewSession { prompt: None }); - } - "sessions" => { - self.show_session_selector(); - } - "usage" => { - self.status = Some("No active session for /usage.".to_string()); - } - // Prompt group - "skills" => { - self.show_skill_selector(); - } - "subagents" => { - self.show_subagent_selector(); - } - // Models group - "select_model" => { - self.show_model_selector(); - } - "add_model" => { - self.push_current_popup_to_stack(); - self.provider_selector.show(); - } - // Appearance group - "theme" => { - self.show_theme_selector(); - } - // Agent group - "switch_agent" => { - self.show_agent_selector(); - } - // MCP group - "mcp_servers" => { - return Some(StartupResult::NewSession { - prompt: Some("/mcps".to_string()), - }); - } - // Account group - "login" => { - self.show_login_form(); - } - "logout" => { - return self.handle_command("/logout"); - } - // System group - "help" => { - self.info_popup = Some(KEYBOARD_SHORTCUTS_HELP.to_string()); - } - "exit" => { - return Some(StartupResult::Exit); - } - _ => { - self.status = Some(format!("Unknown palette action: {}", action_id)); - } - } - None + let Some(action) = action_by_id(action_id, ActionContext::Startup) else { + self.status = Some(format!("Unknown palette action: {action_id}")); + return None; + }; + self.dispatch_action(action, ActionState::startup(false)) } - // ======================== Command execution ======================== - - fn handle_command(&mut self, command: &str) -> Option { - let cmd = command.split_whitespace().next().unwrap_or(""); - - self.text_input.clear(); - self.refresh_command_menu(); + fn dispatch_action( + &mut self, + action: &'static ActionSpec, + state: ActionState, + ) -> Option { + if !action.available(state) { + self.status = Some(action.unavailable_message(state)); + return None; + } - match cmd { - "/help" => { - self.info_popup = Some(KEYBOARD_SHORTCUTS_HELP.to_string()); - } - "/exit" => { - return Some(StartupResult::Exit); - } - "/sessions" => { - self.show_session_selector(); + match action.handler { + ActionHandler::Help => { + self.info_popup = Some(self.keymap.help_text(ActionState::startup(false))); } - "/models" => { - self.show_model_selector(); - } - "/theme" => { - self.show_theme_selector(); + ActionHandler::Exit => return Some(StartupResult::Exit), + ActionHandler::NewSession => { + return Some(StartupResult::NewSession { prompt: None }); } - "/connect" => { + ActionHandler::Sessions => self.show_session_selector(), + ActionHandler::SelectModel => self.show_model_selector(), + ActionHandler::SelectTheme => self.show_theme_selector(), + ActionHandler::AddModel => { self.push_current_popup_to_stack(); self.provider_selector.show(); } - "/agents" => { - self.show_agent_selector(); - } - "/skills" => { - self.show_skill_selector(); - } - "/subagents" => { - self.show_subagent_selector(); - } - "/mcps" => { - // Enter chat mode and auto-trigger /mcps command + ActionHandler::OpenAgentSelector => self.show_agent_selector(), + ActionHandler::SwitchAgent => self.cycle_agent(1), + ActionHandler::SwitchAgentReverse => self.cycle_agent(-1), + ActionHandler::Skills => self.show_skill_selector(), + ActionHandler::Subagents => self.show_subagent_selector(), + ActionHandler::McpServers => { return Some(StartupResult::NewSession { prompt: Some("/mcps".to_string()), }); } - "/acp" => { + ActionHandler::AcpHelp => { return Some(StartupResult::NewSession { prompt: Some("/acp".to_string()), }); } - "/login" => { - self.show_login_form(); - } - "/logout" => { - let logged_in = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(crate::account::is_logged_in()) - }); - if !logged_in { - self.status = Some("Not logged in.".to_string()); - } else { - match tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(crate::account::logout()) - }) { - Ok(()) => self.status = Some("Logged out.".to_string()), - Err(e) => self.status = Some(format!("Logout failed: {e}")), - } - } - } - "/usage" => { + ActionHandler::Login => self.show_login_form(), + ActionHandler::Logout => self.logout(), + ActionHandler::Usage => { self.status = Some("No active session for /usage.".to_string()); } - "/init" => match crate::prompts::get_cli_prompt("init") { + ActionHandler::Init => match crate::prompts::get_cli_prompt("init") { Some(prompt) => { return Some(StartupResult::NewSession { prompt: Some(prompt.to_string()), }); } - None => { - self.status = Some("Init prompt not found".to_string()); - } + None => self.status = Some("Init prompt not found".to_string()), }, - _ => { - self.status = Some(format!( - "Unknown command: {}. Type /help for available commands.", - cmd - )); + ActionHandler::OpenPalette => { + self.push_current_popup_to_stack(); + self.command_palette.show(ActionState::startup(false)); + } + ActionHandler::SubmitInput => return self.submit_input(), + ActionHandler::InsertNewline => { + self.text_input.handle_newline(); + self.refresh_command_menu(); + } + ActionHandler::Paste => { + if let Ok(mut clipboard) = arboard::Clipboard::new() { + if let Ok(text) = clipboard.get_text() { + self.text_input.insert_paste(&text); + self.refresh_command_menu(); + } + } + } + ActionHandler::ClosePopups => self.close_all_popups(), + ActionHandler::NavigateBack => self.navigate_back(), + ActionHandler::ClearConversation + | ActionHandler::ReloadSkills + | ActionHandler::History + | ActionHandler::Interrupt + | ActionHandler::ToggleFocusedTool + | ActionHandler::PreviousTool + | ActionHandler::NextTool + | ActionHandler::HistoryPrevious + | ActionHandler::HistoryNext + | ActionHandler::JumpTop + | ActionHandler::JumpBottom + | ActionHandler::ClearInput + | ActionHandler::ToggleBrowse + | ActionHandler::ScrollUp + | ActionHandler::ScrollDown => { + self.status = Some("Action is unavailable on the startup page.".to_string()); } } - None } + fn submit_input(&mut self) -> Option { + if let Some(action_id) = self.command_menu.apply_selection() { + self.text_input.clear(); + self.refresh_command_menu(); + return self.handle_palette_action(&action_id); + } + if self.text_input.is_empty() { + return Some(StartupResult::NewSession { prompt: None }); + } + + let trimmed = self.text_input.text().trim().to_string(); + if trimmed == "exit" || trimmed == "quit" { + return Some(StartupResult::Exit); + } + if trimmed.starts_with('/') { + return self.handle_command(&trimmed); + } + Some(StartupResult::NewSession { + prompt: Some(trimmed), + }) + } + + fn logout(&mut self) { + let logged_in = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(crate::account::is_logged_in()) + }); + if !logged_in { + self.status = Some("Not logged in.".to_string()); + return; + } + self.status = Some( + match tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(crate::account::logout()) + }) { + Ok(()) => "Logged out.".to_string(), + Err(error) => format!("Logout failed: {error}"), + }, + ); + } + + // ======================== Command execution ======================== + + fn handle_command(&mut self, command: &str) -> Option { + let cmd = command.split_whitespace().next().unwrap_or(""); + + self.text_input.clear(); + self.refresh_command_menu(); + let Some(action) = action_for_alias(cmd, ActionContext::Startup) else { + self.status = Some(format!( + "Unknown command: {cmd}. Type /help for available commands." + )); + return None; + }; + self.dispatch_action(action, ActionState::startup(false)) + } + // ======================== Selectors ======================== /// Push the currently visible popup onto the navigation stack and hide it @@ -2265,6 +2236,7 @@ impl StartupPage { /// Close all popups and clear the navigation stack fn close_all_popups(&mut self) { + self.info_popup = None; self.command_palette.hide(); self.model_selector.hide(); self.agent_selector.hide(); @@ -2376,11 +2348,8 @@ impl StartupPage { } fn refresh_command_menu(&mut self) { - self.command_menu.update_with_commands( - &self.text_input.input, - self.text_input.cursor, - STARTUP_COMMAND_SPECS, - ); + self.command_menu + .update(&self.text_input.input, self.text_input.cursor); } } diff --git a/src/apps/cli/src/ui/widgets.rs b/src/apps/cli/src/ui/widgets.rs index 493a4cf7de..afcef2025b 100644 --- a/src/apps/cli/src/ui/widgets.rs +++ b/src/apps/cli/src/ui/widgets.rs @@ -8,6 +8,8 @@ use ratatui::{ }; use unicode_width::UnicodeWidthStr; +const INFO_POPUP_DISMISS_HINT: &str = " Press Esc to dismiss "; + pub(super) struct Spinner { frame: usize, } @@ -28,7 +30,7 @@ impl Spinner { } } -/// Render a centered info popup overlay. Press any key to dismiss. +/// Render a centered info popup overlay. Esc always dismisses it. pub(super) fn render_info_popup(frame: &mut Frame, area: Rect, message: &str, accent: Color) { let lines: Vec = message .lines() @@ -86,9 +88,20 @@ pub(super) fn render_info_popup(frame: &mut Frame, area: Rect, message: &str, ac height: 1, }; let hint = Paragraph::new(Line::from(Span::styled( - " Press any key to dismiss ", + INFO_POPUP_DISMISS_HINT, Style::default().fg(Color::DarkGray), ))); frame.render_widget(hint, hint_area); } } + +#[cfg(test)] +mod tests { + use super::INFO_POPUP_DISMISS_HINT; + + #[test] + fn info_popup_names_the_modal_safe_dismiss_key() { + assert!(INFO_POPUP_DISMISS_HINT.contains("Esc")); + assert!(!INFO_POPUP_DISMISS_HINT.contains("any key")); + } +}