From 2bc8e5aa34646ddbdc71baccf75072ff1c6ecccc Mon Sep 17 00:00:00 2001 From: Fini Date: Sat, 30 May 2026 19:37:37 +0800 Subject: [PATCH] feat(settings): edit acp agent entries --- crates/op-editor-core/src/agent_settings.rs | 34 ++ crates/op-editor-core/src/lib.rs | 6 +- .../src/widgets/agent_settings_acp.rs | 480 ++++++++++++++++-- .../src/widgets/agent_settings_panel.rs | 27 +- crates/op-host-desktop/src/settings_io.rs | 2 + .../src/widget_host/agent_settings_tests.rs | 142 +++++- .../src/widget_host/keyboard.rs | 3 + .../src/widget_host/press_helpers.rs | 132 ++++- .../src/widget_host/settings_dispatch.rs | 33 +- 9 files changed, 807 insertions(+), 52 deletions(-) diff --git a/crates/op-editor-core/src/agent_settings.rs b/crates/op-editor-core/src/agent_settings.rs index 0d4e24b09..f95cc9026 100644 --- a/crates/op-editor-core/src/agent_settings.rs +++ b/crates/op-editor-core/src/agent_settings.rs @@ -116,6 +116,13 @@ pub enum ImageSearchField { ClientSecret, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AcpAgentField { + DisplayName, + Command, + Url, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SettingsFocus { McpPort, @@ -128,6 +135,10 @@ pub enum SettingsFocus { index: usize, field: ImageGenField, }, + AcpAgent { + index: usize, + field: AcpAgentField, + }, } /// Built-in provider backend configured directly in OpenPencil. @@ -225,6 +236,21 @@ pub struct AcpAgentConfig { pub env: BTreeMap, pub url: Option, pub enabled: bool, + pub connected: bool, +} + +impl AcpAgentConfig { + pub fn ready(&self) -> bool { + self.enabled + && match self.connection_type { + AcpConnectionType::Local => !self.command.trim().is_empty(), + AcpConnectionType::Remote => self + .url + .as_deref() + .map(|url| !url.trim().is_empty()) + .unwrap_or(false), + } + } } /// Image-generation service providers mirrored from the TS @@ -434,10 +460,17 @@ impl AgentSettings { env, url, enabled, + connected: false, }); id } + pub fn remove_acp_agent(&mut self, id: &str) -> bool { + let before = self.acp_agents.len(); + self.acp_agents.retain(|agent| agent.id != id); + self.acp_agents.len() != before + } + pub fn add_image_gen_profile(&mut self) -> String { let n = self.next_image_gen_profile_id.max(1); let id = format!("igp-{n}"); @@ -626,5 +659,6 @@ mod tests { assert!(s.acp_agents[0].env.is_empty()); assert!(s.acp_agents[0].url.is_none()); assert!(s.acp_agents[0].enabled); + assert!(!s.acp_agents[0].connected); } } diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index f058e1959..fb406e877 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::{ - AcpAgentConfig, AcpConnectionType, AgentSettings, AgentSettingsDrag, AgentSettingsTab, - BuiltinAgentConfig, BuiltinAgentField, BuiltinAgentKind, ImageGenField, ImageGenProfile, - ImageGenProvider, ImageSearchField, McpCli, McpServer, SettingsFocus, + AcpAgentConfig, AcpAgentField, 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 index 09ca0547d..d97da7ab3 100644 --- a/crates/op-editor-ui/src/widgets/agent_settings_acp.rs +++ b/crates/op-editor-ui/src/widgets/agent_settings_acp.rs @@ -5,20 +5,34 @@ 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::agent_settings::{ + AcpAgentConfig, AcpAgentField, AcpConnectionType, AgentSettings, SettingsFocus, +}; 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 COMPACT_CARD_H: f32 = 60.0; +const EXPANDED_CARD_H: f32 = 116.0; const CARD_GAP: f32 = 8.0; const TOP_HEADER_RIGHT_INSET: f32 = 12.0; const ADD_W: f32 = 96.0; +const FIELD_LABEL_W: f32 = 72.0; +const FIELD_H: f32 = 24.0; +const TYPE_TOGGLE_W: f32 = 156.0; +const ACTION_W: f32 = 24.0; +const CONNECT_BTN_W: f32 = 96.0; +const CONNECT_BTN_H: f32 = 28.0; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AcpHit { AddAgent, + Focus { index: usize, field: AcpAgentField }, + ToggleConnectionType(usize), + Edit(usize), + Remove(usize), + ToggleConnected(usize), None, } @@ -26,15 +40,52 @@ 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) + settings + .acp_agents + .iter() + .enumerate() + .map(|(index, _)| card_height(settings, index) + CARD_GAP) + .sum() }; HEADER_H + SUBTITLE_H + list_h } -pub fn hit_test(content: Rect, point: Point2D, y: f32) -> AcpHit { +pub fn hit_test(content: Rect, settings: &AgentSettings, point: Point2D, y: f32) -> AcpHit { if rect_contains(add_agent_rect(content, y), point) { return AcpHit::AddAgent; } + let mut card_y = y + HEADER_H + SUBTITLE_H; + for (index, agent) in settings.acp_agents.iter().enumerate() { + let card = card_rect( + content.origin.x, + card_y, + content.size.x, + card_height(settings, index), + ); + if is_editing(settings, index) { + if rect_contains(type_toggle_rect(card), point) { + return AcpHit::ToggleConnectionType(index); + } + for (row, field) in [ + AcpAgentField::DisplayName, + connection_field(agent.connection_type), + ] + .into_iter() + .enumerate() + { + if rect_contains(field_input_rect(card, row), point) { + return AcpHit::Focus { index, field }; + } + } + } else if rect_contains(compact_edit_rect(card), point) { + return AcpHit::Edit(index); + } else if rect_contains(compact_remove_rect(card), point) { + return AcpHit::Remove(index); + } else if rect_contains(connection_button_rect(card), point) { + return AcpHit::ToggleConnected(index); + } + card_y += card.size.y + CARD_GAP; + } AcpHit::None } @@ -70,15 +121,15 @@ pub fn paint_acp_section( y, ); } - for agent in &settings.acp_agents { - paint_acp_card( - cx, - theme, - ui, - agent, - card_rect(content.origin.x, y, content.size.x), + for (index, agent) in settings.acp_agents.iter().enumerate() { + let card = card_rect( + content.origin.x, + y, + content.size.x, + card_height(settings, index), ); - y += CARD_H + CARD_GAP; + paint_acp_card(cx, theme, settings, ui, agent, index, card); + y += card.size.y + CARD_GAP; } y } @@ -137,36 +188,42 @@ fn paint_empty(cx: &mut PaintCx<'_>, theme: &Theme, text: &str, content: Rect, y } fn paint_acp_card( + cx: &mut PaintCx<'_>, + theme: &Theme, + settings: &AgentSettings, + ui: &EditorUiState, + agent: &AcpAgentConfig, + index: usize, + card: Rect, +) { + if is_editing(settings, index) { + paint_acp_form(cx, theme, settings, ui, agent, index, card); + } else { + paint_compact_acp_card(cx, theme, ui, agent, card); + } +} + +fn paint_compact_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, + cx.backend.fill_round_rect( + card, + 8.0, + if agent.connected { + theme.accent + } else { + theme.muted + }, ); + cx.backend.stroke_round_rect(card, 8.0, theme.border, 1.0); + paint_avatar(cx, theme, ui, agent, card); let text_x = card.origin.x + 60.0; - let name = ellipsize(cx, &agent.display_name, 260.0, 13.0); + let name = ellipsize(cx, &agent.display_name, 190.0, 13.0); let name_w = cx.backend.measure_text(&name, 13.0); draw_text( cx, @@ -178,14 +235,13 @@ fn paint_acp_card( ); draw_text( cx, - type_label, + connection_type_label(ui, agent.connection_type), 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); + let detail = ellipsize(cx, &acp_detail(agent), 245.0, 11.0); draw_text( cx, &detail, @@ -194,13 +250,258 @@ fn paint_acp_card( text_x, card.origin.y + 39.0, ); + + paint_action( + cx, + theme, + compact_edit_rect(card), + Icon::Pencil, + theme.muted_foreground, + ); + paint_action( + cx, + theme, + compact_remove_rect(card), + Icon::Trash, + theme.muted_foreground, + ); + paint_connection_button(cx, theme, ui, agent, card); +} + +fn paint_acp_form( + cx: &mut PaintCx<'_>, + theme: &Theme, + settings: &AgentSettings, + ui: &EditorUiState, + agent: &AcpAgentConfig, + index: usize, + card: Rect, +) { + cx.backend.fill_round_rect(card, 10.0, theme.muted); + cx.backend.stroke_round_rect(card, 10.0, theme.border, 1.0); + paint_avatar(cx, theme, ui, agent, card); + + let (status, status_color) = acp_status(theme, agent); draw_text( cx, - t_settings(ui, "acp.notConnected"), + status, + 11.0, + status_color, + card.origin.x + 60.0, + card.origin.y + 29.0, + ); + paint_type_toggle(cx, theme, ui, agent, card); + paint_field( + cx, + theme, + settings, + ui, + agent, + index, + AcpAgentField::DisplayName, + 0, + card, + ); + paint_field( + cx, + theme, + settings, + ui, + agent, + index, + connection_field(agent.connection_type), + 1, + card, + ); +} + +fn paint_avatar( + cx: &mut PaintCx<'_>, + theme: &Theme, + ui: &EditorUiState, + agent: &AcpAgentConfig, + card: Rect, +) { + 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 = match agent.connection_type { + AcpConnectionType::Local => Icon::Terminal, + AcpConnectionType::Remote => Icon::Globe, + }; + draw_icon( + cx.backend, + icon, + Point2D::new(avatar.origin.x + 9.0, avatar.origin.y + 9.0), + 18.0, + theme.foreground, + 1.6, + ); + let _ = ui; +} + +fn paint_connection_button( + cx: &mut PaintCx<'_>, + theme: &Theme, + ui: &EditorUiState, + agent: &AcpAgentConfig, + card: Rect, +) { + let btn = connection_button_rect(card); + let enabled = agent.ready() || agent.connected; + let bg = if agent.connected { + theme.muted + } else if enabled { + theme.primary + } else { + theme.button_hover + }; + let fg = if agent.connected { + Color { + r: 0.93, + g: 0.30, + b: 0.30, + a: 1.0, + } + } else if enabled { + theme.primary_foreground + } else { + theme.muted_foreground + }; + cx.backend.fill_round_rect(btn, 6.0, bg); + cx.backend.stroke_round_rect(btn, 6.0, theme.border, 1.0); + let label = if agent.connected { + t_settings(ui, "settings.agents.disconnect") + } else if agent.ready() { + t_settings(ui, "settings.agents.connect") + } else { + "Configure" + }; + let lw = cx.backend.measure_text(label, 12.0); + draw_text( + cx, + label, + 12.0, + fg, + btn.origin.x + (btn.size.x - lw) / 2.0, + btn.origin.y + 18.0, + ); +} + +#[allow(clippy::too_many_arguments)] +fn paint_field( + cx: &mut PaintCx<'_>, + theme: &Theme, + settings: &AgentSettings, + ui: &EditorUiState, + agent: &AcpAgentConfig, + index: usize, + field: AcpAgentField, + row: usize, + card: Rect, +) { + let focused = settings.focus == Some(SettingsFocus::AcpAgent { index, field }); + let value = if focused { + ui.settings_input_draft.as_str() + } else { + match field { + AcpAgentField::DisplayName => agent.display_name.as_str(), + AcpAgentField::Command => agent.command.as_str(), + AcpAgentField::Url => agent.url.as_deref().unwrap_or(""), + } + }; + let label = match field { + AcpAgentField::DisplayName => "Name", + AcpAgentField::Command => "Command", + AcpAgentField::Url => "URL", + }; + let label_y = field_input_rect(card, row).origin.y + 16.0; + draw_text( + cx, + label, 11.0, theme.muted_foreground, - card.origin.x + card.size.x - 96.0, - card.origin.y + 35.0, + card.origin.x + 12.0, + label_y, + ); + let input = field_input_rect(card, row); + cx.backend.fill_round_rect( + input, + 6.0, + if focused { + theme.background + } else { + theme.card + }, + ); + cx.backend.stroke_round_rect( + input, + 6.0, + if focused { theme.primary } else { theme.border }, + 1.0, + ); + let clipped = ellipsize(cx, value, input.size.x - 12.0, 11.0); + draw_text( + cx, + &clipped, + 11.0, + theme.foreground, + input.origin.x + 6.0, + input.origin.y + 16.0, + ); +} + +fn paint_type_toggle( + cx: &mut PaintCx<'_>, + theme: &Theme, + ui: &EditorUiState, + agent: &AcpAgentConfig, + card: Rect, +) { + let r = type_toggle_rect(card); + cx.backend.stroke_round_rect(r, 6.0, theme.border, 1.0); + let half = r.size.x / 2.0; + for (i, kind) in [AcpConnectionType::Local, AcpConnectionType::Remote] + .iter() + .enumerate() + { + let item = Rect { + origin: Point2D::new(r.origin.x + i as f32 * half, r.origin.y), + size: Point2D::new(half, r.size.y), + }; + let active = agent.connection_type == *kind; + if active { + cx.backend.fill_round_rect(item, 5.0, theme.primary); + } + let color = if active { + theme.primary_foreground + } else { + theme.muted_foreground + }; + let label = connection_type_label(ui, *kind); + let tw = cx.backend.measure_text(label, 10.0); + draw_text( + cx, + label, + 10.0, + color, + item.origin.x + (item.size.x - tw) / 2.0, + item.origin.y + 16.0, + ); + } +} + +fn paint_action(cx: &mut PaintCx<'_>, theme: &Theme, rect: Rect, icon: Icon, color: Color) { + cx.backend.fill_round_rect(rect, 6.0, theme.button_hover); + draw_icon( + cx.backend, + icon, + Point2D::new(rect.origin.x + 6.0, rect.origin.y + 6.0), + 12.0, + color, + 1.4, ); } @@ -213,6 +514,53 @@ fn acp_detail(agent: &AcpAgentConfig) -> String { } } +fn acp_status(theme: &Theme, agent: &AcpAgentConfig) -> (&'static str, Color) { + if agent.connected { + ( + "connected", + Color { + r: 0.34, + g: 0.78, + b: 0.45, + a: 1.0, + }, + ) + } else if agent.ready() { + ("ready", theme.muted_foreground) + } else { + ("not configured", theme.muted_foreground) + } +} + +fn connection_type_label(ui: &EditorUiState, kind: AcpConnectionType) -> &'static str { + match kind { + AcpConnectionType::Local => t_settings(ui, "acp.local"), + AcpConnectionType::Remote => t_settings(ui, "acp.remote"), + } +} + +fn connection_field(kind: AcpConnectionType) -> AcpAgentField { + match kind { + AcpConnectionType::Local => AcpAgentField::Command, + AcpConnectionType::Remote => AcpAgentField::Url, + } +} + +fn is_editing(settings: &AgentSettings, index: usize) -> bool { + matches!( + settings.focus, + Some(SettingsFocus::AcpAgent { index: i, .. }) if i == index + ) +} + +fn card_height(settings: &AgentSettings, index: usize) -> f32 { + if is_editing(settings, index) { + EXPANDED_CARD_H + } else { + COMPACT_CARD_H + } +} + fn add_agent_rect(content: Rect, y: f32) -> Rect { Rect { origin: Point2D::new( @@ -223,10 +571,60 @@ fn add_agent_rect(content: Rect, y: f32) -> Rect { } } -fn card_rect(x: f32, y: f32, w: f32) -> Rect { +fn card_rect(x: f32, y: f32, w: f32, h: f32) -> Rect { Rect { origin: Point2D::new(x, y), - size: Point2D::new(w, CARD_H), + size: Point2D::new(w, h), + } +} + +fn compact_edit_rect(card: Rect) -> Rect { + Rect { + origin: Point2D::new( + connection_button_rect(card).origin.x - 8.0 - ACTION_W * 2.0 - 4.0, + card.origin.y + (card.size.y - ACTION_W) / 2.0, + ), + size: Point2D::new(ACTION_W, ACTION_W), + } +} + +fn compact_remove_rect(card: Rect) -> Rect { + Rect { + origin: Point2D::new( + compact_edit_rect(card).origin.x + ACTION_W + 4.0, + card.origin.y + (card.size.y - ACTION_W) / 2.0, + ), + size: Point2D::new(ACTION_W, ACTION_W), + } +} + +fn connection_button_rect(card: Rect) -> Rect { + Rect { + origin: Point2D::new( + card.origin.x + card.size.x - 12.0 - CONNECT_BTN_W, + card.origin.y + (card.size.y - CONNECT_BTN_H) / 2.0, + ), + size: Point2D::new(CONNECT_BTN_W, CONNECT_BTN_H), + } +} + +fn type_toggle_rect(card: Rect) -> Rect { + Rect { + origin: Point2D::new( + card.origin.x + card.size.x - 16.0 - TYPE_TOGGLE_W, + card.origin.y + 10.0, + ), + size: Point2D::new(TYPE_TOGGLE_W, 24.0), + } +} + +fn field_input_rect(card: Rect, row: usize) -> Rect { + Rect { + origin: Point2D::new( + card.origin.x + 12.0 + FIELD_LABEL_W, + card.origin.y + 48.0 + row as f32 * 28.0, + ), + size: Point2D::new(card.size.x - 24.0 - FIELD_LABEL_W, FIELD_H), } } 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 c8ce1d3b5..555fb6abe 100644 --- a/crates/op-editor-ui/src/widgets/agent_settings_panel.rs +++ b/crates/op-editor-ui/src/widgets/agent_settings_panel.rs @@ -13,8 +13,8 @@ use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::{PaintCx, Widget, WidgetId}; use crate::{Color, Point2D, Rect, TextLayout}; use op_editor_core::agent_settings::{ - AgentProvider, AgentSettings, AgentSettingsTab, BuiltinAgentField, ImageGenField, - ImageSearchField, McpCli, + AcpAgentField, AgentProvider, AgentSettings, AgentSettingsTab, BuiltinAgentField, + ImageGenField, ImageSearchField, McpCli, }; use op_editor_core::editor_ui_state::EditorUiState; use op_editor_core::EditorState; @@ -51,6 +51,14 @@ pub enum AgentSettingsHit { EditBuiltinAgent(usize), RemoveBuiltinAgent(usize), AddAcpAgent, + FocusAcpAgent { + index: usize, + field: AcpAgentField, + }, + ToggleAcpConnectionType(usize), + EditAcpAgent(usize), + RemoveAcpAgent(usize), + ToggleAcpConnected(usize), ToggleMcpServer, ToggleMcpCli(McpCli), ToggleImagesAdvanced, @@ -138,8 +146,19 @@ impl<'a> AgentSettingsPanel<'a> { } let content = content_rect(panel); let acp_y = acp_section_y(content, &self.settings); - match agent_settings_acp::hit_test(content, scrolled, acp_y) { + match agent_settings_acp::hit_test(content, &self.settings, scrolled, acp_y) { AcpHit::AddAgent => return AgentSettingsHit::AddAcpAgent, + AcpHit::Focus { index, field } => { + return AgentSettingsHit::FocusAcpAgent { index, field }; + } + AcpHit::ToggleConnectionType(index) => { + return AgentSettingsHit::ToggleAcpConnectionType(index); + } + AcpHit::Edit(index) => return AgentSettingsHit::EditAcpAgent(index), + AcpHit::Remove(index) => return AgentSettingsHit::RemoveAcpAgent(index), + AcpHit::ToggleConnected(index) => { + return AgentSettingsHit::ToggleAcpConnected(index); + } AcpHit::None => {} } for (i, provider) in AgentProvider::ALL.iter().enumerate() { @@ -701,7 +720,7 @@ fn connect_btn_rect_at(card: Rect) -> Rect { fn agent_card_rect_in(panel: Rect, index: usize, settings: &AgentSettings) -> Rect { let content = content_rect(panel); let builtin_block = agent_settings_builtin::content_height(settings) + SECTION_GAP; - let acp_block = 32.0 + 28.0 + 64.0 + SECTION_GAP; + let acp_block = agent_settings_acp::content_height(settings) + SECTION_GAP; let mut y = content.origin.y + 12.0 + builtin_block + acp_block + 32.0; for i in 0..index { y += CARD_HEIGHT + CARD_GAP; diff --git a/crates/op-host-desktop/src/settings_io.rs b/crates/op-host-desktop/src/settings_io.rs index 4b391b403..131146f32 100644 --- a/crates/op-host-desktop/src/settings_io.rs +++ b/crates/op-host-desktop/src/settings_io.rs @@ -379,6 +379,7 @@ fn acp_agent_from_payload(payload: AcpAgentPayload) -> Option { env: payload.env, url: payload.url, enabled: payload.enabled, + connected: false, }) } @@ -701,6 +702,7 @@ mod tests { assert_eq!(local.display_name, "Design Agent"); assert_eq!(local.connection_type, AcpConnectionType::Local); assert_eq!(local.command, "/usr/local/bin/design-agent"); + assert!(!local.connected); assert_eq!(local.args, vec!["--stdio"]); assert_eq!( local.env.get("ACP_TOKEN").map(String::as_str), 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 a369f7532..4b44a426c 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 @@ -1,10 +1,28 @@ use super::WidgetHostNative; use op_editor_core::agent_settings::{ - AgentSettingsTab, BuiltinAgentField, ImageGenField, ImageGenProvider, ImageSearchField, - SettingsFocus, + AcpAgentField, AgentSettingsTab, BuiltinAgentField, ImageGenField, ImageGenProvider, + ImageSearchField, SettingsFocus, }; use op_editor_ui::widgets::agent_settings_panel::AgentSettingsPanel; +fn agent_settings_content_metrics(host: &WidgetHostNative) -> (f32, f32, f32) { + let panel = AgentSettingsPanel::for_editor(host.editor_state()); + let rect = panel.rect(1200.0, 800.0); + ( + rect.origin.x + 200.0 + 24.0, + rect.origin.y + 24.0, + rect.size.x - 200.0 - 48.0, + ) +} + +fn acp_header_y(content_y: f32) -> f32 { + content_y + 12.0 + 120.0 + 28.0 +} + +fn acp_card_y(content_y: f32) -> f32 { + acp_header_y(content_y) + 28.0 + 28.0 +} + #[test] fn builtin_agent_api_key_focus_accepts_text_and_rebuilds_models() { let mut host = WidgetHostNative::new(); @@ -131,6 +149,126 @@ fn add_acp_agent_press_creates_local_agent() { assert_eq!(settings.acp_agents[0].display_name, "ACP Agent 1"); } +#[test] +fn acp_agent_compact_edit_focuses_display_name_form() { + let mut host = WidgetHostNative::new(); + host.editor_state_mut() + .editor_ui + .agent_settings + .add_acp_agent(); + + let (content_x, content_y, content_w) = agent_settings_content_metrics(&host); + let card_y = acp_card_y(content_y); + assert!(host.dispatch_agent_settings_press( + content_x + content_w - 156.0, + card_y + 30.0, + 1200.0, + 800.0 + )); + + assert_eq!( + host.editor_state().editor_ui.agent_settings.focus, + Some(SettingsFocus::AcpAgent { + index: 0, + field: AcpAgentField::DisplayName, + }) + ); + assert_eq!( + host.editor_state().editor_ui.settings_input_draft, + "ACP Agent 1" + ); +} + +#[test] +fn acp_agent_command_field_accepts_text_and_commits() { + let mut host = WidgetHostNative::new(); + host.editor_state_mut() + .editor_ui + .agent_settings + .add_acp_agent(); + + let (content_x, content_y, content_w) = agent_settings_content_metrics(&host); + let card_y = acp_card_y(content_y); + assert!(host.dispatch_agent_settings_press( + content_x + content_w - 156.0, + card_y + 30.0, + 1200.0, + 800.0 + )); + + assert!(host.dispatch_agent_settings_press( + content_x + 92.0, + card_y + 48.0 + 28.0 + 12.0, + 1200.0, + 800.0 + )); + for c in "op-agent".chars() { + assert!(host.apply_text(c)); + } + assert!(host.apply_send()); + + let settings = &host.editor_state().editor_ui.agent_settings; + assert_eq!(settings.acp_agents[0].command, "op-agent"); + assert!(settings.focus.is_none()); + assert!(host + .editor_state() + .editor_ui + .settings_input_draft + .is_empty()); +} + +#[test] +fn acp_agent_remove_press_deletes_agent_and_clears_focus() { + let mut host = WidgetHostNative::new(); + host.editor_state_mut() + .editor_ui + .agent_settings + .add_acp_agent(); + + let (content_x, content_y, content_w) = agent_settings_content_metrics(&host); + let card_y = acp_card_y(content_y); + assert!(host.dispatch_agent_settings_press( + content_x + content_w - 128.0, + card_y + 30.0, + 1200.0, + 800.0 + )); + + let settings = &host.editor_state().editor_ui.agent_settings; + assert!(settings.acp_agents.is_empty()); + assert!(settings.focus.is_none()); + assert!(host + .editor_state() + .editor_ui + .settings_input_draft + .is_empty()); +} + +#[test] +fn acp_agent_connect_press_toggles_connected_state() { + let mut host = WidgetHostNative::new(); + host.editor_state_mut() + .editor_ui + .agent_settings + .add_acp_agent(); + host.editor_state_mut().editor_ui.agent_settings.acp_agents[0].command = "op-agent".into(); + + let (content_x, content_y, content_w) = agent_settings_content_metrics(&host); + let card_y = acp_card_y(content_y); + let button_x = content_x + content_w - 60.0; + assert!(host.dispatch_agent_settings_press(button_x, card_y + 30.0, 1200.0, 800.0)); + assert!( + host.editor_state().editor_ui.agent_settings.acp_agents[0].connected, + "configured local ACP agent should become connected after pressing Connect" + ); + + assert!(host.dispatch_agent_settings_press(button_x, card_y + 30.0, 1200.0, 800.0)); + assert!( + !host.editor_state().editor_ui.agent_settings.acp_agents[0].connected, + "connected ACP agent should become disconnected after pressing Disconnect" + ); +} + #[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/keyboard.rs b/crates/op-host-native/src/widget_host/keyboard.rs index 8274f036f..ad767de09 100644 --- a/crates/op-host-native/src/widget_host/keyboard.rs +++ b/crates/op-host-native/src/widget_host/keyboard.rs @@ -26,6 +26,9 @@ impl WidgetHostNative { op_editor_core::agent_settings::SettingsFocus::BuiltinAgent { .. } => { !c.is_control() && draft.len() < 512 } + op_editor_core::agent_settings::SettingsFocus::AcpAgent { .. } => { + !c.is_control() && draft.len() < 512 + } op_editor_core::agent_settings::SettingsFocus::ImageGenProfile { .. } => { !c.is_control() && draft.len() < 512 } 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 aaa0918ed..ed2a24189 100644 --- a/crates/op-host-native/src/widget_host/press_helpers.rs +++ b/crates/op-host-native/src/widget_host/press_helpers.rs @@ -345,9 +345,139 @@ impl WidgetHostNative { self.editor_state.editor_ui.settings_input_draft.clear(); self.editor_state.rebuild_chat_models(); } + AgentSettingsHit::FocusAcpAgent { index, field } => { + self.commit_settings_focus_if_any(); + if let Some(agent) = self + .editor_state + .editor_ui + .agent_settings + .acp_agents + .get(index) + { + self.editor_state.editor_ui.settings_input_draft = match field { + op_editor_core::agent_settings::AcpAgentField::DisplayName => { + agent.display_name.clone() + } + op_editor_core::agent_settings::AcpAgentField::Command => { + agent.command.clone() + } + op_editor_core::agent_settings::AcpAgentField::Url => { + agent.url.clone().unwrap_or_default() + } + }; + self.editor_state.editor_ui.agent_settings.focus = + Some(op_editor_core::agent_settings::SettingsFocus::AcpAgent { + index, + field, + }); + } + } + AgentSettingsHit::ToggleAcpConnectionType(index) => { + self.commit_settings_focus_if_any(); + if let Some(agent) = self + .editor_state + .editor_ui + .agent_settings + .acp_agents + .get_mut(index) + { + use op_editor_core::agent_settings::{AcpAgentField, AcpConnectionType}; + agent.connection_type = match agent.connection_type { + AcpConnectionType::Local => AcpConnectionType::Remote, + AcpConnectionType::Remote => AcpConnectionType::Local, + }; + agent.connected = false; + let field = match agent.connection_type { + AcpConnectionType::Local => AcpAgentField::Command, + AcpConnectionType::Remote => AcpAgentField::Url, + }; + self.editor_state.editor_ui.settings_input_draft = match field { + AcpAgentField::Command => agent.command.clone(), + AcpAgentField::Url => agent.url.clone().unwrap_or_default(), + AcpAgentField::DisplayName => agent.display_name.clone(), + }; + self.editor_state.editor_ui.agent_settings.focus = + Some(op_editor_core::agent_settings::SettingsFocus::AcpAgent { + index, + field, + }); + } + } + AgentSettingsHit::EditAcpAgent(index) => { + self.commit_settings_focus_if_any(); + if let Some(agent) = self + .editor_state + .editor_ui + .agent_settings + .acp_agents + .get(index) + { + self.editor_state.editor_ui.settings_input_draft = agent.display_name.clone(); + self.editor_state.editor_ui.agent_settings.focus = + Some(op_editor_core::agent_settings::SettingsFocus::AcpAgent { + index, + field: op_editor_core::agent_settings::AcpAgentField::DisplayName, + }); + } + } + AgentSettingsHit::RemoveAcpAgent(index) => { + self.commit_settings_focus_if_any(); + let agents = &mut self.editor_state.editor_ui.agent_settings.acp_agents; + if index < agents.len() { + agents.remove(index); + self.editor_state.editor_ui.agent_settings.focus = None; + self.editor_state.editor_ui.settings_input_draft.clear(); + } + } + AgentSettingsHit::ToggleAcpConnected(index) => { + self.commit_settings_focus_if_any(); + if let Some(agent) = self + .editor_state + .editor_ui + .agent_settings + .acp_agents + .get_mut(index) + { + if agent.connected { + agent.connected = false; + } else if agent.ready() { + agent.connected = true; + } else { + use op_editor_core::agent_settings::{AcpAgentField, AcpConnectionType}; + let field = match agent.connection_type { + AcpConnectionType::Local => AcpAgentField::Command, + AcpConnectionType::Remote => AcpAgentField::Url, + }; + self.editor_state.editor_ui.settings_input_draft = match field { + AcpAgentField::Command => agent.command.clone(), + AcpAgentField::Url => agent.url.clone().unwrap_or_default(), + AcpAgentField::DisplayName => agent.display_name.clone(), + }; + self.editor_state.editor_ui.agent_settings.focus = + Some(op_editor_core::agent_settings::SettingsFocus::AcpAgent { + index, + field, + }); + } + } + } AgentSettingsHit::AddAcpAgent => { self.commit_settings_focus_if_any(); - self.editor_state.editor_ui.agent_settings.add_acp_agent(); + let id = self.editor_state.editor_ui.agent_settings.add_acp_agent(); + let index = self + .editor_state + .editor_ui + .agent_settings + .acp_agents + .iter() + .position(|agent| agent.id == id) + .unwrap_or(0); + self.editor_state.editor_ui.agent_settings.focus = + Some(op_editor_core::agent_settings::SettingsFocus::AcpAgent { + index, + field: op_editor_core::agent_settings::AcpAgentField::Command, + }); + self.editor_state.editor_ui.settings_input_draft.clear(); } AgentSettingsHit::Inside => {} AgentSettingsHit::AddGenConfig => { diff --git a/crates/op-host-native/src/widget_host/settings_dispatch.rs b/crates/op-host-native/src/widget_host/settings_dispatch.rs index 9291b8a73..8b8b9ebe5 100644 --- a/crates/op-host-native/src/widget_host/settings_dispatch.rs +++ b/crates/op-host-native/src/widget_host/settings_dispatch.rs @@ -5,7 +5,9 @@ use super::WidgetHostNative; impl WidgetHostNative { /// Commit any focused settings-modal input. pub(in crate::widget_host) fn commit_settings_focus_if_any(&mut self) { - use op_editor_core::agent_settings::{BuiltinAgentField, ImageGenField, SettingsFocus}; + use op_editor_core::agent_settings::{ + AcpAgentField, BuiltinAgentField, ImageGenField, SettingsFocus, + }; let Some(focus) = self.editor_state.editor_ui.agent_settings.focus.take() else { return; }; @@ -89,6 +91,35 @@ impl WidgetHostNative { } } } + SettingsFocus::AcpAgent { index, field } => { + if let Some(agent) = self + .editor_state + .editor_ui + .agent_settings + .acp_agents + .get_mut(index) + { + match field { + AcpAgentField::DisplayName => { + if !draft.trim().is_empty() { + agent.display_name = draft.trim().to_string(); + } + } + AcpAgentField::Command => { + agent.command = draft.trim().to_string(); + agent.connected = false; + } + AcpAgentField::Url => { + agent.url = if draft.trim().is_empty() { + None + } else { + Some(draft.trim().to_string()) + }; + agent.connected = false; + } + } + } + } } self.mark_dirty(); }