diff --git a/.github/skills/ui-action/SKILL.md b/.github/skills/ui-action/SKILL.md index 6ddf7ba8..a586d100 100644 --- a/.github/skills/ui-action/SKILL.md +++ b/.github/skills/ui-action/SKILL.md @@ -296,4 +296,4 @@ action is usable from every probe. ### Keep one probe focused Each JSON script represents one test case. Split unrelated behaviours into -separate probes so a single `FAILED-step…` screenshot tells you what broke. +separate probes so a single `FAILED-step…` screenshot tells you what broke. \ No newline at end of file diff --git a/README.md b/README.md index 9cca642c..8484b62a 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,18 @@ MCP support enables integrating external tools and services into Copilot workflo - **Custom Agents** allow users to create personalized agents with specific instructions and behaviors. - **Isolated Subagents** can be spawned by the main agent to handle specific tasks or contexts independently. - **Plan Agent** can generate multi-step plans to accomplish complex tasks, breaking them down into manageable actions. +- **Skills** are reusable, specialized AI assistant templates that enrich chat context in Agent Mode. Skills are defined as `SKILL.md` files and can be scoped to a workspace or shared globally. + + - Creating Skills + + Place a `SKILL.md` file in any of these directories: + + - **Project-scoped:** `.github/skills//`, `.claude/skills//`, `.agents/skills//` + - **User-scoped (global):** `~/.copilot/skills//`, `~/.claude/skills//`, `~/.agents/skills//` + + Each `SKILL.md` file can include YAML front matter with metadata (name, description) followed by Markdown content that provides domain knowledge, workflows, or instructions for the AI assistant. + + Skills are automatically discovered and available in Agent Mode. You can enable or disable skills in **Window → Preferences → Copilot → Chat → Enable Skills**. For other available features in Eclipse, see the [Copilot feature matrix](https://docs.github.com/en/copilot/reference/copilot-feature-matrix?tool=eclipse). diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java index 7e2ae6ca..550a177a 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java @@ -28,6 +28,7 @@ private Constants() { public static final String WORKSPACE_CONTEXT_ENABLED = "workspaceContextEnabled"; public static final String SUB_AGENT_ENABLED = "subAgentEnabled"; public static final String AGENT_MAX_REQUESTS = "agentMaxRequests"; + public static final String ENABLE_SKILLS = "enableSkills"; public static final String MCP = "mcp"; public static final String MCP_REGISTRY_URL = "mcpRegistryUrl"; public static final String MCP_REGISTRY_VERSION = "v0.1"; diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/events/CopilotEventConstants.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/events/CopilotEventConstants.java index abd9f57f..e61052cb 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/events/CopilotEventConstants.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/events/CopilotEventConstants.java @@ -160,4 +160,10 @@ public class CopilotEventConstants { * Event when a rate limit warning is received from the language server. */ public static final String TOPIC_RATE_LIMIT_WARNING = TOPIC_CHAT + "RATE_LIMIT_WARNING"; + + /** + * Event when custom prompts, skills, agents, or instructions change on the language server. Clients should re-fetch + * conversation templates on receipt. + */ + public static final String TOPIC_CHAT_DID_CHANGE_CUSTOMIZATION_FILES = TOPIC_CHAT + "DID_CHANGE_CUSTOMIZATION_FILES"; } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java index 4320c072..ca8f7f48 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java @@ -136,11 +136,11 @@ public CompletableFuture getConversationContext(ConversationContextPar public CompletableFuture invokeClientTool(InvokeClientToolParams params) { return CompletableFuture.supplyAsync(() -> { try { - CompletableFuture toolFuture = - CopilotCore.getPlugin().getChatEventsManager().invokeAgentTool(params); + CompletableFuture toolFuture = CopilotCore.getPlugin().getChatEventsManager() + .invokeAgentTool(params); if (toolFuture == null) { - CopilotCore.LOGGER.error( - new IllegalStateException("invokeAgentTool returned null for tool: " + params.getName())); + CopilotCore.LOGGER + .error(new IllegalStateException("invokeAgentTool returned null for tool: " + params.getName())); LanguageModelToolResult errorResult = new LanguageModelToolResult(); errorResult.addContent("Failed to invoke the tool: tool invocation returned null"); errorResult.setStatus(ToolInvocationStatus.error); @@ -178,6 +178,8 @@ public CompletableFuture confirmClientTool(InvokeClientToolConfirmatio }); } + // TODO: Should remove workspace-root folder as the projects are not directly under it in Eclipse, and can cause + // confusion in CLS. @Override public CompletableFuture> workspaceFolders() { // Ideally, we should return each IProject as a workspace folder, but given that when @@ -255,9 +257,30 @@ public void onRateLimitWarning(RateLimitWarningParams params) { } /** - * Handles the Dynamic OAuth request for MCP. - * Shows a dialog with multiple input fields and returns the user's input values. - * Returns null if the user cancels the request. + * Notify when custom skills change (global or workspace). Signal-only; clients re-fetch templates. + */ + @JsonNotification("copilot/customSkill/didChange") + public void onDidChangeCustomSkill(Object params) { + notifyCustomizationFilesChanged(); + } + + /** + * Notify when custom prompts change (global or workspace). Signal-only; clients re-fetch templates. + */ + @JsonNotification("copilot/customPrompt/didChange") + public void onDidChangeCustomPrompt(Object params) { + notifyCustomizationFilesChanged(); + } + + private void notifyCustomizationFilesChanged() { + if (eventBroker != null) { + eventBroker.post(CopilotEventConstants.TOPIC_CHAT_DID_CHANGE_CUSTOMIZATION_FILES, null); + } + } + + /** + * Handles the Dynamic OAuth request for MCP. Shows a dialog with multiple input fields and returns the user's input + * values. Returns null if the user cancels the request. */ @JsonRequest("copilot/dynamicOAuth") public CompletableFuture> mcpOauth(McpOauthRequest request) { @@ -303,13 +326,11 @@ public void onDidChangePolicy(DidChangePolicyParams params) { } if (flags.isSubAgentPolicyEnabled() != params.isSubAgentEnabled()) { flags.setSubAgentPolicyEnabled(params.isSubAgentEnabled()); - eventBroker.post(CopilotEventConstants.TOPIC_DID_CHANGE_SUB_AGENT_POLICY, - params.isSubAgentEnabled()); + eventBroker.post(CopilotEventConstants.TOPIC_DID_CHANGE_SUB_AGENT_POLICY, params.isSubAgentEnabled()); } if (flags.isCustomAgentPolicyEnabled() != params.isCustomAgentEnabled()) { flags.setCustomAgentPolicyEnabled(params.isCustomAgentEnabled()); - eventBroker.post(CopilotEventConstants.TOPIC_DID_CHANGE_CUSTOM_AGENT_POLICY, - params.isCustomAgentEnabled()); + eventBroker.post(CopilotEventConstants.TOPIC_DID_CHANGE_CUSTOM_AGENT_POLICY, params.isCustomAgentEnabled()); } } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServer.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServer.java index 8065520a..8ac98fef 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServer.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServer.java @@ -28,6 +28,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationMode; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationModesParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationTemplate; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationTemplatesParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationTurnParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult; @@ -137,9 +138,11 @@ public interface CopilotLanguageServer extends LanguageServer { /** * List conversation templates. + * + * @param params includes workspace folders for discovering workspace-specific prompt files and skills */ @JsonRequest("conversation/templates") - CompletableFuture listTemplates(NullParams param); + CompletableFuture listTemplates(ConversationTemplatesParams params); /** * List conversation modes. diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java index 8aaa7f6d..fc2c6830 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java @@ -20,6 +20,7 @@ import org.eclipse.lsp4j.ProgressParams; import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.eclipse.lsp4j.WorkspaceFolder; import org.eclipse.lsp4j.jsonrpc.Endpoint; import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.services.LanguageServer; @@ -45,6 +46,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationMode; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationModesParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationTemplate; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationTemplatesParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationTurnParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult; @@ -369,10 +371,12 @@ public CompletableFuture addConversationTurn(String workDoneToke /** * List the conversation templates. + * + * @param workspaceFolders workspace folders for discovering workspace-specific prompt files and skills */ - public CompletableFuture listConversationTemplates() { + public CompletableFuture listConversationTemplates(List workspaceFolders) { Function> fn = server -> { - return ((CopilotLanguageServer) server).listTemplates(new NullParams()); + return ((CopilotLanguageServer) server).listTemplates(new ConversationTemplatesParams(workspaceFolders)); }; return this.languageServerWrapper.execute(fn); } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplate.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplate.java index f643ac36..23303571 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplate.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplate.java @@ -4,78 +4,14 @@ package com.microsoft.copilot.eclipse.core.lsp.protocol; import java.util.List; -import java.util.Objects; - -import org.apache.commons.lang3.builder.ToStringBuilder; /** - * Get the templates. + * Represents a conversation template returned by the language server. */ -public class ConversationTemplate { - - - private String id; - private String description; - private String shortDescription; - private List scopes; - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public String getShortDescription() { - return shortDescription; - } - - public void setShortDescription(String shortDescription) { - this.shortDescription = shortDescription; - } - - public List getScopes() { - return scopes; - } - - public void setScopes(List scopes) { - this.scopes = scopes; - } - - @Override - public int hashCode() { - return Objects.hash(id, description, shortDescription, scopes); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null || getClass() != obj.getClass()) { - return false; - } - ConversationTemplate that = (ConversationTemplate) obj; - return Objects.equals(id, that.id) && Objects.equals(description, that.description) - && Objects.equals(shortDescription, that.shortDescription) && Objects.equals(scopes, that.scopes); - } - - @Override - public String toString() { - ToStringBuilder builder = new ToStringBuilder(this); - builder.append("id", id); - builder.append("description", description); - builder.append("shortDescription", shortDescription); - builder.append("scopes", scopes); - return builder.toString(); - } +public record ConversationTemplate( + String id, + String description, + String shortDescription, + List scopes, + TemplateSource source) { } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplatesParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplatesParams.java new file mode 100644 index 00000000..6b384b94 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplatesParams.java @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.Collections; +import java.util.List; + +import org.eclipse.lsp4j.WorkspaceFolder; + +/** + * Parameters for the {@code conversation/templates} request. + * + * @param workspaceFolders the workspace folders used to discover workspace-specific prompt files and skills + */ +public record ConversationTemplatesParams(List workspaceFolders) { + /** Compact constructor that defaults {@code null} workspace folders to an empty list. */ + public ConversationTemplatesParams { + workspaceFolders = workspaceFolders != null ? workspaceFolders : Collections.emptyList(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java index 254a1266..42a514d7 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java @@ -15,6 +15,7 @@ public class CopilotAgentSettings { @SerializedName("maxToolCallingLoop") private int agentMaxRequests; + private boolean enableSkills; public int getAgentMaxRequests() { return agentMaxRequests; @@ -24,9 +25,17 @@ public void setAgentMaxRequests(int agentMaxRequests) { this.agentMaxRequests = agentMaxRequests; } + public boolean isEnableSkills() { + return enableSkills; + } + + public void setEnableSkills(boolean enableSkills) { + this.enableSkills = enableSkills; + } + @Override public int hashCode() { - return Objects.hash(agentMaxRequests); + return Objects.hash(agentMaxRequests, enableSkills); } @Override @@ -38,13 +47,14 @@ public boolean equals(Object obj) { return false; } CopilotAgentSettings other = (CopilotAgentSettings) obj; - return agentMaxRequests == other.agentMaxRequests; + return agentMaxRequests == other.agentMaxRequests && enableSkills == other.enableSkills; } @Override public String toString() { ToStringBuilder builder = new ToStringBuilder(this); builder.append("agentMaxRequests", agentMaxRequests); + builder.append("enableSkills", enableSkills); return builder.toString(); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/TemplateSource.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/TemplateSource.java new file mode 100644 index 00000000..e5363a88 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/TemplateSource.java @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import com.google.gson.annotations.SerializedName; + +/** + * Source of a conversation template. + */ +public enum TemplateSource { + @SerializedName("builtin") + BUILTIN, + + @SerializedName("prompt") + PROMPT, + + @SerializedName("skill") + SKILL +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionServiceTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionServiceTest.java index 7634eee8..79f9c3df 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionServiceTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionServiceTest.java @@ -7,55 +7,91 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.List; import java.util.concurrent.CompletableFuture; import org.eclipse.core.runtime.jobs.Job; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.ui.IWorkbench; +import org.eclipse.ui.PlatformUI; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.Mockito; import com.microsoft.copilot.eclipse.core.AuthStatusManager; +import com.microsoft.copilot.eclipse.core.Constants; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatMode; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationAgent; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationTemplate; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotScope; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult; +import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.preferences.LanguageServerSettingManager; class ChatCompletionServiceTest { - @Mock private static CopilotLanguageServerConnection mockLsConnection; - @Mock - private static ConversationTemplate mockTemplate; - - @Mock private static AuthStatusManager mockAuthStatusManager; private static ChatCompletionService chatCompletionService; + private static MockedStatic copilotUiMock; + private static MockedStatic platformUiMock; @BeforeAll static void setUp() { // Initialize the mocks mockLsConnection = Mockito.mock(CopilotLanguageServerConnection.class); - mockTemplate = Mockito.mock(ConversationTemplate.class); mockAuthStatusManager = Mockito.mock(AuthStatusManager.class); - ConversationTemplate[] templates = new ConversationTemplate[] { mockTemplate }; - when(mockLsConnection.listConversationTemplates()).thenReturn(CompletableFuture.completedFuture(templates)); - when(mockTemplate.getScopes()).thenReturn(List.of(CopilotScope.CHAT_PANEL)); - when(mockTemplate.getId()).thenReturn("test"); + + // Mock CopilotUi.getPlugin() so the constructor can register its preference listener + CopilotUi mockPlugin = mock(CopilotUi.class); + IPreferenceStore mockPreferenceStore = mock(IPreferenceStore.class); + LanguageServerSettingManager mockSettingManager = mock(LanguageServerSettingManager.class); + when(mockPlugin.getLanguageServerSettingManager()).thenReturn(mockSettingManager); + when(mockPlugin.getPreferenceStore()).thenReturn(mockPreferenceStore); + when(mockPreferenceStore.getBoolean(Constants.ENABLE_SKILLS)).thenReturn(true); + copilotUiMock = Mockito.mockStatic(CopilotUi.class); + copilotUiMock.when(CopilotUi::getPlugin).thenReturn(mockPlugin); + + // Mock PlatformUI so the constructor can safely obtain an IEventBroker + IWorkbench mockWorkbench = mock(IWorkbench.class); + when(mockWorkbench.getService(any())).thenReturn(null); + platformUiMock = Mockito.mockStatic(PlatformUI.class); + platformUiMock.when(PlatformUI::getWorkbench).thenReturn(mockWorkbench); + + ConversationTemplate template = new ConversationTemplate("test", null, null, + List.of(CopilotScope.CHAT_PANEL), null); + ConversationTemplate[] templates = new ConversationTemplate[] { template }; + when(mockLsConnection.listConversationTemplates(any())).thenReturn(CompletableFuture.completedFuture(templates)); + when(mockLsConnection.listConversationAgents()) + .thenReturn(CompletableFuture.completedFuture(new ConversationAgent[0])); when(mockAuthStatusManager.getCopilotStatus()).thenReturn(CopilotStatusResult.OK); chatCompletionService = new ChatCompletionService(mockLsConnection, mockAuthStatusManager); - Job[] jobs = Job.getJobManager().find(ChatCompletionService.INIT_JOB_FAMILY); - for (Job job : jobs) { - try { - job.join(); - } catch (InterruptedException e) { - continue; - } + try { + Job.getJobManager().join(ChatCompletionService.REFRESH_JOB_FAMILY, null); + } catch (InterruptedException e) { + // ignore + } + } + + @AfterAll + static void tearDown() { + if (chatCompletionService != null) { + chatCompletionService.dispose(); + } + if (copilotUiMock != null) { + copilotUiMock.close(); + } + if (platformUiMock != null) { + platformUiMock.close(); } } @@ -67,7 +103,7 @@ void testConstructor() { @Test void testInitConversationTemplates() throws Exception { - assertEquals(1, chatCompletionService.getTemplates().length); + assertEquals(1, chatCompletionService.getFilteredTemplates(ChatMode.Ask).length); } @Test @@ -83,7 +119,7 @@ void testIsCommand() { } @Test - void testGetTemplates() { - assertNotNull(chatCompletionService.getTemplates()); + void testGetFilteredTemplates() { + assertNotNull(chatCompletionService.getFilteredTemplates(ChatMode.Ask)); } } \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java index 1be3ea3d..a0e9c452 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java @@ -29,6 +29,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotLanguageServerSettings; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotLanguageServerSettings.CopilotSettings; import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.utils.PreferencesUtils; @ExtendWith(MockitoExtension.class) class LanguageServerSettingManagerTests { @@ -47,7 +48,10 @@ void testNoProxy() { // arrange when(mockPreferenceStore.getBoolean(Constants.AUTO_SHOW_COMPLETION)).thenReturn(true); var params = new DidChangeConfigurationParams(); - params.setSettings(new CopilotLanguageServerSettings()); + var noProxySettings = new CopilotLanguageServerSettings(); + noProxySettings.getGithubSettings().getCopilotSettings().getAgent() + .setEnableSkills(PreferencesUtils.isSkillsEnabled()); + params.setSettings(noProxySettings); // act LanguageServerSettingManager manager = new LanguageServerSettingManager(mockLsConnection, mockProxyService, @@ -74,6 +78,8 @@ void testBasicProxy() { var params = new DidChangeConfigurationParams(); var settings = new CopilotLanguageServerSettings(); settings.getHttp().setProxy("HTTPS://localhost:8080"); + settings.getGithubSettings().getCopilotSettings().getAgent() + .setEnableSkills(PreferencesUtils.isSkillsEnabled()); params.setSettings(settings); // act @@ -107,6 +113,7 @@ void testUpdateConfigShouldBeCalledWhenWorkspaceInstructionsEnabledWithContent() DidChangeConfigurationParams params = new DidChangeConfigurationParams(); CopilotSettings copilotSettings = new CopilotSettings(); copilotSettings.setWorkspaceCopilotInstructions("Test instructions"); + copilotSettings.getAgent().setEnableSkills(PreferencesUtils.isSkillsEnabled()); CopilotLanguageServerSettings settings = new CopilotLanguageServerSettings(); settings.getGithubSettings().setCopilotSettings(copilotSettings); params.setSettings(settings); @@ -137,6 +144,8 @@ void testUpdateConfigShouldBeCalledWithoutInstructionWhenWorkspaceInstructionsDi // Expected params should have empty workspace instructions since it's disabled DidChangeConfigurationParams expectedParams = new DidChangeConfigurationParams(); CopilotLanguageServerSettings expectedSettings = new CopilotLanguageServerSettings(); + expectedSettings.getGithubSettings().getCopilotSettings().getAgent() + .setEnableSkills(PreferencesUtils.isSkillsEnabled()); expectedParams.setSettings(expectedSettings); // act diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessor.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessor.java index 208c64e5..0b961438 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessor.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessor.java @@ -3,10 +3,15 @@ package com.microsoft.copilot.eclipse.ui.chat; +import java.util.AbstractMap.SimpleEntry; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; import java.util.List; +import java.util.Map.Entry; import java.util.Objects; +import org.apache.commons.lang3.StringUtils; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.IDocument; import org.eclipse.jface.text.ITextViewer; @@ -27,6 +32,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatMode; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationAgent; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationTemplate; +import com.microsoft.copilot.eclipse.core.lsp.protocol.TemplateSource; import com.microsoft.copilot.eclipse.ui.chat.services.ChatCompletionService; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; @@ -43,11 +49,17 @@ public ChatAssistProcessor(TextViewer input, ChatServiceManager chatServiceManag class ChatCompletionProposal implements ICompletionProposal, ICompletionProposalExtension6 { private String triggerCharacter; private String name; + private String displayName; private String description; public ChatCompletionProposal(String mark, String name, String description) { + this(mark, name, name, description); + } + + public ChatCompletionProposal(String mark, String name, String displayName, String description) { this.triggerCharacter = mark; this.name = name; + this.displayName = displayName; this.description = description; } @@ -80,7 +92,7 @@ public IContextInformation getContextInformation() { @Override public String getDisplayString() { - return triggerCharacter + name; + return triggerCharacter + displayName; } @Override @@ -96,30 +108,63 @@ public Point getSelection(IDocument document) { @Override public StyledString getStyledDisplayString() { StyledString styledString = new StyledString(); - styledString.append(triggerCharacter + name); + styledString.append(triggerCharacter + displayName); styledString.append(" - " + description, StyledString.QUALIFIER_STYLER); return styledString; } } public ICompletionProposal[] createCopilotCompletionTemplateProposals(String prefix) { - List proposals = new ArrayList<>(); ChatCompletionService commandService = chatServiceManager.getChatCompletionService(); if (!commandService.isTempaltesReady()) { return new ICompletionProposal[0]; } - // So far no template supports agent mode. - if (Objects.equals(chatServiceManager.getUserPreferenceService().getActiveChatMode(), ChatMode.Agent)) { - return new ICompletionProposal[0]; + // Filter templates by the scope matching the active chat mode (ask → chat-panel, agent → agent-panel). + ChatMode chatMode = chatServiceManager.getUserPreferenceService().getActiveChatMode(); + ConversationTemplate[] templates = commandService.getFilteredTemplates(chatMode); + String lowerPrefix = prefix.toLowerCase(); + + // Sort results by match quality, then build proposals. + return Arrays.stream(templates).filter(t -> StringUtils.isNotBlank(t.id())) + .map(t -> new SimpleEntry<>(t, getMatchPriority(t, lowerPrefix))) + .filter(e -> e.getValue() >= 0).sorted(Comparator.comparingInt(Entry::getValue)).map(e -> { + ConversationTemplate t = e.getKey(); + boolean isSkill = t.source() == TemplateSource.SKILL; + String displayName = isSkill && StringUtils.isNotBlank(t.shortDescription()) ? t.shortDescription() : t.id(); + return (ICompletionProposal) new ChatCompletionProposal(ChatCompletionService.TEMPLATE_MARK, t.id(), + displayName, t.description()); + }).toArray(ICompletionProposal[]::new); + } + + /** + * Returns a priority for how well the template matches the prefix (lower is better), + * or -1 if it does not match at all. + * + *

