feat(settings): persist acp agent entries
This commit is contained in:
parent
cd1ca1ac66
commit
883f8035fb
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
267
crates/op-editor-ui/src/widgets/agent_settings_acp.rs
Normal file
267
crates/op-editor-ui/src/widgets/agent_settings_acp.rs
Normal 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))
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue