feat(settings): edit acp agent entries
This commit is contained in:
parent
ce59557b4d
commit
2bc8e5aa34
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue