From 5346b73d6d15a0aaf352517163fdb970d28a018d Mon Sep 17 00:00:00 2001 From: leo <450019458@qq.com> Date: Mon, 11 May 2026 16:18:59 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(skill-manager):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=20Skill=20=E7=AE=A1=E7=90=86=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Warp --- app/i18n/en/warp.ftl | 9 + app/i18n/zh-CN/warp.ftl | 9 + app/src/ai/agent_sdk/driver.rs | 4 +- app/src/ai/skills/dummy_skill_manager.rs | 38 +- app/src/ai/skills/listed_skill.rs | 2 +- app/src/ai/skills/mod.rs | 9 +- app/src/ai/skills/skill_manager.rs | 99 +++- app/src/ai/skills/skill_manager_tests.rs | 91 +++ app/src/ai/skills/skill_utils.rs | 14 +- app/src/ai/skills/skill_utils_tests.rs | 30 +- app/src/ai/skills/telemetry.rs | 4 +- app/src/app_state.rs | 2 + app/src/code/view.rs | 14 +- app/src/lib.rs | 1 + app/src/skill_manager/mod.rs | 3 + app/src/skill_manager/panel.rs | 556 ++++++++++++++++++ .../view_components/markdown_toggle_view.rs | 8 +- app/src/workspace/action.rs | 3 + app/src/workspace/action_tests.rs | 5 + app/src/workspace/mod.rs | 26 +- app/src/workspace/view.rs | 32 + app/src/workspace/view/left_panel.rs | 75 ++- crates/ai/src/skills/conversion.rs | 5 +- crates/ai/src/skills/parse_skill.rs | 7 +- crates/ai/src/skills/skill_provider.rs | 28 +- 25 files changed, 988 insertions(+), 86 deletions(-) create mode 100644 app/src/skill_manager/mod.rs create mode 100644 app/src/skill_manager/panel.rs diff --git a/app/i18n/en/warp.ftl b/app/i18n/en/warp.ftl index 0219ef1582..266e533d48 100644 --- a/app/i18n/en/warp.ftl +++ b/app/i18n/en/warp.ftl @@ -320,6 +320,8 @@ code-replace-all = Replace all code-goto-line-placeholder = Line number:Column code-open-file-unavailable-remote-tooltip = Opening files is unavailable for remote sessions code-view-markdown-preview = View Markdown preview +markdown-display-mode-rendered = Rendered +markdown-display-mode-raw = Raw code-review-commit-and-create-pr = Commit and create PR notebook-link-text-placeholder = Text notebook-link-url-placeholder = Link (web or file) @@ -1798,6 +1800,7 @@ keybinding-desc-workspace-left-panel-project-explorer = Left Panel: Project expl keybinding-desc-workspace-left-panel-global-search = Left Panel: Global search keybinding-desc-workspace-left-panel-warp-drive = Left Panel: Warp Drive keybinding-desc-workspace-left-panel-ssh-manager = Left Panel: SSH Manager +keybinding-desc-workspace-left-panel-skill-manager = Left Panel: Skill Manager keybinding-desc-workspace-open-global-search = Open global search keybinding-desc-workspace-open-global-search-menu = Global Search keybinding-desc-workspace-toggle-warp-drive = Toggle Warp Drive @@ -2843,6 +2846,12 @@ workspace-left-panel-global-search = Global search workspace-left-panel-warp-drive = Warp Drive workspace-left-panel-agent-conversations = Agent conversations workspace-left-panel-ssh-manager = SSH Manager +workspace-left-panel-skill-manager = Skill Manager +skill-manager-search-placeholder = Search skills +skill-manager-filter-all = All +skill-manager-filter-provider = Source +skill-manager-empty = No skills match the current filters. +skill-manager-preview-empty = Select a skill to preview SKILL.md. workspace-left-panel-ssh-manager-placeholder = SSH Manager — coming soon workspace-left-panel-ssh-manager-detail-empty = Select a server to see its details. workspace-left-panel-ssh-manager-detail-host = Host diff --git a/app/i18n/zh-CN/warp.ftl b/app/i18n/zh-CN/warp.ftl index 0296ebcd27..a6dcc8c22b 100644 --- a/app/i18n/zh-CN/warp.ftl +++ b/app/i18n/zh-CN/warp.ftl @@ -312,6 +312,8 @@ code-replace-all = 全部替换 code-goto-line-placeholder = 行号:列号 code-open-file-unavailable-remote-tooltip = 远程会话无法打开文件 code-view-markdown-preview = 查看 Markdown 预览 +markdown-display-mode-rendered = 预览 +markdown-display-mode-raw = 源码 code-review-commit-and-create-pr = 提交并创建 PR notebook-link-text-placeholder = 文本 notebook-link-url-placeholder = 链接(网页或文件) @@ -1718,6 +1720,7 @@ keybinding-desc-workspace-left-panel-project-explorer = 左侧面板:项目浏 keybinding-desc-workspace-left-panel-global-search = 左侧面板:全局搜索 keybinding-desc-workspace-left-panel-warp-drive = 左侧面板:Warp Drive keybinding-desc-workspace-left-panel-ssh-manager = 左侧面板:SSH 管理器 +keybinding-desc-workspace-left-panel-skill-manager = 左侧面板:Skill 管理器 keybinding-desc-workspace-open-global-search = 打开全局搜索 keybinding-desc-workspace-open-global-search-menu = 全局搜索 keybinding-desc-workspace-toggle-warp-drive = 切换 Warp Drive @@ -2749,6 +2752,12 @@ workspace-left-panel-global-search = 全局搜索 workspace-left-panel-warp-drive = Warp Drive workspace-left-panel-agent-conversations = 智能体对话 workspace-left-panel-ssh-manager = SSH 管理器 +workspace-left-panel-skill-manager = Skill 管理器 +skill-manager-search-placeholder = 搜索 skill +skill-manager-filter-all = 全部 +skill-manager-filter-provider = 来源 +skill-manager-empty = 当前过滤条件下没有 skill。 +skill-manager-preview-empty = 选择一个 skill 预览 SKILL.md。 workspace-left-panel-ssh-manager-placeholder = SSH 管理器 — 功能开发中 workspace-left-panel-ssh-manager-detail-empty = 选择左侧的服务器以查看详情。 workspace-left-panel-ssh-manager-detail-host = 主机 diff --git a/app/src/ai/agent_sdk/driver.rs b/app/src/ai/agent_sdk/driver.rs index 0e793c114e..4c5509b251 100644 --- a/app/src/ai/agent_sdk/driver.rs +++ b/app/src/ai/agent_sdk/driver.rs @@ -1365,11 +1365,11 @@ impl AgentDriver { skills.len() ); } - SkillManager::handle(ctx).update(ctx, |manager, _| { + SkillManager::handle(ctx).update(ctx, |manager, ctx| { // All repo skills should be in scope regardless of cwd when // a cloud environment is configured. manager.set_cloud_environment(true); - manager.handle_skills_added(skills); + manager.handle_skills_added(skills, ctx); }); }) .await; diff --git a/app/src/ai/skills/dummy_skill_manager.rs b/app/src/ai/skills/dummy_skill_manager.rs index f5e3cde6ea..2a469b1b3d 100644 --- a/app/src/ai/skills/dummy_skill_manager.rs +++ b/app/src/ai/skills/dummy_skill_manager.rs @@ -1,10 +1,38 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; -use ai::skills::{ParsedSkill, SkillProvider, SkillReference}; +use ai::skills::{ParsedSkill, SkillProvider, SkillReference, SkillScope}; use warpui::{AppContext, Entity, ModelContext, SingletonEntity}; use crate::ai::skills::SkillDescriptor; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SkillManagerEvent { + InventoryChanged, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillInventoryDuplicate { + pub path: PathBuf, + pub name: String, + pub description: String, + pub content: String, + pub provider: SkillProvider, + pub scope: SkillScope, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillInventoryItem { + pub name: String, + pub default_skill: SkillInventoryDuplicate, + pub duplicates: Vec, +} + +impl SkillInventoryItem { + pub fn has_duplicates(&self) -> bool { + self.duplicates.len() > 1 + } +} + pub struct SkillManager {} impl SkillManager { @@ -24,6 +52,10 @@ impl SkillManager { None } + pub fn list_skill_inventory(&self, _ctx: &AppContext) -> Vec { + vec![] + } + pub fn reference_for_skill_path(&self, skill_path: &Path) -> SkillReference { SkillReference::Path(skill_path.to_path_buf()) } @@ -54,7 +86,7 @@ impl SkillManager { } impl Entity for SkillManager { - type Event = (); + type Event = SkillManagerEvent; } impl SingletonEntity for SkillManager {} diff --git a/app/src/ai/skills/listed_skill.rs b/app/src/ai/skills/listed_skill.rs index eeba1eebd6..3d20c2a981 100644 --- a/app/src/ai/skills/listed_skill.rs +++ b/app/src/ai/skills/listed_skill.rs @@ -7,7 +7,7 @@ pub struct SkillDescriptor { pub reference: SkillReference, pub name: String, pub description: String, - /// The scope of the skill (home directory vs project directory). + /// The scope of the skill. pub scope: SkillScope, /// The provider/origin of the skill (Claude, Codex, or Warp). /// None if the skill path didn't match a known provider directory. diff --git a/app/src/ai/skills/mod.rs b/app/src/ai/skills/mod.rs index b7a5ddaa98..8421d72ca2 100644 --- a/app/src/ai/skills/mod.rs +++ b/app/src/ai/skills/mod.rs @@ -4,7 +4,9 @@ pub use telemetry::{SkillOpenOrigin, SkillTelemetryEvent}; cfg_if::cfg_if! { if #[cfg(not(feature = "local_fs"))] { mod dummy_skill_manager; - pub use dummy_skill_manager::SkillManager; + pub use dummy_skill_manager::{ + SkillInventoryDuplicate, SkillInventoryItem, SkillManager, SkillManagerEvent, + }; } } @@ -28,6 +30,9 @@ pub use resolve_skill_spec::{ cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { mod skill_manager; - pub use skill_manager::{SkillManager, SkillWatcher}; + pub use skill_manager::{ + SkillInventoryDuplicate, SkillInventoryItem, SkillManager, SkillManagerEvent, + SkillWatcher, + }; } } diff --git a/app/src/ai/skills/skill_manager.rs b/app/src/ai/skills/skill_manager.rs index 16bbc1f73c..22c69bd52e 100644 --- a/app/src/ai/skills/skill_manager.rs +++ b/app/src/ai/skills/skill_manager.rs @@ -21,6 +21,34 @@ use warp_core::{ }; use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity}; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SkillManagerEvent { + InventoryChanged, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillInventoryDuplicate { + pub path: PathBuf, + pub name: String, + pub description: String, + pub content: String, + pub provider: SkillProvider, + pub scope: ai::skills::SkillScope, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillInventoryItem { + pub name: String, + pub default_skill: SkillInventoryDuplicate, + pub duplicates: Vec, +} + +impl SkillInventoryItem { + pub fn has_duplicates(&self) -> bool { + self.duplicates.len() > 1 + } +} + /// Activation condition for a bundled skill. #[derive(Debug, Clone)] pub enum BundledSkillActivation { @@ -86,8 +114,8 @@ impl SkillManager { ctx.spawn_stream_local( skill_watcher_rx, - |me, message, _ctx| { - me.handle_skill_watcher_event(message); + |me, message, ctx| { + me.handle_skill_watcher_event(message, ctx); }, |_, _| {}, // No cleanup needed when stream ends ); @@ -342,18 +370,65 @@ impl SkillManager { bundled.activation.is_enabled(ctx).then_some(&bundled.skill) } - fn handle_skill_watcher_event(&mut self, event: SkillWatcherEvent) { + pub fn list_skill_inventory(&self, _ctx: &AppContext) -> Vec { + let mut by_name: HashMap> = HashMap::new(); + + for skill in self.skills_by_path.values() { + by_name + .entry(skill.name.clone()) + .or_default() + .push(SkillInventoryDuplicate { + path: skill.path.clone(), + name: skill.name.clone(), + description: skill.description.clone(), + content: skill.content.clone(), + provider: skill.provider, + scope: skill.scope, + }); + } + + let mut items = by_name + .into_iter() + .filter_map(|(name, mut duplicates)| { + duplicates.sort_by(|a, b| { + provider_rank(a.provider) + .cmp(&provider_rank(b.provider)) + .then_with(|| format!("{:?}", a.scope).cmp(&format!("{:?}", b.scope))) + .then_with(|| a.path.cmp(&b.path)) + }); + let default_skill = duplicates.first()?.clone(); + Some(SkillInventoryItem { + name, + default_skill, + duplicates, + }) + }) + .collect::>(); + + items.sort_by(|a, b| a.name.cmp(&b.name)); + items + } + + fn handle_skill_watcher_event( + &mut self, + event: SkillWatcherEvent, + ctx: &mut ModelContext, + ) { match event { SkillWatcherEvent::SkillsAdded { skills } => { - self.handle_skills_added(skills); + self.handle_skills_added(skills, ctx); } SkillWatcherEvent::SkillsDeleted { paths } => { - self.handle_skills_deleted(paths); + self.handle_skills_deleted(paths, ctx); } } } - pub fn handle_skills_added(&mut self, skills: Vec) { + pub fn handle_skills_added(&mut self, skills: Vec, ctx: &mut ModelContext) { + if skills.is_empty() { + return; + } + for skill in skills { if let Ok(parent_dir) = extract_skill_parent_directory(&skill.path) { self.directory_skills @@ -373,12 +448,20 @@ impl SkillManager { ); } } + + ctx.emit(SkillManagerEvent::InventoryChanged); } - fn handle_skills_deleted(&mut self, paths: Vec) { + fn handle_skills_deleted(&mut self, paths: Vec, ctx: &mut ModelContext) { + if paths.is_empty() { + return; + } + for path in paths { self.handle_path_deleted(&path); } + + ctx.emit(SkillManagerEvent::InventoryChanged); } fn handle_path_deleted(&mut self, path: &Path) { @@ -593,7 +676,7 @@ fn is_home_directory(path: &Path) -> bool { } impl Entity for SkillManager { - type Event = (); + type Event = SkillManagerEvent; } impl SingletonEntity for SkillManager {} diff --git a/app/src/ai/skills/skill_manager_tests.rs b/app/src/ai/skills/skill_manager_tests.rs index 6ea71b7eba..bc02d18c18 100644 --- a/app/src/ai/skills/skill_manager_tests.rs +++ b/app/src/ai/skills/skill_manager_tests.rs @@ -4,6 +4,10 @@ use ai::skills::{ParsedSkill, SkillProvider, SkillScope}; use repo_metadata::{repositories::DetectedRepositories, DirectoryWatcher, RepoMetadataModel}; use std::collections::{HashMap, HashSet}; use std::fs; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; use tempfile::TempDir; use warp_core::channel::ChannelState; use warpui::App; @@ -13,6 +17,93 @@ use watcher::HomeDirectoryWatcher; // Tests for get_skills_for_working_directory subdirectory scoping // ============================================================================ +#[test] +fn list_skill_inventory_groups_same_name_skills_and_marks_default() { + App::test((), |mut app| async move { + app.add_singleton_model(DirectoryWatcher::new); + app.add_singleton_model(|_| DetectedRepositories::default()); + app.add_singleton_model(RepoMetadataModel::new); + app.add_singleton_model(HomeDirectoryWatcher::new_for_test); + app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing); + let handle = app.add_singleton_model(SkillManager::new); + + let repo = PathBuf::from("/repo"); + let agents_path = repo.join(".agents/skills/deploy/SKILL.md"); + let codex_path = repo.join(".codex/skills/deploy/SKILL.md"); + + let agents_skill = ParsedSkill { + name: "deploy".to_string(), + description: "Agents copy".to_string(), + path: agents_path.clone(), + content: "# Deploy".to_string(), + line_range: None, + provider: SkillProvider::Agents, + scope: SkillScope::Project, + }; + let codex_skill = ParsedSkill { + name: "deploy".to_string(), + description: "Codex copy".to_string(), + path: codex_path, + content: "# Deploy".to_string(), + line_range: None, + provider: SkillProvider::Codex, + scope: SkillScope::Project, + }; + + handle.update(&mut app, |manager, ctx| { + manager.handle_skills_added(vec![agents_skill, codex_skill], ctx); + }); + + let inventory = handle.read(&app, |manager, ctx| manager.list_skill_inventory(ctx)); + + assert_eq!(inventory.len(), 1); + assert_eq!(inventory[0].name, "deploy"); + assert_eq!(inventory[0].duplicates.len(), 2); + assert_eq!(inventory[0].default_skill.path, agents_path); + assert_eq!(inventory[0].default_skill.content, "# Deploy"); + assert!(inventory[0].has_duplicates()); + }); +} + +#[test] +fn skill_manager_emits_inventory_changed_when_skills_change() { + App::test((), |mut app| async move { + app.add_singleton_model(DirectoryWatcher::new); + app.add_singleton_model(|_| DetectedRepositories::default()); + app.add_singleton_model(RepoMetadataModel::new); + app.add_singleton_model(HomeDirectoryWatcher::new_for_test); + app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing); + let handle = app.add_singleton_model(SkillManager::new); + let saw_inventory_changed = Arc::new(AtomicBool::new(false)); + + app.update(|ctx| { + let saw_inventory_changed = saw_inventory_changed.clone(); + ctx.subscribe_to_model(&handle, move |_, event, _| { + if matches!(event, SkillManagerEvent::InventoryChanged) { + saw_inventory_changed.store(true, Ordering::SeqCst); + } + }); + }); + + let skill_path = PathBuf::from("/repo/.agents/skills/deploy/SKILL.md"); + let skill = ParsedSkill { + name: "deploy".to_string(), + description: "Deploy skill".to_string(), + path: skill_path, + content: "# Deploy".to_string(), + line_range: None, + provider: SkillProvider::Agents, + scope: SkillScope::Project, + }; + + handle.update(&mut app, |manager, ctx| { + manager.handle_skills_added(vec![skill], ctx); + }); + + assert!(saw_inventory_changed.load(Ordering::SeqCst)); + }); +} + #[test] fn get_skills_for_working_directory_scopes_subdirectory_skills() { // This test verifies the key scoping behavior: diff --git a/app/src/ai/skills/skill_utils.rs b/app/src/ai/skills/skill_utils.rs index 4a79e526ae..55288d8058 100644 --- a/app/src/ai/skills/skill_utils.rs +++ b/app/src/ai/skills/skill_utils.rs @@ -18,19 +18,19 @@ use warpui::{AppContext, Element, SingletonEntity}; use crate::warp_managed_paths_watcher::warp_managed_skill_dirs; -/// Deduplicates skills by **name**, keeping a single best representative per skill name. +/// Deduplicates skills by **name and owning directory**, keeping a single best representative per +/// skill name within each directory. /// /// 优先级规则(同名 skill 多份时): /// /// 1. **provider rank 小者胜**:依 [`SKILL_PROVIDER_DEFINITIONS`] 顺序(index 0 = 最高优先级), /// 例如 `Agents > Warp > Claude > …`。 -/// 2. **同 rank 时 reference 路径短者胜**:home scope 路径通常比 project scope 短, -/// 取为稳定 tiebreak。 +/// 2. **同 rank 时 reference 路径短者胜**:取为稳定 tiebreak。 /// /// 该实现覆盖了三种场景: /// - `npx skills` 软链同名 skill 到 `~/.agents/skills/` / `~/.warp/skills/` / `~/.claude/skills/` /// (同名不同 provider) → 保留高优先级 provider。 -/// - 同名 skill 同时存在于 home + project (同名同 provider 跨 scope) → 保留路径短的。 +/// - 同名 skill 同时存在于多个目录(例如 repo root + subdir) → 各自保留,让调用方按路径上下文处理。 /// - 同名不同内容 (不同 provider) → 保留高优先级 provider。 /// /// Each element of `skill_paths` is a `(dir_path, skill_file_path)` tuple where @@ -47,14 +47,14 @@ pub(crate) fn unique_skills( skill_paths: &[(PathBuf, PathBuf)], skills_by_path: &HashMap, ) -> Vec { - let mut name_map: HashMap = HashMap::new(); + let mut name_map: HashMap<(String, PathBuf), SkillDescriptor> = HashMap::new(); - for (_dir_path, path) in skill_paths { + for (dir_path, path) in skill_paths { let Some(skill) = skills_by_path.get(path) else { continue; }; let descriptor = SkillDescriptor::from(skill.clone()); - match name_map.entry(descriptor.name.clone()) { + match name_map.entry((descriptor.name.clone(), dir_path.clone())) { Entry::Vacant(e) => { e.insert(descriptor); } diff --git a/app/src/ai/skills/skill_utils_tests.rs b/app/src/ai/skills/skill_utils_tests.rs index 9063263681..1a5bc086a3 100644 --- a/app/src/ai/skills/skill_utils_tests.rs +++ b/app/src/ai/skills/skill_utils_tests.rs @@ -68,7 +68,7 @@ fn test_unique_skills_dedupes_identical_skills_same_dir() { content: content.to_string(), line_range: Some(8..18), provider: SkillProvider::Agents, - scope: SkillScope::Home, + scope: SkillScope::Project, }; let skill2 = ParsedSkill { @@ -78,7 +78,7 @@ fn test_unique_skills_dedupes_identical_skills_same_dir() { content: content.to_string(), line_range: Some(8..18), provider: SkillProvider::Claude, - scope: SkillScope::Home, + scope: SkillScope::Project, }; let mut skills_by_path = HashMap::new(); @@ -97,7 +97,7 @@ fn test_unique_skills_dedupes_identical_skills_same_dir() { } #[test] -fn test_unique_skills_name_dedup_different_dirs_same_provider() { +fn test_unique_skills_keeps_same_provider_skills_from_different_dirs() { let home_dir = PathBuf::from("/home/user"); let project_dir = PathBuf::from("/home/user/projects/repo"); let home_path = home_dir.join(".agents/skills/my-skill/SKILL.md"); @@ -111,7 +111,7 @@ fn test_unique_skills_name_dedup_different_dirs_same_provider() { content: content.to_string(), line_range: Some(8..18), provider: SkillProvider::Agents, - scope: SkillScope::Home, + scope: SkillScope::Project, }; let project_skill = ParsedSkill { @@ -131,19 +131,19 @@ fn test_unique_skills_name_dedup_different_dirs_same_provider() { let skill_paths = vec![(home_dir, home_path.clone()), (project_dir, project_path)]; let result = unique_skills(&skill_paths, &skills_by_path); - assert_eq!( - result.len(), - 1, - "同名 + 同 provider 跨目录应被 name-dedup 合并为 1" + assert_eq!(result.len(), 2, "同名 + 同 provider 跨目录应各自保留"); + assert!( + result + .iter() + .any(|skill| skill.reference.to_string().contains("/home/user/.agents")), + "应保留 home 目录里的同名 skill,实际={result:?}" ); - // 同 provider 时 reference 路径短者胜——home_path 比 project_path 短。 assert!( - result[0] + result.iter().any(|skill| skill .reference .to_string() - .contains("/home/user/.agents"), - "同 rank 应取路径最短的 reference,实际={}", - result[0].reference, + .contains("/home/user/projects/repo/.agents")), + "应保留 project 目录里的同名 skill,实际={result:?}" ); } @@ -163,7 +163,7 @@ fn test_unique_skills_name_dedup_same_name_different_providers() { content: content1.to_string(), line_range: Some(8..18), provider: SkillProvider::Agents, - scope: SkillScope::Home, + scope: SkillScope::Project, }; let skill2 = ParsedSkill { @@ -173,7 +173,7 @@ fn test_unique_skills_name_dedup_same_name_different_providers() { content: content2.to_string(), line_range: Some(8..18), provider: SkillProvider::Claude, - scope: SkillScope::Home, + scope: SkillScope::Project, }; let mut skills_by_path = HashMap::new(); diff --git a/app/src/ai/skills/telemetry.rs b/app/src/ai/skills/telemetry.rs index 0887873088..586076401c 100644 --- a/app/src/ai/skills/telemetry.rs +++ b/app/src/ai/skills/telemetry.rs @@ -17,6 +17,8 @@ pub enum SkillOpenOrigin { EditFiles, // /open-skill command OpenSkillCommand, + // Skill manager panel edit button + SkillManager, } /// Telemetry events for skills @@ -29,7 +31,7 @@ pub enum SkillTelemetryEvent { reference: SkillReference, /// Specifies the parsed skill name from SKILL.md front matter, if the reference resolved name: Option, - /// Specifies the scope of the skill (home or project) + /// Specifies the scope of the skill. scope: Option, /// Specifies the provider of the skill (Warp, Claude, Codex, etc.) provider: Option, diff --git a/app/src/app_state.rs b/app/src/app_state.rs index ddaebbb563..abb34359bd 100644 --- a/app/src/app_state.rs +++ b/app/src/app_state.rs @@ -310,6 +310,7 @@ pub enum LeftPanelDisplayedTab { WarpDrive, ConversationListView, SshManager, + SkillManager, } impl From for LeftPanelDisplayedTab { @@ -320,6 +321,7 @@ impl From for LeftPanelDisplayedTab { ToolPanelView::WarpDrive => LeftPanelDisplayedTab::WarpDrive, ToolPanelView::ConversationListView => LeftPanelDisplayedTab::ConversationListView, ToolPanelView::SshManager => LeftPanelDisplayedTab::SshManager, + ToolPanelView::SkillManager => LeftPanelDisplayedTab::SkillManager, } } } diff --git a/app/src/code/view.rs b/app/src/code/view.rs index 35c5095491..4dfa71fac6 100644 --- a/app/src/code/view.rs +++ b/app/src/code/view.rs @@ -657,6 +657,7 @@ impl CodeView { ) { // If the tab already exists, focus it (and optionally jump) without re-opening from disk. if let Some(existing_index) = self.focus_existing_tab_if_present(&path, ctx) { + self.promote_if_preview(ctx); if let Some(line_col) = line_col { self.jump_to_line_col_in_tab(existing_index, line_col, ctx); } @@ -1766,8 +1767,19 @@ impl CodeView { None, ); + let mut right_controls = Flex::row() + .with_main_axis_alignment(MainAxisAlignment::End) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_main_axis_size(MainAxisSize::Min); + + if let Some(segmented) = &self.markdown_mode_segmented_control { + right_controls.add_child(ChildView::new(segmented).finish()); + } + + right_controls.add_child(buttons); + header_row.add_child( - Container::new(Align::new(buttons).finish()) + Container::new(Align::new(right_controls.finish()).finish()) .with_padding_right(4.) .with_border( Border::bottom(TAB_BAR_BORDER_HEIGHT).with_border_fill(theme.outline()), diff --git a/app/src/lib.rs b/app/src/lib.rs index 5943d0ff0f..ebed980484 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -73,6 +73,7 @@ mod search_bar; mod server; mod session_management; mod shell_indicator; +mod skill_manager; mod ssh_manager; mod suggestions; mod system; diff --git a/app/src/skill_manager/mod.rs b/app/src/skill_manager/mod.rs new file mode 100644 index 0000000000..ea066fb5e4 --- /dev/null +++ b/app/src/skill_manager/mod.rs @@ -0,0 +1,3 @@ +mod panel; + +pub use panel::{SkillManagerPanel, SkillManagerPanelEvent}; diff --git a/app/src/skill_manager/panel.rs b/app/src/skill_manager/panel.rs new file mode 100644 index 0000000000..fad122894f --- /dev/null +++ b/app/src/skill_manager/panel.rs @@ -0,0 +1,556 @@ +use std::collections::HashSet; +use std::path::PathBuf; + +use ai::skills::SkillProvider; +use warp_core::ui::appearance::Appearance; +use warp_core::ui::theme::color::internal_colors; +use warpui::{ + elements::{ + Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, + CornerRadius, CrossAxisAlignment, Element, Fill as ElementFill, Flex, Hoverable, + MainAxisSize, MouseStateHandle, ParentElement, Radius, ScrollbarWidth, Shrinkable, Text, + }, + platform::Cursor, + text_layout::ClipConfig, + AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, +}; + +use crate::ai::skills::{ + SkillInventoryDuplicate, SkillInventoryItem, SkillManager, SkillManagerEvent, +}; +use crate::editor::{ + EditorOptions, EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, + PropagateHorizontalNavigationKeys, TextOptions, +}; + +const PANEL_PADDING: f32 = 8.0; +const ROW_PADDING_VERTICAL: f32 = 5.0; +const ROW_PADDING_HORIZONTAL: f32 = 8.0; +const FONT_SIZE: f32 = 13.0; +const META_FONT_SIZE: f32 = 11.0; +const FILTER_BUTTON_HEIGHT: f32 = 24.0; +const PREVIEW_MIN_HEIGHT: f32 = 180.0; +const FILTER_LABEL_WIDTH: f32 = 44.0; +const FILTER_BUTTON_MIN_WIDTH: f32 = 52.0; + +#[derive(Clone, Debug)] +pub enum SkillManagerPanelAction { + SelectSkill(PathBuf), + SelectProviderFilter(Option), + EditSkill(PathBuf), + EditSelected, +} + +#[derive(Clone, Debug)] +pub enum SkillManagerPanelEvent { + OpenSkillFile { path: PathBuf }, +} + +pub struct SkillManagerPanel { + selected_path: Option, + provider_filter: Option, + query_editor: ViewHandle, + list_scroll_state: ClippedScrollStateHandle, + preview_scroll_state: ClippedScrollStateHandle, +} + +impl SkillManagerPanel { + pub fn new(ctx: &mut ViewContext) -> Self { + let query_editor = ctx.add_typed_action_view(|ctx| { + let options = EditorOptions { + text: TextOptions::ui_text(Some(FONT_SIZE), Appearance::as_ref(ctx)), + propagate_and_no_op_vertical_navigation_keys: + PropagateAndNoOpNavigationKeys::AtBoundary, + propagate_horizontal_navigation_keys: PropagateHorizontalNavigationKeys::Always, + single_line: true, + clear_selections_on_blur: true, + convert_newline_to_space: true, + ..Default::default() + }; + let mut editor = EditorView::new(options, ctx); + editor.set_placeholder_text(crate::t!("skill-manager-search-placeholder"), ctx); + editor + }); + + ctx.subscribe_to_view(&query_editor, |me, _handle, event, ctx| { + if matches!( + event, + EditorEvent::Edited(_) + | EditorEvent::BufferReplaced + | EditorEvent::BufferReinitialized + ) { + me.selected_path = None; + ctx.notify(); + } + }); + + let skill_manager = SkillManager::handle(ctx); + ctx.subscribe_to_model(&skill_manager, |me, _manager, event, ctx| match event { + SkillManagerEvent::InventoryChanged => { + me.selected_path = None; + ctx.notify(); + } + }); + + Self { + selected_path: None, + provider_filter: None, + query_editor, + list_scroll_state: ClippedScrollStateHandle::default(), + preview_scroll_state: ClippedScrollStateHandle::default(), + } + } + + fn query(&self, app: &AppContext) -> String { + self.query_editor + .as_ref(app) + .buffer_text(app) + .trim() + .to_lowercase() + } + + fn filtered_items(&self, app: &AppContext) -> Vec { + let query = self.query(app); + SkillManager::as_ref(app) + .list_skill_inventory(app) + .into_iter() + .filter_map(|item| { + let duplicates = item + .duplicates + .into_iter() + .filter(|duplicate| { + self.provider_filter + .is_none_or(|provider| duplicate.provider == provider) + && (query.is_empty() + || duplicate.name.to_lowercase().contains(&query) + || duplicate.description.to_lowercase().contains(&query) + || duplicate + .path + .display() + .to_string() + .to_lowercase() + .contains(&query)) + }) + .collect::>(); + + let default_skill = duplicates.first()?.clone(); + Some(SkillInventoryItem { + name: item.name, + default_skill, + duplicates, + }) + }) + .collect() + } + + fn selected_duplicate(&self, app: &AppContext) -> Option { + let selected_path = self.selected_path.as_ref()?; + SkillManager::as_ref(app) + .list_skill_inventory(app) + .into_iter() + .flat_map(|item| item.duplicates.into_iter()) + .find(|duplicate| &duplicate.path == selected_path) + } + + fn fallback_duplicate(&self, app: &AppContext) -> Option { + self.filtered_items(app) + .into_iter() + .flat_map(|item| item.duplicates.into_iter()) + .next() + } + + fn active_duplicate(&self, app: &AppContext) -> Option { + self.selected_duplicate(app) + .or_else(|| self.fallback_duplicate(app)) + } + + fn render_label( + text: impl Into, + appearance: &Appearance, + font_size: f32, + color: impl Into, + ) -> Box { + Text::new_inline(text.into(), appearance.ui_font_family(), font_size) + .with_color(color.into()) + .with_clip(ClipConfig::ellipsis()) + .finish() + } + + fn render_filter_button( + label: String, + is_active: bool, + state: MouseStateHandle, + action: SkillManagerPanelAction, + appearance: &Appearance, + ) -> Box { + let theme = appearance.theme(); + let text_color = if is_active { + theme.main_text_color(theme.background()) + } else { + theme.sub_text_color(theme.background()) + }; + let background = is_active.then(|| internal_colors::fg_overlay_3(theme)); + let label_el = Self::render_label(label, appearance, META_FONT_SIZE, text_color); + + Hoverable::new(state, move |mouse| { + let mut button = Container::new(label_el) + .with_padding_left(8.0) + .with_padding_right(8.0) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.0))); + if let Some(background) = background { + button = button.with_background(background); + } + if mouse.is_hovered() && !is_active { + button = button.with_background(internal_colors::fg_overlay_2(theme)); + } + ConstrainedBox::new(button.finish()) + .with_height(FILTER_BUTTON_HEIGHT) + .with_min_width(FILTER_BUTTON_MIN_WIDTH) + .finish() + }) + .with_cursor(Cursor::PointingHand) + .on_mouse_down(move |ctx, _, _| { + ctx.dispatch_typed_action(action.clone()); + }) + .finish() + } + + fn render_filter_group( + label: String, + values: Vec, + is_all_active: bool, + is_active: impl Fn(T) -> bool, + action_for: impl Fn(Option) -> SkillManagerPanelAction, + appearance: &Appearance, + ) -> Box + where + T: Copy + ToString + 'static, + { + let theme = appearance.theme(); + let mut row = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_spacing(4.0) + .with_child( + ConstrainedBox::new(Self::render_label( + label, + appearance, + META_FONT_SIZE, + theme.sub_text_color(theme.background()), + )) + .with_width(FILTER_LABEL_WIDTH) + .finish(), + ) + .with_child(Self::render_filter_button( + crate::t!("skill-manager-filter-all"), + is_all_active, + MouseStateHandle::default(), + action_for(None), + appearance, + )); + + for value in values { + row.add_child(Self::render_filter_button( + value.to_string(), + is_active(value), + MouseStateHandle::default(), + action_for(Some(value)), + appearance, + )); + } + + row.finish() + } + + fn render_filter_rows(&self, app: &AppContext, appearance: &Appearance) -> Box { + let inventory = SkillManager::as_ref(app).list_skill_inventory(app); + let mut providers = inventory + .iter() + .flat_map(|item| item.duplicates.iter().map(|duplicate| duplicate.provider)) + .collect::>() + .into_iter() + .collect::>(); + providers.sort_by_key(|provider| provider.to_string()); + + Self::render_filter_group( + crate::t!("skill-manager-filter-provider"), + providers, + self.provider_filter.is_none(), + |provider| self.provider_filter == Some(provider), + SkillManagerPanelAction::SelectProviderFilter, + appearance, + ) + } + + fn render_skill_row( + &self, + duplicate: &SkillInventoryDuplicate, + is_selected: bool, + is_default: bool, + has_duplicates: bool, + state: MouseStateHandle, + appearance: &Appearance, + ) -> Box { + let theme = appearance.theme(); + let path = duplicate.path.display().to_string(); + let mut meta = format!("{} · {}", duplicate.provider, duplicate.scope); + if has_duplicates { + if is_default { + meta.push_str(" · default"); + } else { + meta.push_str(" · duplicate"); + } + } + + let title = Self::render_label( + duplicate.name.clone(), + appearance, + FONT_SIZE, + theme.main_text_color(theme.background()), + ); + let description = Self::render_label( + duplicate.description.clone(), + appearance, + META_FONT_SIZE, + theme.sub_text_color(theme.background()), + ); + let meta = Self::render_label( + meta, + appearance, + META_FONT_SIZE, + theme.sub_text_color(theme.background()), + ); + let path = Self::render_label( + path, + appearance, + META_FONT_SIZE, + theme.sub_text_color(theme.background()), + ); + + let action = SkillManagerPanelAction::EditSkill(duplicate.path.clone()); + Hoverable::new(state, move |mouse| { + let background = if is_selected { + Some(internal_colors::fg_overlay_3(theme)) + } else if mouse.is_hovered() { + Some(internal_colors::fg_overlay_2(theme)) + } else { + None + }; + let mut row = Container::new( + Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_spacing(2.0) + .with_child(title) + .with_child(description) + .with_child(meta) + .with_child(path) + .finish(), + ) + .with_padding_top(ROW_PADDING_VERTICAL) + .with_padding_bottom(ROW_PADDING_VERTICAL) + .with_padding_left(ROW_PADDING_HORIZONTAL) + .with_padding_right(ROW_PADDING_HORIZONTAL) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.0))); + if let Some(background) = background { + row = row.with_background(background); + } + row.finish() + }) + .with_cursor(Cursor::PointingHand) + .on_mouse_down(move |ctx, _, _| { + ctx.dispatch_typed_action(action.clone()); + }) + .finish() + } + + fn render_skill_list( + &self, + items: &[SkillInventoryItem], + appearance: &Appearance, + ) -> Box { + if items.is_empty() { + return Container::new(Self::render_label( + crate::t!("skill-manager-empty"), + appearance, + FONT_SIZE, + appearance + .theme() + .sub_text_color(appearance.theme().background()), + )) + .with_uniform_padding(12.0) + .finish(); + } + + let mut rows = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_spacing(2.0); + for item in items { + let has_duplicates = item.has_duplicates(); + for duplicate in &item.duplicates { + let is_default = duplicate.path == item.default_skill.path; + let is_selected = self + .selected_path + .as_ref() + .is_some_and(|path| path == &duplicate.path); + rows.add_child(self.render_skill_row( + duplicate, + is_selected, + is_default, + has_duplicates, + MouseStateHandle::default(), + appearance, + )); + } + } + + let theme = appearance.theme(); + ClippedScrollable::vertical( + self.list_scroll_state.clone(), + rows.finish(), + ScrollbarWidth::Auto, + theme.disabled_text_color(theme.background()).into(), + theme.main_text_color(theme.background()).into(), + ElementFill::None, + ) + .with_overlayed_scrollbar() + .finish() + } + + fn render_preview( + &self, + duplicate: Option<&SkillInventoryDuplicate>, + appearance: &Appearance, + ) -> Box { + let theme = appearance.theme(); + let Some(duplicate) = duplicate else { + return Container::new(Self::render_label( + crate::t!("skill-manager-preview-empty"), + appearance, + FONT_SIZE, + theme.sub_text_color(theme.background()), + )) + .with_uniform_padding(12.0) + .finish(); + }; + + let header = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child( + Shrinkable::new( + 1.0, + Self::render_label( + duplicate.path.display().to_string(), + appearance, + META_FONT_SIZE, + theme.sub_text_color(theme.background()), + ), + ) + .finish(), + ) + .finish(); + + let preview_text = Text::new_inline( + duplicate.content.clone(), + appearance.monospace_font_family(), + 12.0, + ) + .with_color(theme.main_text_color(theme.background()).into()) + .finish(); + let preview_body = Container::new(preview_text) + .with_uniform_padding(8.0) + .with_background(theme.surface_2()) + .with_border(Border::all(1.0).with_border_color(theme.surface_3().into())) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.0))) + .finish(); + let preview_scroll = ClippedScrollable::vertical( + self.preview_scroll_state.clone(), + preview_body, + ScrollbarWidth::Auto, + theme.disabled_text_color(theme.background()).into(), + theme.main_text_color(theme.background()).into(), + ElementFill::None, + ) + .with_overlayed_scrollbar() + .finish(); + + Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_spacing(6.0) + .with_child(header) + .with_child( + ConstrainedBox::new(preview_scroll) + .with_height(PREVIEW_MIN_HEIGHT) + .finish(), + ) + .finish() + } +} + +impl TypedActionView for SkillManagerPanel { + type Action = SkillManagerPanelAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + SkillManagerPanelAction::SelectSkill(path) => { + self.selected_path = Some(path.clone()); + ctx.notify(); + } + SkillManagerPanelAction::SelectProviderFilter(provider) => { + self.provider_filter = *provider; + self.selected_path = None; + ctx.notify(); + } + SkillManagerPanelAction::EditSkill(path) => { + self.selected_path = Some(path.clone()); + ctx.emit(SkillManagerPanelEvent::OpenSkillFile { path: path.clone() }); + ctx.notify(); + } + SkillManagerPanelAction::EditSelected => { + if let Some(path) = &self.selected_path { + ctx.emit(SkillManagerPanelEvent::OpenSkillFile { path: path.clone() }); + } + } + } + } +} + +impl View for SkillManagerPanel { + fn ui_name() -> &'static str { + "SkillManagerPanel" + } + + fn on_focus(&mut self, _focus_ctx: &warpui::FocusContext, ctx: &mut ViewContext) { + ctx.focus(&self.query_editor); + } + + fn render(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + let items = self.filtered_items(app); + let active_duplicate = self.active_duplicate(app); + let search = Container::new(ChildView::new(&self.query_editor).finish()) + .with_uniform_padding(6.0) + .with_background(appearance.theme().surface_2()) + .with_border(Border::all(1.0).with_border_color(appearance.theme().surface_3().into())) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.0))) + .finish(); + + Container::new( + Flex::column() + .with_main_axis_size(MainAxisSize::Max) + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_spacing(8.0) + .with_child(search) + .with_child(self.render_filter_rows(app, appearance)) + .with_child( + Shrinkable::new(1.0, self.render_skill_list(&items, appearance)).finish(), + ) + .with_child(self.render_preview(active_duplicate.as_ref(), appearance)) + .finish(), + ) + .with_uniform_padding(PANEL_PADDING) + .finish() + } +} + +impl Entity for SkillManagerPanel { + type Event = SkillManagerPanelEvent; +} diff --git a/app/src/view_components/markdown_toggle_view.rs b/app/src/view_components/markdown_toggle_view.rs index cecb774edc..a660756415 100644 --- a/app/src/view_components/markdown_toggle_view.rs +++ b/app/src/view_components/markdown_toggle_view.rs @@ -33,8 +33,12 @@ impl MarkdownToggleView { icon_color: theme.main_text_color(theme.background()).into(), label: Some(LabelConfig { label: match mode { - MarkdownDisplayMode::Rendered => "Rendered".into(), - MarkdownDisplayMode::Raw => "Raw".into(), + MarkdownDisplayMode::Rendered => { + crate::t!("markdown-display-mode-rendered").into() + } + MarkdownDisplayMode::Raw => { + crate::t!("markdown-display-mode-raw").into() + } }, width_override: Some(55.0), color: if is_selected { diff --git a/app/src/workspace/action.rs b/app/src/workspace/action.rs index c4d1441879..e9ba6b3dab 100644 --- a/app/src/workspace/action.rs +++ b/app/src/workspace/action.rs @@ -150,6 +150,8 @@ pub enum WorkspaceAction { }, /// 打开/关闭左侧 panel 的 SSH 管理器视图(openWarp 独有)。 ToggleSshManager, + /// 打开/关闭左侧 panel 的 Skill 管理器视图(openWarp 独有)。 + ToggleSkillManager, AddTabWithShell { shell: AvailableShell, source: AddTabWithShellSource, @@ -725,6 +727,7 @@ impl WorkspaceAction { | AddTerminalTab { .. } | OpenSshTerminal { .. } | ToggleSshManager + | ToggleSkillManager | AddTabWithShell { .. } | AddGetStartedTab | AddAgentTab diff --git a/app/src/workspace/action_tests.rs b/app/src/workspace/action_tests.rs index e32bbc7f88..cf0887d49d 100644 --- a/app/src/workspace/action_tests.rs +++ b/app/src/workspace/action_tests.rs @@ -20,6 +20,11 @@ fn vertical_tabs_panel_toggle_still_saves_workspace_state() { assert!(WorkspaceAction::ToggleVerticalTabsPanel.should_save_app_state_on_action()); } +#[test] +fn toggle_skill_manager_saves_workspace_state() { + assert!(WorkspaceAction::ToggleSkillManager.should_save_app_state_on_action()); +} + #[test] fn settings_popup_toggle_does_not_save_workspace_state() { assert!(!WorkspaceAction::ToggleVerticalTabsSettingsPopup.should_save_app_state_on_action()); diff --git a/app/src/workspace/mod.rs b/app/src/workspace/mod.rs index dd214d103d..f44f73d231 100644 --- a/app/src/workspace/mod.rs +++ b/app/src/workspace/mod.rs @@ -86,13 +86,14 @@ pub fn is_feedback_skill_available(ctx: &AppContext) -> bool { use crate::workspace::view::{ LEFT_PANEL_AGENT_CONVERSATIONS_BINDING_NAME, LEFT_PANEL_GLOBAL_SEARCH_BINDING_NAME, - LEFT_PANEL_PROJECT_EXPLORER_BINDING_NAME, LEFT_PANEL_SSH_MANAGER_BINDING_NAME, - LEFT_PANEL_WARP_DRIVE_BINDING_NAME, NEW_AGENT_TAB_BINDING_NAME, - NEW_AMBIENT_AGENT_TAB_BINDING_NAME, NEW_TAB_BINDING_NAME, NEW_TERMINAL_TAB_BINDING_NAME, - OPEN_GLOBAL_SEARCH_BINDING_NAME, TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, - TOGGLE_NOTIFICATION_MAILBOX_BINDING_NAME, TOGGLE_PROJECT_EXPLORER_BINDING_NAME, - TOGGLE_RIGHT_PANEL_BINDING_NAME, TOGGLE_TAB_CONFIGS_MENU_BINDING_NAME, - TOGGLE_VERTICAL_TABS_PANEL_BINDING_NAME, TOGGLE_WARP_DRIVE_BINDING_NAME, + LEFT_PANEL_PROJECT_EXPLORER_BINDING_NAME, LEFT_PANEL_SKILL_MANAGER_BINDING_NAME, + LEFT_PANEL_SSH_MANAGER_BINDING_NAME, LEFT_PANEL_WARP_DRIVE_BINDING_NAME, + NEW_AGENT_TAB_BINDING_NAME, NEW_AMBIENT_AGENT_TAB_BINDING_NAME, NEW_TAB_BINDING_NAME, + NEW_TERMINAL_TAB_BINDING_NAME, OPEN_GLOBAL_SEARCH_BINDING_NAME, + TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, TOGGLE_NOTIFICATION_MAILBOX_BINDING_NAME, + TOGGLE_PROJECT_EXPLORER_BINDING_NAME, TOGGLE_RIGHT_PANEL_BINDING_NAME, + TOGGLE_TAB_CONFIGS_MENU_BINDING_NAME, TOGGLE_VERTICAL_TABS_PANEL_BINDING_NAME, + TOGGLE_WARP_DRIVE_BINDING_NAME, }; pub use one_time_modal_model::OneTimeModalModel; pub use registry::WorkspaceRegistry; @@ -822,6 +823,17 @@ pub fn init(app: &mut AppContext) { .with_context_predicate(id!("Workspace")) .with_mac_key_binding("ctrl-5") .with_linux_or_windows_key_binding("alt-5"), + EditableBinding::new( + LEFT_PANEL_SKILL_MANAGER_BINDING_NAME, + BindingDescription::new(crate::t!( + "keybinding-desc-workspace-left-panel-skill-manager" + )), + WorkspaceAction::ToggleSkillManager, + ) + .with_group(bindings::BindingGroup::Navigation.as_str()) + .with_context_predicate(id!("Workspace")) + .with_mac_key_binding("ctrl-6") + .with_linux_or_windows_key_binding("alt-6"), EditableBinding::new( TOGGLE_PROJECT_EXPLORER_BINDING_NAME, BindingDescription::new(crate::t!( diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 9ee3d7ba71..ab454576bf 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -595,6 +595,7 @@ pub(crate) const LEFT_PANEL_WARP_DRIVE_BINDING_NAME: &str = "workspace:left_pane pub(crate) const LEFT_PANEL_AGENT_CONVERSATIONS_BINDING_NAME: &str = "workspace:left_panel_agent_conversations"; pub(crate) const LEFT_PANEL_SSH_MANAGER_BINDING_NAME: &str = "workspace:left_panel_ssh_manager"; +pub(crate) const LEFT_PANEL_SKILL_MANAGER_BINDING_NAME: &str = "workspace:left_panel_skill_manager"; const KEYBINDINGS_TO_CACHE: [&str; 4] = [ ASK_AI_ASSISTANT_KEYBINDING_NAME, @@ -3613,6 +3614,7 @@ impl Workspace { LeftPanelDisplayedTab::WarpDrive => ToolPanelView::WarpDrive, LeftPanelDisplayedTab::ConversationListView => ToolPanelView::ConversationListView, LeftPanelDisplayedTab::SshManager => ToolPanelView::SshManager, + LeftPanelDisplayedTab::SkillManager => ToolPanelView::SkillManager, }; lp.restore_active_view_from_snapshot(active_view, ctx); lp.set_active_pane_group(pane_group.clone(), &self.working_directories_model, ctx); @@ -5552,6 +5554,23 @@ impl Workspace { ctx, ); } + LeftPanelEvent::OpenSkillFile { source } => { + #[cfg(feature = "local_fs")] + { + let layout = *EditorSettings::as_ref(ctx).open_file_layout.value(); + self.open_file_with_target( + source.path().unwrap_or_default(), + FileTarget::CodeEditor(layout), + None, + source.clone(), + ctx, + ); + } + #[cfg(not(feature = "local_fs"))] + { + let _ = source; + } + } LeftPanelEvent::NewConversationInNewTab => { self.add_terminal_tab_with_new_agent_view(ctx); } @@ -15905,6 +15924,9 @@ impl Workspace { ToolPanelView::SshManager => { crate::t!("workspace-left-panel-ssh-manager") } + ToolPanelView::SkillManager => { + crate::t!("workspace-left-panel-skill-manager") + } } } else { crate::t!("workspace-tools-panel-tooltip") @@ -15968,6 +15990,9 @@ impl Workspace { ToolPanelView::SshManager => { crate::t!("workspace-left-panel-ssh-manager") } + ToolPanelView::SkillManager => { + crate::t!("workspace-left-panel-skill-manager") + } } } else { crate::t!("workspace-tools-panel-tooltip") @@ -18836,6 +18861,8 @@ impl Workspace { } // openWarp 独有:SSH 管理器,无 feature flag,默认始终显示。 views.push(ToolPanelView::SshManager); + // openWarp 独有:Skill 管理器,无 feature flag,默认始终显示。 + views.push(ToolPanelView::SkillManager); views } @@ -20632,6 +20659,11 @@ impl TypedActionView for Workspace { self.left_panel_view.as_ref(ctx).active_view() == ToolPanelView::SshManager; self.toggle_left_panel_view(&LeftPanelAction::SshManager, is_showing, ctx); } + ToggleSkillManager => { + let is_showing = + self.left_panel_view.as_ref(ctx).active_view() == ToolPanelView::SkillManager; + self.toggle_left_panel_view(&LeftPanelAction::SkillManager, is_showing, ctx); + } ToggleGlobalSearch => { if FeatureFlag::GlobalSearch.is_enabled() && *CodeSettings::as_ref(ctx).show_global_search diff --git a/app/src/workspace/view/left_panel.rs b/app/src/workspace/view/left_panel.rs index 74dbfcf8d5..fe2d056d41 100644 --- a/app/src/workspace/view/left_panel.rs +++ b/app/src/workspace/view/left_panel.rs @@ -18,6 +18,8 @@ use warpui::{ use crate::ai::agent::conversation::AIConversationId; use crate::ai::agent_conversations_model::AgentConversationsModel; +use crate::ai::skills::{SkillManager, SkillOpenOrigin}; +use crate::code::editor_management::CodeSource; #[cfg(feature = "local_fs")] use crate::code::file_tree::FileTreeEvent; use crate::coding_panel_enablement_state::CodingPanelEnablementState; @@ -28,6 +30,7 @@ use crate::pane_group::{PaneGroup, WorkingDirectoriesEvent, WorkingDirectoriesMo use crate::server::telemetry::CodePanelsFileOpenEntrypoint; use crate::server::telemetry::{FileTreeSource, WarpDriveSource}; use crate::settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier}; +use crate::skill_manager::{SkillManagerPanel, SkillManagerPanelEvent}; use crate::ssh_manager::SshManagerPanel; #[cfg(feature = "local_fs")] use crate::util::file::external_editor::EditorSettings; @@ -42,10 +45,10 @@ use crate::workspace::view::global_search::view::{ }; use crate::workspace::view::{ LEFT_PANEL_AGENT_CONVERSATIONS_BINDING_NAME, LEFT_PANEL_GLOBAL_SEARCH_BINDING_NAME, - LEFT_PANEL_PROJECT_EXPLORER_BINDING_NAME, LEFT_PANEL_SSH_MANAGER_BINDING_NAME, - LEFT_PANEL_WARP_DRIVE_BINDING_NAME, OPEN_GLOBAL_SEARCH_BINDING_NAME, - TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, TOGGLE_PROJECT_EXPLORER_BINDING_NAME, - TOGGLE_WARP_DRIVE_BINDING_NAME, + LEFT_PANEL_PROJECT_EXPLORER_BINDING_NAME, LEFT_PANEL_SKILL_MANAGER_BINDING_NAME, + LEFT_PANEL_SSH_MANAGER_BINDING_NAME, LEFT_PANEL_WARP_DRIVE_BINDING_NAME, + OPEN_GLOBAL_SEARCH_BINDING_NAME, TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, + TOGGLE_PROJECT_EXPLORER_BINDING_NAME, TOGGLE_WARP_DRIVE_BINDING_NAME, }; use crate::{ appearance::Appearance, @@ -63,6 +66,8 @@ use crate::{ TelemetryEvent, }; +const SKILL_MANAGER_MIN_SIDEBAR_WIDTH: f32 = 360.0; + #[derive(Default)] struct MouseStateHandles { project_explorer_button: MouseStateHandle, @@ -70,6 +75,7 @@ struct MouseStateHandles { warp_drive_button: MouseStateHandle, conversation_list_view_button: MouseStateHandle, ssh_manager_button: MouseStateHandle, + skill_manager_button: MouseStateHandle, } #[derive(Clone, Debug)] @@ -79,6 +85,7 @@ pub enum LeftPanelAction { WarpDrive, ConversationListView, SshManager, + SkillManager, } pub enum LeftPanelEvent { @@ -91,6 +98,9 @@ pub enum LeftPanelEvent { target: FileTarget, line_col: Option, }, + OpenSkillFile { + source: CodeSource, + }, NewConversationInNewTab, ShowDeleteConfirmationDialog { conversation_id: AIConversationId, @@ -117,6 +127,7 @@ pub enum ToolPanelView { WarpDrive, ConversationListView, SshManager, + SkillManager, } /// Encapsulates the active view state to enforce that all mutations go through @@ -184,6 +195,7 @@ pub struct LeftPanelView { warp_drive_view: ViewHandle, conversation_list_view: ViewHandle, ssh_manager_view: ViewHandle, + skill_manager_view: ViewHandle, active_view: active_view_state::ActiveViewState, toolbelt_buttons: Vec, active_pane_group: Option>, @@ -229,6 +241,7 @@ impl LeftPanelView { let warp_drive_view = ctx.add_typed_action_view(DrivePanel::new); let conversation_list_view = ctx.add_typed_action_view(ConversationListView::new); let ssh_manager_view = ctx.add_typed_action_view(SshManagerPanel::new); + let skill_manager_view = ctx.add_typed_action_view(SkillManagerPanel::new); ctx.subscribe_to_view(&ssh_manager_view, |_me, _, event, ctx| { use crate::ssh_manager::SshManagerPanelEvent; match event { @@ -248,6 +261,18 @@ impl LeftPanelView { } } }); + ctx.subscribe_to_view(&skill_manager_view, |_me, _, event, ctx| match event { + SkillManagerPanelEvent::OpenSkillFile { path } => { + let reference = SkillManager::as_ref(ctx).reference_for_skill_path(path); + ctx.emit(LeftPanelEvent::OpenSkillFile { + source: CodeSource::Skill { + reference, + path: path.clone(), + origin: SkillOpenOrigin::SkillManager, + }, + }); + } + }); ctx.subscribe_to_view(&warp_drive_view, |_me, _, event, ctx| { ctx.emit(LeftPanelEvent::WarpDrive(event.clone())); @@ -346,6 +371,7 @@ impl LeftPanelView { warp_drive_view, conversation_list_view, ssh_manager_view, + skill_manager_view, active_view: active_view_state::new(active_view), toolbelt_buttons, active_pane_group: None, @@ -386,6 +412,7 @@ impl LeftPanelView { match (v, ¤t_view) { (ToolPanelView::GlobalSearch { .. }, ToolPanelView::GlobalSearch { .. }) => true, (ToolPanelView::SshManager, ToolPanelView::SshManager) => true, + (ToolPanelView::SkillManager, ToolPanelView::SkillManager) => true, _ => std::mem::discriminant(v) == std::mem::discriminant(¤t_view), } }); @@ -491,6 +518,18 @@ impl LeftPanelView { tooltip_keybinding_names, } } + ToolPanelView::SkillManager => { + let tooltip_keybinding_names = vec![LEFT_PANEL_SKILL_MANAGER_BINDING_NAME]; + ToolbeltButtonConfig { + icon: Icon::BookOpen, + active_icon: None, + tooltip_text: crate::t!("workspace-left-panel-skill-manager"), + action: LeftPanelAction::SkillManager, + render_with_active_state: false, + tooltip_keybinding: toolbelt_tooltip_keybinding(&tooltip_keybinding_names, ctx), + tooltip_keybinding_names, + } + } } } @@ -736,6 +775,9 @@ impl LeftPanelView { ToolPanelView::SshManager => { ctx.focus(&self.ssh_manager_view); } + ToolPanelView::SkillManager => { + ctx.focus(&self.skill_manager_view); + } } } @@ -889,6 +931,9 @@ impl LeftPanelView { self.active_view.get() == ToolPanelView::ConversationListView } LeftPanelAction::SshManager => self.active_view.get() == ToolPanelView::SshManager, + LeftPanelAction::SkillManager => { + self.active_view.get() == ToolPanelView::SkillManager + } }; } } @@ -1033,6 +1078,9 @@ impl LeftPanelView { LeftPanelAction::SshManager => { active_view_state::set(self, ToolPanelView::SshManager, ctx); } + LeftPanelAction::SkillManager => { + active_view_state::set(self, ToolPanelView::SkillManager, ctx); + } } } @@ -1133,6 +1181,7 @@ impl View for LeftPanelView { ToolPanelView::WarpDrive => ctx.focus(&self.warp_drive_view), ToolPanelView::ConversationListView => ctx.focus(&self.conversation_list_view), ToolPanelView::SshManager => ctx.focus(&self.ssh_manager_view), + ToolPanelView::SkillManager => ctx.focus(&self.skill_manager_view), } } } @@ -1148,6 +1197,7 @@ impl View for LeftPanelView { .conversation_list_view_button .clone(), self.mouse_state_handles.ssh_manager_button.clone(), + self.mouse_state_handles.skill_manager_button.clone(), ]; // If there is only one button in the toolbelt row, @@ -1214,6 +1264,14 @@ impl View for LeftPanelView { .finish(), ) .finish(), + ToolPanelView::SkillManager => Shrinkable::new( + 1.0, + Container::new(ChildView::new(&self.skill_manager_view).finish()) + .with_padding_left(2.) + .with_padding_right(2.) + .finish(), + ) + .finish(), }; let panel_content = Container::new({ @@ -1258,13 +1316,18 @@ impl View for LeftPanelView { super::PanelPosition::Left => DragBarSide::Right, super::PanelPosition::Right => DragBarSide::Left, }; + let min_sidebar_width = if self.active_view.get() == ToolPanelView::SkillManager { + SKILL_MANAGER_MIN_SIDEBAR_WIDTH + } else { + MIN_SIDEBAR_WIDTH + }; Resizable::new(self.resizable_state_handle.clone(), panel_content) .with_dragbar_side(drag_side) .on_resize(move |ctx, _| { ctx.notify(); }) - .with_bounds_callback(Box::new(|window_size| { - let min_width = MIN_SIDEBAR_WIDTH; + .with_bounds_callback(Box::new(move |window_size| { + let min_width = min_sidebar_width; let max_width = window_size.x() * MAX_SIDEBAR_WIDTH_RATIO; (min_width, max_width.max(min_width)) })) diff --git a/crates/ai/src/skills/conversion.rs b/crates/ai/src/skills/conversion.rs index 193f1a52a6..2f54a66fbd 100644 --- a/crates/ai/src/skills/conversion.rs +++ b/crates/ai/src/skills/conversion.rs @@ -53,7 +53,6 @@ impl From for api::Skill { impl From for api::skill_descriptor::Scope { fn from(scope: SkillScope) -> Self { let scope_type: api::skill_descriptor::scope::Type = match scope { - SkillScope::Home => api::skill_descriptor::scope::Type::Home(()), SkillScope::Project => api::skill_descriptor::scope::Type::Project(()), SkillScope::Bundled => api::skill_descriptor::scope::Type::Bundled(()), }; @@ -138,8 +137,8 @@ fn convert_scope(scope: api::skill_descriptor::Scope) -> Result Ok(SkillScope::Home), - api::skill_descriptor::scope::Type::Project(_) => Ok(SkillScope::Project), + api::skill_descriptor::scope::Type::Home(_) + | api::skill_descriptor::scope::Type::Project(_) => Ok(SkillScope::Project), api::skill_descriptor::scope::Type::Bundled(_) => Ok(SkillScope::Bundled), } } diff --git a/crates/ai/src/skills/parse_skill.rs b/crates/ai/src/skills/parse_skill.rs index b663b0b8c0..9b385cf187 100644 --- a/crates/ai/src/skills/parse_skill.rs +++ b/crates/ai/src/skills/parse_skill.rs @@ -6,7 +6,7 @@ use std::ops::Range; use std::path::{Path, PathBuf}; use super::parser::parse_markdown_file; -use super::skill_provider::{get_provider_for_path, get_scope_for_path, SkillProvider, SkillScope}; +use super::skill_provider::{get_provider_for_path, SkillProvider, SkillScope}; use thiserror::Error; const MAX_SKILL_DESCRIPTION_CHARS: usize = 512; @@ -39,7 +39,7 @@ pub struct ParsedSkill { pub line_range: Option>, /// The provider of the skill (Agents, Claude, Codex, or Warp), determined from the path. pub provider: SkillProvider, - /// The scope of the skill (home directory vs project directory). + /// The scope of the skill. pub scope: SkillScope, } @@ -65,8 +65,7 @@ impl Display for ParsedSkill { /// * `Result` - Parsed skill with validated name and description pub fn parse_skill(path: &Path) -> Result { let provider = get_provider_for_path(path).unwrap_or(SkillProvider::Agents); - let scope = get_scope_for_path(path); - parse_skill_internal(path, provider, scope) + parse_skill_internal(path, provider, SkillScope::Project) } /// Parse a bundled skill markdown file. diff --git a/crates/ai/src/skills/skill_provider.rs b/crates/ai/src/skills/skill_provider.rs index fff53fb4ac..6c60499db0 100644 --- a/crates/ai/src/skills/skill_provider.rs +++ b/crates/ai/src/skills/skill_provider.rs @@ -41,7 +41,7 @@ pub enum SkillProvider { OpenCode, } -/// Represents the scope of a skill (home directory vs project directory). +/// Represents the scope of a skill. #[derive( Debug, Clone, @@ -57,10 +57,8 @@ pub enum SkillProvider { VariantNames, )] pub enum SkillScope { - /// Skills from the user's home directory (e.g., `~/.agents/skills`). - #[default] - Home, /// Skills from a project directory (e.g., `./repo/.agents/skills`). + #[default] Project, /// Bundled skills distributed with Warp. Bundled, @@ -195,26 +193,9 @@ pub fn get_provider_for_path(path: &Path) -> Option { None } -/// Returns the skill scope (Home or Project) for a given path. -/// A skill is considered a "Home" skill if its path starts with the user's home directory. -/// Otherwise, it's a "Project" skill. -pub fn get_scope_for_path(path: &Path) -> SkillScope { - for def in SKILL_PROVIDER_DEFINITIONS.iter() { - if home_skills_path(def.provider) - .into_iter() - .any(|home_skills_path| path.starts_with(home_skills_path)) - { - return SkillScope::Home; - } - } - SkillScope::Project -} - #[cfg(test)] mod tests { - use super::{ - get_provider_for_path, get_scope_for_path, home_skills_path, SkillProvider, SkillScope, - }; + use super::{get_provider_for_path, home_skills_path, SkillProvider}; #[test] fn warp_home_skills_path_uses_warp_home_path() { @@ -225,7 +206,7 @@ mod tests { } #[test] - fn warp_home_skill_path_is_home_warp_skill() { + fn warp_home_skill_path_uses_warp_provider() { let Some(warp_home_skills_dir) = warp_core::paths::warp_home_skills_dir() else { eprintln!("Skipping test: home directory not available"); return; @@ -233,6 +214,5 @@ mod tests { let path = warp_home_skills_dir.join("my-skill").join("SKILL.md"); assert_eq!(get_provider_for_path(&path), Some(SkillProvider::Warp)); - assert_eq!(get_scope_for_path(&path), SkillScope::Home); } } From d18838c10073ed0cb23ccfc1f1116702e4a5ddb3 Mon Sep 17 00:00:00 2001 From: leo <450019458@qq.com> Date: Tue, 12 May 2026 10:46:55 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(skill-manager):=20=E5=A4=84=E7=90=86=20?= =?UTF-8?q?CodeRabbit=20=E8=AF=84=E5=AE=A1=E6=84=8F=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/i18n/en/warp.ftl | 2 ++ app/i18n/zh-CN/warp.ftl | 2 ++ app/src/ai/skills/dummy_skill_manager.rs | 3 ++- app/src/ai/skills/skill_manager.rs | 3 ++- app/src/ai/skills/skill_utils.rs | 6 +++--- app/src/ai/skills/telemetry.rs | 4 +++- app/src/skill_manager/panel.rs | 6 ++++-- app/src/workspace/view.rs | 24 +++++++++++++++--------- 8 files changed, 33 insertions(+), 17 deletions(-) diff --git a/app/i18n/en/warp.ftl b/app/i18n/en/warp.ftl index 266e533d48..9fe21030e4 100644 --- a/app/i18n/en/warp.ftl +++ b/app/i18n/en/warp.ftl @@ -2850,6 +2850,8 @@ workspace-left-panel-skill-manager = Skill Manager skill-manager-search-placeholder = Search skills skill-manager-filter-all = All skill-manager-filter-provider = Source +skill-manager-meta-default = Default +skill-manager-meta-duplicate = Duplicate skill-manager-empty = No skills match the current filters. skill-manager-preview-empty = Select a skill to preview SKILL.md. workspace-left-panel-ssh-manager-placeholder = SSH Manager — coming soon diff --git a/app/i18n/zh-CN/warp.ftl b/app/i18n/zh-CN/warp.ftl index a6dcc8c22b..17291a6352 100644 --- a/app/i18n/zh-CN/warp.ftl +++ b/app/i18n/zh-CN/warp.ftl @@ -2756,6 +2756,8 @@ workspace-left-panel-skill-manager = Skill 管理器 skill-manager-search-placeholder = 搜索 skill skill-manager-filter-all = 全部 skill-manager-filter-provider = 来源 +skill-manager-meta-default = 默认 +skill-manager-meta-duplicate = 重复 skill-manager-empty = 当前过滤条件下没有 skill。 skill-manager-preview-empty = 选择一个 skill 预览 SKILL.md。 workspace-left-panel-ssh-manager-placeholder = SSH 管理器 — 功能开发中 diff --git a/app/src/ai/skills/dummy_skill_manager.rs b/app/src/ai/skills/dummy_skill_manager.rs index 2a469b1b3d..434e5bb73d 100644 --- a/app/src/ai/skills/dummy_skill_manager.rs +++ b/app/src/ai/skills/dummy_skill_manager.rs @@ -52,7 +52,8 @@ impl SkillManager { None } - pub fn list_skill_inventory(&self, _ctx: &AppContext) -> Vec { + pub fn list_skill_inventory(&self, ctx: &AppContext) -> Vec { + let _ = ctx; vec![] } diff --git a/app/src/ai/skills/skill_manager.rs b/app/src/ai/skills/skill_manager.rs index 22c69bd52e..6ef5e5e818 100644 --- a/app/src/ai/skills/skill_manager.rs +++ b/app/src/ai/skills/skill_manager.rs @@ -370,7 +370,8 @@ impl SkillManager { bundled.activation.is_enabled(ctx).then_some(&bundled.skill) } - pub fn list_skill_inventory(&self, _ctx: &AppContext) -> Vec { + pub fn list_skill_inventory(&self, ctx: &AppContext) -> Vec { + let _ = ctx; let mut by_name: HashMap> = HashMap::new(); for skill in self.skills_by_path.values() { diff --git a/app/src/ai/skills/skill_utils.rs b/app/src/ai/skills/skill_utils.rs index 55288d8058..b31aac95dc 100644 --- a/app/src/ai/skills/skill_utils.rs +++ b/app/src/ai/skills/skill_utils.rs @@ -34,14 +34,14 @@ use crate::warp_managed_paths_watcher::warp_managed_skill_dirs; /// - 同名不同内容 (不同 provider) → 保留高优先级 provider。 /// /// Each element of `skill_paths` is a `(dir_path, skill_file_path)` tuple where -/// `dir_path` is the directory that owns the skill (仅用于定位 skill,不参与 dedup)。 +/// `dir_path` is the directory that owns the skill and participates in the dedup key. /// /// **P0-3 prompt cache 补漏**:返回 Vec 按 `(name, reference)` 字典序排序。 /// 原因:`HashMap::into_values()` 迭代顺序不稳定,该返回值会进入 system prompt 的 /// skills section,顺序漂移就会让全部上游供应商(Anthropic / OpenAI / DeepSeek)的 /// prompt cache 全序失效。与 P0-3 MCP tools 排序同性质。 -/// name-dedup 后 name 已唯一,reference 排序键退化为不会被触发的 tiebreak,但保留 -/// 以便未来若放宽 dedup 仍是稳定排序。 +/// 当前按 `(name, owning directory)` 去重,所以不同目录可以同时保留同名 skill。 +/// reference 仍作为稳定排序的次级键,保证输出顺序可复现。 #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] pub(crate) fn unique_skills( skill_paths: &[(PathBuf, PathBuf)], diff --git a/app/src/ai/skills/telemetry.rs b/app/src/ai/skills/telemetry.rs index 586076401c..779b73524a 100644 --- a/app/src/ai/skills/telemetry.rs +++ b/app/src/ai/skills/telemetry.rs @@ -111,7 +111,9 @@ impl TelemetryEventDesc for SkillTelemetryEventDiscriminants { fn description(&self) -> &'static str { match self { Self::Read => "A skill was read via the ReadSkill tool call", - Self::Opened => "A skill was opened from an 'open skill' button or /edit-skill command", + Self::Opened => { + "A skill was opened from an open-skill entry point, slash command, or Skill Manager" + } } } diff --git a/app/src/skill_manager/panel.rs b/app/src/skill_manager/panel.rs index fad122894f..33c08138ef 100644 --- a/app/src/skill_manager/panel.rs +++ b/app/src/skill_manager/panel.rs @@ -295,9 +295,11 @@ impl SkillManagerPanel { let mut meta = format!("{} · {}", duplicate.provider, duplicate.scope); if has_duplicates { if is_default { - meta.push_str(" · default"); + meta.push_str(" · "); + meta.push_str(&crate::t!("skill-manager-meta-default")); } else { - meta.push_str(" · duplicate"); + meta.push_str(" · "); + meta.push_str(&crate::t!("skill-manager-meta-duplicate")); } } diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index ab454576bf..24aad9f172 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -5558,13 +5558,17 @@ impl Workspace { #[cfg(feature = "local_fs")] { let layout = *EditorSettings::as_ref(ctx).open_file_layout.value(); - self.open_file_with_target( - source.path().unwrap_or_default(), - FileTarget::CodeEditor(layout), - None, - source.clone(), - ctx, - ); + if let Some(path) = source.path() { + self.open_file_with_target( + path, + FileTarget::CodeEditor(layout), + None, + source.clone(), + ctx, + ); + } else { + log::error!("failed to open skill file: missing source path"); + } } #[cfg(not(feature = "local_fs"))] { @@ -18861,8 +18865,10 @@ impl Workspace { } // openWarp 独有:SSH 管理器,无 feature flag,默认始终显示。 views.push(ToolPanelView::SshManager); - // openWarp 独有:Skill 管理器,无 feature flag,默认始终显示。 - views.push(ToolPanelView::SkillManager); + // openWarp 独有:Skill 管理器,无 feature flag,local_fs 构建下默认显示。 + if cfg!(feature = "local_fs") { + views.push(ToolPanelView::SkillManager); + } views }