Priority buckets: + * 0 – id starts with prefix (or prefix is empty) + * 1 – id contains prefix (or skill shortDescription contains prefix) + * 2 – description starts with prefix + * 3 – description contains prefix + */ + private int getMatchPriority(ConversationTemplate template, String lowerPrefix) { + if (lowerPrefix.isEmpty()) { + return 0; } - ConversationTemplate[] templates = commandService.getTemplates(); - for (ConversationTemplate template : templates) { - if (prefix.isEmpty() || template.getId().startsWith(prefix)) { - proposals.add(new ChatCompletionProposal(ChatCompletionService.TEMPLATE_MARK, template.getId(), - template.getDescription())); - } + boolean isSkill = template.source() == TemplateSource.SKILL; + String id = template.id() != null ? template.id().toLowerCase() : ""; + String desc = template.description() != null ? template.description().toLowerCase() : ""; + String shortDesc = template.shortDescription() != null ? template.shortDescription().toLowerCase() : ""; + + if (id.startsWith(lowerPrefix)) { + return 0; + } else if (id.contains(lowerPrefix) || (isSkill && shortDesc.contains(lowerPrefix))) { + return 1; + } else if (desc.startsWith(lowerPrefix)) { + return 2; + } else if (desc.contains(lowerPrefix)) { + return 3; } - return proposals.toArray(new ICompletionProposal[proposals.size()]); + return -1; } public ICompletionProposal[] createCopilotCompletionAgentProposals(String prefix) { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java index 89a60c58..cff99834 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java @@ -10,21 +10,35 @@ import java.util.Set; import java.util.concurrent.ExecutionException; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.resources.IResourceChangeEvent; +import org.eclipse.core.resources.IResourceChangeListener; +import org.eclipse.core.resources.IResourceDelta; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.IStatus; import org.eclipse.core.runtime.Status; import org.eclipse.core.runtime.jobs.Job; +import org.eclipse.e4.core.services.events.IEventBroker; +import org.eclipse.lsp4e.LSPEclipseUtils; +import org.eclipse.lsp4j.WorkspaceFolder; +import org.eclipse.ui.PlatformUI; +import org.osgi.service.event.EventHandler; import com.microsoft.copilot.eclipse.core.AuthStatusManager; import com.microsoft.copilot.eclipse.core.CopilotAuthStatusListener; import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.FeatureFlags; -import com.microsoft.copilot.eclipse.core.IdeCapabilities; +import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatMode; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationAgent; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationTemplate; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotScope; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.TemplateSource; +import com.microsoft.copilot.eclipse.ui.utils.PreferencesUtils; /** * Service for handling slash commands. @@ -33,15 +47,21 @@ public class ChatCompletionService implements CopilotAuthStatusListener { public static final String AGENT_MARK = "@"; public static final String TEMPLATE_MARK = "/"; - private List templates = new ArrayList<>(); - private List agents = new ArrayList<>(); - private HashSet allCommands = new HashSet<>(); + private volatile List templates = List.of(); + private volatile List agents = List.of(); + private volatile Set allCommands = Set.of(); // Exclude intelliJ sepcific slash commands private static final Set EXCLUDED_COMMANDS = Set.of("help", "feedback"); - public static final String INIT_JOB_FAMILY = - "com.microsoft.copilot.eclipse.chat.services.SlashCommandService.initJob"; + public static final String REFRESH_JOB_FAMILY = + "com.microsoft.copilot.eclipse.chat.services.SlashCommandService.refreshJob"; private CopilotLanguageServerConnection lsConnection; private AuthStatusManager authStatusManager; + private IResourceChangeListener skillFileListener; + private IEventBroker eventBroker; + private EventHandler customPromptsChangedHandler; + + private static final String SKILL_FILE_NAME = "SKILL.md"; + private static final String PROMPT_FILE_SUFFIX = ".prompt.md"; /** * Constructor for the SlashCommandService. @@ -50,51 +70,79 @@ public ChatCompletionService(CopilotLanguageServerConnection lsConnection, AuthS this.authStatusManager = authStatusManager; this.lsConnection = lsConnection; this.authStatusManager.addCopilotAuthStatusListener(this); + // TODO: Remove this listener once workspace-root is removed from workspaceFolders in CopilotLanguageClient as CLS + // can watch the project prompt file change directly. + this.skillFileListener = new SkillFileChangeListener(); + ResourcesPlugin.getWorkspace().addResourceChangeListener(skillFileListener, IResourceChangeEvent.POST_CHANGE); + this.eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); + if (this.eventBroker != null) { + this.customPromptsChangedHandler = event -> fetchAsync(); + this.eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_DID_CHANGE_CUSTOMIZATION_FILES, + customPromptsChangedHandler); + } syncCommands(this.authStatusManager.getCopilotStatus()); } - private void initAsync() { - final Runnable initRunnable = () -> { - initConversationTemplates(); - }; + private void fetchAsync() { + Job.getJobManager().cancel(REFRESH_JOB_FAMILY); - Job initJob = new Job("Initialize slash commands service") { + Job refreshJob = new Job("Refresh slash commands service") { @Override protected IStatus run(IProgressMonitor monitor) { - initRunnable.run(); + initConversationTemplates(monitor); + if (monitor.isCanceled()) { + return Status.CANCEL_STATUS; + } return Status.OK_STATUS; } @Override public boolean belongsTo(Object family) { - return Objects.equals(INIT_JOB_FAMILY, family); + return Objects.equals(REFRESH_JOB_FAMILY, family); } }; - initJob.setUser(false); - initJob.schedule(); + refreshJob.setUser(false); + refreshJob.schedule(); } - private void initConversationTemplates() { - if (isTempaltesReady() && isAgentsReady()) { - return; - } + private void initConversationTemplates(IProgressMonitor monitor) { + List newTemplates = new ArrayList<>(); + List newAgents = new ArrayList<>(); + Set newCommands = new HashSet<>(); + boolean skillsEnabled = PreferencesUtils.isSkillsEnabled(); // Command: /*** + // Pass workspace folders so the language server returns workspace-specific + // prompt files (.prompt.md) and skills (SKILL.md) alongside built-in templates. try { - ConversationTemplate[] rawTemplates = this.lsConnection.listConversationTemplates().get(); + List workspaceFolders = LSPEclipseUtils.getWorkspaceFolders(); + ConversationTemplate[] rawTemplates = this.lsConnection.listConversationTemplates(workspaceFolders).get(); + if (monitor.isCanceled()) { + return; + } for (ConversationTemplate template : rawTemplates) { - if (template.getScopes().contains(CopilotScope.CHAT_PANEL) && !EXCLUDED_COMMANDS.contains(template.getId())) { - templates.add(template); - allCommands.add(TEMPLATE_MARK + template.getId()); + if (!skillsEnabled && template.source() == TemplateSource.SKILL) { + continue; + } + if (!EXCLUDED_COMMANDS.contains(template.id())) { + newTemplates.add(template); + newCommands.add(TEMPLATE_MARK + template.id()); } } } catch (InterruptedException | ExecutionException e) { CopilotCore.LOGGER.error(e); } + if (monitor.isCanceled()) { + return; + } + // Command: @*** try { ConversationAgent[] rawAgents = this.lsConnection.listConversationAgents().get(); + if (monitor.isCanceled()) { + return; + } for (ConversationAgent agent : rawAgents) { String agentSlug = agent.getSlug(); // @see ui.chat.ChatView#replaceWorkspaceCommand(String) @@ -105,12 +153,32 @@ private void initConversationTemplates() { agent.setSlug("workspace"); } - agents.add(agent); - allCommands.add(AGENT_MARK + agent.getSlug()); + newAgents.add(agent); + newCommands.add(AGENT_MARK + agent.getSlug()); } } catch (InterruptedException | ExecutionException e) { CopilotCore.LOGGER.error(e); } + + if (monitor.isCanceled()) { + return; + } + + // Atomically swap the cached data so readers always see a consistent snapshot. + // Publish immutable snapshots so readers cannot accidentally mutate a live collection. + this.templates = List.copyOf(newTemplates); + this.agents = List.copyOf(newAgents); + this.allCommands = Set.copyOf(newCommands); + } + + /** + * Returns templates filtered by the scope appropriate for the given chat mode. In Agent mode only {@code agent-panel} + * scoped templates (including skills) are shown; in Ask mode only {@code chat-panel} scoped templates are shown. + */ + public ConversationTemplate[] getFilteredTemplates(ChatMode chatMode) { + String scope = chatMode == ChatMode.Agent ? CopilotScope.AGENT_PANEL : CopilotScope.CHAT_PANEL; + return templates.stream().filter(t -> t.scopes() != null && t.scopes().contains(scope)) + .toArray(ConversationTemplate[]::new); } /** @@ -170,10 +238,6 @@ public boolean isAgentsReady() { return agents != null && agents.size() > 0; } - public ConversationTemplate[] getTemplates() { - return templates.toArray(new ConversationTemplate[0]); - } - public ConversationAgent[] getAgents() { return agents.toArray(new ConversationAgent[0]); } @@ -187,12 +251,12 @@ public void onDidCopilotStatusChange(CopilotStatusResult copilotStatusResult) { private void syncCommands(String status) { switch (status) { case CopilotStatusResult.OK: - initAsync(); + fetchAsync(); break; default: - allCommands.clear(); - templates.clear(); - agents.clear(); + this.allCommands = Set.of(); + this.templates = List.of(); + this.agents = List.of(); break; } } @@ -202,5 +266,70 @@ private void syncCommands(String status) { */ public void dispose() { this.authStatusManager.removeCopilotAuthStatusListener(this); + ResourcesPlugin.getWorkspace().removeResourceChangeListener(skillFileListener); + if (this.eventBroker != null && this.customPromptsChangedHandler != null) { + this.eventBroker.unsubscribe(this.customPromptsChangedHandler); + } + } + + /** + * Listens for workspace resource changes involving SKILL.md or .prompt.md files and triggers a template refresh when + * such files are added, removed, or changed. + * + *

TODO: Remove this listener once workspace-root is removed from workspaceFolders in CopilotLanguageClient as CLS + * can watch the project prompt file change directly. + */ + private class SkillFileChangeListener implements IResourceChangeListener { + @Override + public void resourceChanged(IResourceChangeEvent event) { + IResourceDelta delta = event.getDelta(); + if (delta == null) { + return; + } + boolean[] needsRefresh = { false }; + try { + delta.accept(childDelta -> { + if (needsRefresh[0]) { + return false; + } + if (!shouldVisitDelta(childDelta)) { + return false; + } + if (isPromptOrSkillFileDelta(childDelta)) { + needsRefresh[0] = true; + return false; + } + return true; + }); + } catch (CoreException e) { + CopilotCore.LOGGER.error("Error visiting resource delta for skill file changes", e); + } + if (needsRefresh[0]) { + fetchAsync(); + } + } + + private boolean shouldVisitDelta(IResourceDelta delta) { + IResource resource = delta.getResource(); + return resource != null && !resource.isDerived() && !resource.isTeamPrivateMember(); + } + + private boolean isPromptOrSkillFileDelta(IResourceDelta delta) { + IResource resource = delta.getResource(); + if (resource.getType() != IResource.FILE || !isRelevantFileDelta(delta)) { + return false; + } + + String name = resource.getName(); + return SKILL_FILE_NAME.equals(name) || name.endsWith(PROMPT_FILE_SUFFIX); + } + + private boolean isRelevantFileDelta(IResourceDelta delta) { + int kind = delta.getKind(); + if (kind == IResourceDelta.ADDED || kind == IResourceDelta.REMOVED) { + return true; + } + return kind == IResourceDelta.CHANGED && (delta.getFlags() & IResourceDelta.CONTENT) != 0; + } } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java index f88283cb..11a791ef 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java @@ -35,6 +35,7 @@ */ public class ChatPreferencesPage extends FieldEditorPreferencePage implements IWorkbenchPreferencePage { public static final String ID = "com.microsoft.copilot.eclipse.ui.preferences.ChatPreferencesPage"; + private static final int FIELD_WIDTH_HINT = 400; /** * Constructor. @@ -50,40 +51,18 @@ public void createFieldEditors() { GridDataFactory gdf = GridDataFactory.fillDefaults().span(2, 1).align(SWT.FILL, SWT.FILL).grab(true, false); - Composite workspaceContextComposite = new Composite(parent, SWT.NONE); - workspaceContextComposite.setLayout(new GridLayout(1, true)); - gdf.applyTo(workspaceContextComposite); + Composite workspaceContextComposite = createSectionComposite(parent, gdf); BooleanFieldEditor workspaceContextField = new BooleanFieldEditor(Constants.WORKSPACE_CONTEXT_ENABLED, - Messages.preferences_page_watched_files, SWT.WRAP, - workspaceContextComposite); - GridData workspaceContextFieldGridData = new GridData(SWT.FILL, SWT.FILL, true, true); - workspaceContextFieldGridData.widthHint = 400; - workspaceContextField.getDescriptionControl(workspaceContextComposite).setLayoutData(workspaceContextFieldGridData); - + Messages.preferences_page_watched_files, SWT.WRAP, workspaceContextComposite); + applyFieldWidthHint(workspaceContextField, workspaceContextComposite); addField(workspaceContextField); - // add chat note using WrappableNoteLabel - WrappableNoteLabel workspaceContextNote = new WrappableNoteLabel(parent, - Messages.preferences_page_note_prefix + " ", - Messages.preferences_page_watched_files_note_content); - GridData workspaceContextNoteGridData = new GridData(SWT.FILL, SWT.CENTER, true, false); - workspaceContextNoteGridData.horizontalSpan = 2; - workspaceContextNote.setLayoutData(workspaceContextNoteGridData); - - // add separator - Label separator = new Label(parent, SWT.SEPARATOR | SWT.HORIZONTAL); - GridData separatorGridData = new GridData(SWT.FILL, SWT.CENTER, true, false); - separatorGridData.horizontalSpan = 2; - separator.setLayoutData(separatorGridData); + addNote(parent, Messages.preferences_page_watched_files_note_content); + addSeparator(parent); // Add sub-agent toggle - Composite subAgentComposite = new Composite(parent, SWT.NONE); - subAgentComposite.setLayout(new GridLayout(1, true)); - gdf.applyTo(subAgentComposite); - // Check if sub-agent is disabled by policy - FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); - boolean policyAllowsSubAgent = flags != null && flags.isClientPreviewFeatureEnabled() - && flags.isSubAgentPolicyEnabled(); + Composite subAgentComposite = createSectionComposite(parent, gdf); + boolean policyAllowsSubAgent = isPolicyAllowsSubAgent(); if (!policyAllowsSubAgent) { Composite disabledComposite = new Composite(subAgentComposite, SWT.NONE); GridLayout disabledCompositeLayout = new GridLayout(1, false); @@ -98,29 +77,27 @@ public void createFieldEditors() { BooleanFieldEditor subAgentField = new BooleanFieldEditor(Constants.SUB_AGENT_ENABLED, Messages.preferences_page_sub_agent, SWT.WRAP, subAgentComposite); subAgentField.setEnabled(policyAllowsSubAgent, subAgentComposite); - GridData subAgentFieldGridData = new GridData(SWT.FILL, SWT.FILL, true, true); - subAgentFieldGridData.widthHint = 400; - subAgentField.getDescriptionControl(subAgentComposite).setLayoutData(subAgentFieldGridData); + applyFieldWidthHint(subAgentField, subAgentComposite); addField(subAgentField); - // add sub-agent note using WrappableNoteLabel - WrappableNoteLabel subAgentNote = new WrappableNoteLabel(parent, - Messages.preferences_page_note_prefix + " ", - Messages.preferences_page_sub_agent_note_content); - GridData subAgentNoteGridData = new GridData(SWT.FILL, SWT.CENTER, true, false); - subAgentNoteGridData.horizontalSpan = 2; - subAgentNote.setLayoutData(subAgentNoteGridData); + addNote(parent, Messages.preferences_page_sub_agent_note_content); + addSeparator(parent); - // add separator - Label separator2 = new Label(parent, SWT.SEPARATOR | SWT.HORIZONTAL); - GridData separator2GridData = new GridData(SWT.FILL, SWT.CENTER, true, false); - separator2GridData.horizontalSpan = 2; - separator2.setLayoutData(separator2GridData); + if (isClientPreviewFeatureEnabled()) { + // Add Enable Skills toggle + Composite skillsComposite = createSectionComposite(parent, gdf); + + BooleanFieldEditor skillsField = new BooleanFieldEditor(Constants.ENABLE_SKILLS, + Messages.preferences_page_skills_enabled, SWT.WRAP, skillsComposite); + applyFieldWidthHint(skillsField, skillsComposite); + addField(skillsField); + + addNote(parent, Messages.preferences_page_skills_enabled_note_content); + addSeparator(parent); + } // Add Agent Max Requests field - Composite agentMaxRequestsComposite = new Composite(parent, SWT.NONE); - agentMaxRequestsComposite.setLayout(new GridLayout(1, true)); - gdf.applyTo(agentMaxRequestsComposite); + Composite agentMaxRequestsComposite = createSectionComposite(parent, gdf); IntegerFieldEditor agentMaxRequestsField = new IntegerFieldEditor(Constants.AGENT_MAX_REQUESTS, Messages.preferences_page_agent_max_requests, agentMaxRequestsComposite); @@ -128,12 +105,7 @@ public void createFieldEditors() { agentMaxRequestsField.setErrorMessage(Messages.preferences_page_agent_max_requests_validation_error); addField(agentMaxRequestsField); - WrappableNoteLabel agentMaxRequestsNote = new WrappableNoteLabel(parent, - Messages.preferences_page_note_prefix + " ", - Messages.preferences_page_agent_max_requests_desc); - GridData agentMaxRequestsNoteGridData = new GridData(SWT.FILL, SWT.CENTER, true, false); - agentMaxRequestsNoteGridData.horizontalSpan = 2; - agentMaxRequestsNote.setLayoutData(agentMaxRequestsNoteGridData); + addNote(parent, Messages.preferences_page_agent_max_requests_desc); } @Override @@ -142,11 +114,7 @@ public void init(IWorkbench workbench) { // Ensure run_subagent tool configuration is consistent with sub-agent preference // Only check if sub-agent is policy-enabled - FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); - boolean policyAllowsSubAgent = flags != null && flags.isClientPreviewFeatureEnabled() - && flags.isSubAgentPolicyEnabled(); - - if (policyAllowsSubAgent) { + if (isPolicyAllowsSubAgent()) { boolean subAgentEnabled = getPreferenceStore().getBoolean(Constants.SUB_AGENT_ENABLED); updateSubAgentToolConfiguration(subAgentEnabled); } @@ -157,9 +125,7 @@ public boolean performOk() { final boolean oldWorkspaceContextValue = getPreferenceStore().getBoolean(Constants.WORKSPACE_CONTEXT_ENABLED); // Check if sub-agent is policy-enabled before handling sub-agent preferences - FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); - boolean policyAllowsSubAgent = flags != null && flags.isClientPreviewFeatureEnabled() - && flags.isSubAgentPolicyEnabled(); + boolean policyAllowsSubAgent = isPolicyAllowsSubAgent(); boolean oldSubAgentValue = false; if (policyAllowsSubAgent) { @@ -190,8 +156,7 @@ public boolean performOk() { } if (isSubAgentChanged || isWorkspaceContextChanged) { - boolean restart = MessageDialog.openQuestion(getShell(), - Messages.preferences_page_restart_required, + boolean restart = MessageDialog.openQuestion(getShell(), Messages.preferences_page_restart_required, Messages.preferences_page_restart_question); if (restart) { @@ -204,6 +169,43 @@ public boolean performOk() { return result; } + private Composite createSectionComposite(Composite parent, GridDataFactory gdf) { + Composite composite = new Composite(parent, SWT.NONE); + composite.setLayout(new GridLayout(1, true)); + gdf.applyTo(composite); + return composite; + } + + private void applyFieldWidthHint(BooleanFieldEditor field, Composite parent) { + GridData gridData = new GridData(SWT.FILL, SWT.FILL, true, true); + gridData.widthHint = FIELD_WIDTH_HINT; + field.getDescriptionControl(parent).setLayoutData(gridData); + } + + private void addNote(Composite parent, String noteContent) { + WrappableNoteLabel note = new WrappableNoteLabel(parent, Messages.preferences_page_note_prefix + " ", noteContent); + GridData gridData = new GridData(SWT.FILL, SWT.CENTER, true, false); + gridData.horizontalSpan = 2; + note.setLayoutData(gridData); + } + + private void addSeparator(Composite parent) { + Label separator = new Label(parent, SWT.SEPARATOR | SWT.HORIZONTAL); + GridData gridData = new GridData(SWT.FILL, SWT.CENTER, true, false); + gridData.horizontalSpan = 2; + separator.setLayoutData(gridData); + } + + private boolean isPolicyAllowsSubAgent() { + FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); + return isClientPreviewFeatureEnabled() && flags != null && flags.isSubAgentPolicyEnabled(); + } + + private boolean isClientPreviewFeatureEnabled() { + FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); + return flags != null && flags.isClientPreviewFeatureEnabled(); + } + /** * Updates the MCP tool configuration to include or exclude the run_subagent tool for agent mode based on the * sub-agent preference setting. diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java index 0fe32ff0..246f4f81 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java @@ -27,8 +27,9 @@ public void initializeDefaultPreferences() { pref.setDefault(Constants.PROXY_KERBEROS_SP, ""); pref.setDefault(Constants.GITHUB_ENTERPRISE, ""); pref.setDefault(Constants.WORKSPACE_CONTEXT_ENABLED, false); - pref.setDefault(Constants.SUB_AGENT_ENABLED, false); + pref.setDefault(Constants.SUB_AGENT_ENABLED, true); pref.setDefault(Constants.AGENT_MAX_REQUESTS, 25); + pref.setDefault(Constants.ENABLE_SKILLS, true); pref.setDefault(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE_ENABLED, false); pref.setDefault(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE, ""); pref.setDefault(Constants.AUTO_BREAKPOINT_RESPONSE, false); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java index 02f83e53..49a051d7 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java @@ -25,6 +25,7 @@ import com.microsoft.copilot.eclipse.core.Constants; import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.FeatureFlags; import com.microsoft.copilot.eclipse.core.chat.CustomChatModeManager; import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; @@ -40,6 +41,7 @@ import com.microsoft.copilot.eclipse.core.utils.WorkspaceUtils; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.services.McpExtensionPointManager; +import com.microsoft.copilot.eclipse.ui.utils.PreferencesUtils; /** * A class to manage the proxy service for the Copilot Language Server. @@ -50,6 +52,7 @@ public class LanguageServerSettingManager implements IProxyChangeListener, IProp CopilotLanguageServerConnection copilotLanguageServerConnection = null; IPreferenceStore preferenceStore; IProxyData proxyData = null; + private IEventBroker eventBroker; /** * Gets the settings. @@ -82,6 +85,8 @@ public LanguageServerSettingManager(CopilotLanguageServerConnection conn, IProxy // agent related settings getSettings().getGithubSettings().getCopilotSettings().getAgent() .setAgentMaxRequests(preferenceStore.getInt(Constants.AGENT_MAX_REQUESTS)); + getSettings().getGithubSettings().getCopilotSettings().getAgent() + .setEnableSkills(PreferencesUtils.isSkillsEnabled()); // Set workspace context instructions when it is enabled if (preferenceStore.getBoolean(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE_ENABLED)) { @@ -95,7 +100,7 @@ public LanguageServerSettingManager(CopilotLanguageServerConnection conn, IProxy getSettings().getGithubSettings() .setGitCommitCopilotInstructions(preferenceStore.getString(Constants.CUSTOM_INSTRUCTIONS_GIT_COMMIT)); - IEventBroker eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); + eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); eventBroker.subscribe(CopilotEventConstants.TOPIC_DID_CHANGE_MCP_CONTRIBUTION_POINT_POLICY, event -> { Boolean enabled = (Boolean) event.getProperty(IEventBroker.DATA); if (!enabled.booleanValue()) { @@ -168,6 +173,11 @@ public void propertyChange(PropertyChangeEvent event) { .setAgentMaxRequests(preferenceStore.getInt(Constants.AGENT_MAX_REQUESTS)); singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); break; + case Constants.ENABLE_SKILLS: + settings.getGithubSettings().getCopilotSettings().getAgent() + .setEnableSkills(PreferencesUtils.isSkillsEnabled()); + singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); + break; default: return; } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java index bce65aed..05899eb5 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java @@ -151,6 +151,10 @@ public class Messages extends NLS { public static String preferences_page_agent_max_requests_desc; public static String preferences_page_agent_max_requests_validation_error; + // Skills + public static String preferences_page_skills_enabled; + public static String preferences_page_skills_enabled_note_content; + public static String setting_managed_by_organization; public static String setting_disabled_by_organization; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties index 2b06110a..e71d5589 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties @@ -99,7 +99,7 @@ preferences_page_custom_instructions_git_commit_desc=Set custom instructions for preferences_page_custom_instructions_git_commit_note= Access this feature in the Git Staging view by clicking the Copilot icon. You can find this view in the Git perspective or add it via the 'Window' > 'Show View' menu. preferences_page_watched_files_note_content= Allow the use of @workspace in Ask Mode. Enabling this feature may affect startup performance. preferences_page_restart_question=You need to restart Eclipse to apply the changes. Would you like to restart now? -preferences_page_sub_agent= Enable sub-agent (experimental) +preferences_page_sub_agent= Enable sub-agent preferences_page_sub_agent_note_content= Allow Copilot to use sub-agents for complex multi-step tasks. preferences_page_restart_required= Restart Required preferences_page_mcpOAuth_confirmTitle=GitHub Copilot @@ -141,6 +141,10 @@ preferences_page_agent_max_requests=Agent Max Requests: preferences_page_agent_max_requests_desc=The maximum number of requests to allow per-turn when using an agent. When the limit is reached, will ask to confirm to continue. preferences_page_agent_max_requests_validation_error=Agent Max Requests must be a number between 1 and 500. +# Skills +preferences_page_skills_enabled=Enable Skills +preferences_page_skills_enabled_note_content=Controls whether agent skills can be used to enrich chat context. + # enterprise support setting_disabled_by_organization=This setting is disabled by your organization. setting_managed_by_organization=This setting is managed by your organization. diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java index efd3a7c8..87c55093 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java @@ -3,6 +3,10 @@ package com.microsoft.copilot.eclipse.ui.utils; +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.FeatureFlags; +import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.preferences.ByokPreferencePage; import com.microsoft.copilot.eclipse.ui.preferences.ChatPreferencesPage; import com.microsoft.copilot.eclipse.ui.preferences.CompletionsPreferencesPage; @@ -27,4 +31,17 @@ public static String[] getAllPreferenceIds() { McpPreferencePage.ID, ByokPreferencePage.ID }; } + /** + * Returns whether the skills feature is enabled. Skills require both the user preference + * {@link Constants#ENABLE_SKILLS} to be set and the client preview feature flag to be enabled. + * + * @return {@code true} if skills are enabled, {@code false} otherwise + */ + public static boolean isSkillsEnabled() { + CopilotCore plugin = CopilotCore.getPlugin(); + FeatureFlags flags = plugin != null ? plugin.getFeatureFlags() : null; + return CopilotUi.getPlugin().getPreferenceStore().getBoolean(Constants.ENABLE_SKILLS) + && flags != null && flags.isClientPreviewFeatureEnabled(); + } + }