feat(settings): edit acp agent entries

This commit is contained in:
Fini 2026-05-30 19:37:37 +08:00
parent ce59557b4d
commit 2bc8e5aa34
9 changed files with 807 additions and 52 deletions

View file

@ -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<String, String>,
pub url: Option<String>,
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);
}
}

View file

@ -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::{

View file

@ -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),
}
}

View file

@ -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;

View file

@ -379,6 +379,7 @@ fn acp_agent_from_payload(payload: AcpAgentPayload) -> Option<AcpAgentConfig> {
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),

View file

@ -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();

View file

@ -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
}

View file

@ -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 => {

View file

@ -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();
}