feat(settings): persist acp agent entries

This commit is contained in:
Fini 2026-05-30 19:04:00 +08:00
parent cd1ca1ac66
commit 883f8035fb
9 changed files with 529 additions and 83 deletions

View file

@ -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<String>,
pub env: BTreeMap<String, String>,
pub url: Option<String>,
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<BuiltinAgentConfig>,
pub next_builtin_agent_id: u64,
pub acp_agents: Vec<AcpAgentConfig>,
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<String>,
connection_type: AcpConnectionType,
command: impl Into<String>,
args: Vec<String>,
env: BTreeMap<String, String>,
url: Option<String>,
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);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String>,
#[serde(default)]
env: std::collections::BTreeMap<String, String>,
#[serde(default)]
url: Option<String>,
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<BuiltinAgentConfig>,
acp_agents: Vec<AcpAgentConfig>,
image_gen_profiles: Vec<ImageGenProfile>,
active_image_gen_profile_id: Option<String>,
}
@ -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<Vec<BuiltinAgentPayload>>,
#[serde(default)]
acp_agents: Option<Vec<AcpAgentPayload>>,
#[serde(default)]
image_gen_profiles: Option<Vec<ImageGenProfilePayload>>,
#[serde(default)]
active_image_gen_profile_id: Option<String>,
@ -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<BuiltinAge
})
}
fn acp_agent_to_payload(agent: &AcpAgentConfig) -> 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<AcpAgentConfig> {
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<OpenverseOAuthPayload> {
@ -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::<u64>().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();

View file

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

View file

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