#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy //! Command registry — single source of truth for all TUI actions. //! //! Every keybinding, palette entry, help text, CLI equivalent, and //! status hint is generated from [`CommandRegistry`]. No hardcoded //! duplicate maps exist in view/state modules. //! //! Supports single-key and two-key sequences (g-prefix vim bindings). use std::collections::HashMap; use ftui::{KeyCode, Modifiers}; use crate::message::{InputMode, Screen}; // --------------------------------------------------------------------------- // Key formatting // --------------------------------------------------------------------------- /// Format a key code + modifiers as a human-readable string. fn format_key(code: KeyCode, modifiers: Modifiers) -> String { let mut parts = Vec::new(); if modifiers.contains(Modifiers::CTRL) { parts.push("Ctrl"); } if modifiers.contains(Modifiers::ALT) { parts.push("Alt"); } if modifiers.contains(Modifiers::SHIFT) { parts.push("Shift"); } let key_name = match code { KeyCode::Char(c) => c.to_string(), KeyCode::Enter => "Enter".to_string(), KeyCode::Escape => "Esc".to_string(), KeyCode::Tab => "Tab".to_string(), KeyCode::Backspace => "Backspace".to_string(), KeyCode::Delete => "Del".to_string(), KeyCode::Up => "Up".to_string(), KeyCode::Down => "Down".to_string(), KeyCode::Left => "Left".to_string(), KeyCode::Right => "Right".to_string(), KeyCode::Home => "Home".to_string(), KeyCode::End => "End".to_string(), KeyCode::PageUp => "PgUp".to_string(), KeyCode::PageDown => "PgDn".to_string(), KeyCode::F(n) => format!("F{n}"), _ => "?".to_string(), }; parts.push(&key_name); // We need to own the joined string. let joined: String = parts.join("+"); joined } // --------------------------------------------------------------------------- // KeyCombo // --------------------------------------------------------------------------- /// A keybinding: either a single key or a two-key sequence. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum KeyCombo { /// Single key press (e.g., `q`, `Esc`, `Ctrl+P`). Single { code: KeyCode, modifiers: Modifiers }, /// Two-key sequence (e.g., `g` then `i` for go-to-issues). Sequence { first_code: KeyCode, first_modifiers: Modifiers, second_code: KeyCode, second_modifiers: Modifiers, }, } impl KeyCombo { /// Convenience: single key with no modifiers. #[must_use] pub const fn key(code: KeyCode) -> Self { Self::Single { code, modifiers: Modifiers::NONE, } } /// Convenience: single key with Ctrl modifier. #[must_use] pub const fn ctrl(code: KeyCode) -> Self { Self::Single { code, modifiers: Modifiers::CTRL, } } /// Convenience: g-prefix sequence (g + char). #[must_use] pub const fn g_then(c: char) -> Self { Self::Sequence { first_code: KeyCode::Char('g'), first_modifiers: Modifiers::NONE, second_code: KeyCode::Char(c), second_modifiers: Modifiers::NONE, } } /// Human-readable display string for this key combo. #[must_use] pub fn display(&self) -> String { match self { Self::Single { code, modifiers } => format_key(*code, *modifiers), Self::Sequence { first_code, first_modifiers, second_code, second_modifiers, } => { let first = format_key(*first_code, *first_modifiers); let second = format_key(*second_code, *second_modifiers); format!("{first} {second}") } } } /// Whether this combo starts with the given key. #[must_use] pub fn starts_with(&self, code: &KeyCode, modifiers: &Modifiers) -> bool { match self { Self::Single { code: c, modifiers: m, } => c == code && m == modifiers, Self::Sequence { first_code, first_modifiers, .. } => first_code == code && first_modifiers == modifiers, } } } // --------------------------------------------------------------------------- // ScreenFilter // --------------------------------------------------------------------------- /// Specifies which screens a command is available on. #[derive(Debug, Clone)] pub enum ScreenFilter { /// Available on all screens. Global, /// Available only on specific screens. Only(Vec), } impl ScreenFilter { /// Whether the command is available on the given screen. #[must_use] pub fn matches(&self, screen: &Screen) -> bool { match self { Self::Global => true, Self::Only(screens) => screens.contains(screen), } } } // --------------------------------------------------------------------------- // CommandDef // --------------------------------------------------------------------------- /// Unique command identifier. pub type CommandId = &'static str; /// A registered command with its keybinding, help text, and scope. #[derive(Debug, Clone)] pub struct CommandDef { /// Unique identifier (e.g., "quit", "go_issues"). pub id: CommandId, /// Human-readable label for palette and help overlay. pub label: &'static str, /// Keybinding (if any). pub keybinding: Option, /// Equivalent `lore` CLI command (for "Show CLI equivalent" feature). pub cli_equivalent: Option<&'static str>, /// Description for help overlay. pub help_text: &'static str, /// Short hint for status bar (e.g., "q:quit"). pub status_hint: &'static str, /// Which screens this command is available on. pub available_in: ScreenFilter, /// Whether this command works in Text input mode. pub available_in_text_mode: bool, } // --------------------------------------------------------------------------- // CommandRegistry // --------------------------------------------------------------------------- /// Single source of truth for all TUI commands. /// /// Built once at startup via [`build_registry`]. Provides O(1) lookup /// by keybinding and per-screen filtering. pub struct CommandRegistry { commands: Vec, /// Single-key -> command IDs that start with this key. by_single_key: HashMap<(KeyCode, Modifiers), Vec>, /// Full sequence -> command index (for two-key combos). by_sequence: HashMap, } impl CommandRegistry { /// Look up a command by a single key press on a given screen and input mode. /// /// Returns `None` if no matching command is found. For sequence starters /// (like 'g'), returns `None` — use [`is_sequence_starter`] to detect /// that case. #[must_use] pub fn lookup_key( &self, code: &KeyCode, modifiers: &Modifiers, screen: &Screen, mode: &InputMode, ) -> Option<&CommandDef> { let is_text = matches!(mode, InputMode::Text); let key = (*code, *modifiers); let indices = self.by_single_key.get(&key)?; for &idx in indices { let cmd = &self.commands[idx]; if !cmd.available_in.matches(screen) { continue; } if is_text && !cmd.available_in_text_mode { continue; } // Only match Single combos here, not sequence starters. if let Some(KeyCombo::Single { .. }) = &cmd.keybinding { return Some(cmd); } } None } /// Complete a two-key sequence. /// /// Called after the first key of a sequence is detected (e.g., after 'g'). #[must_use] pub fn complete_sequence( &self, first_code: &KeyCode, first_modifiers: &Modifiers, second_code: &KeyCode, second_modifiers: &Modifiers, screen: &Screen, ) -> Option<&CommandDef> { let combo = KeyCombo::Sequence { first_code: *first_code, first_modifiers: *first_modifiers, second_code: *second_code, second_modifiers: *second_modifiers, }; let &idx = self.by_sequence.get(&combo)?; let cmd = &self.commands[idx]; if cmd.available_in.matches(screen) { Some(cmd) } else { None } } /// Whether a key starts a multi-key sequence (e.g., 'g'). #[must_use] pub fn is_sequence_starter(&self, code: &KeyCode, modifiers: &Modifiers) -> bool { self.by_sequence .keys() .any(|combo| combo.starts_with(code, modifiers)) } /// Commands available for the command palette on a given screen. /// /// Returned sorted by label. #[must_use] pub fn palette_entries(&self, screen: &Screen) -> Vec<&CommandDef> { let mut entries: Vec<&CommandDef> = self .commands .iter() .filter(|c| c.available_in.matches(screen)) .collect(); entries.sort_by_key(|c| c.label); entries } /// Commands for the help overlay on a given screen. #[must_use] pub fn help_entries(&self, screen: &Screen) -> Vec<&CommandDef> { self.commands .iter() .filter(|c| c.available_in.matches(screen)) .filter(|c| c.keybinding.is_some()) .collect() } /// Status bar hints for the current screen. #[must_use] pub fn status_hints(&self, screen: &Screen) -> Vec<&str> { self.commands .iter() .filter(|c| c.available_in.matches(screen)) .filter(|c| !c.status_hint.is_empty()) .map(|c| c.status_hint) .collect() } /// Total number of registered commands. #[must_use] pub fn len(&self) -> usize { self.commands.len() } /// Whether the registry has no commands. #[must_use] pub fn is_empty(&self) -> bool { self.commands.is_empty() } } // --------------------------------------------------------------------------- // build_registry // --------------------------------------------------------------------------- /// Build the command registry with all TUI commands. /// /// This is the single source of truth — every keybinding, help text, /// and palette entry originates here. #[must_use] pub fn build_registry() -> CommandRegistry { let commands = vec![ // --- Global commands --- CommandDef { id: "quit", label: "Quit", keybinding: Some(KeyCombo::key(KeyCode::Char('q'))), cli_equivalent: None, help_text: "Exit the TUI", status_hint: "q:quit", available_in: ScreenFilter::Global, available_in_text_mode: false, }, CommandDef { id: "go_back", label: "Go Back", keybinding: Some(KeyCombo::key(KeyCode::Escape)), cli_equivalent: None, help_text: "Go back to previous screen", status_hint: "esc:back", available_in: ScreenFilter::Global, available_in_text_mode: true, }, CommandDef { id: "show_help", label: "Help", keybinding: Some(KeyCombo::key(KeyCode::Char('?'))), cli_equivalent: None, help_text: "Show keybinding help overlay", status_hint: "?:help", available_in: ScreenFilter::Global, available_in_text_mode: false, }, CommandDef { id: "command_palette", label: "Command Palette", keybinding: Some(KeyCombo::ctrl(KeyCode::Char('p'))), cli_equivalent: None, help_text: "Open command palette", status_hint: "C-p:palette", available_in: ScreenFilter::Global, available_in_text_mode: true, }, CommandDef { id: "open_in_browser", label: "Open in Browser", keybinding: Some(KeyCombo::key(KeyCode::Char('o'))), cli_equivalent: None, help_text: "Open current entity in browser", status_hint: "o:browser", available_in: ScreenFilter::Global, available_in_text_mode: false, }, CommandDef { id: "show_cli", label: "Show CLI Equivalent", keybinding: Some(KeyCombo::key(KeyCode::Char('!'))), cli_equivalent: None, help_text: "Show equivalent lore CLI command", status_hint: "", available_in: ScreenFilter::Global, available_in_text_mode: false, }, // --- Navigation: g-prefix sequences --- CommandDef { id: "go_home", label: "Go to Dashboard", keybinding: Some(KeyCombo::g_then('h')), cli_equivalent: None, help_text: "Jump to dashboard", status_hint: "gh:home", available_in: ScreenFilter::Global, available_in_text_mode: false, }, CommandDef { id: "go_issues", label: "Go to Issues", keybinding: Some(KeyCombo::g_then('i')), cli_equivalent: Some("lore issues"), help_text: "Jump to issue list", status_hint: "gi:issues", available_in: ScreenFilter::Global, available_in_text_mode: false, }, CommandDef { id: "go_mrs", label: "Go to Merge Requests", keybinding: Some(KeyCombo::g_then('m')), cli_equivalent: Some("lore mrs"), help_text: "Jump to MR list", status_hint: "gm:mrs", available_in: ScreenFilter::Global, available_in_text_mode: false, }, CommandDef { id: "go_search", label: "Go to Search", keybinding: Some(KeyCombo::g_then('/')), cli_equivalent: Some("lore search"), help_text: "Jump to search", status_hint: "g/:search", available_in: ScreenFilter::Global, available_in_text_mode: false, }, CommandDef { id: "go_timeline", label: "Go to Timeline", keybinding: Some(KeyCombo::g_then('t')), cli_equivalent: Some("lore timeline"), help_text: "Jump to timeline", status_hint: "gt:timeline", available_in: ScreenFilter::Global, available_in_text_mode: false, }, CommandDef { id: "go_who", label: "Go to Who", keybinding: Some(KeyCombo::g_then('w')), cli_equivalent: Some("lore who"), help_text: "Jump to people intelligence", status_hint: "gw:who", available_in: ScreenFilter::Global, available_in_text_mode: false, }, CommandDef { id: "go_sync", label: "Go to Sync", keybinding: Some(KeyCombo::g_then('s')), cli_equivalent: Some("lore sync"), help_text: "Jump to sync status", status_hint: "gs:sync", available_in: ScreenFilter::Global, available_in_text_mode: false, }, // --- Vim-style jump list --- CommandDef { id: "jump_back", label: "Jump Back", keybinding: Some(KeyCombo::ctrl(KeyCode::Char('o'))), cli_equivalent: None, help_text: "Jump backward through visited detail views", status_hint: "C-o:jump back", available_in: ScreenFilter::Global, available_in_text_mode: false, }, CommandDef { id: "jump_forward", label: "Jump Forward", keybinding: Some(KeyCombo::ctrl(KeyCode::Char('i'))), cli_equivalent: None, help_text: "Jump forward through visited detail views", status_hint: "", available_in: ScreenFilter::Global, available_in_text_mode: false, }, // --- List navigation --- CommandDef { id: "move_down", label: "Move Down", keybinding: Some(KeyCombo::key(KeyCode::Char('j'))), cli_equivalent: None, help_text: "Move cursor down", status_hint: "j:down", available_in: ScreenFilter::Only(vec![ Screen::IssueList, Screen::MrList, Screen::Search, Screen::Timeline, ]), available_in_text_mode: false, }, CommandDef { id: "move_up", label: "Move Up", keybinding: Some(KeyCombo::key(KeyCode::Char('k'))), cli_equivalent: None, help_text: "Move cursor up", status_hint: "k:up", available_in: ScreenFilter::Only(vec![ Screen::IssueList, Screen::MrList, Screen::Search, Screen::Timeline, ]), available_in_text_mode: false, }, CommandDef { id: "select_item", label: "Select", keybinding: Some(KeyCombo::key(KeyCode::Enter)), cli_equivalent: None, help_text: "Open selected item", status_hint: "enter:open", available_in: ScreenFilter::Only(vec![ Screen::IssueList, Screen::MrList, Screen::Search, ]), available_in_text_mode: false, }, // --- Filter --- CommandDef { id: "focus_filter", label: "Filter", keybinding: Some(KeyCombo::key(KeyCode::Char('/'))), cli_equivalent: None, help_text: "Focus the filter input", status_hint: "/:filter", available_in: ScreenFilter::Only(vec![Screen::IssueList, Screen::MrList]), available_in_text_mode: false, }, // --- Scroll --- CommandDef { id: "scroll_to_top", label: "Scroll to Top", keybinding: Some(KeyCombo::g_then('g')), cli_equivalent: None, help_text: "Scroll to the top of the current view", status_hint: "", available_in: ScreenFilter::Global, available_in_text_mode: false, }, ]; build_from_defs(commands) } /// Build index maps from a list of command definitions. fn build_from_defs(commands: Vec) -> CommandRegistry { let mut by_single_key: HashMap<(KeyCode, Modifiers), Vec> = HashMap::new(); let mut by_sequence: HashMap = HashMap::new(); for (idx, cmd) in commands.iter().enumerate() { if let Some(combo) = &cmd.keybinding { match combo { KeyCombo::Single { code, modifiers } => { by_single_key .entry((*code, *modifiers)) .or_default() .push(idx); } KeyCombo::Sequence { .. } => { by_sequence.insert(combo.clone(), idx); // Also index the first key so is_sequence_starter works via by_single_key. if let KeyCombo::Sequence { first_code, first_modifiers, .. } = combo { by_single_key .entry((*first_code, *first_modifiers)) .or_default() .push(idx); } } } } } CommandRegistry { commands, by_single_key, by_sequence, } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use chrono::Utc; #[test] fn test_registry_builds_successfully() { let reg = build_registry(); assert!(!reg.is_empty()); assert!(reg.len() >= 15); } #[test] fn test_registry_lookup_quit() { let reg = build_registry(); let cmd = reg.lookup_key( &KeyCode::Char('q'), &Modifiers::NONE, &Screen::Dashboard, &InputMode::Normal, ); assert!(cmd.is_some()); assert_eq!(cmd.unwrap().id, "quit"); } #[test] fn test_registry_lookup_quit_blocked_in_text_mode() { let reg = build_registry(); let cmd = reg.lookup_key( &KeyCode::Char('q'), &Modifiers::NONE, &Screen::Dashboard, &InputMode::Text, ); assert!(cmd.is_none()); } #[test] fn test_registry_esc_works_in_text_mode() { let reg = build_registry(); let cmd = reg.lookup_key( &KeyCode::Escape, &Modifiers::NONE, &Screen::IssueList, &InputMode::Text, ); assert!(cmd.is_some()); assert_eq!(cmd.unwrap().id, "go_back"); } #[test] fn test_registry_ctrl_p_works_in_text_mode() { let reg = build_registry(); let cmd = reg.lookup_key( &KeyCode::Char('p'), &Modifiers::CTRL, &Screen::Search, &InputMode::Text, ); assert!(cmd.is_some()); assert_eq!(cmd.unwrap().id, "command_palette"); } #[test] fn test_g_is_sequence_starter() { let reg = build_registry(); assert!(reg.is_sequence_starter(&KeyCode::Char('g'), &Modifiers::NONE)); assert!(!reg.is_sequence_starter(&KeyCode::Char('x'), &Modifiers::NONE)); } #[test] fn test_complete_sequence_gi() { let reg = build_registry(); let cmd = reg.complete_sequence( &KeyCode::Char('g'), &Modifiers::NONE, &KeyCode::Char('i'), &Modifiers::NONE, &Screen::Dashboard, ); assert!(cmd.is_some()); assert_eq!(cmd.unwrap().id, "go_issues"); } #[test] fn test_complete_sequence_invalid_second_key() { let reg = build_registry(); let cmd = reg.complete_sequence( &KeyCode::Char('g'), &Modifiers::NONE, &KeyCode::Char('x'), &Modifiers::NONE, &Screen::Dashboard, ); assert!(cmd.is_none()); } #[test] fn test_screen_specific_command() { let reg = build_registry(); // 'j' (move_down) should work on IssueList let cmd = reg.lookup_key( &KeyCode::Char('j'), &Modifiers::NONE, &Screen::IssueList, &InputMode::Normal, ); assert!(cmd.is_some()); assert_eq!(cmd.unwrap().id, "move_down"); // 'j' should NOT match on Dashboard (move_down is list-only). let cmd = reg.lookup_key( &KeyCode::Char('j'), &Modifiers::NONE, &Screen::Dashboard, &InputMode::Normal, ); assert!(cmd.is_none()); } #[test] fn test_palette_entries_sorted_by_label() { let reg = build_registry(); let entries = reg.palette_entries(&Screen::Dashboard); let labels: Vec<&str> = entries.iter().map(|c| c.label).collect(); let mut sorted = labels.clone(); sorted.sort(); assert_eq!(labels, sorted); } #[test] fn test_help_entries_only_include_keybindings() { let reg = build_registry(); let entries = reg.help_entries(&Screen::Dashboard); for entry in &entries { assert!( entry.keybinding.is_some(), "help entry without keybinding: {}", entry.id ); } } #[test] fn test_status_hints_non_empty() { let reg = build_registry(); let hints = reg.status_hints(&Screen::Dashboard); assert!(!hints.is_empty()); // All returned hints should be non-empty strings. for hint in &hints { assert!(!hint.is_empty()); } } #[test] fn test_cli_equivalents_populated() { let reg = build_registry(); let with_cli: Vec<&CommandDef> = reg .commands .iter() .filter(|c| c.cli_equivalent.is_some()) .collect(); assert!( with_cli.len() >= 5, "expected at least 5 commands with cli_equivalent, got {}", with_cli.len() ); } #[test] fn test_go_prefix_timeout_detection() { let reg = build_registry(); // Simulate GoPrefix mode entering: 'g' detected as sequence starter. assert!(reg.is_sequence_starter(&KeyCode::Char('g'), &Modifiers::NONE)); // Simulate InputMode::GoPrefix with timeout check. let started = Utc::now(); let mode = InputMode::GoPrefix { started_at: started, }; // In GoPrefix mode, normal lookup should still work for non-sequence keys. let cmd = reg.lookup_key( &KeyCode::Char('q'), &Modifiers::NONE, &Screen::Dashboard, &mode, ); assert!(cmd.is_some()); assert_eq!(cmd.unwrap().id, "quit"); } #[test] fn test_all_commands_have_nonempty_help() { let reg = build_registry(); for cmd in ®.commands { assert!( !cmd.help_text.is_empty(), "command {} has empty help_text", cmd.id ); } } }