Replace Rules UI with Skills creation UI (#56924)
Replaces the legacy Rules creation UI with a focused "New Skill"
creation window when the `SkillsFeatureFlag` is on.
The new `skills_library` crate provides a single-skill form with:
- Name, Description, and Body fields (placeholders double as labels)
- Live validation matching the SKILL.md spec (name: 1–64 lowercase +
digits + hyphens, no leading/trailing hyphen; description: non-empty,
≤1024 chars)
- A scope dropdown listing every local worktree in the originating
workspace plus "Global"
- An Optional Parameters card with the `disable-model-invocation`
checkbox
- Save writes `<scope>/.agents/skills/<name>/SKILL.md` via
`serde_yaml_ng` (YAML-safe escaping), refuses to overwrite existing
skills, shows a success toast in the originating workspace, then closes
the window
The "Rules" entry in the agent panel triple-dot menu is renamed to
"Skills" when the flag is on. The existing `cmd-alt-l` keyboard shortcut
still works: `AgentPanel::deploy_rules_library` reroutes to
`deploy_skills_library` when the flag is on, so any persisted keymaps
and the menu's automatic shortcut lookup keep functioning without
changes to the default keymap files.
The old `rules_library` crate is intentionally left in place for this PR
— once the flag rolls out and the few remaining `OpenRulesLibrary {
prompt_to_select }` call sites (in thread_view link handlers and
@-mention crease) are migrated to an "open existing skill" flow, a
follow-up can delete the crate entirely.
15 unit tests cover the validation rules, frontmatter formatting
(including YAML-special-character round-tripping), and disk write
behavior (creates directory, refuses overwrite).
Closes AI-247
Release Notes:
- Added a new Skills creation UI that replaces the old Rules library
when the Skills feature flag is enabled.
---------
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
This commit is contained in:
1 parent
d39dbc4e6c
commit
37c19c17bf
16 files changed
+1466
-112
No files matched your search
Generated
+25
@@ -438,6 +438,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_json_lenient",
|
||||
"settings",
|
||||
"skill_creator",
|
||||
"streaming_diff",
|
||||
"task",
|
||||
"telemetry",
|
||||
@@ -16532,6 +16533,30 @@ 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",
|
||||
"gpui",
|
||||
"language",
|
||||
"menu",
|
||||
"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"
|
||||
|
||||
@@ -172,6 +172,7 @@ members = [
|
||||
"crates/rope",
|
||||
"crates/rpc",
|
||||
"crates/rules_library",
|
||||
"crates/skill_creator",
|
||||
"crates/scheduler",
|
||||
"crates/schema_generator",
|
||||
"crates/search",
|
||||
@@ -432,6 +433,7 @@ rodio = { git = "https://github.com/RustAudio/rodio", rev = "e50e726ddd0292f6ef9
|
||||
rope = { path = "crates/rope" }
|
||||
rpc = { path = "crates/rpc" }
|
||||
rules_library = { path = "crates/rules_library" }
|
||||
skill_creator = { path = "crates/skill_creator" }
|
||||
scheduler = { path = "crates/scheduler" }
|
||||
search = { path = "crates/search" }
|
||||
session = { path = "crates/session" }
|
||||
|
||||
@@ -1558,4 +1558,20 @@
|
||||
"shift-tab": "git_graph::FocusPreviousTabStop",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "SkillCreator",
|
||||
"bindings": {
|
||||
"ctrl-w": "workspace::CloseWindow",
|
||||
"tab": "skill_creator::FocusNextField",
|
||||
"shift-tab": "skill_creator::FocusPreviousField",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "SkillCreator > Editor",
|
||||
"bindings": {
|
||||
"ctrl-w": "workspace::CloseWindow",
|
||||
"tab": "skill_creator::FocusNextField",
|
||||
"shift-tab": "skill_creator::FocusPreviousField",
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -1651,4 +1651,22 @@
|
||||
"shift-tab": "git_graph::FocusPreviousTabStop",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "SkillCreator",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-w": "workspace::CloseWindow",
|
||||
"tab": "skill_creator::FocusNextField",
|
||||
"shift-tab": "skill_creator::FocusPreviousField",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "SkillCreator > Editor",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-w": "workspace::CloseWindow",
|
||||
"tab": "skill_creator::FocusNextField",
|
||||
"shift-tab": "skill_creator::FocusPreviousField",
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -1577,4 +1577,22 @@
|
||||
"shift-tab": "git_graph::FocusPreviousTabStop",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "SkillCreator",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"ctrl-w": "workspace::CloseWindow",
|
||||
"tab": "skill_creator::FocusNextField",
|
||||
"shift-tab": "skill_creator::FocusPreviousField",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "SkillCreator > Editor",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"ctrl-w": "workspace::CloseWindow",
|
||||
"tab": "skill_creator::FocusNextField",
|
||||
"shift-tab": "skill_creator::FocusPreviousField",
|
||||
},
|
||||
},
|
||||
]
|
||||
+17
-3
@@ -943,7 +943,7 @@
|
||||
"space w j": "workspace::ActivatePaneDown",
|
||||
"space w k": "workspace::ActivatePaneUp",
|
||||
"space w l": "workspace::ActivatePaneRight",
|
||||
"space w q": "pane::CloseActiveItem",
|
||||
"space w q": "pane::CloseActiveItem",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1056,8 +1056,8 @@
|
||||
"ctrl-d": "git_graph::ScrollDown",
|
||||
"ctrl-u": "git_graph::ScrollUp",
|
||||
"shift-g": "menu::SelectLast",
|
||||
"g g": "menu::SelectFirst"
|
||||
}
|
||||
"g g": "menu::SelectFirst",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "GitPanel && ChangesList && !GitBranchSelector",
|
||||
@@ -1205,4 +1205,18 @@
|
||||
"enter": "editor::Newline",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "SkillCreator",
|
||||
"bindings": {
|
||||
"tab": "skill_creator::FocusNextField",
|
||||
"shift-tab": "skill_creator::FocusPreviousField",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "SkillCreator > Editor",
|
||||
"bindings": {
|
||||
"tab": "skill_creator::FocusNextField",
|
||||
"shift-tab": "skill_creator::FocusPreviousField",
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -1,5 +1,5 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use const_format::concatcp;
|
||||
use const_format::{concatcp, formatcp};
|
||||
use fs::Fs;
|
||||
use futures::StreamExt;
|
||||
use gpui::{Global, SharedString};
|
||||
@@ -251,8 +251,8 @@ pub fn parse_skill_frontmatter(
|
||||
|
||||
let (metadata, _body) = extract_frontmatter(content)?;
|
||||
|
||||
validate_name(&metadata.name)?;
|
||||
validate_description(&metadata.description)?;
|
||||
validate_name(&metadata.name).map_err(anyhow::Error::msg)?;
|
||||
validate_description(&metadata.description).map_err(anyhow::Error::msg)?;
|
||||
|
||||
let directory_path = skill_file_path
|
||||
.parent()
|
||||
@@ -346,6 +346,14 @@ fn extract_frontmatter(content: &str) -> Result<(SkillMetadata, &str)> {
|
||||
/// by [`validate_name`].
|
||||
pub const MAX_SKILL_NAME_LEN: usize = 64;
|
||||
|
||||
/// Maximum length (in bytes) for a valid skill description. Mirrors the
|
||||
/// upper bound enforced by [`validate_description`].
|
||||
///
|
||||
/// Byte-based rather than char-based because that's what `.len()` returns
|
||||
/// and what every caller currently measures; the UI also surfaces this
|
||||
/// limit as a byte count so the editor's counter matches the validator.
|
||||
pub const MAX_SKILL_DESCRIPTION_LEN: usize = 1024;
|
||||
|
||||
/// Convert an arbitrary human-readable string into a valid skill name, or
|
||||
/// return `None` if no valid name can be produced (e.g. the input contains
|
||||
/// no ASCII alphanumeric characters at all).
|
||||
@@ -410,34 +418,54 @@ pub fn slugify_skill_name(input: &str) -> Option<String> {
|
||||
if slug.is_empty() { None } else { Some(slug) }
|
||||
}
|
||||
|
||||
fn validate_name(name: &str) -> Result<()> {
|
||||
/// Validate a skill name against the rules enforced by both the loader
|
||||
/// and the create-skill UI.
|
||||
///
|
||||
/// Rules:
|
||||
/// * non-empty
|
||||
/// * at most [`MAX_SKILL_NAME_LEN`] bytes
|
||||
/// * ASCII lowercase letters, digits, and hyphens only
|
||||
/// * must not start or end with a hyphen — [`slugify_skill_name`]
|
||||
/// already guarantees this for its output, so requiring it in the
|
||||
/// validator keeps hand-written `SKILL.md` files consistent with
|
||||
/// slugifier output
|
||||
///
|
||||
/// Error messages are returned as `&'static str` (interpolated at
|
||||
/// compile time via `formatcp!`) so that UI surfaces can store them in
|
||||
/// `Option<&'static str>` fields without allocating, and loader callers
|
||||
/// can convert them to `anyhow::Error` via `anyhow::Error::msg`.
|
||||
pub fn validate_name(name: &str) -> Result<(), &'static str> {
|
||||
if name.is_empty() {
|
||||
anyhow::bail!("Skill name cannot be empty");
|
||||
return Err("Skill name cannot be empty");
|
||||
}
|
||||
|
||||
if name.len() > MAX_SKILL_NAME_LEN {
|
||||
anyhow::bail!("Skill name must be at most {MAX_SKILL_NAME_LEN} characters");
|
||||
return Err(formatcp!(
|
||||
"Skill name must be at most {MAX_SKILL_NAME_LEN} characters"
|
||||
));
|
||||
}
|
||||
if name.starts_with('-') || name.ends_with('-') {
|
||||
return Err("Skill name must not start or end with a hyphen");
|
||||
}
|
||||
|
||||
if !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
anyhow::bail!("Skill name must contain only lowercase letters, numbers, and hyphens");
|
||||
return Err("Skill name must contain only lowercase letters, numbers, and hyphens");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_description(description: &str) -> Result<()> {
|
||||
if description.is_empty() {
|
||||
anyhow::bail!("Skill description cannot be empty");
|
||||
/// Validate a skill description against the rules enforced by both the
|
||||
/// loader and the create-skill UI.
|
||||
pub fn validate_description(description: &str) -> Result<(), &'static str> {
|
||||
if description.trim().is_empty() {
|
||||
return Err("Skill description cannot be empty");
|
||||
}
|
||||
|
||||
if description.len() > 1024 {
|
||||
anyhow::bail!("Skill description must be at most 1024 characters");
|
||||
if description.len() > MAX_SKILL_DESCRIPTION_LEN {
|
||||
return Err(formatcp!(
|
||||
"Skill description must be at most {MAX_SKILL_DESCRIPTION_LEN} bytes"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -672,8 +700,8 @@ pub fn builtin_skills() -> Vec<Skill> {
|
||||
/// gets a synthetic `<built-in>` path since it doesn't live on disk.
|
||||
fn parse_builtin_skill(name: &str, content: &'static str) -> Result<Skill> {
|
||||
let (metadata, body) = extract_frontmatter(content)?;
|
||||
validate_name(&metadata.name)?;
|
||||
validate_description(&metadata.description)?;
|
||||
validate_name(&metadata.name).map_err(anyhow::Error::msg)?;
|
||||
validate_description(&metadata.description).map_err(anyhow::Error::msg)?;
|
||||
|
||||
let synthetic_dir = PathBuf::from(format!("<built-in>/{}", name));
|
||||
let synthetic_path = synthetic_dir.join(SKILL_FILE_NAME);
|
||||
@@ -1004,12 +1032,8 @@ Content.
|
||||
SkillSource::Global,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("at most 64 characters")
|
||||
);
|
||||
let expected = format!("at most {MAX_SKILL_NAME_LEN} characters");
|
||||
assert!(result.unwrap_err().to_string().contains(&expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1285,12 +1309,8 @@ Content.
|
||||
SkillSource::Global,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("at most 1024 characters")
|
||||
);
|
||||
let expected = format!("at most {MAX_SKILL_DESCRIPTION_LEN} bytes");
|
||||
assert!(result.unwrap_err().to_string().contains(&expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1891,4 +1911,89 @@ description: A skill with no body content
|
||||
"project/.AGENTS/SKILLS/foo"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_name_accepts_valid_names() {
|
||||
assert!(validate_name("draft-pr").is_ok());
|
||||
assert!(validate_name("a").is_ok());
|
||||
assert!(validate_name("skill1").is_ok());
|
||||
assert!(validate_name(&"a".repeat(MAX_SKILL_NAME_LEN)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_name_rejects_empty() {
|
||||
assert!(validate_name("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_name_rejects_uppercase() {
|
||||
assert!(validate_name("Draft-PR").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_name_rejects_leading_and_trailing_hyphens() {
|
||||
assert!(validate_name("-draft").is_err());
|
||||
assert!(validate_name("draft-").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_name_rejects_invalid_chars() {
|
||||
assert!(validate_name("draft_pr").is_err());
|
||||
assert!(validate_name("draft pr").is_err());
|
||||
assert!(validate_name("draft.pr").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_name_rejects_too_long() {
|
||||
assert!(validate_name(&"a".repeat(MAX_SKILL_NAME_LEN + 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_description_accepts_valid() {
|
||||
assert!(validate_description("A useful skill").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_description_rejects_empty_and_whitespace_only() {
|
||||
assert!(validate_description("").is_err());
|
||||
assert!(validate_description(" ").is_err());
|
||||
assert!(validate_description("\t\n ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_description_rejects_too_long() {
|
||||
assert!(validate_description(&"a".repeat(MAX_SKILL_DESCRIPTION_LEN + 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_description_length_is_measured_in_bytes() {
|
||||
// "é" is 2 bytes in UTF-8. A string of MAX/2 + 1 "é" characters has
|
||||
// only ~MAX/2 + 1 chars but exceeds MAX bytes, so it must be
|
||||
// rejected by a byte-based validator (and accepted by a char-based
|
||||
// one). This regression-tests the byte semantics that the loader
|
||||
// and UI both rely on.
|
||||
let chars = MAX_SKILL_DESCRIPTION_LEN / 2 + 1;
|
||||
let description = "é".repeat(chars);
|
||||
assert!(description.chars().count() <= MAX_SKILL_DESCRIPTION_LEN);
|
||||
assert!(description.len() > MAX_SKILL_DESCRIPTION_LEN);
|
||||
assert!(validate_description(&description).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_output_always_passes_validate_name() {
|
||||
for input in [
|
||||
"foo",
|
||||
"Foo Bar",
|
||||
"rock & roll",
|
||||
"---weird---",
|
||||
"a".repeat(200).as_str(),
|
||||
] {
|
||||
if let Some(slug) = slugify_skill_name(input) {
|
||||
assert!(
|
||||
validate_name(&slug).is_ok(),
|
||||
"slug {slug:?} from {input:?} failed validate_name"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,7 @@ remote.workspace = true
|
||||
remote_connection.workspace = true
|
||||
rope.workspace = true
|
||||
rules_library.workspace = true
|
||||
skill_creator.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -27,7 +27,7 @@ use zed_actions::{
|
||||
ResetAgentZoom, ResetOnboarding, ResolveConflictedFilesWithAgent,
|
||||
ResolveConflictsWithAgent, ReviewBranchDiff,
|
||||
},
|
||||
assistant::{FocusAgent, OpenRulesLibrary, Toggle, ToggleFocus},
|
||||
assistant::{FocusAgent, OpenRulesLibrary, OpenSkillCreator, Toggle, ToggleFocus},
|
||||
};
|
||||
|
||||
use crate::ExpandMessageEditor;
|
||||
@@ -38,7 +38,7 @@ use crate::terminal_thread_metadata_store::{TerminalThreadMetadata, TerminalThre
|
||||
use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore, ThreadMetadataStoreEvent};
|
||||
use crate::{
|
||||
AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow,
|
||||
InlineAssistant, LoadThreadFromClipboard, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff,
|
||||
LoadThreadFromClipboard, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff,
|
||||
ResetTrialEndUpsell, ResetTrialUpsell, ShowAllSidebarThreadMetadata, ShowThreadMetadata,
|
||||
ToggleNewThreadMenu, ToggleOptionsMenu,
|
||||
agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
|
||||
@@ -66,16 +66,16 @@ use fs::Fs;
|
||||
use gpui::{
|
||||
Action, Anchor, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem,
|
||||
Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels,
|
||||
PlatformDisplay, Subscription, Task, TaskExt, UpdateGlobal, WeakEntity, WindowHandle,
|
||||
prelude::*, pulsating_between,
|
||||
PlatformDisplay, Subscription, Task, TaskExt, WeakEntity, WindowHandle, prelude::*,
|
||||
pulsating_between,
|
||||
};
|
||||
use language::LanguageRegistry;
|
||||
use language_model::LanguageModelRegistry;
|
||||
use project::{Project, ProjectPath, Worktree};
|
||||
use prompt_store::{PromptStore, UserPromptId};
|
||||
use rules_library::{RulesLibrary, open_rules_library};
|
||||
use prompt_store::PromptStore;
|
||||
use settings::TerminalDockPosition;
|
||||
use settings::{NotifyWhenAgentWaiting, Settings, update_settings_file};
|
||||
use skill_creator::open_skill_creator;
|
||||
use terminal::{Event as TerminalEvent, terminal_settings::TerminalSettings};
|
||||
use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
|
||||
use theme_settings::ThemeSettings;
|
||||
@@ -300,6 +300,14 @@ pub fn init(cx: &mut App) {
|
||||
});
|
||||
}
|
||||
})
|
||||
.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, _: &Follow, window, cx| {
|
||||
workspace.follow(CollaboratorId::Agent, window, cx);
|
||||
})
|
||||
@@ -2980,16 +2988,27 @@ impl AgentPanel {
|
||||
|
||||
fn deploy_rules_library(
|
||||
&mut self,
|
||||
action: &OpenRulesLibrary,
|
||||
_action: &OpenRulesLibrary,
|
||||
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>,
|
||||
) {
|
||||
open_rules_library(
|
||||
open_skill_creator(
|
||||
Some(self.workspace.clone()),
|
||||
self.language_registry.clone(),
|
||||
Box::new(PromptLibraryInlineAssist::new(self.workspace.clone())),
|
||||
action
|
||||
.prompt_to_select
|
||||
.map(|uuid| UserPromptId(uuid).into()),
|
||||
self.fs.clone(),
|
||||
cx,
|
||||
)
|
||||
.detach_and_log_err(cx);
|
||||
@@ -4670,6 +4689,16 @@ impl AgentPanel {
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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.
|
||||
let menu_action_context = match &self.base_view {
|
||||
BaseView::AgentThread { conversation_view } => conversation_view
|
||||
.read(cx)
|
||||
.active_thread()
|
||||
.map(|thread| thread.read(cx).focus_handle.clone())
|
||||
.unwrap_or_else(|| focus_handle.clone()),
|
||||
_ => focus_handle.clone(),
|
||||
};
|
||||
let showing_terminal = matches!(self.visible_surface(), VisibleSurface::Terminal(_));
|
||||
|
||||
let conversation_view = match &self.base_view {
|
||||
@@ -4698,16 +4727,13 @@ impl AgentPanel {
|
||||
.trigger_with_tooltip(
|
||||
IconButton::new("agent-options-menu", IconName::Ellipsis)
|
||||
.icon_size(IconSize::Small),
|
||||
{
|
||||
let focus_handle = focus_handle.clone();
|
||||
move |_window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Toggle Agent Menu",
|
||||
&ToggleOptionsMenu,
|
||||
&focus_handle,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
move |_window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Toggle Agent Menu",
|
||||
&ToggleOptionsMenu,
|
||||
&focus_handle,
|
||||
cx,
|
||||
)
|
||||
},
|
||||
)
|
||||
.anchor(Anchor::TopRight)
|
||||
@@ -4715,7 +4741,7 @@ impl AgentPanel {
|
||||
.menu({
|
||||
move |window, cx| {
|
||||
Some(ContextMenu::build(window, cx, |mut menu, _window, _| {
|
||||
menu = menu.context(focus_handle.clone());
|
||||
menu = menu.context(menu_action_context.clone());
|
||||
|
||||
if can_regenerate_thread_title {
|
||||
menu = menu.header("Current Thread");
|
||||
@@ -4750,6 +4776,14 @@ impl AgentPanel {
|
||||
.action("Add Custom Server…", Box::new(AddContextServer))
|
||||
.separator();
|
||||
|
||||
menu = menu.entry(
|
||||
"Skills",
|
||||
Some(Box::new(OpenRulesLibrary::default())),
|
||||
|window, cx| {
|
||||
window.dispatch_action(Box::new(OpenSkillCreator), cx);
|
||||
},
|
||||
);
|
||||
|
||||
menu = menu.action("Profiles", Box::new(ManageProfiles::default()));
|
||||
}
|
||||
|
||||
@@ -5575,6 +5609,7 @@ impl Render for AgentPanel {
|
||||
}))
|
||||
.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::go_back))
|
||||
.on_action(cx.listener(Self::toggle_options_menu))
|
||||
.on_action(cx.listener(Self::increase_font_size))
|
||||
@@ -5626,57 +5661,6 @@ impl Render for AgentPanel {
|
||||
}
|
||||
}
|
||||
|
||||
struct PromptLibraryInlineAssist {
|
||||
workspace: WeakEntity<Workspace>,
|
||||
}
|
||||
|
||||
impl PromptLibraryInlineAssist {
|
||||
pub fn new(workspace: WeakEntity<Workspace>) -> Self {
|
||||
Self { workspace }
|
||||
}
|
||||
}
|
||||
|
||||
impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist {
|
||||
fn assist(
|
||||
&self,
|
||||
prompt_editor: &Entity<Editor>,
|
||||
initial_prompt: Option<String>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<RulesLibrary>,
|
||||
) {
|
||||
InlineAssistant::update_global(cx, |assistant, cx| {
|
||||
let Some(workspace) = self.workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
|
||||
return;
|
||||
};
|
||||
let project = workspace.read(cx).project().downgrade();
|
||||
let panel = panel.read(cx);
|
||||
let thread_store = panel.thread_store().clone();
|
||||
assistant.assist(
|
||||
prompt_editor,
|
||||
self.workspace.clone(),
|
||||
project,
|
||||
thread_store,
|
||||
None,
|
||||
initial_prompt,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
fn focus_agent_panel(
|
||||
&self,
|
||||
workspace: &mut Workspace,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) -> bool {
|
||||
workspace.focus_panel::<AgentPanel>(window, cx).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingUpsell;
|
||||
|
||||
impl Dismissable for OnboardingUpsell {
|
||||
@@ -5958,7 +5942,7 @@ mod tests {
|
||||
use anyhow::{Result, anyhow};
|
||||
use feature_flags::FeatureFlagAppExt;
|
||||
use fs::FakeFs;
|
||||
use gpui::{App, TestAppContext, VisualTestContext};
|
||||
use gpui::{App, TestAppContext, UpdateGlobal, VisualTestContext};
|
||||
use parking_lot::Mutex;
|
||||
use project::{Project, WorktreePaths};
|
||||
use std::any::Any;
|
||||
@@ -8030,6 +8014,60 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_skills_menu_entry_shows_rules_shortcut(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
cx.update(|cx| {
|
||||
let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure(
|
||||
"keymaps/default-macos.json",
|
||||
cx,
|
||||
)
|
||||
.unwrap();
|
||||
cx.bind_keys(default_key_bindings);
|
||||
agent::ThreadStore::init_global(cx);
|
||||
language_model::LanguageModelRegistry::test(cx);
|
||||
});
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
|
||||
fs.insert_tree("/project", json!({ "file.txt": "" })).await;
|
||||
let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
|
||||
|
||||
let multi_workspace =
|
||||
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
||||
let workspace = multi_workspace
|
||||
.read_with(cx, |multi_workspace, _cx| {
|
||||
multi_workspace.workspace().clone()
|
||||
})
|
||||
.unwrap();
|
||||
let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
|
||||
let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
open_thread_with_connection(&panel, StubAgentConnection::new(), &mut cx);
|
||||
workspace.update_in(&mut cx, |workspace, window, cx| {
|
||||
workspace.focus_panel::<AgentPanel>(window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
panel.update_in(&mut cx, |panel, window, cx| {
|
||||
panel.toggle_options_menu(&ToggleOptionsMenu, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
assert!(
|
||||
cx.debug_bounds("MENU_ITEM-Skills").is_some(),
|
||||
"Skills menu item should be visible"
|
||||
);
|
||||
assert!(
|
||||
cx.debug_bounds("KEY_BINDING-l").is_some(),
|
||||
"Skills menu item should show the OpenRulesLibrary shortcut"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_terminal_close_event_closes_without_sidebar(cx: &mut TestAppContext) {
|
||||
let (panel, mut cx) = setup_panel(cx).await;
|
||||
|
||||
@@ -506,6 +506,7 @@ pub fn init(
|
||||
) {
|
||||
agent::ThreadStore::init_global(cx);
|
||||
rules_library::init(cx);
|
||||
skill_creator::init(cx);
|
||||
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.
|
||||
@@ -650,6 +651,7 @@ fn update_command_palette_filter(cx: &mut App) {
|
||||
];
|
||||
|
||||
let open_rules_library_action = [TypeId::of::<zed_actions::assistant::OpenRulesLibrary>()];
|
||||
let open_skill_creator_action = [TypeId::of::<zed_actions::assistant::OpenSkillCreator>()];
|
||||
|
||||
if disable_ai {
|
||||
filter.hide_namespace("agent");
|
||||
@@ -707,8 +709,10 @@ fn update_command_palette_filter(cx: &mut App) {
|
||||
// rest of that namespace's actions.
|
||||
if !disable_ai {
|
||||
filter.hide_action_types(&open_rules_library_action);
|
||||
filter.show_action_types(open_skill_creator_action.iter());
|
||||
} else {
|
||||
filter.show_action_types(open_rules_library_action.iter());
|
||||
filter.hide_action_types(&open_skill_creator_action);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ pub(crate) fn render_skills_setup_page(
|
||||
.gap_2()
|
||||
.child(Label::new(message).color(Color::Muted))
|
||||
.child(
|
||||
Button::new("open-agent-panel", "Create with Agent")
|
||||
Button::new("open-skill-creator", "Create a Skill")
|
||||
.tab_index(0_isize)
|
||||
.style(ButtonStyle::Outlined)
|
||||
.end_icon(
|
||||
@@ -71,10 +71,10 @@ pub(crate) fn render_skills_setup_page(
|
||||
original_window
|
||||
.update(cx, |_workspace, original_window, cx| {
|
||||
original_window.dispatch_action(
|
||||
zed_actions::assistant::ToggleFocus.boxed_clone(),
|
||||
zed_actions::assistant::OpenSkillCreator
|
||||
.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
original_window.activate_window();
|
||||
})
|
||||
.log_err();
|
||||
window.remove_window();
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
[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
|
||||
gpui.workspace = true
|
||||
language.workspace = true
|
||||
menu.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
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5253,6 +5253,7 @@ mod tests {
|
||||
"search",
|
||||
"settings_editor",
|
||||
"settings_profile_selector",
|
||||
"skill_creator",
|
||||
"snippets",
|
||||
"stash_picker",
|
||||
"svg",
|
||||
|
||||
@@ -570,6 +570,8 @@ pub mod assistant {
|
||||
#[action(deprecated_aliases = ["assistant::ToggleFocus"])]
|
||||
ToggleFocus,
|
||||
FocusAgent,
|
||||
/// Opens the skill creator window for creating a new skill.
|
||||
OpenSkillCreator,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user