From fd72465c7bdf779ec1948d69ae0982835aad7967 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 16 Jul 2026 10:42:17 +0800 Subject: [PATCH] fix(acp): support Codex Fast mode --- src/apps/cli/src/acp_cli.rs | 7 +- src/apps/desktop/src/api/acp_client_api.rs | 30 +++- src/apps/desktop/src/lib.rs | 1 + .../acp/src/client/builtin_clients.rs | 112 +++++++++++++- .../interfaces/acp/src/client/manager.rs | 124 +++++++++++++++- src/crates/interfaces/acp/src/client/mod.rs | 7 +- .../interfaces/acp/src/client/requirements.rs | 29 +++- .../acp/src/client/session_options.rs | 139 ++++++++++++++++++ .../workspaceAcpMenuClients.test.ts | 6 +- .../flow_chat/components/ModelSelector.scss | 32 ++++ .../flow_chat/components/ModelSelector.tsx | 70 ++++++++- .../flow_chat/utils/acpSessionConfig.test.ts | 48 ++++++ .../src/flow_chat/utils/acpSessionConfig.ts | 51 +++++++ .../api/service-api/ACPClientAPI.ts | 45 ++++++ .../components/AcpAgentsConfig.test.tsx | 14 +- .../config/components/AcpAgentsConfig.tsx | 8 +- src/web-ui/src/locales/en-US/flow-chat.json | 2 + src/web-ui/src/locales/zh-CN/flow-chat.json | 2 + src/web-ui/src/locales/zh-TW/flow-chat.json | 2 + 19 files changed, 688 insertions(+), 41 deletions(-) create mode 100644 src/web-ui/src/flow_chat/utils/acpSessionConfig.test.ts create mode 100644 src/web-ui/src/flow_chat/utils/acpSessionConfig.ts diff --git a/src/apps/cli/src/acp_cli.rs b/src/apps/cli/src/acp_cli.rs index 5c36088e4b..840fa6d4b7 100644 --- a/src/apps/cli/src/acp_cli.rs +++ b/src/apps/cli/src/acp_cli.rs @@ -52,9 +52,12 @@ impl ExternalAcpClient { Self::Opencode => ("opencode", vec!["acp"]), Self::ClaudeCode => ( "npx", - vec!["--yes", "@zed-industries/claude-code-acp@latest"], + vec!["--yes", "@agentclientprotocol/claude-agent-acp@latest"], + ), + Self::Codex => ( + "npx", + vec!["--yes", "@agentclientprotocol/codex-acp@latest"], ), - Self::Codex => ("npx", vec!["--yes", "@zed-industries/codex-acp@latest"]), }; AcpClientConfig { name: Some(self.display_name().to_string()), diff --git a/src/apps/desktop/src/api/acp_client_api.rs b/src/apps/desktop/src/api/acp_client_api.rs index 1db3a7b300..83d685add8 100644 --- a/src/apps/desktop/src/api/acp_client_api.rs +++ b/src/apps/desktop/src/api/acp_client_api.rs @@ -6,7 +6,8 @@ use crate::startup_trace::DesktopStartupTrace; use bitfun_acp::client::{ AcpAvailableCommand, AcpClientInfo, AcpClientPermissionResponse, AcpClientRequirementProbe, AcpClientStreamEvent, AcpSessionOptions, CreateAcpFlowSessionRecordResponse, - SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest, + SetAcpSessionConfigOptionRequest, SetAcpSessionModelRequest, + SubmitAcpPermissionResponseRequest, }; use serde::{Deserialize, Serialize}; use std::time::Instant; @@ -662,6 +663,33 @@ pub async fn set_acp_session_model( .map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn set_acp_session_config_option( + state: State<'_, AppState>, + request: SetAcpSessionConfigOptionRequest, +) -> Result { + 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 + .set_session_config_option(request, session_storage_path) + .await + .map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn stop_acp_client( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 3894d36f0c..bbaba74cc0 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1134,6 +1134,7 @@ pub async fn run() { get_acp_session_options, get_acp_session_commands, set_acp_session_model, + set_acp_session_config_option, lsp_initialize, lsp_start_server_for_file, lsp_stop_server, diff --git a/src/crates/interfaces/acp/src/client/builtin_clients.rs b/src/crates/interfaces/acp/src/client/builtin_clients.rs index e709a1476d..e1aafbb8ec 100644 --- a/src/crates/interfaces/acp/src/client/builtin_clients.rs +++ b/src/crates/interfaces/acp/src/client/builtin_clients.rs @@ -1,6 +1,13 @@ use std::collections::HashMap; -use super::config::{AcpClientConfig, AcpClientPermissionMode}; +use super::config::{AcpClientConfig, AcpClientConfigFile, AcpClientPermissionMode}; + +const CLAUDE_ACP_PACKAGE: &str = "@agentclientprotocol/claude-agent-acp"; +const CLAUDE_ACP_ARGS: &[&str] = &["--yes", "@agentclientprotocol/claude-agent-acp@latest"]; +const LEGACY_CLAUDE_ACP_ARGS: &[&str] = &["--yes", "@zed-industries/claude-code-acp@latest"]; +const CODEX_ACP_PACKAGE: &str = "@agentclientprotocol/codex-acp"; +const CODEX_ACP_ARGS: &[&str] = &["--yes", "@agentclientprotocol/codex-acp@latest"]; +const LEGACY_CODEX_ACP_ARGS: &[&str] = &["--yes", "@zed-industries/codex-acp@latest"]; pub(crate) struct BuiltinAcpClientPreset { pub(crate) id: &'static str, @@ -43,19 +50,19 @@ const BUILTIN_ACP_CLIENT_PRESETS: &[BuiltinAcpClientPreset] = &[ BuiltinAcpClientPreset { id: "claude-code", command: "npx", - args: &["--yes", "@zed-industries/claude-code-acp@latest"], + args: CLAUDE_ACP_ARGS, tool_command: "claude", install_package: Some("@anthropic-ai/claude-code"), - adapter_package: Some("@zed-industries/claude-code-acp"), - adapter_bin: Some("claude-code-acp"), + adapter_package: Some(CLAUDE_ACP_PACKAGE), + adapter_bin: Some("claude-agent-acp"), }, BuiltinAcpClientPreset { id: "codex", command: "npx", - args: &["--yes", "@zed-industries/codex-acp@latest"], + args: CODEX_ACP_ARGS, tool_command: "codex", install_package: Some("@openai/codex"), - adapter_package: Some("@zed-industries/codex-acp"), + adapter_package: Some(CODEX_ACP_PACKAGE), adapter_bin: Some("codex-acp"), }, ]; @@ -89,6 +96,29 @@ pub(crate) fn default_config_for_builtin_client(client_id: &str) -> Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, + pub config_id: String, + pub value: AcpSessionConfigValue, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum AcpSessionConfigValue { + Select { value: String }, + Boolean { value: bool }, +} + +impl AcpSessionConfigValue { + fn into_protocol_value(self) -> SessionConfigOptionValue { + match self { + Self::Select { value } => SessionConfigOptionValue::value_id(value), + Self::Boolean { value } => SessionConfigOptionValue::boolean(value), + } + } +} + pub struct AcpClientService { config_service: Arc, session_persistence: AcpSessionPersistence, @@ -1007,6 +1044,66 @@ impl AcpClientService { )) } + pub async fn set_session_config_option( + self: &Arc, + request: SetAcpSessionConfigOptionRequest, + session_storage_path: Option, + ) -> BitFunResult { + let resolved = self + .resolve_or_create_client_session( + &request.client_id, + request.workspace_path, + request.remote_connection_id.as_deref(), + &request.session_id, + ) + .await?; + + let mut session = resolved.session.lock().await; + self.ensure_remote_session( + &resolved.client, + &resolved.session_key, + &resolved.cwd, + &request.session_id, + session_storage_path.as_deref(), + &mut session, + ) + .await?; + let active = session + .active + .as_ref() + .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; + let remote_session_id = active.session_id().to_string(); + let connection = active.connection(); + + if !session + .config_options + .iter() + .any(|option| option.id.to_string() == request.config_id) + { + return Err(BitFunError::NotFound(format!( + "ACP session config option not found: {}", + request.config_id + ))); + } + + let response = connection + .send_request(SetSessionConfigOptionRequest::new( + remote_session_id, + request.config_id, + request.value.into_protocol_value(), + )) + .block_task() + .await + .map_err(protocol_error)?; + session.config_options = response.config_options; + + Ok(session_options_from_state( + session.models.as_ref(), + &session.config_options, + session.context_usage.as_ref(), + )) + } + pub async fn prompt_agent( self: &Arc, client_id: &str, @@ -1845,7 +1942,7 @@ async fn wait_for_client_connection( } fn parse_config_value(value: serde_json::Value) -> BitFunResult { - if value.get("acpClients").is_some() { + let mut config = if value.get("acpClients").is_some() { serde_json::from_value(value) .map_err(|error| BitFunError::config(format!("Invalid ACP client config: {}", error))) } else if value.is_object() { @@ -1856,7 +1953,9 @@ fn parse_config_value(value: serde_json::Value) -> BitFunResult String { @@ -2459,6 +2558,21 @@ mod tests { ); } + #[test] + fn maps_session_config_values_to_acp_protocol_values() { + let select = AcpSessionConfigValue::Select { + value: "on".to_string(), + } + .into_protocol_value(); + assert_eq!( + select.as_value_id().map(ToString::to_string).as_deref(), + Some("on") + ); + + let boolean = AcpSessionConfigValue::Boolean { value: true }.into_protocol_value(); + assert_eq!(boolean.as_bool(), Some(true)); + } + #[test] fn renders_remote_client_command_from_config() { let config = AcpClientConfig { @@ -2492,7 +2606,7 @@ mod tests { command: "npx".to_string(), args: vec![ "--yes".to_string(), - "@zed-industries/codex-acp@latest".to_string(), + "@agentclientprotocol/codex-acp@latest".to_string(), ], env: HashMap::from([("BASE".to_string(), "1".to_string())]), enabled: true, @@ -2508,7 +2622,7 @@ mod tests { assert_eq!(resolved.command, "npx"); assert_eq!( resolved.args, - vec!["--yes", "@zed-industries/codex-acp@latest"] + vec!["--yes", "@agentclientprotocol/codex-acp@latest"] ); assert_eq!(resolved.env.get("BASE").map(String::as_str), Some("1")); assert!(resolved.enabled); diff --git a/src/crates/interfaces/acp/src/client/mod.rs b/src/crates/interfaces/acp/src/client/mod.rs index b5a42015e6..12fce4ead6 100644 --- a/src/crates/interfaces/acp/src/client/mod.rs +++ b/src/crates/interfaces/acp/src/client/mod.rs @@ -17,11 +17,12 @@ pub use config::{ RemoteAcpClientRequirementSnapshot, }; pub use manager::{ - AcpClientPermissionResponse, AcpClientService, CreateAcpFlowSessionRecordResponse, + AcpClientPermissionResponse, AcpClientService, AcpSessionConfigValue, + CreateAcpFlowSessionRecordResponse, SetAcpSessionConfigOptionRequest, SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest, }; pub use session_options::{ - AcpAvailableCommand, AcpPlanEntry, AcpSessionContextUsage, AcpSessionModelOption, - AcpSessionOptions, + AcpAvailableCommand, AcpPlanEntry, AcpSessionConfigKind, AcpSessionConfigOption, + AcpSessionConfigSelectOption, AcpSessionContextUsage, AcpSessionModelOption, AcpSessionOptions, }; pub use stream::AcpClientStreamEvent; diff --git a/src/crates/interfaces/acp/src/client/requirements.rs b/src/crates/interfaces/acp/src/client/requirements.rs index 54b9db4a57..df6eb8aba4 100644 --- a/src/crates/interfaces/acp/src/client/requirements.rs +++ b/src/crates/interfaces/acp/src/client/requirements.rs @@ -637,12 +637,12 @@ mod tests { #[test] fn npm_adapter_argument_builders_preserve_cli_order() { assert_eq!( - npm_offline_probe_args("@zed-industries/codex-acp", "codex-acp"), + npm_offline_probe_args("@agentclientprotocol/codex-acp", "codex-acp"), [ "exec", "--offline", "--yes", - "--package=@zed-industries/codex-acp", + "--package=@agentclientprotocol/codex-acp", "--", "codex-acp", "--help", @@ -650,11 +650,11 @@ mod tests { .map(str::to_string) ); assert_eq!( - npm_predownload_args("@zed-industries/codex-acp", "codex-acp"), + npm_predownload_args("@agentclientprotocol/codex-acp", "codex-acp"), [ "exec", "--yes", - "--package=@zed-industries/codex-acp", + "--package=@agentclientprotocol/codex-acp", "--", "codex-acp", "--help", @@ -663,6 +663,21 @@ mod tests { ); } + #[test] + fn builtin_adapter_specs_use_maintained_agent_client_protocol_packages() { + let claude = acp_requirement_spec("claude-code", None) + .adapter + .expect("Claude adapter"); + assert_eq!(claude.package, "@agentclientprotocol/claude-agent-acp"); + assert_eq!(claude.bin, "claude-agent-acp"); + + let codex = acp_requirement_spec("codex", None) + .adapter + .expect("Codex adapter"); + assert_eq!(codex.package, "@agentclientprotocol/codex-acp"); + assert_eq!(codex.bin, "codex-acp"); + } + #[test] fn command_search_paths_keep_configured_path_first() { let configured_paths = env::join_paths([ @@ -698,9 +713,9 @@ mod tests { #[test] fn npx_adapter_probe_item_marks_auto_install_available() { - let item = npx_adapter_probe_item("@zed-industries/codex-acp"); + let item = npx_adapter_probe_item("@agentclientprotocol/codex-acp"); - assert_eq!(item.name, "@zed-industries/codex-acp"); + assert_eq!(item.name, "@agentclientprotocol/codex-acp"); assert!(item.installed); assert_eq!(item.path.as_deref(), Some("npx auto-install")); assert!(item.error.is_none()); @@ -717,7 +732,7 @@ mod tests { let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should be created"); let item = runtime.block_on(probe_npm_adapter_with_path( - "@zed-industries/codex-acp", + "@agentclientprotocol/codex-acp", "codex-acp", Some(test_dir.as_os_str()), )); diff --git a/src/crates/interfaces/acp/src/client/session_options.rs b/src/crates/interfaces/acp/src/client/session_options.rs index 5b45585671..6d8b2493cc 100644 --- a/src/crates/interfaces/acp/src/client/session_options.rs +++ b/src/crates/interfaces/acp/src/client/session_options.rs @@ -96,6 +96,8 @@ pub struct AcpSessionOptions { #[serde(default)] pub model_config_id: Option, #[serde(default)] + pub config_options: Vec, + #[serde(default)] pub context_usage: Option, } @@ -108,12 +110,52 @@ pub struct AcpSessionModelOption { pub description: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpSessionConfigOption { + pub id: String, + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(flatten)] + pub kind: AcpSessionConfigKind, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum AcpSessionConfigKind { + Select { + current_value: String, + options: Vec, + }, + Boolean { + current_value: bool, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpSessionConfigSelectOption { + pub value: String, + pub name: String, + #[serde(default)] + pub description: Option, +} + pub(super) fn session_options_from_state( models: Option<&SessionModelState>, config_options: &[SessionConfigOption], context_usage: Option<&AcpSessionContextUsage>, ) -> AcpSessionOptions { let context_usage = context_usage.cloned(); + let session_config_options = config_options + .iter() + .filter_map(session_config_option_from_protocol) + .collect(); if let Some(models) = models.filter(|models| !models.available_models.is_empty()) { return AcpSessionOptions { current_model_id: Some(models.current_model_id.to_string()), @@ -123,6 +165,7 @@ pub(super) fn session_options_from_state( .map(model_option_from_model_info) .collect(), model_config_id: None, + config_options: session_config_options, context_usage, }; } @@ -133,16 +176,68 @@ pub(super) fn session_options_from_state( current_model_id, available_models, model_config_id: Some(option.id.to_string()), + config_options: session_config_options, context_usage, }; } AcpSessionOptions { + config_options: session_config_options, context_usage, ..Default::default() } } +fn session_config_option_from_protocol( + option: &SessionConfigOption, +) -> Option { + let kind = match &option.kind { + SessionConfigKind::Select(select) => { + let options = match &select.options { + SessionConfigSelectOptions::Ungrouped(options) => options + .iter() + .map(session_config_select_option_from_protocol) + .collect(), + SessionConfigSelectOptions::Grouped(groups) => groups + .iter() + .flat_map(|group| { + group + .options + .iter() + .map(session_config_select_option_from_protocol) + }) + .collect(), + _ => Vec::new(), + }; + AcpSessionConfigKind::Select { + current_value: select.current_value.to_string(), + options, + } + } + SessionConfigKind::Boolean(boolean) => AcpSessionConfigKind::Boolean { + current_value: boolean.current_value, + }, + _ => return None, + }; + + Some(AcpSessionConfigOption { + id: option.id.to_string(), + name: option.name.clone(), + description: option.description.clone(), + kind, + }) +} + +fn session_config_select_option_from_protocol( + option: &agent_client_protocol::schema::SessionConfigSelectOption, +) -> AcpSessionConfigSelectOption { + AcpSessionConfigSelectOption { + value: option.value.to_string(), + name: option.name.clone(), + description: option.description.clone(), + } +} + pub(super) fn model_config_id(config_options: &[SessionConfigOption]) -> Option { model_config_option(config_options).map(|option| option.id.to_string()) } @@ -262,6 +357,50 @@ mod tests { ); } + #[test] + fn exposes_select_and_boolean_session_config_options() { + let select = SessionConfigOption::select( + "fast-mode", + "Fast mode", + "off", + vec![ + agent_client_protocol::schema::SessionConfigSelectOption::new("off", "Off"), + agent_client_protocol::schema::SessionConfigSelectOption::new("on", "On"), + ], + ) + .description("1.5x speed, increased usage"); + let boolean = SessionConfigOption::boolean("web-search", "Web search", true); + + let options = session_options_from_state(None, &[select, boolean], None); + + assert_eq!(options.config_options.len(), 2); + assert_eq!(options.config_options[0].id, "fast-mode"); + assert_eq!( + options.config_options[0].kind, + AcpSessionConfigKind::Select { + current_value: "off".to_string(), + options: vec![ + AcpSessionConfigSelectOption { + value: "off".to_string(), + name: "Off".to_string(), + description: None, + }, + AcpSessionConfigSelectOption { + value: "on".to_string(), + name: "On".to_string(), + description: None, + }, + ], + } + ); + assert_eq!( + options.config_options[1].kind, + AcpSessionConfigKind::Boolean { + current_value: true, + } + ); + } + #[test] fn converts_available_command_with_and_without_input() { use agent_client_protocol::schema::{ diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.test.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.test.ts index 3a26fdb7be..306709896f 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.test.ts @@ -58,7 +58,7 @@ describe('loadWorkspaceAcpMenuClients', () => { { id: 'claude-code', tool: { name: 'claude', installed: false }, - adapter: { name: '@zed-industries/claude-code-acp', installed: true }, + adapter: { name: '@agentclientprotocol/claude-agent-acp', installed: true }, runnable: false, notes: ['claude is not available on remote PATH'], }, @@ -126,7 +126,7 @@ describe('loadWorkspaceAcpMenuClients', () => { { id: 'codex', tool: { name: 'codex', installed: true }, - adapter: { name: '@zed-industries/codex-acp', installed: false }, + adapter: { name: '@agentclientprotocol/codex-acp', installed: false }, runnable: false, notes: ['npx is not available on remote PATH'], }, @@ -135,7 +135,7 @@ describe('loadWorkspaceAcpMenuClients', () => { { id: 'codex', tool: { name: 'codex', installed: true }, - adapter: { name: '@zed-industries/codex-acp', installed: true }, + adapter: { name: '@agentclientprotocol/codex-acp', installed: true }, runnable: true, notes: [], }, diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.scss b/src/web-ui/src/flow_chat/components/ModelSelector.scss index bc0117510b..a80ab90b5f 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.scss +++ b/src/web-ui/src/flow_chat/components/ModelSelector.scss @@ -71,6 +71,11 @@ color: var(--color-text-muted); flex-shrink: 0; } + + &__fast-icon { + color: var(--color-warning); + flex-shrink: 0; + } &__badge { display: inline-flex; @@ -180,6 +185,33 @@ background: var(--border-subtle); margin: 4px 12px; } + + &__config-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px 10px; + } + + &__config-copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + &__config-name { + color: var(--color-text-primary); + font-size: var(--flowchat-font-size-xs); + font-weight: 500; + } + + &__config-description { + color: var(--color-text-muted); + font-size: var(--flowchat-font-size-2xs); + line-height: 1.35; + } &__list { max-height: 240px; diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index 949c62f42e..07eb00e7ec 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -11,7 +11,7 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; -import { Brain, ChevronDown, Check } from 'lucide-react'; +import { Brain, ChevronDown, Check, Zap } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; @@ -20,10 +20,11 @@ import { getProviderDisplayName } from '@/infrastructure/config/services/modelCo import { getEffectiveReasoningMode, isReasoningVisiblyEnabled } from '@/infrastructure/config/utils/reasoning'; import { globalEventBus } from '@/infrastructure/event-bus'; import type { AIModelConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; -import { Tooltip } from '@/component-library'; +import { Switch, Tooltip } from '@/component-library'; import { FlowChatStore } from '../store/FlowChatStore'; import { getModelMaxTokens } from '../services/flow-chat-manager/SessionModule'; import { acpClientIdFromAgentType } from '../utils/acpSession'; +import { buildAcpFastModeValue, resolveAcpFastModeState } from '../utils/acpSessionConfig'; import { buildContextUsageTooltip, type ContextUsageSource, @@ -386,6 +387,11 @@ export const ModelSelector: React.FC = ({ provider: 'acp', }; }, [acpAvailableModels, acpClientId, acpOptions?.currentModelId, isAcpSession]); + + const acpFastMode = useMemo( + () => resolveAcpFastModeState(acpOptions?.configOptions ?? []), + [acpOptions?.configOptions], + ); const getCurrentModelId = useCallback((): string => { // Session-owned model takes priority so that each session remembers @@ -549,6 +555,41 @@ export const ModelSelector: React.FC = ({ loading, sessionId, ]); + + const handleSetAcpFastMode = useCallback(async (enabled: boolean) => { + if (loading || !acpFastMode || !acpClientId || !sessionId) return; + const value = buildAcpFastModeValue(acpFastMode.option, enabled); + if (!value) return; + + setLoading(true); + try { + const options = await ACPClientAPI.setSessionConfigOption({ + sessionId, + clientId: acpClientId, + workspacePath: activeSession?.workspacePath || activeSession?.config.workspacePath, + remoteConnectionId: activeSession?.remoteConnectionId, + remoteSshHost: activeSession?.remoteSshHost, + configId: acpFastMode.option.id, + value, + }); + setAcpOptions(options); + syncAcpContextUsageToStore(sessionId, options); + log.info('ACP Fast mode updated', { sessionId, acpClientId, enabled }); + } catch (error) { + log.error('Failed to update ACP Fast mode', error); + } finally { + setLoading(false); + } + }, [ + activeSession?.config.workspacePath, + activeSession?.remoteConnectionId, + activeSession?.remoteSshHost, + activeSession?.workspacePath, + acpClientId, + acpFastMode, + loading, + sessionId, + ]); const tokenPercentage = useMemo(() => { if (!maxTokens || maxTokens <= 0 || !currentTokens) return 0; @@ -602,6 +643,9 @@ export const ModelSelector: React.FC = ({ {getModelDisplayLabel(acpCurrentModel, currentAcpModelId)} + {acpFastMode?.enabled && ( + + )} {tokenPercentage > 0 && ( · {tokenPercentage}% @@ -652,6 +696,28 @@ export const ModelSelector: React.FC = ({ ); })} + {acpFastMode && ( + <> +
+
+
+ + {t('modelSelector.fastMode')} + + + {t('modelSelector.fastModeDescription')} + +
+ { void handleSetAcpFastMode(event.target.checked); }} + /> +
+ + )}
, document.body )} diff --git a/src/web-ui/src/flow_chat/utils/acpSessionConfig.test.ts b/src/web-ui/src/flow_chat/utils/acpSessionConfig.test.ts new file mode 100644 index 0000000000..511df65a9d --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/acpSessionConfig.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import type { AcpSessionConfigOption } from '@/infrastructure/api/service-api/ACPClientAPI'; +import { buildAcpFastModeValue, resolveAcpFastModeState } from './acpSessionConfig'; + +describe('ACP Fast mode config', () => { + it('resolves and toggles the select fallback exposed by Codex ACP', () => { + const option: AcpSessionConfigOption = { + id: 'fast-mode', + name: 'Fast mode', + type: 'select', + currentValue: 'off', + options: [ + { value: 'off', name: 'Off' }, + { value: 'on', name: 'On' }, + ], + }; + + expect(resolveAcpFastModeState([option])).toEqual({ option, enabled: false }); + expect(buildAcpFastModeValue(option, true)).toEqual({ type: 'select', value: 'on' }); + }); + + it('supports ACP boolean config options', () => { + const option: AcpSessionConfigOption = { + id: 'fast-mode', + name: 'Fast mode', + type: 'boolean', + currentValue: true, + }; + + expect(resolveAcpFastModeState([option])).toEqual({ option, enabled: true }); + expect(buildAcpFastModeValue(option, false)).toEqual({ type: 'boolean', value: false }); + }); + + it('ignores unrelated or malformed options', () => { + const malformed: AcpSessionConfigOption = { + id: 'fast-mode', + name: 'Fast mode', + type: 'select', + currentValue: 'standard', + options: [{ value: 'standard', name: 'Standard' }], + }; + + expect(resolveAcpFastModeState([])).toBeNull(); + expect(resolveAcpFastModeState([malformed])).toBeNull(); + expect(buildAcpFastModeValue(malformed, true)).toBeNull(); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/acpSessionConfig.ts b/src/web-ui/src/flow_chat/utils/acpSessionConfig.ts new file mode 100644 index 0000000000..8730df68dd --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/acpSessionConfig.ts @@ -0,0 +1,51 @@ +import type { + AcpSessionConfigOption, + AcpSessionConfigValue, +} from '@/infrastructure/api/service-api/ACPClientAPI'; + +const FAST_MODE_CONFIG_ID = 'fast-mode'; +const FAST_MODE_ON_VALUE = 'on'; +const FAST_MODE_OFF_VALUE = 'off'; + +export interface AcpFastModeState { + option: AcpSessionConfigOption; + enabled: boolean; +} + +export function resolveAcpFastModeState( + options: AcpSessionConfigOption[], +): AcpFastModeState | null { + const option = options.find(candidate => candidate.id === FAST_MODE_CONFIG_ID); + if (!option) return null; + + if (option.type === 'boolean') { + return { option, enabled: option.currentValue }; + } + + const values = new Set(option.options.map(candidate => candidate.value)); + if (!values.has(FAST_MODE_ON_VALUE) || !values.has(FAST_MODE_OFF_VALUE)) { + return null; + } + if (option.currentValue !== FAST_MODE_ON_VALUE && option.currentValue !== FAST_MODE_OFF_VALUE) { + return null; + } + + return { + option, + enabled: option.currentValue === FAST_MODE_ON_VALUE, + }; +} + +export function buildAcpFastModeValue( + option: AcpSessionConfigOption, + enabled: boolean, +): AcpSessionConfigValue | null { + if (option.type === 'boolean') { + return { type: 'boolean', value: enabled }; + } + + const value = enabled ? FAST_MODE_ON_VALUE : FAST_MODE_OFF_VALUE; + return option.options.some(candidate => candidate.value === value) + ? { type: 'select', value } + : null; +} 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 b7cd173f88..010dc9b712 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts @@ -91,12 +91,50 @@ export interface SetAcpSessionModelRequest { modelId: string; } +export type AcpSessionConfigValue = + | { type: 'select'; value: string } + | { type: 'boolean'; value: boolean }; + +export interface SetAcpSessionConfigOptionRequest { + sessionId: string; + clientId: string; + workspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; + configId: string; + value: AcpSessionConfigValue; +} + export interface AcpSessionModelOption { id: string; name: string; description?: string; } +export interface AcpSessionConfigSelectOption { + value: string; + name: string; + description?: string; +} + +interface AcpSessionConfigOptionBase { + id: string; + name: string; + description?: string; +} + +export type AcpSessionConfigOption = AcpSessionConfigOptionBase & ( + | { + type: 'select'; + currentValue: string; + options: AcpSessionConfigSelectOption[]; + } + | { + type: 'boolean'; + currentValue: boolean; + } +); + export interface AcpContextUsage { used: number; size: number; @@ -110,6 +148,7 @@ export interface AcpSessionOptions { currentModelId?: string; availableModels: AcpSessionModelOption[]; modelConfigId?: string; + configOptions: AcpSessionConfigOption[]; contextUsage?: AcpContextUsage; } @@ -323,6 +362,12 @@ export class ACPClientAPI { return api.invoke('set_acp_session_model', { request }); } + static async setSessionConfigOption( + request: SetAcpSessionConfigOptionRequest + ): Promise { + return api.invoke('set_acp_session_config_option', { request }); + } + static onAvailableCommandsUpdated( callback: (event: AcpAvailableCommandsUpdatedEvent) => void ): () => void { diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx index ee57f6cfbf..e0eb9f9bb6 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx @@ -241,14 +241,14 @@ describe('AcpAgentsConfig', () => { { id: 'claude-code', tool: { name: 'claude', installed: true }, - adapter: { name: '@zed-industries/claude-code-acp', installed: false }, + adapter: { name: '@agentclientprotocol/claude-agent-acp', installed: false }, runnable: false, notes: [], }, { id: 'codex', tool: { name: 'codex', installed: true }, - adapter: { name: '@zed-industries/codex-acp', installed: false }, + adapter: { name: '@agentclientprotocol/codex-acp', installed: false }, runnable: false, notes: [], }, @@ -298,7 +298,7 @@ describe('AcpAgentsConfig', () => { { id: 'claude-code', tool: { name: 'claude', installed: true }, - adapter: { name: '@zed-industries/claude-code-acp', installed: true }, + adapter: { name: '@agentclientprotocol/claude-agent-acp', installed: true }, runnable: true, notes: [], }, @@ -326,7 +326,7 @@ describe('AcpAgentsConfig', () => { 'claude-code': { name: 'Claude Code', command: 'npx', - args: ['--yes', '@zed-industries/claude-code-acp@latest'], + args: ['--yes', '@agentclientprotocol/claude-agent-acp@latest'], env: {}, enabled: true, readonly: false, @@ -335,7 +335,7 @@ describe('AcpAgentsConfig', () => { codex: { name: 'Codex', command: 'npx', - args: ['--yes', '@zed-industries/codex-acp@latest'], + args: ['--yes', '@agentclientprotocol/codex-acp@latest'], env: {}, enabled: true, readonly: false, @@ -385,7 +385,7 @@ describe('AcpAgentsConfig', () => { { id: 'claude-code', tool: { name: 'claude', installed: true }, - adapter: { name: '@zed-industries/claude-code-acp', installed: true }, + adapter: { name: '@agentclientprotocol/claude-agent-acp', installed: true }, runnable: true, notes: [], }, @@ -410,7 +410,7 @@ describe('AcpAgentsConfig', () => { { id: 'claude-code', tool: { name: 'claude', installed: true }, - adapter: { name: '@zed-industries/claude-code-acp', installed: true }, + adapter: { name: '@agentclientprotocol/claude-agent-acp', installed: true }, runnable: true, notes: [], }, diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx index 19f4297ce7..43bf0f4605 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx @@ -87,16 +87,16 @@ const PRESETS: AcpClientPreset[] = [ { id: 'claude-code', name: 'Claude Code', - description: 'Claude Code via the Zed ACP adapter.', + description: 'Claude Code via the official ACP adapter.', command: 'npx', - args: ['--yes', '@zed-industries/claude-code-acp@latest'], + args: ['--yes', '@agentclientprotocol/claude-agent-acp@latest'], }, { id: 'codex', name: 'Codex', - description: 'OpenAI Codex via the Zed ACP adapter.', + description: 'OpenAI Codex via the official ACP adapter.', command: 'npx', - args: ['--yes', '@zed-industries/codex-acp@latest'], + args: ['--yes', '@agentclientprotocol/codex-acp@latest'], }, ]; diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index e98a841fdf..d179a01fb8 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -2075,6 +2075,8 @@ "autoModelDesc": "Automatically Routing", "primaryModel": "Primary Model", "fastModel": "Fast Model", + "fastMode": "Fast mode", + "fastModeDescription": "1.5x speed with higher credit usage", "modelNotConfigured": "Not Configured", "contextUsage": { "agentPrompt": "Last request prompt: {{usage}}", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 6a88d354f7..d15f4af096 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -2075,6 +2075,8 @@ "autoModelDesc": "自动路由最优模型", "primaryModel": "Primary 模型", "fastModel": "Fast 模型", + "fastMode": "Fast 模式", + "fastModeDescription": "1.5 倍速度,消耗更多额度", "modelNotConfigured": "未配置", "contextUsage": { "agentPrompt": "上次请求输入上下文: {{usage}}", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 33ca5a8a18..a5417fc70f 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -2075,6 +2075,8 @@ "autoModelDesc": "自動路由最優模型", "primaryModel": "Primary 模型", "fastModel": "Fast 模型", + "fastMode": "Fast 模式", + "fastModeDescription": "1.5 倍速度,消耗更多額度", "modelNotConfigured": "未設定", "contextUsage": { "agentPrompt": "上次請求輸入上下文: {{usage}}",