settings_ui: Move skills management into settings UI (#58701)

Closes AI-344
Closes AI-342

Moves agent skills management out of the agent panel and into the
settings UI. The skill creator (previously its own crate) now lives in
`settings_ui` as a settings page, alongside the skills setup page. The
agent panel's ellipsis menu now links to the settings page instead of
hosting skill management directly.

Release Notes:

- Improved agent skills management by moving it into the settings UI

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
This commit is contained in:
MartinYe1234 2026-06-08 07:57:47 -07:00 committed by GitHub
parent 1d1a4bee34
commit 4eebf3fdbc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 793 additions and 786 deletions

32
Cargo.lock generated
View file

@ -519,7 +519,6 @@ dependencies = [
"serde_json",
"serde_json_lenient",
"settings",
"skill_creator",
"streaming_diff",
"task",
"telemetry",
@ -16551,6 +16550,7 @@ dependencies = [
"fuzzy",
"gpui",
"heck 0.5.0",
"http_client",
"itertools 0.14.0",
"language",
"log",
@ -16567,6 +16567,7 @@ dependencies = [
"search",
"serde",
"serde_json",
"serde_yaml_ng",
"settings",
"shell_command_parser",
"strum 0.27.2",
@ -16575,6 +16576,7 @@ dependencies = [
"theme_settings",
"title_bar",
"ui",
"ui_input",
"util",
"workspace",
"zed_actions",
@ -16849,33 +16851,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]]
name = "skill_creator"
version = "0.1.0"
dependencies = [
"agent_skills",
"anyhow",
"editor",
"fs",
"futures 0.3.32",
"gpui",
"http_client",
"language",
"menu",
"notifications",
"platform_title_bar",
"release_channel",
"serde_json",
"serde_yaml_ng",
"settings",
"theme_settings",
"ui",
"ui_input",
"util",
"workspace",
"worktree",
]
[[package]]
name = "skrifa"
version = "0.37.0"
@ -23090,7 +23065,6 @@ dependencies = [
"schemars 1.0.4",
"serde",
"util",
"uuid",
]
[[package]]

View file

@ -170,7 +170,6 @@ members = [
"crates/rope",
"crates/rpc",
"crates/sandbox",
"crates/skill_creator",
"crates/scheduler",
"crates/schema_generator",
"crates/search",
@ -428,7 +427,6 @@ reqwest_client = { path = "crates/reqwest_client" }
rodio = { git = "https://github.com/RustAudio/rodio", rev = "e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a", features = ["wav", "playback", "wav_output", "recording"] }
rope = { path = "crates/rope" }
rpc = { path = "crates/rpc" }
skill_creator = { path = "crates/skill_creator" }
scheduler = { path = "crates/scheduler" }
sandbox = { path = "crates/sandbox" }
search = { path = "crates/search" }

View file

@ -261,7 +261,7 @@
"ctrl-n": "agent::NewThread",
"ctrl--": "pane::GoBack",
"ctrl-alt-p": "agent::ManageProfiles",
"ctrl-alt-l": "agent::OpenRulesLibrary",
"ctrl-alt-l": "agent::ManageSkills",
"ctrl-i": "agent::ToggleProfileSelector",
"shift-tab": "agent::CycleModeSelector",
"ctrl-alt-/": "agent::ToggleModelSelector",

View file

@ -306,7 +306,7 @@
"bindings": {
"cmd-n": "agent::NewThread",
"ctrl--": "pane::GoBack",
"cmd-alt-l": "agent::OpenRulesLibrary",
"cmd-alt-l": "agent::ManageSkills",
"cmd-alt-p": "agent::ManageProfiles",
"cmd-i": "agent::ToggleProfileSelector",
"shift-tab": "agent::CycleModeSelector",

View file

@ -261,7 +261,7 @@
"bindings": {
"ctrl-n": "agent::NewThread",
"ctrl--": "pane::GoBack",
"shift-alt-l": "agent::OpenRulesLibrary",
"shift-alt-l": "agent::ManageSkills",
"shift-alt-p": "agent::ManageProfiles",
"ctrl-i": "agent::ToggleProfileSelector",
"shift-tab": "agent::CycleModeSelector",

View file

@ -2,9 +2,10 @@ use anyhow::{Context as _, Result};
use const_format::{concatcp, formatcp};
use fs::Fs;
use futures::StreamExt;
use gpui::{Global, SharedString};
use gpui::{App, Global, SharedString};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
use url::Url;
use util::paths::component_matches_ignore_ascii_case;
@ -195,6 +196,11 @@ pub struct ProjectSkillGroup {
impl Global for SkillIndex {}
/// Rescan skill agent skill directories when skills are created or modified via UI
pub struct SkillsUpdatedHook(pub Rc<dyn Fn(&mut App)>);
impl Global for SkillsUpdatedHook {}
/// Just the frontmatter, used for parsing
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillMetadata {
@ -204,7 +210,7 @@ pub struct SkillMetadata {
pub disable_model_invocation: bool,
}
/// Minimal skill info for system prompt (not full content).
/// Minimal skill info for system prompt.
///
/// `Serialize` is required for handlebars rendering of the system prompt
/// template (see `ProjectContext` in `prompt_store`). `PartialEq, Eq` lets

View file

@ -90,7 +90,6 @@ release_channel.workspace = true
remote.workspace = true
remote_connection.workspace = true
rope.workspace = true
skill_creator.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true

View file

@ -30,8 +30,8 @@ use zed_actions::{
ResolveConflictsWithAgent, ReviewBranchDiff,
},
assistant::{
CreateSkillFromUrl, FocusAgent, OpenGlobalAgentsMdRules, OpenProjectAgentsMdRules,
OpenRulesLibrary, OpenSkillCreator, Toggle, ToggleFocus,
FocusAgent, ManageSkills, OpenGlobalAgentsMdRules, OpenProjectAgentsMdRules, Toggle,
ToggleFocus,
},
};
@ -85,7 +85,7 @@ use notifications::status_toast::StatusToast;
use project::{Project, ProjectPath, Worktree};
use settings::TerminalDockPosition;
use settings::{NotifyWhenAgentWaiting, Settings, update_settings_file};
use skill_creator::{SkillCreatorOpenMode, is_supported_skill_url, open_skill_creator};
use terminal::{Event as TerminalEvent, terminal_settings::TerminalSettings};
use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
use text::OffsetRangeExt;
@ -422,12 +422,10 @@ pub fn init(cx: &mut App) {
});
}
})
.register_action(|workspace, action: &OpenRulesLibrary, window, cx| {
.register_action(|workspace, action: &ManageSkills, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.deploy_rules_library(action, window, cx)
});
panel.update(cx, |panel, cx| panel.manage_skills(action, window, cx));
}
})
.register_action(|workspace, _: &OpenGlobalAgentsMdRules, window, cx| {
@ -436,22 +434,6 @@ pub fn init(cx: &mut App) {
.register_action(|workspace, _: &OpenProjectAgentsMdRules, window, cx| {
open_project_rules(workspace, window, cx);
})
.register_action(|workspace, action: &OpenSkillCreator, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.deploy_skill_creator(action, window, cx)
});
}
})
.register_action(|workspace, action: &CreateSkillFromUrl, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.deploy_skill_creator_from_url(action, window, cx)
});
}
})
.register_action(|workspace, _: &Follow, window, cx| {
workspace.follow(CollaboratorId::Agent, window, cx);
})
@ -3405,90 +3387,46 @@ impl AgentPanel {
self.set_base_view(thread.into(), focus, window, cx);
}
fn deploy_rules_library(
fn manage_skills(
&mut self,
_action: &OpenRulesLibrary,
_action: &ManageSkills,
window: &mut Window,
cx: &mut Context<Self>,
) {
// The legacy Rules action is rerouted to the skill creator so the
// existing keyboard shortcut (still bound to `OpenRulesLibrary` in
// the default keymaps) and any persisted user keymap entries keep
// working.
self.deploy_skill_creator(&OpenSkillCreator, window, cx);
}
fn deploy_skill_creator(
&mut self,
_action: &OpenSkillCreator,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_skill_creator(SkillCreatorOpenMode::Form, cx);
}
fn deploy_skill_creator_from_url(
&mut self,
_action: &CreateSkillFromUrl,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let initial_url = cx
.read_from_clipboard()
.and_then(|clipboard| clipboard.text())
.map(|text| text.trim().to_string())
.filter(|text| is_supported_skill_url(text));
self.open_skill_creator(SkillCreatorOpenMode::Url { initial_url }, cx);
}
/// Open the skill creator pre-filled with a skill received from a
/// `zed://skill` share link, so the user can review it and choose a scope
/// before installing.
pub fn install_shared_skill(&mut self, content: String, cx: &mut Context<Self>) {
self.open_skill_creator(SkillCreatorOpenMode::Install { content }, cx);
}
fn open_skill_creator(&mut self, open_mode: SkillCreatorOpenMode, cx: &mut Context<Self>) {
let this = cx.weak_entity();
let on_saved: Rc<dyn Fn(&mut App)> = Rc::new(move |cx: &mut App| {
this.update(cx, |this, cx| {
if !this.has_open_project(cx) {
return;
}
this.ensure_native_agent_connection(cx);
let Some(connect_task) = this.connection_store.update(cx, |store, cx| {
store
.entry(&Agent::NativeAgent)
.map(|entry| entry.read(cx).wait_for_connection())
}) else {
return;
};
let project = this.project.clone();
cx.spawn(async move |_this, cx| -> Result<()> {
let connected = connect_task.await?;
if let Some(native_connection) = connected
.connection
.downcast::<agent::NativeAgentConnection>()
{
cx.update(|cx| native_connection.refresh_skills_for_project(project, cx));
}
Ok(())
})
.detach_and_log_err(cx);
})
.log_err();
});
open_skill_creator(
Some(self.workspace.clone()),
self.language_registry.clone(),
self.fs.clone(),
open_mode,
Some(on_saved),
window.dispatch_action(
Box::new(zed_actions::OpenSettingsAt {
path: zed_actions::AGENT_SKILLS_SETTINGS_PATH.to_string(),
target: None,
}),
cx,
)
);
}
/// Refresh the native agent's view of available skills
pub fn refresh_skills(&mut self, cx: &mut Context<Self>) {
if !self.has_open_project(cx) {
return;
}
self.ensure_native_agent_connection(cx);
let Some(connect_task) = self.connection_store.update(cx, |store, cx| {
store
.entry(&Agent::NativeAgent)
.map(|entry| entry.read(cx).wait_for_connection())
}) else {
return;
};
let project = self.project.clone();
cx.spawn(async move |_this, cx| -> Result<()> {
let connected = connect_task.await?;
if let Some(native_connection) = connected
.connection
.downcast::<agent::NativeAgentConnection>()
{
cx.update(|cx| native_connection.refresh_skills_for_project(project, cx));
}
Ok(())
})
.detach_and_log_err(cx);
}
@ -5595,7 +5533,7 @@ impl AgentPanel {
) -> impl IntoElement {
let focus_handle = self.focus_handle(cx);
// Resolve menu shortcuts at the thread root; the active editor can
// shadow panel-level commands such as OpenRulesLibrary.
// shadow panel-level commands such as ManageSkills.
let menu_action_context = match &self.base_view {
BaseView::AgentThread { conversation_view } => conversation_view
.read(cx)
@ -5692,28 +5630,10 @@ impl AgentPanel {
}),
)
.separator()
.header("Skills")
.entry(
"Create Skill…",
Some(Box::new(OpenRulesLibrary::default())),
|window, cx| {
window.dispatch_action(Box::new(OpenSkillCreator), cx);
},
)
.entry("Manage Skills…", None, |window, cx| {
window.dispatch_action(
Box::new(zed_actions::OpenSettingsAt {
path: "agent.skills".to_string(),
target: None,
}),
cx,
);
})
.separator();
.header("Context")
.action("Skills", Box::new(ManageSkills));
if project_agents_md_path.is_some() || global_agents_md_loaded {
menu = menu.header("Rules");
if global_agents_md_loaded {
let workspace = workspace.clone();
@ -6606,8 +6526,7 @@ impl Render for AgentPanel {
this.open_configuration(window, cx);
}))
.on_action(cx.listener(Self::open_active_thread_as_markdown))
.on_action(cx.listener(Self::deploy_rules_library))
.on_action(cx.listener(Self::deploy_skill_creator))
.on_action(cx.listener(Self::manage_skills))
.on_action(cx.listener(Self::go_back))
.on_action(cx.listener(Self::toggle_options_menu))
.on_action(cx.listener(Self::increase_font_size))
@ -9313,7 +9232,7 @@ mod tests {
}
#[gpui::test]
async fn test_skills_menu_entry_shows_rules_shortcut(cx: &mut TestAppContext) {
async fn test_skills_menu_entry_shows_manage_skills_shortcut(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure(
@ -9357,12 +9276,12 @@ mod tests {
cx.run_until_parked();
assert!(
cx.debug_bounds("MENU_ITEM-Create Skill…").is_some(),
"Create Skill… menu item should be visible"
cx.debug_bounds("MENU_ITEM-Skills").is_some(),
"Skills menu item should be visible"
);
assert!(
cx.debug_bounds("KEY_BINDING-l").is_some(),
"Create Skill… menu item should show the OpenRulesLibrary shortcut"
"Skills menu item should show the ManageSkills shortcut"
);
}

View file

@ -556,7 +556,26 @@ pub fn init(
) {
agent::ThreadStore::init_global(cx);
prompt_store::init(cx);
skill_creator::init(cx);
cx.set_global(agent_skills::SkillsUpdatedHook(std::rc::Rc::new(|cx| {
let workspaces: Vec<_> = workspace::AppState::global(cx)
.workspace_store
.read(cx)
.workspaces()
.cloned()
.collect();
for workspace in workspaces {
workspace
.update(cx, |workspace, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| panel.refresh_skills(cx));
}
})
.ok();
}
})));
if !is_eval {
// Initializing the language model from the user settings messes with the eval, so we only initialize them when
// we're not running inside of the eval.
@ -763,7 +782,7 @@ fn update_command_palette_filter(cx: &mut App) {
TypeId::of::<ToggleEditPrediction>(),
];
let open_rules_library_action = [TypeId::of::<zed_actions::assistant::OpenRulesLibrary>()];
let manage_skills_action = [TypeId::of::<zed_actions::assistant::ManageSkills>()];
let skill_creator_actions = [
TypeId::of::<zed_actions::assistant::OpenSkillCreator>(),
TypeId::of::<zed_actions::assistant::CreateSkillFromUrl>(),
@ -818,16 +837,15 @@ fn update_command_palette_filter(cx: &mut App) {
filter.show_namespace("multi_workspace");
}
// Hide `assistant: open rules library` — Rules are surfaced
// through the Skills UI now. Applied after the disable-ai /
// agent-enabled branches so it overrides the
// `show_namespace("assistant")` call above without affecting the
// rest of that namespace's actions.
// Hide `agent: manage skills` — skills are surfaced through the
// settings UI now. Applied after the disable-ai / agent-enabled
// branches so it overrides the `show_namespace("assistant")` call
// above without affecting the rest of that namespace's actions.
if !disable_ai {
filter.hide_action_types(&open_rules_library_action);
filter.hide_action_types(&manage_skills_action);
filter.show_action_types(skill_creator_actions.iter());
} else {
filter.show_action_types(open_rules_library_action.iter());
filter.show_action_types(manage_skills_action.iter());
filter.hide_action_types(&skill_creator_actions);
}
});

View file

@ -9879,7 +9879,8 @@ impl ThreadView {
.on_click(move |_, window, cx| {
window.dispatch_action(
Box::new(zed_actions::OpenSettingsAt {
path: "agent.skills".to_string(),
path: zed_actions::AGENT_SKILLS_SETTINGS_PATH
.to_string(),
target: target.clone(),
}),
cx,

View file

@ -36,6 +36,7 @@ futures.workspace = true
fuzzy.workspace = true
gpui.workspace = true
heck.workspace = true
http_client.workspace = true
itertools.workspace = true
language.workspace = true
log.workspace = true
@ -51,6 +52,7 @@ schemars.workspace = true
search.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml_ng.workspace = true
settings.workspace = true
shell_command_parser.workspace = true
strum.workspace = true
@ -58,6 +60,7 @@ telemetry.workspace = true
theme.workspace = true
theme_settings.workspace = true
ui.workspace = true
ui_input.workspace = true
util.workspace = true
workspace.workspace = true
zed_actions.workspace = true

View file

@ -7825,7 +7825,7 @@ fn ai_page(cx: &App) -> SettingsPage {
SettingsPageItem::SubPageLink(SubPageLink {
title: "Skills".into(),
r#type: Default::default(),
json_path: Some("agent.skills"),
json_path: Some(zed_actions::AGENT_SKILLS_SETTINGS_PATH),
description: Some("View and manage agent skills installed globally or in project worktrees.".into()),
in_json: false,
files: USER | PROJECT,

View file

@ -2,6 +2,7 @@ mod audio_input_output_setup;
mod audio_test_window;
mod edit_prediction_provider_setup;
mod feature_flags;
mod skill_creator;
mod skills_setup;
mod tool_permissions_setup;
@ -11,6 +12,10 @@ pub(crate) use audio_input_output_setup::{
pub(crate) use audio_test_window::open_audio_test_window;
pub(crate) use edit_prediction_provider_setup::render_edit_prediction_setup_page;
pub(crate) use feature_flags::render_feature_flags_page;
pub use skill_creator::SkillCreatorOpenMode;
pub(crate) use skill_creator::{
SkillCreatorEvent, SkillCreatorPage, render_skill_creator_page, skill_url_from_clipboard,
};
#[cfg(test)]
pub(crate) use skills_setup::displayed_skills;
pub(crate) use skills_setup::render_skills_setup_page;

View file

@ -1,12 +1,13 @@
use agent_skills::{MAX_SKILL_DESCRIPTION_LEN, Skill, SkillIndex, encode_skill_share_link};
use fs::RemoveOptions;
use gpui::{Action as _, App, ClipboardItem, ScrollHandle, SharedString, prelude::*};
use gpui::{App, ClipboardItem, ScrollHandle, SharedString, prelude::*};
use ui::{Divider, Tooltip, prelude::*};
use util::ResultExt as _;
use std::borrow::Cow;
use crate::pages::SkillCreatorOpenMode;
use crate::{SettingsUiFile, SettingsWindow};
/// Skills shown on the Skills page for the currently selected settings file:
@ -63,36 +64,26 @@ pub(crate) fn render_skills_setup_page(
_ => "No skills available for this context.",
};
let original_window = settings_window.original_window;
this.px_8().items_center().justify_center().child(
v_flex()
.items_center()
.gap_2()
.child(Label::new(message).color(Color::Muted))
.child(
Button::new("open-skill-creator", "Create a Skill")
Button::new("open-skill-creator-empty", "Create a Skill")
.tab_index(0_isize)
.style(ButtonStyle::Outlined)
.end_icon(
Icon::new(IconName::ArrowUpRight)
.start_icon(
Icon::new(IconName::Plus)
.size(IconSize::Small)
.color(Color::Muted),
)
.on_click(cx.listener(move |_this, _event, window, cx| {
let Some(original_window) = original_window else {
return;
};
original_window
.update(cx, |_workspace, original_window, cx| {
original_window.dispatch_action(
zed_actions::assistant::OpenSkillCreator
.boxed_clone(),
cx,
);
})
.log_err();
window.remove_window();
.on_click(cx.listener(move |this, _event, window, cx| {
this.open_skill_creator_sub_page(
SkillCreatorOpenMode::Form,
window,
cx,
);
})),
),
)

View file

@ -48,7 +48,10 @@ use workspace::{
AppState, MultiWorkspace, OpenOptions, OpenVisible, Workspace, WorkspaceSettings,
client_side_decorations,
};
use zed_actions::{OpenProjectSettings, OpenSettings, OpenSettingsAt, OpenSettingsAtTarget};
use zed_actions::{
AGENT_SKILLS_SETTINGS_PATH, OpenProjectSettings, OpenSettings, OpenSettingsAt,
OpenSettingsAtTarget,
};
use crate::components::{
EnumVariantDropdown, NumberField, NumberFieldMode, NumberFieldType, SettingsInputField,
@ -420,6 +423,13 @@ pub fn init(cx: &mut App) {
cx.on_action(|_: &OpenSettings, cx| {
open_settings_editor(None, None, None, cx);
});
cx.on_action(|_: &zed_actions::assistant::OpenSkillCreator, cx| {
open_skill_creator(pages::SkillCreatorOpenMode::Form, None, cx);
});
cx.on_action(|_: &zed_actions::assistant::CreateSkillFromUrl, cx| {
let initial_url = pages::skill_url_from_clipboard(cx);
open_skill_creator(pages::SkillCreatorOpenMode::Url { initial_url }, None, cx);
});
cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
workspace
@ -449,7 +459,24 @@ pub fn init(cx: &mut App) {
.then_some(tree.read(cx).id())
});
open_settings_editor(None, target_worktree_id, window_handle, cx);
});
})
.register_action(
|_, _: &zed_actions::assistant::OpenSkillCreator, window, cx| {
let window_handle = window.window_handle().downcast::<MultiWorkspace>();
open_skill_creator(pages::SkillCreatorOpenMode::Form, window_handle, cx);
},
)
.register_action(
|_, _: &zed_actions::assistant::CreateSkillFromUrl, window, cx| {
let window_handle = window.window_handle().downcast::<MultiWorkspace>();
let initial_url = pages::skill_url_from_clipboard(cx);
open_skill_creator(
pages::SkillCreatorOpenMode::Url { initial_url },
window_handle,
cx,
);
},
);
})
.detach();
}
@ -635,8 +662,6 @@ fn open_settings_editor_at_target(
workspace_handle: Option<WindowHandle<MultiWorkspace>>,
cx: &mut App,
) {
telemetry::event!("Settings Viewed");
fn select_target_file(
target_file: SettingsFileTarget,
settings_window: &mut SettingsWindow,
@ -699,6 +724,36 @@ fn open_settings_editor_at_target(
cx.notify();
}
let path = path.map(ToOwned::to_owned);
open_settings_editor_with(workspace_handle, cx, move |settings_window, window, cx| {
if let Some(target_file) = target_file {
select_target_file(target_file, settings_window, window, cx);
}
if let Some(path) = path {
open_path(&path, settings_window, window, cx);
} else if target_file.is_some() {
cx.notify();
}
});
}
pub fn open_skill_creator(
open_mode: pages::SkillCreatorOpenMode,
workspace_handle: Option<WindowHandle<MultiWorkspace>>,
cx: &mut App,
) {
open_settings_editor_with(workspace_handle, cx, |settings_window, window, cx| {
settings_window.navigate_to_skill_creator(open_mode, window, cx);
});
}
fn open_settings_editor_with(
workspace_handle: Option<WindowHandle<MultiWorkspace>>,
cx: &mut App,
callback: impl FnOnce(&mut SettingsWindow, &mut Window, &mut Context<SettingsWindow>) + 'static,
) {
telemetry::event!("Settings Viewed");
let existing_window = cx
.windows()
.into_iter()
@ -710,21 +765,13 @@ fn open_settings_editor_at_target(
settings_window.original_window = workspace_handle;
window.activate_window();
if let Some(target_file) = target_file {
select_target_file(target_file, settings_window, window, cx);
}
if let Some(path) = path {
open_path(path, settings_window, window, cx);
} else if target_file.is_some() {
cx.notify();
}
callback(settings_window, window, cx);
})
.ok();
return;
}
// We have to defer this to get the workspace off the stack.
let path = path.map(ToOwned::to_owned);
cx.defer(move |cx| {
let current_rem_size: f32 = theme_settings::ThemeSettings::get_global(cx)
.ui_font_size(cx)
@ -773,12 +820,7 @@ fn open_settings_editor_at_target(
let settings_window =
cx.new(|cx| SettingsWindow::new(workspace_handle, window, cx));
settings_window.update(cx, |settings_window, cx| {
if let Some(target_file) = target_file {
select_target_file(target_file, settings_window, window, cx);
}
if let Some(path) = path {
open_path(&path, settings_window, window, cx);
}
callback(settings_window, window, cx);
});
settings_window
@ -845,6 +887,7 @@ pub struct SettingsWindow {
/// Directory path of the skill whose share link was most recently copied,
/// used to show a transient "copied" checkmark on its share button.
pub(crate) last_copied_skill_directory_path: Option<PathBuf>,
skill_creator_page: Option<(Entity<pages::SkillCreatorPage>, Subscription)>,
}
struct SearchDocument {
@ -1472,6 +1515,7 @@ impl PartialEq for SettingItem {
#[derive(Clone, PartialEq, Default)]
enum SubPageType {
Language,
SkillCreator,
#[default]
Other,
}
@ -1819,6 +1863,7 @@ impl SettingsWindow {
list_state,
last_copied_link_path: None,
last_copied_skill_directory_path: None,
skill_creator_page: None,
};
this.fetch_files(window, cx);
@ -3513,6 +3558,8 @@ impl SettingsWindow {
let page_content;
if let Some(current_sub_page) = self.sub_page_stack.last() {
let is_skills_page =
current_sub_page.link.json_path == Some(AGENT_SKILLS_SETTINGS_PATH);
page_header = h_flex()
.w_full()
.min_w_0()
@ -3532,23 +3579,39 @@ impl SettingsWindow {
)
.child(self.render_sub_page_breadcrumbs(window, cx)),
)
.when(current_sub_page.link.in_json, |this| {
this.child(
div().flex_shrink_0().child(
Button::new("open-in-settings-file", "Edit in settings.json")
.tab_index(0_isize)
.style(ButtonStyle::OutlinedGhost)
.tooltip(Tooltip::for_action_title_in(
"Edit in settings.json",
&OpenCurrentFile,
&self.focus_handle,
))
.on_click(cx.listener(|this, _, window, cx| {
this.open_current_settings_file(window, cx);
})),
),
)
})
.child(
div()
.flex_shrink_0()
.when(current_sub_page.link.in_json, |this| {
this.child(
Button::new("open-in-settings-file", "Edit in settings.json")
.tab_index(0_isize)
.style(ButtonStyle::OutlinedGhost)
.tooltip(Tooltip::for_action_title_in(
"Edit in settings.json",
&OpenCurrentFile,
&self.focus_handle,
))
.on_click(cx.listener(|this, _, window, cx| {
this.open_current_settings_file(window, cx);
})),
)
})
.when(is_skills_page, |this| {
this.child(
Button::new("open-skill-creator", "Create Skill")
.tab_index(0_isize)
.style(ButtonStyle::OutlinedGhost)
.on_click(cx.listener(|this, _, window, cx| {
this.open_skill_creator_sub_page(
pages::SkillCreatorOpenMode::Form,
window,
cx,
);
})),
)
}),
)
.into_any_element();
let active_page_render_fn = &current_sub_page.link.render;
@ -3973,6 +4036,101 @@ impl SettingsWindow {
self.push_sub_page(sub_page_link, section_header.into(), window, cx);
}
pub(crate) fn skill_creator_page(&self) -> Option<Entity<pages::SkillCreatorPage>> {
self.skill_creator_page
.as_ref()
.map(|(page, _)| page.clone())
}
/// If the creator is already the active sub-page, the open mode is applied
/// to the existing form instead
pub fn open_skill_creator_sub_page(
&mut self,
open_mode: pages::SkillCreatorOpenMode,
window: &mut Window,
cx: &mut Context<SettingsWindow>,
) {
let creator_is_active_sub_page = self
.sub_page_stack
.last()
.is_some_and(|sub_page| sub_page.link.r#type == SubPageType::SkillCreator);
if creator_is_active_sub_page && let Some((page, _)) = &self.skill_creator_page {
let page = page.clone();
page.update(cx, |page, cx| page.apply_open_mode(open_mode, window, cx));
return;
}
let settings_window = cx.weak_entity();
let page = cx.new(|cx| pages::SkillCreatorPage::new(settings_window, window, cx));
let subscription =
cx.subscribe_in(
&page,
window,
|this, _page, event: &pages::SkillCreatorEvent, window, cx| match event {
pages::SkillCreatorEvent::Dismissed | pages::SkillCreatorEvent::Saved => {
if this.sub_page_stack.last().is_some_and(|sub_page| {
sub_page.link.r#type == SubPageType::SkillCreator
}) {
this.pop_sub_page(window, cx);
}
}
},
);
self.skill_creator_page = Some((page.clone(), subscription));
let sub_page_link = SubPageLink {
title: "Create Skill".into(),
r#type: SubPageType::SkillCreator,
description: None,
json_path: None,
in_json: false,
files: USER | PROJECT,
render: pages::render_skill_creator_page,
};
self.push_sub_page(sub_page_link, "Agent".into(), window, cx);
let creating_from_url = !matches!(open_mode, pages::SkillCreatorOpenMode::Url { .. });
page.update(cx, |page, cx| {
page.apply_open_mode(open_mode, window, cx);
});
if creating_from_url {
let name_editor_focus_handle = page.read(cx).name_editor_focus_handle(cx);
window.focus(&name_editor_focus_handle, cx);
}
}
pub fn navigate_to_skill_creator(
&mut self,
open_mode: pages::SkillCreatorOpenMode,
window: &mut Window,
cx: &mut Context<SettingsWindow>,
) {
self.sub_page_stack.clear();
let skills_page_index = self.pages.iter().position(|page| {
page.items.iter().any(|item| {
matches!(
item,
SettingsPageItem::SubPageLink(link)
if link.json_path == Some(AGENT_SKILLS_SETTINGS_PATH)
)
})
});
if let Some(page_index) = skills_page_index
&& let Some(navbar_entry_index) = self
.navbar_entries
.iter()
.position(|entry| entry.page_index == page_index && entry.is_root)
{
self.open_navbar_entry_page(navbar_entry_index);
}
self.navigate_to_sub_page(AGENT_SKILLS_SETTINGS_PATH, window, cx);
self.open_skill_creator_sub_page(open_mode, window, cx);
}
/// Navigate to a sub-page by its json_path.
/// Returns true if the sub-page was found and pushed, false otherwise.
pub fn navigate_to_sub_page(
@ -4046,7 +4204,11 @@ impl SettingsWindow {
fn pop_sub_page(&mut self, window: &mut Window, cx: &mut Context<SettingsWindow>) {
self.regex_validation_error = None;
self.sub_page_stack.pop();
if let Some(popped) = self.sub_page_stack.pop()
&& popped.link.r#type == SubPageType::SkillCreator
{
self.skill_creator_page = None;
}
self.content_focus_handle.focus_handle(cx).focus(window, cx);
cx.notify();
}
@ -4202,7 +4364,7 @@ impl Render for SettingsWindow {
}
}
fn all_projects(
pub(crate) fn all_projects(
window: Option<&WindowHandle<MultiWorkspace>>,
cx: &App,
) -> impl Iterator<Item = Entity<Project>> {
@ -4857,6 +5019,7 @@ pub mod test {
regex_validation_error: None,
last_copied_link_path: None,
last_copied_skill_directory_path: None,
skill_creator_page: None,
}
}
}
@ -4985,6 +5148,7 @@ pub mod test {
regex_validation_error: None,
last_copied_link_path: None,
last_copied_skill_directory_path: None,
skill_creator_page: None,
};
settings_window.build_filter_table();
@ -5739,7 +5903,7 @@ pub mod test {
assert_eq!(settings_window.current_file, SettingsUiFile::User);
assert!(
settings_window.navigate_to_sub_page("agent.skills", window, cx),
settings_window.navigate_to_sub_page(AGENT_SKILLS_SETTINGS_PATH, window, cx),
"Skills sub-page should exist"
);
assert_eq!(displayed_skill_names(settings_window, cx), ["global-skill"]);
@ -5778,6 +5942,184 @@ pub mod test {
assert_eq!(displayed_skill_names(settings_window, cx), ["global-skill"]);
});
}
#[gpui::test]
async fn test_open_skill_creator_navigates_to_sub_page(cx: &mut gpui::TestAppContext) {
use project::Project;
cx.update(|cx| {
register_settings(cx);
});
let app_state = cx.update(|cx| {
let app_state = AppState::test(cx);
AppState::set_global(app_state.clone(), cx);
app_state
});
app_state
.fs
.as_fake()
.insert_tree("/project", serde_json::json!({ "main.rs": "fn main() {}" }))
.await;
let project = cx.update(|cx| {
Project::local(
app_state.client.clone(),
app_state.node_runtime.clone(),
app_state.user_store.clone(),
app_state.languages.clone(),
app_state.fs.clone(),
None,
project::LocalProjectFlags::default(),
cx,
)
});
project
.update(cx, |project, cx| {
project.find_or_create_worktree("/project", true, cx)
})
.await
.expect("Failed to create worktree");
let (_multi_workspace, cx) = cx.add_window_view(|window, cx| {
let workspace = cx.new(|cx| {
Workspace::new(
Default::default(),
project.clone(),
app_state.clone(),
window,
cx,
)
});
MultiWorkspace::new(workspace, window, cx)
});
let workspace_handle = cx.window_handle().downcast::<MultiWorkspace>().unwrap();
cx.run_until_parked();
let (settings_window, cx) = cx
.add_window_view(|window, cx| SettingsWindow::new(Some(workspace_handle), window, cx));
cx.run_until_parked();
settings_window.update_in(cx, |settings_window, window, cx| {
settings_window.navigate_to_skill_creator(
pages::SkillCreatorOpenMode::Form,
window,
cx,
);
});
cx.run_until_parked();
settings_window.read_with(cx, |settings_window, _| {
let titles: Vec<_> = settings_window
.sub_page_stack
.iter()
.map(|sub_page| sub_page.link.title.to_string())
.collect();
assert_eq!(
titles,
["Skills", "Create Skill"],
"skill creator should be pushed on top of the skills page"
);
assert!(
settings_window.skill_creator_page().is_some(),
"skill creator page state should exist"
);
});
}
#[gpui::test]
async fn test_open_skill_creator_action_opens_settings_window_at_sub_page(
cx: &mut gpui::TestAppContext,
) {
use project::Project;
cx.update(|cx| {
register_settings(cx);
release_channel::init("0.0.0".parse().unwrap(), cx);
crate::init(cx);
});
let app_state = cx.update(|cx| {
let app_state = AppState::test(cx);
AppState::set_global(app_state.clone(), cx);
app_state
});
app_state
.fs
.as_fake()
.insert_tree("/project", serde_json::json!({ "main.rs": "fn main() {}" }))
.await;
let project = cx.update(|cx| {
Project::local(
app_state.client.clone(),
app_state.node_runtime.clone(),
app_state.user_store.clone(),
app_state.languages.clone(),
app_state.fs.clone(),
None,
project::LocalProjectFlags::default(),
cx,
)
});
project
.update(cx, |project, cx| {
project.find_or_create_worktree("/project", true, cx)
})
.await
.expect("Failed to create worktree");
let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
let workspace = cx.new(|cx| {
Workspace::new(
Default::default(),
project.clone(),
app_state.clone(),
window,
cx,
)
});
MultiWorkspace::new(workspace, window, cx)
});
cx.run_until_parked();
// Dispatch the action the way the command palette does: on the
// workspace window.
multi_workspace.update_in(cx, |_multi_workspace, window, cx| {
window.dispatch_action(Box::new(zed_actions::assistant::OpenSkillCreator), cx);
});
cx.run_until_parked();
let settings_window = cx
.update(|_, cx| {
cx.windows()
.into_iter()
.find_map(|window| window.downcast::<SettingsWindow>())
})
.expect("dispatching agent::OpenSkillCreator should open the settings window");
settings_window
.read_with(cx, |settings_window, _| {
let titles: Vec<_> = settings_window
.sub_page_stack
.iter()
.map(|sub_page| sub_page.link.title.to_string())
.collect();
assert_eq!(
titles,
["Skills", "Create Skill"],
"skill creator should be pushed on top of the skills page"
);
})
.unwrap();
}
}
#[cfg(test)]

View file

@ -1,39 +0,0 @@
[package]
name = "skill_creator"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/skill_creator.rs"
[dependencies]
agent_skills.workspace = true
anyhow.workspace = true
editor.workspace = true
fs.workspace = true
futures.workspace = true
gpui.workspace = true
http_client.workspace = true
language.workspace = true
menu.workspace = true
notifications.workspace = true
platform_title_bar.workspace = true
release_channel.workspace = true
serde_yaml_ng.workspace = true
settings.workspace = true
theme_settings.workspace = true
ui.workspace = true
ui_input.workspace = true
util.workspace = true
workspace.workspace = true
worktree.workspace = true
[dev-dependencies]
fs = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
serde_json.workspace = true

View file

@ -1 +0,0 @@
../../LICENSE-GPL

View file

@ -41,6 +41,9 @@ pub struct InputField {
tab_stop: bool,
/// Whether the field content is masked (for sensitive fields like passwords or API keys).
masked: Option<bool>,
/// An optional validation error. When set, the field's border turns red
/// and the message is shown as hint subtext below the field.
error: Option<SharedString>,
}
impl Focusable for InputField {
@ -67,6 +70,7 @@ impl InputField {
tab_index: None,
tab_stop: true,
masked: None,
error: None,
}
}
@ -106,6 +110,14 @@ impl InputField {
self
}
/// Sets a validation error message, turning the field's border red and
/// showing the message as hint subtext below the field. Pass `None` to
/// clear the error.
pub fn set_error(&mut self, error: Option<impl Into<SharedString>>, cx: &mut Context<Self>) {
self.error = error.map(Into::into);
cx.notify();
}
pub fn is_empty(&self, cx: &App) -> bool {
self.editor().text(cx).trim().is_empty()
}
@ -149,6 +161,9 @@ impl Render for InputField {
let focus_handle = self.editor.focus_handle(cx);
let has_error = self.error.is_some();
let error_border = cx.theme().status().error_border;
let configured_handle = if let Some(tab_index) = self.tab_index {
focus_handle.tab_index(tab_index).tab_stop(self.tab_stop)
} else if !self.tab_stop {
@ -186,6 +201,7 @@ impl Render for InputField {
editor.focus_handle(cx).contains_focused(window, cx),
|this| this.border_color(theme_color.border_focused),
)
.when(has_error, |this| this.border_color(error_border))
.when_some(self.start_icon, |this, icon| {
this.gap_1()
.child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
@ -216,6 +232,9 @@ impl Render for InputField {
)
}),
)
.when_some(self.error.clone(), |this, error| {
this.child(Label::new(error).size(LabelSize::Small).color(Color::Error))
})
}
}

View file

@ -1150,19 +1150,12 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
let multi_workspace =
workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?;
multi_workspace.update(cx, |multi_workspace, window, cx| {
multi_workspace.workspace().update(cx, |workspace, cx| {
if let Some(panel) = workspace.focus_panel::<AgentPanel>(window, cx) {
panel.update(cx, |panel, cx| {
panel.install_shared_skill(content, cx);
});
} else {
log::warn!(
"zed://skill received but the AgentPanel is not registered \
(is `disable_ai` enabled?)"
);
}
});
multi_workspace.update(cx, |_multi_workspace, _window, cx| {
settings_ui::open_skill_creator(
settings_ui::pages::SkillCreatorOpenMode::Install { content },
Some(multi_workspace),
cx,
);
})
})
.detach_and_log_err(cx);

View file

@ -13,4 +13,3 @@ gpui.workspace = true
schemars.workspace = true
serde.workspace = true
util.workspace = true
uuid.workspace = true

View file

@ -149,6 +149,9 @@ pub struct OpenSettingsAt {
pub target: Option<OpenSettingsAtTarget>,
}
/// `OpenSettingsAt` path of the agent skills page in the settings UI.
pub const AGENT_SKILLS_SETTINGS_PATH: &str = "agent.skills";
#[derive(PartialEq, Clone, Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum OpenSettingsAtTarget {
@ -577,7 +580,6 @@ pub mod assistant {
use gpui::{Action, actions};
use schemars::JsonSchema;
use serde::Deserialize;
use uuid::Uuid;
actions!(
agent,
@ -597,18 +599,12 @@ pub mod assistant {
/// Opens the project AGENTS.md rules file.
#[action(name = "OpenProjectAGENTS.mdRules")]
OpenProjectAgentsMdRules,
/// Opens the skills manager in the settings window.
#[action(deprecated_aliases = ["agent::OpenRulesLibrary", "assistant::OpenRulesLibrary", "assistant::DeployPromptLibrary"])]
ManageSkills,
]
);
/// Opens the rules library for managing agent rules and prompts.
#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
#[action(namespace = agent, deprecated_aliases = ["assistant::OpenRulesLibrary", "assistant::DeployPromptLibrary"])]
#[serde(deny_unknown_fields)]
pub struct OpenRulesLibrary {
#[serde(skip)]
pub prompt_to_select: Option<Uuid>,
}
/// Deploys the assistant interface with the specified configuration.
#[derive(Clone, Default, Deserialize, PartialEq, JsonSchema, Action)]
#[action(namespace = assistant)]

View file

@ -242,10 +242,17 @@ See [Feature-specific models](./ai/agent-settings.md#feature-specific-models) fo
}
```
To customize the format of generated commit messages, run {#action agent::OpenRulesLibrary} and select the "Commit message" rule on the left side.
From there, you can modify the prompt to match your desired format.
To add custom instructions that apply only to commit message generation, use the `commit_message_instructions` field in your agent settings:
Any specific instructions for commit messages added to [Rules files](./ai/rules.md) are also picked up by the model tasked with writing your commit message.
```json [settings]
{
"agent": {
"commit_message_instructions": "Use the Conventional Commits format: <type>(<scope>): <description>."
}
}
```
These instructions are sent to the model in addition to any instruction files, such as `.rules` or `AGENTS.md`. To add instructions that apply to both commit messages and the agent more broadly, use the global `AGENTS.md` file located `~/.config/zed/AGENTS.md` on macOS and Linux, `%APPDATA%\Zed\AGENTS.md` on Windows.
## Git Integrations {#git-integrations}

View file

@ -15,7 +15,7 @@ A skill is a folder containing a `SKILL.md` file with metadata and instructions.
Zed includes a built-in `create-skill` skill — invoke it with `/create-skill` and the agent walks you through the process.
You can also open the Skill Creator from the Agent Panel using {#kb agent::OpenRulesLibrary}, or by clicking `...` and selecting **Skills**. Outside the panel, use the {#action agent::OpenSkillCreator} action from the command palette. It opens a window where you fill in the skill's name, description, scope (global or project-local), body, and optionally toggle `disable-model-invocation`.
You can also open the Skills Manager from the Agent Panel using {#kb agent::ManageSkills}, or by clicking `...` and selecting **Skills**. Outside the panel, use the {#action agent::OpenSkillCreator} action from the command palette, or click **Create Skill** on the **AI > Skills** settings page. The creator opens as a page in the settings window where you fill in the skill's name, description, body, and optionally toggle `disable-model-invocation`. The skill is saved to the scope of the settings file selected in the settings window — the **User** tab creates a global skill, while a **Project** tab creates a project-local skill — and the form shows exactly where the file will be written.
Lastly, it's also possible to add a skill through importing it from an existing GitHub Markdown file. Open the command palette and look for the {#action agent::CreateSkillFromUrl} action. If your clipboard contains a supported GitHub `.md` URL, Zed pre-fills and fetches it automatically.
@ -35,7 +35,7 @@ To install a skill, copy the skill's folder into `~/.agents/skills/` for global
Open the Settings Editor (`Cmd+,` on macOS, `Ctrl+,` on Linux/Windows) and navigate to **AI > Skills**, or go directly to [agent.skills](zed://settings/agent.skills).
The **User** tab shows your global skills. The **Project** tab shows skills for the current project.
The **User** tab shows your global skills, and each **Project** tab shows the skills for that project.
For each skill you can:
@ -43,13 +43,16 @@ For each skill you can:
- **Open** — opens the skill's `SKILL.md` file in the editor
- **Delete** — removes the skill folder from disk
If no skills are installed, the page shows a **Create a Skill** button that opens the Skill Creator.
In the skills page, you'll see a **Create Skill** button that opens the settings window, providing the ability to create a skill directly through the UI.
## Sharing Skills {#sharing-skills}
You can hand a skill to a teammate without hosting it anywhere. In the Skills settings page, click the **link** icon on a skill row to copy a `zed://skill?data=…` link to your clipboard. The link is self-contained: it embeds the full `SKILL.md` contents (base64url-encoded), so the recipient doesn't need access to your project or any registry.
You can hand a skill to a teammate without hosting it anywhere. In the Skills settings page, click the **link** icon on a skill row to copy a `zed://skill?data=…` link to your clipboard.
The link is self-contained: it embeds the full `SKILL.md` contents (base64url-encoded), so the recipient doesn't need access to your project or any registry.
When someone opens that link (for example by pasting it into their browser or clicking it in a chat), Zed launches the Skill Creator pre-filled with the shared skill. The recipient can review the name, description, and full body, choose a scope (global or project-local), and click **Save** to install it. Nothing is written to disk until they explicitly save, so a shared link can never silently install instructions into someone's agent.
When someone opens that link (for example by pasting it into their browser or clicking it in a chat), Zed opens the "Create Skill" page in the settings window, pre-filled with the shared skill.
The recipient can review the name, description, and full body, choose a scope by selecting the **User** tab (global) or a **Project** tab, and click **Save** to install it.
Nothing is written to disk until they explicitly save, so a shared link can never silently install instructions into someone's agent.
## Using Skills {#using-skills}