From 10cf40e664c5bee106ee8b1d533124730a46f0d8 Mon Sep 17 00:00:00 2001 From: ldc111 <2812975165@qq.com> Date: Wed, 5 Aug 2026 16:42:47 +0800 Subject: [PATCH 1/2] feat(cli): add plugin browser popup with Shift+I install sub-dialog Add a managed plugin browser popup for both ChatMode and the StartupPage: list/search/scroll managed plugin packages and toggle activation with Space, mirroring deveco-code's plugin manager. - Plugins action handler + command palette entry, popup type, render, and state wired into ChatMode (pending-op + spawned task + poll) and StartupPage (block_in_place), with the existing toggle refresh flow - New ui/plugin_browser.rs and modes/chat/plugins.rs modules - Install sub-dialog opened with Shift+I: single input accepting an npm name/@scope/pkg/pkg@version/file://path spec, Tab to toggle user/project scope, Enter to submit, Esc to go back, with busy + message states - Install dispatch is a placeholder stub returning 'not yet implemented' until bitfun-core exposes an install API; UI flow is fully exercisable --- src/apps/cli/src/actions.rs | 17 + src/apps/cli/src/modes/chat.rs | 28 + src/apps/cli/src/modes/chat/account.rs | 4 + src/apps/cli/src/modes/chat/commands.rs | 3 + src/apps/cli/src/modes/chat/input.rs | 17 + src/apps/cli/src/modes/chat/plugins.rs | 238 ++++++++ src/apps/cli/src/modes/chat/run.rs | 32 ++ src/apps/cli/src/ui/chat/popups.rs | 42 ++ src/apps/cli/src/ui/chat/render.rs | 5 + src/apps/cli/src/ui/chat/state.rs | 13 +- src/apps/cli/src/ui/command_palette.rs | 1 + src/apps/cli/src/ui/mod.rs | 1 + src/apps/cli/src/ui/plugin_browser.rs | 694 ++++++++++++++++++++++++ src/apps/cli/src/ui/startup.rs | 124 +++++ 14 files changed, 1213 insertions(+), 6 deletions(-) create mode 100644 src/apps/cli/src/modes/chat/plugins.rs create mode 100644 src/apps/cli/src/ui/plugin_browser.rs diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index fa49ea017..84b067528 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -93,6 +93,7 @@ pub(crate) enum ActionHandler { McpServers, Tools, Extensions, + Plugins, NativeHooks, ExternalHooks, AcpHelp, @@ -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", @@ -2127,6 +2143,7 @@ mod tests { ActionHandler::McpServers, ActionHandler::Tools, ActionHandler::Extensions, + ActionHandler::Plugins, ActionHandler::NativeHooks, ActionHandler::ExternalHooks, ActionHandler::Login, diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 4fb56eaa9..ca3023320 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -51,6 +51,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, PluginInstallScope, PluginItem}; use crate::ui::prompt_command_shell_review::PromptCommandShellReviewAction; use crate::ui::prompt_stash_selector::PromptStashAction; use crate::ui::provider_selector::ProviderSelection; @@ -309,6 +310,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>, + }, + Install { + spec: String, + handle: tokio::task::JoinHandle>, + }, +} + enum PendingSessionOperationKind { Mode { mode_id: String, @@ -526,6 +547,10 @@ pub(crate) struct ChatMode { pending_mcp_op: Option, /// Running MCP tasks (non-blocking, polled in main loop) pending_mcp_tasks: Vec, + /// Pending plugin operation — set in key handler, executed after one render frame + pending_plugin_op: Option, + /// Running plugin tasks (non-blocking, polled in main loop) + pending_plugin_tasks: Vec, /// One Session operation in flight. The event loop remains responsive while /// the Runtime owner updates or deletes Session state. pending_session_operation: Option, @@ -616,6 +641,8 @@ impl ChatMode { initial_prompt: 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, @@ -680,6 +707,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"); diff --git a/src/apps/cli/src/modes/chat/account.rs b/src/apps/cli/src/modes/chat/account.rs index 8ff66ec85..92cc591d3 100644 --- a/src/apps/cli/src/modes/chat/account.rs +++ b/src/apps/cli/src/modes/chat/account.rs @@ -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() @@ -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(); @@ -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(), @@ -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() } diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index 31251d90d..058e1db6a 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -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); } diff --git a/src/apps/cli/src/modes/chat/input.rs b/src/apps/cli/src/modes/chat/input.rs index f34acd6f7..9c9d1a74b 100644 --- a/src/apps/cli/src/modes/chat/input.rs +++ b/src/apps/cli/src/modes/chat/input.rs @@ -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); diff --git a/src/apps/cli/src/modes/chat/plugins.rs b/src/apps/cli/src/modes/chat/plugins.rs new file mode 100644 index 000000000..e62019093 --- /dev/null +++ b/src/apps/cli/src/modes/chat/plugins.rs @@ -0,0 +1,238 @@ +/// 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 `refresh_managed_plugin_sources`. +use bitfun_core::plugin_runtime::{activate_managed_plugin, deactivate_managed_plugin}; +use bitfun_core::plugin_source::refresh_managed_plugin_sources; + +use crate::ui::plugin_browser::plugin_items_from_snapshot; + +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 from the managed plugin source service. + pub(super) fn get_plugin_items(&self, rt_handle: &tokio::runtime::Handle) -> Vec { + let workspace = self.agent.workspace_path_buf(); + tokio::task::block_in_place(|| { + rt_handle.block_on(async { + match refresh_managed_plugin_sources(&workspace).await { + Ok(snapshot) => plugin_items_from_snapshot(&snapshot), + Err(error) => { + tracing::error!("Failed to load plugin snapshot: {}", error); + Vec::new() + } + } + }) + }) + } + + /// 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 { + if was_activated { + deactivate_managed_plugin(&workspace, &plugin_id) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) + } else { + activate_managed_plugin(&workspace, &plugin_id, Some(&content_hash)) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) + } + }); + 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 + } +} + +/// Install a managed plugin from a package specifier. +/// +/// TODO: replace with `bitfun_core::plugin_source::install_managed_plugin` +/// once the core install API lands. This skeleton placeholder reports the +/// operation as not yet implemented so the install UI flow can be exercised +/// without crashing the TUI. +async fn install_managed_plugin( + _workspace: &std::path::Path, + _spec: &str, + _scope: PluginInstallScope, +) -> std::result::Result<(), String> { + Err("plugin install is not yet implemented (TODO: wire core install API)".to_string()) +} diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index bc1ac3847..7938d4d51 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -617,6 +617,10 @@ impl ChatMode { if self.poll_mcp_task_completion(&mut chat_view, &mut chat_state, &rt_handle) { needs_redraw = true; } + // Poll completion of non-blocking plugin toggle operations before rendering. + if self.poll_plugin_task_completion(&mut chat_view, &mut chat_state, &rt_handle) { + needs_redraw = true; + } match self.poll_session_operation_completion( &mut chat_view, &mut chat_state, @@ -875,6 +879,34 @@ impl ChatMode { } needs_redraw = true; } + if let Some(op) = self.pending_plugin_op.take() { + if !did_render_this_loop { + let displayed_chat_state = self.displayed_chat_state(&chat_state); + terminal.draw(|frame| { + chat_view.render(frame, displayed_chat_state); + })?; + } + match op { + PendingPluginOp::Toggle(item) => { + self.execute_plugin_toggle( + &item, + &mut chat_view, + &mut chat_state, + &rt_handle, + ); + } + PendingPluginOp::Install { spec, scope } => { + self.execute_plugin_install( + spec, + scope, + &mut chat_view, + &mut chat_state, + &rt_handle, + ); + } + } + needs_redraw = true; + } } // 2. Process core events (non-blocking) diff --git a/src/apps/cli/src/ui/chat/popups.rs b/src/apps/cli/src/ui/chat/popups.rs index 786b1345f..74803f90b 100644 --- a/src/apps/cli/src/ui/chat/popups.rs +++ b/src/apps/cli/src/ui/chat/popups.rs @@ -485,6 +485,48 @@ impl ChatView { self.mcp_add_dialog.show(); } + // ============ Plugin browser methods ============ + + pub(crate) fn show_plugin_browser(&mut self, items: Vec) { + self.plugin_browser.show(items); + self.popup_stack.push(PopupType::PluginBrowser); + } + + pub(crate) fn hide_plugin_browser(&mut self) { + self.plugin_browser.hide(); + } + + pub(crate) fn reshow_plugin_browser(&mut self) { + self.plugin_browser.reshow(); + } + + pub(crate) fn plugin_browser_visible(&self) -> bool { + self.plugin_browser.is_visible() + } + + pub(crate) fn plugin_browser_handle_key( + &mut self, + key: crossterm::event::KeyEvent, + ) -> PluginBrowserAction { + self.plugin_browser.handle_key_event(key) + } + + pub(crate) fn plugin_browser_set_loading(&mut self, id: Option) { + self.plugin_browser.set_loading(id); + } + + pub(crate) fn plugin_browser_update_items(&mut self, items: Vec) { + self.plugin_browser.update_items(items); + } + + pub(crate) fn plugin_browser_set_install_busy(&mut self, busy: bool) { + self.plugin_browser.set_install_busy(busy); + } + + pub(crate) fn plugin_browser_set_install_message(&mut self, msg: Option) { + self.plugin_browser.set_install_message(msg); + } + // ============ Session selector methods ============ pub(crate) fn show_session_selector( diff --git a/src/apps/cli/src/ui/chat/render.rs b/src/apps/cli/src/ui/chat/render.rs index 93fcf5d45..4e23847ca 100644 --- a/src/apps/cli/src/ui/chat/render.rs +++ b/src/apps/cli/src/ui/chat/render.rs @@ -118,6 +118,7 @@ impl ChatView { self.render_subagent_selector(frame, chunks[1]); self.render_mcp_selector(frame, chunks[1]); self.render_mcp_add_dialog(frame, chunks[1]); + self.render_plugin_browser(frame, chunks[1]); self.render_provider_selector(frame, chunks[1]); self.render_model_config_form(frame, chunks[1]); self.render_theme_selector(frame, chunks[1]); @@ -1003,6 +1004,10 @@ impl ChatView { self.mcp_add_dialog.render(frame, area, &self.theme); } + fn render_plugin_browser(&mut self, frame: &mut Frame, area: Rect) { + self.plugin_browser.render(frame, area, &self.theme); + } + fn render_provider_selector(&mut self, frame: &mut Frame, area: Rect) { self.provider_selector.render(frame, area, &self.theme); } diff --git a/src/apps/cli/src/ui/chat/state.rs b/src/apps/cli/src/ui/chat/state.rs index 9dd295cf8..a6c161df9 100644 --- a/src/apps/cli/src/ui/chat/state.rs +++ b/src/apps/cli/src/ui/chat/state.rs @@ -21,11 +21,12 @@ use super::mcp_selector::{McpAction, McpItem, McpSelectorState}; use super::model_config_form::{ModelConfigFormState, ModelFormAction}; use super::model_selector::{ModelItem, ModelSelectorState}; use super::permission::render_permission_overlay; +use super::plugin_browser::{PluginBrowserAction, PluginBrowserState, PluginItem}; use super::prompt_stash_selector::PromptStashSelectorState; use super::provider_selector::{ProviderSelection, ProviderSelectorState}; use super::question::render_question_overlay; -use super::session_selector::{SessionAction, SessionItem, SessionSelectorState}; use super::session_lineage_selector::{SessionLineageAction, SessionLineageSelectorState}; +use super::session_selector::{SessionAction, SessionItem, SessionSelectorState}; use super::skill_selector::{SkillItem, SkillSelectorAction, SkillSelectorState}; use super::subagent_selector::{SubagentItem, SubagentSelectorAction, SubagentSelectorState}; use super::text_input::TextInput; @@ -86,6 +87,7 @@ pub(crate) enum PopupType { SubagentSelector, McpSelector, McpAddDialog, + PluginBrowser, ProviderSelector, ModelConfigForm, LoginForm, @@ -259,6 +261,8 @@ pub(crate) struct ChatView { mcp_selector: McpSelectorState, /// MCP add dialog state mcp_add_dialog: McpAddDialogState, + /// Plugin browser popup state + plugin_browser: PluginBrowserState, /// Provider selector popup state (step 1 of add model) provider_selector: ProviderSelectorState, /// Model config form state (step 2 of add model) @@ -387,6 +391,7 @@ impl ChatView { subagent_selector: SubagentSelectorState::new(), mcp_selector: McpSelectorState::new(), mcp_add_dialog: McpAddDialogState::new(), + plugin_browser: PluginBrowserState::new(), provider_selector: ProviderSelectorState::new(), model_config_form: ModelConfigFormState::new(), login_form: LoginFormState::new(), @@ -458,11 +463,7 @@ impl ChatView { /// block, identified by the owning message id and the block's index /// within that message. Mirrors the id scheme used by `render_message`. #[cfg(test)] - pub(crate) fn toggle_thinking_block_for_test( - &mut self, - message_id: &str, - block_index: usize, - ) { + pub(crate) fn toggle_thinking_block_for_test(&mut self, message_id: &str, block_index: usize) { let id = format!("{}::thinking:{}", message_id, block_index); self.thinking_disclosures.toggle(&id); self.invalidate_render_cache(); diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index 7bffee62b..963e7a21f 100644 --- a/src/apps/cli/src/ui/command_palette.rs +++ b/src/apps/cli/src/ui/command_palette.rs @@ -67,6 +67,7 @@ const DEFAULT_ITEM_ORDER: &[&str] = &[ "tools", "mcp_servers", "extensions", + "plugins", "hooks", "hooks_external", "login", diff --git a/src/apps/cli/src/ui/mod.rs b/src/apps/cli/src/ui/mod.rs index 0d63333b4..8c6ac8075 100644 --- a/src/apps/cli/src/ui/mod.rs +++ b/src/apps/cli/src/ui/mod.rs @@ -20,6 +20,7 @@ mod message_time; pub(crate) mod model_config_form; pub(crate) mod model_selector; pub(crate) mod permission; +pub(crate) mod plugin_browser; pub(crate) mod prompt_command_shell_review; pub(crate) mod prompt_stash_selector; pub(crate) mod provider_selector; diff --git a/src/apps/cli/src/ui/plugin_browser.rs b/src/apps/cli/src/ui/plugin_browser.rs new file mode 100644 index 000000000..7a4a6890c --- /dev/null +++ b/src/apps/cli/src/ui/plugin_browser.rs @@ -0,0 +1,694 @@ +/// Plugin browser popup +/// +/// Overlay popup that lists managed plugin packages with their activation +/// state. Lets the user search by `package_id`, scroll the list, and toggle +/// activation with Space. Activation/deactivation is dispatched back to the +/// ChatMode owner, which runs the async toggle and refreshes the snapshot. +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, + Frame, +}; + +use crate::ui::{ + responsive_popup::{render_too_small, responsive_popup, ResponsivePopup}, + theme::{StyleKind, Theme}, +}; + +/// Three-state display status for a plugin, mirroring the deveco-code +/// plugin manager: `active` (green), `inactive` (red, approved but not +/// running), `disabled` (gray, denied/revoked), `unreviewed` (yellow). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PluginDisplayStatus { + Active, + Inactive, + Disabled, + Unreviewed, +} + +impl PluginDisplayStatus { + #[allow(dead_code)] + pub(crate) fn label(self) -> &'static str { + match self { + Self::Active => "active", + Self::Inactive => "inactive", + Self::Disabled => "disabled", + Self::Unreviewed => "unreviewed", + } + } +} + +/// Compute the display status from the trust level and activation state. +fn plugin_display_status( + trust: bitfun_core::plugin_source::ManagedPluginTrustLevel, + activated: bool, +) -> PluginDisplayStatus { + use bitfun_core::plugin_source::ManagedPluginTrustLevel; + match trust { + ManagedPluginTrustLevel::Denied | ManagedPluginTrustLevel::Revoked => { + PluginDisplayStatus::Disabled + } + ManagedPluginTrustLevel::Unknown => PluginDisplayStatus::Unreviewed, + ManagedPluginTrustLevel::SourceApproved => { + if activated { + PluginDisplayStatus::Active + } else { + PluginDisplayStatus::Inactive + } + } + _ => PluginDisplayStatus::Unreviewed, + } +} + +/// Render the `ManagedPluginTrustLevel` to a stable short label for display. +pub(crate) fn plugin_trust_label( + trust: bitfun_core::plugin_source::ManagedPluginTrustLevel, +) -> &'static str { + use bitfun_core::plugin_source::ManagedPluginTrustLevel; + match trust { + ManagedPluginTrustLevel::Unknown => "unreviewed", + ManagedPluginTrustLevel::SourceApproved => "source-approved", + ManagedPluginTrustLevel::Denied => "denied", + ManagedPluginTrustLevel::Revoked => "revoked", + _ => "other", + } +} + +/// Map a `ManagedPluginPackageView` to the popup's display item. +pub(crate) fn plugin_item_from_view( + view: &bitfun_core::plugin_source::ManagedPluginPackageView, +) -> PluginItem { + PluginItem { + id: view.package_id.clone(), + version: view.version.clone(), + source_scope: view.source_scope.clone(), + trust_label: plugin_trust_label(view.trust_level).to_string(), + activated: view.activated, + content_hash: view.content_hash.clone(), + status: plugin_display_status(view.trust_level, view.activated), + } +} + +/// Project a plugin snapshot into display items, preserving the snapshot order. +pub(crate) fn plugin_items_from_snapshot( + snapshot: &bitfun_core::plugin_source::ManagedPluginSourceSnapshot, +) -> Vec { + snapshot + .packages + .iter() + .map(plugin_item_from_view) + .collect() +} + +/// A managed plugin package item for display in the browser. +#[derive(Debug, Clone)] +pub(crate) struct PluginItem { + pub id: String, + pub version: String, + pub source_scope: String, + pub trust_label: String, + pub activated: bool, + pub content_hash: String, + pub status: PluginDisplayStatus, +} + +/// Installation scope for a new plugin, mirroring deveco-code's local/global +/// toggle: `User` installs into the user-level plugins dir, `Project` into the +/// workspace's project-level plugins dir. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PluginInstallScope { + User, + Project, +} + +impl PluginInstallScope { + #[allow(dead_code)] + pub(crate) fn label(self) -> &'static str { + match self { + Self::User => "user", + Self::Project => "project", + } + } + + fn toggle(self) -> Self { + match self { + Self::User => Self::Project, + Self::Project => Self::User, + } + } +} + +/// Action returned from the plugin browser. +#[derive(Debug, Clone)] +pub(crate) enum PluginBrowserAction { + /// User pressed Space/Enter on the selected plugin; ChatMode should toggle it. + Toggle(PluginItem), + /// User submitted the install dialog (Shift+I) with a package spec and + /// scope. ChatMode/startup should run the install flow. + Install { + spec: String, + scope: PluginInstallScope, + }, + /// No action (key consumed or no-op). + None, + /// User dismissed the browser (Esc / Ctrl+C). + Dismiss, +} + +/// Plugin browser popup state. +pub(super) struct PluginBrowserState { + items: Vec, + /// Indices into `items` that match the current search query. + filtered_indices: Vec, + list_state: ListState, + visible: bool, + search_query: String, + /// Which plugin is currently being toggled (loading indicator). + loading_id: Option, + last_area: Option, + interaction_enabled: bool, + /// Install sub-dialog state (Shift+I). When active, the browser renders an + /// input form instead of the list and routes keys to the install handler. + install_active: bool, + install_input: String, + install_scope: PluginInstallScope, + /// True while an install task is in flight (input suspended, "Installing…"). + install_busy: bool, + /// Last install outcome message; cleared on the next keystroke. + install_message: Option, +} + +impl PluginBrowserState { + pub(super) fn new() -> Self { + Self { + items: Vec::new(), + filtered_indices: Vec::new(), + list_state: ListState::default(), + visible: false, + search_query: String::new(), + loading_id: None, + last_area: None, + interaction_enabled: true, + install_active: false, + install_input: String::new(), + install_scope: PluginInstallScope::User, + install_busy: false, + install_message: None, + } + } + + /// Show the plugin browser with the given item list. + pub(super) fn show(&mut self, mut items: Vec) { + // Sort: builtin source_scope first (like deveco-code's internal-first), + // then alphabetical by id within each group. + items.sort_by(|a, b| { + let a_builtin = a.source_scope == "builtin"; + let b_builtin = b.source_scope == "builtin"; + match (a_builtin, b_builtin) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.id.cmp(&b.id), + } + }); + self.items = items; + self.search_query.clear(); + self.rebuild_filtered(); + if !self.filtered_indices.is_empty() { + self.list_state.select(Some(0)); + } else { + self.list_state.select(None); + } + self.loading_id = None; + self.visible = true; + self.interaction_enabled = true; + } + + pub(super) fn hide(&mut self) { + self.visible = false; + self.loading_id = None; + self.search_query.clear(); + self.last_area = None; + self.install_active = false; + self.install_input.clear(); + self.install_busy = false; + self.install_message = None; + } + + /// Reshow the plugin browser (for back navigation). + pub(super) fn reshow(&mut self) { + if !self.items.is_empty() { + self.visible = true; + } + } + + pub(super) fn is_visible(&self) -> bool { + self.visible + } + + pub(super) fn set_loading(&mut self, id: Option) { + self.loading_id = id; + } + + // ============ Install sub-dialog ============ + + pub(super) fn set_install_busy(&mut self, busy: bool) { + self.install_busy = busy; + } + + pub(super) fn set_install_message(&mut self, msg: Option) { + self.install_message = msg; + } + + fn open_install(&mut self) { + self.install_active = true; + self.install_input.clear(); + self.install_scope = PluginInstallScope::User; + self.install_busy = false; + self.install_message = None; + } + + fn close_install(&mut self) { + self.install_active = false; + self.install_input.clear(); + self.install_message = None; + } + + /// Key handler for the install sub-dialog. The dialog owns input, scope + /// toggling, and busy/message display; on Enter it returns + /// `Install { spec, scope }` for the caller to dispatch. + fn handle_install_key(&mut self, key: KeyEvent) -> PluginBrowserAction { + if self.install_busy { + // Only Esc is honored while installing; the in-flight task reports + // completion through the poll loop, which clears busy/message. + if key.code == KeyCode::Esc { + self.close_install(); + } + return PluginBrowserAction::None; + } + match key.code { + KeyCode::Esc => { + self.close_install(); + PluginBrowserAction::None + } + KeyCode::Tab => { + self.install_scope = self.install_scope.toggle(); + self.install_message = None; + PluginBrowserAction::None + } + KeyCode::Backspace => { + self.install_input.pop(); + self.install_message = None; + PluginBrowserAction::None + } + KeyCode::Enter => { + let spec = self.install_input.trim().to_string(); + if spec.is_empty() { + self.install_message = Some("Package name or path is required".to_string()); + return PluginBrowserAction::None; + } + let scope = self.install_scope; + self.install_busy = true; + self.install_message = None; + PluginBrowserAction::Install { spec, scope } + } + KeyCode::Char(ch) if ch.is_ascii_graphic() => { + self.install_input.push(ch); + self.install_message = None; + PluginBrowserAction::None + } + _ => PluginBrowserAction::None, + } + } + + /// Replace items in-place (after a toggle completes), preserving selection by id. + pub(super) fn update_items(&mut self, items: Vec) { + let selected_id = self + .list_state + .selected() + .and_then(|idx| self.filtered_indices.get(idx).copied()) + .and_then(|idx| self.items.get(idx)) + .map(|item| item.id.clone()); + self.items = items; + self.rebuild_filtered(); + if self.filtered_indices.is_empty() { + self.list_state.select(None); + } else if let Some(id) = selected_id.as_ref() { + let pos = self + .filtered_indices + .iter() + .position(|&idx| &self.items[idx].id == id); + match pos { + Some(p) => self.list_state.select(Some(p)), + None => self.list_state.select(Some(0)), + } + } else { + self.list_state.select(Some(0)); + } + let loading_removed = self + .loading_id + .as_deref() + .is_some_and(|id| !self.items.iter().any(|item| item.id == id)); + if loading_removed { + self.loading_id = None; + } + } + + fn rebuild_filtered(&mut self) { + let query = self.search_query.to_lowercase(); + self.filtered_indices = self + .items + .iter() + .enumerate() + .filter(|(_, item)| query.is_empty() || item.id.to_lowercase().contains(&query)) + .map(|(idx, _)| idx) + .collect(); + } + + fn move_up(&mut self) { + if !self.visible || !self.interaction_enabled || self.filtered_indices.is_empty() { + return; + } + let selected = self.list_state.selected().unwrap_or(0); + let len = self.filtered_indices.len(); + let next = (selected + len - 1) % len; + self.list_state.select(Some(next)); + } + + fn move_down(&mut self) { + if !self.visible || !self.interaction_enabled || self.filtered_indices.is_empty() { + return; + } + let selected = self.list_state.selected().unwrap_or(0); + let next = (selected + 1) % self.filtered_indices.len(); + self.list_state.select(Some(next)); + } + + fn confirm_selection(&self) -> Option { + if !self.visible || !self.interaction_enabled { + return None; + } + let idx = self.list_state.selected()?; + let &item_idx = self.filtered_indices.get(idx)?; + self.items.get(item_idx).cloned() + } + + /// Handle a key event. Returns an action the caller is responsible for + /// dispatching (e.g. toggling the plugin). Search input is owned by the + /// popup itself: typing any printable ASCII graphic character filters the + /// list by `package_id` (case-insensitive substring); `Backspace` removes + /// the last search character. + pub(super) fn handle_key_event(&mut self, key: KeyEvent) -> PluginBrowserAction { + if !self.visible { + return PluginBrowserAction::None; + } + if !self.interaction_enabled { + if key.code == KeyCode::Esc { + self.hide(); + return PluginBrowserAction::Dismiss; + } + return PluginBrowserAction::None; + } + if self.install_active { + return self.handle_install_key(key); + } + match key.code { + KeyCode::Esc => { + self.hide(); + PluginBrowserAction::Dismiss + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.hide(); + PluginBrowserAction::Dismiss + } + KeyCode::Up | KeyCode::Char('k') => { + self.move_up(); + PluginBrowserAction::None + } + KeyCode::Down | KeyCode::Char('j') => { + self.move_down(); + PluginBrowserAction::None + } + KeyCode::PageUp => { + if !self.filtered_indices.is_empty() { + let selected = self.list_state.selected().unwrap_or(0); + let next = selected.saturating_sub(10); + self.list_state.select(Some(next)); + } + PluginBrowserAction::None + } + KeyCode::PageDown => { + if !self.filtered_indices.is_empty() { + let selected = self.list_state.selected().unwrap_or(0); + let len = self.filtered_indices.len(); + let next = (selected + 10).min(len - 1); + self.list_state.select(Some(next)); + } + PluginBrowserAction::None + } + KeyCode::Home => { + if !self.filtered_indices.is_empty() { + self.list_state.select(Some(0)); + } + PluginBrowserAction::None + } + KeyCode::End => { + if !self.filtered_indices.is_empty() { + let last = self.filtered_indices.len() - 1; + self.list_state.select(Some(last)); + } + PluginBrowserAction::None + } + KeyCode::Char(' ') | KeyCode::Enter => match self.confirm_selection() { + Some(item) => PluginBrowserAction::Toggle(item), + None => PluginBrowserAction::None, + }, + KeyCode::Char('I') if key.modifiers == KeyModifiers::SHIFT => { + self.open_install(); + PluginBrowserAction::None + } + KeyCode::Backspace => { + if self.search_query.pop().is_some() { + self.rebuild_filtered(); + if !self.filtered_indices.is_empty() { + self.list_state.select(Some(0)); + } else { + self.list_state.select(None); + } + } + PluginBrowserAction::None + } + KeyCode::Char(ch) if ch.is_ascii_graphic() => { + self.search_query.push(ch); + self.rebuild_filtered(); + if !self.filtered_indices.is_empty() { + self.list_state.select(Some(0)); + } else { + self.list_state.select(None); + } + PluginBrowserAction::None + } + _ => PluginBrowserAction::None, + } + } + + /// Render the plugin browser popup as an overlay. + pub(super) fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { + if !self.visible { + self.last_area = None; + return; + } + + let ideal_height = (self.filtered_indices.len() as u16 + 6).max(8); + let layout = responsive_popup(area, 72, ideal_height, 18, 6); + let popup_area = match layout { + ResponsivePopup::Content(area) => area, + ResponsivePopup::TooSmall(area) => { + self.last_area = None; + self.interaction_enabled = false; + render_too_small(frame, area, theme, "Plugins"); + return; + } + }; + self.interaction_enabled = true; + self.last_area = Some(popup_area); + let popup_width = popup_area.width; + + if self.install_active { + self.render_install(frame, popup_area, theme); + return; + } + + let loading_id = self.loading_id.clone(); + + let mut list_items: Vec = self + .filtered_indices + .iter() + .map(|&idx| { + let item = &self.items[idx]; + let is_loading = loading_id.as_ref().is_some_and(|id| id == &item.id); + let (marker, marker_style, status_style) = if is_loading { + ( + "\u{22ef} ", + theme.style(StyleKind::Warning), + theme.style(StyleKind::Warning), + ) + } else { + let style = match item.status { + PluginDisplayStatus::Active => theme.style(StyleKind::Success), + PluginDisplayStatus::Inactive => theme.style(StyleKind::Error), + PluginDisplayStatus::Disabled => theme.style(StyleKind::Muted), + PluginDisplayStatus::Unreviewed => theme.style(StyleKind::Warning), + }; + let marker = match item.status { + PluginDisplayStatus::Active => "\u{2713} ", + PluginDisplayStatus::Inactive => "\u{25cb} ", + PluginDisplayStatus::Disabled => "\u{2717} ", + PluginDisplayStatus::Unreviewed => "? ", + }; + (marker, style, style) + }; + let status_label = if is_loading { + "Loading...".to_string() + } else { + item.status.label().to_string() + }; + let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); + if popup_width < 50 { + ListItem::new(vec![ + Line::from(vec![ + Span::styled(marker, marker_style), + Span::styled(&item.id, name_style), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled(status_label, status_style), + Span::raw(" "), + Span::styled(&item.trust_label, theme.style(StyleKind::Muted)), + ]), + ]) + } else { + ListItem::new(Line::from(vec![ + Span::styled(marker, marker_style), + Span::styled(&item.id, name_style), + Span::raw(" "), + Span::styled(status_label, status_style), + Span::raw(" "), + Span::styled( + format!("({})", item.source_scope), + theme.style(StyleKind::Muted), + ), + Span::raw(" "), + Span::styled(&item.trust_label, theme.style(StyleKind::Muted)), + Span::raw(" "), + Span::styled(format!("v{}", item.version), theme.style(StyleKind::Muted)), + ])) + } + }) + .collect(); + + if list_items.is_empty() { + let empty_msg = if self.search_query.is_empty() { + " No plugins found".to_string() + } else { + format!(" No plugins match '{}'", self.search_query) + }; + list_items.push(ListItem::new(Line::from(Span::styled( + empty_msg, + theme.style(StyleKind::Muted), + )))); + } + + if popup_width >= 50 { + list_items.push(ListItem::new(Line::from(Span::styled( + " Space:Toggle Up/Down:Nav Type:Search Shift+I:Install Esc:Close", + theme.style(StyleKind::Muted), + )))); + } + + let search_display = if self.search_query.is_empty() { + String::from("") + } else { + self.search_query.clone() + }; + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.style(StyleKind::Primary)) + .style(Style::default().bg(theme.background)) + .title(format!(" Plugins search: {} ", search_display)); + + let list = List::new(list_items) + .block(block) + .style(Style::default().bg(theme.background)) + .highlight_style( + Style::default() + .bg(theme.primary) + .fg(theme.selection_foreground()) + .add_modifier(Modifier::BOLD), + ); + + frame.render_widget(Clear, popup_area); + frame.render_stateful_widget(list, popup_area, &mut self.list_state); + } + + /// Render the install sub-dialog form over the popup area. + fn render_install(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { + let input_display = if self.install_input.is_empty() { + String::from("npm name, @scope/pkg, pkg@version, or file://path") + } else { + self.install_input.clone() + }; + let scope_hint = match self.install_scope { + PluginInstallScope::User => "user plugins dir", + PluginInstallScope::Project => "project plugins dir", + }; + + let mut lines: Vec = Vec::new(); + lines.push(Line::from(vec![ + Span::styled("> ", theme.style(StyleKind::Primary)), + Span::styled(input_display, theme.style(StyleKind::Primary)), + Span::styled("_", theme.style(StyleKind::Muted)), + ])); + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled( + format!("Scope: {} ({})", self.install_scope.label(), scope_hint), + theme.style(StyleKind::Muted), + ), + Span::raw(" "), + Span::styled("(Tab: toggle)", theme.style(StyleKind::Muted)), + ])); + + if self.install_busy { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Installing…", + theme.style(StyleKind::Warning), + ))); + } else if let Some(msg) = &self.install_message { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled(msg, theme.style(StyleKind::Error)))); + } + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Enter:Install Tab:Scope Esc:Back", + theme.style(StyleKind::Muted), + ))); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.style(StyleKind::Primary)) + .style(Style::default().bg(theme.background)) + .title(" Install plugin "); + + let content = Paragraph::new(lines) + .block(block) + .style(Style::default().bg(theme.background)); + + frame.render_widget(Clear, area); + frame.render_widget(content, area); + } +} diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 105d93c79..0d3865b0f 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -6,6 +6,24 @@ use super::image_paste::{self, ImagePaste}; use super::login_form::{LoginFormAction, LoginFormState}; use super::model_config_form::{ModelConfigFormState, ModelFormAction, ModelFormResult}; use super::model_selector::{ModelItem, ModelSelectorState}; +use super::plugin_browser::{ + plugin_items_from_snapshot, PluginBrowserAction, PluginBrowserState, PluginInstallScope, + PluginItem, +}; + +/// Install a managed plugin from a package specifier. +/// +/// TODO: replace with `bitfun_core::plugin_source::install_managed_plugin` +/// once the core install API lands. This skeleton placeholder reports the +/// operation as not yet implemented so the install UI flow can be exercised +/// without crashing the startup TUI. +async fn install_managed_plugin( + _workspace: &std::path::Path, + _spec: &str, + _scope: PluginInstallScope, +) -> std::result::Result<(), String> { + Err("plugin install is not yet implemented (TODO: wire core install API)".to_string()) +} use super::provider_selector::{ProviderSelection, ProviderSelectorState}; use super::session_selector::{SessionAction, SessionItem, SessionSelectorState}; use super::skill_selector::{SkillItem, SkillSelectorAction, SkillSelectorState}; @@ -69,6 +87,7 @@ enum PopupType { SubagentSelector, ThemeSelector, ProviderSelector, + PluginBrowser, ModelConfigForm, LoginForm, } @@ -202,6 +221,8 @@ pub(crate) struct StartupPage { provider_selector: ProviderSelectorState, model_config_form: ModelConfigFormState, login_form: LoginFormState, + /// Plugin browser popup state + plugin_browser: PluginBrowserState, theme_preview_original: Option, // ── System context ── @@ -291,6 +312,7 @@ impl StartupPage { provider_selector: ProviderSelectorState::new(), model_config_form: ModelConfigFormState::new(), login_form: LoginFormState::new(), + plugin_browser: PluginBrowserState::new(), theme_preview_original: None, agent, compatibility, @@ -361,6 +383,7 @@ impl StartupPage { || self.provider_selector.is_visible() || self.model_config_form.is_visible() || self.login_form.is_visible() + || self.plugin_browser.is_visible() } pub(crate) fn run(&mut self, terminal: &mut Terminal) -> Result { @@ -489,6 +512,7 @@ impl StartupPage { self.theme_selector.render(frame, size, &self.theme); self.provider_selector.render(frame, size, &self.theme); self.model_config_form.render_mut(frame, size, &self.theme); + self.plugin_browser.render(frame, size, &self.theme); // Overlay: command palette (Ctrl+P) self.command_palette.render(frame, size, &self.theme); @@ -768,6 +792,19 @@ impl StartupPage { // ── Selector popups intercept all keys when active ── + if self.plugin_browser.is_visible() { + let action = self.plugin_browser.handle_key_event(key); + match action { + PluginBrowserAction::Toggle(item) => self.toggle_plugin(item), + PluginBrowserAction::Install { spec, scope } => { + self.install_plugin(spec, scope); + } + PluginBrowserAction::Dismiss => self.navigate_back(), + PluginBrowserAction::None => {} + } + return None; + } + if self.theme_selector.is_visible() { match key.code { KeyCode::Up => { @@ -1056,6 +1093,9 @@ impl StartupPage { prompt: Some(ComposerDraft::from_text("/mcp")), }); } + ActionHandler::Plugins => { + self.show_plugin_browser(); + } ActionHandler::AcpHelp => { return Some(StartupResult::NewSession { prompt: Some(ComposerDraft::from_text("/acp")), @@ -1357,6 +1397,9 @@ impl StartupPage { } else if self.provider_selector.is_visible() { self.popup_stack.push(PopupType::ProviderSelector); self.provider_selector.hide(); + } else if self.plugin_browser.is_visible() { + self.popup_stack.push(PopupType::PluginBrowser); + self.plugin_browser.hide(); } else if self.model_config_form.is_visible() { self.popup_stack.push(PopupType::ModelConfigForm); self.model_config_form.hide(); @@ -2111,6 +2154,83 @@ impl StartupPage { self.skill_selector.show_menu(); } + fn show_plugin_browser(&mut self) { + let items = self.get_plugin_items(); + self.push_current_popup_to_stack(); + self.plugin_browser.show(items); + } + + fn get_plugin_items(&self) -> Vec { + let workspace = self.workspace_path_buf(); + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + match bitfun_core::plugin_source::refresh_managed_plugin_sources(&workspace).await { + Ok(snapshot) => plugin_items_from_snapshot(&snapshot), + Err(_) => Vec::new(), + } + }) + }) + } + + fn toggle_plugin(&mut self, item: PluginItem) { + 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; + self.plugin_browser.set_loading(Some(plugin_id.clone())); + let result = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + if was_activated { + bitfun_core::plugin_runtime::deactivate_managed_plugin(&workspace, &plugin_id) + .await + .map(|_| ()) + } else { + bitfun_core::plugin_runtime::activate_managed_plugin( + &workspace, + &plugin_id, + Some(&content_hash), + ) + .await + .map(|_| ()) + } + }) + }); + match result { + Ok(_) => self.status = Some(format!("Plugin '{}' toggled", plugin_id)), + Err(error) => { + self.status = Some(format!( + "Failed to toggle plugin '{}': {}", + plugin_id, error + )) + } + } + let items = self.get_plugin_items(); + self.plugin_browser.update_items(items); + self.plugin_browser.set_loading(None); + } + + fn install_plugin(&mut self, spec: String, scope: PluginInstallScope) { + let workspace = self.agent.workspace_path_buf(); + self.plugin_browser.set_install_busy(true); + let result = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(async { install_managed_plugin(&workspace, &spec, scope).await }) + }); + match result { + Ok(()) => { + self.plugin_browser.set_install_message(None); + self.status = Some(format!("Plugin '{}' installed", spec)); + } + Err(error) => { + self.plugin_browser.set_install_message(Some(error.clone())); + self.status = Some(format!("Failed to install plugin '{}': {}", spec, error)); + } + } + let items = self.get_plugin_items(); + self.plugin_browser.update_items(items); + self.plugin_browser.set_install_busy(false); + } + fn show_available_skill_list(&mut self) { let skills = tokio::task::block_in_place(|| { let workspace = self.workspace_path_buf(); @@ -2435,6 +2555,8 @@ impl StartupPage { self.cancel_theme_preview(); } else if self.provider_selector.is_visible() { self.provider_selector.hide(); + } else if self.plugin_browser.is_visible() { + self.plugin_browser.hide(); } else if self.model_config_form.is_visible() { self.model_config_form.hide(); } else if self.login_form.is_visible() { @@ -2452,6 +2574,7 @@ impl StartupPage { PopupType::SubagentSelector => self.subagent_selector.reshow(), PopupType::ThemeSelector => self.theme_selector.reshow(), PopupType::ProviderSelector => self.provider_selector.reshow(), + PopupType::PluginBrowser => self.plugin_browser.reshow(), PopupType::ModelConfigForm => self.model_config_form.reshow(), PopupType::LoginForm => self.login_form.show(), } @@ -2472,6 +2595,7 @@ impl StartupPage { self.provider_selector.hide(); self.model_config_form.hide(); self.login_form.hide(); + self.plugin_browser.hide(); self.popup_stack.clear(); } From 694ac53ca1c648e012ed52ad9954fff3e2225a1f Mon Sep 17 00:00:00 2001 From: ldc111 <2812975165@qq.com> Date: Thu, 6 Aug 2026 18:43:50 +0800 Subject: [PATCH 2/2] fix(cli): move plugin backend calls behind plugin_ops boundary The ported plugin browser called bitfun-core directly from ui/plugin_browser.rs, modes/chat/plugins.rs, and ui/startup.rs, blowing past the TUI backend direct-call ratchet (budget 0 for new files; startup.rs 18 > 14) and failing the core-boundaries CI check. Move all bitfun-core plugin calls (refresh/toggle/install + the core-DTO to PluginItem projection) into a new cli-local plugin_ops module at src/apps/cli/src/plugin_ops.rs, which sits outside the boundary-scanned ui/ and modes/chat/ trees (same pattern as embedded_app_server.rs). The scanned UI files now consume plain PluginItem/PluginInstallScope/ PluginDisplayStatus types and ops through crate::plugin_ops::, keeping the ratchet clean: plugin_browser.rs 0 (budget 0) plugins.rs 0 (budget 0) startup.rs 14 (budget 14, unchanged) Verified: cargo check -p bitfun-cli passes; checkTuiLegacyBackendRatchet reports 0 failures. --- src/apps/cli/src/main.rs | 1 + src/apps/cli/src/modes/chat.rs | 3 +- src/apps/cli/src/modes/chat/plugins.rs | 47 +------ src/apps/cli/src/plugin_ops.rs | 182 +++++++++++++++++++++++++ src/apps/cli/src/ui/chat/state.rs | 3 +- src/apps/cli/src/ui/plugin_browser.rs | 124 +---------------- src/apps/cli/src/ui/startup.rs | 54 ++------ 7 files changed, 206 insertions(+), 208 deletions(-) create mode 100644 src/apps/cli/src/plugin_ops.rs diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 1b403aa63..85953d2ff 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -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; diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index ca3023320..e97e9540d 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -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}; @@ -51,7 +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, PluginInstallScope, PluginItem}; +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; diff --git a/src/apps/cli/src/modes/chat/plugins.rs b/src/apps/cli/src/modes/chat/plugins.rs index e62019093..3252a85ad 100644 --- a/src/apps/cli/src/modes/chat/plugins.rs +++ b/src/apps/cli/src/modes/chat/plugins.rs @@ -6,11 +6,8 @@ /// 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 `refresh_managed_plugin_sources`. -use bitfun_core::plugin_runtime::{activate_managed_plugin, deactivate_managed_plugin}; -use bitfun_core::plugin_source::refresh_managed_plugin_sources; - -use crate::ui::plugin_browser::plugin_items_from_snapshot; +/// 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 @@ -25,20 +22,10 @@ impl ChatMode { chat_view.show_plugin_browser(items); } - /// Load current plugin items from the managed plugin source service. + /// Load current plugin items via the CLI-local plugin ops boundary. pub(super) fn get_plugin_items(&self, rt_handle: &tokio::runtime::Handle) -> Vec { let workspace = self.agent.workspace_path_buf(); - tokio::task::block_in_place(|| { - rt_handle.block_on(async { - match refresh_managed_plugin_sources(&workspace).await { - Ok(snapshot) => plugin_items_from_snapshot(&snapshot), - Err(error) => { - tracing::error!("Failed to load plugin snapshot: {}", error); - Vec::new() - } - } - }) - }) + refresh_plugin_items(&workspace, rt_handle) } /// Schedule a plugin toggle (deferred to allow the loading state to render). @@ -73,17 +60,7 @@ impl ChatMode { let was_activated = item.activated; let tracked_id = plugin_id.clone(); let handle = rt_handle.spawn(async move { - if was_activated { - deactivate_managed_plugin(&workspace, &plugin_id) - .await - .map(|_| ()) - .map_err(|error| error.to_string()) - } else { - activate_managed_plugin(&workspace, &plugin_id, Some(&content_hash)) - .await - .map(|_| ()) - .map_err(|error| error.to_string()) - } + toggle_managed_plugin(&workspace, &plugin_id, &content_hash, !was_activated).await }); self.pending_plugin_tasks.push(PendingPluginTask::Toggle { plugin_id: tracked_id, @@ -222,17 +199,3 @@ impl ChatMode { changed } } - -/// Install a managed plugin from a package specifier. -/// -/// TODO: replace with `bitfun_core::plugin_source::install_managed_plugin` -/// once the core install API lands. This skeleton placeholder reports the -/// operation as not yet implemented so the install UI flow can be exercised -/// without crashing the TUI. -async fn install_managed_plugin( - _workspace: &std::path::Path, - _spec: &str, - _scope: PluginInstallScope, -) -> std::result::Result<(), String> { - Err("plugin install is not yet implemented (TODO: wire core install API)".to_string()) -} diff --git a/src/apps/cli/src/plugin_ops.rs b/src/apps/cli/src/plugin_ops.rs new file mode 100644 index 000000000..acc2c6b03 --- /dev/null +++ b/src/apps/cli/src/plugin_ops.rs @@ -0,0 +1,182 @@ +//! CLI-local plugin backend operations. +//! +//! Bridges the TUI plugin browser to bitfun-core's managed plugin source and +//! runtime APIs. Lives outside the boundary-scanned `ui/` and `modes/chat/` +//! trees so the TUI backend direct-call ratchet stays clean; the UI layer +//! consumes the plain `PluginItem` / `PluginInstallScope` / +//! `PluginDisplayStatus` types and the operations exposed here, never +//! importing bitfun-core directly. + +use std::path::Path; + +use bitfun_core::plugin_runtime::{activate_managed_plugin, deactivate_managed_plugin}; +use bitfun_core::plugin_source::{ + refresh_managed_plugin_sources, ManagedPluginPackageView, ManagedPluginSourceSnapshot, + ManagedPluginTrustLevel, +}; + +/// Three-state display status for a plugin, mirroring the deveco-code +/// plugin manager: `active` (green), `inactive` (red, approved but not +/// running), `disabled` (gray, denied/revoked), `unreviewed` (yellow). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PluginDisplayStatus { + Active, + Inactive, + Disabled, + Unreviewed, +} + +impl PluginDisplayStatus { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Active => "active", + Self::Inactive => "inactive", + Self::Disabled => "disabled", + Self::Unreviewed => "unreviewed", + } + } +} + +/// Installation scope for a new plugin, mirroring deveco-code's local/global +/// toggle: `User` installs into the user-level plugins dir, `Project` into the +/// workspace's project-level plugins dir. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PluginInstallScope { + User, + Project, +} + +impl PluginInstallScope { + pub(crate) fn label(self) -> &'static str { + match self { + Self::User => "user", + Self::Project => "project", + } + } + + pub(crate) fn toggle(self) -> Self { + match self { + Self::User => Self::Project, + Self::Project => Self::User, + } + } +} + +/// A managed plugin package item for display in the browser. +#[derive(Debug, Clone)] +pub(crate) struct PluginItem { + pub id: String, + pub version: String, + pub source_scope: String, + pub trust_label: String, + pub activated: bool, + pub content_hash: String, + pub status: PluginDisplayStatus, +} + +/// Compute the display status from the trust level and activation state. +fn plugin_display_status(trust: ManagedPluginTrustLevel, activated: bool) -> PluginDisplayStatus { + match trust { + ManagedPluginTrustLevel::Denied | ManagedPluginTrustLevel::Revoked => { + PluginDisplayStatus::Disabled + } + ManagedPluginTrustLevel::Unknown => PluginDisplayStatus::Unreviewed, + ManagedPluginTrustLevel::SourceApproved => { + if activated { + PluginDisplayStatus::Active + } else { + PluginDisplayStatus::Inactive + } + } + _ => PluginDisplayStatus::Unreviewed, + } +} + +/// Render the `ManagedPluginTrustLevel` to a stable short label for display. +pub(crate) fn plugin_trust_label(trust: ManagedPluginTrustLevel) -> &'static str { + match trust { + ManagedPluginTrustLevel::Unknown => "unreviewed", + ManagedPluginTrustLevel::SourceApproved => "source-approved", + ManagedPluginTrustLevel::Denied => "denied", + ManagedPluginTrustLevel::Revoked => "revoked", + _ => "other", + } +} + +/// Map a `ManagedPluginPackageView` to the popup's display item. +pub(crate) fn plugin_item_from_view(view: &ManagedPluginPackageView) -> PluginItem { + PluginItem { + id: view.package_id.clone(), + version: view.version.clone(), + source_scope: view.source_scope.clone(), + trust_label: plugin_trust_label(view.trust_level).to_string(), + activated: view.activated, + content_hash: view.content_hash.clone(), + status: plugin_display_status(view.trust_level, view.activated), + } +} + +/// Project a plugin snapshot into display items, preserving the snapshot order. +pub(crate) fn plugin_items_from_snapshot( + snapshot: &ManagedPluginSourceSnapshot, +) -> Vec { + snapshot + .packages + .iter() + .map(plugin_item_from_view) + .collect() +} + +/// Refresh and project the managed plugin snapshot into display items. +pub(crate) fn refresh_plugin_items( + workspace: &Path, + rt_handle: &tokio::runtime::Handle, +) -> Vec { + tokio::task::block_in_place(|| { + rt_handle.block_on(async { + match refresh_managed_plugin_sources(workspace).await { + Ok(snapshot) => plugin_items_from_snapshot(&snapshot), + Err(error) => { + tracing::error!("Failed to load plugin snapshot: {}", error); + Vec::new() + } + } + }) + }) +} + +/// Toggle a managed plugin's activation. `activate == true` activates; +/// `false` deactivates. Returns a `String` error so the poll loop can render a +/// uniform message regardless of the underlying source error type. +pub(crate) async fn toggle_managed_plugin( + workspace: &Path, + plugin_id: &str, + content_hash: &str, + activate: bool, +) -> Result<(), String> { + if activate { + activate_managed_plugin(workspace, plugin_id, Some(content_hash)) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) + } else { + deactivate_managed_plugin(workspace, plugin_id) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) + } +} + +/// Install a managed plugin from a package specifier. +/// +/// TODO: replace with `bitfun_core::plugin_source::install_managed_plugin` +/// once the core install API lands. This skeleton placeholder reports the +/// operation as not yet implemented so the install UI flow can be exercised +/// without crashing the TUI. +pub(crate) async fn install_managed_plugin( + _workspace: &Path, + _spec: &str, + _scope: PluginInstallScope, +) -> Result<(), String> { + Err("plugin install is not yet implemented (TODO: wire core install API)".to_string()) +} diff --git a/src/apps/cli/src/ui/chat/state.rs b/src/apps/cli/src/ui/chat/state.rs index a6c161df9..607e68ced 100644 --- a/src/apps/cli/src/ui/chat/state.rs +++ b/src/apps/cli/src/ui/chat/state.rs @@ -21,7 +21,7 @@ use super::mcp_selector::{McpAction, McpItem, McpSelectorState}; use super::model_config_form::{ModelConfigFormState, ModelFormAction}; use super::model_selector::{ModelItem, ModelSelectorState}; use super::permission::render_permission_overlay; -use super::plugin_browser::{PluginBrowserAction, PluginBrowserState, PluginItem}; +use super::plugin_browser::{PluginBrowserAction, PluginBrowserState}; use super::prompt_stash_selector::PromptStashSelectorState; use super::provider_selector::{ProviderSelection, ProviderSelectorState}; use super::question::render_question_overlay; @@ -38,6 +38,7 @@ use super::workspace_diff::WorkspaceDiffViewState; use super::workspace_reference::{WorkspaceReferencePopupState, WorkspaceReferenceQuery}; use crate::actions::{ActionState, ResolvedKeymap}; use crate::chat_state::{ChatMessage, ChatState, FlowItem, MessageRole}; +use crate::plugin_ops::PluginItem; #[derive(Debug)] struct SubmittedDraftRecord { diff --git a/src/apps/cli/src/ui/plugin_browser.rs b/src/apps/cli/src/ui/plugin_browser.rs index 7a4a6890c..c8429ddf1 100644 --- a/src/apps/cli/src/ui/plugin_browser.rs +++ b/src/apps/cli/src/ui/plugin_browser.rs @@ -13,134 +13,12 @@ use ratatui::{ Frame, }; +use crate::plugin_ops::{PluginDisplayStatus, PluginInstallScope, PluginItem}; use crate::ui::{ responsive_popup::{render_too_small, responsive_popup, ResponsivePopup}, theme::{StyleKind, Theme}, }; -/// Three-state display status for a plugin, mirroring the deveco-code -/// plugin manager: `active` (green), `inactive` (red, approved but not -/// running), `disabled` (gray, denied/revoked), `unreviewed` (yellow). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum PluginDisplayStatus { - Active, - Inactive, - Disabled, - Unreviewed, -} - -impl PluginDisplayStatus { - #[allow(dead_code)] - pub(crate) fn label(self) -> &'static str { - match self { - Self::Active => "active", - Self::Inactive => "inactive", - Self::Disabled => "disabled", - Self::Unreviewed => "unreviewed", - } - } -} - -/// Compute the display status from the trust level and activation state. -fn plugin_display_status( - trust: bitfun_core::plugin_source::ManagedPluginTrustLevel, - activated: bool, -) -> PluginDisplayStatus { - use bitfun_core::plugin_source::ManagedPluginTrustLevel; - match trust { - ManagedPluginTrustLevel::Denied | ManagedPluginTrustLevel::Revoked => { - PluginDisplayStatus::Disabled - } - ManagedPluginTrustLevel::Unknown => PluginDisplayStatus::Unreviewed, - ManagedPluginTrustLevel::SourceApproved => { - if activated { - PluginDisplayStatus::Active - } else { - PluginDisplayStatus::Inactive - } - } - _ => PluginDisplayStatus::Unreviewed, - } -} - -/// Render the `ManagedPluginTrustLevel` to a stable short label for display. -pub(crate) fn plugin_trust_label( - trust: bitfun_core::plugin_source::ManagedPluginTrustLevel, -) -> &'static str { - use bitfun_core::plugin_source::ManagedPluginTrustLevel; - match trust { - ManagedPluginTrustLevel::Unknown => "unreviewed", - ManagedPluginTrustLevel::SourceApproved => "source-approved", - ManagedPluginTrustLevel::Denied => "denied", - ManagedPluginTrustLevel::Revoked => "revoked", - _ => "other", - } -} - -/// Map a `ManagedPluginPackageView` to the popup's display item. -pub(crate) fn plugin_item_from_view( - view: &bitfun_core::plugin_source::ManagedPluginPackageView, -) -> PluginItem { - PluginItem { - id: view.package_id.clone(), - version: view.version.clone(), - source_scope: view.source_scope.clone(), - trust_label: plugin_trust_label(view.trust_level).to_string(), - activated: view.activated, - content_hash: view.content_hash.clone(), - status: plugin_display_status(view.trust_level, view.activated), - } -} - -/// Project a plugin snapshot into display items, preserving the snapshot order. -pub(crate) fn plugin_items_from_snapshot( - snapshot: &bitfun_core::plugin_source::ManagedPluginSourceSnapshot, -) -> Vec { - snapshot - .packages - .iter() - .map(plugin_item_from_view) - .collect() -} - -/// A managed plugin package item for display in the browser. -#[derive(Debug, Clone)] -pub(crate) struct PluginItem { - pub id: String, - pub version: String, - pub source_scope: String, - pub trust_label: String, - pub activated: bool, - pub content_hash: String, - pub status: PluginDisplayStatus, -} - -/// Installation scope for a new plugin, mirroring deveco-code's local/global -/// toggle: `User` installs into the user-level plugins dir, `Project` into the -/// workspace's project-level plugins dir. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum PluginInstallScope { - User, - Project, -} - -impl PluginInstallScope { - #[allow(dead_code)] - pub(crate) fn label(self) -> &'static str { - match self { - Self::User => "user", - Self::Project => "project", - } - } - - fn toggle(self) -> Self { - match self { - Self::User => Self::Project, - Self::Project => Self::User, - } - } -} - /// Action returned from the plugin browser. #[derive(Debug, Clone)] pub(crate) enum PluginBrowserAction { diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 0d3865b0f..90650c91c 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -6,24 +6,7 @@ use super::image_paste::{self, ImagePaste}; use super::login_form::{LoginFormAction, LoginFormState}; use super::model_config_form::{ModelConfigFormState, ModelFormAction, ModelFormResult}; use super::model_selector::{ModelItem, ModelSelectorState}; -use super::plugin_browser::{ - plugin_items_from_snapshot, PluginBrowserAction, PluginBrowserState, PluginInstallScope, - PluginItem, -}; - -/// Install a managed plugin from a package specifier. -/// -/// TODO: replace with `bitfun_core::plugin_source::install_managed_plugin` -/// once the core install API lands. This skeleton placeholder reports the -/// operation as not yet implemented so the install UI flow can be exercised -/// without crashing the startup TUI. -async fn install_managed_plugin( - _workspace: &std::path::Path, - _spec: &str, - _scope: PluginInstallScope, -) -> std::result::Result<(), String> { - Err("plugin install is not yet implemented (TODO: wire core install API)".to_string()) -} +use super::plugin_browser::{PluginBrowserAction, PluginBrowserState}; use super::provider_selector::{ProviderSelection, ProviderSelectorState}; use super::session_selector::{SessionAction, SessionItem, SessionSelectorState}; use super::skill_selector::{SkillItem, SkillSelectorAction, SkillSelectorState}; @@ -40,6 +23,7 @@ use crate::actions::{ SHARED_TUI_EMBEDDED_HANDOFF, SHARED_TUI_HELP_NOTE, }; use crate::config::CliConfig; +use crate::plugin_ops::{PluginInstallScope, PluginItem}; /// Startup page module /// /// Full-featured startup page with: @@ -2162,14 +2146,7 @@ impl StartupPage { fn get_plugin_items(&self) -> Vec { let workspace = self.workspace_path_buf(); - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - match bitfun_core::plugin_source::refresh_managed_plugin_sources(&workspace).await { - Ok(snapshot) => plugin_items_from_snapshot(&snapshot), - Err(_) => Vec::new(), - } - }) - }) + crate::plugin_ops::refresh_plugin_items(&workspace, &tokio::runtime::Handle::current()) } fn toggle_plugin(&mut self, item: PluginItem) { @@ -2180,19 +2157,13 @@ impl StartupPage { self.plugin_browser.set_loading(Some(plugin_id.clone())); let result = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { - if was_activated { - bitfun_core::plugin_runtime::deactivate_managed_plugin(&workspace, &plugin_id) - .await - .map(|_| ()) - } else { - bitfun_core::plugin_runtime::activate_managed_plugin( - &workspace, - &plugin_id, - Some(&content_hash), - ) - .await - .map(|_| ()) - } + crate::plugin_ops::toggle_managed_plugin( + &workspace, + &plugin_id, + &content_hash, + !was_activated, + ) + .await }) }); match result { @@ -2213,8 +2184,9 @@ impl StartupPage { let workspace = self.agent.workspace_path_buf(); self.plugin_browser.set_install_busy(true); let result = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current() - .block_on(async { install_managed_plugin(&workspace, &spec, scope).await }) + tokio::runtime::Handle::current().block_on(async { + crate::plugin_ops::install_managed_plugin(&workspace, &spec, scope).await + }) }); match result { Ok(()) => {