The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok' crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party license archives, README provenance, and the required 'Based on Grok Build Open Source' attribution, now sourced from version_attribution.txt). Wire-visible renames (both sides in this repo, changed in lockstep): - Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode). - Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* / _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps a read-side alias for the legacy '_x.ai/session/update' method so existing updates.jsonl histories load; writes emit only the new name (both directions test-pinned). - Agent types grok-build* → kigi* with a documented legacy-prefix alias at resolution time so persisted sessions keep resolving. - ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build implementation dirs renamed to kigi*. - x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes grokday/groknight → kigiday/kiginight (old persisted values fall back to the default theme), web_fetch allowlist xAI hosts → kimi.com + moonshot platforms, changelog CDN → this repo, grok-build changelog archives deleted. - BYOK default endpoint removed: [endpoints] api_base_url is now truly optional with NO default — consumers fail fast with the flag name when unset (no silent x.ai egress). Mock harnesses inject it explicitly. - System-prompt identity fixed: 'released by xAI' → 'an unofficial community CLI for Kimi' (template + regenerated encrypted form). Also repaired pre-existing grok-era test debt found by the sweep: the stale trace_classify default-model pin, the grok-pager UA label test, pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY test could previously reach the real api.moonshot.cn), and the outdated oauth fixture scope key. Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings); FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed; deny advisories ok.
5116 lines
195 KiB
Rust
5116 lines
195 KiB
Rust
//! 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::PluginOrigin>,
|
||
) -> 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<usize>)> {
|
||
let mut groups: Vec<(&str, Vec<usize>)> = 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<String>,
|
||
filter: StatusFilter,
|
||
query: &str,
|
||
) -> Option<usize> {
|
||
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<String>,
|
||
filter: StatusFilter,
|
||
query: &str,
|
||
) -> Option<usize> {
|
||
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<usize> = 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<kigi_shell::util::config::McpServerConfig>,
|
||
},
|
||
/// 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<FieldSpec>,
|
||
},
|
||
}
|
||
|
||
/// 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<String>,
|
||
}
|
||
|
||
/// 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<ModalInputField>,
|
||
/// Index of the currently focused field.
|
||
pub focused: usize,
|
||
/// Inline error message shown below the form. Cleared on next keystroke.
|
||
pub error: Option<String>,
|
||
}
|
||
|
||
/// 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<String>,
|
||
}
|
||
|
||
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<FieldSpec>) -> 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<String> {
|
||
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<String>,
|
||
},
|
||
}
|
||
|
||
/// 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<usize>,
|
||
/// 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<usize>],
|
||
entry_group_keys: &[Option<String>],
|
||
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<usize>], selected: usize) -> Option<usize> {
|
||
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<usize>],
|
||
entry_group_keys: &[Option<String>],
|
||
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<usize>],
|
||
entry_group_keys: &[Option<String>],
|
||
selected: usize,
|
||
) -> Option<bool> {
|
||
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<crate::views::shortcuts_bar::HintItem> {
|
||
use crate::input::key::KeyShortcut;
|
||
use crate::views::shortcuts_bar::HintItem;
|
||
use crossterm::event::{KeyCode, KeyModifiers};
|
||
|
||
let mut hints: Vec<HintItem> = 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<ButtonAction> {
|
||
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<String> {
|
||
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<String> = 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<String> = 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<ButtonAction> {
|
||
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<ButtonAction> {
|
||
use kigi_shell::util::config::{McpServerConfig, McpServerTransportConfig};
|
||
|
||
let mut parts = url_or_cmd.split_whitespace();
|
||
let command_or_url = parts.next()?;
|
||
let rest: Vec<String> = 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<T> {
|
||
/// 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<kigi_hooks_plugins_types::HooksListResponse>,
|
||
/// Plugins list data (fetched from shell).
|
||
pub plugins_data: TabDataState<kigi_hooks_plugins_types::PluginsListResponse>,
|
||
/// Cached button hit areas from last render (for mouse click).
|
||
pub button_areas: Vec<ButtonArea>,
|
||
/// Active inline input (when the user is typing an argument for a command).
|
||
/// `None` = normal button mode, `Some` = input mode.
|
||
pub input: Option<ModalInput>,
|
||
/// Modal message state (error, confirmation prompt, etc.).
|
||
pub modal_message: Option<ModalMessage>,
|
||
/// Description of an in-flight action (blocks buttons while set).
|
||
pub pending_action: Option<String>,
|
||
/// 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<usize>,
|
||
/// 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<ActionResultNotice>,
|
||
/// Last dispatched plugins action (for confirmation replay).
|
||
pub last_plugins_action: Option<kigi_hooks_plugins_types::PluginsAction>,
|
||
/// 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<Option<usize>>,
|
||
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<Vec<SkillInfo>>,
|
||
pub skills_selected: usize,
|
||
pub skills_scroll: usize,
|
||
/// MCP servers tab state.
|
||
pub mcps_data: TabDataState<Vec<crate::views::mcps_modal::McpServerInfo>>,
|
||
/// Last selection that triggered auto-scroll. Prevents mouse scroll
|
||
/// from being overridden by auto-scroll on every render.
|
||
pub mcps_scroll_pinned_selection: Option<usize>,
|
||
pub mcps_scroll: usize,
|
||
/// Expanded MCP server tool lists (by raw catalog `si`, not picker row index).
|
||
pub mcps_tools_expanded: std::collections::HashSet<usize>,
|
||
/// Collapsed MCP section headers (`mcp-section:*` keys). Key in set = collapsed.
|
||
pub mcps_collapsed_sections: std::collections::HashSet<String>,
|
||
/// 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<Option<usize>>,
|
||
pub hooks_collapsed_groups: std::collections::HashSet<String>,
|
||
/// Collapsed plugin source groups (by [`PluginGroup`] key).
|
||
pub plugins_collapsed_groups: std::collections::HashSet<String>,
|
||
/// 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<usize>,
|
||
/// 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<Option<usize>>,
|
||
/// Cached entry labels from last render (for group header identification in input handler).
|
||
pub entry_labels_cache: Vec<String>,
|
||
/// 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<Option<String>>,
|
||
/// Per-entry keyboard/mouse selectability (rebuilt each render).
|
||
pub entry_non_selectable: Vec<bool>,
|
||
/// MCP section labels: not selectable, but clickable to fold/unfold.
|
||
pub entry_non_selectable_clickable: Vec<bool>,
|
||
}
|
||
|
||
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<usize> {
|
||
data_index_at(&self.entry_data_indices, self.picker_state.selected)
|
||
}
|
||
|
||
pub fn selected_item_enabled(&self) -> Option<bool> {
|
||
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<usize> {
|
||
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<String>],
|
||
) -> Vec<bool> {
|
||
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<String>]) -> Vec<bool> {
|
||
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<String>,
|
||
pub(crate) group_keys: Vec<Option<String>>,
|
||
pub(crate) data_indices: Vec<Option<usize>>,
|
||
}
|
||
|
||
/// 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<String>,
|
||
tools_expanded: &std::collections::HashSet<usize>,
|
||
) -> 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<String>,
|
||
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<String>,
|
||
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/<subdir>/`. Returns the first path
|
||
// component after the subdir (the plugin's install directory name).
|
||
let plugin_name = |subdir: &str| -> Option<String> {
|
||
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/<subdir>/<name>` 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<PickerEntry> 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<String> {
|
||
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<Rect>,
|
||
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<String> = Vec::new();
|
||
let mut entry_right_labels: Vec<String> = Vec::new();
|
||
let mut entry_desc_lines: Vec<Vec<String>> = Vec::new();
|
||
let mut entry_summary_lines: Vec<Vec<String>> = Vec::new();
|
||
let mut entry_fields: Vec<Vec<(String, String)>> = Vec::new();
|
||
let mut entry_is_header: Vec<bool> = Vec::new();
|
||
let mut entry_dimmed: Vec<bool> = Vec::new();
|
||
let mut entry_indent: Vec<u8> = Vec::new();
|
||
let mut entry_group_keys: Vec<Option<String>> = Vec::new();
|
||
let mut entry_badge_text: Vec<String> = Vec::new();
|
||
let mut entry_badge_color: Vec<Option<ratatui::style::Color>> = Vec::new();
|
||
// Maps picker entry index → original data index (for action dispatch).
|
||
let mut entry_data_indices: Vec<Option<usize>> = 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<Vec<picker::PickerField<'_>>> = 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<Vec<&str>> = entry_desc_lines
|
||
.iter()
|
||
.map(|lines| lines.iter().map(|s| s.as_str()).collect())
|
||
.collect();
|
||
let summary_line_refs: Vec<Vec<&str>> = 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<Shortcut<'_>> = 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<picker::PickerEntry<'_>> = 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/<name>/` 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<McpToolDetail> = 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<usize> = 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<kigi_hooks_plugins_types::PluginInfo>,
|
||
) -> 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);
|
||
}
|
||
}
|