diff --git a/crates/op-editor-core/src/agent_settings.rs b/crates/op-editor-core/src/agent_settings.rs index c5336d4e9..0d4e24b09 100644 --- a/crates/op-editor-core/src/agent_settings.rs +++ b/crates/op-editor-core/src/agent_settings.rs @@ -10,6 +10,8 @@ //! model's backing-agent discriminator) and is re-exported here so //! both the chat layer and the settings layer share one definition. +use std::collections::BTreeMap; + pub use crate::chat::AgentProvider; /// Which section of the settings modal is active. @@ -204,6 +206,27 @@ pub const BUILTIN_AGENT_PRESETS: [BuiltinAgentPreset; 4] = [ }, ]; +/// ACP-compatible agent connection style mirrored from the TS +/// `AcpAgentConfig.connectionType` union. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AcpConnectionType { + Local, + Remote, +} + +/// One configured ACP-compatible external agent. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcpAgentConfig { + pub id: String, + pub display_name: String, + pub connection_type: AcpConnectionType, + pub command: String, + pub args: Vec, + pub env: BTreeMap, + pub url: Option, + pub enabled: bool, +} + /// Image-generation service providers mirrored from the TS /// `ImageGenProvider` union. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -264,6 +287,8 @@ pub struct AgentSettings { pub connected: [bool; 5], pub builtin_agents: Vec, pub next_builtin_agent_id: u64, + pub acp_agents: Vec, + pub next_acp_agent_id: u64, /// Vertical scroll offset of the right content pane in px. pub scroll_y: f32, pub mcp_server: McpServer, @@ -296,6 +321,8 @@ impl Default for AgentSettings { connected: [false; 5], builtin_agents: Vec::new(), next_builtin_agent_id: 1, + acp_agents: Vec::new(), + next_acp_agent_id: 1, scroll_y: 0.0, mcp_server: McpServer::default(), mcp_cli_enabled: [false; 6], @@ -373,6 +400,44 @@ impl AgentSettings { id } + pub fn add_acp_agent(&mut self) -> String { + let n = self.next_acp_agent_id.max(1); + self.add_acp_agent_config( + format!("ACP Agent {n}"), + AcpConnectionType::Local, + "", + Vec::new(), + BTreeMap::new(), + None, + true, + ) + } + + pub fn add_acp_agent_config( + &mut self, + display_name: impl Into, + connection_type: AcpConnectionType, + command: impl Into, + args: Vec, + env: BTreeMap, + url: Option, + enabled: bool, + ) -> String { + let id = format!("acp-{}", self.next_acp_agent_id.max(1)); + self.next_acp_agent_id = self.next_acp_agent_id.max(1).saturating_add(1); + self.acp_agents.push(AcpAgentConfig { + id: id.clone(), + display_name: display_name.into(), + connection_type, + command: command.into(), + args, + env, + url, + enabled, + }); + id + } + pub fn add_image_gen_profile(&mut self) -> String { let n = self.next_image_gen_profile_id.max(1); let id = format!("igp-{n}"); @@ -542,4 +607,24 @@ mod tests { ] ); } + + #[test] + fn add_acp_agent_assigns_id_and_defaults_to_local_config() { + let mut s = AgentSettings::default(); + + let first = s.add_acp_agent(); + let second = s.add_acp_agent(); + + assert_eq!(first, "acp-1"); + assert_eq!(second, "acp-2"); + assert_eq!(s.acp_agents.len(), 2); + assert_eq!(s.next_acp_agent_id, 3); + assert_eq!(s.acp_agents[0].display_name, "ACP Agent 1"); + assert_eq!(s.acp_agents[0].connection_type, AcpConnectionType::Local); + assert!(s.acp_agents[0].command.is_empty()); + assert!(s.acp_agents[0].args.is_empty()); + assert!(s.acp_agents[0].env.is_empty()); + assert!(s.acp_agents[0].url.is_none()); + assert!(s.acp_agents[0].enabled); + } } diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index adfa02b16..f058e1959 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -69,9 +69,9 @@ mod tests_mutators; mod tests_pages; pub use agent_settings::{ - AgentSettings, AgentSettingsDrag, AgentSettingsTab, BuiltinAgentConfig, BuiltinAgentField, - BuiltinAgentKind, ImageGenField, ImageGenProfile, ImageGenProvider, ImageSearchField, McpCli, - McpServer, SettingsFocus, + AcpAgentConfig, AcpConnectionType, AgentSettings, AgentSettingsDrag, AgentSettingsTab, + BuiltinAgentConfig, BuiltinAgentField, BuiltinAgentKind, ImageGenField, ImageGenProfile, + ImageGenProvider, ImageSearchField, McpCli, McpServer, SettingsFocus, }; pub use align::AlignAction; pub use chat::{ diff --git a/crates/op-editor-ui/src/widgets/agent_settings_acp.rs b/crates/op-editor-ui/src/widgets/agent_settings_acp.rs new file mode 100644 index 000000000..09ca0547d --- /dev/null +++ b/crates/op-editor-ui/src/widgets/agent_settings_acp.rs @@ -0,0 +1,267 @@ +//! ACP Agent section for the Agent settings panel. + +use crate::theme::Theme; +use crate::widgets::agent_settings_i18n::t as t_settings; +use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::PaintCx; +use crate::{Color, Point2D, Rect, TextLayout}; +use op_editor_core::agent_settings::{AcpAgentConfig, AcpConnectionType, AgentSettings}; +use op_editor_core::editor_ui_state::EditorUiState; + +const HEADER_H: f32 = 28.0; +const SUBTITLE_H: f32 = 28.0; +const EMPTY_H: f32 = 64.0; +const CARD_H: f32 = 60.0; +const CARD_GAP: f32 = 8.0; +const TOP_HEADER_RIGHT_INSET: f32 = 12.0; +const ADD_W: f32 = 96.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AcpHit { + AddAgent, + None, +} + +pub fn content_height(settings: &AgentSettings) -> f32 { + let list_h = if settings.acp_agents.is_empty() { + EMPTY_H + } else { + settings.acp_agents.len() as f32 * (CARD_H + CARD_GAP) + }; + HEADER_H + SUBTITLE_H + list_h +} + +pub fn hit_test(content: Rect, point: Point2D, y: f32) -> AcpHit { + if rect_contains(add_agent_rect(content, y), point) { + return AcpHit::AddAgent; + } + AcpHit::None +} + +pub fn paint_acp_section( + cx: &mut PaintCx<'_>, + theme: &Theme, + settings: &AgentSettings, + ui: &EditorUiState, + content: Rect, + y: f32, +) -> f32 { + let mut y = paint_header( + cx, + theme, + t_settings(ui, "settings.agents.acp"), + t_settings(ui, "settings.agents.addAcp"), + content, + y, + ); + y = paint_subtitle( + cx, + theme, + t_settings(ui, "settings.agents.acpSubtitle"), + content, + y, + ); + if settings.acp_agents.is_empty() { + return paint_empty( + cx, + theme, + t_settings(ui, "settings.agents.acpEmpty"), + content, + y, + ); + } + for agent in &settings.acp_agents { + paint_acp_card( + cx, + theme, + ui, + agent, + card_rect(content.origin.x, y, content.size.x), + ); + y += CARD_H + CARD_GAP; + } + y +} + +fn paint_header( + cx: &mut PaintCx<'_>, + theme: &Theme, + title: &str, + action: &str, + content: Rect, + y: f32, +) -> f32 { + draw_text( + cx, + title, + 15.0, + theme.foreground, + content.origin.x, + y + 18.0, + ); + let action_w = cx.backend.measure_text(action, 12.0); + draw_text( + cx, + action, + 12.0, + theme.primary, + content.origin.x + content.size.x - TOP_HEADER_RIGHT_INSET - action_w, + y + 18.0, + ); + y + HEADER_H +} + +fn paint_subtitle(cx: &mut PaintCx<'_>, theme: &Theme, text: &str, content: Rect, y: f32) -> f32 { + draw_text( + cx, + text, + 12.0, + theme.muted_foreground, + content.origin.x, + y + 16.0, + ); + y + SUBTITLE_H +} + +fn paint_empty(cx: &mut PaintCx<'_>, theme: &Theme, text: &str, content: Rect, y: f32) -> f32 { + let text_w = cx.backend.measure_text(text, 13.0); + draw_text( + cx, + text, + 13.0, + theme.muted_foreground, + content.origin.x + (content.size.x - text_w) / 2.0, + y + 44.0, + ); + y + EMPTY_H +} + +fn paint_acp_card( + cx: &mut PaintCx<'_>, + theme: &Theme, + ui: &EditorUiState, + agent: &AcpAgentConfig, + card: Rect, +) { + if agent.enabled { + cx.backend.fill_round_rect(card, 8.0, theme.muted); + cx.backend.stroke_round_rect(card, 8.0, theme.border, 1.0); + } + let avatar = Rect { + origin: Point2D::new(card.origin.x + 12.0, card.origin.y + 12.0), + size: Point2D::new(36.0, 36.0), + }; + cx.backend.fill_round_rect(avatar, 8.0, theme.card); + let (icon, type_label) = match agent.connection_type { + AcpConnectionType::Local => (Icon::Terminal, t_settings(ui, "acp.local")), + AcpConnectionType::Remote => (Icon::Globe, t_settings(ui, "acp.remote")), + }; + draw_icon( + cx.backend, + icon, + Point2D::new(avatar.origin.x + 9.0, avatar.origin.y + 9.0), + 18.0, + theme.foreground, + 1.6, + ); + + let text_x = card.origin.x + 60.0; + let name = ellipsize(cx, &agent.display_name, 260.0, 13.0); + let name_w = cx.backend.measure_text(&name, 13.0); + draw_text( + cx, + &name, + 13.0, + theme.foreground, + text_x, + card.origin.y + 22.0, + ); + draw_text( + cx, + type_label, + 10.0, + theme.muted_foreground, + text_x + name_w + 8.0, + card.origin.y + 22.0, + ); + let detail = acp_detail(agent); + let detail = ellipsize(cx, &detail, 310.0, 11.0); + draw_text( + cx, + &detail, + 11.0, + theme.muted_foreground, + text_x, + card.origin.y + 39.0, + ); + draw_text( + cx, + t_settings(ui, "acp.notConnected"), + 11.0, + theme.muted_foreground, + card.origin.x + card.size.x - 96.0, + card.origin.y + 35.0, + ); +} + +fn acp_detail(agent: &AcpAgentConfig) -> String { + match agent.connection_type { + AcpConnectionType::Local if agent.command.trim().is_empty() => "Command required".into(), + AcpConnectionType::Local if agent.args.is_empty() => agent.command.clone(), + AcpConnectionType::Local => format!("{} {}", agent.command, agent.args.join(" ")), + AcpConnectionType::Remote => agent.url.clone().unwrap_or_else(|| "URL required".into()), + } +} + +fn add_agent_rect(content: Rect, y: f32) -> Rect { + Rect { + origin: Point2D::new( + content.origin.x + content.size.x - TOP_HEADER_RIGHT_INSET - ADD_W, + y, + ), + size: Point2D::new(ADD_W, 24.0), + } +} + +fn card_rect(x: f32, y: f32, w: f32) -> Rect { + Rect { + origin: Point2D::new(x, y), + size: Point2D::new(w, CARD_H), + } +} + +fn draw_text(cx: &mut PaintCx<'_>, text: &str, size: f32, color: Color, x: f32, y: f32) { + let layout = TextLayout::single_run( + text, + "system-ui", + size, + to_jian(color), + Point2D::new(0.0, 0.0), + ); + cx.backend.draw_text(&layout, Point2D::new(x, y)); +} + +fn ellipsize(cx: &mut PaintCx<'_>, value: &str, max_w: f32, size: f32) -> String { + if cx.backend.measure_text(value, size) <= max_w { + return value.to_string(); + } + let mut out = value.to_string(); + while !out.is_empty() && cx.backend.measure_text(&format!("{out}..."), size) > max_w { + out.pop(); + } + format!("{out}...") +} + +fn rect_contains(r: Rect, p: Point2D) -> bool { + p.x >= r.origin.x + && p.y >= r.origin.y + && p.x <= r.origin.x + r.size.x + && p.y <= r.origin.y + r.size.y +} + +fn to_jian(c: Color) -> jian_core::scene::Color { + fn ch(v: f32) -> u8 { + (v.clamp(0.0, 1.0) * 255.0).round() as u8 + } + jian_core::scene::Color::rgba(ch(c.r), ch(c.g), ch(c.b), ch(c.a)) +} diff --git a/crates/op-editor-ui/src/widgets/agent_settings_panel.rs b/crates/op-editor-ui/src/widgets/agent_settings_panel.rs index 615c5edd1..17bb2a5b6 100644 --- a/crates/op-editor-ui/src/widgets/agent_settings_panel.rs +++ b/crates/op-editor-ui/src/widgets/agent_settings_panel.rs @@ -1,6 +1,7 @@ //! Multi-tab settings modal opened via `Cmd+,`. use crate::theme::Theme; +use crate::widgets::agent_settings_acp::{self, AcpHit}; use crate::widgets::agent_settings_builtin::{self, BuiltinHit}; use crate::widgets::agent_settings_i18n::t as t_settings; use crate::widgets::agent_settings_images::{self, ImagesHit}; @@ -135,8 +136,11 @@ impl<'a> AgentSettingsPanel<'a> { } BuiltinHit::None => {} } - if rect_contains(add_acp_rect(panel, &self.settings), scrolled) { - return AgentSettingsHit::AddAcpAgent; + let content = content_rect(panel); + let acp_y = acp_section_y(content, &self.settings); + match agent_settings_acp::hit_test(content, scrolled, acp_y) { + AcpHit::AddAgent => return AgentSettingsHit::AddAcpAgent, + AcpHit::None => {} } for (i, provider) in AgentProvider::ALL.iter().enumerate() { let card = agent_card_rect_in(panel, i, &self.settings); @@ -244,10 +248,8 @@ impl<'a> AgentSettingsPanel<'a> { } fn agents_content_height(settings: &AgentSettings) -> f32 { - // header 32 + subtitle 28 + built-in list + GAP, then ACP empty block, then Agents - // header 32 + 5 cards (CARD_HEIGHT + CARD_GAP) + Claude-Code hint 28. 12.0 + (agent_settings_builtin::content_height(settings) + SECTION_GAP) - + (32.0 + 28.0 + 64.0 + SECTION_GAP) + + (agent_settings_acp::content_height(settings) + SECTION_GAP) + 32.0 + 5.0 * (CARD_HEIGHT + CARD_GAP) + 28.0 @@ -400,32 +402,7 @@ fn paint_agents_tab( let mut y = content.origin.y + 12.0; y = agent_settings_builtin::paint_builtin_section(cx, theme, settings, ui, content, y); y += SECTION_GAP; - - y = paint_section_header_inset( - cx, - theme, - t_settings(ui, "settings.agents.acp"), - t_settings(ui, "settings.agents.addAcp"), - content.origin.x, - y, - content.size.x, - TOP_HEADER_RIGHT_INSET, - ); - y = paint_section_subtitle( - cx, - theme, - t_settings(ui, "settings.agents.acpSubtitle"), - content.origin.x, - y, - ); - y = paint_empty_hint( - cx, - theme, - t_settings(ui, "settings.agents.acpEmpty"), - content.origin.x, - y, - content.size.x, - ); + y = agent_settings_acp::paint_acp_section(cx, theme, settings, ui, content, y); y += SECTION_GAP; y = paint_section_header( @@ -506,39 +483,6 @@ fn paint_section_header_inset( y + 28.0 } -fn paint_section_subtitle(cx: &mut PaintCx<'_>, theme: &Theme, text: &str, x: f32, y: f32) -> f32 { - let layout = TextLayout::single_run( - text, - "system-ui", - 12.0, - to_jian(theme.muted_foreground), - Point2D::new(0.0, 0.0), - ); - cx.backend.draw_text(&layout, Point2D::new(x, y + 16.0)); - y + 28.0 -} - -fn paint_empty_hint( - cx: &mut PaintCx<'_>, - theme: &Theme, - text: &str, - x: f32, - y: f32, - w: f32, -) -> f32 { - let text_w = cx.backend.measure_text(text, 13.0); - let layout = TextLayout::single_run( - text, - "system-ui", - 13.0, - to_jian(theme.muted_foreground), - Point2D::new(0.0, 0.0), - ); - cx.backend - .draw_text(&layout, Point2D::new(x + (w - text_w) / 2.0, y + 44.0)); - y + 64.0 -} - fn paint_agent_card( cx: &mut PaintCx<'_>, theme: &Theme, @@ -684,7 +628,6 @@ fn paint_agent_card( } const DISCONNECT_BTN_W: f32 = 96.0; -const TOP_HEADER_RIGHT_INSET: f32 = 12.0; fn tab_i18n_label(ui: &EditorUiState, tab: AgentSettingsTab) -> &'static str { match tab { @@ -734,18 +677,8 @@ fn close_rect(panel: Rect) -> Rect { } } -fn add_acp_rect(panel: Rect, settings: &AgentSettings) -> Rect { - let content = content_rect(panel); - let y = - content.origin.y + 12.0 + agent_settings_builtin::content_height(settings) + SECTION_GAP; - let text_w = 96.0; - Rect { - origin: Point2D::new( - content.origin.x + content.size.x - TOP_HEADER_RIGHT_INSET - text_w, - y, - ), - size: Point2D::new(text_w, 24.0), - } +fn acp_section_y(content: Rect, settings: &AgentSettings) -> f32 { + content.origin.y + 12.0 + agent_settings_builtin::content_height(settings) + SECTION_GAP } fn agent_card_rect_at(x: f32, y: f32, w: f32) -> Rect { diff --git a/crates/op-editor-ui/src/widgets/agent_settings_panel_tests.rs b/crates/op-editor-ui/src/widgets/agent_settings_panel_tests.rs index 6eae75eb9..5c0ff45a6 100644 --- a/crates/op-editor-ui/src/widgets/agent_settings_panel_tests.rs +++ b/crates/op-editor-ui/src/widgets/agent_settings_panel_tests.rs @@ -92,6 +92,20 @@ fn builtin_agent_cards_use_ts_compact_height_when_not_editing() { ); } +#[test] +fn agents_tab_acp_cards_replace_empty_hint_height() { + let empty = AgentSettingsPanel::for_editor(&EditorState::default()).content_total_height(); + let mut state = EditorState::default(); + state.editor_ui.agent_settings.add_acp_agent(); + state.editor_ui.agent_settings.add_acp_agent(); + let with_acp = AgentSettingsPanel::for_editor(&state).content_total_height(); + + assert!( + with_acp > empty, + "configured ACP agents should contribute list-card height instead of a fixed empty hint" + ); +} + #[test] fn hit_test_resolves_builtin_agent_compact_switch() { let mut state = EditorState::default(); diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index 0f5c502e6..386dd7eef 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -91,6 +91,7 @@ mod icons_data; pub mod brand_icons; // Step 4 — extra editor-chrome widgets (TS app parity). +pub mod agent_settings_acp; pub mod agent_settings_builtin; pub mod agent_settings_i18n; pub mod agent_settings_images; diff --git a/crates/op-host-desktop/src/settings_io.rs b/crates/op-host-desktop/src/settings_io.rs index b0efdacb2..4b391b403 100644 --- a/crates/op-host-desktop/src/settings_io.rs +++ b/crates/op-host-desktop/src/settings_io.rs @@ -15,8 +15,8 @@ use std::path::PathBuf; use op_editor_core::editor_ui_state::RecentFile; use op_editor_core::{ - BuiltinAgentConfig, BuiltinAgentKind, EditorState, ImageGenProfile, ImageGenProvider, Locale, - ThemeMode, + AcpAgentConfig, AcpConnectionType, BuiltinAgentConfig, BuiltinAgentKind, EditorState, + ImageGenProfile, ImageGenProvider, Locale, ThemeMode, }; use op_host_native::WidgetHostNative; use serde::{Deserialize, Serialize}; @@ -40,6 +40,22 @@ struct BuiltinAgentPayload { enabled: bool, } +#[derive(Debug, Serialize, Deserialize, Clone)] +struct AcpAgentPayload { + id: String, + display_name: String, + connection_type: String, + #[serde(default)] + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: std::collections::BTreeMap, + #[serde(default)] + url: Option, + enabled: bool, +} + #[derive(Debug, Serialize, Deserialize, Clone)] struct ImageGenProfilePayload { id: String, @@ -71,6 +87,7 @@ pub struct Fingerprint { auto_update_enabled: bool, connected: [bool; 5], builtin_agents: Vec, + acp_agents: Vec, image_gen_profiles: Vec, active_image_gen_profile_id: Option, } @@ -88,6 +105,7 @@ pub fn fingerprint(state: &EditorState) -> Fingerprint { auto_update_enabled: eui.agent_settings.auto_update_enabled, connected: eui.agent_settings.connected, builtin_agents: eui.agent_settings.builtin_agents.clone(), + acp_agents: eui.agent_settings.acp_agents.clone(), image_gen_profiles: eui.agent_settings.image_gen_profiles.clone(), active_image_gen_profile_id: eui.agent_settings.active_image_gen_profile_id.clone(), } @@ -128,6 +146,8 @@ struct SettingsPayload { #[serde(default)] builtin_agents: Option>, #[serde(default)] + acp_agents: Option>, + #[serde(default)] image_gen_profiles: Option>, #[serde(default)] active_image_gen_profile_id: Option, @@ -163,6 +183,13 @@ fn to_payload(state: &EditorState) -> SettingsPayload { .map(builtin_agent_to_payload) .collect(), ), + acp_agents: Some( + eui.agent_settings + .acp_agents + .iter() + .map(acp_agent_to_payload) + .collect(), + ), image_gen_profiles: Some( eui.agent_settings .image_gen_profiles @@ -232,6 +259,13 @@ fn apply_payload(state: &mut EditorState, payload: SettingsPayload) { eui.agent_settings.next_builtin_agent_id = next_builtin_agent_id(&eui.agent_settings.builtin_agents); } + if let Some(agents) = payload.acp_agents { + eui.agent_settings.acp_agents = agents + .into_iter() + .filter_map(acp_agent_from_payload) + .collect(); + eui.agent_settings.next_acp_agent_id = next_acp_agent_id(&eui.agent_settings.acp_agents); + } if let Some(profiles) = payload.image_gen_profiles { eui.agent_settings.image_gen_profiles = profiles .into_iter() @@ -313,6 +347,41 @@ fn builtin_agent_from_payload(payload: BuiltinAgentPayload) -> Option AcpAgentPayload { + AcpAgentPayload { + id: agent.id.clone(), + display_name: agent.display_name.clone(), + connection_type: match agent.connection_type { + AcpConnectionType::Local => "local", + AcpConnectionType::Remote => "remote", + } + .into(), + command: agent.command.clone(), + args: agent.args.clone(), + env: agent.env.clone(), + url: agent.url.clone(), + enabled: agent.enabled, + } +} + +fn acp_agent_from_payload(payload: AcpAgentPayload) -> Option { + let connection_type = match payload.connection_type.as_str() { + "local" => AcpConnectionType::Local, + "remote" => AcpConnectionType::Remote, + _ => return None, + }; + Some(AcpAgentConfig { + id: payload.id, + display_name: payload.display_name, + connection_type, + command: payload.command, + args: payload.args, + env: payload.env, + url: payload.url, + enabled: payload.enabled, + }) +} + fn openverse_oauth_to_payload( settings: &op_editor_core::agent_settings::AgentSettings, ) -> Option { @@ -372,6 +441,15 @@ fn next_builtin_agent_id(agents: &[BuiltinAgentConfig]) -> u64 { .saturating_add(1) } +fn next_acp_agent_id(agents: &[AcpAgentConfig]) -> u64 { + agents + .iter() + .filter_map(|agent| agent.id.strip_prefix("acp-")?.parse::().ok()) + .max() + .unwrap_or(0) + .saturating_add(1) +} + fn next_image_gen_profile_id(profiles: &[ImageGenProfile]) -> u64 { profiles .iter() @@ -589,6 +667,52 @@ mod tests { ); } + #[test] + fn acp_agents_round_trip_through_payload() { + let mut src = EditorState::new(); + let mut env = std::collections::BTreeMap::new(); + env.insert("ACP_TOKEN".into(), "secret".into()); + src.editor_ui.agent_settings.add_acp_agent_config( + "Design Agent", + AcpConnectionType::Local, + "/usr/local/bin/design-agent", + vec!["--stdio".into()], + env, + None, + true, + ); + src.editor_ui.agent_settings.add_acp_agent_config( + "Remote Agent", + AcpConnectionType::Remote, + "", + Vec::new(), + std::collections::BTreeMap::new(), + Some("ws://localhost:8100".into()), + false, + ); + + let json = serde_json::to_string(&to_payload(&src)).unwrap(); + let payload: SettingsPayload = serde_json::from_str(&json).unwrap(); + let mut dst = EditorState::new(); + apply_payload(&mut dst, payload); + + assert_eq!(dst.editor_ui.agent_settings.acp_agents.len(), 2); + let local = &dst.editor_ui.agent_settings.acp_agents[0]; + assert_eq!(local.display_name, "Design Agent"); + assert_eq!(local.connection_type, AcpConnectionType::Local); + assert_eq!(local.command, "/usr/local/bin/design-agent"); + assert_eq!(local.args, vec!["--stdio"]); + assert_eq!( + local.env.get("ACP_TOKEN").map(String::as_str), + Some("secret") + ); + let remote = &dst.editor_ui.agent_settings.acp_agents[1]; + assert_eq!(remote.connection_type, AcpConnectionType::Remote); + assert_eq!(remote.url.as_deref(), Some("ws://localhost:8100")); + assert!(!remote.enabled); + assert_eq!(dst.editor_ui.agent_settings.next_acp_agent_id, 3); + } + #[test] fn image_generation_profiles_round_trip_through_payload() { let mut src = EditorState::new(); diff --git a/crates/op-host-native/src/widget_host/agent_settings_tests.rs b/crates/op-host-native/src/widget_host/agent_settings_tests.rs index ed64817af..a369f7532 100644 --- a/crates/op-host-native/src/widget_host/agent_settings_tests.rs +++ b/crates/op-host-native/src/widget_host/agent_settings_tests.rs @@ -113,6 +113,24 @@ fn builtin_agent_compact_edit_focuses_display_name_form() { ); } +#[test] +fn add_acp_agent_press_creates_local_agent() { + let mut host = WidgetHostNative::new(); + let panel = AgentSettingsPanel::for_editor(host.editor_state()); + let rect = panel.rect(1200.0, 800.0); + let content_x = rect.origin.x + 200.0 + 24.0; + let content_y = rect.origin.y + 24.0; + let content_w = rect.size.x - 200.0 - 48.0; + let add_x = content_x + content_w - 12.0 - 48.0; + let add_y = content_y + 12.0 + 120.0 + 28.0 + 12.0; + + assert!(host.dispatch_agent_settings_press(add_x, add_y, 1200.0, 800.0)); + + let settings = &host.editor_state().editor_ui.agent_settings; + assert_eq!(settings.acp_agents.len(), 1); + assert_eq!(settings.acp_agents[0].display_name, "ACP Agent 1"); +} + #[test] fn starting_mcp_server_commits_port_draft_and_clears_focus() { let mut host = WidgetHostNative::new(); diff --git a/crates/op-host-native/src/widget_host/press_helpers.rs b/crates/op-host-native/src/widget_host/press_helpers.rs index 6d5c0e52e..aaa0918ed 100644 --- a/crates/op-host-native/src/widget_host/press_helpers.rs +++ b/crates/op-host-native/src/widget_host/press_helpers.rs @@ -345,7 +345,11 @@ impl WidgetHostNative { self.editor_state.editor_ui.settings_input_draft.clear(); self.editor_state.rebuild_chat_models(); } - AgentSettingsHit::AddAcpAgent | AgentSettingsHit::Inside => {} + AgentSettingsHit::AddAcpAgent => { + self.commit_settings_focus_if_any(); + self.editor_state.editor_ui.agent_settings.add_acp_agent(); + } + AgentSettingsHit::Inside => {} AgentSettingsHit::AddGenConfig => { self.commit_settings_focus_if_any(); let id = self