Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:47:02.147Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

external_agents_page.rs

1383 lines · 48.5 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.