//! Extensions modal popup (Hooks, Plugins, Skills, MCP Servers). //! //! A centered overlay using the shared [`ModalWindow`](super::modal_window) //! chrome, opened by the `/hooks` and `/plugins` slash commands. //! Blocks all input until closed with `Esc`. use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use unicode_width::UnicodeWidthStr; use crate::theme::Theme; use crate::views::modal_window::{ self, ModalContentArea, ModalSizing, ModalWindowConfig, ModalWindowState, Shortcut, }; use crate::views::picker; use kigi_tools::implementations::skills::types::SkillInfo; /// Check if a name fuzzy-matches the search query. /// Empty query matches everything. fn fuzzy_matches(name: &str, query: &str) -> bool { if query.is_empty() { return true; } let query_lower = query.to_lowercase(); let name_lower = name.to_lowercase(); // Substring match first (fast path). if name_lower.contains(&query_lower) { return true; } // Fuzzy: all query chars appear in order in the name. let mut chars = query_lower.chars(); let mut current = chars.next(); for c in name_lower.chars() { if current == Some(c) { current = chars.next(); } } current.is_none() } /// Check if a hook fuzzy-matches the search query across all its fields. pub fn fuzzy_matches_hook(hook: &kigi_hooks_plugins_types::HookInfo, query: &str) -> bool { if query.is_empty() { return true; } fuzzy_matches(&hook.name, query) || fuzzy_matches(&hook.event.to_string(), query) || hook .matcher .as_ref() .is_some_and(|m| fuzzy_matches(m, query)) || hook .command .as_ref() .is_some_and(|c| fuzzy_matches(c, query)) || hook.url.as_ref().is_some_and(|u| fuzzy_matches(u, query)) } /// Word-wrap text into lines that fit within `max_w` characters. /// /// Splits on newlines first, then wraps each paragraph at word boundaries. /// All slicing uses `char_indices` so multi-byte UTF-8 is never split. fn word_wrap(text: &str, max_w: usize) -> Vec<&str> { if max_w == 0 { return vec![text]; } let mut result = Vec::new(); for line in text.lines() { if line.is_empty() { result.push(line); continue; } let mut remaining = line; while !remaining.is_empty() { if remaining.chars().count() <= max_w { result.push(remaining); break; } let byte_limit = remaining .char_indices() .nth(max_w) .map(|(i, _)| i) .unwrap_or(remaining.len()); let cut = remaining[..byte_limit] .rfind(' ') .filter(|&i| i > 0) .map(|i| i + 1) .unwrap_or(byte_limit); result.push(remaining[..cut].trim_end()); remaining = remaining[cut..].trim_start(); } } result } /// Test fixture: a minimal `PluginInfo` shared by the pager's plugin tests. #[cfg(test)] pub(crate) fn test_plugin_info( name: &str, origin: Option, ) -> kigi_hooks_plugins_types::PluginInfo { kigi_hooks_plugins_types::PluginInfo { name: name.to_string(), id: format!("user/abcd1234/{name}"), root: format!("/tmp/{name}"), scope: kigi_hooks_plugins_types::PluginScope::User, trusted: true, enabled: true, version: None, description: None, skill_count: 0, skill_names: vec![], agent_count: 0, agent_names: vec![], hook_status: kigi_hooks_plugins_types::HookStatus::None, hook_count: 0, mcp_server_count: 0, mcp_status: kigi_hooks_plugins_types::McpStatus::None, marketplace_source: None, origin, conflict: None, } } /// A plugin's source group on the Plugins tab. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginGroup { /// Ordering bucket (lower renders first; groups with the same rank sort by label). pub rank: u8, /// Stable collapse key (stored in `entry_group_keys` and `plugins_collapsed_groups`). pub key: String, /// Header label shown for the group. pub label: String, } impl PluginGroup { fn new(rank: u8, key: &str, label: &str) -> Self { Self { rank, key: key.to_string(), label: label.to_string(), } } } /// Plugins bucketed by `(rank, label, key)` group sort key for the Plugins tab. type GroupedPlugins<'a> = std::collections::BTreeMap< (u8, String, String), Vec<(usize, &'a kigi_hooks_plugins_types::PluginInfo)>, >; /// Header count suffix: `1 plugin`, `2 plugins`. fn plugin_count_label(n: usize) -> String { if n == 1 { "1 plugin".to_string() } else { format!("{n} plugins") } } /// Resolve the source group a plugin belongs to on the Plugins tab. /// /// Uses the plugin's `origin` when present. A missing origin (older shell) /// or an unrecognized variant (newer shell) falls back to the scope plus /// the legacy `marketplace_source` label so the UI still degrades to /// sensible groups. pub fn plugin_group(plugin: &kigi_hooks_plugins_types::PluginInfo) -> PluginGroup { use kigi_hooks_plugins_types::{PluginOrigin, PluginScope}; match &plugin.origin { Some(PluginOrigin::ProjectKigi) => PluginGroup::new(0, "origin:project", "Project"), Some(PluginOrigin::ProjectClaude) => { PluginGroup::new(1, "origin:project-claude", "Project (Claude)") } Some(PluginOrigin::UserKigi) => PluginGroup::new(2, "origin:user", "User"), Some(PluginOrigin::UserClaude) | Some(PluginOrigin::ClaudeInstalled { marketplace: None }) => { PluginGroup::new(3, "origin:user-claude", "User (Claude)") } Some(PluginOrigin::ClaudeMarketplace { marketplace }) | Some(PluginOrigin::ClaudeInstalled { marketplace: Some(marketplace), }) => PluginGroup { rank: 4, key: format!("claude-mp:{marketplace}"), label: marketplace.clone(), }, Some(PluginOrigin::MarketplaceInstall { source_name: Some(source), .. }) => PluginGroup { rank: 5, key: format!("kigi-mp:{source}"), label: source.clone(), }, Some(PluginOrigin::MarketplaceInstall { source_name: None, .. }) => PluginGroup::new(6, "origin:direct", "Direct installs"), Some(PluginOrigin::CliOverride) => PluginGroup::new(7, "origin:cli", "CLI override"), Some(PluginOrigin::ConfigPath) => PluginGroup::new(8, "origin:config", "Custom paths"), Some(PluginOrigin::Unknown) | None => match plugin.scope { PluginScope::Project => PluginGroup::new(0, "origin:project", "Project"), PluginScope::User => match plugin.marketplace_source.as_deref() { Some(source) if source.starts_with("git: ") => { PluginGroup::new(6, "origin:direct", "Direct installs") } Some(source) => PluginGroup { rank: 5, key: format!("kigi-mp:{source}"), label: source.to_string(), }, None => PluginGroup::new(2, "origin:user", "User"), }, PluginScope::Cli => PluginGroup::new(7, "origin:cli", "CLI override"), PluginScope::Config => PluginGroup::new(8, "origin:config", "Custom paths"), }, } } /// Build merged hook groups (same grouping as the renderer uses). fn build_hook_groups<'a>( hooks: &'a [kigi_hooks_plugins_types::HookInfo], filter: StatusFilter, query: &str, ) -> Vec<(&'a str, Vec)> { let mut groups: Vec<(&str, Vec)> = Vec::new(); for (i, hook) in hooks.iter().enumerate() { if !fuzzy_matches_hook(hook, query) { continue; } if !filter.matches(!hook.disabled) { continue; } if let Some(g) = groups.iter_mut().find(|g| g.0 == hook.source_dir) { g.1.push(i); } else { groups.push((&hook.source_dir, vec![i])); } } groups } /// Find the next visible hook index after `current`, skipping collapsed groups. pub fn next_visible_hook( hooks: &[kigi_hooks_plugins_types::HookInfo], current: usize, collapsed: &std::collections::HashSet, filter: StatusFilter, query: &str, ) -> Option { if hooks.is_empty() { return None; } let groups = build_hook_groups(hooks, filter, query); // Find which group `current` belongs to. let mut current_group_idx = None; for (gi, (_source_dir, indices)) in groups.iter().enumerate() { if indices.contains(¤t) { current_group_idx = Some(gi); break; } } if let Some(gi) = current_group_idx { let (source_dir, indices) = &groups[gi]; // If group is expanded, try to move within the group. if !collapsed.contains(*source_dir) && let Some(&next) = indices.iter().find(|&&i| i > current) { return Some(next); } // Move to next group's first hook. if gi + 1 < groups.len() { return groups[gi + 1].1.first().copied(); } } else { // current not found (filtered out): find first group with any index > current. for (_source_dir, indices) in &groups { if let Some(&next) = indices.iter().find(|&&i| i > current) { return Some(next); } } } None } /// Find the previous visible hook index before `current`, skipping collapsed groups. pub fn prev_visible_hook( hooks: &[kigi_hooks_plugins_types::HookInfo], current: usize, collapsed: &std::collections::HashSet, filter: StatusFilter, query: &str, ) -> Option { if hooks.is_empty() { return None; } let groups = build_hook_groups(hooks, filter, query); // Find which group `current` belongs to. let mut current_group_idx = None; for (gi, (_source_dir, indices)) in groups.iter().enumerate() { if indices.contains(¤t) { current_group_idx = Some(gi); break; } } if let Some(gi) = current_group_idx { let (source_dir, indices) = &groups[gi]; // If group is expanded, try to move within the group. if !collapsed.contains(*source_dir) && let Some(&prev) = indices.iter().rev().find(|&&i| i < current) { return Some(prev); } // Move to previous group's representative. if gi > 0 { let (prev_dir, prev_indices) = &groups[gi - 1]; return if collapsed.contains(*prev_dir) { prev_indices.first().copied() } else { prev_indices.last().copied() }; } } else { // current not found: find last index < current across all groups. let mut best: Option = None; for (_source_dir, indices) in &groups { if let Some(&prev) = indices.iter().rev().find(|&&i| i < current) { best = Some(prev); } } return best; } None } // --------------------------------------------------------------------------- // Tab enum // --------------------------------------------------------------------------- /// Which tab is active in the hooks/plugins modal. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExtensionsTab { Hooks, Plugins, Skills, McpServers, } impl ExtensionsTab { /// All tabs in display order. pub const ALL: &[Self] = &[Self::Hooks, Self::Plugins, Self::Skills, Self::McpServers]; /// Display label for the tab bar. pub fn label(self) -> &'static str { match self { Self::Hooks => "Hooks", Self::Plugins => "Plugins", Self::Skills => "Skills", Self::McpServers => "MCP Servers", } } /// Next tab (wraps around). pub fn next(self) -> Self { match self { Self::Hooks => Self::Plugins, Self::Plugins => Self::Skills, Self::Skills => Self::McpServers, Self::McpServers => Self::Hooks, } } /// Previous tab (wraps around). pub fn prev(self) -> Self { match self { Self::Hooks => Self::McpServers, Self::Plugins => Self::Hooks, Self::Skills => Self::Plugins, Self::McpServers => Self::Skills, } } } // --------------------------------------------------------------------------- // Status filter // --------------------------------------------------------------------------- /// Filter items by enabled/disabled status. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum StatusFilter { #[default] All, Enabled, Disabled, } impl StatusFilter { pub fn label(self) -> &'static str { match self { Self::All => "All", Self::Enabled => "Enabled", Self::Disabled => "Disabled", } } pub fn next(self) -> Self { match self { Self::All => Self::Enabled, Self::Enabled => Self::Disabled, Self::Disabled => Self::All, } } pub fn matches(self, enabled: bool) -> bool { match self { Self::All => true, Self::Enabled => enabled, Self::Disabled => !enabled, } } } // --------------------------------------------------------------------------- // Button actions // --------------------------------------------------------------------------- /// What a button does when activated (clicked or keyboard shortcut). #[derive(Debug, Clone)] pub enum ButtonAction { /// Execute a hooks action via ACP (no args needed). HooksAction(kigi_hooks_plugins_types::HooksAction), /// Execute a plugins action via ACP (no args needed). PluginsAction(kigi_hooks_plugins_types::PluginsAction), /// Remove the hook under the cursor (uses source_dir from selected hook). RemoveSelectedHook, /// Toggle enable/disable on the hook under the cursor. ToggleSelectedHook, /// Toggle enable/disable on the plugin under the cursor. ToggleSelectedPlugin, /// Toggle enable/disable on the skill under the cursor. ToggleSelectedSkill, /// Toggle enable/disable on the MCP server under the cursor. ToggleSelectedMcpServer, /// Trigger MCP auth for the selected server. McpAuthTrigger, /// Add an MCP server (parsed from inline input). AddMcpServer { name: String, config: Box, }, /// Remove the selected MCP server from config.toml. RemoveSelectedMcpServer, /// Reload the skills list (re-fetch from shell). ReloadSkills, /// Refresh MCP server list (re-fetch from shell). RefreshMcpList, /// Update (fetch latest from source) the selected plugin. UpdateSelectedPlugin, /// Uninstall the selected plugin. UninstallSelectedPlugin, ToggleExpand, /// Cycle the status filter (All → Enabled → Disabled → All). CycleFilter, /// Enter input mode: show an inline form so the user can type arguments, /// then submit the full command on Enter. StartInput { /// Command prefix used to build the typed action on submit. command_prefix: String, /// Field specifications for the form (one per input field). fields: Vec, }, } /// Specification for a single input field in the modal form. #[derive(Debug, Clone)] pub struct FieldSpec { /// Human-readable label shown before the input field (e.g., "Name"). pub label: String, /// Whether the field must be non-empty to submit. pub required: bool, /// Placeholder text shown when the field is empty. pub placeholder: Option, } /// Inline input state for commands that need one or more arguments. #[derive(Debug, Clone)] pub struct ModalInput { /// Command prefix used to build the typed action on submit. pub command_prefix: String, /// Input fields. pub fields: Vec, /// Index of the currently focused field. pub focused: usize, /// Inline error message shown below the form. Cleared on next keystroke. pub error: Option, } /// State for a single input field in the modal form. #[derive(Debug, Clone)] pub struct ModalInputField { /// Human-readable label shown before the input field. pub label: String, /// Current text in the input field. pub text: String, /// Cursor position (byte offset into `text`). pub cursor: usize, /// Whether the field must be non-empty to submit. pub required: bool, /// Placeholder text shown when the field is empty. pub placeholder: Option, } impl ModalInputField { /// Sanitize pasted text and insert at cursor. Strips `\n` and `\r`. /// Returns `true` if text was inserted. pub fn insert_paste(&mut self, text: &str) -> bool { let cleaned: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect(); if cleaned.is_empty() { return false; } self.text.insert_str(self.cursor, &cleaned); self.cursor += cleaned.len(); true } /// Delete the word before the cursor (readline backward-kill-word). pub fn delete_word_backward(&mut self) { if self.cursor > 0 { let boundary = prev_word_boundary(&self.text, self.cursor); self.text.drain(boundary..self.cursor); self.cursor = boundary; } } } impl ModalInput { /// Build from a command prefix and field specs. pub fn from_specs(command_prefix: String, specs: Vec) -> Self { debug_assert!(!specs.is_empty(), "ModalInput needs at least one field"); let fields = specs .into_iter() .map(|s| ModalInputField { label: s.label, text: String::new(), cursor: 0, required: s.required, placeholder: s.placeholder, }) .collect(); Self { command_prefix, fields, focused: 0, error: None, } } /// The currently focused field (mutable). pub fn focused_field_mut(&mut self) -> Option<&mut ModalInputField> { self.fields.get_mut(self.focused) } /// Whether there are multiple fields to navigate between. pub fn is_multi_field(&self) -> bool { self.fields.len() > 1 } /// Collect all field texts into a Vec for submission. pub fn field_texts(&self) -> Vec { self.fields.iter().map(|f| f.text.clone()).collect() } /// Process a key event on the input form. Returns what the caller /// should do (submit, cancel, nothing, etc.) without coupling to /// `AgentView` or `InputOutcome`. pub fn handle_key(&mut self, key: &KeyEvent) -> ModalInputOutcome { // Clear inline error on any keystroke except Esc. if key.code != KeyCode::Esc { self.error = None; } match key { KeyEvent { code: KeyCode::Esc, .. } => ModalInputOutcome::Cancel, KeyEvent { code: KeyCode::Enter, .. } => { let field_texts = self.field_texts(); let empty_required: Vec<&str> = self .fields .iter() .enumerate() .filter(|(i, f)| { f.required && field_texts.get(*i).is_none_or(|t| t.trim().is_empty()) }) .map(|(_, f)| f.label.as_str()) .collect(); if !empty_required.is_empty() { self.error = Some(format!("Required: {}", empty_required.join(", "))); return ModalInputOutcome::Changed; } ModalInputOutcome::Submit { command_prefix: self.command_prefix.clone(), field_texts, } } // Field navigation (multi-field forms). KeyEvent { code: KeyCode::Tab, modifiers, .. } if !modifiers.contains(KeyModifiers::SHIFT) && self.is_multi_field() => { self.focused = (self.focused + 1) % self.fields.len(); ModalInputOutcome::Changed } KeyEvent { code: KeyCode::BackTab, .. } if self.is_multi_field() => { self.focused = if self.focused == 0 { self.fields.len() - 1 } else { self.focused - 1 }; ModalInputOutcome::Changed } // Tab in single-field forms: path completion. KeyEvent { code: KeyCode::Tab, .. } => { if let Some(field) = self.focused_field_mut() { let partial = field.text[..field.cursor].to_string(); if let Some(completed) = tab_complete_path(&partial) { let len = completed.len(); field.text = completed; field.cursor = len; return ModalInputOutcome::Changed; } } ModalInputOutcome::Unchanged } // Single-char backspace. KeyEvent { code: KeyCode::Backspace, modifiers: KeyModifiers::NONE, .. } => { if let Some(field) = self.focused_field_mut() && field.cursor > 0 { let prev = field.text[..field.cursor] .char_indices() .next_back() .map(|(i, _)| i) .unwrap_or(0); field.text.remove(prev); field.cursor = prev; return ModalInputOutcome::Changed; } ModalInputOutcome::Unchanged } // Forward-delete: Delete key, Ctrl+D. KeyEvent { code: KeyCode::Delete, .. } | KeyEvent { code: KeyCode::Char('d'), modifiers: KeyModifiers::CONTROL, .. } => { if let Some(field) = self.focused_field_mut() && field.cursor < field.text.len() { field.text.remove(field.cursor); return ModalInputOutcome::Changed; } ModalInputOutcome::Unchanged } // Word-delete backward: Alt+Backspace, Ctrl+Backspace, Ctrl+W. KeyEvent { code: KeyCode::Backspace, modifiers: KeyModifiers::ALT, .. } | KeyEvent { code: KeyCode::Backspace, modifiers: KeyModifiers::CONTROL, .. } | KeyEvent { code: KeyCode::Char('w'), modifiers: KeyModifiers::CONTROL, .. } => { if let Some(field) = self.focused_field_mut() { field.delete_word_backward(); } ModalInputOutcome::Changed } // Word-delete backward: Ctrl+Alt+H. KeyEvent { code: KeyCode::Char('h'), modifiers, .. } if *modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => { if let Some(field) = self.focused_field_mut() { field.delete_word_backward(); } ModalInputOutcome::Changed } // Delete to start of line: Cmd+Backspace (Super), Ctrl+U. KeyEvent { code: KeyCode::Backspace, modifiers: KeyModifiers::SUPER, .. } | KeyEvent { code: KeyCode::Char('u'), modifiers: KeyModifiers::CONTROL, .. } => { if let Some(field) = self.focused_field_mut() && field.cursor > 0 { field.text.drain(..field.cursor); field.cursor = 0; } ModalInputOutcome::Changed } // Delete to end of line: Ctrl+K. KeyEvent { code: KeyCode::Char('k'), modifiers: KeyModifiers::CONTROL, .. } => { if let Some(field) = self.focused_field_mut() && field.cursor < field.text.len() { field.text.truncate(field.cursor); } ModalInputOutcome::Changed } // Word movement: Alt+Left/Right, Ctrl+Left/Right. KeyEvent { code: KeyCode::Left, modifiers, .. } if modifiers.contains(KeyModifiers::ALT) || modifiers.contains(KeyModifiers::CONTROL) => { if let Some(field) = self.focused_field_mut() { field.cursor = prev_word_boundary(&field.text, field.cursor); } ModalInputOutcome::Changed } KeyEvent { code: KeyCode::Right, modifiers, .. } if modifiers.contains(KeyModifiers::ALT) || modifiers.contains(KeyModifiers::CONTROL) => { if let Some(field) = self.focused_field_mut() { field.cursor = next_word_boundary(&field.text, field.cursor); } ModalInputOutcome::Changed } // Alt+B / Alt+F (readline word movement). KeyEvent { code: KeyCode::Char('b'), modifiers: KeyModifiers::ALT, .. } => { if let Some(field) = self.focused_field_mut() { field.cursor = prev_word_boundary(&field.text, field.cursor); } ModalInputOutcome::Changed } KeyEvent { code: KeyCode::Char('f'), modifiers: KeyModifiers::ALT, .. } => { if let Some(field) = self.focused_field_mut() { field.cursor = next_word_boundary(&field.text, field.cursor); } ModalInputOutcome::Changed } // Line start/end: Cmd+Left/Right (Super), Home/End, Ctrl+A/E. KeyEvent { code: KeyCode::Left, modifiers: KeyModifiers::SUPER, .. } | KeyEvent { code: KeyCode::Home, .. } | KeyEvent { code: KeyCode::Char('a'), modifiers: KeyModifiers::CONTROL, .. } => { if let Some(field) = self.focused_field_mut() { field.cursor = 0; } ModalInputOutcome::Changed } KeyEvent { code: KeyCode::Right, modifiers: KeyModifiers::SUPER, .. } | KeyEvent { code: KeyCode::End, .. } | KeyEvent { code: KeyCode::Char('e'), modifiers: KeyModifiers::CONTROL, .. } => { if let Some(field) = self.focused_field_mut() { field.cursor = field.text.len(); } ModalInputOutcome::Changed } // Single-char cursor movement. KeyEvent { code: KeyCode::Left, .. } => { if let Some(field) = self.focused_field_mut() && field.cursor > 0 { let prev = field.text[..field.cursor] .char_indices() .next_back() .map(|(i, _)| i) .unwrap_or(0); field.cursor = prev; } ModalInputOutcome::Changed } KeyEvent { code: KeyCode::Right, .. } => { if let Some(field) = self.focused_field_mut() && field.cursor < field.text.len() { let next = field.text[field.cursor..] .char_indices() .nth(1) .map(|(i, _)| field.cursor + i) .unwrap_or(field.text.len()); field.cursor = next; } ModalInputOutcome::Changed } // Clipboard paste (Ctrl+V fallback). KeyEvent { code: KeyCode::Char('v'), modifiers: KeyModifiers::CONTROL, .. } => { if let Some(field) = self.focused_field_mut() && let Some(clip) = crate::clipboard::system_clipboard_get() && field.insert_paste(&clip) { ModalInputOutcome::Changed } else { ModalInputOutcome::Unchanged } } // Plain character insertion (no CONTROL/ALT/SUPER modifier). KeyEvent { code: KeyCode::Char(c), modifiers, .. } if !modifiers .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) || crate::input::key::is_altgr(*modifiers) => { if let Some(field) = self.focused_field_mut() { field.text.insert(field.cursor, *c); field.cursor += c.len_utf8(); } ModalInputOutcome::Changed } _ => ModalInputOutcome::Unchanged, } } } /// Result of processing a key event on the modal input form. #[derive(Debug)] pub enum ModalInputOutcome { /// State was modified, redraw needed. Changed, /// No state change, skip redraw. Unchanged, /// User pressed Esc, close the input form. Cancel, /// User pressed Enter and all required fields are filled. Submit { command_prefix: String, field_texts: Vec, }, } /// Modal message overlay (errors, confirmations). #[derive(Debug, Clone)] pub enum ModalMessage { /// An error message from a failed action. Any key dismisses. Error(String), /// A confirmation prompt. Stores the action to replay with confirmed=true. Confirmation { message: String, action: kigi_hooks_plugins_types::PluginsAction, }, } /// A rendered button's hit area and associated action. #[derive(Debug, Clone)] pub struct ButtonArea { pub rect: Rect, pub action: ButtonAction, pub key: char, } /// Transient, non-covering feedback shown after an extensions action /// completes, so the list (the surface that actually changed — a bumped /// version, a refreshed list) stays visible. Auto-expires via a per-tick /// countdown, mirroring `AgentView::toast`. #[derive(Debug, Clone)] pub struct ActionResultNotice { /// Full result text (used verbatim for the tab-wide status line). pub message: String, /// Row to anchor a badge to; `None` renders a tab-wide status line. pub entry_index: Option, /// Remaining animation ticks before auto-dismiss. pub ticks_remaining: u16, } /// How long a result notice stays on screen, in animation ticks (~2.5s at 30fps). pub const RESULT_NOTICE_TICKS: u16 = 75; /// Single source of truth for the McpServers tab action keys. Consumed by /// the renderer (hint bar), the picker (`PickerConfig::action_keys`), and /// `resolve_key` (must have a matching arm for every entry). pub const MCP_SERVERS_ACTION_KEYS: &[(char, &str)] = &[ ('r', "refresh"), ('a', "add"), ('i', "auth"), (' ', "toggle"), ('x', "remove"), ]; /// Footer label for the MCP tab Ctrl+O shortcut (not in [`MCP_SERVERS_ACTION_KEYS`]). pub const MCP_SERVERS_OPEN_CONNECTORS_FOOTER: &str = "ctrl-o open"; /// Map an action key character to its display string for shortcut hints. /// /// Single source of truth shared by [`render_extensions_modal`] (footer /// shortcuts) and [`crate::views::picker::render_picker`] (hint bar). /// Returns `""` for unmapped characters. pub fn action_key_display(ch: char) -> &'static str { match ch { ' ' => "space", 'a' => "a", 'd' => "d", 'e' => "e", 'f' => "f", 'i' => "i", 'o' => "o", 'r' => "r", 'u' => "u", 'x' => "x", _ => "", } } /// Per-tab action keys for the extensions modal (footer, picker, telemetry). /// /// Space stays labeled `"toggle"` on the wire for telemetry / picker identity; /// user-facing copy remaps via [`action_key_footer_desc`] / /// [`action_key_cheatsheet_desc`]. pub fn extensions_action_keys(tab: ExtensionsTab) -> Vec<(char, &'static str)> { match tab { ExtensionsTab::Hooks => vec![ ('r', "reload"), ('a', "add"), (' ', "toggle"), ('x', "remove"), ], ExtensionsTab::Plugins => vec![ ('r', "reload"), ('u', "update"), ('a', "install"), (' ', "toggle"), ('x', "uninstall"), ], ExtensionsTab::Skills => vec![(' ', "toggle"), ('f', "filter"), ('r', "reload")], ExtensionsTab::McpServers => MCP_SERVERS_ACTION_KEYS.to_vec(), } } /// Footer verb for an action key. Uses the state's published /// `entry_data_indices` / `entry_group_keys` (input handling, unit tests). pub fn action_key_footer_desc( ch: char, desc: &'static str, state: &ExtensionsModalState, ) -> &'static str { action_key_footer_desc_for_mapping( ch, desc, state, &state.entry_data_indices, &state.entry_group_keys, state.picker_state.selected, ) } /// Like [`action_key_footer_desc`], but resolves the Space enable/disable verb /// from freshly built entry-mapping slices (render path) so we need not /// publish `state.entry_*` before paint. fn action_key_footer_desc_for_mapping( ch: char, desc: &'static str, state: &ExtensionsModalState, entry_data_indices: &[Option], entry_group_keys: &[Option], selected: usize, ) -> &'static str { if ch == ' ' && desc == "toggle" { match selected_item_enabled_at(state, entry_data_indices, entry_group_keys, selected) { Some(true) => "disable", Some(false) => "enable", None => "enable/disable", } } else { desc } } pub fn action_key_cheatsheet_desc(ch: char, desc: &'static str) -> &'static str { if ch == ' ' && desc == "toggle" { "enable/disable" } else { desc } } fn data_index_at(entry_data_indices: &[Option], selected: usize) -> Option { entry_data_indices.get(selected).copied().flatten() } /// Resolve picker selection to `(server_index, tool_index)` for an MCP tool row, /// using the given entry-mapping slices (not necessarily yet published on `state`). fn selected_mcp_tool_at( entry_data_indices: &[Option], entry_group_keys: &[Option], selected: usize, ) -> Option<(usize, usize)> { if entry_group_keys.get(selected)?.is_some() { return None; } let parent_si = data_index_at(entry_data_indices, selected)?; let parent_pos = (0..selected).rev().find(|&i| { entry_group_keys .get(i) .and_then(|k| k.as_ref()) .is_some_and(|k| k.starts_with("mcp-tools:")) })?; Some((parent_si, selected - parent_pos - 1)) } /// Whether the selected row is enabled, using the given entry-mapping slices /// so the render path can resolve Space enable/disable without an early /// `state.entry_*` publish. fn selected_item_enabled_at( state: &ExtensionsModalState, entry_data_indices: &[Option], entry_group_keys: &[Option], selected: usize, ) -> Option { match state.active_tab { ExtensionsTab::Plugins => { let idx = data_index_at(entry_data_indices, selected)?; match &state.plugins_data { TabDataState::Loaded(data) => data.plugins.get(idx).map(|p| p.enabled), _ => None, } } ExtensionsTab::Hooks => { let idx = data_index_at(entry_data_indices, selected)?; match &state.hooks_data { TabDataState::Loaded(data) => { let hook = data.hooks.get(idx)?; if state.hooks_collapsed_groups.contains(&hook.source_dir) { Some( data.hooks .iter() .filter(|h| h.source_dir == hook.source_dir) .any(|h| !h.disabled), ) } else { Some(!hook.disabled) } } _ => None, } } ExtensionsTab::Skills => { let idx = data_index_at(entry_data_indices, selected)?; match &state.skills_data { TabDataState::Loaded(skills) => skills.get(idx).map(|s| s.enabled), _ => None, } } ExtensionsTab::McpServers => match &state.mcps_data { TabDataState::Loaded(servers) => { if let Some((si, ti)) = selected_mcp_tool_at(entry_data_indices, entry_group_keys, selected) { return servers .get(si) .and_then(|s| s.tools.get(ti)) .map(|t| t.enabled); } let idx = data_index_at(entry_data_indices, selected)?; servers.get(idx).map(|s| s.enabled) } _ => None, }, } } /// Build the full list of hint items for a given extensions tab (for the /// current section to surface all tab action keys, not just the compact /// subset shown in the bottom bar). pub fn tab_all_hints(tab: ExtensionsTab) -> Vec { use crate::input::key::KeyShortcut; use crate::views::shortcuts_bar::HintItem; use crossterm::event::{KeyCode, KeyModifiers}; let mut hints: Vec = Vec::new(); for (ch, label) in extensions_action_keys(tab) { let display_key = KeyShortcut::new(KeyCode::Char(ch), KeyModifiers::NONE); let mut item = HintItem::new(display_key, action_key_cheatsheet_desc(ch, label)); if ch == ' ' { item.custom_display = Some("Space"); } hints.push(item); } // Common navigation. hints.push(HintItem::paired(crate::key!('j'), crate::key!('k'), "nav")); hints.push(HintItem::new(crate::key!(Tab), "switch tab")); hints.push(HintItem::new(crate::key!('/'), "search")); hints.push(HintItem::new(crate::key!(Enter), "expand")); hints.push(HintItem::new(crate::key!(Esc), "close")); hints } /// Resolve a key press to a button action based on the active tab. pub fn resolve_key(tab: ExtensionsTab, ch: char) -> Option { use kigi_hooks_plugins_types::{HooksAction, PluginsAction}; match (tab, ch) { // Plugins tab (ExtensionsTab::Plugins, 'r') => Some(ButtonAction::PluginsAction(PluginsAction::Reload)), // Update (fetch latest from source) the selected installed plugin. (ExtensionsTab::Plugins, 'u') => Some(ButtonAction::UpdateSelectedPlugin), (ExtensionsTab::Plugins, 'a') => Some(ButtonAction::StartInput { command_prefix: "plugins_install".into(), fields: vec![FieldSpec { label: "Source".into(), required: true, placeholder: Some("owner/repo, URL, or local path".into()), }], }), // Toggle enable/disable on the selected plugin. (ExtensionsTab::Plugins, ' ') => Some(ButtonAction::ToggleSelectedPlugin), (ExtensionsTab::Plugins, 'x') => Some(ButtonAction::UninstallSelectedPlugin), // Hooks tab (ExtensionsTab::Hooks, 'r') => Some(ButtonAction::HooksAction(HooksAction::Reload)), (ExtensionsTab::Hooks, 'a') => Some(ButtonAction::StartInput { command_prefix: "hooks_add".into(), fields: vec![FieldSpec { label: "Path".into(), required: true, placeholder: None, }], }), // Remove acts on the selected hook — resolved at dispatch time. (ExtensionsTab::Hooks, 'x') => Some(ButtonAction::RemoveSelectedHook), // Toggle enable/disable on the selected hook. (ExtensionsTab::Hooks, ' ') => Some(ButtonAction::ToggleSelectedHook), (ExtensionsTab::Skills, ' ') => Some(ButtonAction::ToggleSelectedSkill), (ExtensionsTab::Skills, 'r') => Some(ButtonAction::ReloadSkills), (ExtensionsTab::Skills, 'f') => Some(ButtonAction::CycleFilter), (ExtensionsTab::McpServers, 'a') => Some(ButtonAction::StartInput { command_prefix: "mcp_add".into(), // URL is required, Name is optional (auto-derived from URL), // so URL goes first to match the natural typing order. // `build_action_from_input` reads matching indices. fields: vec![ FieldSpec { label: "URL / Command".into(), required: true, placeholder: Some("https://... or command [args...]".into()), }, FieldSpec { label: "Name".into(), required: false, placeholder: Some("Auto generated by URL".into()), }, ], }), (ExtensionsTab::McpServers, 'x') => Some(ButtonAction::RemoveSelectedMcpServer), (ExtensionsTab::McpServers, 'r') => Some(ButtonAction::RefreshMcpList), (ExtensionsTab::McpServers, ' ') => Some(ButtonAction::ToggleSelectedMcpServer), (ExtensionsTab::McpServers, 'i') => Some(ButtonAction::McpAuthTrigger), (ExtensionsTab::Hooks, 'f') => Some(ButtonAction::CycleFilter), (ExtensionsTab::Plugins, 'f') => Some(ButtonAction::CycleFilter), (ExtensionsTab::McpServers, 'f') => Some(ButtonAction::CycleFilter), _ => None, } } /// Tab-complete a partial path by listing directory entries. /// /// Expands `~` to home directory. If the partial path is a directory, /// lists its contents. If it's a partial filename, finds matching entries /// in the parent directory. Returns the longest common prefix among matches, /// or `None` if no matches or the path doesn't exist. pub fn tab_complete_path(partial: &str) -> Option { use std::path::Path; if partial.is_empty() { return None; } // Expand ~ to home directory. let expanded = if let Some(rest) = partial.strip_prefix('~') { let home = dirs::home_dir()?; if rest.is_empty() || rest == "/" { home.to_string_lossy().to_string() + "/" } else { home.join(rest.strip_prefix('/').unwrap_or(rest)) .to_string_lossy() .to_string() } } else { partial.to_string() }; let path = Path::new(&expanded); // If path is an existing directory (ends with /), list its contents. if path.is_dir() && expanded.ends_with('/') { let mut entries: Vec = std::fs::read_dir(path) .ok()? .filter_map(|e| e.ok()) .filter(|e| !e.file_name().to_string_lossy().starts_with('.')) .map(|e| { let name = e.file_name().to_string_lossy().to_string(); let full = path.join(&name); if full.is_dir() { format!("{expanded}{name}/") } else { format!("{expanded}{name}") } }) .collect(); entries.sort(); if entries.len() == 1 { return Some(entries.into_iter().next().unwrap()); } if entries.len() > 1 { return Some(longest_common_prefix(&entries)); } return None; } // Partial filename: complete in parent directory. let parent = path.parent()?; let prefix = path.file_name()?.to_string_lossy().to_string(); if !parent.is_dir() { return None; } let mut matches: Vec = std::fs::read_dir(parent) .ok()? .filter_map(|e| e.ok()) .filter(|e| { let name = e.file_name().to_string_lossy().to_string(); name.starts_with(&prefix) && !name.starts_with('.') }) .map(|e| { let name = e.file_name().to_string_lossy().to_string(); let full = parent.join(&name); let parent_str = if expanded.contains('/') { expanded .rsplit_once('/') .map(|(p, _)| format!("{p}/")) .unwrap_or_default() } else { String::new() }; if full.is_dir() { format!("{parent_str}{name}/") } else { format!("{parent_str}{name}") } }) .collect(); matches.sort(); if matches.is_empty() { return None; } if matches.len() == 1 { return Some(matches.into_iter().next().unwrap()); } Some(longest_common_prefix(&matches)) } /// Find the longest common prefix among a sorted list of strings. fn longest_common_prefix(strings: &[String]) -> String { if strings.is_empty() { return String::new(); } let first = &strings[0]; let last = &strings[strings.len() - 1]; first .chars() .zip(last.chars()) .take_while(|(a, b)| a == b) .map(|(a, _)| a) .collect() } // --------------------------------------------------------------------------- // Word boundary helpers (for readline-style editing in modal input fields) // --------------------------------------------------------------------------- /// Byte offset of the start of the previous word. /// /// Skips whitespace backward from `cursor`, then skips non-whitespace backward. /// Returns 0 if already at the beginning. pub fn prev_word_boundary(text: &str, cursor: usize) -> usize { let before = &text[..cursor]; let mut iter = before.char_indices().rev(); // Phase 1: skip whitespace. let mut pos = cursor; for (i, c) in iter.by_ref() { if !c.is_whitespace() { pos = i; break; } pos = i; } if pos == cursor && cursor > 0 { // Entire prefix was whitespace (or empty). return 0; } // Phase 2: skip non-whitespace. for (i, c) in iter { if c.is_whitespace() { return i + c.len_utf8(); } } 0 } /// Byte offset of the end of the next word. /// /// Skips whitespace forward from `cursor`, then skips non-whitespace forward. /// Returns `text.len()` if already at the end. pub fn next_word_boundary(text: &str, cursor: usize) -> usize { let after = &text[cursor..]; let mut iter = after.char_indices(); // Phase 1: skip whitespace. let mut offset = after.len(); for (i, c) in iter.by_ref() { if !c.is_whitespace() { offset = i; break; } } if offset == after.len() { return text.len(); } // Phase 2: skip non-whitespace. for (i, c) in iter { if c.is_whitespace() { return cursor + i; } } text.len() } /// Collect characters from `text` until the accumulated display width /// reaches `max_w`. Prevents wide characters (CJK, emoji) from /// overflowing a fixed-width column. fn take_by_width(text: &str, max_w: usize) -> String { let mut w = 0; text.chars() .take_while(|c| { let cw = unicode_width::UnicodeWidthChar::width(*c).unwrap_or(0); if w + cw > max_w { return false; } w += cw; true }) .collect() } /// Build a typed action from a multi-field form submission. /// /// `field_texts` contains one entry per field in submission order. /// Single-field forms pass a 1-element slice; MCP add passes /// `[url_or_command, name]` — URL first to match the on-screen field /// order (URL is required, Name is optional / auto-derived). pub fn build_action_from_input( command_prefix: &str, field_texts: &[String], ) -> Option { use kigi_hooks_plugins_types::{HooksAction, PluginsAction}; let first = field_texts.first().map(|s| s.trim()).unwrap_or(""); match command_prefix { "plugins_install" => Some(ButtonAction::PluginsAction(PluginsAction::Install { source: first.to_string(), })), "plugins_uninstall" => Some(ButtonAction::PluginsAction(PluginsAction::Uninstall { plugin_id: first.to_string(), confirmed: false, })), "hooks_add" => Some(ButtonAction::HooksAction(HooksAction::Add { path: first.to_string(), })), "hooks_remove" => Some(ButtonAction::HooksAction(HooksAction::Remove { path: first.to_string(), })), "mcp_add" => { // Field order: [URL / Command, Name]. URL is required. let url_or_cmd = first.to_string(); let name = field_texts .get(1) .map(|s| s.trim()) .unwrap_or_default() .to_string(); if url_or_cmd.is_empty() { return None; } parse_mcp_add_fields(&name, &url_or_cmd) } _ => None, } } /// Derive a server name from a URL by extracting a meaningful hostname segment. /// /// `https://mcp.linear.app/mcp` -> `linear` /// `https://example.com/mcp` -> `example` fn derive_name_from_url(url: &str) -> String { url.split("://") .nth(1) .unwrap_or(url) .split('/') .next() .unwrap_or("server") .split('.') .find(|seg| !seg.is_empty() && *seg != "mcp" && *seg != "www") .unwrap_or("server") .to_string() } /// Parse MCP add from separate name and url/command fields. /// /// If `name` is empty, derives a name from the URL hostname. /// The `url_or_cmd` field is split on whitespace to extract the command /// and any trailing args for stdio transport. fn parse_mcp_add_fields(name: &str, url_or_cmd: &str) -> Option { use kigi_shell::util::config::{McpServerConfig, McpServerTransportConfig}; let mut parts = url_or_cmd.split_whitespace(); let command_or_url = parts.next()?; let rest: Vec = parts.map(String::from).collect(); let is_url = command_or_url.starts_with("http://") || command_or_url.starts_with("https://"); let name = if name.is_empty() { if is_url { derive_name_from_url(command_or_url) } else { command_or_url.to_string() } } else { name.to_string() }; let transport = if is_url { McpServerTransportConfig::StreamableHttp { url: command_or_url.to_string(), transport_type: None, bearer_token_env_var: None, headers: None, oauth_client_id: None, oauth_client_secret_env_var: None, oauth_scopes: None, } } else { McpServerTransportConfig::Stdio { command: command_or_url.to_string(), args: rest, env: None, cwd: None, } }; Some(ButtonAction::AddMcpServer { name, config: Box::new(McpServerConfig { transport, enabled: true, oauth: None, startup_timeout_sec: None, tool_timeout_sec: None, tool_timeouts: None, expose_image_base64: None, }), }) } // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- /// Per-tab data fetching lifecycle. #[derive(Debug)] pub enum TabDataState { /// Fetch in progress (or not yet started). Loading, /// Data loaded successfully. Loaded(T), /// Fetch failed. Error(String), } /// State for the hooks/plugins modal popup. pub struct ExtensionsModalState { /// Shared modal window chrome state (close button, tabs, footer /// shortcuts, popup area). Replaces the former `last_popup_area`, /// `tab_areas`, `close_button_area`, `close_hovered` fields. pub window: ModalWindowState, /// Currently active tab (source of truth). /// /// `window.active_tab` (a `usize` index) is derived from this in the /// render path via `ExtensionsTab::ALL.position()`. Only this field /// should be mutated by input handlers; the window's copy is a /// rendering hint synced each frame. pub active_tab: ExtensionsTab, /// Hooks list data (fetched from shell). pub hooks_data: TabDataState, /// Plugins list data (fetched from shell). pub plugins_data: TabDataState, /// Cached button hit areas from last render (for mouse click). pub button_areas: Vec, /// Active inline input (when the user is typing an argument for a command). /// `None` = normal button mode, `Some` = input mode. pub input: Option, /// Modal message state (error, confirmation prompt, etc.). pub modal_message: Option, /// Description of an in-flight action (blocks buttons while set). pub pending_action: Option, /// Picker entry index with an in-flight action (shown as inline badge). /// Not invalidated on entry-list changes (filter/refresh/tab-switch); /// out-of-range is skipped at render time, but a stale-but-in-range /// index can decorate the wrong row. pub pending_entry_index: Option, /// Transient result feedback shown after an action succeeds: a right-aligned /// badge on `entry_index`'s row, or a tab-wide footer line when `None`. pub result_notice: Option, /// Last dispatched plugins action (for confirmation replay). pub last_plugins_action: Option, /// Selected item index per tab (for j/k navigation). /// Maps visible row offset (relative to content top) to hook index. /// Rebuilt every render; used for mouse click → hook selection. pub hooks_visible_map: Vec>, pub hooks_selected: usize, pub plugins_selected: usize, /// Scroll offset per tab. pub hooks_scroll: usize, pub plugins_scroll: usize, /// Skills tab state. pub skills_data: TabDataState>, pub skills_selected: usize, pub skills_scroll: usize, /// MCP servers tab state. pub mcps_data: TabDataState>, /// Last selection that triggered auto-scroll. Prevents mouse scroll /// from being overridden by auto-scroll on every render. pub mcps_scroll_pinned_selection: Option, pub mcps_scroll: usize, /// Expanded MCP server tool lists (by raw catalog `si`, not picker row index). pub mcps_tools_expanded: std::collections::HashSet, /// Collapsed MCP section headers (`mcp-section:*` keys). Key in set = collapsed. pub mcps_collapsed_sections: std::collections::HashSet, /// Whether plugin section collapse defaults have been applied after first load. pub mcps_section_collapse_initialized: bool, /// Maps visible row offset to skill index (for mouse click). pub skills_visible_map: Vec>, pub hooks_collapsed_groups: std::collections::HashSet, /// Collapsed plugin source groups (by [`PluginGroup`] key). pub plugins_collapsed_groups: std::collections::HashSet, /// See [`Self::seed_plugin_groups_once`]. pub plugins_groups_seeded: bool, /// Expanded skill entries (by skill index). Skills start collapsed. pub skills_expanded: std::collections::HashSet, /// Status filter for the plugins tab. pub plugins_filter: StatusFilter, /// Status filter for the MCP servers tab. pub mcps_filter: StatusFilter, /// Status filter for the hooks tab. pub hooks_filter: StatusFilter, /// Status filter for the skills tab. pub skills_filter: StatusFilter, /// Unified picker state for tabs managed by `render_picker_content`. /// Search query and search_active live here (previously duplicated). pub picker_state: picker::PickerState, /// Maps picker entry index → original data index (for action dispatch). /// Rebuilt every render. `None` for headers or error entries. pub entry_data_indices: Vec>, /// Cached entry labels from last render (for group header identification in input handler). pub entry_labels_cache: Vec, /// Maps picker entry index → group key for collapse/expand. /// For hooks: the source_dir string. For plugins: the [`PluginGroup`] /// key. `None` for non-group entries. pub entry_group_keys: Vec>, /// Per-entry keyboard/mouse selectability (rebuilt each render). pub entry_non_selectable: Vec, /// MCP section labels: not selectable, but clickable to fold/unfold. pub entry_non_selectable_clickable: Vec, } impl Default for ExtensionsModalState { fn default() -> Self { Self::new(ExtensionsTab::Hooks) } } impl ExtensionsModalState { /// Create a new modal state with the given initial tab. pub fn new(tab: ExtensionsTab) -> Self { Self { window: ModalWindowState::with_tabs(ExtensionsTab::ALL.len()), active_tab: tab, hooks_data: TabDataState::Loading, plugins_data: TabDataState::Loading, button_areas: Vec::new(), input: None, modal_message: None, pending_action: None, pending_entry_index: None, result_notice: None, last_plugins_action: None, hooks_visible_map: Vec::new(), hooks_selected: 0, plugins_selected: 0, hooks_scroll: 0, plugins_scroll: 0, skills_data: TabDataState::Loading, skills_selected: 0, skills_scroll: 0, mcps_data: TabDataState::Loading, mcps_scroll_pinned_selection: None, mcps_scroll: 0, mcps_tools_expanded: std::collections::HashSet::new(), // Plugin sections are seeded collapsed on first MCP load (Local // stays expanded by default for a less noisy initial view). // Section headers are keyboard-selectable so j/k lands on them // and Enter / l / Right re-expands a collapsed section. mcps_collapsed_sections: std::collections::HashSet::new(), mcps_section_collapse_initialized: false, skills_visible_map: Vec::new(), skills_expanded: std::collections::HashSet::new(), hooks_collapsed_groups: std::collections::HashSet::new(), plugins_collapsed_groups: std::collections::HashSet::new(), plugins_groups_seeded: false, plugins_filter: StatusFilter::default(), mcps_filter: StatusFilter::default(), hooks_filter: StatusFilter::default(), skills_filter: StatusFilter::default(), // PickerState mode is vestigial — ModalWindow handles framing. picker_state: picker::PickerState::default(), entry_data_indices: Vec::new(), entry_labels_cache: Vec::new(), entry_group_keys: Vec::new(), entry_non_selectable: Vec::new(), entry_non_selectable_clickable: Vec::new(), } } /// Advance the result-notice countdown by one animation tick. Returns /// `true` if it just expired (a redraw is needed to erase it). pub fn tick_result_notice(&mut self) -> bool { if let Some(ref mut n) = self.result_notice { if n.ticks_remaining == 0 { self.result_notice = None; return true; } n.ticks_remaining = n.ticks_remaining.saturating_sub(1); } false } /// Switch to a different tab and reset the per-tab transient UI state. /// /// Anything tied to the previous tab's data indices or modal flow /// (the Add form, an error/confirmation overlay, an in-flight /// `[processing]` badge, the picker selection / scroll / expansion /// state) is cleared so the new tab opens in a clean browse view. /// The user's search query (`picker_state.query`) is intentionally /// preserved across tabs — current behavior elsewhere in the modal. pub fn switch_tab(&mut self, tab: ExtensionsTab) { self.active_tab = tab; // Clear modal flow state from the previous tab. self.input = None; self.modal_message = None; self.pending_action = None; self.pending_entry_index = None; self.result_notice = None; // Reset picker selection/scroll/expansion for the new tab. // (Note: tabs_focused is *not* cleared here — it is orthogonal focus // state for the tab bar itself. L/R-driven tab switches want to keep // the bar focused so the user can continue cycling with arrows.) self.picker_state.selected = 0; self.picker_state.scroll_offset = None; self.picker_state.expanded.clear(); self.mcps_tools_expanded.clear(); self.picker_state.hovered = None; } /// Whether a group header at picker index `sel` with the given /// `group_key` is currently expanded (children visible). /// /// The answer depends on the active tab: Hooks use /// Seed the all-collapsed default for plugin source groups exactly once. /// /// Called from both plugin-data delivery channels (list fetch and the /// `PluginsChanged` push); the first to deliver seeds, later deliveries /// preserve the user's expand state. pub fn seed_plugin_groups_once(&mut self, plugins: &[kigi_hooks_plugins_types::PluginInfo]) { if self.plugins_groups_seeded { return; } self.plugins_collapsed_groups = plugins.iter().map(|p| plugin_group(p).key).collect(); self.plugins_groups_seeded = true; } /// `hooks_collapsed_groups`, Plugins use `plugins_collapsed_groups`, /// and other tabs use `picker_state.expanded`. pub fn is_group_expanded(&self, sel: usize, group_key: &str) -> bool { let searching = !self.picker_state.query.is_empty(); match self.active_tab { // During active search we force all hook groups open so matches // inside previously-collapsed groups are visible. ExtensionsTab::Hooks => searching || !self.hooks_collapsed_groups.contains(group_key), ExtensionsTab::Plugins => { searching || !self.plugins_collapsed_groups.contains(group_key) } ExtensionsTab::McpServers => { if group_key.starts_with("mcp-section:") { searching || !self.mcps_collapsed_sections.contains(group_key) } else if let Some(si) = parse_mcp_tools_server_index(group_key) { self.mcps_tools_expanded.contains(&si) } else { false } } _ => self.picker_state.expanded.contains(&sel), } } /// Apply pasted text to the focused input field or the search query. /// /// Strips `\n` and `\r`. Returns `true` if any state was modified. pub fn apply_paste(&mut self, text: &str) -> bool { if let Some(ref mut input) = self.input { let Some(field) = input.fields.get_mut(input.focused) else { return false; }; field.insert_paste(text) } else if self.picker_state.search_active { let cleaned: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect(); if cleaned.is_empty() { return false; } self.picker_state .query .insert_str(self.picker_state.query_cursor, &cleaned); self.picker_state.query_cursor += cleaned.len(); true } else { false } } /// Resolve the current picker selection to the original data index. /// Returns `None` if the selection is on a header or out of range. pub fn selected_data_index(&self) -> Option { data_index_at(&self.entry_data_indices, self.picker_state.selected) } pub fn selected_item_enabled(&self) -> Option { selected_item_enabled_at( self, &self.entry_data_indices, &self.entry_group_keys, self.picker_state.selected, ) } /// Resolve the picker selection to `(server_index, tool_index)` when the /// cursor is on an MCP tool row. Returns `None` on server rows, error/ /// loading entries, or out-of-range. Caller must be on the McpServers tab /// — the helper relies on no other tab using `"mcp-tools:"` as a group-key prefix. pub fn selected_mcp_tool(&self) -> Option<(usize, usize)> { selected_mcp_tool_at( &self.entry_data_indices, &self.entry_group_keys, self.picker_state.selected, ) } /// True when the user is expanding an auth-required server's tool list (OAuth /// should run instead of fold). False when collapsing or when tools are already /// expanded. pub fn mcp_auth_intercept_on_expand(&self) -> bool { if self.active_tab != ExtensionsTab::McpServers { return false; } let Some(group_key) = self .entry_group_keys .get(self.picker_state.selected) .and_then(|k| k.as_ref()) else { return false; }; let Some(si) = parse_mcp_tools_server_index(group_key) else { return false; }; if self.mcps_tools_expanded.contains(&si) { return false; } matches!( &self.mcps_data, TabDataState::Loaded(servers) if { servers.get(si).is_some_and(|srv| srv.auth_required) } ) } } /// Parse raw server index from an `mcp-tools:{si}` group key. pub(crate) fn parse_mcp_tools_server_index(group_key: &str) -> Option { group_key.strip_prefix("mcp-tools:")?.parse().ok() } /// Build the picker non-selectable mask (static headers). /// /// MCP section labels (`mcp-section:*`) are keyboard-selectable so j/k can /// land on them and Enter / l / Right toggles their collapsed state — the /// only way to expand a section once it has been collapsed. pub fn build_entry_non_selectable( entry_is_header: &[bool], _entry_group_keys: &[Option], ) -> Vec { entry_is_header.to_vec() } /// MCP section labels are now keyboard-selectable, so no rows need the /// "non-selectable but clickable" treatment. Kept as a function so callers /// can continue to pass a slice to the picker without per-call allocation /// changes; the returned mask is all `false`. pub fn build_entry_non_selectable_clickable(entry_group_keys: &[Option]) -> Vec { vec![false; entry_group_keys.len()] } /// Picker rows built for the MCP servers tab (labels + mapping only). /// /// Used by the full extensions-modal tests and by minimal mode's below-prompt /// MCP list (`crate::minimal::panel`), which reuses this exact ordering so the /// shared `picker_state.selected` (driven by the unchanged input handler) lines /// up with the rendered rows. #[derive(Debug, Default)] pub(crate) struct McpServersPickerRows { pub(crate) labels: Vec, pub(crate) group_keys: Vec>, pub(crate) data_indices: Vec>, } /// Build MCP picker rows (section headers, servers, optional tool children). pub(crate) fn build_mcp_servers_picker_rows( servers: &[crate::views::mcps_modal::McpServerInfo], query: &str, filter: StatusFilter, collapsed_sections: &std::collections::HashSet, tools_expanded: &std::collections::HashSet, ) -> McpServersPickerRows { use crate::views::mcps_modal::{McpSectionId, section_for, section_key, section_label}; let searching = !query.is_empty(); let mut sections: std::collections::BTreeMap< McpSectionId, Vec<(usize, &crate::views::mcps_modal::McpServerInfo)>, > = std::collections::BTreeMap::new(); for (si, server) in servers.iter().enumerate() { let display_name = server.display_name.as_deref().unwrap_or(&server.name); if !fuzzy_matches(display_name, query) { continue; } if !filter.matches(server.enabled) { continue; } sections .entry(section_for(server)) .or_default() .push((si, server)); } let mut out = McpServersPickerRows::default(); for (section_id, section_servers) in §ions { let sec_key = section_key(section_id); let section_collapsed = mcp_section_children_hidden(collapsed_sections, &sec_key, searching); out.labels .push(section_label(section_id, section_servers.len())); out.data_indices.push(None); out.group_keys.push(Some(sec_key)); if section_collapsed { continue; } for &(si, server) in section_servers { out.labels.push( server .display_name .as_deref() .unwrap_or(&server.name) .to_string(), ); out.data_indices.push(Some(si)); out.group_keys.push(Some(format!("mcp-tools:{si}"))); if tools_expanded.contains(&si) { for t in &server.tools { out.labels .push(t.display_name.clone().unwrap_or_else(|| t.name.clone())); out.data_indices.push(Some(si)); out.group_keys.push(None); } } } } out } /// On first MCP list load, collapse each distinct plugin section by default. pub(crate) fn init_mcps_section_collapse_on_first_load( collapsed_sections: &mut std::collections::HashSet, initialized: &mut bool, servers: &[crate::views::mcps_modal::McpServerInfo], ) { if *initialized { return; } use crate::views::mcps_modal::{McpSectionId, section_for, section_key}; for server in servers { if let McpSectionId::Plugin(ref name) = section_for(server) { collapsed_sections.insert(section_key(&McpSectionId::Plugin(name.clone()))); } } *initialized = true; } /// Whether an MCP section's child servers are hidden (mirrors render logic). pub(crate) fn mcp_section_children_hidden( collapsed_sections: &std::collections::HashSet, section_key: &str, searching: bool, ) -> bool { !searching && collapsed_sections.contains(section_key) } /// Derive a display label and whether the source is a custom (removable) path. /// /// Returns `(label, is_custom)` where `is_custom` means the source was added /// via hooks-paths and can be removed. pub fn derive_source_label(source_dir: &str) -> (String, bool) { let kigi = kigi_config::kigi_home(); let source_path = std::path::Path::new(source_dir); // Plugin / installed-plugin dirs, under the user kigi home (KIGI_SHARE_DIR-aware) // or a project-scoped `{cwd}/.kigi//`. Returns the first path // component after the subdir (the plugin's install directory name). let plugin_name = |subdir: &str| -> Option { let first_comp = |p: &std::path::Path| { p.components() .next() .map(|c| c.as_os_str().to_string_lossy().into_owned()) .filter(|s| !s.is_empty()) }; // User kigi home (KIGI_SHARE_DIR-aware). if let Ok(rest) = source_path.strip_prefix(kigi.join(subdir)) && let Some(name) = first_comp(rest) { return Some(name); } // Project-scoped `.kigi//` anywhere in the path. // Component-based so it works regardless of path separator. let comps: Vec<_> = source_path .components() .map(|c| c.as_os_str().to_string_lossy().into_owned()) .collect(); comps .windows(3) .find(|w| w[0] == ".kigi" && w[1] == subdir && !w[2].is_empty()) .map(|w| w[2].clone()) }; if let Some(name) = plugin_name("plugins").or_else(|| plugin_name("installed-plugins")) { return (format!("Plugin: {name}"), false); } // Global hooks under $KIGI_SHARE_DIR/hooks let global_hooks = kigi.join("hooks"); let global_str = global_hooks.display().to_string(); if source_dir == global_str || source_dir.starts_with(&format!("{global_str}/")) { return ("Global hooks".into(), false); } // Settings under .claude/ if source_dir.contains("/.claude/") { return ("Claude settings".into(), false); } // Project hooks if source_dir.ends_with("/.kigi/hooks") || source_dir.contains("/.kigi/hooks/") { return ("Project hooks".into(), false); } // Custom directory — removable let display = { if let Ok(rest) = source_path.strip_prefix(&kigi) { let prefix = crate::util::display_kigi_home_prefix(); let rest_str = rest.to_string_lossy(); let rest_trimmed = rest_str.strip_prefix('/').unwrap_or(&rest_str); format!("Custom: {prefix}/{rest_trimmed}") } else if let Some(home) = dirs::home_dir() { let home_str = home.display().to_string(); source_dir .strip_prefix(&home_str) .map(|rest| format!("Custom: ~{rest}")) .unwrap_or_else(|| format!("Custom: {source_dir}")) } else { format!("Custom: {source_dir}") } }; (display, true) } // --------------------------------------------------------------------------- // Entry builders — convert tab data into Vec for render_picker // --------------------------------------------------------------------------- /// Data needed to build entries for a tab. Avoids borrow conflicts with state. struct SkillsEntryData { /// (skill_index, is_name_match) matches: Vec<(usize, bool)>, } fn filter_and_sort_skills( skills: &[SkillInfo], query: &str, filter: StatusFilter, ) -> SkillsEntryData { let mut matches: Vec<(usize, bool)> = Vec::new(); let query_lower = query.to_lowercase(); for (si, skill) in skills.iter().enumerate() { if !filter.matches(skill.enabled) { continue; } if query.is_empty() { matches.push((si, true)); } else { let desc_text = skill .short_description .as_deref() .unwrap_or(&skill.description); let desc_lower = desc_text.to_lowercase(); let author_lower = skill.author.as_deref().unwrap_or("").to_lowercase(); // Plugin skills differ in label (shown) vs name (slash id); match either. let name_hit = skill.label().to_lowercase().contains(&query_lower) || skill.name.to_lowercase().contains(&query_lower); let desc_hit = desc_lower.contains(&query_lower); let author_hit = !author_lower.is_empty() && author_lower.contains(&query_lower); if name_hit || author_hit { matches.push((si, true)); } else if desc_hit { matches.push((si, false)); } } } matches.sort_by_key(|&(_, is_name)| !is_name); SkillsEntryData { matches } } fn skill_source_str(skill: &SkillInfo) -> String { if let Some(ref cs) = skill.config_source { match cs { kigi_tools::types::config_source::ConfigSource::User { path } => { if crate::util::is_under_user_kigi_home(path) { crate::util::display_user_kigi_path("skills") } else if path.display().to_string().contains("/.claude/") { "~/.claude/skills".into() } else { "user".into() } } kigi_tools::types::config_source::ConfigSource::Project { path } => { let s = path.display().to_string(); if s.contains("/.kigi/") { ".kigi/skills".into() } else if s.contains("/.claude/") { ".claude/skills".into() } else { "project".into() } } kigi_tools::types::config_source::ConfigSource::Plugin { plugin_name, .. } => { format!("plugin: {}", plugin_name) } _ => format!("{:?}", skill.scope).to_lowercase(), } } else { format!("{:?}", skill.scope).to_lowercase() } } /// Build picker fields for an expanded plugin. fn build_plugin_fields(plugin: &kigi_hooks_plugins_types::PluginInfo) -> Vec { use kigi_hooks_plugins_types::McpStatus; let mut components = Vec::new(); if !plugin.skill_names.is_empty() { components.push(format!("skills: {}", plugin.skill_names.join(", "))); } else if plugin.skill_count > 0 { components.push(format!("{} skills", plugin.skill_count)); } if !plugin.agent_names.is_empty() { components.push(format!("agents: {}", plugin.agent_names.join(", "))); } else if plugin.agent_count > 0 { components.push(format!("{} agents", plugin.agent_count)); } if plugin.hook_count > 0 { components.push(format!("{} hooks", plugin.hook_count)); } match plugin.mcp_status { McpStatus::Active | McpStatus::ActiveInline => { components.push(format!("{} MCP servers", plugin.mcp_server_count)); } McpStatus::Blocked => { components.push(format!("{} MCP: blocked", plugin.mcp_server_count)); } McpStatus::None => {} } components } // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- /// Render the hooks/plugins modal popup as a centered overlay. /// /// Uses the shared [`ModalWindow`](super::modal_window) for chrome /// (border, title, close button, tab bar, footer shortcuts) and /// [`render_picker_content`](picker::render_picker_content) for the /// scrollable entry list inside. /// /// `full_area` is the total area available (everything above the shortcuts bar). /// Show each spinner frame for this many animation ticks. const SPINNER_DIVISOR: u64 = 4; pub fn render_extensions_modal( buf: &mut Buffer, full_area: Rect, state: &mut ExtensionsModalState, _shortcuts_area: Option, compact: bool, tick: u64, ) { let theme = Theme::current(); // Guard: if terminal is too small, bail. if full_area.width < 40 || full_area.height < 12 { state.button_areas.clear(); return; } // When switching into (or rendering) a tab while a search query is active, // force-expand all items of that tab. This ensures search filtering shows // every match explicitly, even inside groups that were collapsed before // the user switched tabs. if !state.picker_state.query.is_empty() { state.picker_state.expand_all_for_search(8192); } // Tab labels and active index. let labels: Vec<&str> = ExtensionsTab::ALL.iter().map(|t| t.label()).collect(); let active_idx = ExtensionsTab::ALL .iter() .position(|t| *t == state.active_tab) .unwrap_or(0); state.window.active_tab = active_idx; state.window.tabs_focused = state.picker_state.tabs_focused; // Determine filter for current tab. let filter = match state.active_tab { ExtensionsTab::Hooks => state.hooks_filter, ExtensionsTab::Plugins => state.plugins_filter, ExtensionsTab::McpServers => state.mcps_filter, ExtensionsTab::Skills => state.skills_filter, }; // Determine if this tab is loading. let loading = match state.active_tab { ExtensionsTab::Hooks => matches!(state.hooks_data, TabDataState::Loading), ExtensionsTab::Plugins => matches!(state.plugins_data, TabDataState::Loading), ExtensionsTab::Skills => matches!(state.skills_data, TabDataState::Loading), ExtensionsTab::McpServers => matches!(state.mcps_data, TabDataState::Loading), }; // Input mode hides the entry list (form overlay owns the content area). let in_input_mode = state.input.is_some(); // Rebuild the entry list *before* footer action labels so Space // enable/disable can use this frame's mapping (passed as locals to // `action_key_footer_desc_for_mapping`), not last frame's filter/tab/query. // ── Build PickerEntry list for current tab ── // We build owned data here and reference it for the picker. let mut entry_labels: Vec = Vec::new(); let mut entry_right_labels: Vec = Vec::new(); let mut entry_desc_lines: Vec> = Vec::new(); let mut entry_summary_lines: Vec> = Vec::new(); let mut entry_fields: Vec> = Vec::new(); let mut entry_is_header: Vec = Vec::new(); let mut entry_dimmed: Vec = Vec::new(); let mut entry_indent: Vec = Vec::new(); let mut entry_group_keys: Vec> = Vec::new(); let mut entry_badge_text: Vec = Vec::new(); let mut entry_badge_color: Vec> = Vec::new(); // Maps picker entry index → original data index (for action dispatch). let mut entry_data_indices: Vec> = Vec::new(); // Skip building entries when in input mode (render_input_form handles that). if !in_input_mode && !loading { match state.active_tab { ExtensionsTab::Skills => { if let TabDataState::Loaded(ref skills) = state.skills_data { let filtered = filter_and_sort_skills(skills, &state.picker_state.query, filter); for &(si, _) in &filtered.matches { let skill = &skills[si]; let source = skill_source_str(skill); entry_labels.push(skill.label().to_string()); let right = match &skill.author { Some(a) if !a.is_empty() => format!("({} · {})", source, a), _ => format!("({})", source), }; entry_right_labels.push(right); // Short description as description_lines. let desc = skill .short_description .as_deref() .unwrap_or(&skill.description); if desc.is_empty() { entry_desc_lines.push(vec![]); } else { entry_desc_lines.push(vec![desc.to_string()]); } entry_summary_lines.push(vec![]); // Fields for expanded view. let mut fields = vec![("path".to_string(), skill.path.clone())]; if let Some(ref a) = skill.author && !a.is_empty() { fields.push(("author".to_string(), a.clone())); } if let Some(ref tools) = skill.allowed_tools && !tools.is_empty() { fields.push(("tools".to_string(), tools.join(", "))); } entry_fields.push(fields); entry_is_header.push(false); entry_dimmed.push(!skill.enabled); entry_indent.push(0); entry_data_indices.push(Some(si)); entry_group_keys.push(None); if !skill.enabled { entry_badge_text.push("[disabled]".into()); entry_badge_color.push(Some(theme.accent_error)); } else { entry_badge_text.push(String::new()); entry_badge_color.push(None); } } } else if let TabDataState::Error(ref msg) = state.skills_data { entry_labels.push(format!("Error: {}", msg)); entry_right_labels.push(String::new()); entry_desc_lines.push(vec![]); entry_summary_lines.push(vec![]); entry_fields.push(vec![]); entry_is_header.push(false); entry_dimmed.push(false); entry_indent.push(0); entry_data_indices.push(None); entry_group_keys.push(None); entry_badge_text.push(String::new()); entry_badge_color.push(None); } } ExtensionsTab::Plugins => { if let TabDataState::Loaded(ref response) = state.plugins_data { // Group plugins by source. let mut groups = GroupedPlugins::new(); for (pi, plugin) in response.plugins.iter().enumerate() { if !fuzzy_matches(&plugin.name, &state.picker_state.query) { continue; } if !filter.matches(plugin.enabled) { continue; } let group = plugin_group(plugin); groups .entry((group.rank, group.label, group.key)) .or_default() .push((pi, plugin)); } for ((_, label, group_key), plugins) in &groups { // While searching we ignore previous collapse state so // every plugin inside the group can be seen and matched. let searching = !state.picker_state.query.is_empty(); let collapsed = !searching && state.plugins_collapsed_groups.contains(group_key); entry_labels.push(format!( "{} ({})", label, plugin_count_label(plugins.len()) )); entry_right_labels.push(String::new()); entry_desc_lines.push(vec![]); entry_summary_lines.push(vec![]); entry_fields.push(vec![]); entry_is_header.push(false); // group header, but selectable entry_dimmed.push(false); entry_indent.push(0); entry_data_indices.push(None); entry_group_keys.push(Some(group_key.clone())); entry_badge_text.push(String::new()); entry_badge_color.push(None); if collapsed { continue; } for &(pi, plugin) in plugins { let version_str = plugin .version .as_ref() .map(|v| format!(" v{v}")) .unwrap_or_default(); entry_labels.push(format!("{}{}", plugin.name, version_str)); entry_right_labels.push(String::new()); // Build description lines from components. let components = build_plugin_fields(plugin); if components.is_empty() { entry_desc_lines.push(vec![]); } else { entry_desc_lines.push(vec![components.join(" ")]); } entry_summary_lines.push(vec![]); // Fields for expanded view. let mut fields = Vec::new(); if let Some(ref desc) = plugin.description && !desc.is_empty() { fields.push(("description".to_string(), desc.clone())); } fields.push(("path".to_string(), plugin.root.clone())); entry_fields.push(fields); entry_is_header.push(false); entry_dimmed.push(!plugin.enabled); entry_indent.push(1); entry_data_indices.push(Some(pi)); entry_group_keys.push(None); entry_badge_text.push(if !plugin.enabled { "[disabled]".into() } else { String::new() }); entry_badge_color.push(if !plugin.enabled { Some(theme.accent_error) } else { None }); } } } else if let TabDataState::Error(ref msg) = state.plugins_data { entry_labels.push(format!("Error: {}", msg)); entry_right_labels.push(String::new()); entry_desc_lines.push(vec![]); entry_summary_lines.push(vec![]); entry_fields.push(vec![]); entry_is_header.push(false); entry_dimmed.push(false); entry_indent.push(0); entry_data_indices.push(None); entry_group_keys.push(None); entry_badge_text.push(String::new()); entry_badge_color.push(None); } } ExtensionsTab::Hooks => { if let TabDataState::Loaded(ref data) = state.hooks_data { // Group hooks by source_dir. let mut groups: std::collections::BTreeMap< String, Vec<(usize, &kigi_hooks_plugins_types::HookInfo)>, > = std::collections::BTreeMap::new(); for (i, hook) in data.hooks.iter().enumerate() { if !fuzzy_matches_hook(hook, &state.picker_state.query) { continue; } if !state.hooks_filter.matches(!hook.disabled) { continue; } groups .entry(hook.source_dir.clone()) .or_default() .push((i, hook)); } for (source_dir, hooks) in &groups { let (label, _is_custom) = derive_source_label(source_dir); // While searching we ignore previous collapse state so // every hook inside the group can be seen and matched. let searching = !state.picker_state.query.is_empty(); let collapsed = !searching && state.hooks_collapsed_groups.contains(source_dir); entry_labels.push(format!("{} ({} hooks)", label, hooks.len())); entry_right_labels.push(String::new()); entry_desc_lines.push(vec![]); entry_summary_lines.push(vec![]); entry_fields.push(vec![]); entry_is_header.push(false); // group header, but selectable entry_dimmed.push(false); // headers entry_indent.push(0); entry_data_indices.push(None); entry_group_keys.push(Some(source_dir.clone())); entry_badge_text.push(String::new()); entry_badge_color.push(None); if collapsed { continue; } for &(hi, hook) in hooks { let event_str = hook.event.to_string(); let matcher_str = hook .matcher .as_deref() .map(|m| format!(" /{m}")) .unwrap_or_default(); entry_labels.push(format!("on:{}{}", event_str, matcher_str)); let cmd = hook .command .as_deref() .unwrap_or(hook.url.as_deref().unwrap_or("(no command)")); entry_right_labels.push(String::new()); entry_desc_lines.push(vec![format!("\u{2192} {}", cmd)]); entry_summary_lines.push(vec![]); entry_fields.push(vec![]); entry_is_header.push(false); entry_dimmed.push(hook.disabled); entry_indent.push(1); entry_data_indices.push(Some(hi)); entry_group_keys.push(None); entry_badge_text.push(if hook.disabled { "[disabled]".into() } else { String::new() }); entry_badge_color.push(if hook.disabled { Some(theme.accent_error) } else { None }); } } } else if let TabDataState::Error(ref msg) = state.hooks_data { entry_labels.push(format!("Error: {}", msg)); entry_right_labels.push(String::new()); entry_desc_lines.push(vec![]); entry_summary_lines.push(vec![]); entry_fields.push(vec![]); entry_is_header.push(false); entry_dimmed.push(false); entry_indent.push(0); entry_data_indices.push(None); entry_group_keys.push(None); entry_badge_text.push(String::new()); entry_badge_color.push(None); } } ExtensionsTab::McpServers => { if let TabDataState::Loaded(ref servers) = state.mcps_data { use crate::views::mcps_modal::{ McpSectionId, section_for, section_key, section_label, }; init_mcps_section_collapse_on_first_load( &mut state.mcps_collapsed_sections, &mut state.mcps_section_collapse_initialized, servers, ); let searching = !state.picker_state.query.is_empty(); let mut sections: std::collections::BTreeMap< McpSectionId, Vec<(usize, &crate::views::mcps_modal::McpServerInfo)>, > = std::collections::BTreeMap::new(); for (si, server) in servers.iter().enumerate() { let display_name = server.display_name.as_deref().unwrap_or(&server.name); if !fuzzy_matches(display_name, &state.picker_state.query) { continue; } if !state.mcps_filter.matches(server.enabled) { continue; } sections .entry(section_for(server)) .or_default() .push((si, server)); } for (section_id, section_servers) in §ions { let sec_key = section_key(section_id); let section_collapsed = mcp_section_children_hidden( &state.mcps_collapsed_sections, &sec_key, searching, ); entry_labels.push(section_label(section_id, section_servers.len())); entry_right_labels.push(String::new()); entry_desc_lines.push(vec![]); entry_summary_lines.push(vec![]); entry_fields.push(vec![]); entry_is_header.push(false); entry_dimmed.push(false); entry_indent.push(0); entry_data_indices.push(None); entry_group_keys.push(Some(sec_key)); entry_badge_text.push(String::new()); entry_badge_color.push(None); if section_collapsed { continue; } for &(si, server) in section_servers { entry_labels.push( server .display_name .clone() .unwrap_or_else(|| server.name.clone()), ); entry_right_labels.push(format!("({})", server.source)); // Summary line: tools count + enabled count. if server.tools.is_empty() { entry_desc_lines.push(vec![ "no tools (server may not be connected)".to_string(), ]); } else { let enabled_count = server.tools.iter().filter(|t| t.enabled).count(); if enabled_count == server.tools.len() { entry_desc_lines .push(vec![format!("{} tools", server.tools.len())]); } else { entry_desc_lines.push(vec![format!( "{} tools ({} enabled)", server.tools.len(), enabled_count )]); } } entry_summary_lines.push(vec![]); entry_fields.push(vec![]); let tools_group_key = format!("mcp-tools:{si}"); entry_is_header.push(false); entry_dimmed.push(!server.enabled); entry_indent.push(1); entry_data_indices.push(Some(si)); entry_group_keys.push(Some(tools_group_key)); let (badge_text, badge_col) = if !server.enabled { ("[disabled]".to_string(), Some(theme.accent_error)) } else { ( format!("[{}]", server.status.label()), Some(server.status.theme_color(&theme)), ) }; entry_badge_text.push(badge_text); entry_badge_color.push(badge_col); if state.mcps_tools_expanded.contains(&si) { for t in &server.tools { entry_labels.push( t.display_name.clone().unwrap_or_else(|| t.name.clone()), ); entry_right_labels.push(String::new()); let desc = t.description.as_deref().unwrap_or(""); if desc.is_empty() { entry_desc_lines.push(vec![]); } else { entry_desc_lines.push(vec![desc.to_string()]); } entry_summary_lines.push(vec![]); entry_fields.push(vec![]); entry_is_header.push(false); entry_dimmed.push(!t.enabled); entry_indent.push(2); entry_data_indices.push(Some(si)); entry_group_keys.push(None); let tool_badge = if !t.enabled { ("[disabled]".to_string(), Some(theme.accent_error)) } else { (String::new(), None) }; entry_badge_text.push(tool_badge.0); entry_badge_color.push(tool_badge.1); } } } } } else if let TabDataState::Error(ref msg) = state.mcps_data { entry_labels.push(format!("Error: {}", msg)); entry_right_labels.push(String::new()); entry_desc_lines.push(vec![]); entry_summary_lines.push(vec![]); entry_fields.push(vec![]); entry_is_header.push(false); entry_dimmed.push(false); entry_indent.push(0); entry_data_indices.push(None); entry_group_keys.push(None); entry_badge_text.push(String::new()); entry_badge_color.push(None); } } } } // Override badge for the entry with an in-flight action. if let Some(pending_idx) = state.pending_entry_index && let Some(ref pending_text) = state.pending_action { if let Some(badge) = entry_badge_text.get_mut(pending_idx) { *badge = format!("[{}]", pending_text.trim_end_matches("...")); } if let Some(color) = entry_badge_color.get_mut(pending_idx) { *color = Some(theme.warning); } } // A completed action marks its row with a checkmark (auto-expiring), // overriding the in-flight pending badge — keeps the list visible, no // overlay. The full result text is shown non-covering in the footer below. if let Some(ref n) = state.result_notice && let Some(row) = n.entry_index { if let Some(badge) = entry_badge_text.get_mut(row) { *badge = "✓".to_string(); } if let Some(color) = entry_badge_color.get_mut(row) { *color = Some(theme.accent_success); } } // Build the PickerField slices from owned data. let field_slices: Vec>> = entry_fields .iter() .map(|fields| { fields .iter() .map(|(l, v)| picker::PickerField { label: l.as_str(), value: v.as_str(), }) .collect() }) .collect(); let desc_line_refs: Vec> = entry_desc_lines .iter() .map(|lines| lines.iter().map(|s| s.as_str()).collect()) .collect(); let summary_line_refs: Vec> = entry_summary_lines .iter() .map(|lines| lines.iter().map(|s| s.as_str()).collect()) .collect(); // Build non_selectable mask (MCP section labels are not focusable rows). let non_selectable = build_entry_non_selectable(&entry_is_header, &entry_group_keys); let non_selectable_clickable = build_entry_non_selectable_clickable(&entry_group_keys); // Clamp selection against this frame's entry list for footer labels. // Do not write it to `state` until after `render_modal_window` succeeds: // an early return below would otherwise leave `picker_state.selected` // clamped while `entry_data_indices` / `entry_group_keys` stay stale // (published only post-paint), desyncing `selected_data_index()` and // toggle dispatch until a later full frame. let entry_count = entry_labels.len(); let selected = if entry_count == 0 { 0 } else { state.picker_state.selected.min(entry_count - 1) }; // Build per-tab action keys for the footer shortcuts. // Space enable/disable uses the freshly built entry-mapping locals // (not `state.entry_*`, which are published once after paint below). let action_keys = extensions_action_keys(state.active_tab); // Build owned labels for dynamic action-key shortcuts so we can // borrow from them without leaking memory. Each entry is // `(original_index, label)` so the shortcut `id` stays aligned // with `action_keys` even when some keys have no display string. let action_labels: Vec<(usize, String)> = action_keys .iter() .enumerate() .filter_map(|(i, &(ch, desc))| { let key_str = action_key_display(ch); if key_str.is_empty() { None } else { let verb = action_key_footer_desc_for_mapping( ch, desc, state, &entry_data_indices, &entry_group_keys, selected, ); Some((i, format!("{key_str} {verb}"))) } }) .collect(); // Build Shortcut list for the modal window footer. // Standard nav/select/close + expandable hints + per-tab action keys. // Build footer shortcuts: per-tab action keys + Esc close. // All shortcuts are clickable so they get hover highlights and // dispatch actions on click. // // When a modal message overlay (error OR confirmation) is showing, // the standard shortcuts are suppressed and a custom hint is // rendered directly into the footer area below. Custom render is // used because the dismissal keys ("any key") are multi-word and // would not split correctly through the default Shortcut renderer. // The overlay above is shortened to leave the footer line visible. let modal_msg_kind = state.modal_message.as_ref().map(|m| match m { ModalMessage::Error(_) => ModalMsgKind::Error, ModalMessage::Confirmation { .. } => ModalMsgKind::Confirm, }); let mut shortcuts: Vec> = Vec::new(); if modal_msg_kind.is_some() { // Modal message overlay (error/confirmation) is rendered with // its own dismissal hint in the footer below — leave the // standard shortcuts list empty. } else if state.picker_state.search_active && state.input.is_none() { // Search bar has focus — hide the shortcuts footer entirely so // it doesn't compete visually with the typing cursor and so // typed letters don't appear to map to advertised actions // (they're going into the query, not triggering shortcuts). // Input-mode is handled below; it owns its own footer. } else if let Some(ref input) = state.input { // "Add"/input mode: surface the keys the input form actually // handles. Tab is either path completion (single-field) or // field navigation (multi-field). shortcuts.push(Shortcut { label: "Enter submit", clickable: false, id: 0, }); shortcuts.push(Shortcut { label: if input.is_multi_field() { "Tab/Shift+Tab field" } else { "Tab complete" }, clickable: false, id: 0, }); shortcuts.push(Shortcut { label: "Esc cancel", clickable: false, id: 0, }); } else { // Tab/Shift+Tab cycles tabs (handled in picker.rs). Click on // the hint cycles to the next tab only — sentinel id 98, // dispatched in `handle_extensions_modal_mouse`. The hint // label intentionally documents only `Tab` because click // cycles forward; `Shift+Tab` is still listed in the // cheatsheet (`?` shortcut help). shortcuts.push(Shortcut { label: "Tab tabs", clickable: true, id: 98, }); for &(orig_idx, ref label) in &action_labels { shortcuts.push(Shortcut { label: label.as_str(), clickable: true, id: 100 + orig_idx, }); } if state.active_tab == ExtensionsTab::McpServers { shortcuts.push(Shortcut { label: MCP_SERVERS_OPEN_CONNECTORS_FOOTER, clickable: false, id: 0, }); } // `e` / Shift+e / Enter still expands and collapses (handled by // the picker's built-in expandable branch). The hint is omitted // from the footer to save space — the cheatsheet still lists it. // ID 99 = close action, handled in the mouse handler. shortcuts.push(Shortcut { label: "Esc close", clickable: true, id: 99, }); // Surface `i search` in the footer when vim nav mode is active — but // only on tabs where `i` is not already an action key (MCP Servers // `auth`). `handle_picker_input` resolves action keys before vim search // entry, so on those tabs `i` never opens search and the hint would // mislabel the key. let i_is_action_key = extensions_action_keys(state.active_tab) .iter() .any(|&(ch, _)| ch == 'i'); if !i_is_action_key { modal_window::push_vim_nav_search_hint( &mut shortcuts, state.picker_state.search_active, ); } } // Render modal window chrome. let modal_config = ModalWindowConfig { // Empty title — the tab bar identifies the modal contents. title: "", tabs: Some(&labels), shortcuts: &shortcuts, sizing: ModalSizing { width_pct: 0.65, max_width: 160, min_width: 40, v_margin: 3, h_pad: 2, v_pad: 2, footer_lines: 2, } .with_compact(compact), fold_info: None, }; let Some(ModalContentArea { content: content_area, footer: footer_area, inner_x, inner_width, }) = modal_window::render_modal_window(buf, full_area, &mut state.window, &modal_config, &theme) else { // Too small to paint: leave selection + entry caches unchanged // together (see clamp comment above). return; }; // Commit the clamped selection now that paint will continue and // entry maps will be published later this frame. state.picker_state.selected = selected; // In input ("Add") mode the search bar, filter indicator, and divider // are hidden so the bordered input form can own the full content area. // The search affordances are irrelevant while the user is typing into // a form, and the divider would float above the form awkwardly. let search_width = content_area.width; if !in_input_mode { // Search bar at top of content area. let search_active_render = state.picker_state.search_active; picker::render_search_bar( buf, content_area.x, content_area.y, search_width, &theme, &state.picker_state.query, search_active_render, true, // show_search_hint state.picker_state.query_cursor, Some(theme.bg_base), ); } // Filter indicator (right-aligned on search row). if !in_input_mode { let rect = picker::render_filter_indicator( buf, content_area.x, content_area.y, search_width, &theme, filter.label(), "f", filter != StatusFilter::All, state.picker_state.filter_hovered, ); state.picker_state.filter_area = Some(rect); } else { state.picker_state.filter_area = None; } // Divider below search — spans full inner width (border to border). // Suppressed in input mode (no search bar above to divide from). let sep_y = content_area.y + 1; if !in_input_mode && sep_y < content_area.y + content_area.height { picker::render_divider( buf, inner_x, sep_y, inner_width, &theme, Some(theme.bg_base), ); } // In input mode the form takes the full content area (no search/divider // chrome above). Otherwise entries start one row below the divider. let entries_start_y = if in_input_mode { content_area.y } else { sep_y + 1 }; // Search-bar hit area: zero-rect in input mode so a click in that // (now empty) row doesn't accidentally activate search underneath. let search_bar_rect = if in_input_mode { Rect::default() } else { Rect::new(content_area.x, content_area.y, content_area.width, 1) }; let picker_entries: Vec> = entry_labels .iter() .enumerate() .map(|(i, label)| { if entry_is_header[i] { picker::PickerEntry::Header { label: label.as_str(), } } else { let group_key = entry_group_keys.get(i).and_then(|k| k.as_ref()); let is_collapsible = group_key.is_some(); // For collapsible group headers, `expanded` reflects // whether the group's children are visible (not in the // collapsed set). For regular items, it reflects the // picker's per-row detail expansion. let is_expanded = if is_collapsible { state.is_group_expanded(i, group_key.unwrap()) } else { state.picker_state.expanded.contains(&i) }; picker::PickerEntry::Row(picker::PickerRow { label: label.as_str(), right_label: entry_right_labels[i].as_str(), selected: !state.picker_state.search_active && !state.picker_state.tabs_focused && i == state.picker_state.selected, expanded: is_expanded, fields: &field_slices[i], description_lines: &desc_line_refs[i], summary_lines: &summary_line_refs[i], dimmed: entry_dimmed.get(i).copied().unwrap_or(false), indent: entry_indent.get(i).copied().unwrap_or(0), badge: entry_badge_text.get(i).map(|s| s.as_str()).unwrap_or(""), badge_color: entry_badge_color.get(i).copied().flatten(), collapsible: is_collapsible, }) } }) .collect(); // Render entries into the area below the divider. let entries_area = Rect { x: content_area.x, y: entries_start_y, width: content_area.width, height: content_area .height .saturating_sub(entries_start_y.saturating_sub(content_area.y)), }; // In input mode the entry list is intentionally empty (we skip the // entry-builder loop above). Calling the picker here would render // its empty-state "No matches" message, which is misleading — there // are no entries because we're showing a form, not because nothing // matched. Skip the picker render and let the input-form overlay // below own the entries area instead. let (item_rects, entry_indices) = if in_input_mode { // No picker render in input mode: clear any stale recorded link band. (Vec::new(), Vec::new()) } else { let content_hit = picker::render_picker_content_with_scrollbar_x( buf, entries_area, &theme, &mut state.picker_state, &picker_entries, &non_selectable, &non_selectable_clickable, Some(theme.bg_base), loading, inner_x + inner_width - 1, ); (content_hit.item_rects, content_hit.entry_indices) }; // Store hit areas for mouse handling. Build a PickerHitAreas so // handle_picker_input (used for content-level events) works. let filter_rect = state.picker_state.filter_area; state.picker_state.hit_areas = Some(picker::PickerHitAreas { close_button: Rect::default(), // handled by ModalWindow search_bar: search_bar_rect, item_rects, entry_indices, tab_rects: vec![], // handled by ModalWindow filter_rect, }); state.entry_data_indices = entry_data_indices; state.entry_labels_cache = entry_labels; state.entry_group_keys = entry_group_keys; state.entry_non_selectable = non_selectable; state.entry_non_selectable_clickable = non_selectable_clickable; // Render input form overlay (when in input mode). if let Some(ref input) = state.input { let form_y = entries_start_y; let form_height = entries_area.height; if form_height > 0 { let form_area = Rect::new(content_area.x, form_y, content_area.width, form_height); render_input_form(buf, form_area, input, &theme); } } // Render full-screen pending overlay when no specific entry is targeted // (e.g., AddSource — the new row doesn't exist yet so there's no entry // badge to show). Covers the picker content with a centered spinner + message. if state.pending_action.is_some() && state.pending_entry_index.is_none() && let Some(popup_rect) = state.window.popup_area { let label = state.pending_action.as_deref().unwrap_or("Processing..."); let frames = crate::glyphs::braille_spinner_frames(); let frame_idx = (tick / SPINNER_DIVISOR) as usize % frames.len(); let display = format!("{} {label}", frames[frame_idx]); let msg_content_y = popup_rect.y + 2; let popup_bottom = popup_rect.y + popup_rect.height.saturating_sub(1); let msg_content_height = popup_bottom.saturating_sub(msg_content_y); let msg_content_x = popup_rect.x + 1; let msg_content_width = popup_rect.width.saturating_sub(2); if msg_content_height > 0 { let msg_area = Rect::new( msg_content_x, msg_content_y, msg_content_width, msg_content_height, ); for y in msg_area.y..msg_area.y + msg_area.height { buf.set_string( msg_area.x, y, " ".repeat(msg_area.width as usize), Style::default().bg(theme.bg_base), ); } let msg_y = msg_area.y + msg_area.height / 2; let msg_x = msg_area.x + msg_area.width.saturating_sub(display.width() as u16) / 2; buf.set_string( msg_x, msg_y, &display, Style::default().fg(theme.accent_tool).bg(theme.bg_base), ); } } // Render modal message overlay. if let Some(ref msg) = state.modal_message { let (text, fg) = match msg { ModalMessage::Error(e) => (e.as_str(), theme.accent_error), ModalMessage::Confirmation { message, .. } => (message.as_str(), theme.accent_tool), }; if let Some(popup_rect) = state.window.popup_area { let msg_content_y = popup_rect.y + 2; // Stop the overlay above the footer so the dismissal hint // we render into the footer below stays visible. Applies to // both errors and confirmations. let popup_bottom = footer_area.y; let msg_content_height = popup_bottom.saturating_sub(msg_content_y); let msg_content_x = popup_rect.x + 1; let msg_content_width = popup_rect.width.saturating_sub(2); if msg_content_height > 0 { let msg_area = Rect::new( msg_content_x, msg_content_y, msg_content_width, msg_content_height, ); for y in msg_area.y..msg_area.y + msg_area.height { buf.set_string( msg_area.x, y, " ".repeat(msg_area.width as usize), Style::default().bg(theme.bg_base), ); } let pad = 2u16; let max_w = msg_area.width.saturating_sub(pad * 2) as usize; let wrapped_lines: Vec<&str> = word_wrap(text, max_w); let msg_height = wrapped_lines.len().min(msg_area.height as usize); let msg_y = msg_area.y + (msg_area.height.saturating_sub(msg_height as u16)) / 2; for (i, wline) in wrapped_lines.iter().enumerate().take(msg_height) { buf.set_string( msg_area.x + pad, msg_y + i as u16, wline, Style::default().fg(fg).bg(theme.bg_base), ); } // Dismissal hints (for both errors and confirmations) // are rendered into the footer below, not inline. } } } // Render the dismissal hint(s) for any modal message into the // footer area we kept clear above. Custom render (not via Shortcut) // is needed because dismissal keys ("any key") are multi-word and // would not split correctly through the default renderer. Colors, // bold modifier, and " | " separator all match the standard // footer shortcut style. if let Some(kind) = modal_msg_kind { let segments: &[(&str, &str)] = match kind { ModalMsgKind::Error => &[("any key", " back")], ModalMsgKind::Confirm => &[("y", " confirm"), ("any other key", " cancel")], }; render_footer_hint_segments(buf, footer_area, segments, &theme); } else if let Some(ref n) = state.result_notice && footer_area.height > 0 { // Result status line (per-row and tab-wide): a non-covering success line // in the footer so the list stays visible above it. Auto-expires; the // per-row case also gets a ✓ on its row. let text = n.message.lines().next().unwrap_or(n.message.as_str()); let avail = footer_area.width.saturating_sub(2) as usize; let shown: String = if UnicodeWidthStr::width(text) > avail { // Truncate by display width (file convention) so wide chars can't // overflow the footer, then add an ellipsis. let mut s = String::new(); let mut w = 0usize; for ch in text.chars() { let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); if w + cw > avail.saturating_sub(1) { break; } w += cw; s.push(ch); } s.push('…'); s } else { text.to_string() }; let y = footer_area.y + footer_area.height.saturating_sub(1); // The shortcuts bar renders underneath this row and its keys are BOLD; // `set_string` only *merges* style, so `Style::default()` would leave // that bold on any cell the message overwrites (partial-bold bleed). // `Style::reset()` clears all existing modifiers first. let clear_style = Style::reset().bg(theme.bg_base); let text_style = Style::reset().fg(theme.accent_success).bg(theme.bg_base); buf.set_string( footer_area.x, y, " ".repeat(footer_area.width as usize), clear_style, ); buf.set_string(footer_area.x + 1, y, &shown, text_style); } } /// Kind of modal message overlay currently showing. #[derive(Debug, Clone, Copy)] enum ModalMsgKind { Error, Confirm, } /// Render a centered list of (key, label) hint segments into the bottom /// row of `footer_area`, joined by ` | ` separators. Mirrors the /// styling used by `modal_window::render_modal_shortcuts` so custom /// dismissal hints look the same as standard footer shortcuts. fn render_footer_hint_segments( buf: &mut Buffer, footer_area: Rect, segments: &[(&str, &str)], theme: &Theme, ) { if footer_area.width == 0 || footer_area.height == 0 || segments.is_empty() { return; } let separator = " | "; let sep_w = separator.width() as u16; let mut total_w: u16 = 0; for (i, (key, label)) in segments.iter().enumerate() { if i > 0 { total_w = total_w.saturating_add(sep_w); } total_w = total_w.saturating_add(key.width() as u16); total_w = total_w.saturating_add(label.width() as u16); } if total_w == 0 || total_w > footer_area.width { return; } let key_style = Style::default() .fg(theme.text_secondary) .bg(theme.bg_base) .add_modifier(ratatui::style::Modifier::BOLD); let label_style = Style::default().fg(theme.gray).bg(theme.bg_base); let sep_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base); let mut x = footer_area.x + footer_area.width.saturating_sub(total_w) / 2; let y = footer_area.y + footer_area.height.saturating_sub(1); for (i, (key, label)) in segments.iter().enumerate() { if i > 0 { buf.set_string(x, y, separator, sep_style); x += sep_w; } let kw = key.width() as u16; buf.set_string(x, y, *key, key_style); x += kw; if !label.is_empty() { buf.set_string(x, y, *label, label_style); x += label.width() as u16; } } } /// Render an inline input form for commands that need arguments. /// /// Each field gets its own rounded border around the input row, /// mirroring the prompt input chrome. Labels sit above the bordered /// row so they remain visible. /// /// Layout (stacked, one field shown): /// ```text /// Label /// ╭──────────────────────────╮ /// │ ❯ user input │ /// ╰──────────────────────────╯ /// ``` fn render_input_form(buf: &mut Buffer, area: Rect, input: &ModalInput, theme: &Theme) { if area.height < 4 || area.width < 20 { return; } // Per field: 1 label row + 3 rows for the bordered input // (top border + content + bottom border). let field_count = input.fields.len() as u16; const ROWS_PER_FIELD: u16 = 4; let separators = field_count.saturating_sub(1); // 1 blank row between fields let form_rows = field_count * ROWS_PER_FIELD + separators; // Reserve room for an inline error row when present (1 spacer + 1 line). let error_rows: u16 = if input.error.is_some() { 2 } else { 0 }; let total_rows = form_rows + error_rows; // Center vertically within the available area. let form_top = area.y + area.height.saturating_sub(total_rows) / 2; // Horizontal inset so the bordered box doesn't sit flush against // the outer modal border. let h_inset: u16 = 2; let box_x = area.x + h_inset; let box_w = area.width.saturating_sub(h_inset * 2); if box_w < 10 { return; } let label_x = box_x; let label_style = Style::default() .fg(theme.accent_user) .bg(theme.bg_base) .add_modifier(Modifier::BOLD); let label_dim_style = Style::default() .fg(theme.gray_dim) .bg(theme.bg_base) .add_modifier(Modifier::BOLD); let text_style = Style::default().fg(theme.text_primary).bg(theme.bg_base); let placeholder_style = Style::default().fg(theme.gray).bg(theme.bg_base); let prompt_style = Style::default().fg(theme.gray).bg(theme.bg_base); let prompt_prefix = crate::glyphs::prompt_arrow(); let prompt_w = prompt_prefix.width() as u16; let mut cur_y = form_top; for (fi, field) in input.fields.iter().enumerate() { if cur_y >= area.y + area.height { break; } let is_focused = fi == input.focused; // Row 1: Label (sits above the bordered input, not inside). let ls = if is_focused { label_style } else { label_dim_style }; buf.set_string(label_x, cur_y, &field.label, ls); cur_y += 1; // Rows 2-4: Rounded border around the single-line input. // Need at least 3 rows to fit top/content/bottom borders. let remaining = (area.y + area.height).saturating_sub(cur_y); if remaining < 3 { break; } let box_rect = Rect::new(box_x, cur_y, box_w, 3); let border_color = if is_focused { theme.prompt_border_active } else { theme.prompt_border }; let border_style = Style::default().fg(border_color).bg(theme.bg_base); let block = ratatui::widgets::Block::default() .borders(ratatui::widgets::Borders::ALL) .border_type(ratatui::widgets::BorderType::Rounded) .border_style(border_style) .style(Style::default().bg(theme.bg_base)); let inner = block.inner(box_rect); ratatui::widgets::Widget::render(block, box_rect, buf); // Content row: prompt prefix + input text or placeholder. // Pad 1 cell from the left side of the bordered inner area. let content_x = inner.x + 1; let content_y = inner.y; // Available text width = inner width - left pad - prompt prefix - 1 right margin. let max_text_w = inner.width.saturating_sub(1 + prompt_w + 1).max(1) as usize; buf.set_string(content_x, content_y, prompt_prefix, prompt_style); let text_x = content_x + prompt_w; if field.text.is_empty() { // Placeholder only renders when the field is NOT focused — // matches the prompt widget convention so the cursor isn't // overlapping placeholder text on the active row. if !is_focused && let Some(ref ph) = field.placeholder { let display: String = take_by_width(ph, max_text_w); buf.set_string(text_x, content_y, &display, placeholder_style); } if is_focused && let Some(cell) = buf.cell_mut((text_x, content_y)) { cell.set_style(Style::default().fg(theme.bg_base).bg(theme.text_primary)); } } else { // Compute scroll offset once, tracking actual display widths // so wide characters (CJK, emoji) don't misalign the cursor. let cursor_col = field.text[..field.cursor].width(); let scroll = cursor_col.saturating_sub(max_text_w.saturating_sub(1)); // Skip `scroll` display-width columns, tracking the actual // width skipped (may differ from `scroll` when a wide char // straddles the boundary). let mut skipped_w = 0; let visible: String = field .text .chars() .skip_while(|c| { if skipped_w >= scroll { return false; } skipped_w += unicode_width::UnicodeWidthChar::width(*c).unwrap_or(0); true }) .collect(); let visible = take_by_width(&visible, max_text_w); buf.set_string(text_x, content_y, &visible, text_style); if is_focused { let cx = text_x + (cursor_col.saturating_sub(skipped_w)) as u16; if cx < inner.x + inner.width && let Some(cell) = buf.cell_mut((cx, content_y)) { cell.set_style(Style::default().fg(theme.bg_base).bg(theme.text_primary)); } } } cur_y += 3; // top border + content + bottom border // Blank separator between fields (skip after last field). if fi + 1 < input.fields.len() { cur_y += 1; } } // Inline error message below the form (outside the field borders). if let Some(ref err) = input.error { cur_y += 1; if cur_y < area.y + area.height { let error_style = Style::default().fg(theme.accent_error).bg(theme.bg_base); let display = take_by_width(err, box_w.saturating_sub(2) as usize); buf.set_string(label_x, cur_y, &display, error_style); } } } #[cfg(test)] mod tests { use super::*; #[test] fn derive_source_label_detects_project_scoped_plugins() { // Regression: project-scoped `{cwd}/.kigi/plugins//` must label as // a (non-removable) plugin, not a removable "Custom" source. The user // kigi-home branch is KIGI_SHARE_DIR-aware; this covers the project fallback. let (label, is_custom) = derive_source_label("/repo/work/.kigi/plugins/my-plugin/hooks"); assert_eq!(label, "Plugin: my-plugin"); assert!(!is_custom); let (label, is_custom) = derive_source_label("/repo/work/.kigi/installed-plugins/vendor-abc123/skills"); assert_eq!(label, "Plugin: vendor-abc123"); assert!(!is_custom); } #[test] fn build_entry_non_selectable_leaves_mcp_section_headers_selectable() { let mask = build_entry_non_selectable( &[false, false, true], &[ Some("mcp-section:local".into()), Some("mcp-tools:0".into()), None, ], ); assert!( !mask[0], "MCP section label row is keyboard-selectable so j/k can land on it \ and Enter / l toggles its collapsed state" ); assert!(!mask[1]); assert!(mask[2], "static header rows stay non-selectable"); } #[test] fn build_entry_non_selectable_clickable_is_empty_for_mcp_sections() { let mask = build_entry_non_selectable_clickable(&[ Some("mcp-section:local".into()), Some("mcp-tools:0".into()), None, ]); // Sections are now keyboard-selectable, so clicks go through the // normal Selected → toggle_fold path; no row needs the // non-selectable-but-clickable treatment. assert_eq!(mask, vec![false, false, false]); } #[test] fn mcp_servers_action_keys_have_resolver_arms() { for &(ch, label) in MCP_SERVERS_ACTION_KEYS { let action = resolve_key(ExtensionsTab::McpServers, ch); assert!( action.is_some(), "MCP_SERVERS_ACTION_KEYS advertises ('{ch}', \"{label}\") in the hint bar \ but resolve_key(McpServers, '{ch}') returns None — add a match arm in \ resolve_key or remove the entry from the const." ); } } #[test] fn action_keys_resolve_for_every_tab() { let expected: &[(ExtensionsTab, &[char])] = &[ (ExtensionsTab::Hooks, &['r', 'a', ' ', 'x']), (ExtensionsTab::Plugins, &['r', 'u', 'a', ' ', 'x']), (ExtensionsTab::Skills, &[' ', 'f', 'r']), (ExtensionsTab::McpServers, &['r', 'a', 'i', ' ', 'x']), ]; assert_eq!(expected.len(), ExtensionsTab::ALL.len()); for &(tab, keys) in expected { let action_keys = extensions_action_keys(tab); assert_eq!( action_keys.len(), keys.len(), "{tab:?}: action key set changed — update this pinning test deliberately" ); for &ch in keys { assert!( resolve_key(tab, ch).is_some(), "extensions_action_keys({tab:?}) advertises '{ch}' but resolve_key \ returns None" ); } } } // Fixture layout (local section, two servers with tools): // 0 section header group_key=Some("mcp-section:local") data=None // 1 server 0 header group_key=Some("mcp-tools:0") data=Some(0) // 2 tool 0 of svr 0 group_key=None data=Some(0) // 3 tool 1 of svr 0 group_key=None data=Some(0) // 4 server 1 header group_key=Some("mcp-tools:1") data=Some(1) // 5 tool 0 of svr 1 group_key=None data=Some(1) fn fixture_with_two_servers_and_tools() -> ExtensionsModalState { let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers); state.entry_data_indices = vec![None, Some(0), Some(0), Some(0), Some(1), Some(1)]; state.entry_group_keys = vec![ Some("mcp-section:local".to_string()), Some("mcp-tools:0".to_string()), None, None, Some("mcp-tools:1".to_string()), None, ]; state } #[test] fn selected_mcp_tool_returns_none_on_server_row() { let mut state = fixture_with_two_servers_and_tools(); state.picker_state.selected = 0; assert_eq!(state.selected_mcp_tool(), None); state.picker_state.selected = 1; assert_eq!(state.selected_mcp_tool(), None); state.picker_state.selected = 4; assert_eq!(state.selected_mcp_tool(), None); } #[test] fn selected_mcp_tool_returns_tool_index_on_tool_row() { let mut state = fixture_with_two_servers_and_tools(); state.picker_state.selected = 2; assert_eq!(state.selected_mcp_tool(), Some((0, 0))); state.picker_state.selected = 3; assert_eq!(state.selected_mcp_tool(), Some((0, 1))); state.picker_state.selected = 5; assert_eq!(state.selected_mcp_tool(), Some((1, 0))); } #[test] fn mcp_section_local_collapsed_hides_child_servers() { let mut collapsed = std::collections::HashSet::new(); collapsed.insert("mcp-section:local".to_string()); assert!(mcp_section_children_hidden( &collapsed, "mcp-section:local", false )); } #[test] fn mcp_section_search_forces_children_visible() { let mut collapsed = std::collections::HashSet::new(); collapsed.insert("mcp-section:local".to_string()); assert!(!mcp_section_children_hidden( &collapsed, "mcp-section:local", true )); } #[test] fn mcp_is_group_expanded_search_overrides_collapsed_section() { let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers); state .mcps_collapsed_sections .insert("mcp-section:local".to_string()); state.picker_state.query = "linear".into(); assert!(state.is_group_expanded(0, "mcp-section:local")); } #[test] fn mcp_tools_is_group_expanded_follows_mcps_tools_expanded() { let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers); assert!(!state.is_group_expanded(1, "mcp-tools:0")); state.mcps_tools_expanded.insert(0); assert!(state.is_group_expanded(1, "mcp-tools:0")); state.mcps_tools_expanded.remove(&0); assert!(!state.is_group_expanded(1, "mcp-tools:0")); } #[test] fn mcp_auth_intercept_on_expand_detects_auth_required_server() { use crate::views::mcps_modal::{McpServerDisplayStatus, McpServerInfo}; let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers); state.mcps_data = TabDataState::Loaded(vec![McpServerInfo { name: "needs-oauth".into(), display_name: None, status: McpServerDisplayStatus::NeedsAuth, tool_count: 0, auth_required: true, tools: vec![], enabled: true, source: "local".into(), plugin_name: None, }]); state.entry_data_indices = vec![None, Some(0)]; state.entry_group_keys = vec![Some("mcp-section:local".into()), Some("mcp-tools:0".into())]; state.picker_state.selected = 1; assert!( state.mcp_auth_intercept_on_expand(), "collapsed auth-required server should intercept expand" ); state.mcps_tools_expanded.insert(0); assert!( !state.mcp_auth_intercept_on_expand(), "expanded tools must not intercept (collapse allowed)" ); state.picker_state.selected = 0; assert!(!state.mcp_auth_intercept_on_expand()); } fn make_mcp_server_for_rows( name: &str, tools: Vec<(&str, bool)>, ) -> crate::views::mcps_modal::McpServerInfo { use crate::views::mcps_modal::{McpServerDisplayStatus, McpToolDetail}; let tool_details: Vec = tools .into_iter() .map(|(n, enabled)| McpToolDetail { name: n.into(), display_name: None, description: None, enabled, }) .collect(); let tc = tool_details.len(); crate::views::mcps_modal::McpServerInfo { name: name.into(), display_name: None, status: McpServerDisplayStatus::Ready, tool_count: tc, auth_required: false, tools: tool_details, enabled: true, source: "local".into(), plugin_name: None, } } #[test] fn mcp_collapsed_local_section_omits_server_rows() { let servers = vec![make_mcp_server_for_rows("local-srv", vec![])]; let mut collapsed = std::collections::HashSet::new(); collapsed.insert("mcp-section:local".to_string()); let rows = build_mcp_servers_picker_rows( &servers, "", StatusFilter::All, &collapsed, &std::collections::HashSet::new(), ); assert!( rows.labels.iter().any(|l| l.starts_with("Local")), "local section header must appear" ); assert!( !rows.labels.iter().any(|l| l == "local-srv"), "servers in collapsed local section must be omitted" ); } #[test] fn mcp_tool_rows_emitted_when_tools_expanded_by_server_index() { let servers = vec![ make_mcp_server_for_rows("alpha", vec![("tool-a1", true), ("tool-a2", true)]), make_mcp_server_for_rows("beta", vec![("tool-b1", true)]), ]; let mut tools_expanded = std::collections::HashSet::new(); tools_expanded.insert(0); let rows = build_mcp_servers_picker_rows( &servers, "", StatusFilter::All, &std::collections::HashSet::new(), &tools_expanded, ); assert!(rows.labels.contains(&"tool-a1".to_string())); assert!(rows.labels.contains(&"tool-a2".to_string())); assert!( !rows.labels.contains(&"tool-b1".to_string()), "only expanded server index 0 should show tools" ); let tail_index = rows.labels.len().saturating_sub(1); assert_ne!( tools_expanded.iter().copied().next(), Some(tail_index), "expansion must not use picker tail index" ); } #[test] fn mcps_plugin_sections_collapsed_on_first_load() { use crate::views::mcps_modal::{McpServerDisplayStatus, McpServerInfo}; let servers = vec![ McpServerInfo { name: "p1-srv".into(), display_name: None, status: McpServerDisplayStatus::Ready, tool_count: 0, auth_required: false, tools: vec![], enabled: true, source: "plugin: alpha".into(), plugin_name: Some("alpha".into()), }, McpServerInfo { name: "p2-srv".into(), display_name: None, status: McpServerDisplayStatus::Ready, tool_count: 0, auth_required: false, tools: vec![], enabled: true, source: "plugin: beta".into(), plugin_name: Some("beta".into()), }, ]; let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers); assert!( !state.mcps_collapsed_sections.contains("mcp-section:local"), "Local section starts expanded by default for a less noisy initial view" ); init_mcps_section_collapse_on_first_load( &mut state.mcps_collapsed_sections, &mut state.mcps_section_collapse_initialized, &servers, ); assert!( state .mcps_collapsed_sections .contains("mcp-section:plugin:alpha") ); assert!( state .mcps_collapsed_sections .contains("mcp-section:plugin:beta") ); assert!(state.mcps_section_collapse_initialized); } #[test] fn selected_mcp_tool_returns_none_on_empty_or_out_of_range() { let state = ExtensionsModalState::new(ExtensionsTab::McpServers); assert_eq!(state.selected_mcp_tool(), None); let mut state = fixture_with_two_servers_and_tools(); state.picker_state.selected = 99; assert_eq!(state.selected_mcp_tool(), None); } #[test] fn selected_mcp_tool_returns_none_on_error_or_loading_entry() { let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers); state.entry_data_indices = vec![None]; state.entry_group_keys = vec![None]; state.picker_state.selected = 0; assert_eq!(state.selected_mcp_tool(), None); } #[test] fn selected_mcp_tool_returns_none_when_no_parent_server_header_above() { let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers); state.entry_data_indices = vec![Some(0)]; state.entry_group_keys = vec![None]; state.picker_state.selected = 0; assert_eq!(state.selected_mcp_tool(), None); } // ── fuzzy_matches ──────────────────────────────────────────────── #[test] fn fuzzy_matches_empty_query_matches_everything() { assert!(fuzzy_matches("anything", "")); } #[test] fn fuzzy_matches_substring() { assert!(fuzzy_matches("rust-check", "check")); assert!(fuzzy_matches("rust-check", "rust")); assert!(fuzzy_matches("Rust-Check", "check")); // case insensitive } #[test] fn fuzzy_matches_subsequence() { assert!(fuzzy_matches("rust-check", "rc")); // r...c assert!(fuzzy_matches("frontend-design", "fd")); // f...d } #[test] fn fuzzy_matches_rejects_non_matching() { assert!(!fuzzy_matches("hello", "xyz")); assert!(!fuzzy_matches("abc", "abdc")); // query longer than would match } // ── Skills search: substring-only, title-first ordering ───────── fn make_skill(name: &str, desc: &str) -> kigi_tools::implementations::skills::types::SkillInfo { kigi_tools::implementations::skills::types::SkillInfo { name: name.to_string(), display_name: None, description: desc.to_string(), when_to_use: None, short_description: None, author: None, argument_hint: None, license: None, compatibility: None, metadata: None, path: "test".to_string(), scope: kigi_tools::implementations::skills::types::SkillScope::User, config_source: None, plugin_name: None, plugin_version: None, plugin_root: None, plugin_data: None, allowed_tools: None, model: None, effort: None, user_invocable: false, disable_model_invocation: false, has_user_specified_description: false, paths: None, enabled: true, body: None, } } /// Skills search uses substring (not fuzzy), so "pdf" should NOT match /// "product design framework" even though p, d, f appear in order. #[test] fn skills_search_is_substring_not_fuzzy() { let skills = [ make_skill("pdf", "PDF manipulation"), make_skill("product-design-framework", "Design framework for products"), ]; let query = "pdf"; let query_lower = query.to_lowercase(); let matches: Vec<(usize, bool)> = skills .iter() .enumerate() .filter_map(|(si, skill)| { let name_lower = skill.name.to_lowercase(); let desc_lower = skill.description.to_lowercase(); let name_hit = name_lower.contains(&query_lower); let desc_hit = desc_lower.contains(&query_lower); if name_hit { Some((si, true)) } else if desc_hit { Some((si, false)) } else { None } }) .collect(); // Only "pdf" should match, not "product-design-framework". assert_eq!(matches.len(), 1); assert_eq!(matches[0], (0, true)); } /// Title matches should sort before description-only matches. #[test] fn skills_search_title_matches_first() { let skills = [ make_skill("some-tool", "Run lint check"), // desc match only make_skill("check", "Run lint check"), // name match make_skill("rust-check", "Rust pre-push checks"), // name match ]; let query = "check"; let query_lower = query.to_lowercase(); let mut matches: Vec<(usize, bool)> = skills .iter() .enumerate() .filter_map(|(si, skill)| { let name_lower = skill.name.to_lowercase(); let desc_lower = skill.description.to_lowercase(); let name_hit = name_lower.contains(&query_lower); let desc_hit = desc_lower.contains(&query_lower); if name_hit { Some((si, true)) } else if desc_hit { Some((si, false)) } else { None } }) .collect(); matches.sort_by_key(|&(_, is_name)| !is_name); assert_eq!(matches.len(), 3); // Name matches first (check, rust-check), then desc-only (some-tool). assert!(matches[0].1, "first result should be a name match"); assert!(matches[1].1, "second result should be a name match"); assert!(!matches[2].1, "third result should be a desc-only match"); } // ── Hooks: search forces groups expanded ───────────────────────── #[test] fn hooks_collapsed_groups_ignored_during_search() { let mut collapsed = std::collections::HashSet::new(); collapsed.insert("global/hooks".to_string()); // When query is empty, collapsed groups stay collapsed. let query = ""; let is_collapsed_no_query = collapsed.contains("global/hooks") && query.is_empty(); assert!(is_collapsed_no_query); // When query is non-empty, collapsed groups are forced open. let query = "safety"; let is_collapsed_with_query = collapsed.contains("global/hooks") && query.is_empty(); assert!(!is_collapsed_with_query); } // ── Skills: plugin skills appear in filter results ───────────── fn make_plugin_skill( name: &str, desc: &str, plugin: &str, ) -> kigi_tools::implementations::skills::types::SkillInfo { let mut skill = make_skill(name, desc); skill.plugin_name = Some(plugin.to_string()); skill.scope = kigi_tools::implementations::skills::types::SkillScope::Plugin; skill.config_source = Some(kigi_tools::types::config_source::ConfigSource::Plugin { plugin_name: plugin.to_string(), path: std::path::PathBuf::from(format!("/plugins/{plugin}/skills/{name}/SKILL.md")), }); skill } #[test] fn plugin_skills_appear_in_filter_results() { let skills = vec![ make_skill("rust-check", "Run Rust checks"), make_plugin_skill("hello", "A greeting skill", "example-plugin"), make_plugin_skill("lint", "Run lint checks", "linter"), ]; let result = filter_and_sort_skills(&skills, "", StatusFilter::All); // All three skills (native + plugin) should appear. assert_eq!(result.matches.len(), 3); } #[test] fn plugin_skills_appear_with_enabled_filter() { let mut plugin_skill = make_plugin_skill("hello", "A greeting", "example-plugin"); plugin_skill.enabled = true; let mut disabled_skill = make_plugin_skill("lint", "Lint checks", "linter"); disabled_skill.enabled = false; let skills = vec![ make_skill("native", "A native skill"), plugin_skill, disabled_skill, ]; let result = filter_and_sort_skills(&skills, "", StatusFilter::Enabled); // Only enabled skills: native + hello (lint is disabled). assert_eq!(result.matches.len(), 2); // Verify the correct skills are returned: native (index 0) and hello (index 1). let indices: Vec = result.matches.iter().map(|m| m.0).collect(); assert!(indices.contains(&0), "native skill should be present"); assert!( indices.contains(&1), "plugin skill 'hello' should be present" ); assert!( !indices.contains(&2), "disabled plugin skill 'lint' should be excluded" ); } #[test] fn plugin_skills_searchable_by_name() { let skills = vec![ make_skill("rust-check", "Run Rust checks"), make_plugin_skill("hello", "A greeting skill", "example-plugin"), ]; let result = filter_and_sort_skills(&skills, "hello", StatusFilter::All); assert_eq!(result.matches.len(), 1); assert_eq!(result.matches[0].0, 1); // index of hello } #[test] fn plugin_skill_searchable_by_label_and_slash_identity() { let mut skill = make_plugin_skill("deploy-prod", "Ship it", "example-plugin"); skill.display_name = Some("friendly".to_string()); let skills = vec![skill]; // "friendly" hits only the label; "prod" hits only the slash name. let by_label = filter_and_sort_skills(&skills, "friendly", StatusFilter::All); let by_name = filter_and_sort_skills(&skills, "prod", StatusFilter::All); assert_eq!(by_label.matches.len(), 1); assert_eq!(by_name.matches.len(), 1); } // ── Skills: selection clamping after filter ────────────────────── #[test] fn skills_selection_clamped_after_filter() { // User had selected index 10, but after filtering only 3 match. let mut selected: usize = 10; let match_count = 3usize; if match_count > 0 { selected = selected.min(match_count - 1); } assert_eq!(selected, 2); // clamped to last valid index } #[test] fn skills_selection_zero_when_one_match() { let mut selected: usize = 5; let match_count = 1usize; if match_count > 0 { selected = selected.min(match_count - 1); } assert_eq!(selected, 0); } // ── Plugin fixtures ───────────────────────────────────────────── fn make_plugin(name: &str) -> kigi_hooks_plugins_types::PluginInfo { test_plugin_info(name, None) } fn make_plugin_with_origin( name: &str, origin: kigi_hooks_plugins_types::PluginOrigin, ) -> kigi_hooks_plugins_types::PluginInfo { test_plugin_info(name, Some(origin)) } // ── StatusFilter unit tests ───────────────────────────────────── #[test] fn status_filter_next_cycles() { assert_eq!(StatusFilter::All.next(), StatusFilter::Enabled); assert_eq!(StatusFilter::Enabled.next(), StatusFilter::Disabled); assert_eq!(StatusFilter::Disabled.next(), StatusFilter::All); } #[test] fn status_filter_matches() { assert!(StatusFilter::All.matches(true)); assert!(StatusFilter::All.matches(false)); assert!(StatusFilter::Enabled.matches(true)); assert!(!StatusFilter::Enabled.matches(false)); assert!(!StatusFilter::Disabled.matches(true)); assert!(StatusFilter::Disabled.matches(false)); } fn make_plugin_with_enabled(name: &str, enabled: bool) -> kigi_hooks_plugins_types::PluginInfo { let mut p = make_plugin(name); p.enabled = enabled; p } #[test] fn space_footer_desc_is_contextual_enable_or_disable() { let mut state = ExtensionsModalState::new(ExtensionsTab::Plugins); assert_eq!( action_key_footer_desc(' ', "toggle", &state), "enable/disable" ); assert_eq!(action_key_cheatsheet_desc(' ', "toggle"), "enable/disable"); state.entry_data_indices = vec![Some(0), Some(1)]; state.picker_state.selected = 0; state.plugins_data = TabDataState::Loaded(kigi_hooks_plugins_types::PluginsListResponse { plugins: vec![ make_plugin_with_enabled("on", true), make_plugin_with_enabled("off", false), ], }); assert_eq!(state.selected_item_enabled(), Some(true)); assert_eq!(action_key_footer_desc(' ', "toggle", &state), "disable"); state.picker_state.selected = 1; assert_eq!(state.selected_item_enabled(), Some(false)); assert_eq!(action_key_footer_desc(' ', "toggle", &state), "enable"); assert_eq!(action_key_footer_desc('a', "install", &state), "install"); assert_eq!(action_key_footer_desc('r', "reload", &state), "reload"); assert_eq!(action_key_cheatsheet_desc('a', "install"), "install"); } /// Regression: footer Space verb must follow the *current* /// entry-mapping (post filter/query/tab), not a stale one from the previous /// list shape. Render passes freshly built locals into /// `action_key_footer_desc_for_mapping` (state publish is post-paint only). #[test] fn space_footer_follows_refreshed_entry_data_indices_after_filter_shape_change() { let mut state = ExtensionsModalState::new(ExtensionsTab::Plugins); state.plugins_data = TabDataState::Loaded(kigi_hooks_plugins_types::PluginsListResponse { plugins: vec![ make_plugin_with_enabled("on", true), make_plugin_with_enabled("off", false), ], }); // Unfiltered shape: both rows; selection on the enabled plugin. let unfiltered = vec![Some(0), Some(1)]; state.picker_state.selected = 0; assert_eq!( action_key_footer_desc_for_mapping(' ', "toggle", &state, &unfiltered, &[], 0,), "disable" ); // Filtered to the disabled plugin only — same selected *row* index 0, // but it now maps to data index 1. Stale [Some(0), Some(1)] would still // report "disable"; the refreshed mapping must report "enable". let filtered = vec![Some(1)]; assert_eq!( selected_item_enabled_at(&state, &filtered, &[], 0), Some(false) ); assert_eq!( action_key_footer_desc_for_mapping(' ', "toggle", &state, &filtered, &[], 0), "enable" ); // Published-state path (input handling / unit tests) still works when // state.entry_data_indices is set explicitly. state.entry_data_indices = filtered; assert_eq!(state.selected_item_enabled(), Some(false)); assert_eq!(action_key_footer_desc(' ', "toggle", &state), "enable"); } #[test] fn tab_all_hints_derives_from_action_keys_with_space_enable_disable() { for tab in [ ExtensionsTab::Hooks, ExtensionsTab::Plugins, ExtensionsTab::Skills, ExtensionsTab::McpServers, ] { let keys = extensions_action_keys(tab); let hints = tab_all_hints(tab); for (ch, label) in keys { let expected = action_key_cheatsheet_desc(ch, label); assert!( hints.iter().any(|h| h.label == expected), "tab_all_hints({tab:?}) missing display label {expected:?} for key {ch:?}" ); } assert!( !hints.iter().any(|h| h.label == "toggle"), "tab_all_hints({tab:?}) must not show raw toggle" ); } let plugins = tab_all_hints(ExtensionsTab::Plugins); assert!(plugins.iter().any(|h| h.label == "install")); assert!(!plugins.iter().any(|h| h.label == "add")); } #[test] fn plugins_install_key_still_resolves_to_install_input() { match resolve_key(ExtensionsTab::Plugins, 'a') { Some(ButtonAction::StartInput { command_prefix, .. }) => { assert_eq!(command_prefix, "plugins_install"); } other => panic!("expected plugins install StartInput, got {other:?}"), } } // ── Tab navigation ────────────────────────────────────────────── #[test] fn tab_next_wraps_around() { assert_eq!(ExtensionsTab::Hooks.next(), ExtensionsTab::Plugins); assert_eq!(ExtensionsTab::Plugins.next(), ExtensionsTab::Skills); assert_eq!(ExtensionsTab::Skills.next(), ExtensionsTab::McpServers); assert_eq!(ExtensionsTab::McpServers.next(), ExtensionsTab::Hooks); } #[test] fn tab_prev_wraps_around() { assert_eq!(ExtensionsTab::Hooks.prev(), ExtensionsTab::McpServers); assert_eq!(ExtensionsTab::McpServers.prev(), ExtensionsTab::Skills); assert_eq!(ExtensionsTab::Skills.prev(), ExtensionsTab::Plugins); assert_eq!(ExtensionsTab::Plugins.prev(), ExtensionsTab::Hooks); } #[test] fn tab_all_contains_four_tabs() { assert_eq!(ExtensionsTab::ALL.len(), 4); } // ── Modal state init ──────────────────────────────────────────── #[test] fn modal_state_starts_loading() { let state = ExtensionsModalState::new(ExtensionsTab::McpServers); assert_eq!(state.active_tab, ExtensionsTab::McpServers); assert!(matches!(state.mcps_data, TabDataState::Loading)); assert!(matches!(state.skills_data, TabDataState::Loading)); assert!(state.mcps_tools_expanded.is_empty()); assert!( !state.mcps_collapsed_sections.contains("mcp-section:local"), "Local section starts expanded by default for a less noisy initial view" ); assert!(!state.mcps_section_collapse_initialized); assert!(state.skills_expanded.is_empty()); assert_eq!(state.skills_selected, 0); } // ── Bracketed paste ───────────────────────────────────────────── fn single_field_input(prefix: &str) -> ModalInput { ModalInput::from_specs( prefix.into(), vec![FieldSpec { label: "URL".into(), required: true, placeholder: None, }], ) } fn mcp_add_input() -> ModalInput { // Field order matches `extensions_action_keys`: URL first, Name second. ModalInput::from_specs( "mcp_add".into(), vec![ FieldSpec { label: "URL / Command".into(), required: true, placeholder: None, }, FieldSpec { label: "Name".into(), required: false, placeholder: None, }, ], ) } #[test] fn apply_paste_inserts_url_into_focused_field_and_strips_newline() { let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers); let mut input = single_field_input("test"); input.focused = 0; state.input = Some(input); assert!(state.apply_paste("https://mcp.linear.app/mcp\n")); let field = &state.input.as_ref().unwrap().fields[0]; assert_eq!(field.text, "https://mcp.linear.app/mcp"); assert_eq!(field.cursor, "https://mcp.linear.app/mcp".len()); } #[test] fn apply_paste_inserts_at_cursor_position() { let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers); let mut input = single_field_input("test"); input.fields[0].text = "AB".into(); input.fields[0].cursor = 1; state.input = Some(input); assert!(state.apply_paste("XY")); let field = &state.input.as_ref().unwrap().fields[0]; assert_eq!(field.text, "AXYB"); assert_eq!(field.cursor, 3); } #[test] fn apply_paste_strips_crlf() { let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers); state.input = Some(single_field_input("test")); assert!(state.apply_paste("foo\r\nbar")); assert_eq!(state.input.as_ref().unwrap().fields[0].text, "foobar"); } #[test] fn apply_paste_empty_is_noop() { let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers); state.input = Some(single_field_input("test")); assert!(!state.apply_paste("\n\r")); assert_eq!(state.input.as_ref().unwrap().fields[0].text, ""); assert_eq!(state.input.as_ref().unwrap().fields[0].cursor, 0); } #[test] fn apply_paste_routes_to_search_when_no_input() { let mut state = ExtensionsModalState::new(ExtensionsTab::Plugins); state.picker_state.search_active = true; assert!(state.apply_paste("query")); assert_eq!(state.picker_state.query, "query"); } #[test] fn apply_paste_ignored_when_idle() { let mut state = ExtensionsModalState::new(ExtensionsTab::Plugins); assert!(!state.apply_paste("hello")); assert_eq!(state.picker_state.query, ""); assert!(state.input.is_none()); } #[test] fn apply_paste_prefers_input_over_search() { let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers); state.input = Some(single_field_input("test")); state.picker_state.search_active = true; assert!(state.apply_paste("url")); assert_eq!(state.input.as_ref().unwrap().fields[0].text, "url"); assert_eq!(state.picker_state.query, ""); } #[test] fn apply_paste_targets_focused_field_in_multi_field() { let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers); let mut input = mcp_add_input(); input.focused = 0; // URL field (first in the new order) state.input = Some(input); assert!(state.apply_paste("https://example.com")); let fields = &state.input.as_ref().unwrap().fields; assert_eq!(fields[0].text, "https://example.com"); assert_eq!(fields[1].text, ""); } #[test] fn multi_field_form_field_texts() { let mut input = mcp_add_input(); // Field order: [URL, Name]. input.fields[0].text = "https://example.com".into(); input.fields[1].text = "my-server".into(); let texts = input.field_texts(); assert_eq!(texts, vec!["https://example.com", "my-server"]); } #[test] fn from_specs_creates_empty_fields() { let input = mcp_add_input(); assert_eq!(input.fields.len(), 2); assert!(input.fields[0].text.is_empty()); assert!(input.fields[1].text.is_empty()); // New order: [URL (required), Name (optional)]. assert!(input.fields[0].required); assert!(!input.fields[1].required); assert_eq!(input.focused, 0); } // ── Word boundary helpers ─────────────────────────────────────── #[test] fn prev_word_boundary_basic() { assert_eq!(prev_word_boundary("hello world", 11), 6); assert_eq!(prev_word_boundary("hello world", 6), 0); assert_eq!(prev_word_boundary("hello world", 5), 0); assert_eq!(prev_word_boundary("hello world", 0), 0); } #[test] fn prev_word_boundary_multiple_spaces() { assert_eq!(prev_word_boundary("a b c", 7), 6); assert_eq!(prev_word_boundary("a b c", 6), 3); assert_eq!(prev_word_boundary("a b c", 3), 0); } #[test] fn prev_word_boundary_url() { let url = "https://mcp.linear.app/mcp"; assert_eq!(prev_word_boundary(url, url.len()), 0); } #[test] fn next_word_boundary_basic() { assert_eq!(next_word_boundary("hello world", 0), 5); assert_eq!(next_word_boundary("hello world", 5), 11); assert_eq!(next_word_boundary("hello world", 6), 11); assert_eq!(next_word_boundary("hello world", 11), 11); } #[test] fn next_word_boundary_multiple_spaces() { assert_eq!(next_word_boundary("a b c", 0), 1); assert_eq!(next_word_boundary("a b c", 1), 4); assert_eq!(next_word_boundary("a b c", 4), 7); } #[test] fn next_word_boundary_url() { let url = "https://mcp.linear.app/mcp"; assert_eq!(next_word_boundary(url, 0), url.len()); } #[test] fn prev_word_boundary_mid_word() { assert_eq!(prev_word_boundary("hello world", 3), 0); } #[test] fn next_word_boundary_mid_word() { assert_eq!(next_word_boundary("hello world", 3), 5); } #[test] fn prev_word_boundary_in_whitespace_run() { assert_eq!(prev_word_boundary("a b", 3), 0); } // ── delete_word_backward ──────────────────────────────────────── fn make_field(text: &str, cursor: usize) -> ModalInputField { ModalInputField { label: String::new(), text: text.into(), cursor, required: false, placeholder: None, } } #[test] fn delete_word_backward_at_end() { let mut f = make_field("hello world", 11); f.delete_word_backward(); assert_eq!(f.text, "hello "); assert_eq!(f.cursor, 6); } #[test] fn delete_word_backward_mid_word() { let mut f = make_field("hello world", 8); f.delete_word_backward(); assert_eq!(f.text, "hello rld"); assert_eq!(f.cursor, 6); } #[test] fn delete_word_backward_at_start_is_noop() { let mut f = make_field("hello", 0); f.delete_word_backward(); assert_eq!(f.text, "hello"); assert_eq!(f.cursor, 0); } // ── build_action_from_input / parse_mcp_add_fields ────────────── // Field order in submission: [URL / Command, Name]. URL is required. #[test] fn mcp_add_url_derives_name() { let texts = vec!["https://mcp.linear.app/mcp".into(), "".into()]; let action = build_action_from_input("mcp_add", &texts); match action { Some(ButtonAction::AddMcpServer { name, config }) => { assert_eq!(name, "linear"); assert!(matches!( config.transport, kigi_shell::util::config::McpServerTransportConfig::StreamableHttp { .. } )); } other => panic!("expected AddMcpServer, got {other:?}"), } } #[test] fn mcp_add_explicit_name_and_url() { let texts = vec!["https://example.com".into(), "my-server".into()]; let action = build_action_from_input("mcp_add", &texts); match action { Some(ButtonAction::AddMcpServer { name, .. }) => { assert_eq!(name, "my-server"); } other => panic!("expected AddMcpServer, got {other:?}"), } } #[test] fn mcp_add_command_with_args() { let texts = vec!["npx -y @some/mcp".into(), "srv".into()]; let action = build_action_from_input("mcp_add", &texts); match action { Some(ButtonAction::AddMcpServer { name, config }) => { assert_eq!(name, "srv"); match config.transport { kigi_shell::util::config::McpServerTransportConfig::Stdio { command, args, .. } => { assert_eq!(command, "npx"); assert_eq!(args, vec!["-y", "@some/mcp"]); } other => panic!("expected Stdio, got {other:?}"), } } other => panic!("expected AddMcpServer, got {other:?}"), } } #[test] fn mcp_add_empty_url_returns_none() { let texts = vec!["".into(), "name".into()]; assert!(build_action_from_input("mcp_add", &texts).is_none()); } #[test] fn build_action_single_field_plugins() { let texts = vec!["/path/to/plugin".into()]; let action = build_action_from_input("plugins_install", &texts); assert!(matches!( action, Some(ButtonAction::PluginsAction( kigi_hooks_plugins_types::PluginsAction::Install { .. } )) )); } #[test] fn build_action_unknown_prefix_returns_none() { let texts = vec!["foo".into()]; assert!(build_action_from_input("unknown", &texts).is_none()); } // ── Key dispatch (ModalInput::handle_key) ─────────────────────── fn key_event(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { KeyEvent::new(code, modifiers) } #[test] fn handle_key_esc_cancels() { let mut input = single_field_input("test"); input.fields[0].text = "some text".into(); assert!(matches!( input.handle_key(&key_event(KeyCode::Esc, KeyModifiers::NONE)), ModalInputOutcome::Cancel )); } #[test] fn handle_key_char_inserts() { let mut input = single_field_input("test"); input.handle_key(&key_event(KeyCode::Char('a'), KeyModifiers::NONE)); assert_eq!(input.fields[0].text, "a"); assert_eq!(input.fields[0].cursor, 1); } #[test] fn handle_key_ctrl_char_does_not_insert() { let mut input = single_field_input("test"); let result = input.handle_key(&key_event(KeyCode::Char('x'), KeyModifiers::CONTROL)); assert!(matches!(result, ModalInputOutcome::Unchanged)); assert!(input.fields[0].text.is_empty()); } #[test] fn handle_key_backspace_deletes() { let mut input = single_field_input("test"); input.fields[0].text = "ab".into(); input.fields[0].cursor = 2; input.handle_key(&key_event(KeyCode::Backspace, KeyModifiers::NONE)); assert_eq!(input.fields[0].text, "a"); assert_eq!(input.fields[0].cursor, 1); } #[test] fn handle_key_delete_forward() { let mut input = single_field_input("test"); input.fields[0].text = "ab".into(); input.fields[0].cursor = 0; input.handle_key(&key_event(KeyCode::Delete, KeyModifiers::NONE)); assert_eq!(input.fields[0].text, "b"); assert_eq!(input.fields[0].cursor, 0); } #[test] fn handle_key_ctrl_u_kills_to_start() { let mut input = single_field_input("test"); input.fields[0].text = "hello world".into(); input.fields[0].cursor = 5; input.handle_key(&key_event(KeyCode::Char('u'), KeyModifiers::CONTROL)); assert_eq!(input.fields[0].text, " world"); assert_eq!(input.fields[0].cursor, 0); } #[test] fn handle_key_ctrl_k_kills_to_end() { let mut input = single_field_input("test"); input.fields[0].text = "hello world".into(); input.fields[0].cursor = 5; input.handle_key(&key_event(KeyCode::Char('k'), KeyModifiers::CONTROL)); assert_eq!(input.fields[0].text, "hello"); assert_eq!(input.fields[0].cursor, 5); } #[test] fn handle_key_tab_navigates_multi_field() { let mut input = mcp_add_input(); assert_eq!(input.focused, 0); input.handle_key(&key_event(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(input.focused, 1); input.handle_key(&key_event(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(input.focused, 0); } #[test] fn handle_key_submit_validates_required() { let mut input = mcp_add_input(); let result = input.handle_key(&key_event(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(result, ModalInputOutcome::Changed)); assert!(input.error.is_some()); assert!(input.error.as_ref().unwrap().contains("URL / Command")); } #[test] fn handle_key_submit_succeeds() { let mut input = mcp_add_input(); // URL is the first (required) field in the new order. input.fields[0].text = "https://example.com".into(); let result = input.handle_key(&key_event(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(result, ModalInputOutcome::Submit { .. })); } #[test] fn handle_key_home_end() { let mut input = single_field_input("test"); input.fields[0].text = "hello".into(); input.fields[0].cursor = 3; input.handle_key(&key_event(KeyCode::Home, KeyModifiers::NONE)); assert_eq!(input.fields[0].cursor, 0); input.handle_key(&key_event(KeyCode::End, KeyModifiers::NONE)); assert_eq!(input.fields[0].cursor, 5); } // ── Hook helpers with StatusFilter ─────────────────────────────── fn make_hook( name: &str, source_dir: &str, disabled: bool, ) -> kigi_hooks_plugins_types::HookInfo { kigi_hooks_plugins_types::HookInfo { name: name.to_string(), event: kigi_hooks_plugins_types::HookEvent::PreToolUse, handler_type: kigi_hooks_plugins_types::HookHandlerType::Command, matcher: None, command: Some("/bin/true".to_string()), url: None, timeout_ms: 10_000, source_dir: source_dir.to_string(), disabled, } } #[test] fn next_visible_hook_filter_enabled() { let hooks = vec![ make_hook("a", "/src", true), // disabled make_hook("b", "/src", false), // enabled make_hook("c", "/src", true), // disabled ]; let collapsed = std::collections::HashSet::new(); // From index 0, next enabled hook is index 1. assert_eq!( next_visible_hook(&hooks, 0, &collapsed, StatusFilter::Enabled, ""), Some(1) ); // From index 1, no enabled hook after it. assert_eq!( next_visible_hook(&hooks, 1, &collapsed, StatusFilter::Enabled, ""), None ); } #[test] fn prev_visible_hook_filter_enabled() { let hooks = vec![ make_hook("a", "/src", false), // enabled make_hook("b", "/src", true), // disabled make_hook("c", "/src", false), // enabled ]; let collapsed = std::collections::HashSet::new(); // From index 2, prev enabled hook is index 0. assert_eq!( prev_visible_hook(&hooks, 2, &collapsed, StatusFilter::Enabled, ""), Some(0) ); // From index 0, no enabled hook before it. assert_eq!( prev_visible_hook(&hooks, 0, &collapsed, StatusFilter::Enabled, ""), None ); } #[test] fn next_visible_hook_filter_disabled() { let hooks = vec![ make_hook("a", "/src", false), // enabled make_hook("b", "/src", true), // disabled make_hook("c", "/src", false), // enabled ]; let collapsed = std::collections::HashSet::new(); // From index 0, next disabled hook is index 1. assert_eq!( next_visible_hook(&hooks, 0, &collapsed, StatusFilter::Disabled, ""), Some(1) ); } #[test] fn next_visible_hook_filter_all_same_as_unfiltered() { let hooks = vec![ make_hook("a", "/src", false), make_hook("b", "/src", true), make_hook("c", "/src", false), ]; let collapsed = std::collections::HashSet::new(); assert_eq!( next_visible_hook(&hooks, 0, &collapsed, StatusFilter::All, ""), Some(1) ); assert_eq!( next_visible_hook(&hooks, 1, &collapsed, StatusFilter::All, ""), Some(2) ); } #[test] fn next_visible_hook_filter_across_groups() { let hooks = vec![ make_hook("a", "/src1", true), // disabled, group 1 make_hook("b", "/src2", false), // enabled, group 2 ]; let collapsed = std::collections::HashSet::new(); // With Enabled filter, hook 0 is excluded. Only hook 1 is in groups. // Starting from hook 0 (filtered out), should find hook 1. assert_eq!( next_visible_hook(&hooks, 0, &collapsed, StatusFilter::Enabled, ""), Some(1) ); } #[test] fn build_hook_groups_respects_filter() { let hooks = vec![ make_hook("a", "/src", false), // enabled make_hook("b", "/src", true), // disabled make_hook("c", "/other", false), // enabled ]; let groups = build_hook_groups(&hooks, StatusFilter::Enabled, ""); // Two groups: /src with [0], /other with [2]. Hook 1 excluded. assert_eq!(groups.len(), 2); assert_eq!(groups[0].1, vec![0]); assert_eq!(groups[1].1, vec![2]); let groups_disabled = build_hook_groups(&hooks, StatusFilter::Disabled, ""); // One group: /src with [1]. assert_eq!(groups_disabled.len(), 1); assert_eq!(groups_disabled[0].1, vec![1]); } #[test] fn modal_state_filters_default_to_all() { let state = ExtensionsModalState::new(ExtensionsTab::Hooks); assert_eq!(state.hooks_filter, StatusFilter::All); assert_eq!(state.plugins_filter, StatusFilter::All); assert_eq!(state.mcps_filter, StatusFilter::All); } fn buffer_count(buf: &Buffer, needle: &str) -> usize { let area = *buf.area(); let mut count = 0usize; for y in area.top()..area.bottom() { let mut row = String::new(); for x in area.left()..area.right() { row.push_str(buf[(x, y)].symbol()); } count += row.matches(needle).count(); } count } // ── Plugins: origin grouping ───────────────────────────────────── fn plugins_modal_state( plugins: Vec, ) -> ExtensionsModalState { let mut state = ExtensionsModalState::new(ExtensionsTab::Plugins); state.plugins_data = TabDataState::Loaded(kigi_hooks_plugins_types::PluginsListResponse { plugins }); state } fn render_plugins_into_buffer(state: &mut ExtensionsModalState, w: u16, h: u16) -> Buffer { let area = Rect::new(0, 0, w, h); let mut buf = Buffer::empty(area); render_extensions_modal(&mut buf, area, state, None, false, 0); buf } #[test] fn plugin_group_maps_each_origin_variant() { use kigi_hooks_plugins_types::PluginOrigin; for (origin, rank, key, label) in [ (PluginOrigin::ProjectKigi, 0, "origin:project", "Project"), ( PluginOrigin::ProjectClaude, 1, "origin:project-claude", "Project (Claude)", ), (PluginOrigin::UserKigi, 2, "origin:user", "User"), ( PluginOrigin::UserClaude, 3, "origin:user-claude", "User (Claude)", ), ( PluginOrigin::ClaudeInstalled { marketplace: None }, 3, "origin:user-claude", "User (Claude)", ), ( PluginOrigin::ClaudeMarketplace { marketplace: "mp".into(), }, 4, "claude-mp:mp", "mp", ), ( PluginOrigin::MarketplaceInstall { source_name: Some("xAI Official".into()), git_url: Some("https://example.com/r.git".into()), }, 5, "kigi-mp:xAI Official", "xAI Official", ), ( PluginOrigin::MarketplaceInstall { source_name: None, git_url: Some("https://example.com/r.git".into()), }, 6, "origin:direct", "Direct installs", ), (PluginOrigin::CliOverride, 7, "origin:cli", "CLI override"), (PluginOrigin::ConfigPath, 8, "origin:config", "Custom paths"), ] { let group = plugin_group(&make_plugin_with_origin("p", origin.clone())); assert_eq!(group.rank, rank, "{origin:?}"); assert_eq!(group.key, key, "{origin:?}"); assert_eq!(group.label, label, "{origin:?}"); } } #[test] fn plugin_group_merges_claude_marketplace_and_installed() { use kigi_hooks_plugins_types::PluginOrigin; let catalog = plugin_group(&make_plugin_with_origin( "a", PluginOrigin::ClaudeMarketplace { marketplace: "mp".into(), }, )); let installed = plugin_group(&make_plugin_with_origin( "b", PluginOrigin::ClaudeInstalled { marketplace: Some("mp".into()), }, )); assert_eq!(catalog, installed); } #[test] fn plugin_group_fallback_without_origin() { let mut project = make_plugin("proj"); project.scope = kigi_hooks_plugins_types::PluginScope::Project; assert_eq!(plugin_group(&project).key, "origin:project"); let user = make_plugin("plain"); assert_eq!(plugin_group(&user).key, "origin:user"); let mut cli = make_plugin("cli-tool"); cli.scope = kigi_hooks_plugins_types::PluginScope::Cli; assert_eq!(plugin_group(&cli).key, "origin:cli"); let mut config = make_plugin("cfg-tool"); config.scope = kigi_hooks_plugins_types::PluginScope::Config; assert_eq!(plugin_group(&config).key, "origin:config"); let mut mp = make_plugin("mp-tool"); mp.marketplace_source = Some("xAI Official".into()); let group = plugin_group(&mp); assert_eq!(group.key, "kigi-mp:xAI Official"); assert_eq!(group.label, "xAI Official"); let mut direct = make_plugin("direct-tool"); direct.marketplace_source = Some("git: owner/repo".into()); assert_eq!(plugin_group(&direct).key, "origin:direct"); } #[test] fn plugin_group_unknown_origin_uses_scope_fallback() { let mut unknown = make_plugin_with_origin( "future-tool", kigi_hooks_plugins_types::PluginOrigin::Unknown, ); assert_eq!(plugin_group(&unknown).key, "origin:user"); unknown.marketplace_source = Some("xAI Official".into()); assert_eq!(plugin_group(&unknown).key, "kigi-mp:xAI Official"); } #[test] fn plugins_render_groups_with_headers_in_rank_order() { use kigi_hooks_plugins_types::PluginOrigin; let mut state = plugins_modal_state(vec![ make_plugin_with_origin( "mp-tool", PluginOrigin::ClaudeMarketplace { marketplace: "claude-market".into(), }, ), make_plugin_with_origin("user-tool", PluginOrigin::UserKigi), make_plugin_with_origin("claude-tool", PluginOrigin::UserClaude), ]); let buf = render_plugins_into_buffer(&mut state, 100, 40); assert_eq!(buffer_count(&buf, "User (1 plugin)"), 1); assert_eq!(buffer_count(&buf, "User (Claude) (1 plugin)"), 1); assert_eq!(buffer_count(&buf, "claude-market (1 plugin)"), 1); assert_eq!(buffer_count(&buf, "user-tool"), 1); assert_eq!(buffer_count(&buf, "claude-tool"), 1); assert_eq!(buffer_count(&buf, "mp-tool"), 1); assert_eq!( state.entry_group_keys, vec![ Some("origin:user".to_string()), None, Some("origin:user-claude".to_string()), None, Some("claude-mp:claude-market".to_string()), None, ] ); assert_eq!( state.entry_data_indices, vec![None, Some(1), None, Some(2), None, Some(0)] ); } #[test] fn plugins_render_multiple_plugins_under_one_group() { use kigi_hooks_plugins_types::PluginOrigin; let mut state = plugins_modal_state(vec![ make_plugin_with_origin("solo-tool", PluginOrigin::UserKigi), make_plugin_with_origin( "catalog-tool", PluginOrigin::ClaudeMarketplace { marketplace: "claude-market".into(), }, ), make_plugin_with_origin( "installed-tool", PluginOrigin::ClaudeInstalled { marketplace: Some("claude-market".into()), }, ), ]); let buf = render_plugins_into_buffer(&mut state, 100, 40); assert_eq!( buffer_count(&buf, "claude-market (2 plugins)"), 1, "catalog and installed entries for the same marketplace share one group" ); assert_eq!(buffer_count(&buf, "catalog-tool"), 1); assert_eq!(buffer_count(&buf, "installed-tool"), 1); assert_eq!( state.entry_group_keys, vec![ Some("origin:user".to_string()), None, Some("claude-mp:claude-market".to_string()), None, None, ] ); assert_eq!( state.entry_data_indices, vec![None, Some(0), None, Some(1), Some(2)], "children keep data order within their group" ); state .plugins_collapsed_groups .insert("claude-mp:claude-market".into()); let buf = render_plugins_into_buffer(&mut state, 100, 40); assert_eq!(buffer_count(&buf, "catalog-tool"), 0); assert_eq!(buffer_count(&buf, "installed-tool"), 0); assert_eq!( buffer_count(&buf, "solo-tool"), 1, "collapsing one group must not hide siblings" ); } #[test] fn plugins_collapsed_group_hides_rows_and_search_forces_open() { use kigi_hooks_plugins_types::PluginOrigin; let mut plugin = make_plugin_with_origin("user-tool", PluginOrigin::UserKigi); plugin.root = "/opt/p1".into(); let mut state = plugins_modal_state(vec![plugin]); state.plugins_collapsed_groups.insert("origin:user".into()); let buf = render_plugins_into_buffer(&mut state, 100, 40); assert_eq!(buffer_count(&buf, "User (1 plugin)"), 1); assert_eq!(buffer_count(&buf, "user-tool"), 0); state.picker_state.query = "user".into(); let buf = render_plugins_into_buffer(&mut state, 100, 40); assert_eq!( buffer_count(&buf, "user-tool"), 1, "search must flatten collapsed groups" ); } #[test] fn plugins_fallback_grouping_without_origin() { let mut direct = make_plugin("direct-tool"); direct.marketplace_source = Some("git: owner/repo".into()); let mut mp = make_plugin("official-tool"); mp.marketplace_source = Some("xAI Official".into()); let plain = make_plugin("plain-tool"); let mut state = plugins_modal_state(vec![direct, mp, plain]); let buf = render_plugins_into_buffer(&mut state, 100, 40); assert_eq!(buffer_count(&buf, "User (1 plugin)"), 1); assert_eq!(buffer_count(&buf, "xAI Official (1 plugin)"), 1); assert_eq!(buffer_count(&buf, "Direct installs (1 plugin)"), 1); } #[test] fn plugins_status_filter_omits_empty_groups() { use kigi_hooks_plugins_types::PluginOrigin; let mut disabled = make_plugin_with_origin("off-tool", PluginOrigin::UserClaude); disabled.enabled = false; let mut state = plugins_modal_state(vec![ make_plugin_with_origin("user-tool", PluginOrigin::UserKigi), disabled, ]); state.plugins_filter = StatusFilter::Disabled; let buf = render_plugins_into_buffer(&mut state, 100, 40); assert_eq!( buffer_count(&buf, "User (1 plugin)"), 0, "group with no matching plugins must be omitted" ); assert_eq!(buffer_count(&buf, "User (Claude) (1 plugin)"), 1); assert_eq!(buffer_count(&buf, "off-tool"), 1); assert_eq!(buffer_count(&buf, "[disabled]"), 1); } }