diff --git a/src/apps/desktop/src/api/acp_client_api.rs b/src/apps/desktop/src/api/acp_client_api.rs index 4681848bd..74adf3b82 100644 --- a/src/apps/desktop/src/api/acp_client_api.rs +++ b/src/apps/desktop/src/api/acp_client_api.rs @@ -3,9 +3,9 @@ use crate::api::app_state::AppState; use crate::api::session_storage_path::desktop_effective_session_storage_path; use bitfun_acp::client::{ - AcpClientInfo, AcpClientPermissionResponse, AcpClientRequirementProbe, AcpClientStreamEvent, - AcpSessionOptions, CreateAcpFlowSessionRecordResponse, SetAcpSessionModelRequest, - SubmitAcpPermissionResponseRequest, + AcpAvailableCommand, AcpClientInfo, AcpClientPermissionResponse, AcpClientRequirementProbe, + AcpClientStreamEvent, AcpSessionOptions, CreateAcpFlowSessionRecordResponse, + SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest, }; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, State}; @@ -377,6 +377,50 @@ pub async fn start_acp_dialog_turn( bitfun_core::util::errors::BitFunError::service(e.to_string()) })?; } + AcpClientStreamEvent::AvailableCommandsUpdated(commands) => { + app_handle + .emit( + "agentic://acp-available-commands-updated", + serde_json::json!({ + "sessionId": request.session_id, + "clientId": request.client_id, + "commands": commands, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + AcpClientStreamEvent::PlanUpdated(entries) => { + app_handle + .emit( + "agentic://acp-plan-updated", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "clientId": request.client_id, + "entries": entries, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + AcpClientStreamEvent::ConfigOptionsUpdated(_) => { + // The stored options were refreshed backend-side; tell + // the frontend to refetch the combined session options. + app_handle + .emit( + "agentic://acp-session-options-changed", + serde_json::json!({ + "sessionId": request.session_id, + "clientId": request.client_id, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } AcpClientStreamEvent::Completed => { app_handle .emit( @@ -482,6 +526,39 @@ pub async fn get_acp_session_options( .map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn get_acp_session_commands( + state: State<'_, AppState>, + request: GetAcpSessionOptionsRequest, +) -> Result, String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + let session_storage_path = match request.workspace_path.as_deref() { + Some(workspace_path) => Some( + desktop_effective_session_storage_path( + &state, + workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await, + ), + None => None, + }; + service + .get_session_commands( + &request.client_id, + request.workspace_path, + request.remote_connection_id, + session_storage_path, + request.session_id, + ) + .await + .map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn set_acp_session_model( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 9c4cdbefe..6f18870c9 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -857,6 +857,7 @@ pub async fn run() { start_acp_dialog_turn, cancel_acp_dialog_turn, get_acp_session_options, + get_acp_session_commands, set_acp_session_model, lsp_initialize, lsp_start_server_for_file, diff --git a/src/crates/acp/src/client/manager.rs b/src/crates/acp/src/client/manager.rs index 0911b42ae..4d36cf360 100644 --- a/src/crates/acp/src/client/manager.rs +++ b/src/crates/acp/src/client/manager.rs @@ -47,7 +47,8 @@ use super::requirements::{ probe_remote_executable, probe_remote_npx_adapter, resolve_configured_command, }; use super::session_options::{ - model_config_id, session_options_from_state, AcpSessionContextUsage, AcpSessionOptions, + model_config_id, session_options_from_state, AcpAvailableCommand, AcpSessionContextUsage, + AcpSessionOptions, }; use super::session_persistence::AcpSessionPersistence; pub use super::session_persistence::CreateAcpFlowSessionRecordResponse; @@ -132,6 +133,8 @@ struct AcpRemoteSession { models: Option, config_options: Vec, context_usage: Option, + /// Latest slash commands advertised by the agent (AvailableCommandsUpdate). + available_commands: Vec, discard_pending_updates_before_next_prompt: bool, } @@ -160,6 +163,7 @@ impl AcpRemoteSession { models: None, config_options: Vec::new(), context_usage: None, + available_commands: Vec::new(), discard_pending_updates_before_next_prompt: false, } } @@ -869,6 +873,39 @@ impl AcpClientService { )) } + /// Slash commands the agent has advertised for this session + /// (via `AvailableCommandsUpdate`). Returns an empty list until the agent + /// sends them (typically during/after the first prompt). + pub async fn get_session_commands( + self: &Arc, + client_id: &str, + workspace_path: Option, + remote_connection_id: Option, + session_storage_path: Option, + bitfun_session_id: String, + ) -> BitFunResult> { + let resolved = self + .resolve_or_create_client_session( + client_id, + workspace_path, + remote_connection_id.as_deref(), + &bitfun_session_id, + ) + .await?; + + let mut session = resolved.session.lock().await; + self.ensure_remote_session( + &resolved.client, + &resolved.session_key, + &resolved.cwd, + &bitfun_session_id, + session_storage_path.as_deref(), + &mut session, + ) + .await?; + Ok(session.available_commands.clone()) + } + pub async fn set_session_model( self: &Arc, request: SetAcpSessionModelRequest, @@ -1081,6 +1118,8 @@ impl AcpClientService { ) .await?; update_session_context_usage(&mut session, &events); + update_session_available_commands(&mut session, &events); + update_session_config_options(&mut session, &events); for event in events { for event in round_tracker.apply(event) { on_event(event)?; @@ -2208,6 +2247,37 @@ fn update_session_context_usage(session: &mut AcpRemoteSession, events: &[AcpCli session.context_usage = Some(usage); } +fn update_session_available_commands( + session: &mut AcpRemoteSession, + events: &[AcpClientStreamEvent], +) { + // The agent re-sends the full command list on each update, so the latest + // wins (replace rather than merge). + let Some(commands) = events.iter().rev().find_map(|event| match event { + AcpClientStreamEvent::AvailableCommandsUpdated(commands) => Some(commands.clone()), + _ => None, + }) else { + return; + }; + + session.available_commands = commands; +} + +fn update_session_config_options( + session: &mut AcpRemoteSession, + events: &[AcpClientStreamEvent], +) { + // The agent sends the full set each time, so the latest update wins. + let Some(options) = events.iter().rev().find_map(|event| match event { + AcpClientStreamEvent::ConfigOptionsUpdated(options) => Some(options.clone()), + _ => None, + }) else { + return; + }; + + session.config_options = options; +} + fn protocol_error(error: impl std::fmt::Display) -> BitFunError { BitFunError::service(format!("ACP protocol error: {}", error)) } diff --git a/src/crates/acp/src/client/mod.rs b/src/crates/acp/src/client/mod.rs index 3b26d9a12..e49eaa1e3 100644 --- a/src/crates/acp/src/client/mod.rs +++ b/src/crates/acp/src/client/mod.rs @@ -20,5 +20,7 @@ pub use manager::{ AcpClientPermissionResponse, AcpClientService, CreateAcpFlowSessionRecordResponse, SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest, }; -pub use session_options::{AcpSessionContextUsage, AcpSessionModelOption, AcpSessionOptions}; +pub use session_options::{ + AcpAvailableCommand, AcpSessionContextUsage, AcpSessionModelOption, AcpSessionOptions, +}; pub use stream::AcpClientStreamEvent; diff --git a/src/crates/acp/src/client/session_options.rs b/src/crates/acp/src/client/session_options.rs index c6fc11638..c3bfa3324 100644 --- a/src/crates/acp/src/client/session_options.rs +++ b/src/crates/acp/src/client/session_options.rs @@ -23,6 +23,77 @@ impl From for AcpSessionContextUsage } } +/// A slash command advertised by an ACP agent via `AvailableCommandsUpdate`. +/// +/// Surfaced to the frontend so it can render a `/` command menu. Invocation is +/// plain text: the client sends `session/prompt` with `/ ` — there +/// is no dedicated command RPC in ACP. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpAvailableCommand { + /// Command name without the leading slash (e.g. `create_plan`). + pub name: String, + /// Human-readable description. + pub description: String, + /// Hint for the unstructured input typed after the command name, if the + /// command takes input. `None` means the command takes no arguments. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_hint: Option, +} + +impl From for AcpAvailableCommand { + fn from(command: agent_client_protocol::schema::AvailableCommand) -> Self { + use agent_client_protocol::schema::AvailableCommandInput; + let input_hint = command.input.and_then(|input| match input { + AvailableCommandInput::Unstructured(unstructured) => Some(unstructured.hint), + // `AvailableCommandInput` is #[non_exhaustive]; unknown future input + // kinds carry no hint we can render. + _ => None, + }); + Self { + name: command.name, + description: command.description, + input_hint, + } + } +} + +/// One entry of an agent's execution plan (ACP `Plan` / agent-plan), surfaced to +/// the frontend so it can render a live task checklist during a turn. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpPlanEntry { + /// Human-readable description of the task. + pub content: String, + /// `"high"` | `"medium"` | `"low"`. + pub priority: String, + /// `"pending"` | `"in_progress"` | `"completed"`. + pub status: String, +} + +impl From for AcpPlanEntry { + fn from(entry: agent_client_protocol::schema::PlanEntry) -> Self { + use agent_client_protocol::schema::{PlanEntryPriority, PlanEntryStatus}; + let priority = match entry.priority { + PlanEntryPriority::High => "high", + PlanEntryPriority::Medium => "medium", + PlanEntryPriority::Low => "low", + _ => "medium", + }; + let status = match entry.status { + PlanEntryStatus::Pending => "pending", + PlanEntryStatus::InProgress => "in_progress", + PlanEntryStatus::Completed => "completed", + _ => "pending", + }; + Self { + content: entry.content, + priority: priority.to_string(), + status: status.to_string(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct AcpSessionOptions { @@ -198,4 +269,25 @@ mod tests { Some("USD") ); } + + #[test] + fn converts_available_command_with_and_without_input() { + use agent_client_protocol::schema::{ + AvailableCommand, AvailableCommandInput, UnstructuredCommandInput, + }; + + let with_input = AvailableCommand::new("create_plan", "Draft an execution plan") + .input(AvailableCommandInput::Unstructured( + UnstructuredCommandInput::new("what to plan"), + )); + let converted = AcpAvailableCommand::from(with_input); + assert_eq!(converted.name, "create_plan"); + assert_eq!(converted.description, "Draft an execution plan"); + assert_eq!(converted.input_hint.as_deref(), Some("what to plan")); + + let no_input = AvailableCommand::new("compact", "Compact the context"); + let converted = AcpAvailableCommand::from(no_input); + assert_eq!(converted.name, "compact"); + assert!(converted.input_hint.is_none()); + } } diff --git a/src/crates/acp/src/client/stream.rs b/src/crates/acp/src/client/stream.rs index 8a8b2c8ea..175f3068e 100644 --- a/src/crates/acp/src/client/stream.rs +++ b/src/crates/acp/src/client/stream.rs @@ -1,14 +1,14 @@ use std::collections::HashMap; use agent_client_protocol::schema::{ - ContentBlock, ContentChunk, SessionNotification, SessionUpdate, ToolCall, ToolCallContent, - ToolCallStatus, ToolCallUpdate, + ContentBlock, ContentChunk, SessionConfigOption, SessionNotification, SessionUpdate, ToolCall, + ToolCallContent, ToolCallStatus, ToolCallUpdate, }; use agent_client_protocol::util::MatchDispatch; use bitfun_core::util::errors::{BitFunError, BitFunResult}; use bitfun_events::ToolEventData; -use super::session_options::AcpSessionContextUsage; +use super::session_options::{AcpAvailableCommand, AcpPlanEntry, AcpSessionContextUsage}; use super::tool_card_bridge::{acp_tool_name, normalize_tool_params}; #[derive(Debug, Clone)] @@ -22,6 +22,15 @@ pub enum AcpClientStreamEvent { AgentThought(String), ToolEvent(ToolEventData), ContextUsageUpdated(AcpSessionContextUsage), + /// The agent advertised (or updated) its slash commands. + AvailableCommandsUpdated(Vec), + /// The agent published (or revised) its execution plan. Replaces the prior + /// plan in full. + PlanUpdated(Vec), + /// The agent updated its session configuration options (full set). Used to + /// keep the session's stored options fresh so model/options queries stay + /// accurate mid-session. + ConfigOptionsUpdated(Vec), Completed, Cancelled, } @@ -80,6 +89,9 @@ impl AcpStreamRoundTracker { } AcpClientStreamEvent::ModelRoundStarted { .. } | AcpClientStreamEvent::ContextUsageUpdated(_) + | AcpClientStreamEvent::AvailableCommandsUpdated(_) + | AcpClientStreamEvent::PlanUpdated(_) + | AcpClientStreamEvent::ConfigOptionsUpdated(_) | AcpClientStreamEvent::Completed | AcpClientStreamEvent::Cancelled => vec![event], } @@ -129,6 +141,23 @@ pub(super) async fn acp_dispatch_to_stream_events_with_tracker( AcpSessionContextUsage::from(usage_update), )); } + SessionUpdate::AvailableCommandsUpdate(update) => { + let commands = update + .available_commands + .into_iter() + .map(AcpAvailableCommand::from) + .collect(); + events.push(AcpClientStreamEvent::AvailableCommandsUpdated(commands)); + } + SessionUpdate::Plan(plan) => { + let entries = plan.entries.into_iter().map(AcpPlanEntry::from).collect(); + events.push(AcpClientStreamEvent::PlanUpdated(entries)); + } + SessionUpdate::ConfigOptionUpdate(update) => { + events.push(AcpClientStreamEvent::ConfigOptionsUpdated( + update.config_options, + )); + } _ => {} } Ok(()) @@ -432,6 +461,9 @@ mod tests { AcpClientStreamEvent::AgentThought(_) => "thought", AcpClientStreamEvent::ToolEvent(_) => "tool", AcpClientStreamEvent::ContextUsageUpdated(_) => "usage", + AcpClientStreamEvent::AvailableCommandsUpdated(_) => "commands", + AcpClientStreamEvent::PlanUpdated(_) => "plan", + AcpClientStreamEvent::ConfigOptionsUpdated(_) => "config_options", AcpClientStreamEvent::Completed => "completed", AcpClientStreamEvent::Cancelled => "cancelled", }) @@ -467,6 +499,119 @@ mod tests { )); } + #[test] + fn exposes_available_commands_updates() { + use agent_client_protocol::schema::{AvailableCommand, AvailableCommandsUpdate}; + use agent_client_protocol::JsonRpcMessage; + + let mut tracker = AcpToolCallTracker::new(); + let notification = SessionNotification::new( + "session-1", + SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(vec![ + AvailableCommand::new("compact", "Compact the context"), + AvailableCommand::new("init", "Initialize the project"), + ])), + ) + .to_untyped_message() + .expect("notification"); + let dispatch = agent_client_protocol::Dispatch::Notification(notification); + + let events = tokio::runtime::Runtime::new() + .expect("runtime") + .block_on(acp_dispatch_to_stream_events_with_tracker( + dispatch, + &mut tracker, + )) + .expect("dispatch"); + + match events.as_slice() { + [AcpClientStreamEvent::AvailableCommandsUpdated(commands)] => { + assert_eq!(commands.len(), 2); + assert_eq!(commands[0].name, "compact"); + assert_eq!(commands[1].name, "init"); + } + other => panic!("expected AvailableCommandsUpdated, got {other:?}"), + } + } + + #[test] + fn exposes_plan_updates() { + use agent_client_protocol::schema::{ + Plan, PlanEntry, PlanEntryPriority, PlanEntryStatus, + }; + use agent_client_protocol::JsonRpcMessage; + + let mut tracker = AcpToolCallTracker::new(); + let notification = SessionNotification::new( + "session-1", + SessionUpdate::Plan(Plan::new(vec![ + PlanEntry::new("Explore", PlanEntryPriority::High, PlanEntryStatus::Completed), + PlanEntry::new("Implement", PlanEntryPriority::Medium, PlanEntryStatus::InProgress), + ])), + ) + .to_untyped_message() + .expect("notification"); + let dispatch = agent_client_protocol::Dispatch::Notification(notification); + + let events = tokio::runtime::Runtime::new() + .expect("runtime") + .block_on(acp_dispatch_to_stream_events_with_tracker( + dispatch, + &mut tracker, + )) + .expect("dispatch"); + + match events.as_slice() { + [AcpClientStreamEvent::PlanUpdated(entries)] => { + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].content, "Explore"); + assert_eq!(entries[0].priority, "high"); + assert_eq!(entries[0].status, "completed"); + assert_eq!(entries[1].status, "in_progress"); + } + other => panic!("expected PlanUpdated, got {other:?}"), + } + } + + #[test] + fn exposes_config_option_updates() { + use agent_client_protocol::schema::{ConfigOptionUpdate, SessionConfigOption}; + use agent_client_protocol::JsonRpcMessage; + + let mut tracker = AcpToolCallTracker::new(); + let notification = SessionNotification::new( + "session-1", + SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(vec![ + SessionConfigOption::select( + "model", + "Model", + "fast", + vec![agent_client_protocol::schema::SessionConfigSelectOption::new( + "fast", "Fast", + )], + ), + ])), + ) + .to_untyped_message() + .expect("notification"); + let dispatch = agent_client_protocol::Dispatch::Notification(notification); + + let events = tokio::runtime::Runtime::new() + .expect("runtime") + .block_on(acp_dispatch_to_stream_events_with_tracker( + dispatch, + &mut tracker, + )) + .expect("dispatch"); + + match events.as_slice() { + [AcpClientStreamEvent::ConfigOptionsUpdated(options)] => { + assert_eq!(options.len(), 1); + } + other => panic!("expected ConfigOptionsUpdated, got {other:?}"), + } + } + #[test] fn starts_new_round_for_text_after_tool() { let mut tracker = AcpStreamRoundTracker::new(); diff --git a/src/web-ui/src/flow_chat/components/AcpPlanPanel.scss b/src/web-ui/src/flow_chat/components/AcpPlanPanel.scss new file mode 100644 index 000000000..9b226ec00 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/AcpPlanPanel.scss @@ -0,0 +1,96 @@ +/** + * ACP execution-plan checklist, rendered above the chat input while an ACP + * agent streams a plan. + */ + +.bitfun-acp-plan { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0 0 8px; + padding: 8px 10px; + border-radius: 8px; + background: var(--element-bg-medium); + border: 1px solid var(--border-color-light); + font-size: var(--flowchat-font-size-xs); + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + } + + &__title { + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.4px; + font-size: var(--flowchat-font-size-2xs); + } + + &__progress { + color: var(--color-text-tertiary); + font-variant-numeric: tabular-nums; + } + + &__list { + display: flex; + flex-direction: column; + gap: 2px; + margin: 0; + padding: 0; + list-style: none; + } + + &__item { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 2px 0; + color: var(--color-text-primary); + + &--completed { + color: var(--color-text-tertiary); + + .bitfun-acp-plan__content { + text-decoration: line-through; + } + } + + &--pending { + color: var(--color-text-secondary); + } + } + + &__icon { + flex-shrink: 0; + margin-top: 2px; + + &--done { + color: var(--color-success, #4caf50); + } + + &--active { + color: var(--color-primary, #648cff); + animation: bitfun-acp-plan-spin 1.2s linear infinite; + } + + &--pending { + opacity: 0.5; + } + } + + &__content { + line-height: 1.4; + word-break: break-word; + } +} + +@keyframes bitfun-acp-plan-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/src/web-ui/src/flow_chat/components/AcpPlanPanel.tsx b/src/web-ui/src/flow_chat/components/AcpPlanPanel.tsx new file mode 100644 index 000000000..196eed3de --- /dev/null +++ b/src/web-ui/src/flow_chat/components/AcpPlanPanel.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { Check, CircleDashed, LoaderCircle } from 'lucide-react'; + +import type { AcpPlanEntry } from '@/infrastructure/api/service-api/ACPClientAPI'; +import './AcpPlanPanel.scss'; + +export interface AcpPlanPanelProps { + entries: AcpPlanEntry[]; +} + +function statusIcon(status: string): React.ReactNode { + switch (status) { + case 'completed': + return ; + case 'in_progress': + return ( + + ); + default: + return ( + + ); + } +} + +/** + * Renders an ACP agent's execution plan as a live task checklist. Presentational + * only — fed by {@link useAcpPlan}. Renders nothing when there are no entries. + */ +export const AcpPlanPanel: React.FC = ({ entries }) => { + if (entries.length === 0) return null; + + const done = entries.filter((entry) => entry.status === 'completed').length; + + return ( +
+
+ Plan + + {done}/{entries.length} + +
+
    + {entries.map((entry, index) => ( +
  • + {statusIcon(entry.status)} + {entry.content} +
  • + ))} +
+
+ ); +}; + +AcpPlanPanel.displayName = 'AcpPlanPanel'; +export default AcpPlanPanel; diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 3361db881..d377120c6 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -20,6 +20,10 @@ import { import { SessionExecutionEvent } from '../state-machine/types'; import { ModelSelector } from './ModelSelector'; import { FlowChatStore } from '../store/FlowChatStore'; +import { useAcpSlashCommands } from '../hooks/useAcpSlashCommands'; +import { useAcpPlan } from '../hooks/useAcpPlan'; +import { AcpPlanPanel } from './AcpPlanPanel'; +import { acpSessionRef, acpSlashCommandText } from '../utils/acpSession'; import type { FlowChatState, Session } from '../types/flow-chat'; import type { FileContext, DirectoryContext, ImageContext } from '@/types/context.ts'; import { SmartRecommendations } from './smart-recommendations'; @@ -102,7 +106,14 @@ type SlashMcpPromptItem = { }>; }; -type SlashPickerItem = SlashActionItem | SlashModeItem | SlashMcpPromptItem; +type SlashAcpCommandItem = { + kind: 'acpCommand'; + id: string; // the agent command name (without leading slash) + command: string; // '/' + label: string; // description +}; + +type SlashPickerItem = SlashActionItem | SlashModeItem | SlashMcpPromptItem | SlashAcpCommandItem; type ChatInputTarget = 'main' | 'btw'; type PendingLargePasteMap = Record; @@ -269,6 +280,15 @@ export const ChatInput: React.FC = ({ : undefined; const effectiveTargetRelationship = resolveSessionRelationship(effectiveTargetSession); const isBtwSession = effectiveTargetRelationship.displayAsChild; + + // ACP agent slash commands (empty for non-ACP sessions). Surfaced in the + // existing slash picker alongside native actions / MCP prompts. + const acpSessionForCommands = useMemo( + () => acpSessionRef(effectiveTargetSession), + [effectiveTargetSession], + ); + const { commands: acpAgentCommands } = useAcpSlashCommands(acpSessionForCommands); + const { entries: acpPlanEntries } = useAcpPlan(acpSessionForCommands?.sessionId ?? null); const currentSessionTitle = currentSession?.title?.trim() || t('session.untitled'); const activeBtwSession = activeBtwSessionId ? flowChatState.sessions.get(activeBtwSessionId) @@ -1258,6 +1278,21 @@ export const ChatInput: React.FC = ({ }); }, [mcpPromptCommands, slashCommandState.query]); + const getFilteredAcpCommands = useCallback((): SlashAcpCommandItem[] => { + const items: SlashAcpCommandItem[] = acpAgentCommands.map(command => ({ + kind: 'acpCommand', + id: command.name, + command: `/${command.name}`, + label: command.description, + })); + const q = (slashCommandState.query || '').trim().toLowerCase(); + if (!q) return items; + return items.filter(item => + item.command.slice(1).toLowerCase().includes(q) || + item.label.toLowerCase().includes(q), + ); + }, [acpAgentCommands, slashCommandState.query]); + const resolveTypedMcpPromptCommand = useCallback((text: string): SlashMcpPromptItem | null => { const trimmed = text.trim(); if (!trimmed.startsWith('/')) { @@ -1277,6 +1312,7 @@ export const ChatInput: React.FC = ({ const getSlashPickerItems = useCallback((): SlashPickerItem[] => { const actions = getFilteredActions(); const mcpPrompts = getFilteredMcpPromptCommands(); + const acpCommands = getFilteredAcpCommands(); let modeList = incrementalCodeModes; if (canSwitchModes && slashCommandState.query) { const q = slashCommandState.query; @@ -1291,8 +1327,10 @@ export const ChatInput: React.FC = ({ id: mode.id, name: mode.name, })); - return [...actions, ...mcpPrompts, ...modes]; - }, [canSwitchModes, getFilteredActions, getFilteredMcpPromptCommands, incrementalCodeModes, slashCommandState.query]); + // ACP agent commands first — for an ACP session they are the primary + // commands the user wants. + return [...acpCommands, ...actions, ...mcpPrompts, ...modes]; + }, [canSwitchModes, getFilteredAcpCommands, getFilteredActions, getFilteredMcpPromptCommands, incrementalCodeModes, slashCommandState.query]); const handleInputChange = useCallback((text: string, activeContexts: import('../../shared/types/context').ContextItem[]) => { if (!inputState.isActive && text.length > 0) { @@ -2209,6 +2247,15 @@ export const ChatInput: React.FC = ({ window.setTimeout(() => richTextInputRef.current?.focus(), 0); }, [setQueuedInput]); + const selectSlashAcpCommand = useCallback((item: SlashAcpCommandItem) => { + // Insert "/ " as prompt text — ACP has no command RPC; the command is + // sent as a normal prompt. The trailing space lets the user type arguments. + dispatchInput({ type: 'SET_VALUE', payload: acpSlashCommandText(item.id) }); + setQueuedInput(null); + setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); + window.setTimeout(() => richTextInputRef.current?.focus(), 0); + }, [setQueuedInput]); + const handleBoostStartBtw = useCallback( (e: React.SyntheticEvent) => { e.stopPropagation(); @@ -2331,6 +2378,8 @@ export const ChatInput: React.FC = ({ selectSlashCommandMode(item.id); } else if (item.kind === 'mcpPrompt') { selectSlashPromptCommand(item); + } else if (item.kind === 'acpCommand') { + selectSlashAcpCommand(item); } else { selectSlashCommandAction(item.id); } @@ -2366,6 +2415,8 @@ export const ChatInput: React.FC = ({ selectSlashCommandMode(item.id); } else if (item.kind === 'mcpPrompt') { selectSlashPromptCommand(item); + } else if (item.kind === 'acpCommand') { + selectSlashAcpCommand(item); } else { selectSlashCommandAction(item.id); } @@ -2493,7 +2544,7 @@ export const ChatInput: React.FC = ({ e.preventDefault(); void handleCancelCurrentTask(); } - }, [handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, handleCancelCurrentTask, slashCommandState, getFilteredIncrementalModes, getFilteredActions, getSlashPickerItems, selectSlashCommandMode, selectSlashCommandAction, selectSlashPromptCommand, canSwitchModes, historyIndex, inputHistory, savedDraft, inputState.value, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, t]); + }, [handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, handleCancelCurrentTask, slashCommandState, getFilteredIncrementalModes, getFilteredActions, getSlashPickerItems, selectSlashCommandMode, selectSlashCommandAction, selectSlashAcpCommand, selectSlashPromptCommand, canSwitchModes, historyIndex, inputHistory, savedDraft, inputState.value, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, t]); const handleImeCompositionStart = useCallback(() => { isImeComposingRef.current = true; @@ -2743,6 +2794,7 @@ export const ChatInput: React.FC = ({
+
{showTargetSwitcher && (
@@ -2908,6 +2960,8 @@ export const ChatInput: React.FC = ({ selectSlashCommandMode(item.id); } else if (item.kind === 'mcpPrompt') { selectSlashPromptCommand(item); + } else if (item.kind === 'acpCommand') { + selectSlashAcpCommand(item); } else { selectSlashCommandAction(item.id); } diff --git a/src/web-ui/src/flow_chat/hooks/useAcpPlan.ts b/src/web-ui/src/flow_chat/hooks/useAcpPlan.ts new file mode 100644 index 000000000..f169e9aff --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/useAcpPlan.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +import type { + AcpPlanEntry, + AcpPlanUpdatedEvent, +} from '@/infrastructure/api/service-api/ACPClientAPI'; + +const PLAN_UPDATED_EVENT = 'agentic://acp-plan-updated'; + +/** + * Track the execution plan an ACP agent publishes for the given session. + * + * The agent sends the full plan on each update (it replaces the prior one), so + * the latest event wins. Returns an empty list for non-ACP sessions or before + * the agent publishes a plan. Resets when the session changes. + */ +export function useAcpPlan(sessionId: string | null): { entries: AcpPlanEntry[] } { + const [entries, setEntries] = useState([]); + + useEffect(() => { + setEntries([]); + }, [sessionId]); + + useEffect(() => { + if (!sessionId) return; + let active = true; + let unlisten: UnlistenFn | undefined; + listen(PLAN_UPDATED_EVENT, (event) => { + if (event.payload.sessionId === sessionId) { + setEntries(event.payload.entries); + } + }).then((fn) => { + if (active) unlisten = fn; + else fn(); + }); + return () => { + active = false; + unlisten?.(); + }; + }, [sessionId]); + + return { entries }; +} diff --git a/src/web-ui/src/flow_chat/hooks/useAcpSlashCommands.test.ts b/src/web-ui/src/flow_chat/hooks/useAcpSlashCommands.test.ts new file mode 100644 index 000000000..8239a95d0 --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/useAcpSlashCommands.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import type { AcpAvailableCommand } from '@/infrastructure/api/service-api/ACPClientAPI'; +import { filterSlashCommands } from './useAcpSlashCommands'; +import { acpSessionRef, acpSlashCommandText } from '../utils/acpSession'; + +const commands: AcpAvailableCommand[] = [ + { name: 'compact', description: 'Compact the conversation context' }, + { name: 'init', description: 'Initialize the project' }, + { name: 'create_plan', description: 'Draft an execution plan', inputHint: 'what to plan' }, +]; + +describe('filterSlashCommands', () => { + it('returns all commands for an empty query', () => { + expect(filterSlashCommands(commands, '')).toHaveLength(3); + expect(filterSlashCommands(commands, ' ')).toHaveLength(3); + }); + + it('tolerates a leading slash', () => { + expect(filterSlashCommands(commands, '/comp').map((c) => c.name)).toEqual(['compact']); + }); + + it('matches on name (case-insensitive)', () => { + expect(filterSlashCommands(commands, 'INIT').map((c) => c.name)).toEqual(['init']); + }); + + it('matches on description', () => { + expect(filterSlashCommands(commands, 'plan').map((c) => c.name)).toEqual(['create_plan']); + }); + + it('returns empty when nothing matches', () => { + expect(filterSlashCommands(commands, 'zzz')).toEqual([]); + }); +}); + +describe('acpSlashCommandText', () => { + it('formats a command name as invokable prompt text', () => { + expect(acpSlashCommandText('create_plan')).toBe('/create_plan '); + }); +}); + +describe('acpSessionRef', () => { + it('returns null for a non-ACP session', () => { + expect(acpSessionRef({ sessionId: 's1', config: { agentType: 'agentic' }, mode: 'agentic' } as never)).toBeNull(); + }); + + it('returns null when there is no session', () => { + expect(acpSessionRef(null)).toBeNull(); + expect(acpSessionRef(undefined)).toBeNull(); + }); + + it('derives the client id from an acp: agentType', () => { + const ref = acpSessionRef({ + sessionId: 's1', + config: { agentType: 'acp:omp', workspacePath: '/ws' }, + workspacePath: '/ws', + remoteConnectionId: 'conn-1', + remoteSshHost: 'host-1', + } as never); + expect(ref).toEqual({ + sessionId: 's1', + clientId: 'omp', + workspacePath: '/ws', + remoteConnectionId: 'conn-1', + remoteSshHost: 'host-1', + }); + }); + + it('falls back to mode when config.agentType is not acp', () => { + const ref = acpSessionRef({ sessionId: 's2', config: {}, mode: 'acp:claude-code' } as never); + expect(ref?.clientId).toBe('claude-code'); + expect(ref?.sessionId).toBe('s2'); + }); +}); diff --git a/src/web-ui/src/flow_chat/hooks/useAcpSlashCommands.ts b/src/web-ui/src/flow_chat/hooks/useAcpSlashCommands.ts new file mode 100644 index 000000000..8c62710cf --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/useAcpSlashCommands.ts @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +import { + ACPClientAPI, + type AcpAvailableCommand, + type AcpAvailableCommandsUpdatedEvent, +} from '@/infrastructure/api/service-api/ACPClientAPI'; +import type { AcpSessionRef } from '../utils/acpSession'; + +const AVAILABLE_COMMANDS_EVENT = 'agentic://acp-available-commands-updated'; + +/** + * Filter advertised slash commands by a query (the text the user has typed + * after `/`). A leading slash is tolerated. Matching is case-insensitive over + * both the command name and its description. An empty query returns all. + */ +export function filterSlashCommands( + commands: AcpAvailableCommand[], + query: string, +): AcpAvailableCommand[] { + const q = query.trim().toLowerCase().replace(/^\//, ''); + if (!q) return commands; + return commands.filter( + (command) => + command.name.toLowerCase().includes(q) || + command.description.toLowerCase().includes(q), + ); +} + +/** + * Track the slash commands an ACP agent advertises for the given session. + * + * Fetches the current list on mount / session change and then keeps it live by + * subscribing to `agentic://acp-available-commands-updated`. Returns an empty + * list for non-ACP sessions or before the agent has advertised any commands. + */ +export function useAcpSlashCommands( + acpSession: AcpSessionRef | null, +): { commands: AcpAvailableCommand[] } { + const [commands, setCommands] = useState([]); + + const sessionId = acpSession?.sessionId ?? null; + const clientId = acpSession?.clientId ?? null; + const workspacePath = acpSession?.workspacePath; + const remoteConnectionId = acpSession?.remoteConnectionId; + const remoteSshHost = acpSession?.remoteSshHost; + + // Reset immediately when the session changes so a stale list never leaks + // across sessions. + useEffect(() => { + setCommands([]); + }, [sessionId]); + + // Initial fetch of whatever the agent has already advertised. + useEffect(() => { + if (!sessionId || !clientId) return; + let cancelled = false; + ACPClientAPI.getSessionCommands({ + sessionId, + clientId, + workspacePath, + remoteConnectionId, + remoteSshHost, + }) + .then((list) => { + if (!cancelled) setCommands(list); + }) + .catch(() => { + /* commands stay empty; the live event may still populate them */ + }); + return () => { + cancelled = true; + }; + }, [sessionId, clientId, workspacePath, remoteConnectionId, remoteSshHost]); + + // Live updates while a turn streams (the agent can change its command set). + useEffect(() => { + if (!sessionId) return; + let active = true; + let unlisten: UnlistenFn | undefined; + listen(AVAILABLE_COMMANDS_EVENT, (event) => { + if (event.payload.sessionId === sessionId) { + setCommands(event.payload.commands); + } + }).then((fn) => { + if (active) unlisten = fn; + else fn(); + }); + return () => { + active = false; + unlisten?.(); + }; + }, [sessionId]); + + return { commands }; +} diff --git a/src/web-ui/src/flow_chat/utils/acpSession.ts b/src/web-ui/src/flow_chat/utils/acpSession.ts index 27fef1e00..4c5db4f74 100644 --- a/src/web-ui/src/flow_chat/utils/acpSession.ts +++ b/src/web-ui/src/flow_chat/utils/acpSession.ts @@ -22,3 +22,41 @@ export function isAcpFlowSession( isAcpAgentType(session?.mode), ); } + +/** The identifying fields needed to query/observe an ACP session's backend state. */ +export interface AcpSessionRef { + sessionId: string; + clientId: string; + workspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; +} + +/** + * Derive an {@link AcpSessionRef} from a flow-chat session, or `null` if the + * session is not ACP-backed. Mirrors the resolution used by ModelSelector. + */ +export function acpSessionRef( + session: Pick | null | undefined, +): AcpSessionRef | null { + if (!session?.sessionId) return null; + const clientId = + acpClientIdFromAgentType(session.config?.agentType) ?? + acpClientIdFromAgentType(session.mode); + if (!clientId) return null; + return { + sessionId: session.sessionId, + clientId, + workspacePath: session.workspacePath ?? session.config?.workspacePath, + remoteConnectionId: session.remoteConnectionId ?? session.config?.remoteConnectionId, + remoteSshHost: session.remoteSshHost, + }; +} + +/** + * The text that invokes an ACP slash command. ACP has no dedicated command RPC: + * a command is sent as a normal prompt whose text begins with `/`. + */ +export function acpSlashCommandText(name: string): string { + return `/${name} `; +} diff --git a/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts index 502aa0ab3..3a3a89bed 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts @@ -119,6 +119,51 @@ export interface SubmitAcpPermissionResponseRequest { optionId?: string; } +/** + * A slash command advertised by an ACP agent (AvailableCommandsUpdate). + * Invoked by sending a normal prompt of the form `/ `. + */ +export interface AcpAvailableCommand { + name: string; + description: string; + /** Hint for the text typed after the command name; absent if no input. */ + inputHint?: string; +} + +/** Live event payload emitted on `agentic://acp-available-commands-updated`. */ +export interface AcpAvailableCommandsUpdatedEvent { + sessionId: string; + clientId: string; + commands: AcpAvailableCommand[]; +} + +/** One entry of an ACP agent's execution plan (agent-plan). */ +export interface AcpPlanEntry { + content: string; + /** 'high' | 'medium' | 'low' */ + priority: string; + /** 'pending' | 'in_progress' | 'completed' */ + status: string; +} + +/** Live event payload emitted on `agentic://acp-plan-updated`. Replaces the plan in full. */ +export interface AcpPlanUpdatedEvent { + sessionId: string; + turnId: string; + clientId: string; + entries: AcpPlanEntry[]; +} + +/** + * Live signal emitted on `agentic://acp-session-options-changed` when the agent + * updated its config options mid-session. Consumers should refetch via + * `getSessionOptions` to get the fresh combined view. + */ +export interface AcpSessionOptionsChangedEvent { + sessionId: string; + clientId: string; +} + export interface AcpPermissionOption { optionId: string; name: string; @@ -241,6 +286,17 @@ export class ACPClientAPI { return api.invoke('get_acp_session_options', { request }); } + /** + * Slash commands the agent has advertised for this session. Empty until the + * agent sends them (typically during/after the first prompt). Pair with the + * `agentic://acp-available-commands-updated` event for live updates. + */ + static async getSessionCommands( + request: GetAcpSessionOptionsRequest + ): Promise { + return api.invoke('get_acp_session_commands', { request }); + } + static async setSessionModel( request: SetAcpSessionModelRequest ): Promise {