Files
Kigi-CLI/crates/codegen/kigi-tui/src/views/extensions_modal.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
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.
2026-07-18 02:48:46 -04:00

5116 lines
195 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(&current) {
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(&current) {
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 &sections {
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 &sections {
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);
}
}