use anyhow::{Context, Result};
use std::sync::Arc;
use vtcode_core::utils::ansi::MessageStyle;
use vtcode_ui::tui::app::{InlineListItem, InlineListSearchConfig, InlineListSelection, WizardModalMode};
use crate::agent::runloop::unified::session_setup::session_mcp_config;
use crate::agent::runloop::unified::wizard_modal::{WizardModalOutcome, show_wizard_modal_and_wait};
use crate::agent::runloop::{PluginCommandAction, PluginCommandOutcome, handle_plugin_command};
use super::mcp::apply_manual_mcp_refresh;
use super::{SlashCommandContext, SlashCommandControl};
const PLUGIN_ACTION_PREFIX: &str = "plugins.action.";
const PLUGIN_ACTION_BACK: &str = "plugins.action.back";
const PLUGIN_PICK_PREFIX: &str = "plugins.pick.";
const PLUGIN_PICK_BACK_ACTION: &str = "plugins.pick.back";
const PLUGIN_ADD_SOURCE_QUESTION_ID: &str = "plugins.add.source";
pub(crate) async fn handle_manage_plugins(
mut ctx: SlashCommandContext<'_>,
action: PluginCommandAction,
) -> Result<SlashCommandControl> {
if matches!(action, PluginCommandAction::Interactive) {
return run_interactive_plugins_manager(&mut ctx).await;
}
if matches!(action, PluginCommandAction::Refresh) {
refresh_plugin_mcp_runtime(&mut ctx).await?;
return Ok(SlashCommandControl::Continue);
}
let outcome = handle_plugin_command(action, ctx.config.workspace.clone()).await?;
if matches!(outcome, PluginCommandOutcome::Installed { .. } | PluginCommandOutcome::Removed { .. }) {
refresh_plugin_mcp_runtime(&mut ctx).await?;
}
apply_plugin_command_outcome(&mut ctx, outcome).await
}
async fn refresh_plugin_mcp_runtime(ctx: &mut SlashCommandContext<'_>) -> Result<()> {
let Some(manager) = ctx.async_mcp_manager.map(Arc::clone) else {
ctx.renderer.line(
MessageStyle::Info,
"MCP is not active in this session; plugin MCP providers will be discovered at the next session start.",
)?;
return Ok(());
};
let new_config =
session_mcp_config(ctx.vt_cfg.as_ref(), Some(ctx.active_primary_agent.active()), &ctx.config.workspace);
let restarted = manager.reconfigure_active_runtime(new_config).await?;
if restarted {
apply_manual_mcp_refresh(ctx, "plugin_refresh").await;
ctx.renderer
.line(MessageStyle::Info, "MCP runtime reconfigured; plugin MCP providers are reconnecting.")?;
} else {
ctx.renderer
.line(MessageStyle::Info, "Plugin MCP providers will be picked up at the next MCP initialization.")?;
}
Ok(())
}
async fn apply_plugin_command_outcome(
ctx: &mut SlashCommandContext<'_>,
outcome: PluginCommandOutcome,
) -> Result<SlashCommandControl> {
match outcome {
PluginCommandOutcome::Handled { message } => {
ctx.renderer.line(MessageStyle::Info, &message)?;
Ok(SlashCommandControl::Continue)
}
PluginCommandOutcome::Installed { message } => {
ctx.renderer.line(MessageStyle::Output, &message)?;
Ok(SlashCommandControl::Continue)
}
PluginCommandOutcome::Removed { message } => {
ctx.renderer.line(MessageStyle::Info, &message)?;
Ok(SlashCommandControl::Continue)
}
PluginCommandOutcome::Error { message } => {
ctx.renderer.line(MessageStyle::Error, &message)?;
Ok(SlashCommandControl::Continue)
}
}
}
async fn execute_plugin_action(ctx: &mut SlashCommandContext<'_>, action: PluginCommandAction) -> Result<()> {
let outcome = handle_plugin_command(action, ctx.config.workspace.clone()).await?;
let _ = apply_plugin_command_outcome(ctx, outcome).await?;
Ok(())
}
async fn run_interactive_plugins_manager(ctx: &mut SlashCommandContext<'_>) -> Result<SlashCommandControl> {
if !ctx.renderer.supports_inline_ui() {
execute_plugin_action(ctx, PluginCommandAction::Help).await?;
return Ok(SlashCommandControl::Continue);
}
loop {
show_plugins_manager_actions_modal(ctx);
let Some(selection) = super::ui::wait_for_list_modal_selection(ctx).await else {
return Ok(SlashCommandControl::Continue);
};
let InlineListSelection::ConfigAction(action) = selection else {
continue;
};
if action == PLUGIN_ACTION_BACK {
return Ok(SlashCommandControl::Continue);
}
let Some(action_key) = action.strip_prefix(PLUGIN_ACTION_PREFIX) else {
continue;
};
match action_key {
"list" => execute_plugin_action(ctx, PluginCommandAction::List).await?,
"info" => {
if let Some(name) = pick_installed_plugin(ctx, "Plugin Details", "Select a plugin to inspect.").await? {
execute_plugin_action(ctx, PluginCommandAction::Info { name }).await?;
}
}
"add" => {
if let Some(source) = prompt_source(ctx).await? {
execute_plugin_action(ctx, PluginCommandAction::Add { source, name: None }).await?;
}
}
"remove" => {
if let Some(name) = pick_installed_plugin(ctx, "Remove Plugin", "Select a plugin to uninstall.").await?
{
execute_plugin_action(ctx, PluginCommandAction::Remove { name }).await?;
}
}
"refresh" => execute_plugin_action(ctx, PluginCommandAction::Refresh).await?,
"help" => execute_plugin_action(ctx, PluginCommandAction::Help).await?,
_ => continue,
}
}
}
async fn prompt_source(ctx: &mut SlashCommandContext<'_>) -> Result<Option<String>> {
let step = vtcode_ui::tui::app::WizardStep {
title: "Source".to_string(),
question: "Provide a git URL or local directory to install the plugin from.".to_string(),
items: vec![InlineListItem {
title: "Submit".to_string(),
subtitle: Some("Press Tab to type the source, then Enter to install.".to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::RequestUserInputAnswer {
question_id: PLUGIN_ADD_SOURCE_QUESTION_ID.to_string(),
selected: vec![],
other: Some(String::new()),
}),
search_value: Some("submit source".to_string()),
}],
completed: false,
answer: None,
allow_freeform: true,
freeform_label: Some("Source".to_string()),
freeform_placeholder: Some("https://github.com/org/plugin or /local/path".to_string()),
freeform_default: None,
};
let outcome = show_wizard_modal_and_wait(
ctx.handle,
ctx.session,
"Add Plugin".to_string(),
vec![step],
0,
None,
WizardModalMode::MultiStep,
ctx.ctrl_c_state,
ctx.ctrl_c_notify,
)
.await?;
let value = match outcome {
WizardModalOutcome::Submitted(selections) => selections.into_iter().find_map(|selection| match selection {
InlineListSelection::RequestUserInputAnswer { question_id, selected, other }
if question_id == PLUGIN_ADD_SOURCE_QUESTION_ID =>
{
other.or_else(|| selected.first().cloned())
}
_ => None,
}),
WizardModalOutcome::Cancelled { .. } => None,
};
let Some(value) = value else {
return Ok(None);
};
let trimmed = value.trim().to_string();
if trimmed.is_empty() {
ctx.renderer
.line(MessageStyle::Info, "Plugin source was empty. Nothing installed.")?;
return Ok(None);
}
Ok(Some(trimmed))
}
async fn pick_installed_plugin(
ctx: &mut SlashCommandContext<'_>,
title: &str,
description: &str,
) -> Result<Option<String>> {
let workspace = ctx.config.workspace.clone();
let entries = tokio::task::spawn_blocking(move || {
crate::agent::runloop::plugin_commands::discover_installed_plugins(&workspace)
})
.await
.context("plugin discovery task panicked")?;
if entries.is_empty() {
ctx.renderer
.line(MessageStyle::Info, "No installed plugins. Add one with 'Add plugin' or '/plugin add <source>'.")?;
return Ok(None);
}
let mut items: Vec<InlineListItem> = entries
.iter()
.map(|entry| {
let version = entry.version.as_deref().unwrap_or("unknown");
InlineListItem {
title: entry.name.clone(),
subtitle: Some(format!(
"v{version} — {} skill(s), {} MCP server(s) — {}",
entry.skill_count,
entry.mcp_server_count,
entry.scope.label()
)),
badge: None,
indent: 0,
selection: Some(InlineListSelection::ConfigAction(format!("{}{}", PLUGIN_PICK_PREFIX, entry.name))),
search_value: Some(format!("{} {} {}", entry.name, entry.description, entry.scope.label())),
}
})
.collect();
items.push(InlineListItem {
title: "Back".to_string(),
subtitle: Some("Cancel and return".to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::ConfigAction(PLUGIN_PICK_BACK_ACTION.to_string())),
search_value: Some("back cancel".to_string()),
});
ctx.renderer.show_list_modal(
title,
vec![description.to_string()],
items,
entries
.first()
.map(|entry| InlineListSelection::ConfigAction(format!("{}{}", PLUGIN_PICK_PREFIX, entry.name))),
Some(InlineListSearchConfig {
label: String::new(),
placeholder: Some("Type plugin name".to_string()),
}),
);
let Some(selection) = super::ui::wait_for_list_modal_selection(ctx).await else {
return Ok(None);
};
let InlineListSelection::ConfigAction(action) = selection else {
return Ok(None);
};
if action == PLUGIN_PICK_BACK_ACTION {
return Ok(None);
}
Ok(action.strip_prefix(PLUGIN_PICK_PREFIX).map(ToString::to_string))
}
fn show_plugins_manager_actions_modal(ctx: &mut SlashCommandContext<'_>) {
let items = vec![
InlineListItem {
title: "List plugins".to_string(),
subtitle: Some("Show all installed plugins".to_string()),
badge: Some("Recommended".to_string()),
indent: 0,
selection: Some(InlineListSelection::ConfigAction(format!("{PLUGIN_ACTION_PREFIX}list"))),
search_value: Some("list plugins".to_string()),
},
InlineListItem {
title: "Add plugin".to_string(),
subtitle: Some("Install a plugin from a git URL or local directory".to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::ConfigAction(format!("{PLUGIN_ACTION_PREFIX}add"))),
search_value: Some("install add plugin".to_string()),
},
InlineListItem {
title: "Remove plugin".to_string(),
subtitle: Some("Uninstall a plugin".to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::ConfigAction(format!("{PLUGIN_ACTION_PREFIX}remove"))),
search_value: Some("remove uninstall plugin".to_string()),
},
InlineListItem {
title: "Plugin details".to_string(),
subtitle: Some("Show metadata and components for a plugin".to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::ConfigAction(format!("{PLUGIN_ACTION_PREFIX}info"))),
search_value: Some("details info metadata plugin".to_string()),
},
InlineListItem {
title: "Refresh MCP providers".to_string(),
subtitle: Some("Re-discover plugin-provided MCP servers".to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::ConfigAction(format!("{PLUGIN_ACTION_PREFIX}refresh"))),
search_value: Some("refresh mcp providers".to_string()),
},
InlineListItem {
title: "Show help".to_string(),
subtitle: Some("Display `/plugin` command help".to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::ConfigAction(format!("{PLUGIN_ACTION_PREFIX}help"))),
search_value: Some("help commands plugin".to_string()),
},
InlineListItem {
title: "Back".to_string(),
subtitle: Some("Close plugin manager".to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::ConfigAction(PLUGIN_ACTION_BACK.to_string())),
search_value: Some("back close".to_string()),
},
];
ctx.renderer.show_list_modal(
"Plugin Manager",
vec![
"Manage portable Agent Plugins interactively.".to_string(),
"Use Enter to run an action, Esc to close.".to_string(),
],
items,
Some(InlineListSelection::ConfigAction(format!("{PLUGIN_ACTION_PREFIX}list"))),
None,
);
}