Skip to repository content1383 lines · 48.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:43:09.878Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
external_agents_page.rs
1use std::ops::Range;
2
3use anyhow::Result;
4use collections::HashMap;
5use editor::{Editor, MultiBufferOffset, SelectionEffects, scroll::Autoscroll};
6use gpui::{
7 AsyncWindowContext, Entity, FocusHandle, Focusable as _, ReadGlobal as _, ScrollHandle,
8 WeakEntity, WindowHandle, prelude::*,
9};
10use itertools::Itertools as _;
11use project::agent_server_store::{AgentId, AgentServerStore, ExternalAgentSource};
12use settings::{
13 AgentConfigOptionValue, CustomAgentServerSettings, SettingsStore, update_settings_file,
14};
15use ui::{
16 AiSettingItem, AiSettingItemSource, AiSettingItemStatus, ContextMenu, ContextMenuEntry,
17 Divider, PopoverMenu, Tooltip, prelude::*,
18};
19use omega_harness::{HarnessDistribution, HarnessFrontDoorState, PinControl, ProvenanceVerdict};
20use util::ResultExt as _;
21use workspace::{MultiWorkspace, Workspace, create_and_open_local_file};
22
23use crate::SettingsWindow;
24
25pub(crate) fn render_external_agents_page(
26 settings_window: &SettingsWindow,
27 scroll_handle: &ScrollHandle,
28 _window: &mut Window,
29 cx: &mut Context<SettingsWindow>,
30) -> AnyElement {
31 let agent_server_store = agent_server_store(settings_window, cx);
32
33 let maintenance = settings_window.harness_maintenance.clone();
34 let maintenance_error = settings_window.harness_maintenance_error.clone();
35
36 let agent_list = if let Some(store) = agent_server_store.as_ref() {
37 let agents = collect_agents(store, cx);
38 if agents.is_empty() {
39 render_empty_state(cx)
40 } else {
41 render_agent_list(agents, &maintenance, maintenance_error.as_ref(), cx)
42 }
43 } else {
44 render_no_project_state(cx)
45 };
46
47 v_flex()
48 .id("external-agents-page")
49 .size_full()
50 .pt_2p5()
51 .px_8()
52 .pb_16()
53 .track_scroll(scroll_handle)
54 .overflow_y_scroll()
55 .child(Label::new("External Agents"))
56 .child(
57 Label::new("Agents connected through the Agent Client Protocol.")
58 .size(LabelSize::Small)
59 .color(Color::Muted),
60 )
61 .child(agent_list)
62 .into_any_element()
63}
64
65pub(crate) fn agent_server_store(
66 settings_window: &SettingsWindow,
67 cx: &App,
68) -> Option<Entity<AgentServerStore>> {
69 let original_window = settings_window.original_window.as_ref()?;
70 let multi_workspace = original_window.read(cx).ok()?;
71 let workspace = multi_workspace.workspaces().next()?;
72 let project = workspace.read(cx).project().clone();
73 Some(project.read(cx).agent_server_store().clone())
74}
75
76/// An external agent listed on the page, paired with the data needed to render
77/// its row: the optional extension-provided icon path, a human-readable name,
78/// and where the agent came from.
79type AgentRow = (
80 AgentId,
81 Option<SharedString>,
82 SharedString,
83 ExternalAgentSource,
84);
85
86fn collect_agents(store: &Entity<AgentServerStore>, cx: &App) -> Vec<AgentRow> {
87 let store = store.read(cx);
88 store
89 .external_agents()
90 .cloned()
91 .collect::<Vec<_>>()
92 .into_iter()
93 .map(|name| {
94 let icon = store.agent_icon(&name);
95 let display_name = store
96 .agent_display_name(&name)
97 .unwrap_or_else(|| name.0.clone());
98 let source = store.agent_source(&name).unwrap_or_default();
99 (name, icon, display_name, source)
100 })
101 .sorted_unstable_by_key(|(_, _, display_name, _)| display_name.to_lowercase())
102 .collect()
103}
104
105/// Reads the raw, user-configured settings for a custom agent so the edit form
106/// can be pre-filled. Reading the parsed settings (rather than the resolved
107/// runtime server) keeps this resilient to malformed `settings.json`: the
108/// settings layer drops individual bad fields instead of failing.
109fn custom_agent_settings(id: &AgentId, cx: &App) -> Option<CustomAgentServerSettings> {
110 SettingsStore::global(cx)
111 .get_content_for_file(settings::SettingsFile::User)?
112 .agent_servers
113 .as_ref()?
114 .get(id.0.as_ref())
115 .cloned()
116}
117
118fn render_empty_state(cx: &App) -> AnyElement {
119 h_flex()
120 .p_4()
121 .justify_center()
122 .border_1()
123 .border_dashed()
124 .border_color(cx.theme().colors().border.opacity(0.6))
125 .rounded_sm()
126 .child(
127 Label::new("No external agents added yet. Click \"Add Agent\" to get started.")
128 .color(Color::Muted)
129 .size(LabelSize::Small),
130 )
131 .into_any_element()
132}
133
134fn render_no_project_state(cx: &App) -> AnyElement {
135 h_flex()
136 .p_4()
137 .justify_center()
138 .border_1()
139 .border_dashed()
140 .border_color(cx.theme().colors().border.opacity(0.6))
141 .rounded_sm()
142 .child(
143 Label::new("No active project found. Open a workspace to manage external agents.")
144 .color(Color::Muted)
145 .size(LabelSize::Small),
146 )
147 .into_any_element()
148}
149
150fn render_agent_list(
151 agents: Vec<AgentRow>,
152 maintenance: &HashMap<AgentId, HarnessFrontDoorState>,
153 maintenance_error: Option<&(AgentId, SharedString)>,
154 cx: &mut Context<SettingsWindow>,
155) -> AnyElement {
156 v_flex()
157 .w_full()
158 .gap_1()
159 .children(itertools::intersperse_with(
160 agents
161 .into_iter()
162 .map(|(id, icon, display_name, source)| {
163 let state = maintenance.get(&id).cloned();
164 let error = maintenance_error
165 .filter(|(errored, _)| errored == &id)
166 .map(|(_, message)| message.clone());
167 render_agent(id, icon, display_name, source, state, error, cx)
168 .into_any_element()
169 })
170 .collect::<Vec<_>>(),
171 || Divider::horizontal().into_any_element(),
172 ))
173 .into_any_element()
174}
175
176fn render_agent(
177 id: AgentId,
178 icon: Option<SharedString>,
179 display_name: SharedString,
180 source: ExternalAgentSource,
181 maintenance: Option<HarnessFrontDoorState>,
182 maintenance_error: Option<SharedString>,
183 cx: &mut Context<SettingsWindow>,
184) -> impl IntoElement {
185 let id_string = id.0.clone();
186
187 let icon = match icon {
188 Some(icon_path) => Icon::from_external_svg(icon_path),
189 None => Icon::new(IconName::Sparkle),
190 }
191 .size(IconSize::Small)
192 .color(Color::Muted);
193
194 let source_kind = match source {
195 ExternalAgentSource::Registry => AiSettingItemSource::Registry,
196 ExternalAgentSource::Custom => AiSettingItemSource::Custom,
197 };
198
199 // Only custom agents are editable here; registry agents are managed via the
200 // ACP registry and only support removal.
201 let configure_button = (source == ExternalAgentSource::Custom).then(|| {
202 IconButton::new(format!("configure-{}", id_string), IconName::Settings)
203 .icon_color(Color::Muted)
204 .icon_size(IconSize::Small)
205 .size(ButtonSize::Medium)
206 .tab_index(0isize)
207 .tooltip(Tooltip::text("Configure Agent"))
208 .on_click(cx.listener({
209 let id = id.clone();
210 move |this, _event, window, cx| {
211 let existing =
212 custom_agent_settings(&id, cx).map(|settings| (id.clone(), settings));
213 open_custom_agent_form(this, existing, window, cx);
214 }
215 }))
216 });
217
218 let remove_tooltip = match source {
219 ExternalAgentSource::Registry => "Remove Registry Agent",
220 ExternalAgentSource::Custom => "Remove Custom Agent",
221 };
222
223 let pin_button = maintenance
224 .as_ref()
225 .map(|state| render_pin_control(&id, state, cx));
226 let verify_button = maintenance
227 .as_ref()
228 .filter(|state| {
229 // Nothing to re-measure when no directory of Omega's holds the
230 // bytes, so the control is absent rather than present and inert.
231 matches!(state.distribution, HarnessDistribution::OwnedTree)
232 })
233 .map(|_| render_verify_control(&id, cx));
234
235 let details = maintenance
236 .as_ref()
237 .map(|state| render_maintenance_details(state, maintenance_error.clone()));
238
239 let remove_button = IconButton::new(format!("uninstall-{}", id_string), IconName::Trash)
240 .icon_color(Color::Muted)
241 .icon_size(IconSize::Small)
242 .size(ButtonSize::Medium)
243 .tab_index(0isize)
244 .tooltip(Tooltip::text(remove_tooltip))
245 .on_click(move |_event, _window, cx| {
246 remove_agent(&id, source, cx);
247 });
248
249 // The connection status of an external agent is tracked per agent-panel
250 // session (via the agent panel's `AgentConnectionStore`), which isn't
251 // available from the settings window. We therefore render a neutral status;
252 // the row still shows the agent's source and supports configure/removal.
253 AiSettingItem::new(
254 id_string,
255 display_name,
256 AiSettingItemStatus::Stopped,
257 source_kind,
258 )
259 .icon(icon)
260 .when_some(
261 maintenance
262 .as_ref()
263 .map(|state| SharedString::from(format!("v{}", state.version))),
264 |this, version| this.detail_label(version),
265 )
266 .when_some(verify_button, |this, button| this.action(button))
267 .when_some(pin_button, |this, button| this.action(button))
268 .when_some(configure_button, |this, button| this.action(button))
269 .action(remove_button)
270 .when_some(details, |this, details| this.details(details))
271}
272
273/// The one pin control this harness's state admits.
274///
275/// The button that exists is decided by [`PinControl`], not here. This function
276/// turns each of its three cases into a widget and does not add a fourth: a
277/// control that appears because the page thought it should, rather than because
278/// the decision layer offered it, is exactly how a front door and a gate start
279/// disagreeing.
280fn render_pin_control(
281 id: &AgentId,
282 state: &HarnessFrontDoorState,
283 cx: &mut Context<SettingsWindow>,
284) -> AnyElement {
285 let button_id = SharedString::from(format!("harness-pin-{}", id.0));
286 match &state.pin_control {
287 PinControl::Take { version, .. } => {
288 let version = version.clone();
289 IconButton::new(button_id, IconName::Lock)
290 .icon_color(Color::Muted)
291 .icon_size(IconSize::Small)
292 .size(ButtonSize::Medium)
293 .tab_index(0isize)
294 .tooltip(move |_, cx| {
295 Tooltip::with_meta(
296 "Pin This Version",
297 None,
298 format!(
299 "Freeze this agent at {version} and at the files Omega measures now. \
300 Omega will refuse to run anything else until you remove the pin."
301 ),
302 cx,
303 )
304 })
305 .on_click(cx.listener({
306 let id = id.clone();
307 move |this, _event, _window, cx| {
308 this.pin_harness(&id, cx);
309 }
310 }))
311 .into_any_element()
312 }
313 PinControl::Remove { pinned_version } => {
314 let pinned_version = pinned_version.clone();
315 IconButton::new(button_id, IconName::LockOff)
316 .icon_color(Color::Accent)
317 .icon_size(IconSize::Small)
318 .size(ButtonSize::Medium)
319 .tab_index(0isize)
320 .tooltip(move |_, cx| {
321 Tooltip::with_meta(
322 "Remove Pin",
323 None,
324 format!(
325 "Pinned to {pinned_version}. Removing the pin lets Omega install and \
326 run whatever version the registry offers."
327 ),
328 cx,
329 )
330 })
331 .on_click(cx.listener({
332 let id = id.clone();
333 move |this, _event, _window, cx| {
334 this.unpin_harness(&id, cx);
335 }
336 }))
337 .into_any_element()
338 }
339 // Disabled, never absent: a row missing the control every other row has
340 // reads to an owner as a bug in Omega rather than as a fact about this
341 // agent. The sentence is the one the decision layer produced.
342 PinControl::Unavailable { reason } => {
343 let reason = reason.clone();
344 IconButton::new(button_id, IconName::Lock)
345 .icon_color(Color::Disabled)
346 .icon_size(IconSize::Small)
347 .size(ButtonSize::Medium)
348 .disabled(true)
349 .tooltip(move |_, cx| {
350 Tooltip::with_meta("Cannot Pin", None, reason.clone(), cx)
351 })
352 .into_any_element()
353 }
354 }
355}
356
357/// Re-measure an installed tree on request, under its own maintenance action.
358fn render_verify_control(id: &AgentId, cx: &mut Context<SettingsWindow>) -> AnyElement {
359 IconButton::new(
360 SharedString::from(format!("harness-verify-{}", id.0)),
361 IconName::Check,
362 )
363 .icon_color(Color::Muted)
364 .icon_size(IconSize::Small)
365 .size(ButtonSize::Medium)
366 .tab_index(0isize)
367 .tooltip(move |_, cx| {
368 Tooltip::with_meta(
369 "Re-Check Installed Files",
370 None,
371 "Hash this agent's installed files again and write a receipt for what was found.",
372 cx,
373 )
374 })
375 .on_click(cx.listener({
376 let id = id.clone();
377 move |this, _event, _window, cx| {
378 this.reprobe_harness(&id, cx);
379 }
380 }))
381 .into_any_element()
382}
383
384/// The sentences under the row.
385///
386/// Every one of them is produced by `omega_harness`. This function chooses
387/// colour and order and writes no reason of its own, which is what keeps the
388/// page unable to soften a refusal the gate will enforce.
389fn render_maintenance_details(
390 state: &HarnessFrontDoorState,
391 error: Option<SharedString>,
392) -> AnyElement {
393 let mut lines: Vec<(SharedString, Color)> = Vec::new();
394
395 if let Some(reason) = state.launch.reason() {
396 lines.push((SharedString::from(reason.to_string()), Color::Error));
397 }
398 if let Some(pin) = state.pin.as_ref() {
399 lines.push((
400 SharedString::from(format!(
401 "Pinned to {} at {}.",
402 pin.version,
403 pin.digest.chars().take(12).collect::<String>()
404 )),
405 Color::Accent,
406 ));
407 }
408 match &state.provenance {
409 ProvenanceVerdict::Verified { digest } => lines.push((
410 SharedString::from(format!(
411 "Verified: the installed files hash to {}.",
412 digest.chars().take(12).collect::<String>()
413 )),
414 Color::Success,
415 )),
416 ProvenanceVerdict::Refused(gap) => lines.push((
417 SharedString::from(gap.reason().to_string()),
418 Color::Warning,
419 )),
420 }
421 if let Some(error) = error {
422 lines.push((error, Color::Error));
423 }
424
425 v_flex()
426 .gap_0p5()
427 .children(lines.into_iter().map(|(text, color)| {
428 Label::new(text)
429 .size(LabelSize::Small)
430 .color(color)
431 .into_any_element()
432 }))
433 .into_any_element()
434}
435
436fn remove_agent(id: &AgentId, source: ExternalAgentSource, cx: &mut App) {
437 let fs = <dyn fs::Fs>::global(cx);
438 let id = id.clone();
439 update_settings_file(fs, cx, move |settings, _| {
440 let Some(agent_servers) = settings.agent_servers.as_mut() else {
441 return;
442 };
443 // Only remove the entry if it still matches the source we rendered, so a
444 // stale row can't clobber an entry that was changed in the meantime.
445 let matches_source = agent_servers
446 .get(id.0.as_ref())
447 .is_some_and(|entry| match source {
448 ExternalAgentSource::Registry => {
449 matches!(entry, CustomAgentServerSettings::Registry { .. })
450 }
451 ExternalAgentSource::Custom => {
452 matches!(entry, CustomAgentServerSettings::Custom { .. })
453 }
454 });
455 if matches_source {
456 agent_servers.remove(id.0.as_ref());
457 }
458 });
459}
460
461pub(crate) fn render_add_agent_popover(
462 settings_window: &SettingsWindow,
463 window: &mut Window,
464 cx: &mut Context<SettingsWindow>,
465) -> impl IntoElement {
466 let original_window = settings_window.original_window;
467 // Stable handle so the button keeps focus state across renders and can show a
468 // focus ring even when the page is opened (and the button auto-focused) via a
469 // mouse click, where `focus_visible` styling is suppressed.
470 let focus_handle = settings_window
471 .external_agent_add_focus_handle
472 .clone()
473 .tab_index(0)
474 .tab_stop(true);
475 let border_color = focus_ring_color(&focus_handle, window, cx);
476 let settings_window = cx.entity().downgrade();
477
478 let popover = PopoverMenu::new("add-agent-server-popover")
479 .trigger(
480 Button::new("add-agent", "Add Agent")
481 .style(ButtonStyle::Outlined)
482 .track_focus(&focus_handle)
483 .start_icon(
484 Icon::new(IconName::Plus)
485 .size(IconSize::Small)
486 .color(Color::Muted),
487 )
488 .label_size(LabelSize::Small),
489 )
490 .anchor(gpui::Anchor::TopRight)
491 .menu(move |window, cx| {
492 let settings_window = settings_window.clone();
493 Some(ContextMenu::build(window, cx, move |menu, _window, _cx| {
494 menu.entry("Install from Registry", None, move |_window, cx| {
495 if let Some(original_window) = original_window {
496 cx.activate(true);
497 original_window
498 .update(cx, |_, window, cx| {
499 window.activate_window();
500 window.dispatch_action(Box::new(zed_actions::AcpRegistry), cx);
501 })
502 .log_err();
503 }
504 })
505 .entry("Add Custom Agent", None, move |window, cx| {
506 settings_window
507 .update(cx, |this, cx| {
508 open_custom_agent_form(this, None, window, cx);
509 })
510 .log_err();
511 })
512 .separator()
513 .header("Learn More")
514 .item(
515 ContextMenuEntry::new("ACP Docs")
516 .icon(IconName::ArrowUpRight)
517 .icon_color(Color::Muted)
518 .icon_position(IconPosition::End)
519 .handler(|_window, cx| cx.open_url("https://agentclientprotocol.com/")),
520 )
521 }))
522 });
523
524 div()
525 .rounded_md()
526 .border_1()
527 .border_color(border_color)
528 .child(popover)
529}
530
531// === Custom external agent add/edit form ===
532
533struct KeyValueRow {
534 key: Entity<Editor>,
535 value: Entity<Editor>,
536}
537
538/// Editor-backed state for the custom external agent add/edit form.
539pub(crate) struct CustomAgentForm {
540 /// `Some` when editing an existing agent (used to remove the old entry on rename).
541 original_id: Option<AgentId>,
542 name: Entity<Editor>,
543 command: Entity<Editor>,
544 args: Entity<Editor>,
545 env: Vec<KeyValueRow>,
546 /// Advanced fields not surfaced by the form. They're preserved verbatim so
547 /// editing the basic settings doesn't drop a user's hand-written config.
548 default_mode: Option<String>,
549 default_config_options: HashMap<String, AgentConfigOptionValue>,
550 favorite_config_option_values: HashMap<String, Vec<String>>,
551 /// Stable handles for the Cancel/Save buttons so they can render a focus
552 /// ring. `Filled`/`Subtle` buttons only get a subtle `focus_visible`
553 /// background change otherwise, which is hard to see.
554 cancel_focus_handle: FocusHandle,
555 save_focus_handle: FocusHandle,
556 error: Option<SharedString>,
557}
558
559impl CustomAgentForm {
560 fn new(
561 existing: Option<(AgentId, CustomAgentServerSettings)>,
562 window: &mut Window,
563 cx: &mut Context<SettingsWindow>,
564 ) -> Self {
565 let original_id = existing.as_ref().map(|(id, _)| id.clone());
566 let name_initial = original_id.as_ref().map(|id| id.0.to_string());
567
568 let mut command_initial = None;
569 let mut args_initial = None;
570 let mut env = Vec::new();
571 let mut default_mode = None;
572 let mut default_config_options = HashMap::default();
573 let mut favorite_config_option_values = HashMap::default();
574
575 // Pre-fill from the raw settings so invalid values typed directly into
576 // settings.json still load into the form for correction.
577 if let Some((_, settings)) = existing.as_ref() {
578 match settings {
579 CustomAgentServerSettings::Custom {
580 path,
581 args,
582 env: env_map,
583 default_mode: mode,
584 default_config_options: config_options,
585 favorite_config_option_values: favorites,
586 } => {
587 command_initial = Some(path.to_string_lossy().to_string());
588 if !args.is_empty() {
589 args_initial = Some(args.join(" "));
590 }
591 for (key, value) in sorted_pairs(env_map) {
592 env.push(new_kv_row(Some(&key), Some(&value), window, cx));
593 }
594 default_mode = mode.clone();
595 default_config_options = config_options.clone();
596 favorite_config_option_values = favorites.clone();
597 }
598 CustomAgentServerSettings::Registry {
599 env: env_map,
600 default_mode: mode,
601 default_config_options: config_options,
602 favorite_config_option_values: favorites,
603 } => {
604 for (key, value) in sorted_pairs(env_map) {
605 env.push(new_kv_row(Some(&key), Some(&value), window, cx));
606 }
607 default_mode = mode.clone();
608 default_config_options = config_options.clone();
609 favorite_config_option_values = favorites.clone();
610 }
611 }
612 }
613
614 Self {
615 original_id,
616 name: new_input("my-agent", name_initial.as_deref(), window, cx),
617 command: new_input("/path/to/agent", command_initial.as_deref(), window, cx),
618 args: new_input("--flag value", args_initial.as_deref(), window, cx),
619 env,
620 default_mode,
621 default_config_options,
622 favorite_config_option_values,
623 cancel_focus_handle: cx.focus_handle(),
624 save_focus_handle: cx.focus_handle(),
625 error: None,
626 }
627 }
628}
629
630fn sorted_pairs(map: &HashMap<String, String>) -> Vec<(String, String)> {
631 let mut pairs: Vec<(String, String)> = map
632 .iter()
633 .map(|(key, value)| (key.clone(), value.clone()))
634 .collect();
635 pairs.sort_by(|a, b| a.0.cmp(&b.0));
636 pairs
637}
638
639fn new_input(
640 placeholder: &str,
641 initial: Option<&str>,
642 window: &mut Window,
643 cx: &mut Context<SettingsWindow>,
644) -> Entity<Editor> {
645 let placeholder = placeholder.to_string();
646 let initial = initial.map(|text| text.to_string());
647 cx.new(|cx| {
648 let mut editor = Editor::single_line(window, cx);
649 editor.set_placeholder_text(placeholder.as_str(), window, cx);
650 if let Some(text) = initial {
651 editor.set_text(text, window, cx);
652 }
653 editor
654 })
655}
656
657fn new_kv_row(
658 key: Option<&str>,
659 value: Option<&str>,
660 window: &mut Window,
661 cx: &mut Context<SettingsWindow>,
662) -> KeyValueRow {
663 KeyValueRow {
664 key: new_input("Key", key, window, cx),
665 value: new_input("Value", value, window, cx),
666 }
667}
668
669/// Creates the form state and pushes the form sub-page onto the stack.
670pub(crate) fn open_custom_agent_form(
671 settings_window: &mut SettingsWindow,
672 existing: Option<(AgentId, CustomAgentServerSettings)>,
673 window: &mut Window,
674 cx: &mut Context<SettingsWindow>,
675) {
676 let is_edit = existing.is_some();
677 settings_window.custom_agent_form = Some(CustomAgentForm::new(existing, window, cx));
678
679 let title = if is_edit {
680 "Configure External Agent"
681 } else {
682 "Add Custom Agent"
683 };
684
685 settings_window.push_dynamic_sub_page(
686 title,
687 "Agent Configuration",
688 Some("agent_servers"),
689 false,
690 render_custom_agent_form_page,
691 window,
692 cx,
693 );
694}
695
696fn render_custom_agent_form_page(
697 settings_window: &SettingsWindow,
698 scroll_handle: &ScrollHandle,
699 window: &mut Window,
700 cx: &mut Context<SettingsWindow>,
701) -> AnyElement {
702 let Some(form) = settings_window.custom_agent_form.as_ref() else {
703 return div().into_any_element();
704 };
705 let error = form.error.clone();
706
707 let fields = v_flex()
708 .w_full()
709 .gap_4()
710 .child(
711 crate::render_settings_item_layout(
712 settings_window,
713 "Agent Name",
714 "Required. A unique name used to identify this agent.",
715 input_box(&form.name, cx).into_any_element(),
716 None,
717 None,
718 None,
719 false,
720 cx,
721 )
722 .into_any_element(),
723 )
724 .child(
725 crate::render_settings_item_layout(
726 settings_window,
727 "Command",
728 "Required. Path to the executable that launches the agent.",
729 input_box(&form.command, cx).into_any_element(),
730 None,
731 None,
732 None,
733 false,
734 cx,
735 )
736 .into_any_element(),
737 )
738 .child(
739 crate::render_settings_item_layout(
740 settings_window,
741 "Arguments",
742 "Space-separated arguments passed to the command.",
743 input_box(&form.args, cx).into_any_element(),
744 None,
745 None,
746 None,
747 false,
748 cx,
749 )
750 .into_any_element(),
751 )
752 .child(render_env_section(settings_window, &form.env, cx))
753 .when_some(error, |this, error| this.child(render_form_error(error)))
754 .child(render_form_actions(form, window, cx));
755
756 v_flex()
757 .id("custom-agent-form-page")
758 .size_full()
759 .pt_2p5()
760 .px_8()
761 .pb_16()
762 .track_scroll(scroll_handle)
763 .overflow_y_scroll()
764 .child(fields)
765 .into_any_element()
766}
767
768fn input_box(editor: &Entity<Editor>, cx: &App) -> impl IntoElement {
769 let colors = cx.theme().colors();
770 // All form inputs share tab index 0, so tab order follows render (insertion)
771 // order. Tracking the editor's focus handle makes the field a tab stop and
772 // routes keyboard focus into the editor when tabbed to.
773 let focus_handle = editor.focus_handle(cx).tab_index(0).tab_stop(true);
774 h_flex()
775 .min_w_64()
776 .py_1()
777 .px_2()
778 .h_8()
779 .rounded_md()
780 .border_1()
781 .border_color(colors.border)
782 .bg(colors.editor_background)
783 .track_focus(&focus_handle)
784 .focus(|style| style.border_color(colors.border_focused))
785 .child(editor.clone())
786}
787
788fn render_env_section(
789 settings_window: &SettingsWindow,
790 rows: &[KeyValueRow],
791 cx: &mut Context<SettingsWindow>,
792) -> impl IntoElement {
793 // The right-hand control column is narrower than a full row, so each
794 // variable stacks its key above its value (with the remove affordance next
795 // to the value) to stay readable.
796 let control = v_flex()
797 .min_w_64()
798 .gap_2()
799 .children(rows.iter().enumerate().map(|(ix, row)| {
800 v_flex().gap_1().child(input_box(&row.key, cx)).child(
801 h_flex()
802 .gap_1()
803 .items_center()
804 .child(input_box(&row.value, cx))
805 .child(
806 IconButton::new(("custom-agent-env-remove", ix), IconName::Close)
807 .icon_size(IconSize::Small)
808 .icon_color(Color::Muted)
809 .tab_index(0isize)
810 .tooltip(Tooltip::text("Remove"))
811 .on_click(cx.listener(move |this, _, _window, cx| {
812 if let Some(form) = this.custom_agent_form.as_mut()
813 && ix < form.env.len()
814 {
815 form.env.remove(ix);
816 }
817 cx.notify();
818 })),
819 ),
820 )
821 }))
822 .child(
823 Button::new("custom-agent-env-add", "Add")
824 .style(ButtonStyle::Outlined)
825 .label_size(LabelSize::Small)
826 .tab_index(0isize)
827 .start_icon(
828 Icon::new(IconName::Plus)
829 .size(IconSize::Small)
830 .color(Color::Muted),
831 )
832 .on_click(cx.listener(move |this, _, window, cx| {
833 let row = new_kv_row(None, None, window, cx);
834 // Focus the new key so the user can type immediately and tab
835 // through the new row (key -> value -> ... -> Add button).
836 let key_handle = row.key.focus_handle(cx);
837 if let Some(form) = this.custom_agent_form.as_mut() {
838 form.env.push(row);
839 }
840 key_handle.focus(window, cx);
841 cx.notify();
842 })),
843 )
844 .into_any_element();
845
846 crate::render_settings_item_layout(
847 settings_window,
848 "Environment Variables",
849 "Environment variables provided to the agent process.",
850 control,
851 None,
852 None,
853 None,
854 false,
855 cx,
856 )
857 .into_any_element()
858}
859
860fn render_form_error(error: SharedString) -> impl IntoElement {
861 h_flex()
862 .w_full()
863 .gap_2()
864 .items_start()
865 .child(
866 Icon::new(IconName::XCircle)
867 .size(IconSize::Small)
868 .color(Color::Error),
869 )
870 .child(Label::new(error).size(LabelSize::Small).color(Color::Error))
871}
872
873fn render_form_actions(
874 form: &CustomAgentForm,
875 window: &mut Window,
876 cx: &mut Context<SettingsWindow>,
877) -> impl IntoElement {
878 let cancel_handle = form.cancel_focus_handle.clone().tab_index(0).tab_stop(true);
879 let save_handle = form.save_focus_handle.clone().tab_index(0).tab_stop(true);
880 let cancel_border = focus_ring_color(&cancel_handle, window, cx);
881 let save_border = focus_ring_color(&save_handle, window, cx);
882
883 h_flex()
884 .w_full()
885 .gap_2()
886 .justify_end()
887 .pt_2()
888 .child(
889 div()
890 .rounded_md()
891 .border_1()
892 .border_color(cancel_border)
893 .child(
894 Button::new("custom-agent-form-cancel", "Cancel")
895 .style(ButtonStyle::Subtle)
896 .track_focus(&cancel_handle)
897 .on_click(cx.listener(|this, _, window, cx| {
898 this.custom_agent_form = None;
899 this.pop_sub_page(window, cx);
900 })),
901 ),
902 )
903 .child(
904 div()
905 .rounded_md()
906 .border_1()
907 .border_color(save_border)
908 .child(
909 Button::new("custom-agent-form-save", "Save")
910 .style(ButtonStyle::Filled)
911 .track_focus(&save_handle)
912 .on_click(cx.listener(|this, _, window, cx| {
913 save_custom_agent_form(this, window, cx);
914 })),
915 ),
916 )
917}
918
919/// Returns the border color for a button's focus ring: visible when focused
920/// (keyboard or programmatic), transparent otherwise.
921fn focus_ring_color(handle: &FocusHandle, window: &Window, cx: &App) -> gpui::Hsla {
922 if handle.is_focused(window) {
923 cx.theme().colors().border_focused
924 } else {
925 gpui::transparent_black()
926 }
927}
928
929fn save_custom_agent_form(
930 settings_window: &mut SettingsWindow,
931 window: &mut Window,
932 cx: &mut Context<SettingsWindow>,
933) {
934 let built = {
935 let Some(form) = settings_window.custom_agent_form.as_ref() else {
936 return;
937 };
938 build_settings_from_form(form, cx)
939 };
940
941 let (id, original_id, content) = match built {
942 Ok(value) => value,
943 Err(error) => {
944 if let Some(form) = settings_window.custom_agent_form.as_mut() {
945 form.error = Some(error);
946 }
947 cx.notify();
948 return;
949 }
950 };
951
952 // Reject names that would collide with a *different* existing agent. This
953 // covers both adding a new agent and renaming an existing one.
954 let collides_with_other_agent =
955 agent_server_store(settings_window, cx).is_some_and(|store| {
956 let existing_ids = store
957 .read(cx)
958 .external_agents()
959 .cloned()
960 .collect::<Vec<_>>();
961 name_collides_with_other_agent(&id, original_id.as_ref(), &existing_ids)
962 });
963 if collides_with_other_agent {
964 if let Some(form) = settings_window.custom_agent_form.as_mut() {
965 form.error = Some(format!("An agent named \"{}\" already exists.", id.0).into());
966 }
967 cx.notify();
968 return;
969 }
970
971 let fs = <dyn fs::Fs>::global(cx);
972 update_settings_file(fs, cx, move |settings, _| {
973 let agent_servers = settings.agent_servers.get_or_insert_default();
974 if let Some(original_id) = &original_id
975 && original_id.0 != id.0
976 {
977 agent_servers.remove(original_id.0.as_ref());
978 }
979 agent_servers.insert(id.0.to_string(), content);
980 });
981
982 settings_window.custom_agent_form = None;
983 settings_window.pop_sub_page(window, cx);
984}
985
986/// Plain (editor-free) snapshot of the form's contents, so the validation /
987/// build logic can be exercised without a GPUI context.
988struct CustomAgentFormValues {
989 original_id: Option<AgentId>,
990 name: String,
991 command: String,
992 args: String,
993 env: Vec<(String, String)>,
994 default_mode: Option<String>,
995 default_config_options: HashMap<String, AgentConfigOptionValue>,
996 favorite_config_option_values: HashMap<String, Vec<String>>,
997}
998
999fn build_settings_from_form(
1000 form: &CustomAgentForm,
1001 cx: &App,
1002) -> Result<(AgentId, Option<AgentId>, CustomAgentServerSettings), SharedString> {
1003 let values = CustomAgentFormValues {
1004 original_id: form.original_id.clone(),
1005 name: form.name.read(cx).text(cx),
1006 command: form.command.read(cx).text(cx),
1007 args: form.args.read(cx).text(cx),
1008 env: read_kv(&form.env, cx),
1009 default_mode: form.default_mode.clone(),
1010 default_config_options: form.default_config_options.clone(),
1011 favorite_config_option_values: form.favorite_config_option_values.clone(),
1012 };
1013 build_settings_from_values(values)
1014}
1015
1016fn read_kv(rows: &[KeyValueRow], cx: &App) -> Vec<(String, String)> {
1017 rows.iter()
1018 .map(|row| (row.key.read(cx).text(cx), row.value.read(cx).text(cx)))
1019 .collect()
1020}
1021
1022fn build_settings_from_values(
1023 values: CustomAgentFormValues,
1024) -> Result<(AgentId, Option<AgentId>, CustomAgentServerSettings), SharedString> {
1025 let name = values.name.trim().to_string();
1026 if name.is_empty() {
1027 return Err("Agent name is required.".into());
1028 }
1029
1030 let command = values.command.trim().to_string();
1031 if command.is_empty() {
1032 return Err("Command is required.".into());
1033 }
1034
1035 let args = values
1036 .args
1037 .split_whitespace()
1038 .map(|arg| arg.to_string())
1039 .collect::<Vec<_>>();
1040 let env = collect_kv(&values.env, "environment variable")?;
1041
1042 let content = CustomAgentServerSettings::Custom {
1043 path: command.into(),
1044 args,
1045 env,
1046 default_mode: values.default_mode,
1047 default_config_options: values.default_config_options,
1048 favorite_config_option_values: values.favorite_config_option_values,
1049 };
1050
1051 Ok((AgentId(name.into()), values.original_id, content))
1052}
1053
1054/// Returns whether saving under `id` would overwrite a *different* existing
1055/// agent. Editing an agent in place (`id == original_id`) is allowed.
1056fn name_collides_with_other_agent(
1057 id: &AgentId,
1058 original_id: Option<&AgentId>,
1059 existing_ids: &[AgentId],
1060) -> bool {
1061 original_id.is_none_or(|original| original.0 != id.0)
1062 && existing_ids.iter().any(|existing| existing.0 == id.0)
1063}
1064
1065fn collect_kv(
1066 rows: &[(String, String)],
1067 label: &str,
1068) -> Result<HashMap<String, String>, SharedString> {
1069 let mut map = HashMap::default();
1070 for (key, value) in rows {
1071 let key = key.trim().to_string();
1072 if key.is_empty() {
1073 continue;
1074 }
1075 if map.contains_key(&key) {
1076 return Err(format!("Duplicate {label} \"{key}\".").into());
1077 }
1078 map.insert(key, value.clone());
1079 }
1080 Ok(map)
1081}
1082
1083// === Open settings.json at the agent's position ===
1084//
1085// Retained for an upcoming "Edit in settings.json" affordance that jumps the
1086// user to the relevant `agent_servers` entry. Not currently wired to any UI.
1087
1088/// Opens the user's `settings.json` in the original (editor) window, inserts a
1089/// scaffold `agent_servers` entry, and selects its name so the user can fill in
1090/// the executable path.
1091#[allow(dead_code)]
1092fn open_new_custom_agent_in_settings(original_window: WindowHandle<MultiWorkspace>, cx: &mut App) {
1093 cx.activate(true);
1094 original_window
1095 .update(cx, |multi_workspace, window, cx| {
1096 // Use the workspace handed to us by the update closure rather than
1097 // `Workspace::for_window`, which would read the `MultiWorkspace`
1098 // entity that this closure is already updating (a double borrow).
1099 let Some(workspace) = multi_workspace.workspaces().next() else {
1100 return;
1101 };
1102 let workspace = workspace.downgrade();
1103 window.activate_window();
1104 window
1105 .spawn(cx, async move |cx| {
1106 add_custom_agent_settings_entry(workspace, cx).await
1107 })
1108 .detach_and_log_err(cx);
1109 })
1110 .log_err();
1111}
1112
1113#[allow(dead_code)]
1114async fn add_custom_agent_settings_entry(
1115 workspace: WeakEntity<Workspace>,
1116 cx: &mut AsyncWindowContext,
1117) -> Result<()> {
1118 let item = workspace
1119 .update_in(cx, |_, window, cx| {
1120 create_and_open_local_file(paths::settings_file(), window, cx, || {
1121 settings::initial_user_settings_content().as_ref().into()
1122 })
1123 })?
1124 .await?;
1125
1126 let Some(settings_editor) = item.downcast::<Editor>() else {
1127 return Ok(());
1128 };
1129
1130 settings_editor
1131 .downgrade()
1132 .update_in(cx, |item, window, cx| {
1133 let text = item.buffer().read(cx).snapshot(cx).text();
1134
1135 let settings = cx.global::<SettingsStore>();
1136
1137 let mut unique_server_name = None;
1138 let Some(edits) = settings
1139 .edits_for_update(&text, |settings| {
1140 let server_name: Option<String> = (0..u8::MAX)
1141 .map(|i| {
1142 if i == 0 {
1143 "your_agent".to_string()
1144 } else {
1145 format!("your_agent_{}", i)
1146 }
1147 })
1148 .find(|name| {
1149 !settings
1150 .agent_servers
1151 .as_ref()
1152 .is_some_and(|agent_servers| {
1153 agent_servers.contains_key(name.as_str())
1154 })
1155 });
1156 if let Some(server_name) = server_name {
1157 unique_server_name = Some(SharedString::from(server_name.clone()));
1158 settings.agent_servers.get_or_insert_default().insert(
1159 server_name,
1160 CustomAgentServerSettings::Custom {
1161 path: "path_to_executable".into(),
1162 args: vec![],
1163 env: HashMap::default(),
1164 default_mode: None,
1165 default_config_options: Default::default(),
1166 favorite_config_option_values: Default::default(),
1167 },
1168 );
1169 }
1170 })
1171 .log_err()
1172 else {
1173 return;
1174 };
1175
1176 if edits.is_empty() {
1177 return;
1178 }
1179
1180 let ranges = edits
1181 .iter()
1182 .map(|(range, _)| range.clone())
1183 .collect::<Vec<_>>();
1184
1185 item.edit(
1186 edits.into_iter().map(|(range, s)| {
1187 (
1188 MultiBufferOffset(range.start)..MultiBufferOffset(range.end),
1189 s,
1190 )
1191 }),
1192 cx,
1193 );
1194
1195 if let Some((unique_server_name, buffer)) =
1196 unique_server_name.zip(item.buffer().read(cx).as_singleton())
1197 {
1198 let snapshot = buffer.read(cx).snapshot();
1199 if let Some(range) =
1200 find_text_in_buffer(&unique_server_name, ranges[0].start, &snapshot)
1201 {
1202 item.change_selections(
1203 SelectionEffects::scroll(Autoscroll::newest()),
1204 window,
1205 cx,
1206 |selections| {
1207 selections.select_ranges(vec![
1208 MultiBufferOffset(range.start)..MultiBufferOffset(range.end),
1209 ]);
1210 },
1211 );
1212 }
1213 }
1214 })
1215 .log_err();
1216
1217 Ok(())
1218}
1219
1220#[allow(dead_code)]
1221fn find_text_in_buffer(
1222 text: &str,
1223 start: usize,
1224 snapshot: &language::BufferSnapshot,
1225) -> Option<Range<usize>> {
1226 let chars = text.chars().collect::<Vec<char>>();
1227
1228 let mut offset = start;
1229 let mut char_offset = 0;
1230 for c in snapshot.chars_at(start) {
1231 if char_offset >= chars.len() {
1232 break;
1233 }
1234 offset += 1;
1235
1236 if c == chars[char_offset] {
1237 char_offset += 1;
1238 } else {
1239 char_offset = 0;
1240 }
1241 }
1242
1243 if char_offset == chars.len() {
1244 Some(offset.saturating_sub(chars.len())..offset)
1245 } else {
1246 None
1247 }
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252 use super::*;
1253
1254 fn values() -> CustomAgentFormValues {
1255 CustomAgentFormValues {
1256 original_id: None,
1257 name: "my-agent".into(),
1258 command: "/usr/bin/agent".into(),
1259 args: String::new(),
1260 env: Vec::new(),
1261 default_mode: None,
1262 default_config_options: HashMap::default(),
1263 favorite_config_option_values: HashMap::default(),
1264 }
1265 }
1266
1267 fn id(name: &str) -> AgentId {
1268 AgentId(name.into())
1269 }
1270
1271 #[test]
1272 fn requires_agent_name() {
1273 let mut values = values();
1274 values.name = " ".into();
1275 assert_eq!(
1276 build_settings_from_values(values).unwrap_err().as_ref(),
1277 "Agent name is required."
1278 );
1279 }
1280
1281 #[test]
1282 fn requires_command() {
1283 let mut values = values();
1284 values.command = " ".into();
1285 assert_eq!(
1286 build_settings_from_values(values).unwrap_err().as_ref(),
1287 "Command is required."
1288 );
1289 }
1290
1291 #[test]
1292 fn rejects_duplicate_environment_variables() {
1293 let mut values = values();
1294 values.env = vec![("FOO".into(), "1".into()), ("FOO".into(), "2".into())];
1295 assert_eq!(
1296 build_settings_from_values(values).unwrap_err().as_ref(),
1297 "Duplicate environment variable \"FOO\"."
1298 );
1299 }
1300
1301 #[test]
1302 fn builds_custom_agent() {
1303 let mut values = values();
1304 values.name = " my-agent ".into();
1305 values.command = "/usr/bin/agent".into();
1306 values.args = "--flag value".into();
1307 // Empty values are kept, but rows with a blank key are ignored.
1308 values.env = vec![
1309 ("KEY".into(), "VALUE".into()),
1310 ("EMPTY".into(), String::new()),
1311 (" ".into(), "ignored".into()),
1312 ];
1313
1314 let (id, original_id, content) = build_settings_from_values(values).unwrap();
1315 assert_eq!(id.0.as_ref(), "my-agent");
1316 assert_eq!(original_id, None);
1317
1318 let expected_env = HashMap::from_iter([
1319 ("KEY".to_string(), "VALUE".to_string()),
1320 ("EMPTY".to_string(), String::new()),
1321 ]);
1322 assert_eq!(
1323 content,
1324 CustomAgentServerSettings::Custom {
1325 path: "/usr/bin/agent".into(),
1326 args: vec!["--flag".into(), "value".into()],
1327 env: expected_env,
1328 default_mode: None,
1329 default_config_options: HashMap::default(),
1330 favorite_config_option_values: HashMap::default(),
1331 }
1332 );
1333 }
1334
1335 #[test]
1336 fn preserves_advanced_fields() {
1337 let mut values = values();
1338 values.default_mode = Some("ask".into());
1339 values.default_config_options =
1340 HashMap::from_iter([("opt".to_string(), AgentConfigOptionValue::from("val"))]);
1341
1342 let (_, _, content) = build_settings_from_values(values).unwrap();
1343 match content {
1344 CustomAgentServerSettings::Custom {
1345 default_mode,
1346 default_config_options,
1347 ..
1348 } => {
1349 assert_eq!(default_mode.as_deref(), Some("ask"));
1350 assert_eq!(
1351 default_config_options
1352 .get("opt")
1353 .and_then(AgentConfigOptionValue::as_value_id),
1354 Some("val"),
1355 );
1356 }
1357 _ => panic!("expected a custom agent"),
1358 }
1359 }
1360
1361 #[test]
1362 fn name_collision_covers_new_and_rename() {
1363 let existing = vec![id("foo"), id("bar")];
1364
1365 // New agent taking an existing name collides.
1366 assert!(name_collides_with_other_agent(&id("foo"), None, &existing));
1367 // New agent with a free name is fine.
1368 assert!(!name_collides_with_other_agent(&id("baz"), None, &existing));
1369 // Editing an agent in place is allowed even though the name "exists".
1370 assert!(!name_collides_with_other_agent(
1371 &id("foo"),
1372 Some(&id("foo")),
1373 &existing
1374 ));
1375 // Renaming onto a different agent's name collides.
1376 assert!(name_collides_with_other_agent(
1377 &id("bar"),
1378 Some(&id("foo")),
1379 &existing
1380 ));
1381 }
1382}
1383