Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/apps/cli/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ pub(crate) enum ActionHandler {
McpServers,
Tools,
Extensions,
Plugins,
NativeHooks,
ExternalHooks,
AcpHelp,
Expand Down Expand Up @@ -596,6 +597,21 @@ static ACTION_SPECS: &[ActionSpec] = &[
shortcut_label: None,
slash_on_startup: false,
},
ActionSpec {
id: "plugins",
name: "Plugins",
aliases: &["/plugins"],
description: "Browse managed plugin packages and toggle activation",
contexts: BOTH,
availability: ActionAvailability::Always,
handler: ActionHandler::Plugins,
default_bindings: &[],
fallback_bindings: &[],
shortcut_field: None,
palette: palette("Plugins", false),
shortcut_label: None,
slash_on_startup: true,
},
ActionSpec {
id: "hooks",
name: "Hooks",
Expand Down Expand Up @@ -2127,6 +2143,7 @@ mod tests {
ActionHandler::McpServers,
ActionHandler::Tools,
ActionHandler::Extensions,
ActionHandler::Plugins,
ActionHandler::NativeHooks,
ActionHandler::ExternalHooks,
ActionHandler::Login,
Expand Down
1 change: 1 addition & 0 deletions src/apps/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ mod model_selection;
mod modes;
mod peer_host;
mod plugin_diagnostics;
mod plugin_ops;
mod product_assembly;
mod prompt_stash;
mod prompts;
Expand Down
29 changes: 29 additions & 0 deletions src/apps/cli/src/modes/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ use crate::actions::{
use crate::agent::tui_client::{SessionOperationError, TuiAgentClient, TuiAgentMode};
use crate::chat_state::{ChatState, ModelTokenUsageSnapshot};
use crate::config::CliConfig;
use crate::plugin_ops::{PluginInstallScope, PluginItem};
use crate::ui::agent_selector::{AgentItem, AgentSelectorAction};
use crate::ui::chat::{session_status_text, ChatView, MouseGestureOutcome};
use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection};
Expand All @@ -51,6 +52,7 @@ use crate::ui::mcp_selector::{McpItem, McpItemAction};
use crate::ui::model_config_form::{ModelFormAction, ModelFormResult};
use crate::ui::model_selector::ModelItem;
use crate::ui::permission::PermissionAction;
use crate::ui::plugin_browser::PluginBrowserAction;
use crate::ui::prompt_command_shell_review::PromptCommandShellReviewAction;
use crate::ui::prompt_stash_selector::PromptStashAction;
use crate::ui::provider_selector::ProviderSelection;
Expand Down Expand Up @@ -309,6 +311,26 @@ enum PendingMcpTask {
},
}

/// Pending plugin operation (deferred to allow a render frame for loading state)
enum PendingPluginOp {
Toggle(PluginItem),
Install {
spec: String,
scope: PluginInstallScope,
},
}

enum PendingPluginTask {
Toggle {
plugin_id: String,
handle: tokio::task::JoinHandle<std::result::Result<(), String>>,
},
Install {
spec: String,
handle: tokio::task::JoinHandle<std::result::Result<(), String>>,
},
}

enum PendingSessionOperationKind {
Mode {
mode_id: String,
Expand Down Expand Up @@ -528,6 +550,10 @@ pub(crate) struct ChatMode {
pending_mcp_op: Option<PendingMcpOp>,
/// Running MCP tasks (non-blocking, polled in main loop)
pending_mcp_tasks: Vec<PendingMcpTask>,
/// Pending plugin operation — set in key handler, executed after one render frame
pending_plugin_op: Option<PendingPluginOp>,
/// Running plugin tasks (non-blocking, polled in main loop)
pending_plugin_tasks: Vec<PendingPluginTask>,
/// One Session operation in flight. The event loop remains responsive while
/// the Runtime owner updates or deletes Session state.
pending_session_operation: Option<PendingSessionOperation>,
Expand Down Expand Up @@ -619,6 +645,8 @@ impl ChatMode {
model_id: None,
pending_mcp_op: None,
pending_mcp_tasks: Vec::new(),
pending_plugin_op: None,
pending_plugin_tasks: Vec::new(),
pending_session_operation: None,
pending_workspace_diff: None,
pending_local_effect: None,
Expand Down Expand Up @@ -689,6 +717,7 @@ include!("chat/commands.rs");
include!("chat/worktree.rs");
include!("chat/selection.rs");
include!("chat/mcp.rs");
include!("chat/plugins.rs");
include!("chat/sessions.rs");
include!("chat/workspace_references.rs");
include!("chat/capabilities.rs");
Expand Down
4 changes: 4 additions & 0 deletions src/apps/cli/src/modes/chat/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ impl ChatMode {
|| chat_view.subagent_selector_visible()
|| chat_view.mcp_selector_visible()
|| chat_view.mcp_add_dialog_visible()
|| chat_view.plugin_browser_visible()
|| chat_view.provider_selector_visible()
|| chat_view.model_config_form_visible()
|| chat_view.login_form_visible()
Expand Down Expand Up @@ -224,6 +225,7 @@ impl ChatMode {
chat_view.hide_subagent_selector();
chat_view.hide_mcp_selector();
chat_view.hide_mcp_add_dialog();
chat_view.hide_plugin_browser();
chat_view.hide_provider_selector();
chat_view.hide_model_config_form();
chat_view.hide_login_form();
Expand Down Expand Up @@ -258,6 +260,7 @@ impl ChatMode {
crate::ui::chat::PopupType::SubagentSelector => chat_view.hide_subagent_selector(),
crate::ui::chat::PopupType::McpSelector => chat_view.hide_mcp_selector(),
crate::ui::chat::PopupType::McpAddDialog => chat_view.hide_mcp_add_dialog(),
crate::ui::chat::PopupType::PluginBrowser => chat_view.hide_plugin_browser(),
crate::ui::chat::PopupType::ProviderSelector => chat_view.hide_provider_selector(),
crate::ui::chat::PopupType::ModelConfigForm => chat_view.hide_model_config_form(),
crate::ui::chat::PopupType::LoginForm => chat_view.hide_login_form(),
Expand Down Expand Up @@ -297,6 +300,7 @@ impl ChatMode {
}
crate::ui::chat::PopupType::McpSelector => chat_view.reshow_mcp_selector(),
crate::ui::chat::PopupType::McpAddDialog => chat_view.reshow_mcp_add_dialog(),
crate::ui::chat::PopupType::PluginBrowser => chat_view.reshow_plugin_browser(),
crate::ui::chat::PopupType::ProviderSelector => {
chat_view.reshow_provider_selector()
}
Expand Down
3 changes: 3 additions & 0 deletions src/apps/cli/src/modes/chat/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1107,6 +1107,9 @@ impl ChatMode {
ActionHandler::McpServers => {
self.show_mcp_selector(chat_view, chat_state, rt_handle);
}
ActionHandler::Plugins => {
self.show_plugin_browser(chat_view, chat_state, rt_handle);
}
ActionHandler::Tools => {
self.handle_external_tool_review("", chat_view, chat_state, rt_handle);
}
Expand Down
17 changes: 17 additions & 0 deletions src/apps/cli/src/modes/chat/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,23 @@ impl ChatMode {
return Ok(None);
}

if chat_view.plugin_browser_visible() {
let action = chat_view.plugin_browser_handle_key(key);
match action {
PluginBrowserAction::Toggle(item) => {
self.toggle_plugin(item, chat_view);
}
PluginBrowserAction::Install { spec, scope } => {
self.install_plugin(spec, scope, chat_view);
}
PluginBrowserAction::Dismiss => {
self.navigate_back(chat_view);
}
PluginBrowserAction::None => {}
}
return Ok(None);
}

if chat_view.provider_selector_visible() {
if let Some(selection) = chat_view.provider_selector_handle_key(key) {
self.handle_provider_selection(selection, chat_view);
Expand Down
201 changes: 201 additions & 0 deletions src/apps/cli/src/modes/chat/plugins.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/// Plugin browser ChatMode integration.
///
/// Mirrors the MCP toggle pattern: popups never spawn async work directly.
/// Instead, the popup returns a `PluginBrowserAction::Toggle(item)` from
/// `handle_key_event`; the main key handler schedules `pending_plugin_op`,
/// the run loop renders the loading state, then spawns the toggle on
/// `rt_handle`, stores the `JoinHandle` in `pending_plugin_tasks`, polls
/// `is_finished()` each loop, and on completion refreshes the popup items
/// via `plugin_ops::refresh_plugin_items`.
use crate::plugin_ops::{install_managed_plugin, refresh_plugin_items, toggle_managed_plugin};

impl ChatMode {
/// Show the plugin browser popup. Loads items synchronously via
/// `block_in_place + block_on` so the popup opens with a complete list.
fn show_plugin_browser(
&self,
chat_view: &mut ChatView,
_chat_state: &mut ChatState,
rt_handle: &tokio::runtime::Handle,
) {
let items = self.get_plugin_items(rt_handle);
chat_view.show_plugin_browser(items);
}

/// Load current plugin items via the CLI-local plugin ops boundary.
pub(super) fn get_plugin_items(&self, rt_handle: &tokio::runtime::Handle) -> Vec<PluginItem> {
let workspace = self.agent.workspace_path_buf();
refresh_plugin_items(&workspace, rt_handle)
}

/// Schedule a plugin toggle (deferred to allow the loading state to render).
fn toggle_plugin(&mut self, item: PluginItem, chat_view: &mut ChatView) {
if self.pending_plugin_op.is_some() || self.is_plugin_task_running(&item.id) {
return;
}
chat_view.plugin_browser_set_loading(Some(item.id.clone()));
self.pending_plugin_op = Some(PendingPluginOp::Toggle(item));
}

fn is_plugin_task_running(&self, plugin_id: &str) -> bool {
self.pending_plugin_tasks.iter().any(|task| match task {
PendingPluginTask::Toggle { plugin_id: id, .. } => id == plugin_id,
PendingPluginTask::Install { .. } => false,
})
}

/// Execute the plugin toggle by spawning an async task on `rt_handle`.
/// The task returns `Result<(), String>` so the poll loop can render a
/// uniform error message regardless of the underlying source error type.
fn execute_plugin_toggle(
&mut self,
item: &PluginItem,
_chat_view: &mut ChatView,
_chat_state: &mut ChatState,
rt_handle: &tokio::runtime::Handle,
) {
let workspace = self.agent.workspace_path_buf();
let plugin_id = item.id.clone();
let content_hash = item.content_hash.clone();
let was_activated = item.activated;
let tracked_id = plugin_id.clone();
let handle = rt_handle.spawn(async move {
toggle_managed_plugin(&workspace, &plugin_id, &content_hash, !was_activated).await
});
self.pending_plugin_tasks.push(PendingPluginTask::Toggle {
plugin_id: tracked_id,
handle,
});
}

fn is_install_task_running(&self) -> bool {
self.pending_plugin_tasks
.iter()
.any(|task| matches!(task, PendingPluginTask::Install { .. }))
}

/// Schedule a plugin install (deferred to allow the busy state to render).
fn install_plugin(
&mut self,
spec: String,
scope: PluginInstallScope,
chat_view: &mut ChatView,
) {
if self.pending_plugin_op.is_some() || self.is_install_task_running() {
chat_view.plugin_browser_set_install_busy(false);
return;
}
chat_view.plugin_browser_set_install_busy(true);
self.pending_plugin_op = Some(PendingPluginOp::Install { spec, scope });
}

/// Execute the plugin install by spawning an async task on `rt_handle`.
fn execute_plugin_install(
&mut self,
spec: String,
scope: PluginInstallScope,
_chat_view: &mut ChatView,
_chat_state: &mut ChatState,
rt_handle: &tokio::runtime::Handle,
) {
let workspace = self.agent.workspace_path_buf();
let spec_for_task = spec.clone();
let handle = rt_handle
.spawn(async move { install_managed_plugin(&workspace, &spec_for_task, scope).await });
self.pending_plugin_tasks
.push(PendingPluginTask::Install { spec, handle });
}

/// Poll in-flight plugin tasks. On completion, clears the loading
/// indicator and refreshes the popup items. Returns `true` if any state
/// changed (so the run loop can schedule a redraw).
fn poll_plugin_task_completion(
&mut self,
chat_view: &mut ChatView,
chat_state: &mut ChatState,
rt_handle: &tokio::runtime::Handle,
) -> bool {
let mut changed = false;
let mut i = 0;
while i < self.pending_plugin_tasks.len() {
let finished = match &self.pending_plugin_tasks[i] {
PendingPluginTask::Toggle { handle, .. } => handle.is_finished(),
PendingPluginTask::Install { handle, .. } => handle.is_finished(),
};
if !finished {
i += 1;
continue;
}
let task = self.pending_plugin_tasks.swap_remove(i);
changed = true;
match task {
PendingPluginTask::Toggle { plugin_id, handle } => {
let join_result = tokio::task::block_in_place(|| rt_handle.block_on(handle));
match join_result {
Ok(Ok(())) => {
chat_state
.add_system_message(format!("Plugin '{}' toggled", plugin_id));
}
Ok(Err(error)) => {
tracing::error!("Failed to toggle plugin '{}': {}", plugin_id, error);
chat_state.add_system_message(format!(
"Failed to toggle plugin '{}': {}",
plugin_id, error
));
}
Err(error) => {
tracing::error!(
"Plugin toggle task join error for '{}': {}",
plugin_id,
error
);
chat_state.add_system_message(format!(
"Plugin '{}' toggle task failed: {}",
plugin_id, error
));
}
}
chat_view.plugin_browser_set_loading(None);
let updated_items = self.get_plugin_items(rt_handle);
chat_view.plugin_browser_update_items(updated_items);
}
PendingPluginTask::Install { spec, handle } => {
let join_result = tokio::task::block_in_place(|| rt_handle.block_on(handle));
match join_result {
Ok(Ok(())) => {
chat_view.plugin_browser_set_install_message(None);
chat_state.add_system_message(format!("Plugin '{}' installed", spec));
}
Ok(Err(error)) => {
tracing::error!("Failed to install plugin '{}': {}", spec, error);
chat_view.plugin_browser_set_install_message(Some(error.clone()));
chat_state.add_system_message(format!(
"Failed to install plugin '{}': {}",
spec, error
));
}
Err(error) => {
tracing::error!(
"Plugin install task join error for '{}': {}",
spec,
error
);
chat_view.plugin_browser_set_install_message(Some(format!(
"install task failed: {}",
error
)));
chat_state.add_system_message(format!(
"Plugin '{}' install task failed: {}",
spec, error
));
}
}
chat_view.plugin_browser_set_install_busy(false);
let updated_items = self.get_plugin_items(rt_handle);
chat_view.plugin_browser_update_items(updated_items);
}
}
}
changed
}
}
Loading