From 9289af025052b80396cfc079bb5502dd3b3a37be Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Mon, 25 May 2026 10:22:15 +0800 Subject: [PATCH 01/27] feat - support auto approve (#255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat - LSP data layer + confirmationService skeleton (Auto-approve Part 1 ) (#132) * feat: Terminal auto-approve with confirmation dialog enhancement (Auto-approve Part 2) (#171) * revert splitDropdownButton padding change (#184) * feat: File operation auto-approve with glob pattern rules (Auto-approve Part 3) (#216) * add file operation approve logic * resolve comments * change default rules color * resolve comments * update test cases * feat: MCP tool auto-approve and global auto-approve (Auto-approve Part 4) (#218) * add mcp approve support and global approve support * resolve comments * fix test cases * resolve comments and add token/policy check * fix compile error * Fix auto-approve preference page layout instability * fix copilot comments --- .../copilot/eclipse/core/Constants.java | 10 + .../copilot/eclipse/core/FeatureFlags.java | 23 + .../eclipse/core/chat/ConfirmationAction.java | 110 +++ .../core/chat/ConfirmationActionScope.java | 16 + .../core/chat/ConfirmationContent.java | 77 ++ .../core/chat/ConfirmationHandler.java | 22 + .../eclipse/core/chat/ConfirmationResult.java | 82 ++ .../chat/FileOperationAutoApproveRule.java | 109 +++ .../core/chat/TerminalAutoApproveRule.java | 77 ++ .../core/lsp/CopilotLanguageClient.java | 2 + .../core/lsp/CopilotLanguageServer.java | 8 + .../lsp/CopilotLanguageServerConnection.java | 11 + .../lsp/protocol/CopilotAgentSettings.java | 173 +++- .../protocol/DidChangeFeatureFlagsParams.java | 9 + .../core/lsp/protocol/FileSafetyRuleInfo.java | 92 +++ .../GetDefaultFileSafetyRulesResult.java | 63 ++ .../InvokeClientToolConfirmationParams.java | 36 +- .../core/lsp/protocol/ToolAnnotations.java | 84 ++ .../core/lsp/protocol/ToolMetadata.java | 189 +++++ .../policy/DidChangePolicyParams.java | 18 +- .../file-operation-auto-approve.md | 342 ++++++++ .../global-auto-approve.md | 125 +++ .../mcp-auto-approve/mcp-auto-approve.md | 265 +++++++ .../terminal-auto-approve.md | 551 +++++++++++++ ...FileOperationConfirmationHandlerTests.java | 737 ++++++++++++++++++ .../McpConfirmationHandlerTests.java | 460 +++++++++++ .../TerminalConfirmationHandlerTests.java | 650 +++++++++++++++ .../LanguageServerSettingManagerTests.java | 21 + .../META-INF/MANIFEST.MF | 1 + com.microsoft.copilot.eclipse.ui/css/dark.css | 2 +- .../css/light.css | 2 +- .../plugin.properties | 3 +- com.microsoft.copilot.eclipse.ui/plugin.xml | 6 + .../eclipse/ui/chat/BaseTurnWidget.java | 18 +- .../copilot/eclipse/ui/chat/ChatView.java | 82 ++ .../ui/chat/InvokeToolConfirmationDialog.java | 359 +++++---- .../copilot/eclipse/ui/chat/Messages.java | 33 + .../confirmation/AttachedFileRegistry.java | 140 ++++ .../confirmation/ConfirmationHandler.java | 102 +++ .../confirmation/ConfirmationService.java | 150 ++++ .../FallbackConfirmationHandler.java | 37 + .../FileOperationConfirmationHandler.java | 457 +++++++++++ .../confirmation/McpConfirmationHandler.java | 364 +++++++++ .../TerminalConfirmationHandler.java | 547 +++++++++++++ .../eclipse/ui/chat/messages.properties | 33 + .../ui/chat/services/AgentToolService.java | 73 +- .../ui/chat/services/McpConfigService.java | 24 + .../eclipse/ui/dialogs/mcp/McpServerItem.java | 9 +- .../AddFileOperationRuleDialog.java | 124 +++ .../ui/preferences/AddTerminalRuleDialog.java | 103 +++ .../AutoApprovePreferencePage.java | 116 +++ .../CopilotPreferenceInitializer.java | 15 + .../preferences/CopilotPreferencesPage.java | 2 + .../FileOperationAutoApproveSection.java | 496 ++++++++++++ .../preferences/GlobalAutoApproveSection.java | 122 +++ .../LanguageServerSettingManager.java | 80 ++ .../ui/preferences/McpAutoApproveSection.java | 382 +++++++++ .../eclipse/ui/preferences/Messages.java | 54 ++ .../TerminalAutoApproveSection.java | 275 +++++++ .../ui/preferences/messages.properties | 56 ++ .../SplitDropdownButton.java} | 35 +- .../eclipse/ui/utils/PreferencesUtils.java | 3 +- 62 files changed, 8493 insertions(+), 174 deletions(-) create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationAction.java create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationActionScope.java create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationContent.java create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationHandler.java create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationResult.java create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/FileOperationAutoApproveRule.java create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/TerminalAutoApproveRule.java create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FileSafetyRuleInfo.java create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/GetDefaultFileSafetyRulesResult.java create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ToolAnnotations.java create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ToolMetadata.java create mode 100644 com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-operation-auto-approve/file-operation-auto-approve.md create mode 100644 com.microsoft.copilot.eclipse.swtbot.test/test-plans/global-auto-approve/global-auto-approve.md create mode 100644 com.microsoft.copilot.eclipse.swtbot.test/test-plans/mcp-auto-approve/mcp-auto-approve.md create mode 100644 com.microsoft.copilot.eclipse.swtbot.test/test-plans/terminal-auto-approve/terminal-auto-approve.md create mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandlerTests.java create mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandlerTests.java create mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandlerTests.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/AttachedFileRegistry.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationHandler.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationService.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FallbackConfirmationHandler.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandler.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandler.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandler.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddFileOperationRuleDialog.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddTerminalRuleDialog.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AutoApprovePreferencePage.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GlobalAutoApproveSection.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/TerminalAutoApproveSection.java rename com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/{dialogs/mcp/DropDownButton.java => swt/SplitDropdownButton.java} (89%) 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 165d17cf..176bc245 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 @@ -54,6 +54,16 @@ private Constants() { public static final String GITHUB_JOBS_VIEW_ID = "com.microsoft.copilot.eclipse.ui.jobs.JobsView"; public static final String SUPPRESS_TERMINAL_DEPENDENCY_DIALOG = "suppressTerminalDependencyDialog"; + // Auto-Approve settings + public static final String AUTO_APPROVE_TERMINAL_RULES = "autoApproveTerminalRules"; + public static final String AUTO_APPROVE_UNMATCHED_TERMINAL = "autoApproveUnmatchedTerminal"; + public static final String AUTO_APPROVE_FILE_OP_RULES = "autoApproveEditRules"; + public static final String AUTO_APPROVE_UNMATCHED_FILE_OP = "autoApproveUnmatchedFileOp"; + public static final String AUTO_APPROVE_MCP_SERVERS = "autoApproveMcpServers"; + public static final String AUTO_APPROVE_MCP_TOOLS = "autoApproveMcpTools"; + public static final String AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS = "autoApproveTrustToolAnnotations"; + public static final String AUTO_APPROVE_YOLO_MODE = "autoApproveYoloMode"; + // Base excluded file types shared by both // Copied from InelliJ, excluded file extension list // https://github.com/microsoft/copilot-intellij/blob/main/core/src/main/kotlin/com/github/copilot/chat/references/FileSearchService.kt diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/FeatureFlags.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/FeatureFlags.java index 1e9a6bf0..f54e0bfa 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/FeatureFlags.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/FeatureFlags.java @@ -25,6 +25,10 @@ public class FeatureFlags { private boolean customAgentPolicyEnabled = true; + private boolean autoApprovalTokenEnabled = true; + + private boolean autoApprovalPolicyEnabled = true; + public boolean isAgentModeEnabled() { return agentModeEnabled; } @@ -84,6 +88,25 @@ public void setCustomAgentPolicyEnabled(boolean customAgentPolicyEnabled) { this.customAgentPolicyEnabled = customAgentPolicyEnabled; } + /** + * Returns true if the auto-approval feature is available. + * Requires both the server token ({@code agent_mode_auto_approval}) and + * the organization policy ({@code agentMode.autoApproval.enabled}) to permit it. + * + * @return true if auto-approval is permitted + */ + public boolean isAutoApprovalEnabled() { + return autoApprovalTokenEnabled && autoApprovalPolicyEnabled; + } + + public void setAutoApprovalTokenEnabled(boolean autoApprovalTokenEnabled) { + this.autoApprovalTokenEnabled = autoApprovalTokenEnabled; + } + + public void setAutoApprovalPolicyEnabled(boolean autoApprovalPolicyEnabled) { + this.autoApprovalPolicyEnabled = autoApprovalPolicyEnabled; + } + public boolean isClientPreviewFeatureEnabled() { return clientPreviewFeatureEnabled; } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationAction.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationAction.java new file mode 100644 index 00000000..07f10d92 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationAction.java @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import java.util.Map; +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Represents a button action in the confirmation UI. Each action has a label, + * a decision (accept or dismiss), a persistence scope, and optional metadata + * for the handler to know what to persist. + */ +public class ConfirmationAction { + + /** Metadata key for the action type enum name. */ + public static final String META_ACTION = "action"; + + private final String label; + private final boolean accept; + private final ConfirmationActionScope scope; + private final Map metadata; + private final boolean primary; + + /** + * Creates a new confirmation action. + * + * @param label the button label + * @param accept true for accept, false for dismiss + * @param scope the persistence scope (null for dismiss actions) + * @param metadata extra data for the handler (e.g., command names, server name) + * @param primary whether this is the primary/default button + */ + public ConfirmationAction(String label, boolean accept, + ConfirmationActionScope scope, Map metadata, + boolean primary) { + this.label = label; + this.accept = accept; + this.scope = scope; + this.metadata = metadata != null ? metadata : Map.of(); + this.primary = primary; + } + + public String getLabel() { + return label; + } + + public boolean isAccept() { + return accept; + } + + public ConfirmationActionScope getScope() { + return scope; + } + + public Map getMetadata() { + return metadata; + } + + public boolean isPrimary() { + return primary; + } + + /** Creates a primary accept action (scope = ONCE). */ + public static ConfirmationAction allowOnce(String label) { + return new ConfirmationAction(label, true, + ConfirmationActionScope.ONCE, null, true); + } + + /** Creates a dismiss action. */ + public static ConfirmationAction skip(String label) { + return new ConfirmationAction(label, false, null, null, false); + } + + @Override + public int hashCode() { + return Objects.hash(accept, label, metadata, primary, scope); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ConfirmationAction other = (ConfirmationAction) obj; + return accept == other.accept + && Objects.equals(label, other.label) + && Objects.equals(metadata, other.metadata) + && primary == other.primary && scope == other.scope; + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("label", label) + .append("accept", accept) + .append("scope", scope) + .append("metadata", metadata) + .append("primary", primary) + .toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationActionScope.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationActionScope.java new file mode 100644 index 00000000..37fc6d88 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationActionScope.java @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +/** + * Scope of how a confirmation decision should be persisted. + */ +public enum ConfirmationActionScope { + /** One-time acceptance for this single invocation. */ + ONCE, + /** Remember for the current conversation session (in-memory). */ + SESSION, + /** Remember globally (application-level persistent, synced to CLS). */ + GLOBAL +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationContent.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationContent.java new file mode 100644 index 00000000..abc588fc --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationContent.java @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import java.util.List; +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Complete content for rendering a confirmation UI. Returned by handlers when a tool call + * needs user confirmation. Contains the display text and the list of action buttons. + */ +public class ConfirmationContent { + + private final String title; + private final String message; + private final List actions; + + /** + * Creates a new confirmation content. + * + * @param title bold title text displayed at the top + * @param message description text (may be null) + * @param actions list of button actions for the confirmation UI + */ + public ConfirmationContent(String title, String message, + List actions) { + this.title = title; + this.message = message; + this.actions = actions; + } + + public String getTitle() { + return title; + } + + public String getMessage() { + return message; + } + + public List getActions() { + return actions; + } + + @Override + public int hashCode() { + return Objects.hash(actions, message, title); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ConfirmationContent other = (ConfirmationContent) obj; + return Objects.equals(actions, other.actions) + && Objects.equals(message, other.message) + && Objects.equals(title, other.title); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("title", title) + .append("message", message) + .append("actions", actions) + .toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationHandler.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationHandler.java new file mode 100644 index 00000000..b8553435 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationHandler.java @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; + +/** + * Evaluates whether a tool confirmation request can be auto-approved. + * Each implementation handles a specific category of tool (terminal, file operations, MCP, etc.). + */ +public interface ConfirmationHandler { + + /** + * Evaluates whether the given confirmation request should be auto-approved. + * + * @param params the confirmation request parameters from CLS + * @return ConfirmationResult.AUTO_APPROVED if the tool call can proceed without user + * confirmation, or ConfirmationResult.NEEDS_CONFIRMATION if the user must approve + */ + ConfirmationResult evaluate(InvokeClientToolConfirmationParams params); +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationResult.java new file mode 100644 index 00000000..a496ab10 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationResult.java @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Result of evaluating an auto-approve confirmation request. + * Either AUTO_APPROVED (no UI needed) or NEEDS_CONFIRMATION with content for the dialog. + */ +public class ConfirmationResult { + + /** Auto-approved, no user confirmation needed. */ + public static final ConfirmationResult AUTO_APPROVED = new ConfirmationResult(true, false, null); + + /** Dismissed — malformed or unhandleable request; CLS should be told to skip the tool. */ + public static final ConfirmationResult DISMISSED = new ConfirmationResult(false, true, null); + + private final boolean autoApproved; + private final boolean dismissed; + private final ConfirmationContent content; + + private ConfirmationResult(boolean autoApproved, boolean dismissed, ConfirmationContent content) { + this.autoApproved = autoApproved; + this.dismissed = dismissed; + this.content = content; + } + + /** Creates a result that requires user confirmation with the given content. */ + public static ConfirmationResult needsConfirmation( + ConfirmationContent content) { + return new ConfirmationResult(false, false, content); + } + + public boolean isAutoApproved() { + return autoApproved; + } + + /** Returns true if the request should be dismissed without showing UI. */ + public boolean isDismissed() { + return dismissed; + } + + /** Returns the confirmation content, or null if auto-approved or using defaults. */ + public ConfirmationContent getContent() { + return content; + } + + @Override + public int hashCode() { + return Objects.hash(autoApproved, dismissed, content); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ConfirmationResult other = (ConfirmationResult) obj; + return autoApproved == other.autoApproved + && dismissed == other.dismissed + && Objects.equals(content, other.content); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("autoApproved", autoApproved) + .append("dismissed", dismissed) + .append("content", content) + .toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/FileOperationAutoApproveRule.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/FileOperationAutoApproveRule.java new file mode 100644 index 00000000..9f5c819d --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/FileOperationAutoApproveRule.java @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * A single file-operation auto-approve rule mapping a glob pattern to an allow/deny decision. + */ +public class FileOperationAutoApproveRule { + private String pattern; + private String description; + private boolean autoApprove; + private transient boolean isDefault; + + /** + * Creates a new rule. + * + * @param pattern the glob pattern (e.g., "**\/.github/instructions/*") + * @param description human-readable description of what this pattern matches + * @param autoApprove true to auto-approve, false to always require confirmation + */ + public FileOperationAutoApproveRule(String pattern, String description, boolean autoApprove) { + this(pattern, description, autoApprove, false); + } + + /** + * Creates a new rule. + * + * @param pattern the glob pattern + * @param description human-readable description + * @param autoApprove true to auto-approve, false to always require confirmation + * @param isDefault true if this is a CLS default rule (non-removable) + */ + public FileOperationAutoApproveRule(String pattern, String description, + boolean autoApprove, boolean isDefault) { + this.pattern = pattern; + this.description = description; + this.autoApprove = autoApprove; + this.isDefault = isDefault; + } + + /** Default constructor for Gson deserialization. */ + public FileOperationAutoApproveRule() { + } + + public String getPattern() { + return pattern; + } + + public void setPattern(String pattern) { + this.pattern = pattern; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public boolean isAutoApprove() { + return autoApprove; + } + + public void setAutoApprove(boolean autoApprove) { + this.autoApprove = autoApprove; + } + + public boolean isDefault() { + return isDefault; + } + + public void setDefault(boolean isDefault) { + this.isDefault = isDefault; + } + + @Override + public int hashCode() { + return Objects.hash(pattern, description, autoApprove); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + FileOperationAutoApproveRule other = (FileOperationAutoApproveRule) obj; + return Objects.equals(pattern, other.pattern) + && Objects.equals(description, other.description) + && autoApprove == other.autoApprove; + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("pattern", pattern) + .append("description", description) + .append("autoApprove", autoApprove) + .toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/TerminalAutoApproveRule.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/TerminalAutoApproveRule.java new file mode 100644 index 00000000..b70a2797 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/TerminalAutoApproveRule.java @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * A single terminal auto-approve rule mapping a command name or regex pattern to an + * allow/deny decision. + */ +public class TerminalAutoApproveRule { + private String command; + private boolean autoApprove; + + /** + * Creates a new rule. + * + * @param command the command name or regex pattern + * @param autoApprove true to auto-approve, false to always require confirmation + */ + public TerminalAutoApproveRule(String command, boolean autoApprove) { + this.command = command; + this.autoApprove = autoApprove; + } + + /** Default constructor for Gson deserialization. */ + public TerminalAutoApproveRule() { + } + + public String getCommand() { + return command; + } + + public void setCommand(String command) { + this.command = command; + } + + public boolean isAutoApprove() { + return autoApprove; + } + + public void setAutoApprove(boolean autoApprove) { + this.autoApprove = autoApprove; + } + + @Override + public int hashCode() { + return Objects.hash(autoApprove, command); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + TerminalAutoApproveRule other = (TerminalAutoApproveRule) obj; + return autoApprove == other.autoApprove + && Objects.equals(command, other.command); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("command", command) + .append("autoApprove", autoApprove) + .toString(); + } +} 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 c3897ad9..1e40287a 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 @@ -310,6 +310,7 @@ public void onDidChangeFeatureFlags(DidChangeFeatureFlagsParams params) { flags.setMcpEnabled(params.isMcpEnabled()); flags.setByokEnabled(params.isByokEnabled()); flags.setClientPreviewFeatureEnabled(params.isClientPreviewFeaturesEnabled()); + flags.setAutoApprovalTokenEnabled(params.isAutoApprovalEnabled()); } if (eventBroker != null) { @@ -337,6 +338,7 @@ public void onDidChangePolicy(DidChangePolicyParams params) { flags.setCustomAgentPolicyEnabled(params.isCustomAgentEnabled()); eventBroker.post(CopilotEventConstants.TOPIC_DID_CHANGE_CUSTOM_AGENT_POLICY, params.isCustomAgentEnabled()); } + flags.setAutoApprovalPolicyEnabled(params.isAutoApprovalPolicyEnabled()); } } 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 aae71950..8f74d5dd 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 @@ -36,6 +36,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.DidShowInlineEditParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleResponse; +import com.microsoft.copilot.eclipse.core.lsp.protocol.GetDefaultFileSafetyRulesResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; import com.microsoft.copilot.eclipse.core.lsp.protocol.NextEditSuggestionsParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.NextEditSuggestionsResult; @@ -285,6 +286,13 @@ public interface CopilotLanguageServer extends LanguageServer { @JsonRequest("githubApi/searchPR") CompletableFuture searchPr(SearchPrParams params); + /** + * Get the default file safety rules from CLS. + */ + @JsonRequest("getDefaultFileSafetyRules") + CompletableFuture getDefaultFileSafetyRules( + NullParams params); + /** * Notify that an inline edit was shown. */ 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 7820652b..85e26f98 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 @@ -54,6 +54,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.DidShowInlineEditParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleResponse; +import com.microsoft.copilot.eclipse.core.lsp.protocol.GetDefaultFileSafetyRulesResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; import com.microsoft.copilot.eclipse.core.lsp.protocol.ModelInfo; import com.microsoft.copilot.eclipse.core.lsp.protocol.NextEditSuggestionsParams; @@ -153,6 +154,16 @@ public CompletableFuture checkQuota() { return this.languageServerWrapper.execute(fn); } + /** + * Get the default file safety rules from CLS. + */ + public CompletableFuture getDefaultFileSafetyRules() { + Function> fn = + server -> ((CopilotLanguageServer) server) + .getDefaultFileSafetyRules(new NullParams()); + return this.languageServerWrapper.execute(fn); + } + /** * Get single completion for the given parameters. */ 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 5a54d5e1..e9412e2f 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 @@ -3,6 +3,7 @@ package com.microsoft.copilot.eclipse.core.lsp.protocol; +import java.util.Map; import java.util.Objects; import com.google.gson.annotations.SerializedName; @@ -19,6 +20,137 @@ public class CopilotAgentSettings { private String transcriptDirectory; + @SerializedName("autoApproveUnmatchedTerminal") + private boolean autoApproveUnmatchedTerminal; + + @SerializedName("autoApproveUnmatchedFileOp") + private boolean autoApproveUnmatchedFileOp; + + // Tells CLS to always send confirmation requests to the editor + @SerializedName("editorHandlesAllConfirmation") + private boolean editorHandlesAllConfirmation = true; + + private ToolsSettings tools; + + /** Nested tools settings matching CLS agent.tools structure. */ + public static class ToolsSettings { + private TerminalSettings terminal; + private EditSettings edit; + + /** Gets terminal settings, creating if needed. */ + public TerminalSettings getTerminal() { + if (terminal == null) { + terminal = new TerminalSettings(); + } + return terminal; + } + + /** Gets edit settings, creating if needed. */ + public EditSettings getEdit() { + if (edit == null) { + edit = new EditSettings(); + } + return edit; + } + + @Override + public int hashCode() { + return Objects.hash(terminal, edit); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ToolsSettings other = (ToolsSettings) obj; + return Objects.equals(terminal, other.terminal) && Objects.equals(edit, other.edit); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("terminal", terminal) + .append("edit", edit) + .toString(); + } + } + + /** Terminal auto-approve rules: command/pattern -> allow(true)/deny(false). */ + public static class TerminalSettings { + private Map autoApprove; + + public Map getAutoApprove() { + return autoApprove; + } + + public void setAutoApprove(Map autoApprove) { + this.autoApprove = autoApprove; + } + + @Override + public int hashCode() { + return Objects.hash(autoApprove); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + return Objects.equals(autoApprove, ((TerminalSettings) obj).autoApprove); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("autoApprove", autoApprove) + .toString(); + } + } + + /** Edit (file operation) auto-approve rules: pattern → allow(true)/deny(false). */ + public static class EditSettings { + private Map autoApprove; + + public Map getAutoApprove() { + return autoApprove; + } + + public void setAutoApprove(Map autoApprove) { + this.autoApprove = autoApprove; + } + + @Override + public int hashCode() { + return Objects.hash(autoApprove); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + return Objects.equals(autoApprove, ((EditSettings) obj).autoApprove); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("autoApprove", autoApprove) + .toString(); + } + } + public int getAgentMaxRequests() { return agentMaxRequests; } @@ -50,9 +182,38 @@ public void setTranscriptDirectory(String transcriptDirectory) { this.transcriptDirectory = transcriptDirectory; } + public boolean isEditorHandlesAllConfirmation() { + return editorHandlesAllConfirmation; + } + + public boolean isAutoApproveUnmatchedTerminal() { + return autoApproveUnmatchedTerminal; + } + + public void setAutoApproveUnmatchedTerminal(boolean autoApproveUnmatchedTerminal) { + this.autoApproveUnmatchedTerminal = autoApproveUnmatchedTerminal; + } + + public boolean isAutoApproveUnmatchedFileOp() { + return autoApproveUnmatchedFileOp; + } + + public void setAutoApproveUnmatchedFileOp(boolean autoApproveUnmatchedFileOp) { + this.autoApproveUnmatchedFileOp = autoApproveUnmatchedFileOp; + } + + /** Gets tools settings, creating if needed. */ + public ToolsSettings getTools() { + if (tools == null) { + tools = new ToolsSettings(); + } + return tools; + } + @Override public int hashCode() { - return Objects.hash(agentMaxRequests, enableSkills, transcriptDirectory); + return Objects.hash(agentMaxRequests, enableSkills, transcriptDirectory, + editorHandlesAllConfirmation, autoApproveUnmatchedTerminal, autoApproveUnmatchedFileOp, tools); } @Override @@ -68,7 +229,11 @@ public boolean equals(Object obj) { } CopilotAgentSettings other = (CopilotAgentSettings) obj; return agentMaxRequests == other.agentMaxRequests && enableSkills == other.enableSkills - && Objects.equals(transcriptDirectory, other.transcriptDirectory); + && Objects.equals(transcriptDirectory, other.transcriptDirectory) + && editorHandlesAllConfirmation == other.editorHandlesAllConfirmation + && autoApproveUnmatchedTerminal == other.autoApproveUnmatchedTerminal + && autoApproveUnmatchedFileOp == other.autoApproveUnmatchedFileOp + && Objects.equals(tools, other.tools); } @Override @@ -77,6 +242,10 @@ public String toString() { builder.append("agentMaxRequests", agentMaxRequests); builder.append("enableSkills", enableSkills); builder.append("transcriptDirectory", transcriptDirectory); + builder.append("editorHandlesAllConfirmation", editorHandlesAllConfirmation); + builder.append("autoApproveUnmatchedTerminal", autoApproveUnmatchedTerminal); + builder.append("autoApproveUnmatchedFileOp", autoApproveUnmatchedFileOp); + builder.append("tools", tools); return builder.toString(); } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/DidChangeFeatureFlagsParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/DidChangeFeatureFlagsParams.java index 44dd3e4c..cdd0e839 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/DidChangeFeatureFlagsParams.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/DidChangeFeatureFlagsParams.java @@ -76,6 +76,15 @@ public boolean isClientPreviewFeaturesEnabled() { return !disabled; } + /** + * Checks if the auto-approval feature is enabled. + * Disabled only when the feature flag "agent_mode_auto_approval" is set to "0". + */ + public boolean isAutoApprovalEnabled() { + boolean disabled = featureFlags != null && "0".equals(featureFlags.get("agent_mode_auto_approval")); + return !disabled; + } + @Override public int hashCode() { return Objects.hash(activeExps, featureFlags, envelope, byokEnabled); diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FileSafetyRuleInfo.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FileSafetyRuleInfo.java new file mode 100644 index 00000000..90348463 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FileSafetyRuleInfo.java @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * A file safety rule as returned by CLS {@code getDefaultFileSafetyRules}. + * + *

Field names match the CLS JSON-RPC response exactly.

+ */ +public class FileSafetyRuleInfo { + + private String pattern; + private boolean requiresConfirmation; + private String description; + + /** Default constructor for Gson deserialization. */ + public FileSafetyRuleInfo() { + } + + /** + * Creates a new FileSafetyRuleInfo. + * + * @param pattern the glob pattern + * @param requiresConfirmation whether the file requires confirmation + * @param description description of the rule + */ + public FileSafetyRuleInfo(String pattern, boolean requiresConfirmation, + String description) { + this.pattern = pattern; + this.requiresConfirmation = requiresConfirmation; + this.description = description; + } + + public String getPattern() { + return pattern; + } + + + public void setPattern(String pattern) { + this.pattern = pattern; + } + + public boolean isRequiresConfirmation() { + return requiresConfirmation; + } + + public void setRequiresConfirmation(boolean requiresConfirmation) { + this.requiresConfirmation = requiresConfirmation; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + @Override + public int hashCode() { + return Objects.hash(description, pattern, requiresConfirmation); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + FileSafetyRuleInfo other = (FileSafetyRuleInfo) obj; + return Objects.equals(description, other.description) + && Objects.equals(pattern, other.pattern) + && requiresConfirmation == other.requiresConfirmation; + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("pattern", pattern) + .append("requiresConfirmation", requiresConfirmation) + .append("description", description) + .toString(); + } + +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/GetDefaultFileSafetyRulesResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/GetDefaultFileSafetyRulesResult.java new file mode 100644 index 00000000..a88e2c6c --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/GetDefaultFileSafetyRulesResult.java @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.List; +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Result of the {@code getDefaultFileSafetyRules} CLS request. + */ +public class GetDefaultFileSafetyRulesResult { + + private List defaultRules; + + /** Default constructor for Gson deserialization. */ + public GetDefaultFileSafetyRulesResult() { + } + + /** + * Creates a new result with the given default rules. + * + * @param defaultRules the list of default file safety rules + */ + public GetDefaultFileSafetyRulesResult( + List defaultRules) { + this.defaultRules = defaultRules; + } + + public List getDefaultRules() { + return defaultRules; + } + + public void setDefaultRules(List defaultRules) { + this.defaultRules = defaultRules; + } + + @Override + public int hashCode() { + return Objects.hash(defaultRules); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + GetDefaultFileSafetyRulesResult other = (GetDefaultFileSafetyRulesResult) obj; + return Objects.equals(defaultRules, other.defaultRules); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("defaultRules", defaultRules) + .toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/InvokeClientToolConfirmationParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/InvokeClientToolConfirmationParams.java index ba40b954..2066c74c 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/InvokeClientToolConfirmationParams.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/InvokeClientToolConfirmationParams.java @@ -52,6 +52,16 @@ public class InvokeClientToolConfirmationParams { */ private String toolCallId; + /** + * The MCP tool annotations describing tool behavior hints. + */ + private ToolAnnotations annotations; + + /** + * Additional metadata associated with the tool invocation. + */ + private ToolMetadata toolMetadata; + public String getName() { return name; } @@ -116,9 +126,26 @@ public void setToolCallId(String toolCallId) { this.toolCallId = toolCallId; } + public ToolAnnotations getAnnotations() { + return annotations; + } + + public void setAnnotations(ToolAnnotations annotations) { + this.annotations = annotations; + } + + public ToolMetadata getToolMetadata() { + return toolMetadata; + } + + public void setToolMetadata(ToolMetadata toolMetadata) { + this.toolMetadata = toolMetadata; + } + @Override public int hashCode() { - return Objects.hash(conversationId, input, message, name, roundId, title, toolCallId, turnId); + return Objects.hash(annotations, conversationId, input, message, name, roundId, title, toolCallId, toolMetadata, + turnId); } @Override @@ -133,10 +160,11 @@ public boolean equals(Object obj) { return false; } InvokeClientToolConfirmationParams other = (InvokeClientToolConfirmationParams) obj; - return Objects.equals(conversationId, other.conversationId) && Objects.equals(input, other.input) + return Objects.equals(annotations, other.annotations) + && Objects.equals(conversationId, other.conversationId) && Objects.equals(input, other.input) && Objects.equals(message, other.message) && Objects.equals(name, other.name) && roundId == other.roundId && Objects.equals(title, other.title) && Objects.equals(toolCallId, other.toolCallId) - && Objects.equals(turnId, other.turnId); + && Objects.equals(toolMetadata, other.toolMetadata) && Objects.equals(turnId, other.turnId); } @Override @@ -150,6 +178,8 @@ public String toString() { builder.append("turnId", turnId); builder.append("roundId", roundId); builder.append("toolCallId", toolCallId); + builder.append("annotations", annotations); + builder.append("toolMetadata", toolMetadata); return builder.toString(); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ToolAnnotations.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ToolAnnotations.java new file mode 100644 index 00000000..a59f3598 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ToolAnnotations.java @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * MCP tool annotations describing tool behavior hints (e.g., readOnly, destructive). + * Provided by CLS, originally sourced from the MCP server's tool definition. + */ +public class ToolAnnotations { + private boolean readOnlyHint; + private boolean destructiveHint; + private boolean idempotentHint; + private boolean openWorldHint; + + public boolean isReadOnlyHint() { + return readOnlyHint; + } + + public void setReadOnlyHint(boolean readOnlyHint) { + this.readOnlyHint = readOnlyHint; + } + + public boolean isDestructiveHint() { + return destructiveHint; + } + + public void setDestructiveHint(boolean destructiveHint) { + this.destructiveHint = destructiveHint; + } + + public boolean isIdempotentHint() { + return idempotentHint; + } + + public void setIdempotentHint(boolean idempotentHint) { + this.idempotentHint = idempotentHint; + } + + public boolean isOpenWorldHint() { + return openWorldHint; + } + + public void setOpenWorldHint(boolean openWorldHint) { + this.openWorldHint = openWorldHint; + } + + @Override + public int hashCode() { + return Objects.hash(readOnlyHint, destructiveHint, idempotentHint, openWorldHint); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ToolAnnotations other = (ToolAnnotations) obj; + return readOnlyHint == other.readOnlyHint + && destructiveHint == other.destructiveHint + && idempotentHint == other.idempotentHint + && openWorldHint == other.openWorldHint; + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("readOnlyHint", readOnlyHint); + builder.append("destructiveHint", destructiveHint); + builder.append("idempotentHint", idempotentHint); + builder.append("openWorldHint", openWorldHint); + return builder.toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ToolMetadata.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ToolMetadata.java new file mode 100644 index 00000000..9883a504 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ToolMetadata.java @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.Arrays; +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Tool-specific metadata provided by CLS as part of the confirmation request, used by the + * auto-approve system to make confirmation decisions. + */ +public class ToolMetadata { + + private TerminalCommandData terminalCommandData; + private SensitiveFileData sensitiveFileData; + + public TerminalCommandData getTerminalCommandData() { + return terminalCommandData; + } + + public void setTerminalCommandData(TerminalCommandData terminalCommandData) { + this.terminalCommandData = terminalCommandData; + } + + public SensitiveFileData getSensitiveFileData() { + return sensitiveFileData; + } + + public void setSensitiveFileData(SensitiveFileData sensitiveFileData) { + this.sensitiveFileData = sensitiveFileData; + } + + @Override + public int hashCode() { + return Objects.hash(terminalCommandData, sensitiveFileData); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ToolMetadata other = (ToolMetadata) obj; + return Objects.equals(terminalCommandData, other.terminalCommandData) + && Objects.equals(sensitiveFileData, other.sensitiveFileData); + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("terminalCommandData", terminalCommandData); + builder.append("sensitiveFileData", sensitiveFileData); + return builder.toString(); + } + + /** + * Data describing a terminal command and its sub-commands. + */ + public static class TerminalCommandData { + private String[] subCommands; + private String[] commandNames; + + public String[] getSubCommands() { + return subCommands; + } + + public void setSubCommands(String[] subCommands) { + this.subCommands = subCommands; + } + + public String[] getCommandNames() { + return commandNames; + } + + public void setCommandNames(String[] commandNames) { + this.commandNames = commandNames; + } + + @Override + public int hashCode() { + return Objects.hash(Arrays.hashCode(subCommands), Arrays.hashCode(commandNames)); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + TerminalCommandData other = (TerminalCommandData) obj; + return Arrays.equals(subCommands, other.subCommands) + && Arrays.equals(commandNames, other.commandNames); + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("subCommands", subCommands); + builder.append("commandNames", commandNames); + return builder.toString(); + } + } + + /** + * Data describing a sensitive file that requires confirmation. + */ + public static class SensitiveFileData { + private String filePath; + private String matchingRule; + private String ruleDescription; + private boolean isGlobal; + + public String getFilePath() { + return filePath; + } + + public void setFilePath(String filePath) { + this.filePath = filePath; + } + + public String getMatchingRule() { + return matchingRule; + } + + public void setMatchingRule(String matchingRule) { + this.matchingRule = matchingRule; + } + + public String getRuleDescription() { + return ruleDescription; + } + + public void setRuleDescription(String ruleDescription) { + this.ruleDescription = ruleDescription; + } + + public boolean isGlobal() { + return isGlobal; + } + + public void setGlobal(boolean isGlobal) { + this.isGlobal = isGlobal; + } + + @Override + public int hashCode() { + return Objects.hash(filePath, matchingRule, ruleDescription, isGlobal); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + SensitiveFileData other = (SensitiveFileData) obj; + return Objects.equals(filePath, other.filePath) + && Objects.equals(matchingRule, other.matchingRule) + && Objects.equals(ruleDescription, other.ruleDescription) + && isGlobal == other.isGlobal; + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("filePath", filePath); + builder.append("matchingRule", matchingRule); + builder.append("ruleDescription", ruleDescription); + builder.append("isGlobal", isGlobal); + return builder.toString(); + } + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/policy/DidChangePolicyParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/policy/DidChangePolicyParams.java index b5087205..3d21772a 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/policy/DidChangePolicyParams.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/policy/DidChangePolicyParams.java @@ -22,6 +22,9 @@ public class DidChangePolicyParams { @SerializedName("customAgent.enabled") private boolean customAgentEnabled = true; + @SerializedName("agentMode.autoApproval.enabled") + private boolean autoApprovalPolicyEnabled = true; + public boolean isMcpContributionPointEnabled() { return mcpContributionPointEnabled; } @@ -46,9 +49,18 @@ public void setCustomAgentEnabled(boolean customAgentEnabled) { this.customAgentEnabled = customAgentEnabled; } + public boolean isAutoApprovalPolicyEnabled() { + return autoApprovalPolicyEnabled; + } + + public void setAutoApprovalPolicyEnabled(boolean autoApprovalPolicyEnabled) { + this.autoApprovalPolicyEnabled = autoApprovalPolicyEnabled; + } + @Override public int hashCode() { - return Objects.hash(mcpContributionPointEnabled, subAgentEnabled, customAgentEnabled); + return Objects.hash(mcpContributionPointEnabled, subAgentEnabled, customAgentEnabled, + autoApprovalPolicyEnabled); } @Override @@ -65,7 +77,8 @@ public boolean equals(Object obj) { DidChangePolicyParams other = (DidChangePolicyParams) obj; return mcpContributionPointEnabled == other.mcpContributionPointEnabled && subAgentEnabled == other.subAgentEnabled - && customAgentEnabled == other.customAgentEnabled; + && customAgentEnabled == other.customAgentEnabled + && autoApprovalPolicyEnabled == other.autoApprovalPolicyEnabled; } @Override @@ -74,6 +87,7 @@ public String toString() { builder.append("mcpContributionPointEnabled", mcpContributionPointEnabled); builder.append("subAgentEnabled", subAgentEnabled); builder.append("customAgentEnabled", customAgentEnabled); + builder.append("autoApprovalPolicyEnabled", autoApprovalPolicyEnabled); return builder.toString(); } } diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-operation-auto-approve/file-operation-auto-approve.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-operation-auto-approve/file-operation-auto-approve.md new file mode 100644 index 00000000..2a76f5f9 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-operation-auto-approve/file-operation-auto-approve.md @@ -0,0 +1,342 @@ +# File Operation Auto Approve + +## Overview +Tests the file-operation (read/write) auto-approve feature end-to-end: +configuring glob-pattern rules in the preference page, attaching context +files, then triggering Agent Mode tool calls and observing whether the +confirmation dialog appears or the operation runs automatically. + +Each test case exercises the full stack: preference store → CLS sync → +Agent Mode prompt → tool confirmation request → `ConfirmationService` → +`FileOperationConfirmationHandler` → dialog (or auto-approve) → file +operation execution. This mirrors the real user workflow: tweak settings, +attach files, chat with Copilot, observe behavior. + +Entry points exercised: +- **Preferences → GitHub Copilot → Tool Auto Approve** — the file + operation rule table (add / remove / toggle / reset). +- **Agent Mode chat** — sending prompts that trigger `copilot.read_file`, + `copilot.editFile`, `copilot.createFile`, or `copilot.deleteFile` tool + calls. +- **Confirmation dialog** — the button with session/global allow actions. +- **Attached files** — the context panel for user-attached files. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- A signed-in Copilot account on the host machine. +- Network access to `api.githubcopilot.com`. +- **Agent Mode** selected in the chat mode dropdown. +- A workspace with at least one Java project open (e.g., `demo`). +- No previous file-operation auto-approve rules beyond the defaults + (reset via "Reset to Defaults" before each scenario). +- "Auto approve file operations not covered by rules" is **unchecked** + unless the test specifies otherwise. + +--- + +## 1. Default behavior and dialog UI + +### TC-001: File read in workspace triggers confirmation dialog + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Default rules are active (not modified). +- "Auto approve file operations not covered by rules" is **unchecked**. + +#### Steps +1. Open **Preferences → GitHub Copilot → Tool Auto Approve**. +2. Verify the "File Operation Auto Approve" section is visible with a + table showing default deny rules (e.g., `.github/instructions/*`, + `github-copilot/**/*`). +3. **Manually uncheck** "Auto approve file operations not covered by rules" + (system default is checked; uncheck it for this test). Close preferences. +4. Open the **Copilot Chat** view, select **Agent** mode. +5. Type: `read the file src/demo/App.java and summarize it`. +6. Wait for the agent to invoke the `copilot.read_file` tool. +7. Observe the confirmation dialog. Verify it shows: + - Title: **"Read file"**, message mentioning the file name. + - **"Allow Once"** button with dropdown, and a **"Skip"** button. +8. Click the dropdown arrow. Verify the menu contains: + - "Allow this file in this Session" + - "Always Allow" +9. Click **"Skip"**. +10. Verify the agent receives a dismiss result — no file content. + +#### Expected Result +- No matching allow rule → confirmation dialog appears. +- Dialog renders correctly with session/global actions. +- Skip prevents execution. + +#### 📸 Key Screenshots +- [ ] Preference page with default rules. +- [ ] Confirmation dialog with dropdown expanded. +- [ ] Agent turn after skip. + +--- + +## 2. Attached file auto-approval + +### TC-002: Attached file auto-approves; deny rule does not block attached files + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- **Manually uncheck** "Auto approve file operations not covered by rules" + (system default is checked; uncheck it for this test). + +#### Steps +1. Open preferences, add a deny rule `**/*.java` → **Deny**. + Apply and close. +2. Open `src/demo/App.java` in the editor. +3. Open the **Copilot Chat** view, select **Agent** mode. +4. Attach `App.java` via the context panel (paperclip / "Add Context"). +5. Type: `read App.java and explain what it does`. +6. Observe **no confirmation dialog** — the file is auto-approved + because it is attached, even though the deny rule matches. +7. Verify the agent reads and summarizes the file content. +8. In the **same conversation**, type: `now read Helper.java`. +9. Observe **confirmation dialog appears** — `Helper.java` is not + attached and the deny rule matches. + +#### Expected Result +- Attached file auto-approval takes precedence over deny rules. +- Non-attached files still respect rules normally. + +#### 📸 Key Screenshots +- [ ] Context panel showing attached `App.java`. +- [ ] Agent auto-approved read without dialog. +- [ ] Dialog appears for non-attached `Helper.java`. + +--- + +## 3. Session-level file approval + +### TC-003: Session approval — read, then write, then new conversation + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- **Manually uncheck** "Auto approve file operations not covered by rules" + (system default is checked; uncheck it so the initial read triggers a dialog). + +#### Steps +1. Ensure no custom rules exist for the test file. +2. In Agent Mode, type: `read src/demo/App.java`. +3. Confirmation dialog appears. +4. Click dropdown → **"Allow this file in this Session"**. +5. The file is read successfully. +6. In the **same conversation**, type: `read src/demo/App.java again`. +7. Observe **auto-approved** — session cache hit. +8. In the **same conversation**, type: `add a comment "// test" to + the top of src/demo/App.java`. +9. The agent invokes `copilot.editFile` for `App.java`. +10. Observe **auto-approved** — session approval is path-based, covers + both reads and writes. +11. Start a **new conversation** (click "New Chat"). +12. Type: `read src/demo/App.java`. +13. Observe **confirmation dialog appears** — session approvals do not + carry to new conversations. + +#### Expected Result +- Session approval: same file re-read auto-approves. +- Session approval: write to same file auto-approves. +- New conversation: resets session state. + +#### 📸 Key Screenshots +- [ ] First dialog: selecting "Allow this file in this Session". +- [ ] Write auto-approved in same conversation. +- [ ] New conversation: dialog reappears. + +--- + +## 4. Global rules — allow, deny, and "Always Allow" from dialog + +### TC-004: Glob allow and deny rules with unmatched toggle + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Open **Preferences → GitHub Copilot → Tool Auto Approve**. +2. Add rule: `**/*.java` → **Allow**. Click OK. +3. Add rule: `**/secret/**` → **Deny**. Click OK. +4. Enable **"Auto approve file operations not covered by rules"**. +5. Click **"Apply and Close"**. +6. In Agent Mode, type: `read src/demo/App.java`. +7. Observe **auto-approved** — matches `**/*.java` allow rule. +8. Type: `read src/secret/config.properties`. +9. Observe **confirmation dialog** — matches `**/secret/**` deny rule, + even though unmatched is enabled. +10. Click **"Skip"**. +11. Type: `read README.md`. +12. Observe **auto-approved** — no rule matches, unmatched fallback. +13. Open preferences, **uncheck** "Auto approve file operations not + covered by rules". Apply and close. +14. Type: `read README.md`. +15. Observe **confirmation dialog** — unmatched now disabled. + +#### Expected Result +- Allow glob rule auto-approves matching files. +- Deny glob rule blocks matching files even with unmatched enabled. +- Unmatched toggle controls fallback for non-matching files. + +#### 📸 Key Screenshots +- [ ] Preference page with both rules. +- [ ] `.java` auto-approved, `secret/**` denied, `README.md` varies. + +--- + +### TC-005: "Always Allow" persists as global rule and overrides deny + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- **Manually uncheck** "Auto approve file operations not covered by rules" + (system default is checked; uncheck it for this test). + +#### Steps +1. Open preferences, add deny rule for the file's absolute path + (e.g., `C:\\demo\src\demo\App.java`) → **Deny**. Apply and close. +2. In Agent Mode, trigger a file read for `App.java`. +3. Confirmation dialog appears (deny rule matches). +4. Click dropdown → **"Always Allow"**. +5. The file is read. +6. Open **Preferences → Tool Auto Approve**. +7. Verify the rule changed from **Deny → Allow** (no duplicate). +8. Close preferences. +9. Start a **new conversation**. +10. Type: `read src/demo/App.java`. +11. Observe **auto-approved** — the updated global rule persists. + +#### Expected Result +- "Always Allow" writes/updates the file path as a global allow rule. +- Overrides existing deny rule (case-insensitive match, no duplicates). +- Persists across conversations. + +#### 📸 Key Screenshots +- [ ] Preference page: deny → allow transition. +- [ ] New conversation: auto-approved. + +--- + +## 5. Outside-workspace files and folder-level approval + +### TC-006: Outside-workspace file requires confirmation with folder +approval + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Enable "Auto approve file operations not covered by rules". +2. Add allow rule `**/*`. Apply and close. +3. In Agent Mode, type: `read C:\temp\test\file1.txt` + (path outside workspace). +4. Observe **confirmation dialog** — outside-workspace files always + require confirmation regardless of rules. +5. Verify the dialog offers folder-level approval: + - "Allow Once" + - "Allow files in 'test' folder in this Session" + - "Skip" +6. Click dropdown → **"Allow files in 'test' folder in this Session"**. +7. The file is read. +8. In the same conversation, trigger read for `C:\temp\test\file2.txt`. +9. Observe **auto-approved** — same folder. +10. Trigger read for `C:\temp\other\file3.txt`. +11. Observe **confirmation dialog** — different folder. + +#### Expected Result +- Outside-workspace files bypass rules, always show dialog. +- Folder-level session approval covers sibling files. +- Different folders still require confirmation. + +#### 📸 Key Screenshots +- [ ] Dialog with folder-level action. +- [ ] Same-folder auto-approved. +- [ ] Different-folder dialog. + +--- + +## 6. Session approval overrides deny rule + +### TC-007: Session approval overrides deny rule within conversation + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Steps +1. Add deny rule `**/App.java` in preferences. Apply and close. +2. In Agent Mode, trigger a read for `src/demo/App.java`. +3. Confirmation dialog appears (deny rule). +4. Click dropdown → **"Allow this file in this Session"**. +5. The file is read. +6. In the same conversation, trigger another read for `App.java`. +7. Observe **auto-approved** — session approval checked before rules. + +#### Expected Result +- Session-level approval overrides global deny rules. + +--- + +## 7. Subagent inherits parent session approvals + +### TC-008: Session file approval applies to subagent + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- No custom rules for the test file. +- "Auto approve file operations not covered by rules" is **unchecked**. + +#### Steps +1. In Agent Mode, trigger a read for `App.java`. +2. Confirmation dialog appears. +3. Select **"Allow this file in this Session"**. +4. The file is read. +5. In the **same conversation**, send a prompt that spawns a subagent + (e.g., `use a subagent to analyze App.java`). +6. The subagent invokes `copilot.read_file` for `App.java`. +7. Observe **auto-approved** — session approval carries to subagent. + +#### Expected Result +- Subagent shares parent conversation's session scope. + +--- + +## 8. Reset to Defaults + +### TC-009: Reset clears custom rules and reverts behavior + +**Type:** `Happy Path` +**Priority:** `P2` + +#### Steps +1. Add custom rules: `**/*.java` → Allow, `**/secret/*` → Deny. + Apply and close. +2. Verify in Agent Mode: `.java` files auto-approve. +3. Open preferences, click **"Reset to Defaults"** and confirm. +4. **Manually uncheck** "Auto approve file operations not covered by rules" + (Reset to Defaults only clears rules, it does not reset this checkbox). + Apply and close. +5. Verify only default deny rules remain (`.github/instructions/*`, + `github-copilot/**/*`). +5. Apply and close. +6. In Agent Mode, trigger a `.java` file read. +7. Observe **confirmation dialog** — custom allow rule removed. + +#### Expected Result +- Reset removes all custom rules and restores defaults. + +#### 📸 Key Screenshots +- [ ] Preference page after reset — only defaults. +- [ ] `.java` file shows confirmation dialog post-reset. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/global-auto-approve/global-auto-approve.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/global-auto-approve/global-auto-approve.md new file mode 100644 index 00000000..09cf4171 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/global-auto-approve/global-auto-approve.md @@ -0,0 +1,125 @@ +# Global Auto-Approve + +## Overview + +Tests the Global Auto-Approve (YOLO) feature: enabling/disabling it via the +preference page and verifying that all tool confirmations are bypassed when +active. + +Entry points exercised: +- **Preferences → GitHub Copilot → Tool Auto Approve → Global Auto-Approve** — + the "Automatically approve ALL tool invocations" checkbox with its + confirmation dialog. +- **Agent Mode chat** — any tool call (terminal, file operation, MCP) to + observe confirmation bypass. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- A signed-in Copilot account on the host machine. +- Network access to `api.githubcopilot.com`. +- **Agent Mode** selected in the chat mode dropdown. +- Global Auto-Approve is **disabled** at the start of each scenario. + +--- + +## 1. Enable Global Auto-Approve — confirmation dialog required + +### TC-001: Enable Global Auto-Approve → confirmation dialog required +→ all tools skip confirmation + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Open **Preferences → Tool Auto Approve → Global Auto-Approve** section. +2. Click the **"Automatically approve ALL tool invocations"** checkbox. +3. Observe that a **confirmation dialog immediately appears** asking the user + to confirm this dangerous setting. +4. Verify the dialog title and message warn about the risk. +5. Click **Cancel** — verify the checkbox remains **unchecked**. +6. Click the checkbox again, then click **OK** in the confirmation dialog. +7. Verify the checkbox is now **checked**. +8. Click **"Apply and Close"**. +9. In Agent Mode, send a prompt that would normally trigger a confirmation + (e.g., an MCP tool call or a terminal command). +10. Observe that **no confirmation dialog appears** — all tools auto-approve. + +#### Expected Result +- Enabling YOLO mode requires an explicit confirmation dialog. +- Cancelling the dialog keeps the checkbox unchecked. +- When enabled, all tool confirmations (terminal, file operations, MCP) + are bypassed. + +#### 📸 Key Screenshots +- [ ] Confirmation dialog when enabling YOLO mode. +- [ ] Checkbox unchecked after Cancel. +- [ ] Checkbox checked after OK. +- [ ] Agent Mode: tool runs without any confirmation dialog. + +--- + +## 2. Disable Global Auto-Approve — no confirmation needed + +### TC-002: Disable Global Auto-Approve → no dialog → tools require +confirmation again + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- Global Auto-Approve is **enabled**. + +#### Steps +1. Open **Preferences → Tool Auto Approve → Global Auto-Approve** section. +2. Click the **"Automatically approve ALL tool invocations"** checkbox to + uncheck it. +3. Observe that **no confirmation dialog appears** — turning it off is safe + and does not require confirmation. +4. Verify the checkbox is now **unchecked**. +5. Click **"Apply and Close"**. +6. In Agent Mode, trigger any tool. +7. Observe that the **confirmation dialog appears** — YOLO mode is off. + +#### Expected Result +- Disabling YOLO mode does not require a confirmation dialog. +- Tools require confirmation again after disabling. + +#### 📸 Key Screenshots +- [ ] Checkbox unchecked without any dialog. +- [ ] Tool shows confirmation dialog again. + +--- + +## 3. Global Auto-Approve overrides all tool categories + +### TC-003: Global Auto-Approve bypasses terminal deny rules, MCP +rules, and file operation rules + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- Global Auto-Approve is **enabled**. +- Terminal has a custom **Deny** rule for `curl`. + +#### Steps +1. In Agent Mode, trigger a `curl` terminal command (normally blocked by the + deny rule). +2. Observe **auto-approved** — YOLO mode bypasses the deny rule. +3. Trigger an MCP tool call with no prior MCP approval. +4. Observe **auto-approved** — YOLO mode bypasses MCP confirmation. +5. Trigger a file operation on a file not in the attached context. +6. Observe **auto-approved** — YOLO mode bypasses file operation confirmation. + +#### Expected Result +- Global Auto-Approve bypasses ALL tool categories regardless of individual + rules or approval lists. + +#### 📸 Key Screenshots +- [ ] `curl` auto-approved despite deny rule. +- [ ] MCP tool auto-approved without prior approval. +- [ ] File operation auto-approved. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/mcp-auto-approve/mcp-auto-approve.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/mcp-auto-approve/mcp-auto-approve.md new file mode 100644 index 00000000..0659ec51 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/mcp-auto-approve/mcp-auto-approve.md @@ -0,0 +1,265 @@ +# MCP Auto-Approve + +## Overview + +Tests the MCP tool auto-approve feature end-to-end: configuring rules in the +preference page, then triggering Agent Mode tool calls and observing whether +the confirmation dialog appears or the tool runs automatically. + +Each test case exercises the full stack: preference store → +`McpConfirmationHandler` → dialog (or auto-approve) → tool execution. This +mirrors the real user workflow: tweak settings, chat with Copilot via an MCP +tool, observe behavior. + +Entry points exercised: +- **Preferences → GitHub Copilot → Tool Auto Approve → MCP Configuration** — + the "Trust MCP tool annotations" checkbox and the server/tool tree. +- **Agent Mode chat** — sending prompts that trigger MCP tool calls. +- **Confirmation dialog** — the split-dropdown button with session/global + allow actions for tool and server scope. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- A signed-in Copilot account on the host machine. +- Network access to `api.githubcopilot.com`. +- **At least one MCP server configured** in the Copilot MCP settings, with + at least one tool available. Note the server name and a tool name for use + in the prompts below. +- **Agent Mode** selected in the chat mode dropdown. +- All MCP auto-approve preferences at their defaults before each scenario: + no globally approved servers/tools, "Trust MCP tool annotations" unchecked. + +--- + +## 1. Default behavior: confirmation dialog appears for MCP tools + +### TC-001: MCP tool call with no rules → confirmation dialog + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- No global approved servers or tools. +- "Trust MCP tool annotations" is **unchecked**. + +#### Steps +1. Open **Preferences → GitHub Copilot → Tool Auto Approve**. +2. Navigate to the **MCP Configuration** section. +3. Verify the server/tool tree shows no tools checked. +4. Confirm "Trust MCP tool annotations" is unchecked. +5. Close preferences. +6. Open the **Copilot Chat** view and select **Agent** mode. +7. Send a prompt that triggers the known MCP tool (e.g., `use to + `). +8. Wait for the Copilot turn — the agent should invoke the MCP tool. +9. Observe the **confirmation dialog** that appears in the chat panel. +10. Verify the dialog shows: + - Bold title: `Run '' tool from '' MCP server`. + - A description of the MCP tool call. + - A blue **"Allow Once ▾"** split-dropdown button and a **"Skip"** button. +11. Click the dropdown arrow on "Allow Once ▾". +12. Verify the dropdown contains: + - "Allow '' in this Session" + - "Always Allow ''" + - "Allow tools from '' in this Session" + - "Always Allow tools from ''" +13. Click **"Skip"**. +14. Verify the tool was **NOT** executed. + +#### Expected Result +- Confirmation dialog appears for MCP tools with no auto-approve rules. +- Dropdown shows tool-level and server-level scoped actions. +- Skipping prevents execution. + +#### 📸 Key Screenshots +- [ ] MCP preference section with no checked tools. +- [ ] Confirmation dialog with dropdown expanded showing all actions. +- [ ] Agent turn after skip — no tool output. + +--- + +## 2. Session allow for a specific tool + +### TC-002: "Allow tool in Session" → same tool auto-approves → new +conversation resets + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- No global approved servers or tools. +- "Trust MCP tool annotations" is **unchecked**. + +#### Steps +1. In Agent Mode, send a prompt that triggers the MCP tool. +2. Confirmation dialog appears. +3. Click the dropdown arrow and select **"Allow '' in this + Session"**. +4. The tool executes. +5. In the **same conversation**, send another prompt that triggers the same + tool. +6. Observe that **no confirmation dialog appears** — the tool is + session-approved. +7. Start a **new conversation** (click "New Chat" or equivalent). +8. Send a prompt that triggers the same MCP tool. +9. Observe that the **confirmation dialog appears again** — session approvals + do not carry over. + +#### Expected Result +- Session approval auto-approves the same tool within the conversation. +- New conversation resets session approvals. + +#### 📸 Key Screenshots +- [ ] First dialog: selecting "Allow '' in this Session". +- [ ] Second invocation (same conversation): auto-approved, no dialog. +- [ ] New conversation: dialog reappears. + +--- + +## 3. Session allow for an entire server + +### TC-003: "Allow all tools from server in Session" → all tools from +that server auto-approve + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- No global approved servers or tools. +- "Trust MCP tool annotations" is **unchecked**. + +#### Steps +1. In Agent Mode, send a prompt that triggers any tool from the target + MCP server. +2. Confirmation dialog appears. +3. Click the dropdown and select **"Allow all tools from '' + in this Session"**. +4. The tool executes. +5. In the **same conversation**, send prompts that trigger **different tools** + from the same server. +6. Observe that **none of them** show a confirmation dialog. +7. Start a **new conversation**. +8. Trigger any tool from the same server. +9. Observe that the **confirmation dialog appears again**. + +#### Expected Result +- Server-level session approval covers all tools from that server. +- New conversation resets the session approval. + +#### 📸 Key Screenshots +- [ ] Dropdown: selecting "Allow tools from '' in this Session". +- [ ] Second tool from same server: auto-approved. +- [ ] New conversation: dialog reappears. + +--- + +## 4. "Always Allow" for a specific tool — global persistence + +### TC-004: "Always Allow ''" → persists across conversations → +visible in preferences tree + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- No global approved servers or tools. +- "Trust MCP tool annotations" is **unchecked**. + +#### Steps +1. In Agent Mode, trigger the MCP tool. +2. Confirmation dialog appears. +3. Click the dropdown and select **"Always Allow ''"**. +4. The tool executes. +5. Open **Preferences → Tool Auto Approve → MCP Configuration**. +6. Verify the specific tool is **checked** in the server/tool tree. +7. Close preferences. +8. Start a **new conversation**. +9. Send a prompt that triggers the same MCP tool. +10. Observe that **no confirmation dialog appears** — the global rule persists. + +#### Expected Result +- "Always Allow" writes the tool key to the global preference store. +- The tool appears checked in the preference tree. +- The approval persists across conversations. + +#### 📸 Key Screenshots +- [ ] Dropdown: selecting "Always Allow ''". +- [ ] Preference tree: tool checked. +- [ ] New conversation: tool auto-approved without dialog. + +--- + +## 5. "Always Allow" for an entire server — global persistence + +### TC-005: "Always Allow all tools from ''" → server shown +checked in preferences + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- No global approved servers or tools (clear any rules written by TC-004 + if running in sequence). +- "Trust MCP tool annotations" is **unchecked**. + +#### Steps +1. In Agent Mode, trigger any MCP tool. +2. Confirmation dialog appears. +3. Click the dropdown and select **"Always Allow all tools from + ''"**. +4. The tool executes. +5. Open **Preferences → Tool Auto Approve → MCP Configuration**. +6. Verify the server node is **checked** in the tree. +7. Close preferences. +8. Start a **new conversation** and trigger different tools from the same + server. +9. Observe all tools **auto-approve without dialog**. + +#### Expected Result +- "Always Allow" for server writes to the global servers list. +- The server row appears checked in the preference tree. +- All tools from the server auto-approve in new conversations. + +#### 📸 Key Screenshots +- [ ] Dropdown: "Always Allow all tools from ''". +- [ ] Preference tree: server node checked. +- [ ] New conversation: all server tools auto-approved. + +--- + +## 6. Trust MCP tool annotations — read-only tools auto-approve + +### TC-006: Enable "Trust MCP tool annotations" → tools with +readOnlyHint=true auto-approve + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- An MCP tool is available with `readOnlyHint=true` and `openWorldHint=false` + in its annotations. + +#### Steps +1. Open **Preferences → Tool Auto Approve → MCP Configuration**. +2. Check **"Trust MCP tool annotations"**. +3. Click **"Apply and Close"**. +4. In Agent Mode, send a prompt that triggers the read-only MCP tool. +5. Observe that **no confirmation dialog appears** — the tool auto-approves + because it is annotated as read-only and closed-world. +6. Send a prompt that triggers an MCP tool that does NOT have + `readOnlyHint=true` (or has `openWorldHint=true`). +7. Observe that the **confirmation dialog appears** — only strictly + read-only + closed-world tools bypass confirmation. + +#### Expected Result +- Tools with `readOnlyHint=true` AND `openWorldHint=false` auto-approve. +- All other tools still show the confirmation dialog. + +#### 📸 Key Screenshots +- [ ] Preference: "Trust MCP tool annotations" checked. +- [ ] Read-only tool: auto-approved. +- [ ] Non-read-only tool: confirmation dialog shown. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/terminal-auto-approve/terminal-auto-approve.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/terminal-auto-approve/terminal-auto-approve.md new file mode 100644 index 00000000..180783a4 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/terminal-auto-approve/terminal-auto-approve.md @@ -0,0 +1,551 @@ +# Terminal Auto Approve + +## Overview +Tests the terminal command auto-approve feature end-to-end: configuring rules +in the preference page, then triggering Agent Mode tool calls and observing +whether the confirmation dialog appears or the command runs automatically. + +Each test case exercises the full stack: preference store → CLS sync → +Agent Mode prompt → tool confirmation request → `ConfirmationService` → +`TerminalConfirmationHandler` → dialog (or auto-approve) → terminal +execution. This mirrors the real user workflow: tweak settings, chat with +Copilot, observe behavior. + +Entry points exercised: +- **Preferences → GitHub Copilot → Tool Auto Approve** — the terminal + rule table (add / remove / toggle / reset). +- **Agent Mode chat** — sending prompts that trigger `run_in_terminal` + tool calls. +- **Confirmation dialog** — the split-dropdown button with session/global + allow actions. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- A signed-in Copilot account on the host machine. +- Network access to `api.githubcopilot.com`. +- **Agent Mode** selected in the chat mode dropdown. +- No previous auto-approve rules beyond the defaults (reset via + "Reset to Defaults" before each scenario). + +--- + +## 1. Default deny rules block dangerous commands + +### TC-001: Default deny rules + Agent Mode terminal call + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Default rules are active (not modified). +- "Auto approve commands not covered by rules" is **unchecked**. + +#### Steps +1. Open **Preferences → GitHub Copilot → Tool Auto Approve**. +2. Verify the "Terminal Auto Approve" section is visible with a table + showing default deny rules (rm, rmdir, del, kill, curl, wget, eval, + chmod, chown, and the regex rules for subshells / backticks / braces). +3. Confirm "Auto approve commands not covered by rules" is unchecked. +4. Close preferences. +5. Open the **Copilot Chat** view and select **Agent** mode. +6. Wait for the model picker to resolve. +7. Type the prompt: `please run curl https://example.com`. +8. Wait for a Copilot turn to stream — the agent should invoke the + `run_in_terminal` tool with a `curl` command. +9. Observe the confirmation dialog that appears in the chat panel. +10. Verify the dialog shows: + - Bold title: **"Run command in terminal"** + - Message: *"The tool is about to run the following command in the + terminal."* + - The command text in a scrollable panel with `bg-command-panel` + background. + - A blue **"Allow Once ▾"** split-dropdown button and a **"Skip"** + button. +11. Click the dropdown arrow on "Allow Once ▾". +12. Verify the dropdown menu contains: + - "Allow 'curl' in this Session" + - "Always Allow 'curl'" + - "Allow this exact command in this Session" (if the full command + differs from "curl") + - "Always Allow this exact command" + - "Allow all commands in this Session" +13. Click **"Skip"**. +14. Verify the command was NOT executed — the agent receives a dismiss + result and continues without terminal output. + +#### Expected Result +- Default deny rule for `curl` causes the confirmation dialog to appear. +- The dialog renders correctly with split-dropdown actions. +- Skipping prevents execution. + +#### 📸 Key Screenshots +- [ ] Preference page with default rules visible. +- [ ] Confirmation dialog with dropdown expanded showing all actions. +- [ ] Agent turn after skip — no terminal output. + +--- + +## 2. Custom allow rule auto-approves matching commands + +### TC-002: Add allow rule → Agent auto-approves → verify execution + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Open **Preferences → GitHub Copilot → Tool Auto Approve**. +2. Click **"Add..."** in the Terminal Auto Approve section. +3. Enter `systeminfo` as command, select **"Allow"**, click OK. +4. Verify `systeminfo` appears in the table with "Allow" status. +5. Click **"Apply and Close"**. +6. In Agent Mode chat, type: `run systeminfo to show my computer info`. +7. Wait for the agent to invoke `run_in_terminal`. +8. Observe that **no confirmation dialog appears** — the command runs + directly. +9. Wait for the tool call to complete — the agent should report terminal + output containing system information. +10. Open preferences again and verify the rule is still present. + +#### Expected Result +- The custom allow rule causes the `systeminfo` command to auto-approve. +- No confirmation dialog is shown. +- The terminal runs the command and the agent receives output. + +#### 📸 Key Screenshots +- [ ] Preference page with `systeminfo` Allow rule added. +- [ ] Agent turn showing "✔ Ran run_in_terminal tool" without a + confirmation dialog in between. +- [ ] Agent response containing system information output. + +--- + +## 3. Session "Allow command name" persists within conversation + +### TC-003: Allow name in Session → same command auto-approves → new +conversation resets + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Ensure no custom rules for `echo` exist (only defaults). +2. In Agent Mode, type: `run echo hello world`. +3. Confirmation dialog appears (echo has no matching rule and unmatched + is disabled). +4. Click the dropdown arrow and select **"Allow 'echo' in this Session"**. +5. The command executes. +6. In the **same conversation**, type: `run echo second message`. +7. Observe that **no confirmation dialog appears** — the command + auto-approves because `echo` is session-approved. +8. Verify the agent shows terminal output for both commands. +9. Start a **new conversation** (click "New Chat" or equivalent). +10. Type: `run echo third message`. +11. Observe that the **confirmation dialog appears again** — session + approvals do not carry over to new conversations. + +#### Expected Result +- First invocation: dialog shown, user selects session allow. +- Second invocation (same conversation): auto-approved. +- Third invocation (new conversation): dialog shown again. + +#### 📸 Key Screenshots +- [ ] First dialog with dropdown showing "Allow 'echo' in this Session". +- [ ] Second invocation auto-approved — no dialog. +- [ ] New conversation — dialog reappears. + +--- + +## 4. "Always Allow" persists globally + +### TC-004: Always Allow command name → persists across conversations +and appears in preferences + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Ensure no custom rules for `dir` exist. +2. In Agent Mode, type: `list files in current directory`. +3. Confirmation dialog appears for the `dir` (or `ls`) command. +4. Click the dropdown and select **"Always Allow 'dir'"**. +5. The command executes. +6. Open **Preferences → Tool Auto Approve**. +7. Verify `dir` appears in the rules table with **"Allow"** status. +8. Close preferences. +9. Start a **new conversation**. +10. Type: `show me what files are here` (triggers `dir` again). +11. Observe that **no confirmation dialog appears** — the global rule + persists. + +#### Expected Result +- The "Always Allow" action writes the command name to the preference + store. +- The rule appears in the preference page UI. +- The rule survives across conversations. + +#### 📸 Key Screenshots +- [ ] Dropdown selection: "Always Allow 'dir'". +- [ ] Preference page showing `dir` as Allow rule. +- [ ] New conversation: `dir` auto-approved. + +--- + +## 5. Exact command vs. command name distinction + +### TC-005: Exact command Session approval only matches the same +command line + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Steps +1. In Agent Mode, type: `run echo hello world`. +2. Confirmation dialog appears. +3. Click dropdown and select **"Allow this exact command in this + Session"**. +4. The command executes. +5. In the same conversation, type: `run echo hello world` again. +6. Observe **auto-approved** — exact same command line. +7. In the same conversation, type: `run echo different text`. +8. Observe **confirmation dialog appears** — different command line, + even though command name `echo` is the same. + +#### Expected Result +- Exact command approval is strict: only the identical command line + is auto-approved. +- A different argument string requires separate confirmation. + +#### 📸 Key Screenshots +- [ ] First dialog: selecting "Allow this exact command in this Session". +- [ ] Same command: auto-approved. +- [ ] Different arguments: dialog appears again. + +--- + +## 6. Simple command hides redundant exact-command actions + +### TC-006: Single-word command shows minimal dropdown + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Steps +1. In Agent Mode, trigger a single-word command like `ipconfig` + (or `hostname`). +2. Confirmation dialog appears. +3. Click the dropdown arrow. +4. Count the menu items. + +#### Expected Result +- "Allow 'ipconfig' in this Session" — present. +- "Always Allow 'ipconfig'" — present. +- "Allow this exact command in this Session" — **NOT present** + (redundant: exact command = command name for single-word commands). +- "Always Allow this exact command" — **NOT present**. +- "Allow all commands in this Session" — present. + +--- + +## 7. Unmatched auto-approve toggle + +### TC-007: Enable unmatched auto-approve → unknown commands pass +through → disable → they require confirmation again + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Steps +1. Open **Preferences → Tool Auto Approve**. +2. Check **"Auto approve commands not covered by rules"**. +3. Click **"Apply and Close"**. +4. In Agent Mode, type: `run hostname`. +5. Observe **auto-approved** — `hostname` has no matching rule but + unmatched auto-approve is enabled. +6. Open preferences again. +7. **Uncheck** "Auto approve commands not covered by rules". +8. Click **"Apply and Close"**. +9. In the same or new conversation, type: `run hostname`. +10. Observe **confirmation dialog appears**. + +#### Expected Result +- With unmatched enabled: unknown commands auto-approve. +- With unmatched disabled: unknown commands require confirmation. + +#### 📸 Key Screenshots +- [ ] Preference: "Auto approve commands not covered by rules" checked. +- [ ] `hostname` auto-approved. +- [ ] Preference: unchecked. +- [ ] `hostname` shows confirmation dialog. + +--- + +## 8. Regex rule integration + +### TC-008: Add a case-insensitive regex allow rule → verify it +matches in Agent Mode + +**Type:** `Happy Path` +**Priority:** `P2` + +#### Steps +1. Open **Preferences → Tool Auto Approve**. +2. Click **"Add..."**, enter `/^npm\b/i`, select **"Allow"**, click OK. +3. Click **"Apply and Close"**. +4. In Agent Mode, type: `install lodash using npm`. +5. Agent invokes `npm install lodash`. +6. Observe **auto-approved** — regex `/^npm\b/i` matches. +7. Type: `run NPM run build` (uppercase). +8. Observe **auto-approved** — case-insensitive flag `/i` matches. + +#### Expected Result +- The regex allow rule auto-approves matching commands. +- Case-insensitive flag works correctly. + +--- + +## 9. "Allow all commands in this Session" — blanket approval + +### TC-009: Allow all → every subsequent terminal call auto-approves +in this conversation + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Steps +1. In Agent Mode, trigger any terminal command. +2. Confirmation dialog appears. +3. Click dropdown and select **"Allow all commands in this Session"**. +4. The command executes. +5. In the same conversation, send multiple prompts that trigger + different terminal commands (e.g., "run dir", "run echo test", + "run ipconfig"). +6. Observe that **none of them** show a confirmation dialog. +7. Start a **new conversation**. +8. Trigger any terminal command. +9. Observe that the **confirmation dialog appears again**. + +#### Expected Result +- "Allow all" is a blanket session-scoped approval. +- Every terminal command in the same conversation auto-approves. +- New conversations start fresh. + +--- + +## 10. Reset to Defaults clears custom rules + +### TC-010: Add custom rules → Reset → verify Agent Mode behavior +reverts + +**Type:** `Happy Path` +**Priority:** `P2` + +#### Steps +1. Open preferences and add `echo` as Allow, `javac` as Allow. +2. Apply and close. +3. Verify in Agent Mode: `echo hello` auto-approves. +4. Open preferences again. +5. Click **"Reset to Defaults"** and confirm. +6. Verify only default deny rules remain — `echo` and `javac` are gone. +7. Apply and close. +8. In Agent Mode, trigger `echo hello`. +9. Observe **confirmation dialog appears** — the custom allow rule was + removed. + +#### Expected Result +- Reset removes all custom rules. +- Commands that were previously allowed now require confirmation. + +#### 📸 Key Screenshots +- [ ] Preference page after adding custom rules. +- [ ] Preference page after reset — only defaults. +- [ ] `echo hello` shows confirmation dialog post-reset. + +--- + +## 11. Already-approved names filtered from dropdown + +### TC-011: Session-approved command name hidden from dropdown +actions in multi-command scenario + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Steps +1. In Agent Mode, trigger a command that includes `echo` + (e.g., "run echo hello"). +2. Confirmation dialog appears. +3. Select **"Allow 'echo' in this Session"** from the dropdown. +4. The command executes. +5. In the same conversation, send a prompt that triggers a + multi-command like `echo test && curl https://example.com`. +6. Confirmation dialog appears (because `curl` is a default deny + rule). +7. Click the dropdown arrow. + +#### Expected Result +- The dropdown shows **"Allow 'curl' in this Session"** and + **"Always Allow 'curl'"**. +- `echo` does **NOT** appear in the command-name actions — it was + already session-approved. +- "Allow all commands in this Session" is still shown. +- Exact command actions (if shown) refer to the full multi-command + string, not individual parts. + +#### 📸 Key Screenshots +- [ ] First dialog: approving `echo` in session. +- [ ] Second dialog: dropdown showing only `curl`, not `echo`. + +--- + +### TC-012: Global-approved command name hidden from dropdown + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Steps +1. Open **Preferences → Tool Auto Approve**. +2. Add `echo` as **Allow** rule. Apply and close. +3. In Agent Mode, trigger `echo hello && hostname`. +4. Confirmation dialog appears (because `hostname` has no matching + rule and unmatched is disabled). +5. Click the dropdown arrow. + +#### Expected Result +- The dropdown shows **"Allow 'hostname' in this Session"** and + **"Always Allow 'hostname'"**. +- `echo` does **NOT** appear — it is globally allowed. +- "Allow all commands in this Session" is still shown. + +--- + +## 13. Subagent inherits parent session approvals + +### TC-013: Session approval in main agent applies to subagent calls + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- No custom allow rules for `echo` or `cat` (only defaults). +- "Auto approve commands not covered by rules" is **unchecked**. + +#### Steps +1. In Agent Mode, send a prompt that triggers `echo hello`. +2. Confirmation dialog appears. +3. Click dropdown and select **"Allow 'echo' in this Session"**. +4. The command executes. +5. In the **same conversation**, send a complex prompt that causes the + agent to spawn a **subagent** (e.g., `use a subagent to run echo + from subagent`). +6. The subagent invokes `run_in_terminal` with an `echo` command. +7. Observe that **no confirmation dialog appears** — the session + approval from the main agent carries over to the subagent. +8. Still in the subagent context, the subagent invokes a different + command (e.g., `cat somefile.txt`). +9. Observe that a **confirmation dialog appears** — `cat` was not + session-approved. + +#### Expected Result +- Subagent tool calls use the parent conversation's session rules. +- Commands approved in the main agent conversation auto-approve in + subagent context. +- Commands NOT approved still require confirmation in subagent context. + +#### 📸 Key Screenshots +- [ ] Main agent: approving `echo` in session. +- [ ] Subagent: `echo` auto-approved — no dialog. +- [ ] Subagent: `cat` shows confirmation dialog. + +--- + +### TC-014: Session approval in subagent carries back to main agent + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- No custom allow rules for `hostname`. +- "Auto approve commands not covered by rules" is **unchecked**. + +#### Steps +1. In Agent Mode, send a complex prompt that triggers a **subagent**. +2. The subagent invokes `run_in_terminal` with `hostname`. +3. Confirmation dialog appears. +4. Click dropdown and select **"Allow 'hostname' in this Session"**. +5. The command executes in the subagent context. +6. After the subagent completes, continue in the **same conversation** + with the main agent. +7. Send a prompt that triggers `hostname` again (e.g., `what is my + hostname?`). +8. Observe that **no confirmation dialog appears** — the session + approval made during the subagent call is shared with the main + conversation. + +#### Expected Result +- Session approvals made during subagent execution are stored under + the parent conversation's session scope. +- The main agent benefits from approvals granted in subagent context. + +#### 📸 Key Screenshots +- [ ] Subagent: approving `hostname` in session. +- [ ] Main agent: `hostname` auto-approved — no dialog. + +--- + +### TC-015: "Allow all commands in this Session" in main agent covers subagent + +**Type:** `Edge Case` +**Priority:** `P2` + +#### Steps +1. In Agent Mode, trigger any terminal command. +2. Confirmation dialog appears. +3. Select **"Allow all commands in this Session"** from the dropdown. +4. In the **same conversation**, send a prompt that spawns a subagent + which runs multiple different terminal commands. +5. Observe that **none of them** show a confirmation dialog — the + blanket session approval covers subagent calls too. + +#### Expected Result +- "Allow all commands in this Session" is a blanket approval that + applies to both main agent and subagent tool calls within the + same conversation. + +--- + +## 14. Edge Cases + +### TC-016: "Always Allow" overrides existing deny rule in preferences + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Steps +1. Open **Preferences → Tool Auto Approve**. +2. Click **"Add..."**, enter `curl`, select **"Deny"**, click OK. +3. Click **"Apply and Close"**. +4. In Agent Mode, type a prompt that triggers `curl` (e.g., `fetch + https://example.com using curl`). +5. Confirmation dialog appears (deny rule blocks it). +6. Click dropdown and select **"Always Allow curl"**. +7. The command executes. +8. Open **Preferences → Tool Auto Approve** again. +9. Verify the `curl` rule has been changed from **Deny → Allow**. +10. Start a **new conversation**, trigger `curl` again. +11. Observe **auto-approved** — the global allow rule now applies. + +#### Expected Result +- Clicking "Always Allow" in the dialog overrides an existing deny + rule in preferences, changing it from deny to allow. +- The updated rule persists across conversations. +- The preferences table reflects the updated rule. + +#### 📸 Key Screenshots +- [ ] Preferences: `curl → Deny` rule before override. +- [ ] Confirmation dialog: showing "Always Allow curl" action. +- [ ] Preferences: `curl → Allow` rule after override. +- [ ] New conversation: `curl` auto-approved without dialog. diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandlerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandlerTests.java new file mode 100644 index 00000000..5d9b9573 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandlerTests.java @@ -0,0 +1,737 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.Mockito.when; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.google.gson.Gson; +import org.eclipse.jface.preference.IPreferenceStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.chat.FileOperationAutoApproveRule; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata.SensitiveFileData; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class FileOperationConfirmationHandlerTests { + + private static final String CONV_ID = "conv-1"; + private static final Gson GSON = new Gson(); + + @Mock + private IPreferenceStore preferenceStore; + + private AttachedFileRegistry attachedFileRegistry; + private FileOperationConfirmationHandler handler; + + @BeforeEach + void setUp() { + attachedFileRegistry = new AttachedFileRegistry(); + handler = new FileOperationConfirmationHandler( + preferenceStore, attachedFileRegistry); + } + + // --- glob matching behavior (tested via evaluate + rules) --- + + @Test + void evaluate_globExactPathMatchCaseInsensitive() { + // Rule uses forward slash + lowercase; evaluate uses backslash + uppercase + stubRules(List.of( + new FileOperationAutoApproveRule("C:/Users/test.java", "", true))); + stubUnmatched(false); + + assertTrue(evaluate( + buildParams("C:\\Users\\test.java", false), CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_globStarStarPatternMatches() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", true))); + stubUnmatched(false); + + assertTrue(evaluate( + buildParams("/workspace/src/Main.java", false), CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_globPatternNoMatch() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.py", "", true))); + stubUnmatched(false); + + assertFalse(evaluate( + buildParams("/workspace/src/Main.java", false), CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_globBackslashPathNormalized() { + // Rule uses forward slashes; file path uses backslashes + stubRules(List.of( + new FileOperationAutoApproveRule("**/.github/instructions/*", "", true))); + stubUnmatched(false); + + assertTrue(evaluate( + buildParams("C:\\project\\.github\\instructions\\file.md", false), + CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_invalidGlobRuleFallsThrough() { + // Invalid glob should not match; falls through to unmatched setting + stubRules(List.of( + new FileOperationAutoApproveRule("[invalid", "", true))); + stubUnmatched(true); + + assertTrue(evaluate( + buildParams("/a/b.java", false), CONV_ID).isAutoApproved()); + } + + // --- evaluate: attached files --- + + @Test + void evaluate_autoApprovedWhenFileAttachedViaPending() { + attachedFileRegistry.addPending(List.of("/workspace/src/Main.java")); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_autoApprovedWhenFileAttachedToConversation() { + attachedFileRegistry.addAttachedFiles(CONV_ID, + List.of("/workspace/src/Main.java")); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_attachedFilePathNormalized() { + // Attached with backslashes + uppercase + attachedFileRegistry.addPending( + List.of("C:\\Workspace\\Src\\Main.java")); + + // Evaluate with forward slashes + lowercase + InvokeClientToolConfirmationParams params = + buildParams("c:/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate: session overrides --- + + @Test + void evaluate_autoApprovedBySessionFileApproval() { + // Cache a file-level session approval + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + handler.cacheDecision(action, params, CONV_ID); + + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_sessionFileApprovalNormalizesPath() { + // Cache with backslash + uppercase + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "C:\\Workspace\\Main.java")); + handler.cacheDecision(action, + buildParams("C:\\Workspace\\Main.java", false), CONV_ID); + + // Evaluate with forward slash + lowercase + InvokeClientToolConfirmationParams params = + buildParams("c:/workspace/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_autoApprovedBySessionFolderApproval() { + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION, + Map.of(FileOperationConfirmationHandler.META_FOLDER_PATH, + "/home/user/external")); + handler.cacheDecision(action, + buildParams("/home/user/external/file.txt", true), CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/home/user/external/data.csv", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_sessionFolderDoesNotMatchParentPath() { + stubRules(List.of()); + stubUnmatched(false); + + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION, + Map.of(FileOperationConfirmationHandler.META_FOLDER_PATH, + "/home/user/external")); + handler.cacheDecision(action, + buildParams("/home/user/external/file.txt", true), CONV_ID); + + // File in a different folder (prefix but not under the folder) + InvokeClientToolConfirmationParams params = + buildParams("/home/user/external-other/file.txt", false); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_sessionApprovalDoesNotAffectOtherConversation() { + stubRules(List.of()); + stubUnmatched(false); + + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertFalse(evaluate(params, "other-conv").isAutoApproved()); + } + + // --- evaluate: outside workspace --- + + @Test + void evaluate_outsideWorkspaceAlwaysRequiresConfirmation() { + InvokeClientToolConfirmationParams params = + buildParams("/tmp/secret.txt", true); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_outsideWorkspaceStillAutoApprovedBySessionFolder() { + // Session folder approval overrides the outside-workspace check + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION, + Map.of(FileOperationConfirmationHandler.META_FOLDER_PATH, + "/tmp")); + handler.cacheDecision(action, + buildParams("/tmp/file.txt", true), CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/tmp/other.txt", true); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate: rule matching --- + + @Test + void evaluate_autoApprovedByAllowRule() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_needsConfirmationByDenyRule() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/.github/**/*", "", false))); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/.github/instructions/rules.md", false); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + assertNotNull(result.getContent()); + } + + @Test + void evaluate_firstMatchingRuleWins() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", false), + new FileOperationAutoApproveRule("**/*", "", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + // The first rule (deny .java) should win + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate: unmatched fallback --- + + @Test + void evaluate_unmatchedAutoApprovedWhenCheckboxTrue() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.py", "", true))); + stubUnmatched(true); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_unmatchedNeedsConfirmationWhenCheckboxFalse() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.py", "", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_emptyRulesUsesUnmatchedSetting() { + stubRules(List.of()); + stubUnmatched(true); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate: blank file path --- + + @Test + void evaluate_blankFilePathNeedsConfirmation() { + stubRules(List.of()); + stubUnmatched(true); + + InvokeClientToolConfirmationParams params = + buildParams(null, false); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate: file path extraction --- + + @Test + void evaluate_extractsFilePathFromSensitiveFileData() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", true))); + stubUnmatched(false); + + // Path set via sensitiveFileData (toolMetadata), not input map + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_extractsFilePathFromInputMapFallback() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", true))); + stubUnmatched(false); + + // No toolMetadata, only input map with "filePath" + InvokeClientToolConfirmationParams params = + new InvokeClientToolConfirmationParams(); + params.setConversationId(CONV_ID); + Map input = new HashMap<>(); + input.put("filePath", "/workspace/src/Main.java"); + input.put("toolType", "file_write"); + params.setInput(input); + + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_extractsPathKeyFromInputMapFallback() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + new InvokeClientToolConfirmationParams(); + params.setConversationId(CONV_ID); + Map input = new HashMap<>(); + input.put("path", "/workspace/src/Main.java"); + input.put("toolType", "file_write"); + params.setInput(input); + + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- cacheDecision: global rule --- + + @Test + void cacheDecision_globalAddsRuleToPreferenceStore() { + stubRules(List.of()); + + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_GLOBAL, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + + // The handler should have called setValue on the preference store. + // We verify by loading rules from the same store (which requires + // the mock to return the updated value). Instead, verify the + // store was called with the right key. + org.mockito.Mockito.verify(preferenceStore).setValue( + org.mockito.ArgumentMatchers.eq(Constants.AUTO_APPROVE_FILE_OP_RULES), + org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void cacheDecision_globalUpdatesExistingRuleCaseInsensitive() { + // Start with a deny rule + stubRules(List.of( + new FileOperationAutoApproveRule( + "C:/workspace/Main.java", "", false))); + + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_GLOBAL, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "c:/workspace/Main.java")); + + handler.cacheDecision(action, + buildParams("c:/workspace/Main.java", false), CONV_ID); + + // Verify setValue was called (updated existing rule to autoApprove) + org.mockito.Mockito.verify(preferenceStore).setValue( + org.mockito.ArgumentMatchers.eq(Constants.AUTO_APPROVE_FILE_OP_RULES), + org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void cacheDecision_ignoresUnknownAction() { + Map meta = Map.of( + ConfirmationAction.META_ACTION, "UNKNOWN_ACTION"); + ConfirmationAction action = new ConfirmationAction( + "test", true, ConfirmationActionScope.SESSION, meta, false); + + // Should not throw + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + } + + @Test + void cacheDecision_ignoresNullActionMetadata() { + ConfirmationAction action = new ConfirmationAction( + "test", true, ConfirmationActionScope.SESSION, Map.of(), false); + + // Should not throw + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + } + + // --- clearSession --- + + @Test + void clearSession_removesFileAndFolderApprovals() { + stubRules(List.of()); + stubUnmatched(false); + + ConfirmationAction fileAction = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + handler.cacheDecision(fileAction, + buildParams("/workspace/src/Main.java", false), CONV_ID); + + handler.clearSession(CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void clearSession_doesNotAffectOtherConversation() { + stubRules(List.of()); + stubUnmatched(false); + + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + + handler.clearSession("other-conv"); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void clearSession_clearsAttachedFileRegistry() { + stubRules(List.of()); + stubUnmatched(false); + + attachedFileRegistry.addAttachedFiles(CONV_ID, + List.of("/workspace/src/Main.java")); + + handler.clearSession(CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- buildContent: in-workspace actions --- + + @Test + void buildContent_inWorkspaceHasAllowOnceAsPrimary() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + ConfirmationResult result = evaluate(params, CONV_ID); + + ConfirmationContent content = result.getContent(); + assertNotNull(content); + List actions = content.getActions(); + ConfirmationAction first = actions.get(0); + assertTrue(first.isPrimary()); + assertTrue(first.isAccept()); + assertEquals(ConfirmationActionScope.ONCE, first.getScope()); + } + + @Test + void buildContent_inWorkspaceHasSkipAsDismiss() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + ConfirmationAction last = actions.get(actions.size() - 1); + assertFalse(last.isAccept()); + } + + @Test + void buildContent_inWorkspaceHasFileSessionAndGlobalActions() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + boolean hasFileSession = actions.stream().anyMatch(a -> + hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION)); + boolean hasFileGlobal = actions.stream().anyMatch(a -> + hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FILE_GLOBAL)); + assertTrue(hasFileSession); + assertTrue(hasFileGlobal); + } + + @Test + void buildContent_inWorkspaceNoFolderAction() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + boolean hasFolderSession = actions.stream().anyMatch(a -> + hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION)); + assertFalse(hasFolderSession); + } + + // --- buildContent: outside-workspace actions --- + + @Test + void buildContent_outsideWorkspaceHasFolderSessionAction() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/tmp/data/file.txt", true); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + boolean hasFolderSession = actions.stream().anyMatch(a -> + hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION)); + assertTrue(hasFolderSession); + } + + @Test + void buildContent_outsideWorkspaceNoFileGlobalAction() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/tmp/data/file.txt", true); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + boolean hasFileGlobal = actions.stream().anyMatch(a -> + hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FILE_GLOBAL)); + assertFalse(hasFileGlobal); + } + + // --- buildContent: action scopes --- + + @Test + void buildContent_actionScopesAreCorrect() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + + // Session actions have SESSION scope + actions.stream() + .filter(a -> hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION)) + .forEach(a -> assertEquals( + ConfirmationActionScope.SESSION, a.getScope())); + + // Global actions have GLOBAL scope + actions.stream() + .filter(a -> hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FILE_GLOBAL)) + .forEach(a -> assertEquals( + ConfirmationActionScope.GLOBAL, a.getScope())); + } + + // --- evaluate priority order --- + + @Test + void evaluate_priorityOrder_attachedFileBeatsGlobalDenyRule() { + // Attached file auto-approves even when a deny rule would otherwise apply + attachedFileRegistry.addPending( + List.of("/workspace/src/Main.java")); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_priorityOrder_sessionApprovalBeatsGlobalDenyRule() { + // Session-level approval auto-approves even when a deny rule would otherwise apply + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_priorityOrder_sessionFolderBeatsOutsideWorkspace() { + // Session folder approval auto-approves even for outside-workspace files + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION, + Map.of(FileOperationConfirmationHandler.META_FOLDER_PATH, + "/external/dir")); + handler.cacheDecision(action, + buildParams("/external/dir/file.txt", true), CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/external/dir/another.txt", true); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- Helpers --- + + private void stubRules(List rules) { + when(preferenceStore.getString(Constants.AUTO_APPROVE_FILE_OP_RULES)) + .thenReturn(GSON.toJson(rules)); + } + + private void stubUnmatched(boolean value) { + when(preferenceStore.getBoolean( + Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)).thenReturn(value); + } + + private ConfirmationResult evaluate( + InvokeClientToolConfirmationParams params, String conversationId) { + return handler.evaluate(params, conversationId, true); + } + + private static InvokeClientToolConfirmationParams buildParams( + String filePath, boolean isGlobal) { + InvokeClientToolConfirmationParams params = + new InvokeClientToolConfirmationParams(); + params.setConversationId(CONV_ID); + + if (filePath != null) { + SensitiveFileData sfd = new SensitiveFileData(); + sfd.setFilePath(filePath); + sfd.setGlobal(isGlobal); + + ToolMetadata meta = new ToolMetadata(); + meta.setSensitiveFileData(sfd); + params.setToolMetadata(meta); + } + + Map input = new HashMap<>(); + input.put("toolType", "file_write"); + if (filePath != null) { + input.put("filePath", filePath); + } + params.setInput(input); + return params; + } + + private static ConfirmationAction buildAction( + FileOperationConfirmationHandler.Action actionType, + Map extra) { + Map meta = new HashMap<>(extra); + meta.put(ConfirmationAction.META_ACTION, actionType.name()); + return new ConfirmationAction( + "test", true, ConfirmationActionScope.SESSION, meta, false); + } + + private static boolean hasActionType(ConfirmationAction action, + FileOperationConfirmationHandler.Action type) { + return action.getMetadata().containsKey(ConfirmationAction.META_ACTION) + && action.getMetadata().get(ConfirmationAction.META_ACTION) + .equals(type.name()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandlerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandlerTests.java new file mode 100644 index 00000000..4380439b --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandlerTests.java @@ -0,0 +1,460 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import com.google.gson.Gson; +import org.eclipse.jface.preference.IPreferenceStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolAnnotations; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class McpConfirmationHandlerTests { + + private static final String CONV_ID = "conv-mcp-1"; + private static final String SERVER = "myServer"; + private static final String TOOL = "myTool"; + private static final Gson GSON = new Gson(); + + @Mock + private IPreferenceStore preferenceStore; + + private McpConfirmationHandler handler; + + @BeforeEach + void setUp() { + handler = new McpConfirmationHandler(preferenceStore); + stubGlobalServers(List.of()); + stubGlobalTools(List.of()); + stubTrustAnnotations(false); + } + + // --- evaluate: global server list --- + + @Test + void evaluate_autoApprovedWhenServerInGlobalList() { + stubGlobalServers(List.of(SERVER)); + + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_autoApprovedWhenServerInGlobalListCaseInsensitive() { + stubGlobalServers(List.of(SERVER.toUpperCase())); + + ConfirmationResult result = evaluate( + buildParams(SERVER.toLowerCase(), TOOL), CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenServerNotInGlobalList() { + stubGlobalServers(List.of("otherServer")); + + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + // --- evaluate: global tool list --- + + @Test + void evaluate_autoApprovedWhenToolInGlobalList() { + String toolKey = SERVER.toLowerCase() + "::" + TOOL.toLowerCase(); + stubGlobalTools(List.of(toolKey)); + + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_autoApprovedWhenToolInGlobalListCaseInsensitive() { + String toolKey = SERVER.toUpperCase() + "::" + TOOL.toUpperCase(); + stubGlobalTools(List.of(toolKey)); + + ConfirmationResult result = evaluate( + buildParams(SERVER.toLowerCase(), TOOL.toLowerCase()), CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenOnlyOtherToolInGlobalList() { + String otherKey = SERVER.toLowerCase() + "::otherTool"; + stubGlobalTools(List.of(otherKey)); + + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + // --- evaluate: trust annotations --- + + @Test + void evaluate_autoApprovedWhenReadOnlyAndTrustAnnotationsEnabled() { + stubTrustAnnotations(true); + ToolAnnotations annotations = new ToolAnnotations(); + annotations.setReadOnlyHint(true); + annotations.setOpenWorldHint(false); + + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + params.setAnnotations(annotations); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenReadOnlyButOpenWorldHint() { + stubTrustAnnotations(true); + ToolAnnotations annotations = new ToolAnnotations(); + annotations.setReadOnlyHint(true); + annotations.setOpenWorldHint(true); + + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + params.setAnnotations(annotations); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenAnnotationsTrustedButNotReadOnly() { + stubTrustAnnotations(true); + ToolAnnotations annotations = new ToolAnnotations(); + annotations.setReadOnlyHint(false); + annotations.setOpenWorldHint(false); + + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + params.setAnnotations(annotations); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenReadOnlyButTrustAnnotationsDisabled() { + stubTrustAnnotations(false); + ToolAnnotations annotations = new ToolAnnotations(); + annotations.setReadOnlyHint(true); + annotations.setOpenWorldHint(false); + + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + params.setAnnotations(annotations); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + // --- evaluate: session approvals --- + + @Test + void evaluate_autoApprovedWhenToolApprovedForSession() { + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION, + Map.of(McpConfirmationHandler.META_TOOL_KEY, + SERVER.toLowerCase() + "::" + TOOL.toLowerCase())); + handler.cacheDecision(action, params, CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenToolApprovedForDifferentSession() { + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION, + Map.of(McpConfirmationHandler.META_TOOL_KEY, + SERVER.toLowerCase() + "::" + TOOL.toLowerCase())); + handler.cacheDecision(action, params, "other-conv"); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_autoApprovedWhenServerApprovedForSession() { + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_SERVER_SESSION, + Map.of(McpConfirmationHandler.META_SERVER_NAME, SERVER)); + handler.cacheDecision(action, params, CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + // --- cacheDecision: global persistence --- + + @Test + void cacheDecision_acceptToolGlobal_writesToPreferenceStore() { + String toolKey = SERVER.toLowerCase() + "::" + TOOL.toLowerCase(); + stubGlobalTools(List.of()); + + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_GLOBAL, + Map.of(McpConfirmationHandler.META_TOOL_KEY, toolKey)); + handler.cacheDecision(action, buildParams(SERVER, TOOL), CONV_ID); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(preferenceStore).setValue( + org.mockito.ArgumentMatchers.eq(Constants.AUTO_APPROVE_MCP_TOOLS), + captor.capture()); + assertTrue(captor.getValue().contains(toolKey)); + } + + @Test + void cacheDecision_acceptServerGlobal_writesToPreferenceStore() { + stubGlobalServers(List.of()); + + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_SERVER_GLOBAL, + Map.of(McpConfirmationHandler.META_SERVER_NAME, SERVER)); + handler.cacheDecision(action, buildParams(SERVER, TOOL), CONV_ID); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(preferenceStore).setValue( + org.mockito.ArgumentMatchers.eq( + Constants.AUTO_APPROVE_MCP_SERVERS), + captor.capture()); + assertTrue(captor.getValue().contains(SERVER)); + } + + @Test + void cacheDecision_acceptToolGlobal_noDuplicateWrite() { + String toolKey = SERVER.toLowerCase() + "::" + TOOL.toLowerCase(); + stubGlobalTools(List.of(toolKey)); + + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_GLOBAL, + Map.of(McpConfirmationHandler.META_TOOL_KEY, toolKey)); + handler.cacheDecision(action, buildParams(SERVER, TOOL), CONV_ID); + + // setValue should NOT be called — key already present + verify(preferenceStore, org.mockito.Mockito.never()) + .setValue(org.mockito.ArgumentMatchers.eq( + Constants.AUTO_APPROVE_MCP_TOOLS), + org.mockito.ArgumentMatchers.anyString()); + } + + // --- clearSession --- + + @Test + void clearSession_removesSessionApprovalsForConversation() { + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION, + Map.of(McpConfirmationHandler.META_TOOL_KEY, + SERVER.toLowerCase() + "::" + TOOL.toLowerCase())); + handler.cacheDecision(action, params, CONV_ID); + + handler.clearSession(CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertFalse(result.isAutoApproved()); + } + + @Test + void clearSession_doesNotAffectOtherConversation() { + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION, + Map.of(McpConfirmationHandler.META_TOOL_KEY, + SERVER.toLowerCase() + "::" + TOOL.toLowerCase())); + handler.cacheDecision(action, params, CONV_ID); + + handler.clearSession("other-conv"); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertTrue(result.isAutoApproved()); + } + + // --- buildContent (actions) --- + + @Test + void buildContent_hasAllowOnceAsFirstAction() { + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + List actions = result.getContent().getActions(); + assertTrue(actions.get(0).isPrimary()); + assertTrue(actions.get(0).isAccept()); + assertEquals(ConfirmationActionScope.ONCE, actions.get(0).getScope()); + } + + @Test + void buildContent_hasSkipAsLastAction() { + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + List actions = result.getContent().getActions(); + assertFalse(actions.get(actions.size() - 1).isAccept()); + } + + @Test + void buildContent_hasAllFourScopedActions() { + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + List actions = result.getContent().getActions(); + assertTrue(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION)); + assertTrue(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_TOOL_GLOBAL)); + assertTrue(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_SERVER_SESSION)); + assertTrue(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_SERVER_GLOBAL)); + } + + @Test + void buildContent_toolAndServerActionsHaveCorrectScopes() { + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + List actions = result.getContent().getActions(); + actions.stream() + .filter(a -> hasAction(a, + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION) + || hasAction(a, + McpConfirmationHandler.Action.ACCEPT_SERVER_SESSION)) + .forEach(a -> assertEquals( + ConfirmationActionScope.SESSION, a.getScope())); + actions.stream() + .filter(a -> hasAction(a, + McpConfirmationHandler.Action.ACCEPT_TOOL_GLOBAL) + || hasAction(a, + McpConfirmationHandler.Action.ACCEPT_SERVER_GLOBAL)) + .forEach(a -> assertEquals( + ConfirmationActionScope.GLOBAL, a.getScope())); + } + + @Test + void buildContent_contentHasTitleWithToolAndServer() { + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + ConfirmationContent content = result.getContent(); + assertNotNull(content); + assertNotNull(content.getTitle()); + assertTrue(content.getTitle().contains(TOOL)); + assertTrue(content.getTitle().contains(SERVER)); + } + + @Test + void buildContent_noActionsWhenServerAndToolNull() { + InvokeClientToolConfirmationParams params = + buildParams(null, null); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + List actions = result.getContent().getActions(); + assertFalse(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION)); + assertFalse(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_SERVER_SESSION)); + } + + // --- Helpers --- + + private void stubGlobalServers(List servers) { + when(preferenceStore.getString(Constants.AUTO_APPROVE_MCP_SERVERS)) + .thenReturn(GSON.toJson(servers)); + } + + private void stubGlobalTools(List tools) { + when(preferenceStore.getString(Constants.AUTO_APPROVE_MCP_TOOLS)) + .thenReturn(GSON.toJson(tools)); + } + + private void stubTrustAnnotations(boolean value) { + when(preferenceStore.getBoolean( + Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS)) + .thenReturn(value); + } + + private ConfirmationResult evaluate( + InvokeClientToolConfirmationParams params, String conversationId) { + return handler.evaluate(params, conversationId, true); + } + + private static InvokeClientToolConfirmationParams buildParams( + String serverName, String toolName) { + InvokeClientToolConfirmationParams params = + new InvokeClientToolConfirmationParams(); + params.setConversationId(CONV_ID); + Map input = new java.util.HashMap<>(); + input.put("toolType", "mcp_tool"); + if (serverName != null) { + input.put("mcpServerName", serverName); + } + if (toolName != null) { + input.put("mcpToolName", toolName); + } + params.setInput(input); + return params; + } + + private static ConfirmationAction buildAction( + McpConfirmationHandler.Action type, Map extra) { + Map meta = new java.util.HashMap<>(extra); + meta.put(ConfirmationAction.META_ACTION, type.name()); + return new ConfirmationAction( + "test", true, ConfirmationActionScope.SESSION, meta, false); + } + + private static boolean hasAction(List actions, + McpConfirmationHandler.Action type) { + return actions.stream().anyMatch(a -> hasAction(a, type)); + } + + private static boolean hasAction(ConfirmationAction action, + McpConfirmationHandler.Action type) { + return action.getMetadata().containsKey(ConfirmationAction.META_ACTION) + && action.getMetadata().get(ConfirmationAction.META_ACTION) + .equals(type.name()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandlerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandlerTests.java new file mode 100644 index 00000000..5cb11e3e --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandlerTests.java @@ -0,0 +1,650 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.Mockito.when; + +import java.util.List; +import java.util.Map; + +import com.google.gson.Gson; +import org.eclipse.jface.preference.IPreferenceStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.chat.TerminalAutoApproveRule; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata.TerminalCommandData; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class TerminalConfirmationHandlerTests { + + private static final String CONV_ID = "conv-1"; + private static final Gson GSON = new Gson(); + + @Mock + private IPreferenceStore preferenceStore; + + private TerminalConfirmationHandler handler; + + @BeforeEach + void setUp() { + handler = new TerminalConfirmationHandler(preferenceStore); + } + + // --- rule matching (tested through evaluate) --- + + @Test + void ruleMatching_simpleRuleMatchesCommandAtStart() { + stubRules(List.of(new TerminalAutoApproveRule("rm", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"rm -rf /tmp"}, new String[]{"rm"}, + "rm -rf /tmp"); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void ruleMatching_simpleRuleDoesNotMatchMiddleOfWord() { + stubRules(List.of(new TerminalAutoApproveRule("rm", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"remove something"}, + new String[]{"remove"}, "remove something"); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void ruleMatching_regexCaseInsensitive() { + stubRules(List.of(new TerminalAutoApproveRule("/^git\\b/i", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"Git status"}, new String[]{"Git"}, + "Git status"); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void ruleMatching_regexDotallMatchesSubshell() { + stubRules(List.of( + new TerminalAutoApproveRule("/(\\(.+\\))/s", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"(echo hello)"}, + new String[]{"(echo"}, "(echo hello)"); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void ruleMatching_noMatchWhenSubCommandsNull() { + stubRules(List.of(new TerminalAutoApproveRule("rm", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(null, null, "rm -rf"); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void ruleMatching_noMatchWhenSubCommandsEmpty() { + stubRules(List.of(new TerminalAutoApproveRule("rm", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(new String[]{}, new String[]{}, "rm -rf"); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void ruleMatching_noMatchWhenSubCommandBlank() { + stubRules(List.of(new TerminalAutoApproveRule("rm", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(new String[]{" "}, new String[]{" "}, " "); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate --- + + @Test + void evaluate_autoApprovedWhenAllSubCommandsMatchAllowRules() { + stubRules(List.of(new TerminalAutoApproveRule("echo", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo hello"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_needsConfirmationWhenDenyRuleMatches() { + stubRules(List.of(new TerminalAutoApproveRule("rm", false))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"rm -rf /"}, new String[]{"rm"}, + "rm -rf /"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + assertNotNull(result.getContent()); + } + + @Test + void evaluate_needsConfirmationWhenNoRulesMatchAndUnmatchedFalse() { + stubRules(List.of(new TerminalAutoApproveRule("echo", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"ls -la"}, new String[]{"ls"}, + "ls -la"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_autoApprovedWhenNoRulesMatchAndUnmatchedTrue() { + stubRules(List.of(new TerminalAutoApproveRule("echo", true))); + stubUnmatched(true); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"ls -la"}, new String[]{"ls"}, + "ls -la"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_needsConfirmationWhenSubCommandsNull() { + stubRules(List.of()); + + InvokeClientToolConfirmationParams params = + buildParams(null, null, "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_needsConfirmationWhenSubCommandsEmpty() { + stubRules(List.of()); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{}, null, "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_emptyRulesUsesUnmatchedSetting() { + stubRules(List.of()); + stubUnmatched(true); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"ls"}, new String[]{"ls"}, "ls"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + // --- Session memory via cacheDecision --- + + @Test + void cacheDecision_acceptAllSession_autoApprovesSubsequent() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + + ConfirmationAction allSession = buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_ALL_SESSION); + handler.cacheDecision(allSession, params, CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertTrue(result.isAutoApproved()); + } + + @Test + void cacheDecision_acceptNamesSession_autoApprovesMatchingNames() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + + ConfirmationAction namesSession = buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION); + handler.cacheDecision(namesSession, params, CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertTrue(result.isAutoApproved()); + } + + @Test + void cacheDecision_acceptExactSession_autoApprovesMatchingCommand() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + + ConfirmationAction exactSession = buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_EXACT_SESSION); + handler.cacheDecision(exactSession, params, CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertTrue(result.isAutoApproved()); + } + + @Test + void clearSession_removesApprovalsForConversation() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + + ConfirmationAction allSession = buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_ALL_SESSION); + handler.cacheDecision(allSession, params, CONV_ID); + + handler.clearSession(CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertFalse(result.isAutoApproved()); + } + + @Test + void clearSession_doesNotAffectOtherConversation() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + + ConfirmationAction allSession = buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_ALL_SESSION); + handler.cacheDecision(allSession, params, CONV_ID); + + handler.clearSession("other-conv"); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertTrue(result.isAutoApproved()); + } + + // --- buildContent actions --- + + @Test + void buildContent_alwaysHasAllowOnceAsPrimary() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + ConfirmationContent content = result.getContent(); + assertNotNull(content); + List actions = content.getActions(); + ConfirmationAction first = actions.get(0); + assertTrue(first.isPrimary()); + assertTrue(first.isAccept()); + } + + @Test + void buildContent_alwaysHasSkipAsDismiss() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + ConfirmationAction last = actions.get(actions.size() - 1); + assertFalse(last.isAccept()); + } + + @Test + void buildContent_hasAllowAllSessionAction() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + boolean hasAllSession = actions.stream().anyMatch(a -> + a.getMetadata().containsKey(ConfirmationAction.META_ACTION) + && a.getMetadata().get(ConfirmationAction.META_ACTION) + .equals( + TerminalConfirmationHandler.Action.ACCEPT_ALL_SESSION + .name())); + assertTrue(hasAllSession); + } + + @Test + void buildContent_hasCommandNameActionsWhenNamesPresent() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + boolean hasNamesSession = actions.stream().anyMatch(a -> + hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION)); + boolean hasNamesGlobal = actions.stream().anyMatch(a -> + hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_NAMES_GLOBAL)); + assertTrue(hasNamesSession); + assertTrue(hasNamesGlobal); + } + + @Test + void buildContent_hasExactCommandActionsWhenDifferentFromName() { + stubRules(List.of()); + stubUnmatched(false); + + // commandLine "echo hello" differs from single commandName "echo" + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + boolean hasExactSession = actions.stream().anyMatch(a -> + hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_EXACT_SESSION)); + boolean hasExactGlobal = actions.stream().anyMatch(a -> + hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_EXACT_GLOBAL)); + assertTrue(hasExactSession); + assertTrue(hasExactGlobal); + } + + @Test + void buildContent_noExactActionsWhenSingleSubCommandEqualsName() { + stubRules(List.of()); + stubUnmatched(false); + + // commandLine equals the single commandName + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, "echo"); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + boolean hasExact = actions.stream().anyMatch(a -> + hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_EXACT_SESSION) + || hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_EXACT_GLOBAL)); + assertFalse(hasExact); + } + + @Test + void buildContent_actionScopesAreCorrect() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + + // Allow Once → ONCE scope + assertEquals(ConfirmationActionScope.ONCE, actions.get(0).getScope()); + + // Session actions have SESSION scope + actions.stream() + .filter(a -> hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION) + || hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_EXACT_SESSION) + || hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_ALL_SESSION)) + .forEach(a -> assertEquals(ConfirmationActionScope.SESSION, + a.getScope())); + + // Global actions have GLOBAL scope + actions.stream() + .filter(a -> hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_NAMES_GLOBAL) + || hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_EXACT_GLOBAL)) + .forEach(a -> assertEquals(ConfirmationActionScope.GLOBAL, + a.getScope())); + } + + // --- Helpers --- + + private void stubRules(List rules) { + when(preferenceStore.getString(Constants.AUTO_APPROVE_TERMINAL_RULES)) + .thenReturn(GSON.toJson(rules)); + } + + private void stubUnmatched(boolean value) { + when(preferenceStore.getBoolean( + Constants.AUTO_APPROVE_UNMATCHED_TERMINAL)).thenReturn(value); + } + + private ConfirmationResult evaluate( + InvokeClientToolConfirmationParams params, String conversationId) { + return handler.evaluate(params, conversationId, true); + } + + private static InvokeClientToolConfirmationParams buildParams( + String[] subCommands, String[] commandNames, String commandLine) { + TerminalCommandData tcd = new TerminalCommandData(); + tcd.setSubCommands(subCommands); + tcd.setCommandNames(commandNames); + + ToolMetadata meta = new ToolMetadata(); + meta.setTerminalCommandData(tcd); + + InvokeClientToolConfirmationParams params = + new InvokeClientToolConfirmationParams(); + params.setConversationId(CONV_ID); + params.setToolMetadata(meta); + params.setInput(Map.of("toolType", "terminal", "command", + commandLine != null ? commandLine : "")); + return params; + } + + private static ConfirmationAction buildSessionAction( + TerminalConfirmationHandler.Action actionType) { + Map meta = Map.of( + ConfirmationAction.META_ACTION, actionType.name()); + return new ConfirmationAction( + "test", true, ConfirmationActionScope.SESSION, meta, false); + } + + private static boolean hasActionType(ConfirmationAction action, + TerminalConfirmationHandler.Action type) { + return action.getMetadata().containsKey(ConfirmationAction.META_ACTION) + && action.getMetadata().get(ConfirmationAction.META_ACTION) + .equals(type.name()); + } + + // --- Unapproved filtering tests --- + + @Test + void buildContent_filtersSessionApprovedNamesFromActions() { + stubRules(List.of()); + stubUnmatched(false); + + // "echo && curl" — approve echo in session first + InvokeClientToolConfirmationParams approveParams = + buildParams(new String[]{"echo hello"}, new String[]{"echo"}, + "echo hello"); + handler.cacheDecision( + buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION), + approveParams, CONV_ID); + + // Now evaluate "echo hello && curl example.com" + InvokeClientToolConfirmationParams params = + buildParams( + new String[]{"echo hello", "curl example.com"}, + new String[]{"echo", "curl"}, + "echo hello && curl example.com"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + List actions = result.getContent().getActions(); + + // Command-name actions should only mention "curl", not "echo" + ConfirmationAction namesAction = actions.stream() + .filter(a -> hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION)) + .findFirst().orElse(null); + assertNotNull(namesAction); + assertTrue(namesAction.getLabel().contains("curl")); + assertFalse(namesAction.getLabel().contains("echo")); + } + + @Test + void buildContent_filtersGlobalApprovedNamesFromActions() { + // Global allow rule for "echo" + stubRules(List.of(new TerminalAutoApproveRule("echo", true))); + stubUnmatched(false); + + // Evaluate "echo hello && hostname" + InvokeClientToolConfirmationParams params = + buildParams( + new String[]{"echo hello", "hostname"}, + new String[]{"echo", "hostname"}, + "echo hello && hostname"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + List actions = result.getContent().getActions(); + + // "echo" is globally allowed — only "hostname" in actions + ConfirmationAction namesAction = actions.stream() + .filter(a -> hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION)) + .findFirst().orElse(null); + assertNotNull(namesAction); + assertTrue(namesAction.getLabel().contains("hostname")); + assertFalse(namesAction.getLabel().contains("echo")); + } + + @Test + void buildContent_allNamesApproved_autoApproves() { + stubRules(List.of(new TerminalAutoApproveRule("echo", true))); + stubUnmatched(false); + + // Session-approve "curl" + InvokeClientToolConfirmationParams approveParams = + buildParams(new String[]{"curl x"}, new String[]{"curl"}, + "curl x"); + handler.cacheDecision( + buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION), + approveParams, CONV_ID); + + // Evaluate "echo hello && curl example.com" + // echo = global allow, curl = session allow → all approved + InvokeClientToolConfirmationParams params = + buildParams( + new String[]{"echo hello", "curl example.com"}, + new String[]{"echo", "curl"}, + "echo hello && curl example.com"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void sessionRules_surviveConversationSwitch() { + // Approve "echo" in conversation A + InvokeClientToolConfirmationParams approveParams = + buildParams(new String[]{"echo hello"}, new String[]{"echo"}, + "echo hello"); + handler.cacheDecision( + buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION), + approveParams, "conv-A"); + + // Switch to conversation B, then back to A — rule should survive + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo world"}, new String[]{"echo"}, + "echo world"); + ConfirmationResult result = evaluate(params, "conv-A"); + assertTrue(result.isAutoApproved()); + } + + @Test + void sessionRules_evictOldestWhenCapExceeded() { + // Fill up to MAX_SESSION_CONVERSATIONS with unique conversation IDs + for (int i = 0; i < TerminalConfirmationHandler.MAX_SESSION_CONVERSATIONS; + i++) { + InvokeClientToolConfirmationParams p = + buildParams(new String[]{"echo"}, new String[]{"echo"}, "echo"); + handler.cacheDecision( + buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION), + p, "conv-" + i); + } + + // Add one more — should evict the oldest (conv-0) + InvokeClientToolConfirmationParams p = + buildParams(new String[]{"echo"}, new String[]{"echo"}, "echo"); + handler.cacheDecision( + buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION), + p, "conv-new"); + + // conv-0 should have been evicted + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo test"}, new String[]{"echo"}, + "echo test"); + ConfirmationResult evicted = evaluate(params, "conv-0"); + assertFalse(evicted.isAutoApproved()); + + // conv-new should still work + ConfirmationResult kept = evaluate(params, "conv-new"); + assertTrue(kept.isAutoApproved()); + + // conv-1 (second oldest, not evicted) should still work + ConfirmationResult second = evaluate(params, "conv-1"); + assertTrue(second.isAutoApproved()); + } +} 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 5843ccfe..2892f521 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 @@ -14,6 +14,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.LinkedHashMap; + import org.eclipse.core.net.proxy.IProxyData; import org.eclipse.core.net.proxy.IProxyService; import org.eclipse.jface.preference.IPreferenceStore; @@ -23,6 +25,8 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import com.microsoft.copilot.eclipse.core.Constants; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; @@ -33,6 +37,7 @@ import com.microsoft.copilot.eclipse.ui.utils.PreferencesUtils; @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class LanguageServerSettingManagerTests { @Mock private IPreferenceStore mockPreferenceStore; @@ -53,6 +58,10 @@ void testNoProxy() { settings.getGithubSettings().getCopilotSettings().getAgent() .setEnableSkills(PreferencesUtils.isSkillsEnabled()) .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getEdit().setAutoApprove(new LinkedHashMap<>()); params.setSettings(settings); // act @@ -83,6 +92,10 @@ void testBasicProxy() { settings.getGithubSettings().getCopilotSettings().getAgent() .setEnableSkills(PreferencesUtils.isSkillsEnabled()) .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getEdit().setAutoApprove(new LinkedHashMap<>()); params.setSettings(settings); // act @@ -121,6 +134,10 @@ void testUpdateConfigShouldBeCalledWhenWorkspaceInstructionsEnabledWithContent() settings.getGithubSettings().setCopilotSettings(copilotSettings); settings.getGithubSettings().getCopilotSettings().getAgent() .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getEdit().setAutoApprove(new LinkedHashMap<>()); params.setSettings(settings); // act @@ -152,6 +169,10 @@ void testUpdateConfigShouldBeCalledWithoutInstructionWhenWorkspaceInstructionsDi expectedSettings.getGithubSettings().getCopilotSettings().getAgent() .setEnableSkills(PreferencesUtils.isSkillsEnabled()) .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + expectedSettings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); + expectedSettings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getEdit().setAutoApprove(new LinkedHashMap<>()); expectedParams.setSettings(expectedSettings); // act diff --git a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF index 2a91ad85..066f4400 100644 --- a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF @@ -7,6 +7,7 @@ Bundle-Vendor: GitHub Copilot Bundle-Localization: plugin Export-Package: com.microsoft.copilot.eclipse.ui, com.microsoft.copilot.eclipse.ui.chat, + com.microsoft.copilot.eclipse.ui.chat.confirmation;x-friends:="com.microsoft.copilot.eclipse.ui.test", com.microsoft.copilot.eclipse.ui.chat.services, com.microsoft.copilot.eclipse.ui.chat.tools, com.microsoft.copilot.eclipse.ui.completion, diff --git a/com.microsoft.copilot.eclipse.ui/css/dark.css b/com.microsoft.copilot.eclipse.ui/css/dark.css index 6cff9e7e..528204cb 100644 --- a/com.microsoft.copilot.eclipse.ui/css/dark.css +++ b/com.microsoft.copilot.eclipse.ui/css/dark.css @@ -91,7 +91,7 @@ background-color: #3584F1; } -#chat-content-viewer > Composite > CopilotTurnWidget > InvokeToolConfirmationDialog > .bg-command-panel { +#chat-content-viewer > Composite > CopilotTurnWidget > InvokeToolConfirmationDialog > ScrolledComposite > .bg-command-panel { background-color: #48484C; } diff --git a/com.microsoft.copilot.eclipse.ui/css/light.css b/com.microsoft.copilot.eclipse.ui/css/light.css index 9fc554ba..25c39464 100644 --- a/com.microsoft.copilot.eclipse.ui/css/light.css +++ b/com.microsoft.copilot.eclipse.ui/css/light.css @@ -91,7 +91,7 @@ background-color: #3584F1; } -#chat-content-viewer > Composite > CopilotTurnWidget > InvokeToolConfirmationDialog > .bg-command-panel { +#chat-content-viewer > Composite > CopilotTurnWidget > InvokeToolConfirmationDialog > ScrolledComposite > .bg-command-panel { background-color: #FFFFFF; } diff --git a/com.microsoft.copilot.eclipse.ui/plugin.properties b/com.microsoft.copilot.eclipse.ui/plugin.properties index 38fd45a7..636fc1fb 100644 --- a/com.microsoft.copilot.eclipse.ui/plugin.properties +++ b/com.microsoft.copilot.eclipse.ui/plugin.properties @@ -47,4 +47,5 @@ intro.quickStart.description=Boost your coding efficiency with built-in Copilot theme.category.label=GitHub Copilot theme.category.description=Font and color settings for GitHub Copilot theme.chatFont.label=Chat Font -theme.chatFont.description=The font used for text in the Copilot Chat view \ No newline at end of file +theme.chatFont.description=The font used for text in the Copilot Chat view +page.preferencesPage.autoApprove.name=Tool Auto Approve \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui/plugin.xml b/com.microsoft.copilot.eclipse.ui/plugin.xml index be871853..a3d4a20a 100644 --- a/com.microsoft.copilot.eclipse.ui/plugin.xml +++ b/com.microsoft.copilot.eclipse.ui/plugin.xml @@ -175,6 +175,12 @@ category="com.microsoft.copilot.eclipse.ui.preferences.CopilotPreferencesPage" class="com.microsoft.copilot.eclipse.ui.preferences.CustomModesPreferencePage"> + + diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java index c1cc893b..d8a5221a 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java @@ -24,6 +24,7 @@ import org.osgi.service.event.EventHandler; import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult; @@ -70,6 +71,11 @@ public abstract class BaseTurnWidget extends Composite { protected Font boldFont = null; protected InvokeToolConfirmationDialog confirmDialog; + /** Returns the current confirmation dialog, or {@code null} if none active. */ + public InvokeToolConfirmationDialog getConfirmDialog() { + return confirmDialog; + } + // Footer protected Composite footer; @@ -636,20 +642,18 @@ protected void createAgentMessageWidget(CodingAgentMessageRequestParams params) /** * Prompts the user to confirm or deny a tool execution. * - * @param title The title of the confirmation dialog. - * @param message The message to display in the confirmation dialog. + * @param content The confirmation content with title, message, and action buttons. * @param input The input object to be passed to the tool. */ - public CompletableFuture requestToolExecutionConfirmation(String title, - String message, Object input) { - // process all the messages before showing the confirmation dialog + public CompletableFuture requestToolExecutionConfirmation( + ConfirmationContent content, Object input) { reset(); - this.confirmDialog = new InvokeToolConfirmationDialog(this, title, message, input); + this.confirmDialog = new InvokeToolConfirmationDialog(this, content, input); CompletableFuture toolConfirmationFuture = this.confirmDialog .getConfirmationFuture(); - this.getParent().layout(); + this.getParent().requestLayout(); return toolConfirmationFuture; } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java index 65cf0a74..c8bff236 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java @@ -3,6 +3,7 @@ package com.microsoft.copilot.eclipse.ui.chat; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -80,6 +81,7 @@ import com.microsoft.copilot.eclipse.core.persistence.UserTurnData; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.UiConstants; +import com.microsoft.copilot.eclipse.ui.chat.services.AgentToolService; import com.microsoft.copilot.eclipse.ui.chat.services.ChatCompletionService; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; import com.microsoft.copilot.eclipse.ui.chat.services.DebugEventAutoResponseHandler; @@ -1027,7 +1029,16 @@ private void onSendInternal(String workDoneToken, String message, String agentSl final CopilotLanguageServerConnection ls = CopilotCore.getPlugin().getCopilotLanguageServer(); final CopilotModel activeModel = chatServiceManager.getModelService().getActiveModel(); + // Collect attached file paths for auto-approve of file operations. + // Stage as pending so confirmation requests can match immediately, + // even before the real conversation ID arrives. + List pendingAttachedFiles = + collectAttachedFilePaths(currentFile, references); + stagePendingAttachedFiles(pendingAttachedFiles); + if (conversationState == ConversationState.CONTINUED_CONVERSATION) { + // conversationId is already the real one — flush pending into registry + flushPendingAttachedFiles(this.conversationId); // Continue existing conversation - persist user message and send to existing conversation if (persistenceManager != null) { this.persistUserTurnFuture = persistenceManager.persistUserTurnInfo(conversationId, null, processedMessage, @@ -1131,6 +1142,9 @@ private void onSendInternal(String workDoneToken, String message, String agentSl CopilotCore.LOGGER.error("Error updating conversation ID in persistence manager: ", e); } + // Flush pending attached files into the real conversation ID + flushPendingAttachedFiles(newConversationId); + // Render model information in the Copilot turn widget if (result != null && StringUtils.isNotBlank(result.getModelName()) && !UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG.equals(result.getAgentSlug())) { @@ -1144,6 +1158,7 @@ private void onSendInternal(String workDoneToken, String message, String agentSl } } }).exceptionally(th -> { + clearPendingAttachedFiles(); if (!ConversationUtils.isConversationCancellationThrowable(th)) { CopilotCore.LOGGER.error("Error creating new conversation with exception: ", th); displayErrorAndResetSendButton(workDoneToken, th.getMessage()); @@ -1246,8 +1261,75 @@ private void handleCodingAgentMessage(CodingAgentMessageRequestParams params) { }, parent); } + /** + * Collects absolute paths of the current file and explicitly attached + * references. The returned list is saved to the + * {@link AttachedFileRegistry} once a stable conversation ID is available. + */ + private List collectAttachedFilePaths(IFile currentFile, + List references) { + List filePaths = new ArrayList<>(); + if (currentFile != null && currentFile.getLocation() != null) { + filePaths.add(currentFile.getLocation().toOSString()); + } + if (references != null) { + for (IResource r : references) { + if (r instanceof IFile && r.getLocation() != null) { + filePaths.add(r.getLocation().toOSString()); + } + } + } + return filePaths; + } + + /** + * Stages file paths as pending in the attached file registry. + * These are immediately visible to confirmation handlers. + */ + private void stagePendingAttachedFiles(List filePaths) { + if (filePaths.isEmpty() || this.chatServiceManager == null) { + return; + } + AgentToolService agentToolService = + this.chatServiceManager.getAgentToolService(); + if (agentToolService == null) { + return; + } + agentToolService.getAttachedFileRegistry().addPending(filePaths); + } + + /** + * Flushes pending attached files into per-conversation storage + * under the given (stable) conversation ID. + */ + private void flushPendingAttachedFiles(String conversationId) { + if (this.chatServiceManager == null + || StringUtils.isBlank(conversationId)) { + return; + } + AgentToolService agentToolService = + this.chatServiceManager.getAgentToolService(); + if (agentToolService == null) { + return; + } + agentToolService.getAttachedFileRegistry() + .flushPending(conversationId); + } + + private void clearPendingAttachedFiles() { + if (this.chatServiceManager == null) { + return; + } + AgentToolService agentToolService = + this.chatServiceManager.getAgentToolService(); + if (agentToolService != null) { + agentToolService.getAttachedFileRegistry().clearPending(); + } + } + private void clearCurrentConversation() { this.onCancel(); + clearPendingAttachedFiles(); this.hasHistory = false; this.conversationId = ""; this.conversationState = ConversationState.NEW_CONVERSATION; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/InvokeToolConfirmationDialog.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/InvokeToolConfirmationDialog.java index 745f62a5..ebc307a0 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/InvokeToolConfirmationDialog.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/InvokeToolConfirmationDialog.java @@ -3,6 +3,8 @@ package com.microsoft.copilot.eclipse.ui.chat; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -13,23 +15,34 @@ import org.eclipse.swt.custom.ScrolledComposite; import org.eclipse.swt.events.ControlAdapter; import org.eclipse.swt.events.ControlEvent; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; import org.eclipse.swt.graphics.Font; import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Button; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Menu; +import org.eclipse.swt.widgets.MenuItem; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult.ToolConfirmationResult; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; +import com.microsoft.copilot.eclipse.ui.swt.SplitDropdownButton; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; /** - * Dialog to confirm tool execution. + * Dialog to confirm tool execution. Renders a title, message, optional command + * block, and action buttons driven by {@link ConfirmationContent}. The primary + * action is shown as a {@link SplitDropdownButton} with secondary actions in + * the dropdown menu. */ public class InvokeToolConfirmationDialog extends Composite { @@ -47,32 +60,77 @@ public class InvokeToolConfirmationDialog extends Composite { * The key for the action in the input map (used by debugger tool). */ private static final String ACTION_KEY = "action"; + private CompletableFuture toolConfirmationFuture; private String cancelMessage; private Label titleLbl; private Font boldFont; private Runnable titleFontChangeCallback; + private ConfirmationContent confirmationContent; + private ConfirmationAction selectedAction; /** - * Create a new confirmation dialog for tool execution. + * Create a new confirmation dialog driven by {@link ConfirmationContent}. * - * @param parent The parent composite - * @param title The title of the confirmation dialog - * @param message The message to display - * @param input The input object to pass to the tool + * @param parent the parent composite + * @param content confirmation content with title, message, and action buttons + * @param input the input object to pass to the tool */ - public InvokeToolConfirmationDialog(Composite parent, String title, String message, Object input) { + public InvokeToolConfirmationDialog(Composite parent, + ConfirmationContent content, Object input) { super(parent, SWT.BORDER | SWT.WRAP); this.setLayout(new GridLayout(1, false)); this.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); - createDialogContent(title, message, input); + this.confirmationContent = content; + createDialogContent(content.getTitle(), content.getMessage(), input); this.toolConfirmationFuture = new CompletableFuture<>(); } - private void createDialogContent(String title, String message, Object input) { - // Title of the confirmation dialog + /** + * Returns the action the user selected, or {@code null} if dismissed. + */ + public ConfirmationAction getSelectedAction() { + return selectedAction; + } + + /** + * Get the future that will be completed when the user makes a choice. + * + * @return CompletableFuture containing the result of user's choice + */ + public CompletableFuture getConfirmationFuture() { + return toolConfirmationFuture; + } + + /** + * Cancels the current tool confirmation dialog programmatically. This has + * the same effect as clicking the Cancel / Skip button. + */ + public void cancelConfirmation() { + if (toolConfirmationFuture != null && !toolConfirmationFuture.isDone()) { + toolConfirmationFuture.complete( + new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS)); + + Composite parent = this.getParent(); + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (parent != null && !parent.isDisposed() + && StringUtils.isNotEmpty(this.cancelMessage)) { + new AgentToolCancelLabel(parent, SWT.NONE, this.cancelMessage); + } + this.dispose(); + if (parent != null && !parent.isDisposed()) { + parent.requestLayout(); + } + }, this); + } + } + + // --------------- content creation --------------- + + private void createDialogContent(String title, String message, + Object input) { titleLbl = new Label(this, SWT.LEFT | SWT.WRAP); titleLbl.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); titleLbl.setText(title); @@ -93,160 +151,177 @@ private void createDialogContent(String title, String message, Object input) { } }); - // Confirmation message of the confirmation dialog Label messageLbl = new Label(this, SWT.LEFT | SWT.WRAP); - GridData messageGridData = new GridData(SWT.FILL, SWT.FILL, true, false); - messageLbl.setLayoutData(messageGridData); - messageLbl.setText(message); + messageLbl.setLayoutData( + new GridData(SWT.FILL, SWT.FILL, true, false)); + messageLbl.setText(message != null ? message : ""); registerControlForFontUpdates(messageLbl); - // More information about the tool invocation - if (input != null) { - Map inputMap = (Map) input; - - // For debugger tool, show all input parameters - if (inputMap.containsKey(ACTION_KEY)) { - String displayText = formatDebuggerInput(inputMap); - - // Create a scrollable container for the input text - ScrolledComposite commandScroll = new ScrolledComposite(this, SWT.H_SCROLL | SWT.V_SCROLL); - commandScroll.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); - commandScroll.setExpandHorizontal(true); - commandScroll.setExpandVertical(true); - - Label commandLbl = new Label(commandScroll, SWT.LEFT); - // Escape & characters that are followed by non-space characters, needed for SWT labels where & is used as a - // mnemonic character - String escapedCommand = displayText.replace("&", "&&"); - commandLbl.setText(escapedCommand); - commandLbl.setData(CssConstants.CSS_CLASS_NAME_KEY, "bg-command-panel"); - this.cancelMessage = escapedCommand; - registerControlForFontUpdates(commandLbl); - - commandScroll.setContent(commandLbl); - commandScroll.addControlListener(new ControlAdapter() { - @Override - public void controlResized(ControlEvent e) { - Point size = commandLbl.computeSize(SWT.DEFAULT, SWT.DEFAULT); - commandLbl.setSize(size); - commandScroll.setMinSize(size); - } - }); - // Initial size computation - Point size = commandLbl.computeSize(SWT.DEFAULT, SWT.DEFAULT); - commandLbl.setSize(size); - commandScroll.setMinSize(size); - } else if (inputMap.containsKey(COMMAND_KEY)) { - // For terminal tool, show command - // Create a scrollable container for the command text - ScrolledComposite commandScroll = new ScrolledComposite(this, SWT.H_SCROLL); - commandScroll.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); - commandScroll.setExpandHorizontal(true); - commandScroll.setExpandVertical(true); - - Label commandLbl = new Label(commandScroll, SWT.LEFT); - String command = (String) inputMap.get(COMMAND_KEY); - // Escape & characters that are followed by non-space characters, needed for SWT labels where & is used as a - // mnemonic character - String escapedCommand = command.replace("&", "&&"); - commandLbl.setText(escapedCommand); - commandLbl.setData(CssConstants.CSS_CLASS_NAME_KEY, "bg-command-panel"); - this.cancelMessage = escapedCommand; - registerControlForFontUpdates(commandLbl); - - commandScroll.setContent(commandLbl); - commandScroll.addControlListener(new ControlAdapter() { - @Override - public void controlResized(ControlEvent e) { - Point size = commandLbl.computeSize(SWT.DEFAULT, SWT.DEFAULT); - commandLbl.setSize(size); - commandScroll.setMinSize(size); - } - }); - // Initial size computation + createInputContent(input); + createActionButtons(); + } + + @SuppressWarnings("unchecked") + private void createInputContent(Object input) { + if (input == null) { + return; + } + Map inputMap = (Map) input; + + if (inputMap.containsKey(ACTION_KEY)) { + createScrollableCommand(formatDebuggerInput(inputMap), + SWT.H_SCROLL | SWT.V_SCROLL); + } else if (inputMap.containsKey(COMMAND_KEY)) { + createScrollableCommand((String) inputMap.get(COMMAND_KEY), + SWT.H_SCROLL); + } + + if (inputMap.containsKey(EXPLANATION_KEY)) { + Label explanationLbl = new Label(this, SWT.LEFT | SWT.WRAP); + explanationLbl.setLayoutData( + new GridData(SWT.FILL, SWT.FILL, true, false)); + explanationLbl.setText((String) inputMap.get(EXPLANATION_KEY)); + registerControlForFontUpdates(explanationLbl); + } + } + + private void createScrollableCommand(String text, int scrollStyle) { + ScrolledComposite commandScroll = + new ScrolledComposite(this, scrollStyle); + commandScroll.setLayoutData( + new GridData(SWT.FILL, SWT.FILL, true, false)); + commandScroll.setExpandHorizontal(true); + commandScroll.setExpandVertical(true); + + Label commandLbl = new Label(commandScroll, SWT.LEFT); + String escapedCommand = text.replace("&", "&&"); + commandLbl.setText(escapedCommand); + commandLbl.setData(CssConstants.CSS_CLASS_NAME_KEY, "bg-command-panel"); + this.cancelMessage = escapedCommand; + registerControlForFontUpdates(commandLbl); + + commandScroll.setContent(commandLbl); + commandScroll.addControlListener(new ControlAdapter() { + @Override + public void controlResized(ControlEvent e) { Point size = commandLbl.computeSize(SWT.DEFAULT, SWT.DEFAULT); commandLbl.setSize(size); commandScroll.setMinSize(size); } + }); + Point size = commandLbl.computeSize(SWT.DEFAULT, SWT.DEFAULT); + commandLbl.setSize(size); + commandScroll.setMinSize(size); + } + + // --------------- action buttons with dropdown --------------- - if (inputMap.containsKey(EXPLANATION_KEY)) { - Label explanationLbl = new Label(this, SWT.LEFT | SWT.WRAP); - explanationLbl.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); - explanationLbl.setText((String) inputMap.get(EXPLANATION_KEY)); - registerControlForFontUpdates(explanationLbl); + private void createActionButtons() { + List actions = confirmationContent.getActions(); + + ConfirmationAction primaryAction = null; + ConfirmationAction dismissAction = null; + List dropdownActions = new ArrayList<>(); + + for (ConfirmationAction action : actions) { + if (!action.isAccept()) { + dismissAction = action; + } else if (action.isPrimary()) { + primaryAction = action; + } else { + dropdownActions.add(action); } } - createButtons(); - } + if (primaryAction == null) { + return; + } - private void createButtons() { - GridLayout actionLayout = new GridLayout(2, false); - actionLayout.marginLeft = 0; - actionLayout.marginRight = 0; - actionLayout.marginWidth = 0; - actionLayout.horizontalSpacing = 0; - actionLayout.marginHeight = 0; - Composite actionArea = new Composite(this, SWT.NONE); - actionArea.setLayout(actionLayout); - actionArea.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); - - Button continueButton = new Button(actionArea, SWT.PUSH); - continueButton.setLayoutData(new GridData(SWT.BEGINNING, SWT.CENTER, false, false)); - continueButton.setText("Continue"); - continueButton.addListener(SWT.Selection, e -> { - this.toolConfirmationFuture.complete(new LanguageModelToolConfirmationResult(ToolConfirmationResult.ACCEPT)); - - // Store parent reference before disposal - Composite parent = this.getParent(); - this.dispose(); - // Check if parent is still valid before using it - if (parent != null && !parent.isDisposed()) { - parent.layout(); + // Column count: primary dropdown button + dismiss + Composite actionArea = newButtonArea(2); + + // --- primary dropdown button --- + SplitDropdownButton primaryDropdown = + new SplitDropdownButton(actionArea, SWT.PUSH); + primaryDropdown.setText(primaryAction.getLabel()); + primaryDropdown.setShowArrow(!dropdownActions.isEmpty()); + primaryDropdown.setSeparatorColor( + getDisplay().getSystemColor(SWT.COLOR_WHITE)); + + Button primaryBtn = primaryDropdown.getButton(); + primaryBtn.setData(CssConstants.CSS_CLASS_NAME_KEY, "btn-primary"); + primaryBtn.setLayoutData( + new GridData(SWT.BEGINNING, SWT.CENTER, false, false)); + registerControlForFontUpdates(primaryBtn); + + final ConfirmationAction primaryRef = primaryAction; + primaryDropdown.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + if (e.detail == SWT.ARROW && !dropdownActions.isEmpty()) { + Menu menu = new Menu(primaryBtn.getShell(), SWT.POP_UP); + for (ConfirmationAction action : dropdownActions) { + MenuItem item = new MenuItem(menu, SWT.PUSH); + item.setText(action.getLabel()); + item.addListener(SWT.Selection, + ev -> acceptAndDispose(action)); + } + menu.addListener(SWT.Hide, ev -> { + ev.display.asyncExec(menu::dispose); + }); + Rectangle bounds = primaryBtn.getBounds(); + Point loc = primaryBtn.getParent() + .toDisplay(bounds.x, bounds.y + bounds.height); + menu.setLocation(loc); + menu.setVisible(true); + } else { + acceptAndDispose(primaryRef); + } } }); - continueButton.setData(CssConstants.CSS_CLASS_NAME_KEY, "btn-primary"); - registerControlForFontUpdates(continueButton); - Button cancelButton = new Button(actionArea, SWT.PUSH); - cancelButton.setLayoutData(new GridData(SWT.BEGINNING, SWT.CENTER, false, false)); - cancelButton.setText("Cancel"); - cancelButton.addListener(SWT.Selection, e -> { + // --- dismiss (skip) button --- + Button dismissBtn = new Button(actionArea, SWT.PUSH); + dismissBtn.setLayoutData( + new GridData(SWT.BEGINNING, SWT.CENTER, false, false)); + dismissBtn.setText( + dismissAction != null ? dismissAction.getLabel() + : Messages.confirmation_action_skip); + registerControlForFontUpdates(dismissBtn); + + final ConfirmationAction dismissRef = dismissAction; + dismissBtn.addListener(SWT.Selection, e -> { + this.selectedAction = dismissRef; cancelConfirmation(); }); - registerControlForFontUpdates(cancelButton); } - /** - * Get the future that will be completed when the user makes a choice. - * - * @return CompletableFuture containing the result of user's choice - */ - public CompletableFuture getConfirmationFuture() { - return toolConfirmationFuture; + // --------------- helpers --------------- + + private Composite newButtonArea(int columns) { + GridLayout layout = new GridLayout(columns, false); + layout.marginLeft = 0; + layout.marginRight = 0; + layout.marginWidth = 0; + layout.horizontalSpacing = 0; + layout.marginHeight = 0; + + Composite area = new Composite(this, SWT.NONE); + area.setLayout(layout); + area.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + return area; } - /** - * Cancels the current tool confirmation dialog programmatically. This has the same effect as clicking the Cancel - * button in the confirmation dialog. - */ - public void cancelConfirmation() { - if (toolConfirmationFuture != null && !toolConfirmationFuture.isDone()) { - toolConfirmationFuture.complete(new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS)); + private void acceptAndDispose(ConfirmationAction action) { + this.selectedAction = action; + this.toolConfirmationFuture.complete( + new LanguageModelToolConfirmationResult( + ToolConfirmationResult.ACCEPT)); - // Store parent reference before disposal - Composite parent = this.getParent(); - SwtUtils.invokeOnDisplayThread(() -> { - // Only show the cancel widget for special cases when the tool has a parameter "command" in the input map - if (StringUtils.isNotEmpty(this.cancelMessage)) { - new AgentToolCancelLabel(this.getParent(), SWT.NONE, this.cancelMessage); - } - this.dispose(); - // Check if parent is still valid before using it - if (parent != null && !parent.isDisposed()) { - parent.layout(); - } - }, this); + Composite parent = this.getParent(); + this.dispose(); + if (parent != null && !parent.isDisposed()) { + parent.requestLayout(); } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java index 9c905913..f1a06d18 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java @@ -41,6 +41,39 @@ public final class Messages extends NLS { public static String thinking_expandTooltip; public static String thinking_collapseTooltip; + // Confirmation dialog action labels + public static String confirmation_action_allowOnce; + public static String confirmation_action_skip; + public static String confirmation_action_allowAllCommands; + public static String confirmation_action_allowNamesSession; + public static String confirmation_action_alwaysAllowNames; + public static String confirmation_action_allowExactSession; + public static String confirmation_action_alwaysAllowExact; + public static String confirmation_action_alwaysAllow; + public static String confirmation_action_allowFileSession; + public static String confirmation_action_allowFolderSession; + + // MCP confirmation dialog action labels + public static String confirmation_title_mcpTool; + public static String confirmation_title_mcpToolDefault; + public static String confirmation_action_allowServerSession; + public static String confirmation_action_alwaysAllowServer; + + // Confirmation dialog titles + public static String confirmation_title_terminal; + public static String confirmation_title_fallback; + public static String confirmation_title_fileRead; + public static String confirmation_title_fileWrite; + public static String confirmation_title_fileOperation; + + // Confirmation dialog messages + public static String confirmation_message_fileRead; + public static String confirmation_message_fileWrite; + public static String confirmation_message_fileOperation; + + // Misc + public static String confirmation_autoApprovedDescription; + static { // initialize resource bundle NLS.initializeMessages(BUNDLE_NAME, Messages.class); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/AttachedFileRegistry.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/AttachedFileRegistry.java new file mode 100644 index 00000000..bb548c77 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/AttachedFileRegistry.java @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; + +/** + * Tracks files explicitly attached by the user in the context panel. + * + *

New files are first stored in a {@code pending} set (not yet bound + * to any conversation ID). The confirmation handler checks pending files + * first, so auto-approve works even before the real conversation ID + * arrives from CLS. Once the real ID is known, call + * {@link #flushPending(String)} to move them into per-conversation + * storage. + */ +public class AttachedFileRegistry { + + private final Map> attachedPaths = + Collections.synchronizedMap(new LinkedHashMap<>()); + + /** Files waiting for a stable conversation ID. */ + private final Set pendingFiles = + Collections.synchronizedSet(new HashSet<>()); + + /** + * Stages file paths for auto-approve before the conversation ID is + * known. These are checked by {@link #isAttachedFile} immediately. + */ + public void addPending(Collection filePaths) { + if (filePaths == null || filePaths.isEmpty()) { + return; + } + filePaths.stream() + .filter(StringUtils::isNotBlank) + .map(AttachedFileRegistry::toComparisonKey) + .forEach(pendingFiles::add); + } + + /** + * Moves pending files into per-conversation storage under the given + * conversation ID, then clears the pending set. + */ + public void flushPending(String conversationId) { + if (StringUtils.isBlank(conversationId) || pendingFiles.isEmpty()) { + return; + } + Set flushed; + synchronized (pendingFiles) { + flushed = new HashSet<>(pendingFiles); + pendingFiles.clear(); + } + evictOldestIfNeeded(); + synchronized (attachedPaths) { + attachedPaths.merge(conversationId, flushed, (a, b) -> { + Set merged = new HashSet<>(a); + merged.addAll(b); + return merged; + }); + } + } + + /** + * Records files for an existing conversation (continued turns). + */ + public void addAttachedFiles(String conversationId, + Collection filePaths) { + if (filePaths == null || filePaths.isEmpty() + || StringUtils.isBlank(conversationId)) { + return; + } + Set keys = filePaths.stream() + .filter(StringUtils::isNotBlank) + .map(AttachedFileRegistry::toComparisonKey) + .collect(Collectors.toSet()); + if (keys.isEmpty()) { + return; + } + evictOldestIfNeeded(); + synchronized (attachedPaths) { + attachedPaths.merge(conversationId, keys, (a, b) -> { + Set merged = new HashSet<>(a); + merged.addAll(b); + return merged; + }); + } + } + + /** + * Returns {@code true} when the given file was explicitly attached + * by the user — either in the pending set or for the given conversation. + */ + public boolean isAttachedFile(String conversationId, String filePath) { + if (StringUtils.isBlank(filePath)) { + return false; + } + String key = toComparisonKey(filePath); + // Check pending files first (before conversation ID is known) + if (pendingFiles.contains(key)) { + return true; + } + Set paths = attachedPaths.get(conversationId); + return paths != null && paths.contains(key); + } + + /** Removes all tracked data for a conversation. */ + public void clearConversation(String conversationId) { + attachedPaths.remove(conversationId); + } + + /** Discards any pending (pre-conversation) files. */ + public void clearPending() { + pendingFiles.clear(); + } + + private void evictOldestIfNeeded() { + synchronized (attachedPaths) { + while (attachedPaths.size() >= ConfirmationHandler.MAX_SESSION_CONVERSATIONS) { + var it = attachedPaths.entrySet().iterator(); + if (it.hasNext()) { + it.next(); + it.remove(); + } + } + } + } + + private static String toComparisonKey(String path) { + return ConfirmationHandler.normalizePath(path); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationHandler.java new file mode 100644 index 00000000..3e1e2e1a --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationHandler.java @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; + +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; + +/** + * Evaluates whether a tool confirmation request can be auto-approved. + * Each implementation handles a specific category of tool (terminal, file operations, MCP, etc.). + */ +public interface ConfirmationHandler { + + /** Maximum number of conversations tracked in session memory. */ + int MAX_SESSION_CONVERSATIONS = 50; + + /** + * Normalizes a file path for case-insensitive, separator-agnostic comparison. + * Converts backslashes to forward slashes and lowercases the result. + * + * @param path the file path to normalize + * @return the normalized path + */ + static String normalizePath(String path) { + return path.replace('\\', '/').toLowerCase(Locale.ROOT); + } + + /** + * Extracts the {@code toolType} string from the input map of a confirmation request. + * Returns {@code null} if the field is absent or not a string. + * + * @param params the confirmation request parameters from CLS + * @return the toolType value, or {@code null} + */ + static String extractToolType(InvokeClientToolConfirmationParams params) { + Object input = params.getInput(); + if (input instanceof Map inputMap) { + Object toolType = inputMap.get("toolType"); + if (toolType instanceof String) { + return (String) toolType; + } + } + return null; + } + + /** + * Evaluates whether the given confirmation request should be auto-approved, + * taking into account whether the auto-approval feature is enabled by token/policy. + * + * @param params the confirmation request parameters from CLS + * @param sessionConversationId the conversation ID to use for session-scoped + * lookups (may differ from params.getConversationId() when called from a + * subagent context) + * @param isAutoApprovalEnabled whether the auto-approval feature is currently enabled + * @return the confirmation result + */ + ConfirmationResult evaluate(InvokeClientToolConfirmationParams params, + String sessionConversationId, boolean isAutoApprovalEnabled); + + /** + * Caches a user's decision based on the action scope. + * SESSION actions are stored in-memory per conversation; + * GLOBAL actions are written to preferences. + * + * @param action the user's selected action with metadata + * @param params the original confirmation params (for command data etc.) + * @param sessionConversationId the conversation ID to use for session storage + */ + default void cacheDecision(ConfirmationAction action, + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + // no-op by default + } + + /** Clears session-scoped approvals for the given conversation. */ + default void clearSession(String conversationId) { + // no-op by default + } + + /** + * Evicts the oldest entry from a {@link java.util.LinkedHashMap}-backed map when it reaches + * {@link #MAX_SESSION_CONVERSATIONS}. Thread-safe via {@code synchronized(map)}. + * + * @param value type + * @param map the map to evict from (must be a {@code Collections.synchronizedMap} wrapping a + * {@code LinkedHashMap} for correct eviction order) + */ + static void evictOldestIfNeeded(Map map) { + synchronized (map) { + while (map.size() >= MAX_SESSION_CONVERSATIONS) { + Entry oldest = map.entrySet().iterator().next(); + map.remove(oldest.getKey()); + } + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationService.java new file mode 100644 index 00000000..50242adc --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationService.java @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.util.EnumMap; +import java.util.Map; + +import org.eclipse.jface.preference.IPreferenceStore; + +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.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; + +/** + * Central entry point for auto-approve evaluation. Classifies each tool confirmation request + * into a category using the toolType field provided by CLS, then dispatches to the registered + * handler for that category. All session/global persist logic lives in each handler. + */ +public class ConfirmationService { + + /** Tool functional types matching CLS ToolFunctionalType enum values. */ + public enum ToolCategory { + TERMINAL("terminal"), + FILE_READ("file_read"), + FILE_WRITE("file_write"), + FILE_OPERATION("file_operation"), + MCP_TOOL("mcp_tool"), + SAFE_TOOL("safe_tool"), + WEB("web"), + UNKNOWN("unknown"); + + private final String value; + + ToolCategory(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + /** + * Resolves a CLS toolType string to a ToolCategory. + */ + public static ToolCategory fromValue(String value) { + if (value != null) { + for (ToolCategory category : values()) { + if (category.value.equals(value)) { + return category; + } + } + } + return UNKNOWN; + } + } + + private final Map handlers = + new EnumMap<>(ToolCategory.class); + private final ConfirmationHandler fallbackHandler = + new FallbackConfirmationHandler(); + private final IPreferenceStore preferenceStore; + + /** + * Creates a new ConfirmationService. + * + * @param preferenceStore the preference store for reading auto-approve settings + * @param attachedFileRegistry registry of user-attached context files + */ + public ConfirmationService(IPreferenceStore preferenceStore, + AttachedFileRegistry attachedFileRegistry) { + this.preferenceStore = preferenceStore; + handlers.put(ToolCategory.TERMINAL, + new TerminalConfirmationHandler(preferenceStore)); + FileOperationConfirmationHandler fileHandler = + new FileOperationConfirmationHandler(preferenceStore, + attachedFileRegistry); + handlers.put(ToolCategory.FILE_READ, fileHandler); + handlers.put(ToolCategory.FILE_WRITE, fileHandler); + handlers.put(ToolCategory.FILE_OPERATION, fileHandler); + handlers.put(ToolCategory.MCP_TOOL, + new McpConfirmationHandler(preferenceStore)); + } + + /** + * Evaluates whether a tool confirmation request should be auto-approved. + * + *

When the auto-approval feature is disabled by token or organization policy, all + * auto-approve rules (YOLO, session, global) are bypassed and the user is always prompted + * with a simple Allow-Once / Skip dialog. + * + * @param params the confirmation request parameters + * @param sessionConversationId the conversation ID for session-scoped lookups + */ + public ConfirmationResult evaluate( + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); + boolean autoApprovalEnabled = flags == null || flags.isAutoApprovalEnabled(); + + if (autoApprovalEnabled && preferenceStore.getBoolean(Constants.AUTO_APPROVE_YOLO_MODE)) { + return ConfirmationResult.AUTO_APPROVED; + } + + ToolCategory category = classify(params); + if (category == ToolCategory.SAFE_TOOL) { + return ConfirmationResult.AUTO_APPROVED; + } + + ConfirmationHandler handler = handlers.getOrDefault(category, fallbackHandler); + return handler.evaluate(params, sessionConversationId, autoApprovalEnabled); + } + + /** + * Caches the user's decision for future auto-approve lookups. + * + * @param action the user's selected action + * @param params the original confirmation params + * @param sessionConversationId the conversation ID for session storage + */ + public void cacheDecision(ConfirmationAction action, + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + if (action == null || action.getScope() == null + || action.getScope() == ConfirmationActionScope.ONCE) { + return; + } + ToolCategory category = classify(params); + ConfirmationHandler handler = handlers.get(category); + if (handler != null) { + handler.cacheDecision(action, params, sessionConversationId); + } + } + + /** Clears session-scoped approvals for a conversation across all handlers. */ + public void clearSession(String conversationId) { + for (ConfirmationHandler handler : handlers.values()) { + handler.clearSession(conversationId); + } + } + + ToolCategory classify(InvokeClientToolConfirmationParams params) { + return ToolCategory.fromValue(ConfirmationHandler.extractToolType(params)); + } + +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FallbackConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FallbackConfirmationHandler.java new file mode 100644 index 00000000..d58c77e7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FallbackConfirmationHandler.java @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.util.List; + +import org.eclipse.osgi.util.NLS; + +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.ui.chat.Messages; + +/** + * Catch-all handler for tool types that have no dedicated handler registered. + * Always shows a simple confirmation dialog with Allow Once / Skip. + */ +public class FallbackConfirmationHandler implements ConfirmationHandler { + + @Override + public ConfirmationResult evaluate( + InvokeClientToolConfirmationParams params, + String sessionConversationId, boolean isAutoApprovalEnabled) { + String title = params.getTitle() != null + ? params.getTitle() + : NLS.bind(Messages.confirmation_title_fallback, params.getName()); + return ConfirmationResult.needsConfirmation( + new ConfirmationContent(title, params.getMessage(), + List.of( + ConfirmationAction.allowOnce( + Messages.confirmation_action_allowOnce), + ConfirmationAction.skip( + Messages.confirmation_action_skip)))); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandler.java new file mode 100644 index 00000000..313c56c2 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandler.java @@ -0,0 +1,457 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.lang.reflect.Type; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.osgi.util.NLS; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.chat.FileOperationAutoApproveRule; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata; +import com.microsoft.copilot.eclipse.ui.chat.Messages; + +/** + * Evaluates file-operation confirmation requests against user-configured glob pattern rules. + * Files outside the workspace always require confirmation. + */ +public class FileOperationConfirmationHandler implements ConfirmationHandler { + + /** Action types for edit confirmation. */ + public enum Action { + /** Allow this specific file for the rest of the session. */ + ACCEPT_FILE_SESSION, + /** Always allow this specific file (persisted globally). */ + ACCEPT_FILE_GLOBAL, + /** Allow all files under this folder for the session. */ + ACCEPT_FOLDER_SESSION + } + + /** File tool type matching CLS values. */ + enum FileToolType { + FILE_READ("file_read"), + FILE_WRITE("file_write"), + FILE_OPERATION("file_operation"); + + private final String value; + + FileToolType(String value) { + this.value = value; + } + + String getDefaultTitle() { + switch (this) { + case FILE_READ: + return Messages.confirmation_title_fileRead; + case FILE_WRITE: + return Messages.confirmation_title_fileWrite; + default: + return Messages.confirmation_title_fileOperation; + } + } + + String getMessageTemplate() { + switch (this) { + case FILE_READ: + return Messages.confirmation_message_fileRead; + case FILE_WRITE: + return Messages.confirmation_message_fileWrite; + default: + return Messages.confirmation_message_fileOperation; + } + } + + static FileToolType fromValue(String value) { + if (value != null) { + for (FileToolType t : values()) { + if (t.value.equals(value)) { + return t; + } + } + } + return FILE_OPERATION; + } + } + + static final String META_FILE_PATH = "filePath"; + static final String META_FOLDER_PATH = "folderPath"; + + /** + * Local fallback defaults matching CLS + * {@code FileSafetyRulesService.defaultRules}. + * Used when CLS is not yet available. + */ + public static final List FALLBACK_DEFAULT_RULES = List.of( + new FileOperationAutoApproveRule("**/.github/instructions/*", + "GitHub instructions files", false, true), + new FileOperationAutoApproveRule("**/github-copilot/**/*", + "GitHub Copilot settings and token files", false, true)); + + private static final Type RULES_TYPE = + new TypeToken>() { + }.getType(); + + private final IPreferenceStore preferenceStore; + private final AttachedFileRegistry attachedFileRegistry; + private final Map> sessionApprovedFiles = + Collections.synchronizedMap(new LinkedHashMap<>()); + private final Map> sessionApprovedFolders = + Collections.synchronizedMap(new LinkedHashMap<>()); + + /** + * Creates a new FileOperationConfirmationHandler. + * + * @param preferenceStore the preference store for reading file-operation auto-approve rules + * @param attachedFileRegistry registry of user-attached files for auto-approval + */ + public FileOperationConfirmationHandler(IPreferenceStore preferenceStore, + AttachedFileRegistry attachedFileRegistry) { + this.preferenceStore = preferenceStore; + this.attachedFileRegistry = attachedFileRegistry; + } + + @Override + public ConfirmationResult evaluate(InvokeClientToolConfirmationParams params, + String sessionConversationId, boolean isAutoApprovalEnabled) { + if (!isAutoApprovalEnabled) { + return evaluateAutoApprovalDisabled(params, sessionConversationId); + } + return evaluateAutoApprovalEnabled(params, sessionConversationId); + } + + private ConfirmationResult evaluateAutoApprovalEnabled( + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String filePath = extractFilePath(params); + if (StringUtils.isBlank(filePath)) { + return ConfirmationResult.DISMISSED; + } + + // Auto-approve files explicitly attached by the user in the context panel + if (attachedFileRegistry.isAttachedFile( + sessionConversationId, filePath)) { + return ConfirmationResult.AUTO_APPROVED; + } + + // Session override — file level + String convId = sessionConversationId; + Set convFiles = sessionApprovedFiles.get(convId); + if (convFiles != null && convFiles.contains(normalizePath(filePath))) { + return ConfirmationResult.AUTO_APPROVED; + } + // Session override — folder level + Set convFolders = sessionApprovedFolders.get(convId); + if (convFolders != null && filePath != null) { + String normalizedPath = normalizePath(filePath); + for (String folder : convFolders) { + if (normalizedPath.startsWith(folder + "/")) { + return ConfirmationResult.AUTO_APPROVED; + } + } + } + + // Files outside workspace always require confirmation + if (isOutsideWorkspace(params)) { + return ConfirmationResult.needsConfirmation(buildContent(params)); + } + + // Check against rules + List rules = loadRules(); + for (FileOperationAutoApproveRule rule : rules) { + if (matchesGlob(filePath, rule.getPattern())) { + return rule.isAutoApprove() + ? ConfirmationResult.AUTO_APPROVED + : ConfirmationResult.needsConfirmation(buildContent(params)); + } + } + + return evaluateUnmatched(params); + } + + private ConfirmationResult evaluateAutoApprovalDisabled( + InvokeClientToolConfirmationParams params, String sessionConversationId) { + String filePath = extractFilePath(params); + if (StringUtils.isBlank(filePath)) { + return ConfirmationResult.DISMISSED; + } + + // Still honor files explicitly attached by the user (intentional context) + if (attachedFileRegistry.isAttachedFile(sessionConversationId, filePath)) { + return ConfirmationResult.AUTO_APPROVED; + } + + // Outside workspace always requires confirmation (simplified dialog only) + if (isOutsideWorkspace(params)) { + return ConfirmationResult.needsConfirmation(buildSimplifiedContent(params)); + } + + // Only check default rules; ignore user-configured rules + for (FileOperationAutoApproveRule rule : FALLBACK_DEFAULT_RULES) { + if (matchesGlob(filePath, rule.getPattern())) { + return rule.isAutoApprove() + ? ConfirmationResult.AUTO_APPROVED + : ConfirmationResult.needsConfirmation(buildSimplifiedContent(params)); + } + } + + // Unmatched workspace file: auto-approve + return ConfirmationResult.AUTO_APPROVED; + } + + private ConfirmationResult evaluateUnmatched( + InvokeClientToolConfirmationParams params) { + if (preferenceStore.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)) { + return ConfirmationResult.AUTO_APPROVED; + } + return ConfirmationResult.needsConfirmation(buildContent(params)); + } + + private ConfirmationContent buildContent( + InvokeClientToolConfirmationParams params) { + String filePath = extractFilePath(params); + final FileToolType fileType = + FileToolType.fromValue(ConfirmationHandler.extractToolType(params)); + String safeFilePath = filePath != null ? filePath : ""; + boolean outsideWorkspace = isOutsideWorkspace(params); + + List actions = new ArrayList<>(); + actions.add(ConfirmationAction.allowOnce( + Messages.confirmation_action_allowOnce)); + + if (outsideWorkspace && filePath != null) { + // Outside workspace: offer folder-level approval + Path parent = Path.of(filePath).getParent(); + String folderName = (parent != null && parent.getFileName() != null) + ? parent.getFileName().toString() + : (parent != null ? parent.toString() : filePath); + String folderPath = parent != null ? parent.toString() : ""; + actions.add(action(Action.ACCEPT_FOLDER_SESSION, + NLS.bind(Messages.confirmation_action_allowFolderSession, + folderName), + ConfirmationActionScope.SESSION, + Map.of(META_FOLDER_PATH, folderPath))); + } else { + // In workspace: offer file-level approval + actions.add(action(Action.ACCEPT_FILE_SESSION, + Messages.confirmation_action_allowFileSession, + ConfirmationActionScope.SESSION, + Map.of(META_FILE_PATH, safeFilePath))); + actions.add(action(Action.ACCEPT_FILE_GLOBAL, + Messages.confirmation_action_alwaysAllow, + ConfirmationActionScope.GLOBAL, + Map.of(META_FILE_PATH, safeFilePath))); + } + actions.add(ConfirmationAction.skip( + Messages.confirmation_action_skip)); + + String title = params.getTitle() != null + ? params.getTitle() : fileType.getDefaultTitle(); + String fileName = ""; + try { + if (filePath != null) { + fileName = Path.of(filePath).getFileName().toString(); + } + } catch (Exception ignored) { + // use empty + } + String message = NLS.bind(fileType.getMessageTemplate(), fileName); + return new ConfirmationContent(title, message, actions); + } + + /** + * Builds a simplified confirmation dialog with only Allow Once and Skip actions. + * Used when the auto-approval feature is disabled by token/policy. + */ + private ConfirmationContent buildSimplifiedContent( + InvokeClientToolConfirmationParams params) { + String filePath = extractFilePath(params); + final FileToolType fileType = + FileToolType.fromValue(ConfirmationHandler.extractToolType(params)); + String fileName = ""; + try { + if (filePath != null) { + fileName = Path.of(filePath).getFileName().toString(); + } + } catch (Exception ignored) { + // use empty + } + String title = params.getTitle() != null ? params.getTitle() : fileType.getDefaultTitle(); + String message = NLS.bind(fileType.getMessageTemplate(), fileName); + return new ConfirmationContent(title, message, + List.of( + ConfirmationAction.allowOnce(Messages.confirmation_action_allowOnce), + ConfirmationAction.skip(Messages.confirmation_action_skip))); + } + + private static ConfirmationAction action(Action type, String label, + ConfirmationActionScope scope, Map extra) { + Map meta = new java.util.HashMap<>(extra); + meta.put(ConfirmationAction.META_ACTION, type.name()); + return new ConfirmationAction(label, true, scope, meta, false); + } + + private String extractFilePath(InvokeClientToolConfirmationParams params) { + // Try toolMetadata first + ToolMetadata metadata = params.getToolMetadata(); + if (metadata != null && metadata.getSensitiveFileData() != null) { + return metadata.getSensitiveFileData().getFilePath(); + } + // Fallback: extract from input map + Object input = params.getInput(); + if (input instanceof Map) { + Object path = ((Map) input).get("filePath"); + if (path == null) { + path = ((Map) input).get("path"); + } + return path instanceof String ? (String) path : null; + } + return null; + } + + // Uses CLS-provided isGlobal flag from sensitiveFileData metadata + private boolean isOutsideWorkspace(InvokeClientToolConfirmationParams params) { + ToolMetadata metadata = params.getToolMetadata(); + if (metadata != null && metadata.getSensitiveFileData() != null) { + return metadata.getSensitiveFileData().isGlobal(); + } + return false; + } + + /** Normalizes a file path for case-insensitive, separator-agnostic comparison. */ + private static String normalizePath(String path) { + return ConfirmationHandler.normalizePath(path); + } + + static boolean matchesGlob(String filePath, String globPattern) { + if (StringUtils.isBlank(filePath) || StringUtils.isBlank(globPattern)) { + return false; + } + try { + // Normalize both path and pattern to forward slashes for consistent matching + String normalizedPath = filePath.replace('\\', '/'); + String normalizedPattern = globPattern.replace('\\', '/'); + + // Fast exact-match for absolute file path rules (e.g., from "Always Allow") + if (normalizedPath.equalsIgnoreCase(normalizedPattern)) { + return true; + } + + PathMatcher matcher = FileSystems.getDefault() + .getPathMatcher("glob:" + normalizedPattern); + return matcher.matches(Path.of(normalizedPath)); + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Invalid file-operation auto-approve glob: " + globPattern, e); + return false; + } + } + + List loadRules() { + String json = + preferenceStore.getString(Constants.AUTO_APPROVE_FILE_OP_RULES); + if (StringUtils.isBlank(json) || "[]".equals(json.trim())) { + return Collections.emptyList(); + } + try { + List rules = + new Gson().fromJson(json, RULES_TYPE); + return rules != null ? rules : Collections.emptyList(); + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Failed to parse file-operation auto-approve rules", e); + return Collections.emptyList(); + } + } + + @Override + public void cacheDecision(ConfirmationAction action, + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String actionName = action.getMetadata() + .get(ConfirmationAction.META_ACTION); + if (actionName == null) { + return; + } + Action type; + try { + type = Action.valueOf(actionName); + } catch (IllegalArgumentException e) { + return; + } + + String convId = sessionConversationId; + Map meta = action.getMetadata(); + switch (type) { + case ACCEPT_FILE_SESSION: + String fp = meta.getOrDefault(META_FILE_PATH, ""); + if (!fp.isEmpty()) { + ConfirmationHandler.evictOldestIfNeeded(sessionApprovedFiles); + sessionApprovedFiles.computeIfAbsent( + convId, k -> ConcurrentHashMap.newKeySet()) + .add(normalizePath(fp)); + } + break; + case ACCEPT_FOLDER_SESSION: + String folder = meta.getOrDefault(META_FOLDER_PATH, ""); + if (!folder.isEmpty()) { + ConfirmationHandler.evictOldestIfNeeded(sessionApprovedFolders); + sessionApprovedFolders.computeIfAbsent( + convId, k -> ConcurrentHashMap.newKeySet()) + .add(normalizePath(folder)); + } + break; + case ACCEPT_FILE_GLOBAL: + String globalFp = meta.getOrDefault(META_FILE_PATH, ""); + if (!globalFp.isEmpty()) { + List rules = + new ArrayList<>(loadRules()); + // Update existing rule if path matches (case-insensitive for Windows) + boolean found = false; + for (FileOperationAutoApproveRule r : rules) { + if (r.getPattern().equalsIgnoreCase(globalFp)) { + r.setAutoApprove(true); + found = true; + break; + } + } + if (!found) { + rules.add(new FileOperationAutoApproveRule(globalFp, + Messages.confirmation_autoApprovedDescription, true)); + } + preferenceStore.setValue(Constants.AUTO_APPROVE_FILE_OP_RULES, + new Gson().toJson(rules)); + } + break; + default: + break; + } + } + + @Override + public void clearSession(String conversationId) { + sessionApprovedFiles.remove(conversationId); + sessionApprovedFolders.remove(conversationId); + attachedFileRegistry.clearConversation(conversationId); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandler.java new file mode 100644 index 00000000..c2757223 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandler.java @@ -0,0 +1,364 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.osgi.util.NLS; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolAnnotations; +import com.microsoft.copilot.eclipse.ui.chat.Messages; + +/** + * Evaluates MCP tool confirmation requests against session and global approval lists. + * Supports per-server and per-tool approval at both session and global scopes, + * plus optional trust-annotation-based auto-approve for read-only tools. + */ +public class McpConfirmationHandler implements ConfirmationHandler { + + /** Action types for MCP tool confirmation decisions. */ + public enum Action { + /** Allow a specific tool for the current session/conversation. */ + ACCEPT_TOOL_SESSION, + /** Always allow a specific tool (persisted globally). */ + ACCEPT_TOOL_GLOBAL, + /** Allow all tools from a server for the current session/conversation. */ + ACCEPT_SERVER_SESSION, + /** Always allow all tools from a server (persisted globally). */ + ACCEPT_SERVER_GLOBAL + } + + static final String META_SERVER_NAME = "serverName"; + static final String META_TOOL_KEY = "toolKey"; + + private static final String SEPARATOR = "::"; + private static final Type STRING_LIST_TYPE = + new TypeToken>() {}.getType(); + + private final IPreferenceStore preferenceStore; + + // Session-scoped in-memory storage keyed by conversationId. + private final Map> approvedServers = + Collections.synchronizedMap(new LinkedHashMap<>()); + private final Map> approvedTools = + Collections.synchronizedMap(new LinkedHashMap<>()); + + /** + * Creates a new McpConfirmationHandler. + * + * @param preferenceStore the preference store for reading MCP auto-approve settings + */ + public McpConfirmationHandler(IPreferenceStore preferenceStore) { + this.preferenceStore = preferenceStore; + } + + /** + * When the auto-approval feature is disabled, MCP tools always prompt with + * Allow Once / Skip only — no session or global approval buttons. + * This matches IntelliJ's behavior where MCP ignores all rules when disabled. + */ + @Override + public ConfirmationResult evaluate(InvokeClientToolConfirmationParams params, + String sessionConversationId, boolean isAutoApprovalEnabled) { + if (!isAutoApprovalEnabled) { + return evaluateAutoApprovalDisabled(params); + } + return evaluateAutoApprovalEnabled(params, sessionConversationId); + } + + /** + * Evaluates an MCP tool confirmation request. Check order: + * 1. Session approved servers (by conversationId) + * 2. Session approved tools (by conversationId, key = "server::tool") + * 3. Global approved servers list + * 4. Global approved tools list + * 5. Trust annotations (readOnlyHint=true AND openWorldHint=false) + * 6. Otherwise: needs confirmation + */ + private ConfirmationResult evaluateAutoApprovalEnabled( + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String serverName = extractServerName(params); + String toolName = extractToolName(params); + String serverLower = serverName != null + ? serverName.toLowerCase(Locale.ROOT) : null; + String toolKey = buildToolKey(serverLower, toolName); + + // 1. Session: server approved for this conversation + if (serverLower != null) { + Set sessionServers = + approvedServers.get(sessionConversationId); + if (sessionServers != null + && sessionServers.contains(serverLower)) { + return ConfirmationResult.AUTO_APPROVED; + } + } + + // 2. Session: specific tool approved for this conversation + if (toolKey != null) { + Set sessionTools = + approvedTools.get(sessionConversationId); + if (sessionTools != null && sessionTools.contains(toolKey)) { + return ConfirmationResult.AUTO_APPROVED; + } + } + + // 3. Global: server in approved servers list + if (serverLower != null) { + List globalServers = loadJsonList( + Constants.AUTO_APPROVE_MCP_SERVERS); + for (String s : globalServers) { + if (s.toLowerCase(Locale.ROOT).equals(serverLower)) { + return ConfirmationResult.AUTO_APPROVED; + } + } + } + + // 4. Global: tool in approved tools list + if (toolKey != null) { + List globalTools = loadJsonList( + Constants.AUTO_APPROVE_MCP_TOOLS); + for (String t : globalTools) { + if (t.toLowerCase(Locale.ROOT).equals(toolKey)) { + return ConfirmationResult.AUTO_APPROVED; + } + } + } + + // 5. Trust annotations: read-only and not open-world + if (preferenceStore.getBoolean( + Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS)) { + ToolAnnotations annotations = params.getAnnotations(); + if (annotations != null + && annotations.isReadOnlyHint() + && !annotations.isOpenWorldHint()) { + return ConfirmationResult.AUTO_APPROVED; + } + } + + // 6. Needs confirmation + return ConfirmationResult.needsConfirmation( + buildContent(params, serverName, toolName)); + } + + private ConfirmationResult evaluateAutoApprovalDisabled( + InvokeClientToolConfirmationParams params) { + String serverName = extractServerName(params); + String toolName = extractToolName(params); + return ConfirmationResult.needsConfirmation( + buildContent(params, serverName, toolName, /* simplifiedOnly= */ true)); + } + + @Override + public void cacheDecision(ConfirmationAction confirmAction, + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String actionName = confirmAction.getMetadata() + .get(ConfirmationAction.META_ACTION); + if (actionName == null) { + return; + } + Action type; + try { + type = Action.valueOf(actionName); + } catch (IllegalArgumentException e) { + return; + } + + Map meta = confirmAction.getMetadata(); + String serverName = meta.get(META_SERVER_NAME); + String toolKey = meta.get(META_TOOL_KEY); + + switch (type) { + case ACCEPT_TOOL_SESSION: + if (toolKey != null) { + synchronized (approvedTools) { + ConfirmationHandler.evictOldestIfNeeded(approvedTools); + approvedTools.computeIfAbsent( + sessionConversationId, + k -> ConcurrentHashMap.newKeySet()) + .add(toolKey.toLowerCase(Locale.ROOT)); + } + } + break; + case ACCEPT_SERVER_SESSION: + if (serverName != null) { + synchronized (approvedServers) { + ConfirmationHandler.evictOldestIfNeeded(approvedServers); + approvedServers.computeIfAbsent( + sessionConversationId, + k -> ConcurrentHashMap.newKeySet()) + .add(serverName.toLowerCase(Locale.ROOT)); + } + } + break; + case ACCEPT_TOOL_GLOBAL: + if (toolKey != null) { + addToGlobalList(Constants.AUTO_APPROVE_MCP_TOOLS, toolKey); + } + break; + case ACCEPT_SERVER_GLOBAL: + if (serverName != null) { + addToGlobalList(Constants.AUTO_APPROVE_MCP_SERVERS, serverName); + } + break; + default: + break; + } + } + + @Override + public void clearSession(String conversationId) { + approvedServers.remove(conversationId); + approvedTools.remove(conversationId); + } + + private ConfirmationContent buildContent( + InvokeClientToolConfirmationParams params, + String serverName, String toolName) { + return buildContent(params, serverName, toolName, false); + } + + private ConfirmationContent buildContent( + InvokeClientToolConfirmationParams params, + String serverName, String toolName, boolean simplifiedOnly) { + String toolKey = buildToolKey( + serverName != null ? serverName.toLowerCase(Locale.ROOT) : null, + toolName); + + List actions = new ArrayList<>(); + actions.add(ConfirmationAction.allowOnce( + Messages.confirmation_action_allowOnce)); + + if (!simplifiedOnly) { + if (toolName != null && toolKey != null) { + actions.add(action(Action.ACCEPT_TOOL_SESSION, + NLS.bind(Messages.confirmation_action_allowNamesSession, + "'" + toolName + "'"), + ConfirmationActionScope.SESSION, + Map.of(META_TOOL_KEY, toolKey))); + actions.add(action(Action.ACCEPT_TOOL_GLOBAL, + NLS.bind(Messages.confirmation_action_alwaysAllowNames, + "'" + toolName + "'"), + ConfirmationActionScope.GLOBAL, + Map.of(META_TOOL_KEY, toolKey))); + } + + if (serverName != null) { + actions.add(action(Action.ACCEPT_SERVER_SESSION, + NLS.bind(Messages.confirmation_action_allowServerSession, + "'" + serverName + "'"), + ConfirmationActionScope.SESSION, + Map.of(META_SERVER_NAME, serverName))); + actions.add(action(Action.ACCEPT_SERVER_GLOBAL, + NLS.bind(Messages.confirmation_action_alwaysAllowServer, + "'" + serverName + "'"), + ConfirmationActionScope.GLOBAL, + Map.of(META_SERVER_NAME, serverName))); + } + } + + actions.add(ConfirmationAction.skip( + Messages.confirmation_action_skip)); + + String title; + if (toolName != null && serverName != null) { + title = NLS.bind(Messages.confirmation_title_mcpTool, + toolName, serverName); + } else { + title = params.getTitle() != null + ? params.getTitle() + : Messages.confirmation_title_mcpToolDefault; + } + + return new ConfirmationContent(title, params.getMessage(), actions); + } + + private static ConfirmationAction action(Action type, String label, + ConfirmationActionScope scope, Map extra) { + Map meta = new HashMap<>(extra); + meta.put(ConfirmationAction.META_ACTION, type.name()); + return new ConfirmationAction(label, true, scope, meta, false); + } + + private String extractServerName( + InvokeClientToolConfirmationParams params) { + Object input = params.getInput(); + if (input instanceof Map inputMap) { + Object value = inputMap.get("mcpServerName"); + if (value instanceof String s && StringUtils.isNotBlank(s)) { + return s; + } + } + return null; + } + + private String extractToolName( + InvokeClientToolConfirmationParams params) { + Object input = params.getInput(); + if (input instanceof Map inputMap) { + Object value = inputMap.get("mcpToolName"); + if (value instanceof String s && StringUtils.isNotBlank(s)) { + return s; + } + } + return null; + } + + private static String buildToolKey(String serverLower, String toolName) { + if (serverLower == null || toolName == null) { + return null; + } + return serverLower + SEPARATOR + + toolName.toLowerCase(Locale.ROOT); + } + + private List loadJsonList(String preferenceKey) { + String json = preferenceStore.getString(preferenceKey); + if (StringUtils.isBlank(json) || "[]".equals(json.trim())) { + return Collections.emptyList(); + } + try { + List list = new Gson().fromJson(json, STRING_LIST_TYPE); + return list != null ? list : Collections.emptyList(); + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Failed to parse MCP auto-approve list: " + preferenceKey, e); + return Collections.emptyList(); + } + } + + private void addToGlobalList(String preferenceKey, String value) { + List current = new ArrayList<>(loadJsonList(preferenceKey)); + String lowerValue = value.toLowerCase(Locale.ROOT); + for (String existing : current) { + if (existing.toLowerCase(Locale.ROOT).equals(lowerValue)) { + return; // already present + } + } + current.add(value); + preferenceStore.setValue(preferenceKey, new Gson().toJson(current)); + } + +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandler.java new file mode 100644 index 00000000..b4641499 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandler.java @@ -0,0 +1,547 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import java.util.stream.Collectors; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.osgi.util.NLS; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.chat.TerminalAutoApproveRule; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata; +import com.microsoft.copilot.eclipse.ui.chat.Messages; + +/** + * Evaluates terminal command confirmation requests against user-configured allow/deny rules. + * Rules are matched against command names provided by CLS in toolMetadata.terminalCommandData. + */ +public class TerminalConfirmationHandler implements ConfirmationHandler { + + /** Action types matching IntelliJ's TerminalAutoApproveAction enum. */ + public enum Action { + /** Allow specific command names for the current session/conversation. */ + ACCEPT_NAMES_SESSION, + /** Always allow specific command names (persisted as a global rule). */ + ACCEPT_NAMES_GLOBAL, + /** Allow this exact command line for the current session/conversation. */ + ACCEPT_EXACT_SESSION, + /** Always allow this exact command line (persisted as a global rule). */ + ACCEPT_EXACT_GLOBAL, + /** Allow all terminal commands for the current session/conversation. */ + ACCEPT_ALL_SESSION + } + + /** Result of evaluating sub-commands against rules. */ + enum RuleVerdict { ALL_APPROVED, DENY_BLOCKED, UNMATCHED } + + private static final class RuleResult { + final RuleVerdict verdict; + final List unapprovedItems; + + RuleResult(RuleVerdict verdict, List unapprovedItems) { + this.verdict = verdict; + this.unapprovedItems = unapprovedItems; + } + } + + static final String META_COMMAND_NAMES = "commandNames"; + static final String META_COMMAND_LINE = "commandLine"; + + /** Default deny rules for dangerous terminal commands. */ + public static final List DEFAULT_RULES = List.of( + new TerminalAutoApproveRule("rm", false), + new TerminalAutoApproveRule("rmdir", false), + new TerminalAutoApproveRule("del", false), + new TerminalAutoApproveRule("kill", false), + new TerminalAutoApproveRule("curl", false), + new TerminalAutoApproveRule("wget", false), + new TerminalAutoApproveRule("eval", false), + new TerminalAutoApproveRule("chmod", false), + new TerminalAutoApproveRule("chown", false), + new TerminalAutoApproveRule("/^Remove-Item\\b/i", false), + new TerminalAutoApproveRule("/(\\(.+\\))/s", false), + new TerminalAutoApproveRule("/`.+`/s", false), + new TerminalAutoApproveRule("/\\{.+\\}/s", false)); + + private static final Type RULES_TYPE = new TypeToken>() { + }.getType(); + + private final IPreferenceStore preferenceStore; + + // Session-scoped in-memory storage keyed by conversationId. + // Uses insertion-ordered maps so we can evict the oldest entry when the + // map grows beyond MAX_SESSION_CONVERSATIONS. + private final Map> allowedCommandNames = + Collections.synchronizedMap(new LinkedHashMap<>()); + private final Map> allowedExactCommands = + Collections.synchronizedMap(new LinkedHashMap<>()); + private final Set allowAllConversations = + Collections.newSetFromMap( + Collections.synchronizedMap(new LinkedHashMap<>())); + + /** + * Creates a new TerminalConfirmationHandler. + * + * @param preferenceStore the preference store for reading terminal auto-approve rules + */ + public TerminalConfirmationHandler(IPreferenceStore preferenceStore) { + this.preferenceStore = preferenceStore; + } + + /** + * When the auto-approval feature is disabled, terminal commands always prompt + * with Allow Once / Skip only — no session or global approval buttons. + * This matches IntelliJ's behavior where terminal ignores all rules when disabled. + */ + @Override + public ConfirmationResult evaluate(InvokeClientToolConfirmationParams params, + String sessionConversationId, boolean isAutoApprovalEnabled) { + if (!isAutoApprovalEnabled) { + return evaluateAutoApprovalDisabled(params); + } + return evaluateAutoApprovalEnabled(params, sessionConversationId); + } + + /** + * Evaluates a terminal confirmation request. Check order follows IntelliJ: + * 1. Session "allow all" flag + * 2. Session exact commandLine match + * 3. Session command name match (all names must be approved) + * 4. Global exact commandLine match against rules + * 5. Global per-subCommand regex/prefix match against rules + * 6. Unmatched fallback (auto-approve if preference enabled) + */ + private ConfirmationResult evaluateAutoApprovalEnabled( + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String convId = sessionConversationId; + String commandLine = extractCommandLine(params); + + // 1. Session: all commands allowed for this conversation + if (allowAllConversations.contains(convId)) { + return ConfirmationResult.AUTO_APPROVED; + } + + // 2. Session: exact commandLine previously approved + Set exactSet = allowedExactCommands.get(convId); + if (commandLine != null && exactSet != null + && exactSet.contains(commandLine.trim())) { + return ConfirmationResult.AUTO_APPROVED; + } + + // 3. Session: all command names (e.g. "tree", "echo") approved + String[] cmdNames = getCommandNames(params); + Set namesSet = allowedCommandNames.get(convId); + if (cmdNames != null && namesSet != null && cmdNames.length > 0) { + boolean allApproved = true; + for (String name : cmdNames) { + if (!namesSet.contains(name)) { + allApproved = false; + break; + } + } + if (allApproved) { + return ConfirmationResult.AUTO_APPROVED; + } + } + + // 4-6. Global rules + String[] subCommands = getSubCommands(params); + if (subCommands == null || subCommands.length == 0) { + return ConfirmationResult.needsConfirmation( + buildContent(params, null)); + } + + List rules = loadRules(); + + // 4. Exact commandLine match + if (commandLine != null) { + for (TerminalAutoApproveRule rule : rules) { + if (commandLine.trim().equals(rule.getCommand().trim()) + && rule.isAutoApprove()) { + return ConfirmationResult.AUTO_APPROVED; + } + } + } + + // 5. Per-subCommand evaluation + RuleResult result = evaluateSubCommands( + subCommands, cmdNames, rules, namesSet); + + switch (result.verdict) { + case ALL_APPROVED: + return ConfirmationResult.AUTO_APPROVED; + case DENY_BLOCKED: + return ConfirmationResult.needsConfirmation( + buildContent(params, result.unapprovedItems)); + case UNMATCHED: + default: + // 6. Unmatched fallback + if (preferenceStore.getBoolean( + Constants.AUTO_APPROVE_UNMATCHED_TERMINAL)) { + return ConfirmationResult.AUTO_APPROVED; + } + List items = result.unapprovedItems.isEmpty() + ? null : result.unapprovedItems; + return ConfirmationResult.needsConfirmation( + buildContent(params, items)); + } + } + + private ConfirmationResult evaluateAutoApprovalDisabled( + InvokeClientToolConfirmationParams params) { + String title = params.getTitle() != null + ? params.getTitle() : Messages.confirmation_title_terminal; + return ConfirmationResult.needsConfirmation( + new ConfirmationContent(title, params.getMessage(), + List.of( + ConfirmationAction.allowOnce(Messages.confirmation_action_allowOnce), + ConfirmationAction.skip(Messages.confirmation_action_skip)))); + } + + /** + * Evaluates each sub-command against global rules and session state. + * Returns a verdict and the list of unapproved command names. + */ + private RuleResult evaluateSubCommands(String[] subCommands, + String[] cmdNames, List rules, + Set sessionApprovedNames) { + boolean allApproved = true; + boolean hasDeny = false; + List unapproved = new ArrayList<>(); + + for (int i = 0; i < subCommands.length; i++) { + String subCommand = subCommands[i]; + String cmdName = cmdNames != null && i < cmdNames.length + ? cmdNames[i] : null; + boolean hasAllow = false; + boolean denied = false; + + // Session command-name approval + if (cmdName != null && sessionApprovedNames != null + && sessionApprovedNames.contains(cmdName)) { + hasAllow = true; + } + + // Global rule matching + for (TerminalAutoApproveRule rule : rules) { + if (matchesRule(subCommand, rule.getCommand())) { + if (rule.isAutoApprove()) { + hasAllow = true; + } else { + denied = true; + } + break; + } + } + + if (denied && !hasAllow) { + hasDeny = true; + if (cmdName != null && !unapproved.contains(cmdName)) { + unapproved.add(cmdName); + } + } else if (!hasAllow) { + allApproved = false; + if (cmdName != null && !unapproved.contains(cmdName)) { + unapproved.add(cmdName); + } + } + } + + RuleVerdict verdict; + if (hasDeny) { + verdict = RuleVerdict.DENY_BLOCKED; + } else if (allApproved) { + verdict = RuleVerdict.ALL_APPROVED; + } else { + verdict = RuleVerdict.UNMATCHED; + } + return new RuleResult(verdict, unapproved); + } + + /** + * Builds the confirmation dialog content. When {@code unapprovedNames} + * is non-null, only those names appear in the command-name actions + * (already-approved names are filtered out). + */ + private ConfirmationContent buildContent( + InvokeClientToolConfirmationParams params, + List unapprovedNames) { + final String[] commandNames = getCommandNames(params); + String commandLine = extractCommandLine(params); + + // Use unapproved names if available, otherwise all unique names + List uniqueNames = unapprovedNames != null + ? unapprovedNames : dedup(commandNames); + String label = !uniqueNames.isEmpty() + ? "'" + String.join(", ", uniqueNames) + "'" : "'command'"; + String namesValue = String.join(",", uniqueNames); + + // Show exact command actions when commandLine differs from + // a single command name (otherwise redundant). + boolean showExact = commandLine != null + && !(uniqueNames.size() == 1 + && commandLine.trim().equals(uniqueNames.get(0))); + + List actions = new ArrayList<>(); + actions.add(ConfirmationAction.allowOnce(Messages.confirmation_action_allowOnce)); + if (!uniqueNames.isEmpty()) { + actions.add(action(Action.ACCEPT_NAMES_SESSION, + NLS.bind(Messages.confirmation_action_allowNamesSession, label), + ConfirmationActionScope.SESSION, + Map.of(META_COMMAND_NAMES, namesValue))); + actions.add(action(Action.ACCEPT_NAMES_GLOBAL, + NLS.bind(Messages.confirmation_action_alwaysAllowNames, label), + ConfirmationActionScope.GLOBAL, + Map.of(META_COMMAND_NAMES, namesValue))); + } + if (showExact) { + actions.add(action(Action.ACCEPT_EXACT_SESSION, + Messages.confirmation_action_allowExactSession, + ConfirmationActionScope.SESSION, + Map.of(META_COMMAND_LINE, commandLine))); + actions.add(action(Action.ACCEPT_EXACT_GLOBAL, + Messages.confirmation_action_alwaysAllowExact, + ConfirmationActionScope.GLOBAL, + Map.of(META_COMMAND_LINE, commandLine))); + } + actions.add(action(Action.ACCEPT_ALL_SESSION, + Messages.confirmation_action_allowAllCommands, + ConfirmationActionScope.SESSION, Map.of())); + actions.add(ConfirmationAction.skip(Messages.confirmation_action_skip)); + + String title = params.getTitle() != null + ? params.getTitle() : Messages.confirmation_title_terminal; + return new ConfirmationContent(title, params.getMessage(), actions); + } + + private static ConfirmationAction action(Action type, String label, + ConfirmationActionScope scope, Map extra) { + Map meta = new HashMap<>(extra); + meta.put(ConfirmationAction.META_ACTION, type.name()); + return new ConfirmationAction(label, true, scope, meta, false); + } + + private String extractCommandLine( + InvokeClientToolConfirmationParams params) { + Object input = params.getInput(); + if (input instanceof Map inputMap) { + Object cmd = inputMap.get("command"); + if (cmd instanceof String) { + return (String) cmd; + } + } + return null; + } + + private static List dedup(String[] items) { + if (items == null || items.length == 0) { + return Collections.emptyList(); + } + LinkedHashSet set = new LinkedHashSet<>(); + for (String item : items) { + if (item != null && !item.isBlank()) { + set.add(item); + } + } + return new ArrayList<>(set); + } + + private String[] getSubCommands(InvokeClientToolConfirmationParams params) { + ToolMetadata metadata = params.getToolMetadata(); + if (metadata != null && metadata.getTerminalCommandData() != null) { + return metadata.getTerminalCommandData().getSubCommands(); + } + return null; + } + + private String[] getCommandNames(InvokeClientToolConfirmationParams params) { + ToolMetadata metadata = params.getToolMetadata(); + if (metadata != null && metadata.getTerminalCommandData() != null) { + return metadata.getTerminalCommandData().getCommandNames(); + } + return null; + } + + /** + * Matches a sub-command against a rule. Exact string match is checked + * first (for exact-command rules). Then regex rules (/pattern/flags) are + * used directly, and simple rules (e.g., "rm") are converted to ^rm\b. + */ + static boolean matchesRule(String subCommand, String rulePattern) { + if (StringUtils.isBlank(subCommand) + || StringUtils.isBlank(rulePattern)) { + return false; + } + + // Exact match first + if (subCommand.trim().equals(rulePattern.trim())) { + return true; + } + + String regex; + int regexFlags = 0; + + if (rulePattern.startsWith("/") + && rulePattern.lastIndexOf('/') > 0) { + // Explicit regex: "/^git\b/i" + int lastSlash = rulePattern.lastIndexOf('/'); + regex = rulePattern.substring(1, lastSlash); + String flags = rulePattern.substring(lastSlash + 1); + if (flags.contains("i")) { + regexFlags |= Pattern.CASE_INSENSITIVE; + } + if (flags.contains("s")) { + regexFlags |= Pattern.DOTALL; + } + } else { + // Simple rule: "rm" → "^rm\b" + regex = "^" + Pattern.quote(rulePattern) + "\\b"; + } + + try { + return Pattern.compile(regex, regexFlags) + .matcher(subCommand).find(); + } catch (PatternSyntaxException e) { + CopilotCore.LOGGER.error( + "Invalid terminal auto-approve regex: " + rulePattern, e); + return false; + } + } + + List loadRules() { + String json = preferenceStore.getString(Constants.AUTO_APPROVE_TERMINAL_RULES); + if (StringUtils.isBlank(json) || "[]".equals(json.trim())) { + return Collections.emptyList(); + } + try { + List rules = new Gson().fromJson(json, RULES_TYPE); + return rules != null ? rules : Collections.emptyList(); + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to parse terminal auto-approve rules", e); + return Collections.emptyList(); + } + } + + @Override + public void cacheDecision(ConfirmationAction confirmAction, + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String actionName = confirmAction.getMetadata() + .get(ConfirmationAction.META_ACTION); + if (actionName == null) { + return; + } + Action type; + try { + type = Action.valueOf(actionName); + } catch (IllegalArgumentException e) { + return; + } + + String convId = sessionConversationId; + + // Prefer command data from action metadata (set by buildContent) + // so we persist exactly what the user chose, not the full params. + Map meta = confirmAction.getMetadata(); + String metaNames = meta.get(META_COMMAND_NAMES); + String metaLine = meta.get(META_COMMAND_LINE); + + String[] cmdNames = metaNames != null && !metaNames.isBlank() + ? metaNames.split(",") : getCommandNames(params); + String commandLine = metaLine != null && !metaLine.isBlank() + ? metaLine : extractCommandLine(params); + + switch (type) { + case ACCEPT_NAMES_SESSION: + if (cmdNames != null) { + ConfirmationHandler.evictOldestIfNeeded(allowedCommandNames); + Set nameSet = allowedCommandNames.computeIfAbsent( + convId, k -> ConcurrentHashMap.newKeySet()); + Collections.addAll(nameSet, cmdNames); + } + break; + case ACCEPT_EXACT_SESSION: + if (commandLine != null && !commandLine.isBlank()) { + ConfirmationHandler.evictOldestIfNeeded(allowedExactCommands); + allowedExactCommands.computeIfAbsent( + convId, k -> ConcurrentHashMap.newKeySet()) + .add(commandLine.trim()); + } + break; + case ACCEPT_ALL_SESSION: + allowAllConversations.add(convId); + // Cap allowAllConversations the same way + while (allowAllConversations.size() > MAX_SESSION_CONVERSATIONS) { + allowAllConversations.iterator().remove(); + } + break; + case ACCEPT_NAMES_GLOBAL: + if (cmdNames != null) { + addGlobalRules(List.of(cmdNames)); + } + break; + case ACCEPT_EXACT_GLOBAL: + if (commandLine != null && !commandLine.isBlank()) { + addGlobalRules(List.of(commandLine.trim())); + } + break; + default: + break; + } + } + + @Override + public void clearSession(String conversationId) { + allowedCommandNames.remove(conversationId); + allowedExactCommands.remove(conversationId); + allowAllConversations.remove(conversationId); + } + + private void addGlobalRules(List commands) { + List original = loadRules(); + Set existing = original.stream() + .map(TerminalAutoApproveRule::getCommand) + .collect(Collectors.toSet()); + + // Override existing deny rules → allow + List updated = original.stream() + .map(r -> commands.contains(r.getCommand()) && !r.isAutoApprove() + ? new TerminalAutoApproveRule(r.getCommand(), true) : r) + .collect(Collectors.toCollection(ArrayList::new)); + + // Append new rules for commands not yet present + commands.stream() + .filter(cmd -> !existing.contains(cmd)) + .map(cmd -> new TerminalAutoApproveRule(cmd, true)) + .forEach(updated::add); + + if (!updated.equals(original)) { + preferenceStore.setValue(Constants.AUTO_APPROVE_TERMINAL_RULES, + new Gson().toJson(updated)); + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties index 05a27d94..d46ae613 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties @@ -35,3 +35,36 @@ todoList_collapseTooltip=Click to collapse the todo list thinking_title=Thinking thinking_expandTooltip=Click to show the thinking details thinking_collapseTooltip=Click to hide the thinking details + +# Confirmation dialog action labels +confirmation_action_allowOnce=Allow Once +confirmation_action_skip=Skip +confirmation_action_allowAllCommands=Allow all commands in this Session +confirmation_action_allowNamesSession=Allow {0} in this Session +confirmation_action_alwaysAllowNames=Always Allow {0} +confirmation_action_allowExactSession=Allow this exact command in this Session +confirmation_action_alwaysAllowExact=Always Allow this exact command +confirmation_action_alwaysAllow=Always Allow +confirmation_action_allowFileSession=Allow this file in this Session +confirmation_action_allowFolderSession=Allow folder ''{0}'' in this Session + +# MCP confirmation dialog +confirmation_title_mcpTool=Run ''{0}'' tool from ''{1}'' MCP server +confirmation_title_mcpToolDefault=Allow MCP tool? +confirmation_action_allowServerSession=Allow tools from {0} in this Session +confirmation_action_alwaysAllowServer=Always Allow tools from {0} + +# Confirmation dialog titles +confirmation_title_terminal=Run command in terminal +confirmation_title_fallback=Allow {0}? +confirmation_title_fileRead=Allow reading sensitive file? +confirmation_title_fileWrite=Allow edits to sensitive file? +confirmation_title_fileOperation=Allow operation on sensitive file? + +# Confirmation dialog messages +confirmation_message_fileRead=The model wants to read sensitive file ({0}). +confirmation_message_fileWrite=The model wants to edit sensitive file ({0}). +confirmation_message_fileOperation=The model wants to access sensitive file ({0}). + +# Misc +confirmation_autoApprovedDescription=Auto-approved from dialog diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java index 4eab4f0c..be236d9c 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java @@ -18,6 +18,9 @@ import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.chat.ChatEventsManager; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; import com.microsoft.copilot.eclipse.core.chat.ToolInvocationListener; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; @@ -32,9 +35,13 @@ import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool; import com.microsoft.copilot.eclipse.terminal.api.TerminalServiceManager; +import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.BaseTurnWidget; import com.microsoft.copilot.eclipse.ui.chat.ChatContentViewer; import com.microsoft.copilot.eclipse.ui.chat.ChatView; +import com.microsoft.copilot.eclipse.ui.chat.InvokeToolConfirmationDialog; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.AttachedFileRegistry; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.ConfirmationService; import com.microsoft.copilot.eclipse.ui.chat.tools.BaseTool; import com.microsoft.copilot.eclipse.ui.chat.tools.CreateFileTool; import com.microsoft.copilot.eclipse.ui.chat.tools.EditFileTool; @@ -55,6 +62,8 @@ public class AgentToolService implements ToolInvocationListener, TerminalService protected CopilotLanguageServerConnection lsConnection; private volatile boolean terminalToolsRegistered = false; private List cachedBuiltInTools; + private final ConfirmationService confirmationService; + private final AttachedFileRegistry attachedFileRegistry; /** * Constructor for AgentToolService. @@ -62,6 +71,10 @@ public class AgentToolService implements ToolInvocationListener, TerminalService public AgentToolService(CopilotLanguageServerConnection lsConnection) { this.tools = new ConcurrentHashMap<>(); this.lsConnection = lsConnection; + this.attachedFileRegistry = new AttachedFileRegistry(); + this.confirmationService = new ConfirmationService( + CopilotUi.getPlugin().getPreferenceStore(), + attachedFileRegistry); TerminalServiceManager terminalManager = TerminalServiceManager.getInstance(); if (terminalManager != null) { terminalManager.addListener(this); @@ -243,6 +256,29 @@ public CompletableFuture onToolConfirmation return CompletableFuture.completedFuture(result); } + // Resolve the session conversation ID: map subagent conversations to the + // parent so that session-scoped approvals apply to the whole chat. + String sessionConversationId = params.getConversationId(); + if (boundChatView != null + && !Objects.equals(sessionConversationId, + boundChatView.getConversationId()) + && Objects.equals(sessionConversationId, + boundChatView.getSubagentConversationId())) { + sessionConversationId = boundChatView.getConversationId(); + } + + // Auto-approve evaluation + ConfirmationResult autoApproveResult = + confirmationService.evaluate(params, sessionConversationId); + if (autoApproveResult.isAutoApproved()) { + return CompletableFuture.completedFuture( + new LanguageModelToolConfirmationResult(ToolConfirmationResult.ACCEPT)); + } + if (autoApproveResult.isDismissed()) { + return CompletableFuture.completedFuture( + new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS)); + } + BaseTurnWidget turnWidget = boundChatView.getChatContentViewer().getTurnWidget(params.getTurnId()); if (turnWidget == null) { LanguageModelToolConfirmationResult result = new LanguageModelToolConfirmationResult( @@ -254,13 +290,30 @@ public CompletableFuture onToolConfirmation BaseTurnWidget activeTurnWidget = turnWidget.getActiveTurnWidget(); AtomicReference> ref = new AtomicReference<>(); + ConfirmationContent content = autoApproveResult.getContent(); SwtUtils.invokeOnDisplayThread(() -> { - ref.set( - activeTurnWidget.requestToolExecutionConfirmation(params.getTitle(), params.getMessage(), params.getInput())); + ref.set(activeTurnWidget.requestToolExecutionConfirmation( + content, params.getInput())); boundChatView.getChatContentViewer().refreshScrollerLayout(); }); - return ref.get(); + CompletableFuture future = ref.get(); + if (future != null && content != null) { + // Capture dialog reference before it can be reset by a new request + final InvokeToolConfirmationDialog dialog = + activeTurnWidget.getConfirmDialog(); + final String sessConvId = sessionConversationId; + future = future.thenApply(result -> { + ConfirmationAction selected = dialog != null + ? dialog.getSelectedAction() : null; + if (selected != null && selected.isAccept()) { + confirmationService.cacheDecision(selected, params, + sessConvId); + } + return result; + }); + } + return future; } private boolean validToolConfirmInvokeParams(String conversationId, String turnId) { @@ -283,6 +336,20 @@ private boolean validToolConfirmInvokeParams(String conversationId, String turnI return true; } + /** + * Get the confirmation service for auto-approve evaluation. + * + * @return the confirmation service + */ + public ConfirmationService getConfirmationService() { + return confirmationService; + } + + /** Returns the registry of user-attached context files. */ + public AttachedFileRegistry getAttachedFileRegistry() { + return attachedFileRegistry; + } + /** * Dispose the service. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpConfigService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpConfigService.java index 60ccc202..078faa1e 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpConfigService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpConfigService.java @@ -29,6 +29,7 @@ import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.dialogs.DynamicOauthDialog; import com.microsoft.copilot.eclipse.ui.i18n.Messages; +import com.microsoft.copilot.eclipse.ui.preferences.McpAutoApproveSection; import com.microsoft.copilot.eclipse.ui.preferences.McpPreferencePage; /** @@ -50,6 +51,7 @@ public class McpConfigService extends ChatBaseService implements IMcpConfigServi private ISideEffect mcpToolButtonEnableSideEffect; private ISideEffect mcpToolsButtonRedNoticeSideEffect; private ISideEffect mcpPrefencePageExtMcpTitleRedNoticeSideEffect; + private ISideEffect mcpAutoApproveSideEffect; private IEventBroker eventBroker; @@ -108,6 +110,28 @@ private void initializeMcpFeatureFlagUpdateEvent() { eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_DID_CHANGE_FEATURE_FLAGS, featureFlagNotifiedEventHandler); } + /** + * Bind the observable with UI in McpAutoApproveSection. + */ + public void bindWithAutoApproveSection(McpAutoApproveSection section) { + ensureRealm(() -> { + unbindWithAutoApproveSection(); + mcpAutoApproveSideEffect = ISideEffect.create( + mcpToolsObservableValue::getValue, + section::updateServerCollections); + }); + } + + /** + * Unbind the McpAutoApproveSection side-effect. + */ + public void unbindWithAutoApproveSection() { + if (mcpAutoApproveSideEffect != null) { + mcpAutoApproveSideEffect.dispose(); + mcpAutoApproveSideEffect = null; + } + } + /** * Bind the observable with UI in McpPreferencePage. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/McpServerItem.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/McpServerItem.java index f59f74ec..2c15179e 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/McpServerItem.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/McpServerItem.java @@ -45,6 +45,7 @@ import com.microsoft.copilot.eclipse.ui.dialogs.mcp.McpServerInstallManager.ActionType; import com.microsoft.copilot.eclipse.ui.dialogs.mcp.McpServerInstallManager.ButtonState; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; +import com.microsoft.copilot.eclipse.ui.swt.SplitDropdownButton; import com.microsoft.copilot.eclipse.ui.utils.McpUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; @@ -301,15 +302,17 @@ public void widgetSelected(SelectionEvent e) { disabledInstallButton.setToolTipText(Messages.mcpServerItem_noInstallOptions); actionButton = disabledInstallButton; } else { - DropDownButton dropDownInstallButton = createDropDownInstallButton(actionComposite, initialState, + SplitDropdownButton dropDownInstallButton = createDropDownInstallButton(actionComposite, initialState, installOptions); actionButton = dropDownInstallButton.getButton(); } } } - private DropDownButton createDropDownInstallButton(Composite parent, ButtonState state, List options) { - DropDownButton dropDownInstallButton = new DropDownButton(parent, SWT.NONE); + private SplitDropdownButton createDropDownInstallButton( + Composite parent, ButtonState state, + List options) { + SplitDropdownButton dropDownInstallButton = new SplitDropdownButton(parent, SWT.NONE); dropDownInstallButton.setShowArrow(options.size() > 1); dropDownInstallButton.setText(state.getText()); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddFileOperationRuleDialog.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddFileOperationRuleDialog.java new file mode 100644 index 00000000..cbaa82c1 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddFileOperationRuleDialog.java @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import org.eclipse.jface.dialogs.Dialog; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; + +/** + * Dialog for adding a file-operation auto-approve rule with pattern, description, and allow/deny. + */ +public class AddFileOperationRuleDialog extends Dialog { + + private Text patternText; + private Text descriptionText; + private Button allowRadio; + + private String pattern; + private String description; + private boolean autoApprove; + + /** + * Creates the dialog. + * + * @param parent the parent shell + */ + public AddFileOperationRuleDialog(Shell parent) { + super(parent); + } + + @Override + protected void configureShell(Shell shell) { + super.configureShell(shell); + shell.setText( + Messages.preferences_page_file_op_auto_approve_add_dialog_title); + } + + @Override + protected Control createDialogArea(Composite parent) { + Composite area = (Composite) super.createDialogArea(parent); + Composite container = new Composite(area, SWT.NONE); + container.setLayout(new GridLayout(2, false)); + container.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + + // Pattern * + Label patternLabel = new Label(container, SWT.NONE); + patternLabel.setText( + Messages.preferences_page_file_op_auto_approve_add_dialog_pattern + + " *"); + patternText = new Text(container, SWT.BORDER); + GridData patternData = new GridData(SWT.FILL, SWT.CENTER, true, false); + patternData.widthHint = 300; + patternText.setLayoutData(patternData); + patternText.setMessage( + Messages.preferences_page_file_op_auto_approve_add_dialog_pattern_hint); + patternText.addModifyListener(e -> updateOkButton()); + + // Description + Label descLabel = new Label(container, SWT.NONE); + descLabel.setText( + Messages.preferences_page_file_op_auto_approve_add_dialog_description); + descriptionText = new Text(container, SWT.BORDER); + descriptionText.setMessage( + Messages.preferences_page_file_op_auto_approve_add_dialog_description_hint); + descriptionText.setLayoutData( + new GridData(SWT.FILL, SWT.CENTER, true, false)); + + // Allow / Deny + Label approveLabel = new Label(container, SWT.NONE); + approveLabel.setText( + Messages.preferences_page_auto_approve_add_dialog_approve); + Composite radioGroup = new Composite(container, SWT.NONE); + radioGroup.setLayout(new GridLayout(2, false)); + allowRadio = new Button(radioGroup, SWT.RADIO); + allowRadio.setText(Messages.preferences_page_auto_approve_allow); + allowRadio.setSelection(true); + Button denyRadio = new Button(radioGroup, SWT.RADIO); + denyRadio.setText(Messages.preferences_page_auto_approve_deny); + + return area; + } + + @Override + protected void createButtonsForButtonBar(Composite parent) { + super.createButtonsForButtonBar(parent); + updateOkButton(); + } + + private void updateOkButton() { + Button ok = getButton(OK); + if (ok != null) { + ok.setEnabled( + patternText != null && !patternText.getText().trim().isEmpty()); + } + } + + @Override + protected void okPressed() { + pattern = patternText.getText().trim(); + description = descriptionText.getText().trim(); + autoApprove = allowRadio.getSelection(); + super.okPressed(); + } + + public String getPattern() { + return pattern; + } + + public String getDescription() { + return description; + } + + public boolean isAutoApprove() { + return autoApprove; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddTerminalRuleDialog.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddTerminalRuleDialog.java new file mode 100644 index 00000000..aa4af5de --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddTerminalRuleDialog.java @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import org.eclipse.jface.dialogs.Dialog; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; + +/** + * Dialog for adding a terminal auto-approve rule with command and allow/deny. + */ +public class AddTerminalRuleDialog extends Dialog { + + private Text commandText; + private Button allowRadio; + + private String command; + private boolean autoApprove; + + /** + * Creates the dialog. + * + * @param parent the parent shell + */ + public AddTerminalRuleDialog(Shell parent) { + super(parent); + } + + @Override + protected void configureShell(Shell shell) { + super.configureShell(shell); + shell.setText(Messages.preferences_page_terminal_auto_approve_add_dialog_title); + } + + @Override + protected Control createDialogArea(Composite parent) { + Composite area = (Composite) super.createDialogArea(parent); + Composite container = new Composite(area, SWT.NONE); + container.setLayout(new GridLayout(2, false)); + container.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + + // Command * + Label commandLabel = new Label(container, SWT.NONE); + commandLabel.setText( + Messages.preferences_page_terminal_auto_approve_add_dialog_command + " *"); + commandText = new Text(container, SWT.BORDER); + GridData commandData = new GridData(SWT.FILL, SWT.CENTER, true, false); + commandData.widthHint = 300; + commandText.setLayoutData(commandData); + commandText.setMessage(Messages.preferences_page_terminal_auto_approve_add_dialog_placeholder); + commandText.addModifyListener(e -> updateOkButton()); + + // Allow / Deny + Label approveLabel = new Label(container, SWT.NONE); + approveLabel.setText( + Messages.preferences_page_auto_approve_add_dialog_approve); + Composite radioGroup = new Composite(container, SWT.NONE); + radioGroup.setLayout(new GridLayout(2, false)); + allowRadio = new Button(radioGroup, SWT.RADIO); + allowRadio.setText(Messages.preferences_page_auto_approve_allow); + allowRadio.setSelection(true); + Button denyRadio = new Button(radioGroup, SWT.RADIO); + denyRadio.setText(Messages.preferences_page_auto_approve_deny); + + return area; + } + + @Override + protected void createButtonsForButtonBar(Composite parent) { + super.createButtonsForButtonBar(parent); + updateOkButton(); + } + + private void updateOkButton() { + Button ok = getButton(OK); + if (ok != null) { + ok.setEnabled(commandText != null && !commandText.getText().trim().isEmpty()); + } + } + + @Override + protected void okPressed() { + command = commandText.getText().trim(); + autoApprove = allowRadio.getSelection(); + super.okPressed(); + } + + public String getCommand() { + return command; + } + + public boolean isAutoApprove() { + return autoApprove; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AutoApprovePreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AutoApprovePreferencePage.java new file mode 100644 index 00000000..b4b8b3f9 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AutoApprovePreferencePage.java @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.preference.PreferencePage; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.ui.ISharedImages; +import org.eclipse.ui.IWorkbench; +import org.eclipse.ui.IWorkbenchPreferencePage; +import org.eclipse.ui.PlatformUI; + +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.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.chat.services.McpConfigService; + +/** + * Auto-Approve preference page for terminal and file operation auto-approval rules. + */ +public class AutoApprovePreferencePage extends PreferencePage + implements IWorkbenchPreferencePage { + + public static final String ID = + "com.microsoft.copilot.eclipse.ui.preferences.AutoApprovePreferencePage"; + + private TerminalAutoApproveSection terminalSection; + private FileOperationAutoApproveSection fileOperationSection; + private McpAutoApproveSection mcpSection; + private GlobalAutoApproveSection globalSection; + + @Override + public void init(IWorkbench workbench) { + setPreferenceStore(CopilotUi.getPlugin().getPreferenceStore()); + noDefaultAndApplyButton(); + } + + @Override + protected Control createContents(Composite parent) { + FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); + if (flags != null && !flags.isAutoApprovalEnabled()) { + return WrappableIconLink.createWithSharedImage(parent, + PlatformUI.getWorkbench().getSharedImages() + .getImage(ISharedImages.IMG_OBJS_INFO_TSK), + Messages.preferences_page_auto_approve_disabled_by_organization); + } + + Composite root = new Composite(parent, SWT.NONE); + GridLayout layout = new GridLayout(1, false); + layout.marginWidth = 0; + layout.marginHeight = 0; + root.setLayout(layout); + root.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false)); + + IPreferenceStore store = getPreferenceStore(); + terminalSection = new TerminalAutoApproveSection(root, SWT.NONE); + terminalSection.loadFromPreferences(store); + + fileOperationSection = new FileOperationAutoApproveSection(root, SWT.NONE); + fileOperationSection.loadFromPreferences(store); + + mcpSection = new McpAutoApproveSection(root, SWT.NONE); + mcpSection.loadFromPreferences(store); + bindMcpConfigService(); + + globalSection = new GlobalAutoApproveSection(root, SWT.NONE); + globalSection.loadFromPreferences(store); + + root.addDisposeListener(e -> unbindMcpConfigService()); + + return root; + } + + @Override + public boolean performOk() { + if (terminalSection == null) { + return true; + } + IPreferenceStore store = getPreferenceStore(); + terminalSection.saveToPreferences(store); + fileOperationSection.saveToPreferences(store); + mcpSection.saveToPreferences(store); + globalSection.saveToPreferences(store); + return true; + } + + private void bindMcpConfigService() { + ChatServiceManager chatServiceManager = + CopilotUi.getPlugin().getChatServiceManager(); + if (chatServiceManager != null) { + McpConfigService mcpConfigService = + chatServiceManager.getMcpConfigService(); + if (mcpConfigService != null) { + mcpConfigService.bindWithAutoApproveSection(mcpSection); + } + } + } + + private void unbindMcpConfigService() { + ChatServiceManager chatServiceManager = + CopilotUi.getPlugin().getChatServiceManager(); + if (chatServiceManager != null) { + McpConfigService mcpConfigService = + chatServiceManager.getMcpConfigService(); + if (mcpConfigService != null) { + mcpConfigService.unbindWithAutoApproveSection(); + } + } + } +} 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 65d86abc..6b43717c 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 @@ -3,6 +3,7 @@ package com.microsoft.copilot.eclipse.ui.preferences; +import com.google.gson.Gson; import org.eclipse.core.runtime.preferences.AbstractPreferenceInitializer; import org.eclipse.core.runtime.preferences.ConfigurationScope; import org.eclipse.core.runtime.preferences.IEclipsePreferences; @@ -11,6 +12,8 @@ import com.microsoft.copilot.eclipse.core.Constants; import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope; import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.FileOperationConfirmationHandler; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.TerminalConfirmationHandler; /** * A class to initialize the default preferences for the plugin. @@ -60,6 +63,18 @@ public void initializeDefaultPreferences() { """); pref.setDefault(Constants.MCP_TOOLS_STATUS, "{}"); + // Auto-approve defaults + pref.setDefault(Constants.AUTO_APPROVE_TERMINAL_RULES, + new Gson().toJson(TerminalConfirmationHandler.DEFAULT_RULES)); + pref.setDefault(Constants.AUTO_APPROVE_UNMATCHED_TERMINAL, false); + pref.setDefault(Constants.AUTO_APPROVE_FILE_OP_RULES, + new Gson().toJson(FileOperationConfirmationHandler.FALLBACK_DEFAULT_RULES)); + pref.setDefault(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP, true); + pref.setDefault(Constants.AUTO_APPROVE_MCP_SERVERS, "[]"); + pref.setDefault(Constants.AUTO_APPROVE_MCP_TOOLS, "[]"); + pref.setDefault(Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS, false); + pref.setDefault(Constants.AUTO_APPROVE_YOLO_MODE, false); + IEclipsePreferences configPrefs = ConfigurationScope.INSTANCE .getNode(CopilotUi.getPlugin().getBundle().getSymbolicName()); boolean autoShowWhatsNew = configPrefs.getBoolean(Constants.AUTO_SHOW_WHAT_IS_NEW, true); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferencesPage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferencesPage.java index 473c9313..a6e6da99 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferencesPage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferencesPage.java @@ -38,6 +38,8 @@ protected Control createContents(Composite parent) { McpPreferencePage.ID); PreferencePageUtils.createPreferenceLink(getShell(), container, "Model Management", null, ByokPreferencePage.ID); + PreferencePageUtils.createPreferenceLink(getShell(), container, "Tool Auto Approve", null, + AutoApprovePreferencePage.ID); return container; } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java new file mode 100644 index 00000000..93bc9d21 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java @@ -0,0 +1,496 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.viewers.ArrayContentProvider; +import org.eclipse.jface.viewers.ColumnLabelProvider; +import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.jface.viewers.TableViewer; +import org.eclipse.jface.viewers.TableViewerColumn; +import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Table; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.FileOperationAutoApproveRule; +import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FileSafetyRuleInfo; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.FileOperationConfirmationHandler; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; + +/** + * File-operation auto-approve section with a rule table, action buttons, and + * unmatched-file-operation checkbox. + * + *

Default rules are fetched from CLS asynchronously and merged with + * local fallback defaults + * ({@link FileOperationConfirmationHandler#FALLBACK_DEFAULT_RULES}). + * Default rules cannot be removed; only user-added rules can be removed.

+ */ +public class FileOperationAutoApproveSection extends Composite { + + private static final int TABLE_HEIGHT_HINT = 200; + private static final Type FILE_OP_RULES_TYPE = + new TypeToken>() {}.getType(); + + private TableViewer tableViewer; + private final List defaultRules = + new ArrayList<>(); + private final List userRules = + new ArrayList<>(); + /** Combined view (defaults + user) shown in the table. */ + private final List allRules = + new ArrayList<>(); + private boolean defaultRulesLoaded; + private Button removeButton; + private Button toggleButton; + private Button resetButton; + private Button unmatchedCheckbox; + + /** Creates the file-operation auto-approve section inside the given parent. */ + public FileOperationAutoApproveSection(Composite parent, int style) { + super(parent, style); + setLayout(new GridLayout(1, false)); + setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + createContents(); + } + + private void createContents() { + Group group = new Group(this, SWT.NONE); + group.setText(Messages.preferences_page_file_op_auto_approve_title); + group.setLayout(new GridLayout(1, false)); + group.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + group.setBackgroundMode(SWT.INHERIT_FORCE); + + Label description = new Label(group, SWT.WRAP); + description.setText( + Messages.preferences_page_file_op_auto_approve_description); + GridData descData = new GridData(SWT.FILL, SWT.TOP, true, false); + descData.widthHint = 400; + description.setLayoutData(descData); + + Composite container = new Composite(group, SWT.NONE); + container.setLayout(new GridLayout(2, false)); + container.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + + createTable(container); + createButtons(container); + + unmatchedCheckbox = new Button(group, SWT.CHECK); + unmatchedCheckbox.setText( + Messages.preferences_page_file_op_auto_approve_unmatched); + unmatchedCheckbox.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + + new WrappableNoteLabel(group, + Messages.preferences_page_note_prefix + " ", + Messages.preferences_page_file_op_auto_approve_unmatched_note); + } + + private void createTable(Composite parent) { + tableViewer = new TableViewer(parent, + SWT.BORDER | SWT.FULL_SELECTION | SWT.SINGLE); + Table table = tableViewer.getTable(); + GridData tableData = new GridData(SWT.FILL, SWT.FILL, true, false); + tableData.heightHint = TABLE_HEIGHT_HINT; + table.setLayoutData(tableData); + table.setHeaderVisible(true); + table.setLinesVisible(true); + + TableViewerColumn patternCol = + new TableViewerColumn(tableViewer, SWT.NONE); + patternCol.getColumn().setText( + Messages.preferences_page_file_op_auto_approve_column_pattern); + patternCol.getColumn().setWidth(200); + patternCol.setLabelProvider(new ColumnLabelProvider() { + @Override + public String getText(Object element) { + return ((FileOperationAutoApproveRule) element).getPattern(); + } + + @Override + public Color getForeground(Object element) { + return ((FileOperationAutoApproveRule) element).isDefault() + ? Display.getDefault().getSystemColor(SWT.COLOR_DARK_GRAY) : null; + } + }); + + TableViewerColumn descCol = + new TableViewerColumn(tableViewer, SWT.NONE); + descCol.getColumn().setText( + Messages.preferences_page_file_op_auto_approve_column_description); + descCol.getColumn().setWidth(150); + descCol.setLabelProvider(new ColumnLabelProvider() { + @Override + public String getText(Object element) { + String desc = + ((FileOperationAutoApproveRule) element).getDescription(); + return desc != null ? desc : ""; + } + + @Override + public Color getForeground(Object element) { + return ((FileOperationAutoApproveRule) element).isDefault() + ? Display.getDefault().getSystemColor(SWT.COLOR_DARK_GRAY) : null; + } + }); + + TableViewerColumn statusCol = + new TableViewerColumn(tableViewer, SWT.NONE); + statusCol.getColumn().setText( + Messages.preferences_page_auto_approve_column_status); + statusCol.getColumn().setWidth(100); + statusCol.setLabelProvider(new ColumnLabelProvider() { + @Override + public String getText(Object element) { + return ((FileOperationAutoApproveRule) element).isAutoApprove() + ? Messages.preferences_page_auto_approve_allow + : Messages.preferences_page_auto_approve_deny; + } + + @Override + public Color getForeground(Object element) { + return ((FileOperationAutoApproveRule) element).isDefault() + ? Display.getDefault().getSystemColor(SWT.COLOR_DARK_GRAY) : null; + } + }); + + tableViewer.setContentProvider(ArrayContentProvider.getInstance()); + tableViewer.addSelectionChangedListener(e -> updateButtonState()); + } + + private void createButtons(Composite parent) { + Composite btnGroup = new Composite(parent, SWT.NONE); + btnGroup.setLayout(new GridLayout(1, false)); + btnGroup.setLayoutData( + new GridData(SWT.BEGINNING, SWT.BEGINNING, false, false)); + + Button addButton = new Button(btnGroup, SWT.PUSH); + addButton.setText(Messages.preferences_page_auto_approve_add); + addButton.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false)); + addButton.addListener(SWT.Selection, e -> onAdd()); + + removeButton = new Button(btnGroup, SWT.PUSH); + removeButton.setText( + Messages.preferences_page_auto_approve_remove); + removeButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + removeButton.setEnabled(false); + removeButton.addListener(SWT.Selection, e -> onRemove()); + + toggleButton = new Button(btnGroup, SWT.PUSH); + toggleButton.setText( + Messages.preferences_page_auto_approve_allow); + toggleButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + toggleButton.setEnabled(false); + toggleButton.addListener(SWT.Selection, e -> onToggle()); + + resetButton = new Button(btnGroup, SWT.PUSH); + resetButton.setText( + Messages.preferences_page_auto_approve_reset); + resetButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + resetButton.addListener(SWT.Selection, e -> onResetToDefaults()); + } + + private void onAdd() { + AddFileOperationRuleDialog dialog = + new AddFileOperationRuleDialog(getShell()); + if (dialog.open() == AddFileOperationRuleDialog.OK) { + String pattern = dialog.getPattern(); + if (isPatternExists(pattern)) { + MessageDialog.openWarning(getShell(), + Messages.preferences_page_file_op_auto_approve_duplicate_title, + Messages.preferences_page_file_op_auto_approve_duplicate_message); + return; + } + userRules.add(new FileOperationAutoApproveRule( + pattern, dialog.getDescription(), dialog.isAutoApprove())); + rebuildAllRules(); + tableViewer.refresh(); + updateButtonState(); + } + } + + private boolean isPatternExists(String pattern) { + return defaultRules.stream() + .anyMatch(r -> r.getPattern().equals(pattern)) + || userRules.stream() + .anyMatch(r -> r.getPattern().equals(pattern)); + } + + private void onRemove() { + IStructuredSelection sel = tableViewer.getStructuredSelection(); + if (!sel.isEmpty()) { + FileOperationAutoApproveRule rule = + (FileOperationAutoApproveRule) sel.getFirstElement(); + if (!rule.isDefault()) { + userRules.remove(rule); + rebuildAllRules(); + tableViewer.refresh(); + updateButtonState(); + } + } + } + + private void onToggle() { + IStructuredSelection sel = tableViewer.getStructuredSelection(); + if (!sel.isEmpty()) { + FileOperationAutoApproveRule rule = + (FileOperationAutoApproveRule) sel.getFirstElement(); + if (!rule.isDefault()) { + rule.setAutoApprove(!rule.isAutoApprove()); + tableViewer.refresh(); + updateButtonState(); + } + } + } + + private void onResetToDefaults() { + boolean confirmed = MessageDialog.openQuestion(getShell(), + Messages.preferences_page_file_op_auto_approve_reset_title, + Messages.preferences_page_auto_approve_reset_message); + if (confirmed) { + userRules.clear(); + resetDefaultRulesToOriginal(); + rebuildAllRules(); + tableViewer.refresh(); + updateButtonState(); + } + } + + /** + * Resets default rules' autoApprove to their original values. + * CLS defaults use {@code requiresConfirmation=true} → {@code autoApprove=false}. + * Local fallback defaults are already {@code autoApprove=false}. + */ + private void resetDefaultRulesToOriginal() { + for (FileOperationAutoApproveRule rule : defaultRules) { + rule.setAutoApprove(false); + } + } + + private void updateButtonState() { + boolean hasSelection = + !tableViewer.getStructuredSelection().isEmpty(); + FileOperationAutoApproveRule selected = hasSelection + ? (FileOperationAutoApproveRule) tableViewer + .getStructuredSelection().getFirstElement() + : null; + boolean isDefault = selected != null && selected.isDefault(); + + removeButton.setEnabled(hasSelection && !isDefault); + toggleButton.setEnabled(hasSelection && !isDefault); + if (hasSelection) { + toggleButton.setText(selected.isAutoApprove() + ? Messages.preferences_page_auto_approve_deny + : Messages.preferences_page_auto_approve_allow); + } else { + toggleButton.setText( + Messages.preferences_page_auto_approve_allow); + } + resetButton.setEnabled(!isMatchingDefaults()); + } + + /** + * Checks whether the current rule set matches defaults exactly + * (no user rules, all defaults at original autoApprove values). + */ + private boolean isMatchingDefaults() { + if (!userRules.isEmpty()) { + return false; + } + for (FileOperationAutoApproveRule rule : defaultRules) { + if (rule.isAutoApprove()) { + return false; + } + } + return true; + } + + /** Loads file-operation rules and unmatched-file-operation preference from the store. */ + public void loadFromPreferences(IPreferenceStore store) { + List savedRules = parseSavedRules(store); + + // Initialize with local fallback defaults + applyFallbackDefaults(); + + // Separate saved rules into defaults (override autoApprove) and user + Set defaultPatterns = defaultRules.stream() + .map(FileOperationAutoApproveRule::getPattern) + .collect(Collectors.toSet()); + userRules.clear(); + for (FileOperationAutoApproveRule saved : savedRules) { + if (defaultPatterns.contains(saved.getPattern())) { + // Restore toggled autoApprove for default rules + defaultRules.stream() + .filter(d -> d.getPattern().equals(saved.getPattern())) + .findFirst() + .ifPresent(d -> d.setAutoApprove(saved.isAutoApprove())); + } else { + userRules.add(saved); + } + } + + rebuildAllRules(); + tableViewer.setInput(allRules); + + unmatchedCheckbox.setSelection( + store.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)); + updateButtonState(); + + fetchDefaultRulesFromCls(); + } + + private List parseSavedRules( + IPreferenceStore store) { + String json = + store.getString(Constants.AUTO_APPROVE_FILE_OP_RULES); + if (StringUtils.isNotBlank(json) && !"[]".equals(json.trim())) { + try { + List loaded = + new Gson().fromJson(json, FILE_OP_RULES_TYPE); + if (loaded != null) { + return loaded; + } + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Failed to parse file operation auto-approve rules", e); + } + } + return List.of(); + } + + private void applyFallbackDefaults() { + defaultRules.clear(); + for (FileOperationAutoApproveRule fallback + : FileOperationConfirmationHandler.FALLBACK_DEFAULT_RULES) { + defaultRules.add(new FileOperationAutoApproveRule( + fallback.getPattern(), fallback.getDescription(), + fallback.isAutoApprove(), true)); + } + } + + /** + * Fetches default file safety rules from CLS asynchronously. + * On success, merges CLS rules with local fallback and updates + * the table. On failure, keeps local fallback defaults. + */ + private void fetchDefaultRulesFromCls() { + CopilotLanguageServerConnection conn = + CopilotCore.getPlugin().getCopilotLanguageServer(); + if (conn == null) { + return; + } + conn.getDefaultFileSafetyRules().thenAccept(result -> { + if (result == null || result.getDefaultRules() == null) { + return; + } + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (isDisposed() || defaultRulesLoaded) { + return; + } + applyClsDefaults(result.getDefaultRules()); + }, FileOperationAutoApproveSection.this); + }).exceptionally(ex -> { + // CLS not available — keep local fallback defaults + CopilotCore.LOGGER.error( + "Failed to fetch default file safety rules from CLS", ex); + return null; + }); + } + + /** + * Applies CLS-provided default rules, merging with local fallback. + * CLS rules take priority; local fallbacks fill gaps. + */ + private void applyClsDefaults(List clsRules) { + // Preserve any user-toggled autoApprove on existing defaults + Map toggledDefaults = new HashMap<>(); + for (FileOperationAutoApproveRule existing : defaultRules) { + toggledDefaults.put(existing.getPattern(), existing.isAutoApprove()); + } + + defaultRules.clear(); + Set clsPatterns = new HashSet<>(); + for (FileSafetyRuleInfo clsRule : clsRules) { + String pattern = clsRule.getPattern(); + clsPatterns.add(pattern); + boolean autoApprove = !clsRule.isRequiresConfirmation(); + // Restore user toggle if they had changed this default + if (toggledDefaults.containsKey(pattern)) { + autoApprove = toggledDefaults.get(pattern); + } + String desc = clsRule.getDescription() != null + ? clsRule.getDescription() : ""; + defaultRules.add(new FileOperationAutoApproveRule( + pattern, desc, autoApprove, true)); + } + + // Add local fallbacks for patterns not in CLS + for (FileOperationAutoApproveRule fallback + : FileOperationConfirmationHandler.FALLBACK_DEFAULT_RULES) { + if (!clsPatterns.contains(fallback.getPattern())) { + boolean autoApprove = fallback.isAutoApprove(); + if (toggledDefaults.containsKey(fallback.getPattern())) { + autoApprove = toggledDefaults.get(fallback.getPattern()); + } + defaultRules.add(new FileOperationAutoApproveRule( + fallback.getPattern(), fallback.getDescription(), + autoApprove, true)); + } + } + + // Remove user rules that overlap with new defaults + Set allDefaultPatterns = defaultRules.stream() + .map(FileOperationAutoApproveRule::getPattern) + .collect(Collectors.toSet()); + userRules.removeIf(r -> allDefaultPatterns.contains(r.getPattern())); + + defaultRulesLoaded = true; + rebuildAllRules(); + tableViewer.refresh(); + updateButtonState(); + } + + /** Rebuilds the combined list shown in the table. */ + private void rebuildAllRules() { + allRules.clear(); + allRules.addAll(defaultRules); + allRules.addAll(userRules); + } + + /** Saves file-operation rules and unmatched-file-operation preference to the store. */ + public void saveToPreferences(IPreferenceStore store) { + // Save all rules (defaults + user) to preferences. + // On next load, defaults are re-identified by pattern matching. + store.setValue(Constants.AUTO_APPROVE_FILE_OP_RULES, + new Gson().toJson(allRules)); + store.setValue(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP, + unmatchedCheckbox.getSelection()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GlobalAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GlobalAutoApproveSection.java new file mode 100644 index 00000000..3aaa8c7a --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GlobalAutoApproveSection.java @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; +import org.eclipse.ui.ISharedImages; +import org.eclipse.ui.PlatformUI; + +import com.microsoft.copilot.eclipse.core.Constants; + +/** + * Global auto-approve preference section with a YOLO mode checkbox + * that bypasses all confirmation dialogs when enabled. + */ +public class GlobalAutoApproveSection extends Composite { + + private static final int TOOLTIP_LINE_LENGTH = 90; + + private Button yoloCheckbox; + + /** Creates the global auto-approve section inside the given parent. */ + public GlobalAutoApproveSection(Composite parent, int style) { + super(parent, style); + setLayout(new GridLayout(1, false)); + setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + createContents(); + } + + private void createContents() { + Group group = new Group(this, SWT.NONE); + group.setText(Messages.preferences_page_global_auto_approve_title); + group.setLayout(new GridLayout(1, false)); + group.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + group.setBackgroundMode(SWT.INHERIT_FORCE); + + Composite yoloRow = new Composite(group, SWT.NONE); + GridLayout yoloRowLayout = new GridLayout(2, false); + yoloRowLayout.marginWidth = 0; + yoloRowLayout.marginHeight = 0; + yoloRow.setLayout(yoloRowLayout); + yoloRow.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false)); + + yoloCheckbox = new Button(yoloRow, SWT.CHECK); + yoloCheckbox.setText( + Messages.preferences_page_global_auto_approve_label); + yoloCheckbox.setLayoutData( + new GridData(SWT.LEFT, SWT.CENTER, false, false)); + yoloCheckbox.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + if (yoloCheckbox.getSelection()) { + MessageDialog dialog = new MessageDialog( + getShell(), + Messages.preferences_page_global_auto_approve_confirm_title, + null, + Messages.preferences_page_global_auto_approve_confirm_message, + MessageDialog.WARNING, + new String[] { + Messages.preferences_page_global_auto_approve_confirm_button, + Messages.preferences_page_global_auto_approve_cancel_button + }, + 1); + int result = dialog.open(); + if (result != 0) { + yoloCheckbox.setSelection(false); + } + } + } + }); + + Label warningIcon = new Label(yoloRow, SWT.NONE); + warningIcon.setImage(PlatformUI.getWorkbench().getSharedImages() + .getImage(ISharedImages.IMG_OBJS_WARN_TSK)); + warningIcon.setToolTipText(wrapTooltip( + Messages.preferences_page_global_auto_approve_confirm_message)); + warningIcon.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false)); + + new WrappableNoteLabel(group, + Messages.preferences_page_note_prefix + " ", + Messages.preferences_page_global_auto_approve_confirm_message); + } + + /** Loads global auto-approve settings from the preference store. */ + public void loadFromPreferences(IPreferenceStore store) { + yoloCheckbox.setSelection( + store.getBoolean(Constants.AUTO_APPROVE_YOLO_MODE)); + } + + /** Saves global auto-approve settings to the preference store. */ + public void saveToPreferences(IPreferenceStore store) { + store.setValue(Constants.AUTO_APPROVE_YOLO_MODE, + yoloCheckbox.getSelection()); + } + + private static String wrapTooltip(String text) { + StringBuilder wrapped = new StringBuilder(text.length()); + int lineLength = 0; + for (String word : text.split(" ")) { + if (lineLength > 0 && lineLength + word.length() + 1 > TOOLTIP_LINE_LENGTH) { + wrapped.append('\n'); + lineLength = 0; + } else if (lineLength > 0) { + wrapped.append(' '); + lineLength++; + } + wrapped.append(word); + lineLength += word.length(); + } + return wrapped.toString(); + } +} 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 d04cc224..e3b78dad 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 @@ -5,10 +5,12 @@ import java.net.URI; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; +import com.google.gson.Gson; import com.google.gson.JsonSyntaxException; import com.google.gson.reflect.TypeToken; import org.apache.commons.lang3.StringUtils; @@ -27,6 +29,8 @@ 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.chat.FileOperationAutoApproveRule; +import com.microsoft.copilot.eclipse.core.chat.TerminalAutoApproveRule; import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; import com.microsoft.copilot.eclipse.core.lsp.mcp.McpServerToolsStatusCollection; @@ -92,6 +96,14 @@ public LanguageServerSettingManager(CopilotLanguageServerConnection conn, IProxy // Set transcript directory for CLS session persistence and restoration getSettings().getGithubSettings().getCopilotSettings().getAgent() .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + getSettings().getGithubSettings().getCopilotSettings().getAgent() + .setAutoApproveUnmatchedTerminal( + preferenceStore.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_TERMINAL)); + getSettings().getGithubSettings().getCopilotSettings().getAgent() + .setAutoApproveUnmatchedFileOp( + preferenceStore.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)); + syncTerminalRulesToCls(); + syncFileOperationRulesToCls(); // Set workspace context instructions when it is enabled if (preferenceStore.getBoolean(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE_ENABLED)) { @@ -183,6 +195,26 @@ public void propertyChange(PropertyChangeEvent event) { .setEnableSkills(PreferencesUtils.isSkillsEnabled()); singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); break; + case Constants.AUTO_APPROVE_UNMATCHED_TERMINAL: + settings.getGithubSettings().getCopilotSettings().getAgent() + .setAutoApproveUnmatchedTerminal( + preferenceStore.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_TERMINAL)); + singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); + break; + case Constants.AUTO_APPROVE_TERMINAL_RULES: + syncTerminalRulesToCls(); + singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); + break; + case Constants.AUTO_APPROVE_UNMATCHED_FILE_OP: + settings.getGithubSettings().getCopilotSettings().getAgent() + .setAutoApproveUnmatchedFileOp( + preferenceStore.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)); + singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); + break; + case Constants.AUTO_APPROVE_FILE_OP_RULES: + syncFileOperationRulesToCls(); + singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); + break; default: return; } @@ -233,6 +265,54 @@ public void syncMcpRegistrationConfiguration() { syncSingleConfiguration(new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings())); } + /** + * Converts terminal auto-approve rules from preference store JSON to the Map format + * expected by CLS and syncs them. + */ + private void syncTerminalRulesToCls() { + String json = preferenceStore.getString(Constants.AUTO_APPROVE_TERMINAL_RULES); + Map rulesMap = new LinkedHashMap<>(); + if (StringUtils.isNotBlank(json)) { + try { + List rules = + new Gson().fromJson(json, + new TypeToken>() { + }.getType()); + if (rules != null) { + rules.forEach(r -> rulesMap.put(r.getCommand(), r.isAutoApprove())); + } + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to parse terminal rules for CLS sync", e); + } + } + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getTerminal().setAutoApprove(rulesMap); + } + + /** + * Converts file-operation auto-approve rules from preference store JSON to the Map format + * expected by CLS and syncs them. + */ + private void syncFileOperationRulesToCls() { + String json = preferenceStore.getString(Constants.AUTO_APPROVE_FILE_OP_RULES); + Map rulesMap = new LinkedHashMap<>(); + if (StringUtils.isNotBlank(json)) { + try { + List rules = + new Gson().fromJson(json, + new TypeToken>() { + }.getType()); + if (rules != null) { + rules.forEach(r -> rulesMap.put(r.getPattern(), r.isAutoApprove())); + } + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to parse file-operation rules for CLS sync", e); + } + } + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getEdit().setAutoApprove(rulesMap); + } + /** * Initializes the MCP tools status from the preference store for built-in agent mode only. * Custom agent modes get their tool configuration from the LSP/file, not from preferences. diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java new file mode 100644 index 00000000..bc52d63e --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java @@ -0,0 +1,382 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.viewers.CheckStateChangedEvent; +import org.eclipse.jface.viewers.CheckboxTreeViewer; +import org.eclipse.jface.viewers.ICheckStateListener; +import org.eclipse.jface.viewers.ITreeContentProvider; +import org.eclipse.jface.viewers.LabelProvider; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.lsp.mcp.McpServerToolsCollection; +import com.microsoft.copilot.eclipse.core.lsp.mcp.McpToolInformation; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; + +/** + * MCP auto-approve preference section with a trust-annotations checkbox + * and a tree viewer for per-server/tool approval management. + */ +public class McpAutoApproveSection extends Composite { + + private static final int TREE_HEIGHT_HINT = 200; + private static final Type STRING_LIST_TYPE = + new TypeToken>() {}.getType(); + + private Button trustAnnotationsCheckbox; + private CheckboxTreeViewer treeViewer; + private Group group; + + private List serverCollections = + new ArrayList<>(); + + // Current check state (lowercased for case-insensitive matching) + private final Set checkedServers = new HashSet<>(); + private final Set checkedTools = new HashSet<>(); + + /** Creates the MCP auto-approve section inside the given parent. */ + public McpAutoApproveSection(Composite parent, int style) { + super(parent, style); + setLayout(new GridLayout(1, false)); + setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + createContents(); + } + + private void createContents() { + group = new Group(this, SWT.NONE); + group.setText(Messages.preferences_page_mcp_auto_approve_title); + group.setLayout(new GridLayout(1, false)); + group.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + group.setBackgroundMode(SWT.INHERIT_FORCE); + + // Trust annotations checkbox + trustAnnotationsCheckbox = new Button(group, SWT.CHECK); + trustAnnotationsCheckbox.setText( + Messages.preferences_page_mcp_auto_approve_trust_annotations); + trustAnnotationsCheckbox.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + + new WrappableNoteLabel(group, + Messages.preferences_page_note_prefix + " ", + Messages.preferences_page_mcp_auto_approve_trust_annotations_note); + + // Server/tool approval label + Label serverToolsLabel = new Label(group, SWT.NONE); + serverToolsLabel.setText( + Messages.preferences_page_mcp_auto_approve_server_tools_label); + GridData labelData = new GridData(SWT.FILL, SWT.TOP, true, false); + serverToolsLabel.setLayoutData(labelData); + + // Tree viewer for server/tool approval + treeViewer = new CheckboxTreeViewer(group, + SWT.BORDER | SWT.FULL_SELECTION); + GridData treeData = new GridData(SWT.FILL, SWT.FILL, true, false); + treeData.heightHint = TREE_HEIGHT_HINT; + treeViewer.getTree().setLayoutData(treeData); + + treeViewer.setContentProvider(new McpTreeContentProvider()); + treeViewer.setLabelProvider(new McpTreeLabelProvider()); + treeViewer.addCheckStateListener(new McpCheckStateListener()); + treeViewer.setInput(serverCollections); + } + + /** Loads MCP auto-approve settings from the preference store. */ + public void loadFromPreferences(IPreferenceStore store) { + trustAnnotationsCheckbox.setSelection( + store.getBoolean(Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS)); + + checkedServers.clear(); + checkedTools.clear(); + + List servers = loadJsonList(store, + Constants.AUTO_APPROVE_MCP_SERVERS); + for (String s : servers) { + checkedServers.add(s.toLowerCase(Locale.ROOT)); + } + + List tools = loadJsonList(store, + Constants.AUTO_APPROVE_MCP_TOOLS); + for (String t : tools) { + checkedTools.add(t.toLowerCase(Locale.ROOT)); + } + + refreshTreeCheckState(); + } + + /** Saves MCP auto-approve settings to the preference store. */ + public void saveToPreferences(IPreferenceStore store) { + store.setValue(Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS, + trustAnnotationsCheckbox.getSelection()); + + store.setValue(Constants.AUTO_APPROVE_MCP_SERVERS, + new Gson().toJson(new ArrayList<>(checkedServers))); + store.setValue(Constants.AUTO_APPROVE_MCP_TOOLS, + new Gson().toJson(new ArrayList<>(checkedTools))); + } + + /** + * Updates the server/tool collections displayed in the tree viewer. + * Called from the MCP config service when server data changes. + */ + public void updateServerCollections( + List collections) { + if (isDisposed()) { + return; + } + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (isDisposed()) { + return; + } + this.serverCollections = collections != null + ? collections : Collections.emptyList(); + treeViewer.setInput(serverCollections); + refreshTreeCheckState(); + requestLayout(); + }, this); + } + + private void refreshTreeCheckState() { + if (treeViewer.getTree().isDisposed()) { + return; + } + for (McpServerToolsCollection server : serverCollections) { + // Expand to ensure child TreeItems exist before setChecked + treeViewer.expandToLevel(server, 1); + + String serverLower = server.getName() != null + ? server.getName().toLowerCase(Locale.ROOT) : ""; + boolean serverChecked = checkedServers.contains(serverLower); + + List tools = server.getTools(); + if (tools == null) { + tools = Collections.emptyList(); + } + + if (serverChecked) { + // Server is approved: check server and all its tools + treeViewer.setChecked(server, true); + treeViewer.setGrayed(server, false); + for (McpToolInformation tool : tools) { + treeViewer.setChecked(tool, true); + } + } else { + // Check individual tools + int checkedCount = 0; + for (McpToolInformation tool : tools) { + String toolKey = serverLower + "::" + + (tool.getName() != null + ? tool.getName().toLowerCase(Locale.ROOT) : ""); + boolean toolChecked = checkedTools.contains(toolKey); + treeViewer.setChecked(tool, toolChecked); + if (toolChecked) { + checkedCount++; + } + } + // Update parent check/gray state + if (checkedCount == 0) { + treeViewer.setChecked(server, false); + treeViewer.setGrayed(server, false); + } else if (checkedCount == tools.size()) { + treeViewer.setChecked(server, true); + treeViewer.setGrayed(server, false); + } else { + treeViewer.setChecked(server, true); + treeViewer.setGrayed(server, true); + } + } + } + } + + private static List loadJsonList(IPreferenceStore store, + String key) { + String json = store.getString(key); + if (StringUtils.isBlank(json) || "[]".equals(json.trim())) { + return Collections.emptyList(); + } + try { + List list = new Gson().fromJson(json, STRING_LIST_TYPE); + return list != null ? list : Collections.emptyList(); + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Failed to parse MCP auto-approve list: " + key, e); + return Collections.emptyList(); + } + } + + /** Content provider for the server/tool tree. */ + private static class McpTreeContentProvider + implements ITreeContentProvider { + + @Override + public Object[] getElements(Object inputElement) { + if (inputElement instanceof List list) { + return list.toArray(); + } + return new Object[0]; + } + + @Override + public Object[] getChildren(Object parentElement) { + if (parentElement instanceof McpServerToolsCollection server) { + List tools = server.getTools(); + return tools != null ? tools.toArray() : new Object[0]; + } + return new Object[0]; + } + + @Override + public Object getParent(Object element) { + return null; + } + + @Override + public boolean hasChildren(Object element) { + if (element instanceof McpServerToolsCollection server) { + List tools = server.getTools(); + return tools != null && !tools.isEmpty(); + } + return false; + } + } + + /** Label provider for the server/tool tree. */ + private static class McpTreeLabelProvider extends LabelProvider { + @Override + public String getText(Object element) { + if (element instanceof McpServerToolsCollection server) { + return server.getName() != null ? server.getName() : ""; + } + if (element instanceof McpToolInformation tool) { + return tool.getName() != null ? tool.getName() : ""; + } + return ""; + } + } + + /** Handles check state changes in the server/tool tree. */ + private class McpCheckStateListener implements ICheckStateListener { + @Override + public void checkStateChanged(CheckStateChangedEvent event) { + Object element = event.getElement(); + boolean checked = event.getChecked(); + + if (element instanceof McpServerToolsCollection server) { + handleServerCheckChanged(server, checked); + } else if (element instanceof McpToolInformation tool) { + handleToolCheckChanged(tool, checked); + } + } + + private void handleServerCheckChanged( + McpServerToolsCollection server, boolean checked) { + String serverLower = server.getName() != null + ? server.getName().toLowerCase(Locale.ROOT) : ""; + treeViewer.setGrayed(server, false); + + if (checked) { + checkedServers.add(serverLower); + } else { + checkedServers.remove(serverLower); + } + + // Check/uncheck all children + List tools = server.getTools(); + if (tools != null) { + for (McpToolInformation tool : tools) { + treeViewer.setChecked(tool, checked); + String toolKey = serverLower + "::" + + (tool.getName() != null + ? tool.getName().toLowerCase(Locale.ROOT) : ""); + if (checked) { + checkedTools.add(toolKey); + } else { + checkedTools.remove(toolKey); + } + } + } + } + + private void handleToolCheckChanged( + McpToolInformation tool, boolean checked) { + // Find parent server + McpServerToolsCollection parentServer = findParentServer(tool); + if (parentServer == null) { + return; + } + String serverLower = parentServer.getName() != null + ? parentServer.getName().toLowerCase(Locale.ROOT) : ""; + String toolKey = serverLower + "::" + + (tool.getName() != null + ? tool.getName().toLowerCase(Locale.ROOT) : ""); + + if (checked) { + checkedTools.add(toolKey); + } else { + checkedTools.remove(toolKey); + } + + // Update parent check/gray state + updateParentState(parentServer); + } + + private void updateParentState(McpServerToolsCollection server) { + String serverLower = server.getName() != null + ? server.getName().toLowerCase(Locale.ROOT) : ""; + List tools = server.getTools(); + if (tools == null || tools.isEmpty()) { + return; + } + int checkedCount = 0; + for (McpToolInformation tool : tools) { + if (treeViewer.getChecked(tool)) { + checkedCount++; + } + } + if (checkedCount == 0) { + treeViewer.setChecked(server, false); + treeViewer.setGrayed(server, false); + checkedServers.remove(serverLower); + } else if (checkedCount == tools.size()) { + treeViewer.setChecked(server, true); + treeViewer.setGrayed(server, false); + checkedServers.add(serverLower); + } else { + treeViewer.setChecked(server, true); + treeViewer.setGrayed(server, true); + checkedServers.remove(serverLower); + } + } + + private McpServerToolsCollection findParentServer( + McpToolInformation tool) { + for (McpServerToolsCollection server : serverCollections) { + List tools = server.getTools(); + if (tools != null && tools.contains(tool)) { + return server; + } + } + return null; + } + } +} 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 918b1e74..7582fd7a 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 @@ -154,6 +154,60 @@ public class Messages extends NLS { public static String setting_managed_by_organization; public static String setting_disabled_by_organization; + // Shared Auto-Approve strings + public static String preferences_page_auto_approve_disabled_by_organization; + public static String preferences_page_auto_approve_column_status; + public static String preferences_page_auto_approve_add; + public static String preferences_page_auto_approve_remove; + public static String preferences_page_auto_approve_reset; + public static String preferences_page_auto_approve_reset_message; + public static String preferences_page_auto_approve_allow; + public static String preferences_page_auto_approve_deny; + public static String preferences_page_auto_approve_add_dialog_approve; + + // Terminal Auto-Approve + public static String preferences_page_terminal_auto_approve_title; + public static String preferences_page_terminal_auto_approve_description; + public static String preferences_page_terminal_auto_approve_column_command; + public static String preferences_page_terminal_auto_approve_reset_title; + public static String preferences_page_terminal_auto_approve_unmatched; + public static String preferences_page_terminal_auto_approve_unmatched_note; + public static String preferences_page_terminal_auto_approve_add_dialog_title; + public static String preferences_page_terminal_auto_approve_add_dialog_message; + public static String preferences_page_terminal_auto_approve_add_dialog_command; + public static String preferences_page_terminal_auto_approve_add_dialog_placeholder; + + // File Operations Auto Approve + public static String preferences_page_file_op_auto_approve_title; + public static String preferences_page_file_op_auto_approve_description; + public static String preferences_page_file_op_auto_approve_column_pattern; + public static String preferences_page_file_op_auto_approve_column_description; + public static String preferences_page_file_op_auto_approve_reset_title; + public static String preferences_page_file_op_auto_approve_unmatched; + public static String preferences_page_file_op_auto_approve_unmatched_note; + public static String preferences_page_file_op_auto_approve_add_dialog_title; + public static String preferences_page_file_op_auto_approve_add_dialog_message; + public static String preferences_page_file_op_auto_approve_add_dialog_pattern; + public static String preferences_page_file_op_auto_approve_add_dialog_description; + public static String preferences_page_file_op_auto_approve_add_dialog_pattern_hint; + public static String preferences_page_file_op_auto_approve_add_dialog_description_hint; + public static String preferences_page_file_op_auto_approve_duplicate_title; + public static String preferences_page_file_op_auto_approve_duplicate_message; + + // MCP Auto-Approve + public static String preferences_page_mcp_auto_approve_title; + public static String preferences_page_mcp_auto_approve_trust_annotations; + public static String preferences_page_mcp_auto_approve_trust_annotations_note; + public static String preferences_page_mcp_auto_approve_server_tools_label; + + // Global Auto-Approve + public static String preferences_page_global_auto_approve_title; + public static String preferences_page_global_auto_approve_label; + public static String preferences_page_global_auto_approve_confirm_title; + public static String preferences_page_global_auto_approve_confirm_message; + public static String preferences_page_global_auto_approve_confirm_button; + public static String preferences_page_global_auto_approve_cancel_button; + static { // initialize resource bundle NLS.initializeMessages(BUNDLE_NAME, Messages.class); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/TerminalAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/TerminalAutoApproveSection.java new file mode 100644 index 00000000..db41cb10 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/TerminalAutoApproveSection.java @@ -0,0 +1,275 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.viewers.ArrayContentProvider; +import org.eclipse.jface.viewers.ColumnLabelProvider; +import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.jface.viewers.TableViewer; +import org.eclipse.jface.viewers.TableViewerColumn; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Table; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.TerminalAutoApproveRule; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.TerminalConfirmationHandler; + +/** + * Terminal auto-approve section with a rule table, action buttons, and + * unmatched-command checkbox. + */ +public class TerminalAutoApproveSection extends Composite { + + private static final int TABLE_HEIGHT_HINT = 200; + private static final Type TERMINAL_RULES_TYPE = + new TypeToken>() {}.getType(); + + private TableViewer tableViewer; + private List rules = new ArrayList<>(); + private Button removeButton; + private Button toggleButton; + private Button resetButton; + private Button unmatchedCheckbox; + + /** Creates the terminal auto-approve section inside the given parent. */ + public TerminalAutoApproveSection(Composite parent, int style) { + super(parent, style); + setLayout(new GridLayout(1, false)); + setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + createContents(); + } + + private void createContents() { + Group group = new Group(this, SWT.NONE); + group.setText(Messages.preferences_page_terminal_auto_approve_title); + group.setLayout(new GridLayout(1, false)); + group.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + group.setBackgroundMode(SWT.INHERIT_FORCE); + + Label description = new Label(group, SWT.WRAP); + description.setText( + Messages.preferences_page_terminal_auto_approve_description); + GridData descData = new GridData(SWT.FILL, SWT.TOP, true, false); + descData.widthHint = 400; + description.setLayoutData(descData); + + Composite container = new Composite(group, SWT.NONE); + container.setLayout(new GridLayout(2, false)); + container.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + + createTable(container); + createButtons(container); + + unmatchedCheckbox = new Button(group, SWT.CHECK); + unmatchedCheckbox.setText( + Messages.preferences_page_terminal_auto_approve_unmatched); + unmatchedCheckbox.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + + new WrappableNoteLabel(group, + Messages.preferences_page_note_prefix + " ", + Messages.preferences_page_terminal_auto_approve_unmatched_note); + } + + private void createTable(Composite parent) { + tableViewer = new TableViewer(parent, + SWT.BORDER | SWT.FULL_SELECTION | SWT.SINGLE); + Table table = tableViewer.getTable(); + GridData tableData = new GridData(SWT.FILL, SWT.FILL, true, false); + tableData.heightHint = TABLE_HEIGHT_HINT; + table.setLayoutData(tableData); + table.setHeaderVisible(true); + table.setLinesVisible(true); + + TableViewerColumn commandCol = + new TableViewerColumn(tableViewer, SWT.NONE); + commandCol.getColumn().setText( + Messages.preferences_page_terminal_auto_approve_column_command); + commandCol.getColumn().setWidth(300); + commandCol.setLabelProvider(new ColumnLabelProvider() { + @Override + public String getText(Object element) { + return ((TerminalAutoApproveRule) element).getCommand(); + } + }); + + TableViewerColumn statusCol = + new TableViewerColumn(tableViewer, SWT.NONE); + statusCol.getColumn().setText( + Messages.preferences_page_auto_approve_column_status); + statusCol.getColumn().setWidth(100); + statusCol.setLabelProvider(new ColumnLabelProvider() { + @Override + public String getText(Object element) { + return ((TerminalAutoApproveRule) element).isAutoApprove() + ? Messages.preferences_page_auto_approve_allow + : Messages.preferences_page_auto_approve_deny; + } + }); + + tableViewer.setContentProvider(ArrayContentProvider.getInstance()); + tableViewer.addSelectionChangedListener(e -> updateButtonState()); + } + + private void createButtons(Composite parent) { + Composite btnGroup = new Composite(parent, SWT.NONE); + btnGroup.setLayout(new GridLayout(1, false)); + btnGroup.setLayoutData( + new GridData(SWT.BEGINNING, SWT.BEGINNING, false, false)); + + Button addButton = new Button(btnGroup, SWT.PUSH); + addButton.setText(Messages.preferences_page_auto_approve_add); + addButton.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false)); + addButton.addListener(SWT.Selection, e -> onAdd()); + + removeButton = new Button(btnGroup, SWT.PUSH); + removeButton.setText( + Messages.preferences_page_auto_approve_remove); + removeButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + removeButton.setEnabled(false); + removeButton.addListener(SWT.Selection, e -> onRemove()); + + toggleButton = new Button(btnGroup, SWT.PUSH); + toggleButton.setText( + Messages.preferences_page_auto_approve_allow); + toggleButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + toggleButton.setEnabled(false); + toggleButton.addListener(SWT.Selection, e -> onToggle()); + + resetButton = new Button(btnGroup, SWT.PUSH); + resetButton.setText( + Messages.preferences_page_auto_approve_reset); + resetButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + resetButton.addListener(SWT.Selection, e -> onResetToDefaults()); + } + + private void onAdd() { + AddTerminalRuleDialog dialog = new AddTerminalRuleDialog(getShell()); + if (dialog.open() == AddTerminalRuleDialog.OK) { + String command = dialog.getCommand(); + // Remove existing rule with same command, then add at end + rules.removeIf(r -> r.getCommand().equals(command)); + rules.add(new TerminalAutoApproveRule(command, dialog.isAutoApprove())); + tableViewer.refresh(); + updateButtonState(); + } + } + + private void onRemove() { + IStructuredSelection sel = tableViewer.getStructuredSelection(); + if (!sel.isEmpty()) { + rules.remove(sel.getFirstElement()); + tableViewer.refresh(); + updateButtonState(); + } + } + + private void onToggle() { + IStructuredSelection sel = tableViewer.getStructuredSelection(); + if (!sel.isEmpty()) { + TerminalAutoApproveRule rule = + (TerminalAutoApproveRule) sel.getFirstElement(); + rule.setAutoApprove(!rule.isAutoApprove()); + tableViewer.refresh(); + updateButtonState(); + } + } + + private void onResetToDefaults() { + boolean confirmed = MessageDialog.openQuestion(getShell(), + Messages.preferences_page_terminal_auto_approve_reset_title, + Messages.preferences_page_auto_approve_reset_message); + if (confirmed) { + rules.clear(); + rules.addAll(TerminalConfirmationHandler.DEFAULT_RULES.stream() + .map(r -> new TerminalAutoApproveRule( + r.getCommand(), r.isAutoApprove())) + .toList()); + tableViewer.refresh(); + updateButtonState(); + } + } + + private void updateButtonState() { + boolean hasSelection = + !tableViewer.getStructuredSelection().isEmpty(); + removeButton.setEnabled(hasSelection); + toggleButton.setEnabled(hasSelection); + if (hasSelection) { + TerminalAutoApproveRule rule = (TerminalAutoApproveRule) + tableViewer.getStructuredSelection().getFirstElement(); + toggleButton.setText(rule.isAutoApprove() + ? Messages.preferences_page_auto_approve_deny + : Messages.preferences_page_auto_approve_allow); + } else { + toggleButton.setText( + Messages.preferences_page_auto_approve_allow); + } + resetButton.setEnabled(!isMatchingDefaults()); + } + + private boolean isMatchingDefaults() { + List defaults = TerminalConfirmationHandler.DEFAULT_RULES; + if (rules.size() != defaults.size()) { + return false; + } + for (int i = 0; i < rules.size(); i++) { + if (!rules.get(i).getCommand().equals(defaults.get(i).getCommand()) + || rules.get(i).isAutoApprove() != defaults.get(i).isAutoApprove()) { + return false; + } + } + return true; + } + + /** Loads terminal rules and unmatched-command preference from the store. */ + public void loadFromPreferences(IPreferenceStore store) { + String json = store.getString(Constants.AUTO_APPROVE_TERMINAL_RULES); + rules.clear(); + if (StringUtils.isNotBlank(json) && !"[]".equals(json.trim())) { + try { + List loaded = + new Gson().fromJson(json, TERMINAL_RULES_TYPE); + if (loaded != null) { + rules.addAll(loaded); + } + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Failed to parse terminal auto-approve rules from preferences", e); + } + } + tableViewer.setInput(rules); + + unmatchedCheckbox.setSelection( + store.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_TERMINAL)); + updateButtonState(); + } + + /** Saves terminal rules and unmatched-command preference to the store. */ + public void saveToPreferences(IPreferenceStore store) { + store.setValue(Constants.AUTO_APPROVE_TERMINAL_RULES, + new Gson().toJson(rules)); + store.setValue(Constants.AUTO_APPROVE_UNMATCHED_TERMINAL, + unmatchedCheckbox.getSelection()); + } +} 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 cec6e395..f041db59 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 @@ -143,3 +143,59 @@ preferences_page_skills_enabled_note_content=Controls whether agent skills can b # enterprise support setting_disabled_by_organization=This setting is disabled by your organization. setting_managed_by_organization=This setting is managed by your organization. + + +preferences_page_auto_approve_disabled_by_organization=Tool auto-approval rules are disabled by your organization's administrator. Please contact your organization's administrator for more information. + +# Shared Auto Approve strings (reusable by all auto-approve sections) +preferences_page_auto_approve_column_status=Auto Approve +preferences_page_auto_approve_add=Add... +preferences_page_auto_approve_remove=Remove +preferences_page_auto_approve_reset=Reset to Defaults +preferences_page_auto_approve_reset_message=This will replace all current rules with the defaults. Continue? +preferences_page_auto_approve_allow=Allow +preferences_page_auto_approve_deny=Deny +preferences_page_auto_approve_add_dialog_approve=Auto Approve: + +# Terminal Auto Approve +preferences_page_terminal_auto_approve_title=Terminal Auto Approve +preferences_page_terminal_auto_approve_description=Controls whether chat-initiated terminal commands are automatically approved. Set to Allow to auto approve matching commands; set to Deny to always require explicit approval. +preferences_page_terminal_auto_approve_column_command=Command +preferences_page_terminal_auto_approve_reset_title=Reset Terminal Auto Approve Rules +preferences_page_terminal_auto_approve_unmatched=Auto approve commands not covered by rules +preferences_page_terminal_auto_approve_unmatched_note=When enabled, terminal commands not covered by the rules above are automatically approved. Use this setting at your own discretion. +preferences_page_terminal_auto_approve_add_dialog_title=Add Terminal Command Rule +preferences_page_terminal_auto_approve_add_dialog_message=Enter the command name or regex pattern (e.g., npm, git, /^apt-get\\b/) +preferences_page_terminal_auto_approve_add_dialog_command=Command or Regex: +preferences_page_terminal_auto_approve_add_dialog_placeholder=e.g., npm, git, /^apt-get\\b/ + +# File Operations Auto Approve +preferences_page_file_op_auto_approve_title=File Operations Auto Approve +preferences_page_file_op_auto_approve_description=Controls whether file operations generated by Copilot are approved automatically. Rules only apply to files within the workspace; operations on files outside the workspace always require confirmation. Set to Allow to auto approve operations on matching files; set to Deny to always require explicit approval. +preferences_page_file_op_auto_approve_column_pattern=Pattern +preferences_page_file_op_auto_approve_column_description=Description +preferences_page_file_op_auto_approve_reset_title=Reset File Operations Auto Approve Rules +preferences_page_file_op_auto_approve_unmatched=Auto approve file operations not covered by rules +preferences_page_file_op_auto_approve_unmatched_note=When enabled, file operations not covered by the rules above are automatically approved. File operations outside the workspace always require confirmation. +preferences_page_file_op_auto_approve_add_dialog_title=Add File Operation Auto Approve Rule +preferences_page_file_op_auto_approve_add_dialog_message=Enter the glob pattern (e.g., **/.idea/**/* or **/*.config) +preferences_page_file_op_auto_approve_add_dialog_pattern=Pattern: +preferences_page_file_op_auto_approve_add_dialog_description=Description: +preferences_page_file_op_auto_approve_add_dialog_pattern_hint=e.g., **/.idea/**/* or **/*.config +preferences_page_file_op_auto_approve_add_dialog_description_hint=Optional description +preferences_page_file_op_auto_approve_duplicate_title=Duplicate Pattern +preferences_page_file_op_auto_approve_duplicate_message=A rule for this pattern already exists. + +# MCP Auto Approve +preferences_page_mcp_auto_approve_title=MCP Auto Approve +preferences_page_mcp_auto_approve_trust_annotations=Trust MCP tool annotations +preferences_page_mcp_auto_approve_trust_annotations_note=When enabled, Copilot uses MCP tool annotations to automatically approve read-only tool calls without confirmation. +preferences_page_mcp_auto_approve_server_tools_label=MCP Server and Tool Approval + +# Global Auto Approve +preferences_page_global_auto_approve_title=Global Auto Approve +preferences_page_global_auto_approve_label=Auto approve all tool calls +preferences_page_global_auto_approve_confirm_title=Enable Global Auto Approve? +preferences_page_global_auto_approve_confirm_message=Global auto-approve disables manual approval completely for all tools. When enabled, ALL tool calls (terminal commands, file edits, built-in tools, and MCP tools) will be automatically approved without any confirmation. This is extremely dangerous and is never recommended. +preferences_page_global_auto_approve_confirm_button=Confirm +preferences_page_global_auto_approve_cancel_button=Cancel diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/DropDownButton.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SplitDropdownButton.java similarity index 89% rename from com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/DropDownButton.java rename to com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SplitDropdownButton.java index 55aa6301..c1e908d8 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/DropDownButton.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SplitDropdownButton.java @@ -14,7 +14,7 @@ * The Eclipse Foundation - initial API and implementation *******************************************************************************/ -package com.microsoft.copilot.eclipse.ui.dialogs.mcp; +package com.microsoft.copilot.eclipse.ui.swt; import java.util.ArrayList; import java.util.List; @@ -36,12 +36,18 @@ import org.eclipse.swt.widgets.Shell; /** - * A drop down button from Eclipse Foundation that can show a menu when the arrow is clicked. + * A split button that distinguishes between clicking the label area (primary action) + * and clicking the arrow area (opens a dropdown menu). Originally from Eclipse Foundation. + * + *

Selection listeners receive {@code e.detail == SWT.ARROW} when the arrow + * area is clicked, and {@code e.detail == 0} for the primary area. */ -public class DropDownButton { +public class SplitDropdownButton { private boolean showArrow; + private Color separatorColor; + private Rectangle arrowBounds; private String padding = null; @@ -71,9 +77,10 @@ public void paintControl(PaintEvent e) { try { int inset = 3; int lineX = arrowBounds.x; + Color lineColor = separatorColor != null ? separatorColor : shadowColor; gc.setLineWidth(1); - gc.setForeground(shadowColor); - gc.setBackground(shadowColor); + gc.setForeground(lineColor); + gc.setBackground(lineColor); gc.drawLine(lineX, arrowBounds.y + inset - 1, lineX, e.y + buttonBounds.height - inset); Color arrowColor = button.getForeground(); @@ -93,12 +100,12 @@ public void paintControl(PaintEvent e) { }; /** - * Creates a new DropDownButton. + * Creates a new SplitButton. * * @param parent the parent composite * @param style the style of button */ - public DropDownButton(Composite parent, int style) { + public SplitDropdownButton(Composite parent, int style) { button = new Button(parent, SWT.PUSH); } @@ -125,6 +132,17 @@ public boolean isShowArrow() { return showArrow; } + /** + * Overrides the separator line color. When {@code null} (default), + * the system shadow color is used. + * + * @param color the color for the separator, or {@code null} for the default + */ + public void setSeparatorColor(Color color) { + this.separatorColor = color; + button.redraw(); + } + /** * Sets the text of the button. * @@ -291,7 +309,8 @@ private DropDownSelectionListenerWrapper findWrapper(final SelectionListener lis * @param listener the listener to remove */ public void removeSelectionListener(SelectionListener listener) { - DropDownSelectionListenerWrapper wrapper = findWrapper(listener); + DropDownSelectionListenerWrapper wrapper = + findWrapper(listener); if (wrapper != null) { button.removeSelectionListener(wrapper); } 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 016bbb2a..20f0285d 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 @@ -12,6 +12,7 @@ import com.microsoft.copilot.eclipse.core.FeatureFlags; import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope; import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.preferences.AutoApprovePreferencePage; import com.microsoft.copilot.eclipse.ui.preferences.ByokPreferencePage; import com.microsoft.copilot.eclipse.ui.preferences.ChatPreferencesPage; import com.microsoft.copilot.eclipse.ui.preferences.CompletionsPreferencesPage; @@ -33,7 +34,7 @@ private PreferencesUtils() { public static String[] getAllPreferenceIds() { return new String[] { CopilotPreferencesPage.ID, GeneralPreferencesPage.ID, ChatPreferencesPage.ID, CompletionsPreferencesPage.ID, CustomInstructionPreferencePage.ID, CustomModesPreferencePage.ID, - McpPreferencePage.ID, ByokPreferencePage.ID }; + McpPreferencePage.ID, ByokPreferencePage.ID, AutoApprovePreferencePage.ID }; } /** From cddd5daea0e2bcbbcc3448c8907009f6a4acf931 Mon Sep 17 00:00:00 2001 From: Darshan Poudel Date: Tue, 26 May 2026 07:58:54 +0545 Subject: [PATCH 02/27] fix: refresh scroller layout when tool confirmation dialog is cancelled (#254) When the user cancels a subagent tool confirmation dialog, the subagent panel stays expanded at its pre-cancel height. This happens because cancelConfirmation() only called parent.layout() (relaying the turn widget's children), but never updated the ScrolledComposite's minimum size via refreshScrollerLayout(). On the Continue path this goes unnoticed because subsequent subagent messages trigger a layout refresh; on the Cancel path no further messages arrive, so the panel is stuck. Fix by adding a dispose listener on the dialog in BaseTurnWidget that walks up to the nearest ChatContentViewer and calls requestRefreshScrollerLayout(), a new coalesced async helper that schedules a single refreshScrollerLayout() after pending SWT mutations settle. InvokeToolConfirmationDialog remains self-contained: both the accept and cancel paths use a private disposeAndRequestParentLayout() helper, keeping scroller-specific behaviour out of the dialog. Fixes #169 --- .../copilot/eclipse/ui/chat/BaseTurnWidget.java | 10 ++++++++++ .../copilot/eclipse/ui/chat/ChatContentViewer.java | 8 ++++++++ .../eclipse/ui/chat/InvokeToolConfirmationDialog.java | 9 +++++---- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java index d8a5221a..8890a95b 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java @@ -650,6 +650,16 @@ public CompletableFuture requestToolExecuti reset(); this.confirmDialog = new InvokeToolConfirmationDialog(this, content, input); + this.confirmDialog.addDisposeListener(e -> { + Composite ancestor = this.getParent(); + while (ancestor != null && !ancestor.isDisposed()) { + if (ancestor instanceof ChatContentViewer) { + ((ChatContentViewer) ancestor).requestRefreshScrollerLayout(); + break; + } + ancestor = ancestor.getParent(); + } + }); CompletableFuture toolConfirmationFuture = this.confirmDialog .getConfirmationFuture(); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java index 58ee3f5d..7ef32077 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java @@ -404,6 +404,14 @@ public void renderErrorMessage(String errorMessage) { scrollToLatestUserTurn(); } + /** + * Schedules a single async {@link #refreshScrollerLayout()} call so that multiple dispose/layout + * events that arrive in the same event-loop tick are coalesced into one pass. + */ + public void requestRefreshScrollerLayout() { + SwtUtils.invokeOnDisplayThreadAsync(() -> refreshScrollerLayout(), this); + } + /** * Update the size of scrolled composite when there are content updates. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/InvokeToolConfirmationDialog.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/InvokeToolConfirmationDialog.java index ebc307a0..1f65aead 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/InvokeToolConfirmationDialog.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/InvokeToolConfirmationDialog.java @@ -119,10 +119,7 @@ public void cancelConfirmation() { && StringUtils.isNotEmpty(this.cancelMessage)) { new AgentToolCancelLabel(parent, SWT.NONE, this.cancelMessage); } - this.dispose(); - if (parent != null && !parent.isDisposed()) { - parent.requestLayout(); - } + disposeAndRequestParentLayout(); }, this); } } @@ -318,6 +315,10 @@ private void acceptAndDispose(ConfirmationAction action) { new LanguageModelToolConfirmationResult( ToolConfirmationResult.ACCEPT)); + disposeAndRequestParentLayout(); + } + + private void disposeAndRequestParentLayout() { Composite parent = this.getParent(); this.dispose(); if (parent != null && !parent.isDisposed()) { From d6a6992c41a374ef6822afc7dd98a55d64c0d3e2 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Tue, 26 May 2026 15:28:15 +0800 Subject: [PATCH 03/27] feat - improve terminal command execution across windows and linux (#247) --- .../META-INF/MANIFEST.MF | 3 +- .../scripts/copilot-bash-integration.sh | 45 ++++ .../copilot-powershell-integration.ps1 | 56 ++++ .../scripts/copilot-sh-integration.sh | 32 --- .../terminal/api/IRunInTerminalTool.java | 16 +- .../terminal/api/ShellIntegrationScripts.java | 53 +++- .../api/TerminalCommandProcessor.java | 254 ++++++++++++++++++ .../ui/terminal/tm/RunInTerminalTool.java | 248 +++++++++-------- .../ui/terminal/RunInTerminalTool.java | 251 +++++++++-------- .../api/TerminalCommandProcessorTest.java | 150 +++++++++++ .../tools/RunInTerminalToolAdapterTest.java | 4 +- .../copilot/eclipse/ui/chat/ChatView.java | 15 ++ .../chat/tools/RunInTerminalToolAdapter.java | 95 ++++++- 13 files changed, 939 insertions(+), 283 deletions(-) create mode 100644 com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-bash-integration.sh create mode 100644 com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-powershell-integration.ps1 delete mode 100644 com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-sh-integration.sh create mode 100644 com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessor.java create mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessorTest.java diff --git a/com.microsoft.copilot.eclipse.terminal.api/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.terminal.api/META-INF/MANIFEST.MF index b6fe125a..07300c74 100644 --- a/com.microsoft.copilot.eclipse.terminal.api/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.terminal.api/META-INF/MANIFEST.MF @@ -13,4 +13,5 @@ Require-Bundle: org.eclipse.core.runtime;bundle-version="[3.30.0,4.0.0)", com.microsoft.copilot.eclipse.core;bundle-version="0.15.0", org.eclipse.jface, org.eclipse.swt, - org.eclipse.ui.workbench + org.eclipse.ui.workbench, + org.apache.commons.lang3;bundle-version="3.13.0" diff --git a/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-bash-integration.sh b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-bash-integration.sh new file mode 100644 index 00000000..b36076c7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-bash-integration.sh @@ -0,0 +1,45 @@ +# Copilot Shell Integration for Bash +# This script is loaded with bash --init-file when starting a terminal. + +__copilot_bash_integration_main() { + [ -n "${COPILOT_BASH_INTEGRATION:-}" ] && return + COPILOT_BASH_INTEGRATION=1 + + bind 'set enable-bracketed-paste on' 2>/dev/null || true + __copilot_prompt_initialized=0 + + __copilot_precmd() { + __copilot_status=$? + if [ "${__copilot_prompt_initialized:-0}" = "1" ]; then + printf '\033]7775;C;%s\007' "$__copilot_status" + else + __copilot_prompt_initialized=1 + fi + printf '\033]7775;A\007' + return "$__copilot_status" + } + + __copilot_prompt_end() { + printf '\033]7775;B\007' + } + + if [ -z "${__copilot_original_ps1:-}" ]; then + __copilot_original_ps1=${PS1:-'\$ '} + fi + + case "$(declare -p PROMPT_COMMAND 2>/dev/null)" in + declare\ -a*|declare\ -A*) + PROMPT_COMMAND=(__copilot_precmd "${PROMPT_COMMAND[@]}") + ;; + *) + if [ -n "${PROMPT_COMMAND:-}" ]; then + PROMPT_COMMAND="__copilot_precmd; ${PROMPT_COMMAND}" + else + PROMPT_COMMAND=__copilot_precmd + fi + ;; + esac + PS1="${__copilot_original_ps1}"'\[$(__copilot_prompt_end)\]' +} + +__copilot_bash_integration_main \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-powershell-integration.ps1 b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-powershell-integration.ps1 new file mode 100644 index 00000000..5cc0c77e --- /dev/null +++ b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-powershell-integration.ps1 @@ -0,0 +1,56 @@ +# Copilot Shell Integration for Windows PowerShell +# This script is loaded when starting a Copilot terminal. + +try { + $global:OutputEncoding = New-Object System.Text.UTF8Encoding $false + [Console]::InputEncoding = $global:OutputEncoding + [Console]::OutputEncoding = $global:OutputEncoding +} catch { + # Some hosts do not expose console encodings during startup. +} + +if (-not $global:COPILOT_SHELL_INTEGRATION) { + $global:COPILOT_SHELL_INTEGRATION = $true + $global:__copilot_original_prompt = (Get-Command prompt -CommandType Function).ScriptBlock + $global:__copilot_last_history_id = -1 + + function global:prompt { + $lastSuccess = $? + $lastExitCode = $LASTEXITCODE + $esc = [char]27 + $bel = [char]7 + $lastHistoryEntry = Get-History -Count 1 + $result = "" + + if ($global:__copilot_last_history_id -ne -1) { + if ($null -ne $lastHistoryEntry -and $lastHistoryEntry.Id -eq $global:__copilot_last_history_id) { + $result += "$esc]7775;C$bel" + } else { + if ($lastSuccess) { + $exitCode = 0 + } elseif ($null -ne $lastExitCode -and $lastExitCode -ne 0) { + $exitCode = $lastExitCode + } else { + $exitCode = 1 + } + $result += "$esc]7775;C;$exitCode$bel" + } + } + + $result += "$esc]7775;A$bel" + + if ($global:__copilot_original_prompt) { + $result += $global:__copilot_original_prompt.Invoke() + } else { + $result += "PS $($executionContext.SessionState.Path.CurrentLocation)> " + } + + $result += "$esc]7775;B$bel" + if ($null -ne $lastHistoryEntry) { + $global:__copilot_last_history_id = $lastHistoryEntry.Id + } else { + $global:__copilot_last_history_id = 0 + } + $result + } +} \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-sh-integration.sh b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-sh-integration.sh deleted file mode 100644 index 4b365b14..00000000 --- a/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-sh-integration.sh +++ /dev/null @@ -1,32 +0,0 @@ -# Copilot Shell Integration for POSIX sh -# This script is sourced via ENV when starting a terminal. - -__copilot_sh_integration_main() { - # Avoid multiple initialization - [ -n "$COPILOT_SHELL_INTEGRATION" ] && return - COPILOT_SHELL_INTEGRATION=1 - - # OSC escape sequence: ESC ] 7775 ; C BEL - # This is invisible in terminal but detectable programmatically - __COPILOT_MARKER=$(printf '\033]7775;C\007') - - # The function that prints the marker - __copilot_precmd() { - printf '%s' "$__COPILOT_MARKER" - } - - # Save original PS1 only if PS1 is already set and not empty - if [ -z "$__copilot_original_ps1" ] && [ -n "$PS1" ]; then - __copilot_original_ps1=$PS1 - fi - - # Ensure PS1 has a value (POSIX shells may start without PS1) - : "${__copilot_original_ps1:=$ }" - - # Assemble PS1: - # (invisible OSC sequence) - # - PS1="\$(__copilot_precmd)${__copilot_original_ps1}" -} - -__copilot_sh_integration_main \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/IRunInTerminalTool.java b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/IRunInTerminalTool.java index b3773000..b91429e1 100644 --- a/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/IRunInTerminalTool.java +++ b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/IRunInTerminalTool.java @@ -16,22 +16,25 @@ public interface IRunInTerminalTool { /** - * Executes a command in the terminal. + * Executes a command in the terminal with an initial working directory. * * @param command The command to execute. * @param isBackground Whether the command should run in the background. + * @param workingDirectory The terminal's initial working directory. * @return A CompletableFuture that resolves to the output of the command. */ - public CompletableFuture executeCommand(String command, boolean isBackground); + public CompletableFuture executeCommand(String command, boolean isBackground, String workingDirectory); /** - * Prepares terminal properties for the command execution. + * Prepares terminal properties for the command execution with an initial working directory. * * @param runInBackground Whether the command should run in the background. * @param executionId The unique identifier for the execution. + * @param workingDirectory The terminal's initial working directory. * @return A map containing terminal properties. */ - public Map prepareTerminalProperties(boolean runInBackground, String executionId); + public Map prepareTerminalProperties(boolean runInBackground, String executionId, + String workingDirectory); /** * Retrieves the output of a background command execution. @@ -41,6 +44,11 @@ public interface IRunInTerminalTool { */ public StringBuilder getBackgroundCommandOutput(String executionId); + /** + * Cancels the foreground terminal command if one is currently running. + */ + public void cancelCurrentCommand(); + /** * Sets the terminal icon descriptor for the tool. */ diff --git a/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/ShellIntegrationScripts.java b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/ShellIntegrationScripts.java index cd527c3a..c2015f2c 100644 --- a/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/ShellIntegrationScripts.java +++ b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/ShellIntegrationScripts.java @@ -19,33 +19,62 @@ */ public final class ShellIntegrationScripts { - /** - * The marker string output by the shell integration script after each command completes. - * Uses OSC (Operating System Command) escape sequence format: ESC ] 7775 ; marker BEL - * This is invisible in terminal output but can be detected programmatically. - */ - public static final String SHELL_MARKER = "\u001b]7775;C\u0007"; + /** OSC namespace used by Copilot shell integration markers. */ + public static final String OSC_NAMESPACE = "7775"; + + /** Marker emitted when the prompt starts. */ + public static final String PROMPT_START_MARKER = "\u001b]" + OSC_NAMESPACE + ";A\u0007"; + + /** Marker emitted when the prompt ends. */ + public static final String PROMPT_END_MARKER = "\u001b]" + OSC_NAMESPACE + ";B\u0007"; + + /** Marker emitted when a command finishes without an exit code. */ + public static final String COMMAND_FINISH_MARKER = "\u001b]" + OSC_NAMESPACE + ";C\u0007"; + + /** Marker prefix emitted when a command finishes with an exit code. */ + public static final String COMMAND_FINISH_MARKER_PREFIX = "\u001b]" + OSC_NAMESPACE + ";C;"; + + /** Pattern matching Copilot OSC markers, including markers that lost ESC/BEL during terminal processing. */ + public static final String OSC_MARKER_PATTERN = buildOscMarkerPattern(); private static final String SCRIPTS_PATH = "scripts/"; - private static final String SH_SCRIPT = "copilot-sh-integration.sh"; + private static final String BASH_SCRIPT = "copilot-bash-integration.sh"; + private static final String POWERSHELL_SCRIPT = "copilot-powershell-integration.ps1"; private ShellIntegrationScripts() { // Utility class } + private static String buildOscMarkerPattern() { + return "(?:\u001B)?\\]" + OSC_NAMESPACE + ";[ABC](?:;[-]?\\d+)?(?:\u0007|\u001B\\\\)?"; + } + + /** + * Gets the absolute file path to the Bash integration script. + * + * @return the absolute path to the Bash script, or null if not found + */ + public static String getBashScriptPath() { + return getScriptPath(BASH_SCRIPT); + } + /** - * Gets the absolute file path to the POSIX sh integration script. + * Gets the absolute file path to the PowerShell integration script. * - * @return the absolute path to the sh script, or null if not found + * @return the absolute path to the PowerShell script, or null if not found */ - public static String getShScriptPath() { + public static String getPowerShellScriptPath() { + return getScriptPath(POWERSHELL_SCRIPT); + } + + private static String getScriptPath(String scriptName) { try { Bundle bundle = FrameworkUtil.getBundle(ShellIntegrationScripts.class); if (bundle == null) { return null; } - URL scriptUrl = FileLocator.find(bundle, new Path(SCRIPTS_PATH + SH_SCRIPT)); + URL scriptUrl = FileLocator.find(bundle, new Path(SCRIPTS_PATH + scriptName)); if (scriptUrl == null) { return null; } @@ -60,7 +89,7 @@ public static String getShScriptPath() { return scriptFile.getAbsolutePath(); } } catch (IOException e) { - CopilotCore.LOGGER.error("Failed to locate shell integration script: " + SH_SCRIPT, e); + CopilotCore.LOGGER.error("Failed to locate shell integration script: " + scriptName, e); } return null; } diff --git a/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessor.java b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessor.java new file mode 100644 index 00000000..92dbb93b --- /dev/null +++ b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessor.java @@ -0,0 +1,254 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.terminal.api; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; + +/** + * Processes terminal command input and output buffers. + */ +public final class TerminalCommandProcessor { + private static final int MAX_OUTPUT_LINE_COUNT = 1000; + private static final String BRACKETED_PASTE_START = "\u001b[200~"; + private static final String BRACKETED_PASTE_END = "\u001b[201~"; + private static final String ANSI_CSI_SEQUENCE_PATTERN = "\u001B\\[(\\?)?[\\d;]*[a-zA-Z]"; + private static final String OSC_SEQUENCE_PATTERN = "\u001B\\][^\u0007\u001B]*(?:\u0007|\u001B\\\\)"; + private static final Pattern PROMPT_START_MARKER_PATTERN = buildMarkerPattern("A", false); + private static final Pattern PROMPT_END_MARKER_PATTERN = buildMarkerPattern("B", false); + private static final Pattern COMMAND_FINISH_MARKER_PATTERN = buildMarkerPattern("C", true); + + private TerminalCommandProcessor() { + // Utility class + } + + /** + * Formats a command for immediate terminal execution. + * + * @param command the command to send + * @return command text formatted for terminal input + */ + public static String formatForExecution(String command) { + return formatForExecution(command, true); + } + + /** + * Formats a command for immediate terminal execution. + * + * @param command the command to send + * @param useBracketedPaste whether multiline commands should be sent using bracketed paste + * @return command text formatted for terminal input + */ + public static String formatForExecution(String command, boolean useBracketedPaste) { + String normalizedCommand = StringUtils.stripEnd(normalizeLineEndings(command), "\n"); + String terminalInput = useBracketedPaste && isMultilineCommand(normalizedCommand) + ? BRACKETED_PASTE_START + normalizedCommand + BRACKETED_PASTE_END + : normalizedCommand; + terminalInput = terminalInput.replace('\n', '\r'); + if (!terminalInput.endsWith("\r")) { + terminalInput += "\r"; + } + return terminalInput; + } + + /** + * Truncates terminal output to the tail when it is too long for a tool result. + * + * @param output terminal output + * @return terminal output, truncated to the last lines when needed + */ + public static String truncateOutput(String output) { + if (output == null || output.isEmpty()) { + return output == null ? "" : output; + } + + String normalizedOutput = normalizeLineEndings(output); + int scanIndex = normalizedOutput.endsWith("\n") ? normalizedOutput.length() - 2 : normalizedOutput.length() - 1; + int keptLineCount = 1; + while (scanIndex >= 0) { + if (normalizedOutput.charAt(scanIndex) == '\n') { + if (keptLineCount == MAX_OUTPUT_LINE_COUNT) { + StringBuilder truncatedOutput = new StringBuilder(); + truncatedOutput.append("[Terminal output truncated: showing last ") + .append(MAX_OUTPUT_LINE_COUNT) + .append(" lines.]\n"); + truncatedOutput.append(normalizedOutput.substring(scanIndex + 1)); + return truncatedOutput.toString(); + } + keptLineCount++; + } + scanIndex--; + } + + return output; + } + + /** + * Cleans terminal output for model-visible tool results. + * + * @param output terminal output + * @return output with terminal control sequences removed and long output truncated + */ + public static String prepareOutputForModel(String output) { + return truncateOutput(cleanTerminalControlSequences(output)); + } + + /** + * Attempts to complete a command using shell integration markers. + * + * @param output terminal output buffer + * @return the completion check result + */ + public static CompletionCheckResult tryCompleteWithMarker(StringBuilder output) { + MarkerRange commandFinishMarkerRange = findMarker(output, COMMAND_FINISH_MARKER_PATTERN, 0); + if (commandFinishMarkerRange == null) { + // Startup or idle prompts can arrive before a command runs. Keep the visible prompt text, but remove marker + // bytes so later command output cleanup does not have to handle stale prompt boundaries. + removePromptMarkers(output); + return CompletionCheckResult.incomplete(); + } + + // A complete marker command is the command finish marker followed by the next prompt end. Waiting for B keeps the + // prompt line in the returned output, which gives the language model the terminal's current working directory. + MarkerRange promptEndMarkerRange = findMarker(output, PROMPT_END_MARKER_PATTERN, commandFinishMarkerRange.endIndex); + if (promptEndMarkerRange == null) { + return CompletionCheckResult.incomplete(); + } + + // Keep terminal output plus the next prompt, but exclude command finish markers. + String completedOutput = output.substring(0, commandFinishMarkerRange.startIndex) + + output.substring(commandFinishMarkerRange.endIndex, promptEndMarkerRange.endIndex); + return CompletionCheckResult.completed(cleanCommandOutput(completedOutput)); + } + + /** + * Attempts to complete a command by detecting a shell prompt. + * + * @param output terminal output buffer + * @return the completion check result + */ + public static CompletionCheckResult tryCompleteWithPrompt(StringBuilder output) { + String terminalOutput = output.toString().trim(); + int lastNewLineIndex = terminalOutput.lastIndexOf('\n'); + if (lastNewLineIndex <= 0) { + return CompletionCheckResult.incomplete(); + } + + String lastLine = terminalOutput.substring(lastNewLineIndex).trim(); + if (lastLine.isBlank() || lastLine.length() == 1) { + return CompletionCheckResult.incomplete(); + } + + char lastChar = lastLine.charAt(lastLine.length() - 1); + boolean isPromptChar = lastChar == '>' || lastChar == '#' || lastChar == '$' || lastChar == '%'; + if (!isPromptChar) { + return CompletionCheckResult.incomplete(); + } + + String contentWithoutLastPrompt = terminalOutput.substring(0, lastNewLineIndex); + int promptStartIndex = contentWithoutLastPrompt.indexOf(lastLine); + if (promptStartIndex == -1) { + promptStartIndex = 0; + } else { + promptStartIndex += lastLine.length(); + } + + if (contentWithoutLastPrompt.isBlank()) { + return CompletionCheckResult.incomplete(); + } + return CompletionCheckResult.completed(contentWithoutLastPrompt.substring(promptStartIndex).trim()); + } + + private static String removeBracketedPasteMarkers(String output) { + return output.replace(BRACKETED_PASTE_START, "").replace(BRACKETED_PASTE_END, ""); + } + + private static boolean isMultilineCommand(String command) { + int newlineIndex = command.indexOf('\n'); + while (newlineIndex >= 0) { + if (newlineIndex == 0 || command.charAt(newlineIndex - 1) != '\\') { + return true; + } + newlineIndex = command.indexOf('\n', newlineIndex + 1); + } + return false; + } + + private static MarkerRange findMarker(StringBuilder output, Pattern markerPattern, int startIndex) { + Matcher matcher = markerPattern.matcher(output); + if (!matcher.find(startIndex)) { + return null; + } + return new MarkerRange(matcher.start(), matcher.end()); + } + + private static void removePromptMarkers(StringBuilder output) { + removeAll(output, PROMPT_START_MARKER_PATTERN); + removeAll(output, PROMPT_END_MARKER_PATTERN); + } + + private static void removeAll(StringBuilder output, Pattern markerPattern) { + Matcher matcher = markerPattern.matcher(output); + while (matcher.find()) { + output.delete(matcher.start(), matcher.end()); + matcher.reset(output); + } + } + + private static Pattern buildMarkerPattern(String markerKind, boolean includeExitCode) { + String exitCodePattern = includeExitCode ? "(?:;[-]?\\d+)?" : ""; + return Pattern.compile("(?:\u001B)?\\]" + ShellIntegrationScripts.OSC_NAMESPACE + ";" + markerKind + + exitCodePattern + "(?:\u0007|\u001B\\\\)?"); + } + + private static String cleanCommandOutput(String output) { + String normalizedOutput = normalizeLineEndings(output); + normalizedOutput = removeShellIntegrationMarkers(normalizedOutput); + normalizedOutput = removeBracketedPasteMarkers(normalizedOutput); + return normalizedOutput.trim(); + } + + private static String removeShellIntegrationMarkers(String output) { + return output.replaceAll(ShellIntegrationScripts.OSC_MARKER_PATTERN, ""); + } + + private static String cleanTerminalControlSequences(String output) { + if (output == null || output.isEmpty()) { + return output == null ? "" : output; + } + return removeShellIntegrationMarkers(output) + .replaceAll(ANSI_CSI_SEQUENCE_PATTERN, "") + .replaceAll(OSC_SEQUENCE_PATTERN, ""); + } + + private static String normalizeLineEndings(String value) { + return value.replace("\r\n", "\n").replace('\r', '\n'); + } + + private record MarkerRange(int startIndex, int endIndex) { + } + + /** + * Result of checking a terminal output buffer for command completion. + */ + public record CompletionCheckResult(CompletionCheckState state, String output) { + private static CompletionCheckResult incomplete() { + return new CompletionCheckResult(CompletionCheckState.INCOMPLETE, ""); + } + + private static CompletionCheckResult completed(String output) { + return new CompletionCheckResult(CompletionCheckState.COMPLETED, output); + } + } + + /** + * Terminal output completion states. + */ + public enum CompletionCheckState { + INCOMPLETE, + COMPLETED + } +} diff --git a/com.microsoft.copilot.eclipse.ui.terminal.tm/src/com/microsoft/copilot/eclipse/ui/terminal/tm/RunInTerminalTool.java b/com.microsoft.copilot.eclipse.ui.terminal.tm/src/com/microsoft/copilot/eclipse/ui/terminal/tm/RunInTerminalTool.java index 78f12c39..28849b6e 100644 --- a/com.microsoft.copilot.eclipse.ui.terminal.tm/src/com/microsoft/copilot/eclipse/ui/terminal/tm/RunInTerminalTool.java +++ b/com.microsoft.copilot.eclipse.ui.terminal.tm/src/com/microsoft/copilot/eclipse/ui/terminal/tm/RunInTerminalTool.java @@ -3,6 +3,7 @@ package com.microsoft.copilot.eclipse.ui.terminal.tm; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -36,6 +37,9 @@ import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool; import com.microsoft.copilot.eclipse.terminal.api.ShellIntegrationScripts; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor.CompletionCheckResult; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor.CompletionCheckState; /** * terminal tool implementation for older Eclipse versions. @@ -46,6 +50,9 @@ public class RunInTerminalTool implements IRunInTerminalTool { private static final Object lock = new Object(); private static final Map backgroundCommandOutputs = new HashMap<>(); private static final String BACKGROUND_TERMINAL_PREFIX = "Copilot-"; + private static final String POWERSHELL_SCRIPT_ENV = "COPILOT_POWERSHELL_INTEGRATION_SCRIPT"; + private static final String COMMAND_CANCELLED_MESSAGE = "Terminal command cancelled."; + private static final String COMMAND_INTERRUPTED_MESSAGE = "Terminal command interrupted by a new command."; // Non-background terminal field private ITerminalViewControl persistentTerminalViewControl; @@ -59,9 +66,7 @@ public class RunInTerminalTool implements IRunInTerminalTool { // Output and command state private StringBuilder sb; - private CompletableFuture resultFuture; - private volatile boolean useMarker; - private volatile boolean isInitialMarkerHandled; + private volatile ForegroundCommand foregroundCommand; /** * Constructor for RunInTerminalTool. @@ -71,52 +76,60 @@ public RunInTerminalTool() { } @Override - public CompletableFuture executeCommand(String command, boolean isBackground) { + public CompletableFuture executeCommand(String command, boolean isBackground, String workingDirectory) { if (StringUtils.isBlank(command)) { return CompletableFuture.completedFuture("The command is null or empty."); } - resultFuture = new CompletableFuture<>(); - useMarker = Platform.getOS().equals(Platform.OS_LINUX); + if (!isBackground) { + closeRunningForegroundTerminal(COMMAND_INTERRUPTED_MESSAGE); + } - if (!useMarker) { - // Retain only the last line (prompt) in the output buffer - if (!sb.isEmpty()) { - int lastLineStart = sb.lastIndexOf(StringUtils.LF); - if (lastLineStart > 0) { - sb.delete(0, lastLineStart); + CompletableFuture commandFuture = new CompletableFuture<>(); + ForegroundCommand commandState = isBackground ? null + : new ForegroundCommand(commandFuture, hasShellIntegrationMarker()); + + if (commandState != null) { + foregroundCommand = commandState; + if (!commandState.useMarker()) { + // Retain only the last line (prompt) in the output buffer + if (!sb.isEmpty()) { + int lastLineStart = sb.lastIndexOf(StringUtils.LF); + if (lastLineStart > 0) { + sb.delete(0, lastLineStart); + } } + } else { + // For marker-based detection, clear the buffer + sb.setLength(0); } - } else { - // For marker-based detection, clear the buffer - sb.setLength(0); } String executionId = UUID.randomUUID().toString(); - final String finalCommand = command + System.lineSeparator(); + final String finalCommand = TerminalCommandProcessor.formatForExecution(command, useBracketedPaste()); synchronized (lock) { if (!isBackground && this.persistentTerminalViewControl != null) { revealTerminal(); - this.persistentTerminalViewControl.pasteString(finalCommand); - return this.resultFuture; + sendCommand(this.persistentTerminalViewControl, finalCommand); + return commandFuture; } ITerminalService service = TerminalServiceFactory.getService(); if (service == null) { + clearForegroundCommand(commandState); return CompletableFuture.completedFuture("Failed to open terminal console due to terminal service is null."); } - // New non-background terminal will have an initial marker from shell startup; need to handle it - if (useMarker && !isBackground) { - isInitialMarkerHandled = false; - } - - service.openConsole(prepareTerminalProperties(isBackground, executionId), status -> { + service.openConsole(prepareTerminalProperties(isBackground, executionId, workingDirectory), status -> { if (status.isOK()) { + if (commandFuture.isDone()) { + return; + } ITerminalViewControl terminalViewControl = finalizeTerminalSetup(executionId, isBackground); if (terminalViewControl == null) { - resultFuture.complete("Terminal view control cannot be setup for RunInTerminalTool."); + clearForegroundCommand(commandState); + commandFuture.complete("Terminal view control cannot be setup for RunInTerminalTool."); return; } @@ -124,9 +137,10 @@ public CompletableFuture executeCommand(String command, boolean isBackgr this.persistentTerminalViewControl = terminalViewControl; revealTerminal(); } - terminalViewControl.pasteString(finalCommand); + sendCommand(terminalViewControl, finalCommand); } else { - resultFuture.complete("Failed to open terminal console: " + status.getException()); + clearForegroundCommand(commandState); + commandFuture.complete("Failed to open terminal console: " + status.getException()); } }); } @@ -135,25 +149,35 @@ public CompletableFuture executeCommand(String command, boolean isBackgr return CompletableFuture.completedFuture("Command is running in terminal with ID=" + executionId); } - return resultFuture; + return commandFuture; } @Override - public Map prepareTerminalProperties(boolean runInBackground, String executionId) { + public Map prepareTerminalProperties(boolean runInBackground, String executionId, + String workingDirectory) { Map properties = new HashMap<>(); properties.put(ITerminalsConnectorConstants.PROP_ENCODING, "UTF-8"); properties.put(ITerminalsConnectorConstants.PROP_TITLE_DISABLE_ANSI_TITLE, true); + if (StringUtils.isNotBlank(workingDirectory)) { + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_WORKING_DIR, workingDirectory); + } if (Platform.getOS().equals(Platform.OS_WIN32)) { - properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "cmd.exe"); - } else if (Platform.getOS().equals(Platform.OS_LINUX)) { - properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "/bin/sh"); - // Use ENV to load shell integration script at startup - String scriptPath = ShellIntegrationScripts.getShScriptPath(); + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "powershell.exe"); + String scriptPath = ShellIntegrationScripts.getPowerShellScriptPath(); if (scriptPath != null) { - properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ENVIRONMENT, new String[] { "ENV=" + scriptPath }); + String[] environment = new String[] { POWERSHELL_SCRIPT_ENV + "=" + scriptPath }; + String args = "-NoExit -ExecutionPolicy Bypass -Command \". $env:" + POWERSHELL_SCRIPT_ENV + "\""; + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ENVIRONMENT, environment); properties.put(ITerminalsConnectorConstants.PROP_PROCESS_MERGE_ENVIRONMENT, true); + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ARGS, args); + } + } else if (Platform.getOS().equals(Platform.OS_LINUX)) { + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "/bin/bash"); + String scriptPath = ShellIntegrationScripts.getBashScriptPath(); + if (scriptPath != null) { + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ARGS, "--init-file \"" + scriptPath + "\" -i"); } } else { // macOS or other Unix-like: keep existing behavior, only set args if empty @@ -187,6 +211,69 @@ public StringBuilder getBackgroundCommandOutput(String executionId) { return output; } + @Override + public void cancelCurrentCommand() { + closeRunningForegroundTerminal(COMMAND_CANCELLED_MESSAGE); + } + + private void closeRunningForegroundTerminal(String completionMessage) { + ForegroundCommand commandState = foregroundCommand; + if (commandState != null && !commandState.future().isDone()) { + closeCurrentForegroundTerminal(completionMessage); + } + } + + private void closeCurrentForegroundTerminal(String completionMessage) { + ForegroundCommand commandState = null; + CTabItem tabItem = null; + synchronized (lock) { + commandState = foregroundCommand; + foregroundCommand = null; + persistentTerminalViewControl = null; + tabItem = copilotTabItem; + copilotTabItem = null; + sb.setLength(0); + } + + if (tabItem != null) { + final CTabItem tabItemToDispose = tabItem; + // Keep this synchronous so a new foreground command cannot open before the old terminal tab is disposed. + Display.getDefault().syncExec(() -> { + if (!tabItemToDispose.isDisposed()) { + tabItemToDispose.dispose(); + } + }); + } + if (commandState != null && !commandState.future().isDone()) { + commandState.future().complete(completionMessage); + } + } + + private void clearForegroundCommand(ForegroundCommand commandState) { + if (commandState != null && foregroundCommand == commandState) { + foregroundCommand = null; + } + } + + private void sendCommand(ITerminalViewControl terminalViewControl, String command) { + terminalViewControl.pasteString(command); + } + + private boolean hasShellIntegrationMarker() { + if (Platform.getOS().equals(Platform.OS_WIN32)) { + return ShellIntegrationScripts.getPowerShellScriptPath() != null; + } + if (Platform.getOS().equals(Platform.OS_LINUX)) { + return ShellIntegrationScripts.getBashScriptPath() != null; + } + return false; + } + + private boolean useBracketedPaste() { + // macOS terminal multiline handling differs from PowerShell/Bash integration, so keep its existing plain input. + return Platform.getOS().equals(Platform.OS_WIN32) || Platform.getOS().equals(Platform.OS_LINUX); + } + private ITerminalViewControl finalizeTerminalSetup(String executionId, boolean isBackground) { String title = isBackground ? buildBackgroundTerminalTitle(executionId) : "Copilot"; synchronized (lock) { @@ -246,12 +333,9 @@ private ITerminalServiceOutputStreamMonitorListener buildOutputStreamMonitorList } return (byteBuffer, bytesRead) -> { - String content = new String(byteBuffer, 0, bytesRead); - // Remove ANSI escape sequences - // Sometimes it also removes the linebreaks. But we need the last prompt line to - // be a separate line later. So we - // add line separator back to the content. - content = content.replaceAll("\u001B\\[(\\?)?[\\d;]*[a-zA-Z]", StringUtils.LF); + String content = new String(byteBuffer, 0, bytesRead, StandardCharsets.UTF_8); + // Remove ANSI escape sequences while preserving only real line breaks from the terminal output. + content = content.replaceAll("\u001B\\[(\\?)?[\\d;]*[a-zA-Z]", ""); // Handle Windows terminal title sequences - using Platform instead of // PlatformUtils @@ -265,80 +349,25 @@ private ITerminalServiceOutputStreamMonitorListener buildOutputStreamMonitorList output.append(content); // Detect completion based on platform strategy - if (!isBackground && resultFuture != null && !resultFuture.isDone()) { - if (useMarker) { - tryCompleteWithMarker(output); - } else { - tryCompleteWithPrompt(output); - } + ForegroundCommand commandState = foregroundCommand; + if (!isBackground && commandState != null && !commandState.future().isDone()) { + CompletionCheckResult completionResult = commandState.useMarker() + ? TerminalCommandProcessor.tryCompleteWithMarker(output) + : TerminalCommandProcessor.tryCompleteWithPrompt(output); + handleCompletionResult(commandState, completionResult); } }; } - /** - * Attempts to complete the command by detecting the shell marker in output. - * Used on Linux where shell integration script outputs a marker after each command. - */ - private void tryCompleteWithMarker(StringBuilder output) { - int markerIndex = output.indexOf(ShellIntegrationScripts.SHELL_MARKER); - if (markerIndex < 0) { + private void handleCompletionResult(ForegroundCommand commandState, CompletionCheckResult completionResult) { + if (completionResult.state() == CompletionCheckState.INCOMPLETE) { return; } - - // Remove marker from output - output.delete(markerIndex, markerIndex + ShellIntegrationScripts.SHELL_MARKER.length()); - - // Skip the initial marker that appears when terminal starts (before any command is run) - if (!isInitialMarkerHandled) { - isInitialMarkerHandled = true; - return; + if (foregroundCommand == commandState) { + foregroundCommand = null; } - - String cleaned = output.toString().trim(); - resultFuture.complete(cleaned); - } - - /** - * Attempts to complete the command by detecting a shell prompt in output. - * Used on Windows and macOS where prompt characters indicate command completion. - */ - private void tryCompleteWithPrompt(StringBuilder output) { - String terminalOutput = output.toString().trim(); - int lastNewLineIndex = terminalOutput.lastIndexOf(StringUtils.LF); - if (lastNewLineIndex <= 0) { - return; - } - - String lastLine = terminalOutput.substring(lastNewLineIndex).trim(); - - // Check if last line is a prompt line - // Mac always has single '%' as last line, that's not what we want. - if (StringUtils.isBlank(lastLine) || lastLine.length() == 1) { - return; - } - - char lastChar = lastLine.charAt(lastLine.length() - 1); - boolean isPromptChar = lastChar == '>' || lastChar == '#' || lastChar == '$' || lastChar == '%'; - if (!isPromptChar) { - return; - } - - // Extract result text between prompts - String contentWithoutLastPrompt = terminalOutput.substring(0, lastNewLineIndex); - int promptStartIndex = contentWithoutLastPrompt.indexOf(lastLine); - // If the prompt line is not found, set start index to 0. Sometimes it starts - // with the commandResult. - if (promptStartIndex == -1) { - promptStartIndex = 0; - } else { - promptStartIndex += lastLine.length(); - } - - if (!contentWithoutLastPrompt.isBlank()) { - String commandResult = contentWithoutLastPrompt.substring(promptStartIndex).trim(); - if (resultFuture != null && !resultFuture.isDone()) { - resultFuture.complete(commandResult); - } + if (!commandState.future().isDone()) { + commandState.future().complete(completionResult.output()); } } @@ -385,6 +414,9 @@ private String buildBackgroundTerminalTitle(String executionId) { return BACKGROUND_TERMINAL_PREFIX + executionId; } + private record ForegroundCommand(CompletableFuture future, boolean useMarker) { + } + /** * Get active workbench page without UiUtils dependency. */ diff --git a/com.microsoft.copilot.eclipse.ui.terminal/src/com/microsoft/copilot/eclipse/ui/terminal/RunInTerminalTool.java b/com.microsoft.copilot.eclipse.ui.terminal/src/com/microsoft/copilot/eclipse/ui/terminal/RunInTerminalTool.java index 721dc954..dfe44701 100644 --- a/com.microsoft.copilot.eclipse.ui.terminal/src/com/microsoft/copilot/eclipse/ui/terminal/RunInTerminalTool.java +++ b/com.microsoft.copilot.eclipse.ui.terminal/src/com/microsoft/copilot/eclipse/ui/terminal/RunInTerminalTool.java @@ -3,6 +3,7 @@ package com.microsoft.copilot.eclipse.ui.terminal; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -36,6 +37,9 @@ import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool; import com.microsoft.copilot.eclipse.terminal.api.ShellIntegrationScripts; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor.CompletionCheckResult; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor.CompletionCheckState; /** * Modern terminal tool implementation for newer Eclipse versions. @@ -46,6 +50,9 @@ public class RunInTerminalTool implements IRunInTerminalTool { private static final Object lock = new Object(); private static final Map backgroundCommandOutputs = new HashMap<>(); private static final String BACKGROUND_TERMINAL_PREFIX = "Copilot-"; + private static final String POWERSHELL_SCRIPT_ENV = "COPILOT_POWERSHELL_INTEGRATION_SCRIPT"; + private static final String COMMAND_CANCELLED_MESSAGE = "Terminal command cancelled."; + private static final String COMMAND_INTERRUPTED_MESSAGE = "Terminal command interrupted by a new command."; // Non-background terminal field private ITerminalViewControl persistentTerminalViewControl; @@ -59,9 +66,7 @@ public class RunInTerminalTool implements IRunInTerminalTool { // Output and command state private StringBuilder sb; - private CompletableFuture resultFuture; - private volatile boolean useMarker; - private volatile boolean isInitialMarkerHandled; + private volatile ForegroundCommand foregroundCommand; /** * Constructor for RunInTerminalTool. @@ -71,54 +76,60 @@ public RunInTerminalTool() { } @Override - public CompletableFuture executeCommand(String command, boolean isBackground) { + public CompletableFuture executeCommand(String command, boolean isBackground, String workingDirectory) { if (StringUtils.isBlank(command)) { return CompletableFuture.completedFuture("The command is null or empty."); } - resultFuture = new CompletableFuture<>(); - - // Linux uses shell-integration marker lines; others rely on prompt detection - useMarker = Platform.getOS().equals(Platform.OS_LINUX); + if (!isBackground) { + closeRunningForegroundTerminal(COMMAND_INTERRUPTED_MESSAGE); + } - if (!useMarker) { - // Retain only the last line (prompt) in the output buffer - if (!sb.isEmpty()) { - int lastLineStart = sb.lastIndexOf(StringUtils.LF); - if (lastLineStart > 0) { - sb.delete(0, lastLineStart); + CompletableFuture commandFuture = new CompletableFuture<>(); + ForegroundCommand commandState = isBackground ? null + : new ForegroundCommand(commandFuture, hasShellIntegrationMarker()); + + if (commandState != null) { + foregroundCommand = commandState; + if (!commandState.useMarker()) { + // Retain only the last line (prompt) in the output buffer + if (!sb.isEmpty()) { + int lastLineStart = sb.lastIndexOf(StringUtils.LF); + if (lastLineStart > 0) { + sb.delete(0, lastLineStart); + } } + } else { + // For marker-based detection, clear the buffer + sb.setLength(0); } - } else { - // For marker-based detection, clear the buffer - sb.setLength(0); } String executionId = UUID.randomUUID().toString(); - final String finalCommand = command + System.lineSeparator(); + final String finalCommand = TerminalCommandProcessor.formatForExecution(command, useBracketedPaste()); synchronized (lock) { if (!isBackground && this.persistentTerminalViewControl != null) { revealTerminal(); - this.persistentTerminalViewControl.pasteString(finalCommand); - return this.resultFuture; + sendCommand(this.persistentTerminalViewControl, finalCommand); + return commandFuture; } ITerminalService service = CoreBundleActivator.getTerminalService(); if (service == null) { + clearForegroundCommand(commandState); return CompletableFuture.completedFuture("Failed to open terminal console due to terminal service is null."); } - // New non-background terminal will have an initial marker from shell startup; need to handle it - if (useMarker && !isBackground) { - isInitialMarkerHandled = false; - } - - service.openConsole(prepareTerminalProperties(isBackground, executionId)).handle((o, e) -> { + service.openConsole(prepareTerminalProperties(isBackground, executionId, workingDirectory)).handle((o, e) -> { if (e == null) { + if (commandFuture.isDone()) { + return null; + } ITerminalViewControl terminalViewControl = finalizeTerminalSetup(executionId, isBackground); if (terminalViewControl == null) { - resultFuture.complete("Terminal view control cannot be setup for RunInTerminalTool."); + clearForegroundCommand(commandState); + commandFuture.complete("Terminal view control cannot be setup for RunInTerminalTool."); return null; } @@ -126,9 +137,10 @@ public CompletableFuture executeCommand(String command, boolean isBackgr this.persistentTerminalViewControl = terminalViewControl; revealTerminal(); } - terminalViewControl.pasteString(finalCommand); + sendCommand(terminalViewControl, finalCommand); } else { - resultFuture.complete("Failed to open terminal console: " + e.getMessage()); + clearForegroundCommand(commandState); + commandFuture.complete("Failed to open terminal console: " + e.getMessage()); } return null; }); @@ -138,25 +150,35 @@ public CompletableFuture executeCommand(String command, boolean isBackgr return CompletableFuture.completedFuture("Command is running in terminal with ID=" + executionId); } - return resultFuture; + return commandFuture; } @Override - public Map prepareTerminalProperties(boolean runInBackground, String executionId) { + public Map prepareTerminalProperties(boolean runInBackground, String executionId, + String workingDirectory) { Map properties = new HashMap<>(); properties.put(ITerminalsConnectorConstants.PROP_ENCODING, "UTF-8"); properties.put(ITerminalsConnectorConstants.PROP_TITLE_DISABLE_ANSI_TITLE, true); + if (StringUtils.isNotBlank(workingDirectory)) { + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_WORKING_DIR, workingDirectory); + } if (Platform.getOS().equals(Platform.OS_WIN32)) { - properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "cmd.exe"); - } else if (Platform.getOS().equals(Platform.OS_LINUX)) { - properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "/bin/sh"); - // Use ENV to load shell integration script at startup - String scriptPath = ShellIntegrationScripts.getShScriptPath(); + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "powershell.exe"); + String scriptPath = ShellIntegrationScripts.getPowerShellScriptPath(); if (scriptPath != null) { - properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ENVIRONMENT, new String[] { "ENV=" + scriptPath }); + String[] environment = new String[] { POWERSHELL_SCRIPT_ENV + "=" + scriptPath }; + String args = "-NoExit -ExecutionPolicy Bypass -Command \". $env:" + POWERSHELL_SCRIPT_ENV + "\""; + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ENVIRONMENT, environment); properties.put(ITerminalsConnectorConstants.PROP_PROCESS_MERGE_ENVIRONMENT, true); + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ARGS, args); + } + } else if (Platform.getOS().equals(Platform.OS_LINUX)) { + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "/bin/bash"); + String scriptPath = ShellIntegrationScripts.getBashScriptPath(); + if (scriptPath != null) { + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ARGS, "--init-file \"" + scriptPath + "\" -i"); } } else { // macOS or other Unix-like: keep existing behavior, only set args if empty @@ -190,6 +212,69 @@ public StringBuilder getBackgroundCommandOutput(String executionId) { return output; } + @Override + public void cancelCurrentCommand() { + closeRunningForegroundTerminal(COMMAND_CANCELLED_MESSAGE); + } + + private void closeRunningForegroundTerminal(String completionMessage) { + ForegroundCommand commandState = foregroundCommand; + if (commandState != null && !commandState.future().isDone()) { + closeCurrentForegroundTerminal(completionMessage); + } + } + + private void closeCurrentForegroundTerminal(String completionMessage) { + ForegroundCommand commandState = null; + CTabItem tabItem = null; + synchronized (lock) { + commandState = foregroundCommand; + foregroundCommand = null; + persistentTerminalViewControl = null; + tabItem = copilotTabItem; + copilotTabItem = null; + sb.setLength(0); + } + + if (tabItem != null) { + final CTabItem tabItemToDispose = tabItem; + // Keep this synchronous so a new foreground command cannot open before the old terminal tab is disposed. + Display.getDefault().syncExec(() -> { + if (!tabItemToDispose.isDisposed()) { + tabItemToDispose.dispose(); + } + }); + } + if (commandState != null && !commandState.future().isDone()) { + commandState.future().complete(completionMessage); + } + } + + private void clearForegroundCommand(ForegroundCommand commandState) { + if (commandState != null && foregroundCommand == commandState) { + foregroundCommand = null; + } + } + + private void sendCommand(ITerminalViewControl terminalViewControl, String command) { + terminalViewControl.pasteString(command); + } + + private boolean hasShellIntegrationMarker() { + if (Platform.getOS().equals(Platform.OS_WIN32)) { + return ShellIntegrationScripts.getPowerShellScriptPath() != null; + } + if (Platform.getOS().equals(Platform.OS_LINUX)) { + return ShellIntegrationScripts.getBashScriptPath() != null; + } + return false; + } + + private boolean useBracketedPaste() { + // macOS terminal multiline handling differs from PowerShell/Bash integration, so keep its existing plain input. + return Platform.getOS().equals(Platform.OS_WIN32) || Platform.getOS().equals(Platform.OS_LINUX); + } + private ITerminalViewControl finalizeTerminalSetup(String executionId, boolean isBackground) { String title = isBackground ? buildBackgroundTerminalTitle(executionId) : "Copilot"; synchronized (lock) { @@ -234,7 +319,7 @@ private ITerminalControl getTerminalControl(String terminalTitle, boolean isBack } } } catch (PartInitException e) { - //skip exception + // Skip exception } }); @@ -249,11 +334,9 @@ private ITerminalServiceOutputStreamMonitorListener buildOutputStreamMonitorList } return (byteBuffer, bytesRead) -> { - String content = new String(byteBuffer, 0, bytesRead); - // Remove ANSI escape sequences - // Sometimes it also removes the linebreaks. But we need the last prompt line to be a separate line later. So we - // add line separator back to the content. - content = content.replaceAll("\u001B\\[(\\?)?[\\d;]*[a-zA-Z]", StringUtils.LF); + String content = new String(byteBuffer, 0, bytesRead, StandardCharsets.UTF_8); + // Remove ANSI escape sequences while preserving only real line breaks from the terminal output. + content = content.replaceAll("\u001B\\[(\\?)?[\\d;]*[a-zA-Z]", ""); // Handle Windows terminal title sequences - using Platform instead of PlatformUtils if (Platform.getOS().equals(Platform.OS_WIN32)) { @@ -265,80 +348,25 @@ private ITerminalServiceOutputStreamMonitorListener buildOutputStreamMonitorList output.append(content); // Detect completion based on platform strategy - if (!isBackground && resultFuture != null && !resultFuture.isDone()) { - if (useMarker) { - tryCompleteWithMarker(output); - } else { - tryCompleteWithPrompt(output); - } + ForegroundCommand commandState = foregroundCommand; + if (!isBackground && commandState != null && !commandState.future().isDone()) { + CompletionCheckResult completionResult = commandState.useMarker() + ? TerminalCommandProcessor.tryCompleteWithMarker(output) + : TerminalCommandProcessor.tryCompleteWithPrompt(output); + handleCompletionResult(commandState, completionResult); } }; } - /** - * Attempts to complete the command by detecting the shell marker in output. - * Used on Linux where shell integration script outputs a marker after each command. - */ - private void tryCompleteWithMarker(StringBuilder output) { - int markerIndex = output.indexOf(ShellIntegrationScripts.SHELL_MARKER); - if (markerIndex < 0) { - return; - } - - // Remove marker from output - output.delete(markerIndex, markerIndex + ShellIntegrationScripts.SHELL_MARKER.length()); - - // Skip the initial marker that appears when terminal starts (before any command is run) - if (!isInitialMarkerHandled) { - isInitialMarkerHandled = true; - return; - } - - String cleaned = output.toString().trim(); - resultFuture.complete(cleaned); - } - - /** - * Attempts to complete the command by detecting a shell prompt in output. - * Used on Windows and macOS where prompt characters indicate command completion. - */ - private void tryCompleteWithPrompt(StringBuilder output) { - String terminalOutput = output.toString().trim(); - int lastNewLineIndex = terminalOutput.lastIndexOf(StringUtils.LF); - if (lastNewLineIndex <= 0) { - return; - } - - String lastLine = terminalOutput.substring(lastNewLineIndex).trim(); - - // Check if last line is a prompt line - // Mac always has single '%' as last line, that's not what we want. - if (StringUtils.isBlank(lastLine) || lastLine.length() == 1) { + private void handleCompletionResult(ForegroundCommand commandState, CompletionCheckResult completionResult) { + if (completionResult.state() == CompletionCheckState.INCOMPLETE) { return; } - - char lastChar = lastLine.charAt(lastLine.length() - 1); - boolean isPromptChar = lastChar == '>' || lastChar == '#' || lastChar == '$' || lastChar == '%'; - if (!isPromptChar) { - return; + if (foregroundCommand == commandState) { + foregroundCommand = null; } - - // Extract result text between prompts - String contentWithoutLastPrompt = terminalOutput.substring(0, lastNewLineIndex); - int promptStartIndex = contentWithoutLastPrompt.indexOf(lastLine); - // If the prompt line is not found, set start index to 0. Sometimes it starts - // with the commandResult. - if (promptStartIndex == -1) { - promptStartIndex = 0; - } else { - promptStartIndex += lastLine.length(); - } - - if (!contentWithoutLastPrompt.isBlank()) { - String commandResult = contentWithoutLastPrompt.substring(promptStartIndex).trim(); - if (resultFuture != null && !resultFuture.isDone()) { - resultFuture.complete(commandResult); - } + if (!commandState.future().isDone()) { + commandState.future().complete(completionResult.output()); } } @@ -385,6 +413,9 @@ private String buildBackgroundTerminalTitle(String executionId) { return BACKGROUND_TERMINAL_PREFIX + executionId; } + private record ForegroundCommand(CompletableFuture future, boolean useMarker) { + } + /** * Get active workbench page without UiUtils dependency. */ diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessorTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessorTest.java new file mode 100644 index 00000000..67e4607e --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessorTest.java @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.terminal.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor.CompletionCheckState; + +class TerminalCommandProcessorTest { + @Test + void testFormatForExecution_singleLine_appendsCarriageReturn() { + assertEquals("echo hello\r", TerminalCommandProcessor.formatForExecution("echo hello")); + } + + @Test + void testFormatForExecution_multiline_wrapsWithBracketedPaste() { + assertEquals("\u001b[200~echo first\recho second\u001b[201~\r", + TerminalCommandProcessor.formatForExecution("echo first\necho second")); + } + + @Test + void testFormatForExecution_multilineWithCrlf_normalizesLineEndings() { + assertEquals("\u001b[200~echo first\recho second\u001b[201~\r", + TerminalCommandProcessor.formatForExecution("echo first\r\necho second")); + } + + @Test + void testFormatForExecution_multilineWithTrailingNewline_doesNotSubmitEmptyCommand() { + assertEquals("\u001b[200~echo first\recho second\u001b[201~\r", + TerminalCommandProcessor.formatForExecution("echo first\necho second\n")); + } + + @Test + void testFormatForExecution_multilineWithoutBracketedPaste_submitsPlainLines() { + assertEquals("echo first\recho second\r", + TerminalCommandProcessor.formatForExecution("echo first\necho second", false)); + } + + @Test + void testFormatForExecution_singleLineWithTrailingNewline_doesNotUseBracketedPaste() { + assertEquals("echo hello\r", TerminalCommandProcessor.formatForExecution("echo hello\n")); + } + + @Test + void testFormatForExecution_backslashContinuation_doesNotUseBracketedPaste() { + assertEquals("echo hello \\" + "\r world\r", + TerminalCommandProcessor.formatForExecution("echo hello \\\n world")); + } + + @Test + void testTryCompleteWithMarker_completed_keepsNextPrompt() { + StringBuilder output = new StringBuilder("echo hi\r\n") + .append("hi\r\n") + .append(ShellIntegrationScripts.COMMAND_FINISH_MARKER_PREFIX).append("0\u0007") + .append(ShellIntegrationScripts.PROMPT_START_MARKER) + .append("PS C:\\projects\\copilot-eclipse> ") + .append(ShellIntegrationScripts.PROMPT_END_MARKER); + + var result = TerminalCommandProcessor.tryCompleteWithMarker(output); + + assertEquals(CompletionCheckState.COMPLETED, result.state()); + assertEquals("echo hi\nhi\nPS C:\\projects\\copilot-eclipse>", result.output()); + } + + @Test + void testTryCompleteWithMarker_completedWithBareMarker() { + StringBuilder output = new StringBuilder("echo hi\r\nhi\r\n]7775;C;0]7775;A$ ]7775;B"); + + var result = TerminalCommandProcessor.tryCompleteWithMarker(output); + + assertEquals(CompletionCheckState.COMPLETED, result.state()); + assertEquals("echo hi\nhi\n$", result.output()); + } + + @Test + void testTryCompleteWithMarker_incomplete_removesPromptMarkers() { + StringBuilder output = new StringBuilder() + .append(ShellIntegrationScripts.PROMPT_START_MARKER) + .append("PS C:\\projects\\copilot-eclipse> ") + .append(ShellIntegrationScripts.PROMPT_END_MARKER); + + var result = TerminalCommandProcessor.tryCompleteWithMarker(output); + + assertEquals(CompletionCheckState.INCOMPLETE, result.state()); + assertEquals("PS C:\\projects\\copilot-eclipse> ", output.toString()); + } + + @Test + void testTryCompleteWithMarker_completedPreservesOutputContainingCommandText() { + StringBuilder output = new StringBuilder("echo hi\r\n") + .append("before\r\necho hi\r\nafter\r\n") + .append(ShellIntegrationScripts.COMMAND_FINISH_MARKER_PREFIX).append("0\u0007") + .append(ShellIntegrationScripts.PROMPT_START_MARKER) + .append("$ ") + .append(ShellIntegrationScripts.PROMPT_END_MARKER); + + var result = TerminalCommandProcessor.tryCompleteWithMarker(output); + + assertEquals(CompletionCheckState.COMPLETED, result.state()); + assertEquals("echo hi\nbefore\necho hi\nafter\n$", result.output()); + } + + @Test + void testTryCompleteWithPrompt_completed_extractsOutputBetweenPrompts() { + StringBuilder output = new StringBuilder("PS C:\\repo> echo hi\nhi\nPS C:\\repo> "); + + var result = TerminalCommandProcessor.tryCompleteWithPrompt(output); + + assertEquals(CompletionCheckState.COMPLETED, result.state()); + assertEquals("echo hi\nhi", result.output()); + } + + @Test + void testTruncateOutput_shortOutput_returnsOriginalOutput() { + String output = "line 1\r\nline 2"; + + assertEquals(output, TerminalCommandProcessor.truncateOutput(output)); + } + + @Test + void testTruncateOutput_longOutput_keepsTailLines() { + StringBuilder output = new StringBuilder(); + for (int lineIndex = 1; lineIndex <= 1005; lineIndex++) { + output.append("line ").append(lineIndex).append('\n'); + } + + String result = TerminalCommandProcessor.truncateOutput(output.toString()); + + assertTrue(result.startsWith("[Terminal output truncated: showing last 1000 lines.]\n")); + assertFalse(result.contains("line 5\n")); + assertTrue(result.contains("line 6\n")); + assertTrue(result.endsWith("line 1005\n")); + } + + @Test + void testPrepareOutputForModel_removesCopilotShellMarkers() { + String output = "start\n" + ShellIntegrationScripts.PROMPT_START_MARKER + + "]7775;B\nbody\n]7775;C\nliteral 7775;A remains\n" + + ShellIntegrationScripts.COMMAND_FINISH_MARKER_PREFIX + "1\u0007end"; + + String result = TerminalCommandProcessor.prepareOutputForModel(output); + + assertEquals("start\n\nbody\n\nliteral 7775;A remains\nend", result); + } +} \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapterTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapterTest.java index 01ebcc41..5f6aa92c 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapterTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapterTest.java @@ -77,7 +77,8 @@ void testInvokeWithEmptyCommandReturnsErrorStatus() throws InterruptedException, assertNotNull(results); assertEquals(1, results.length); assertEquals(ToolInvocationStatus.error, results[0].getStatus()); - assertTrue(results[0].getContent().get(0).getValue().equals("No terminal implementation available. Terminal service not yet loaded or failed to load.")); + assertEquals("No terminal implementation available. Terminal service not yet loaded or failed to load.", + results[0].getContent().get(0).getValue()); } @Test @@ -113,7 +114,6 @@ void testToolName() { assertEquals("run_in_terminal", runInTerminalToolAdapter.getToolName()); } - @Test void testGetTerminalOutputToolWithNoTerminalServiceReturnsErrorStatus() throws InterruptedException, ExecutionException { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java index c8bff236..13e7fa09 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java @@ -79,6 +79,8 @@ import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData; import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; import com.microsoft.copilot.eclipse.core.persistence.UserTurnData; +import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool; +import com.microsoft.copilot.eclipse.terminal.api.TerminalServiceManager; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.UiConstants; import com.microsoft.copilot.eclipse.ui.chat.services.AgentToolService; @@ -1361,6 +1363,7 @@ public void onCancel() { this.subagentConversationId = null; this.lastRunSubagentToolCallId = null; + cancelCurrentTerminalCommand(); if (persistenceManager != null && StringUtils.isNotBlank(this.conversationId)) { persistenceManager.markRunningToolCallsCancelledAndPersist(this.conversationId); @@ -1376,6 +1379,18 @@ public void onCancel() { } } + private void cancelCurrentTerminalCommand() { + try { + TerminalServiceManager terminalManager = TerminalServiceManager.getInstance(); + IRunInTerminalTool terminalTool = terminalManager != null ? terminalManager.getCurrentService() : null; + if (terminalTool != null) { + terminalTool.cancelCurrentCommand(); + } + } catch (RuntimeException e) { + CopilotCore.LOGGER.error("Failed to cancel terminal command", e); + } + } + @Override public void onNewConversation() { clearCurrentConversation(); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapter.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapter.java index 085d4913..2bfbdc11 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapter.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapter.java @@ -3,12 +3,17 @@ package com.microsoft.copilot.eclipse.ui.chat.tools; +import java.io.File; +import java.net.URI; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import org.apache.commons.lang3.StringUtils; +import org.eclipse.core.resources.IResource; +import org.eclipse.lsp4j.WorkspaceFolder; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConfirmationMessages; import com.microsoft.copilot.eclipse.core.lsp.protocol.InputSchema; @@ -18,8 +23,13 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor; import com.microsoft.copilot.eclipse.terminal.api.TerminalServiceManager; +import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.ChatView; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.chat.services.ReferencedFileService; +import com.microsoft.copilot.eclipse.ui.utils.ResourceUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; /** @@ -38,18 +48,22 @@ public RunInTerminalToolAdapter() { private String buildToolDescription() { if (PlatformUtils.isWindows()) { return """ - Shell: cmd.exe + Shell: powershell.exe - This tool allows you to execute Windows Command Prompt commands in a persistent terminal session, \ + This tool allows you to execute PowerShell commands in a persistent terminal session, \ preserving environment variables, working directory, and other context across multiple commands. Use this tool instead of printing a shell codeblock and asking the user to run it. Command Execution: - - Use & to chain commands on one line (or && for conditional execution on success) - - Never create a sub-shell (e.g., cmd /c "command") unless explicitly asked - - Use pipelines | for data flow + - Use ; to chain commands on one line + - Never create a sub-shell (e.g., powershell -c "command") unless explicitly asked + - Prefer pipelines | for object-based data flow + - Multi-line commands must be complete and non-interactive. Do not run REPLs, continuation-prompt commands, + unmatched quotes or brackets, or commands that wait for stdin. For Python/Node scripts, create a file first, + then run it - Must use absolute paths to avoid navigation issues - If a command may use a pager, disable it with command flags (e.g., `git --no-pager`) + - Output returned to the model is automatically truncated to the last 1000 lines to prevent context overflow Background Processes: - For long-running tasks (e.g., servers), set isBackground=true @@ -58,19 +72,23 @@ private String buildToolDescription() { } if (PlatformUtils.isLinux()) { return """ - Shell: /bin/sh (POSIX shell) + Shell: /bin/bash - This tool allows you to execute POSIX shell commands in a persistent terminal session, \ + This tool allows you to execute Bash commands in a persistent terminal session, \ preserving environment variables, working directory, and other context across multiple commands. Use this tool instead of printing a shell codeblock and asking the user to run it. Command Execution: - - Use ; to chain commands on one line (or && for conditional execution on success) - - Never create a sub-shell (e.g., sh -c "command") unless explicitly asked + - Use && to chain commands on one line + - Never create a sub-shell (e.g., bash -c "command") unless explicitly asked - Prefer pipelines | for data flow + - Multi-line commands must be complete and non-interactive. Do not run REPLs, continuation-prompt commands, + unmatched quotes or brackets, or commands that wait for stdin. For Python/Node scripts, create a file first, + then run it - Must use absolute paths to avoid navigation issues - If a command may use a pager, disable it (e.g., `git --no-pager` or add `| cat`) - - Use POSIX-compliant syntax (avoid bash-specific features like arrays or [[ ]]) + - Bash syntax is supported, including arrays and [[ ]] + - Output returned to the model is automatically truncated to the last 1000 lines to prevent context overflow Background Processes: - For long-running tasks (e.g., servers), set isBackground=true @@ -87,8 +105,12 @@ private String buildToolDescription() { - Use && to chain commands on one line - Never create a sub-shell (e.g., bash -c "command") unless explicitly asked - Prefer pipelines | for data flow + - Multi-line commands must be complete and non-interactive. Do not run REPLs, continuation-prompt commands, + unmatched quotes or brackets, or commands that wait for stdin. For Python/Node scripts, create a file first, + then run it - Must use absolute paths to avoid navigation issues - If a command may use a pager, disable it (e.g., `git --no-pager` or add `| cat`) + - Output returned to the model is automatically truncated to the last 1000 lines to prevent context overflow Background Processes: - For long-running tasks (e.g., servers), set isBackground=true @@ -178,13 +200,55 @@ public CompletableFuture invoke(Map i } impl.setTerminalIconDescriptor(UiUtils.buildImageDescriptorFromPngPath("/icons/github_copilot.png")); + String workingDirectory = resolveWorkingDirectory(); - return impl.executeCommand(command, isBackground).thenApply( - result -> new LanguageModelToolResult[] { new LanguageModelToolResult(result, ToolInvocationStatus.success) }) + return impl.executeCommand(command, isBackground, workingDirectory) + .thenApply(result -> new LanguageModelToolResult[] { new LanguageModelToolResult( + TerminalCommandProcessor.prepareOutputForModel(result), ToolInvocationStatus.success) }) .exceptionally(throwable -> new LanguageModelToolResult[] { new LanguageModelToolResult( "Terminal execution failed: " + throwable.getMessage(), ToolInvocationStatus.error) }); } + static String resolveWorkingDirectoryFromResources(List resources) { + return ResourceUtils.deriveWorkspaceFoldersFrom(resources).stream() + .findFirst() + .map(RunInTerminalToolAdapter::toLocalPath) + .orElse(""); + } + + private static String resolveWorkingDirectory() { + ChatServiceManager manager = CopilotUi.getPlugin() != null ? CopilotUi.getPlugin().getChatServiceManager() : null; + if (manager != null) { + ReferencedFileService fileService = manager.getReferencedFileService(); + if (fileService != null) { + List resources = new ArrayList<>(); + if (fileService.getCurrentFile() != null) { + resources.add(fileService.getCurrentFile()); + } + if (fileService.getReferencedFiles() != null) { + resources.addAll(fileService.getReferencedFiles()); + } + String referencedLocation = resolveWorkingDirectoryFromResources(resources); + if (StringUtils.isNotBlank(referencedLocation)) { + return referencedLocation; + } + } + } + + return ""; + } + + private static String toLocalPath(WorkspaceFolder workspaceFolder) { + if (workspaceFolder == null || StringUtils.isBlank(workspaceFolder.getUri())) { + return ""; + } + try { + return new File(URI.create(workspaceFolder.getUri())).getPath(); + } catch (IllegalArgumentException e) { + return ""; + } + } + /** * Tool to retrieve the output of a terminal command that was previously started with run_in_terminal. */ @@ -204,7 +268,10 @@ public LanguageModelToolInformation getToolInformation() { // Set the name and description of the tool toolInfo.setName(TOOL_NAME); - toolInfo.setDescription("Get the output of a terminal command previous started with run_in_terminal."); + toolInfo.setDescription(""" + Get the output of a terminal command previously started with run_in_terminal. + Output returned to the model is automatically truncated to the last 1000 lines to prevent context overflow. + """); // Define the input schema for the tool InputSchema inputSchema = new InputSchema(); @@ -247,7 +314,7 @@ public CompletableFuture invoke(Map i toolResult.addContent("Invalid terminal ID " + id); } else { toolResult.setStatus(ToolInvocationStatus.success); - toolResult.addContent(output.toString()); + toolResult.addContent(TerminalCommandProcessor.prepareOutputForModel(output.toString())); } } resultFuture.complete(new LanguageModelToolResult[] { toolResult }); From e5e32082cd26ece79d70ab5676e1cb6e7a380140 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Wed, 27 May 2026 17:39:41 +0800 Subject: [PATCH 04/27] fix - context item accessibility issue (#260) --- .../ui/chat/CurrentReferencedFile.java | 6 ++++++ .../eclipse/ui/chat/ReferencedFile.java | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CurrentReferencedFile.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CurrentReferencedFile.java index 46f22d4d..19a9dde2 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CurrentReferencedFile.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CurrentReferencedFile.java @@ -92,6 +92,12 @@ public void setFile(IResource file) { super.setFile(file); } + @Override + protected @Nullable String getAccessibilityName() { + IResource file = getFile(); + return file == null ? null : Messages.chat_currentReferencedFile_description + " " + file.getName(); + } + /** * Set the current selection to display. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ReferencedFile.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ReferencedFile.java index 8c9faf9d..db63a981 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ReferencedFile.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ReferencedFile.java @@ -9,6 +9,8 @@ import org.eclipse.e4.ui.css.swt.CSSSWTConstants; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.swt.SWT; +import org.eclipse.swt.accessibility.AccessibleAdapter; +import org.eclipse.swt.accessibility.AccessibleEvent; import org.eclipse.swt.events.KeyAdapter; import org.eclipse.swt.events.KeyEvent; import org.eclipse.swt.events.MouseAdapter; @@ -19,6 +21,7 @@ import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.layout.RowData; import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Label; import org.eclipse.ui.ISharedImages; import org.eclipse.ui.PlatformUI; @@ -100,6 +103,7 @@ public void keyPressed(KeyEvent e) { }); AccessibilityUtils.addFocusBorderToComposite(this); + addAccessibilityName(this); } /** @@ -170,6 +174,22 @@ protected void setFile(@Nullable IResource file) { } } + /** + * Returns the accessible name for this referenced file widget. + */ + protected @Nullable String getAccessibilityName() { + return file == null ? null : file.getName(); + } + + private void addAccessibilityName(Control control) { + control.getAccessible().addAccessibleListener(new AccessibleAdapter() { + @Override + public void getName(AccessibleEvent event) { + event.result = getAccessibilityName(); + } + }); + } + /** * Setup display for unsupported files (e.g., images without vision support). */ From 2ad0568df587efc97efb1f8c58fb904b34ad5fc6 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Fri, 29 May 2026 10:35:22 +0800 Subject: [PATCH 05/27] feat - Support edit/create local files outside of workspace (#248) --- .../local-file-edit-and-create-tools.md | 194 ++++++++++ .../eclipse/ui/chat/WorkingSetBarTest.java | 41 ++- .../ui/chat/tools/CreateFileToolTest.java | 118 +++++- .../ui/chat/tools/EditFileToolTest.java | 170 +++++++++ .../eclipse/ui/chat/WorkingSetBar.java | 23 +- .../eclipse/ui/chat/tools/ChangedFile.java | 123 +++++++ .../eclipse/ui/chat/tools/CreateFileTool.java | 120 ++++-- .../eclipse/ui/chat/tools/EditFileTool.java | 134 ++++--- .../eclipse/ui/chat/tools/FileToolBase.java | 348 ++++++++++++------ .../ui/chat/tools/FileToolService.java | 85 ++--- .../ui/chat/tools/WorkingSetHandler.java | 21 +- .../copilot/eclipse/ui/utils/UiUtils.java | 25 ++ 12 files changed, 1103 insertions(+), 299 deletions(-) create mode 100644 com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md create mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileToolTest.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ChangedFile.java diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md new file mode 100644 index 00000000..ae46796c --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md @@ -0,0 +1,194 @@ +# Support Editing and Creating Local Files Outside the Workspace + +## Overview +Verify that Copilot Agent mode can edit and create local filesystem files that are outside the Eclipse workspace, and +that those changes are surfaced through the file change summary bar with the same review actions users expect for +workspace files. + +This covers the user-visible flow for the `insert_edit_into_file` and `create_file` tools when the target is an +absolute local path rather than an Eclipse `IFile`. + +Entry points: +- Window -> Show View -> Other... -> Copilot -> Copilot Chat -> Agent mode + +Not exercised: +- Direct unit-level invocation of the file tools. +- Workspace-file edit coverage. +- Low-level compare editor APIs; this plan verifies the Compare UI through the summary bar. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and activated. +- The user is signed in to GitHub Copilot and Agent mode is available in the Copilot Chat view. +- A writable local directory outside the Eclipse workspace is available, for example: + - Windows: `%TEMP%\\copilot-eclipse-local-file-tools` + - macOS/Linux: `/tmp/copilot-eclipse-local-file-tools` +- The local directory contains an existing text file named `existing-local-file.txt` with this content: + `before local edit` +- The local directory does not contain `created-local-file.txt` before the create-file test starts. + +--- + +## 1. Edit an existing local file outside the workspace + +### TC-001: Agent edits a local file and exposes the change in the summary bar + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in a fresh or cleared conversation. +- `existing-local-file.txt` exists outside the workspace and contains `before local edit`. + +#### Steps +1. Open **Copilot Chat** from `Window -> Show View -> Other... -> Copilot -> Copilot Chat`. +2. Switch the chat mode selector to **Agent**. +3. Send a prompt that asks Agent mode to edit the external local file by absolute path, for example: + `Edit so its entire content is exactly "after local edit".` +4. If Copilot asks for tool confirmation, approve the file edit operation. +5. Wait for the Agent turn to complete. +6. Verify the file change summary bar appears in the Chat view. +7. Verify the summary bar includes `existing-local-file.txt` and displays a local filesystem path for that file. +8. Click **View Diff** for `existing-local-file.txt`. +9. Verify the Compare editor opens and shows the original content `before local edit` against the modified content + `after local edit`. +10. Close the Compare editor. + +#### Expected Result +- Copilot completes the edit without reporting that the file is outside the workspace or cannot be edited. +- The local file on disk contains `after local edit`. +- The summary bar lists `existing-local-file.txt` even though it is not an Eclipse workspace file. +- The Compare editor opens from **View Diff** and shows the correct before/after content. +- No error dialog is shown. The Eclipse error log has no uncaught exception from `insert_edit_into_file`, local file + path handling, or compare editor creation. + +#### Key Screenshots +- [ ] **Agent edit prompt** -- Copilot Chat in Agent mode with the absolute local file path visible. +- [ ] **Summary bar after local edit** -- The changed local file appears in the file change summary bar. +- [ ] **Local file Compare editor** -- The Compare editor shows `before local edit` vs. `after local edit`. + +#### Notes on failure modes +- The edit succeeds on disk but the file is missing from the summary bar -- the local `Path` change may not be tracked + by the summary bar model. +- **View Diff** does nothing or throws an error -- local files may not be routed through the local Compare input path. +- The diff baseline shows the modified content on both sides -- the original content may not have been cached before + applying the edit. + +### TC-002: Keep clears the local file change and later edits use a new baseline + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in a fresh or cleared conversation. +- `existing-local-file.txt` exists outside the workspace and contains `before local edit`. +- Agent mode has edited `existing-local-file.txt` so it contains `after local edit`, and the file is listed in the + summary bar. + +#### Steps +1. Click **Keep** for `existing-local-file.txt` in the file change summary bar. +2. Verify the file is removed from the summary bar. +3. Send another Agent prompt to edit the same absolute file path so its entire content is exactly `second local edit`. +4. Approve the edit if prompted and wait for the turn to complete. +5. Click **View Diff** for `existing-local-file.txt`. +6. Verify the Compare editor shows `after local edit` as the original content and `second local edit` as the modified + content. + +#### Expected Result +- **Keep** accepts the current local file content and clears the tracked change. +- The next edit of the same local file starts a new diff baseline from the kept content. +- The file remains accessible through the summary bar and Compare editor after the second edit. + +#### Key Screenshots +- [ ] **After Keep** -- The summary bar no longer lists the local file. +- [ ] **Second local diff** -- The Compare editor shows the kept content as the new baseline. + +### TC-003: Undo restores the original local file content + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in a fresh or cleared conversation. +- `existing-local-file.txt` exists outside the workspace and contains `before local edit`. +- Agent mode has edited `existing-local-file.txt` so it contains `after local edit`, and the file is listed in the + summary bar. + +#### Steps +1. Click **Undo** for `existing-local-file.txt` in the file change summary bar. +2. Verify the file is removed from the summary bar. +3. Open `existing-local-file.txt` from the local filesystem and inspect its content. + +#### Expected Result +- **Undo** restores the file to the original content captured before the tracked edit. +- The file is removed from the summary bar after undo completes. +- No error dialog is shown and the Eclipse error log has no local file undo exception. + +#### Key Screenshots +- [ ] **Before Undo** -- The summary bar lists the edited local file. +- [ ] **After Undo** -- The summary bar no longer lists the local file and the file content is restored. + +--- + +## 2. Create a new local file outside the workspace + +### TC-004: Agent creates a local file and opens it from the summary bar + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- `created-local-file.txt` does not exist in the local test directory. +- Copilot Chat is open in Agent mode. + +#### Steps +1. Send a prompt that asks Agent mode to create the external local file by absolute path, for example: + `Create with the exact content "created local content".` +2. If Copilot asks for tool confirmation, approve the file create operation. +3. Wait for the Agent turn to complete. +4. Verify `created-local-file.txt` exists on disk and contains `created local content`. +5. Verify the file change summary bar lists `created-local-file.txt`. +6. Click **View Diff** for `created-local-file.txt`. +7. Verify Eclipse opens `created-local-file.txt` in an editor and shows `created local content`. + +#### Expected Result +- Copilot creates the local file without requiring it to be inside an Eclipse workspace project. +- The created file is listed in the summary bar. +- The created local file can be opened from the summary bar. +- No error dialog is shown and the Eclipse error log has no local file create or editor-open exception. + +#### Key Screenshots +- [ ] **Agent create prompt** -- Copilot Chat in Agent mode with the absolute create path visible. +- [ ] **Summary bar after local create** -- The created local file appears in the file change summary bar. +- [ ] **Created local file editor** -- The external local file opens in an editor with the created content. + +### TC-005: Undo removes a created local file + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in Agent mode. +- `created-local-file.txt` does not exist in the local test directory. +- Agent mode has created `created-local-file.txt` with content `created local content`, and the file is listed in the + summary bar. + +#### Steps +1. Click **Undo** for `created-local-file.txt` in the file change summary bar. +2. Verify the file is removed from the summary bar. +3. Verify `created-local-file.txt` no longer exists on disk. + +#### Expected Result +- **Undo** for a created local file deletes the file, matching the create-file semantics. +- The summary bar no longer lists the created file after undo completes. +- No error dialog is shown and the Eclipse error log has no local file deletion exception. + +#### Key Screenshots +- [ ] **Before created-file Undo** -- The summary bar lists `created-local-file.txt`. +- [ ] **After created-file Undo** -- The summary bar is clear and the file is absent from disk. diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBarTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBarTest.java index d44508a3..3f5a288c 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBarTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBarTest.java @@ -38,6 +38,7 @@ import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.chat.tools.ChangedFile; import com.microsoft.copilot.eclipse.ui.chat.tools.FileToolService; import com.microsoft.copilot.eclipse.ui.chat.tools.FileToolService.FileChangeProperty; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; @@ -103,7 +104,7 @@ private void setupMocks() { void testNoScrollForFewFiles() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(3, false); + Map filesMap = createMockFilesMap(3); workingSetBar.buildSummaryBarFor(filesMap); @@ -122,7 +123,7 @@ void testNoScrollForFewFiles() { void testNoScrollForExactlyMaxFiles() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(5, false); + Map filesMap = createMockFilesMap(5); workingSetBar.buildSummaryBarFor(filesMap); @@ -141,7 +142,7 @@ void testNoScrollForExactlyMaxFiles() { void testScrollCreatedForManyFiles() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(10, false); + Map filesMap = createMockFilesMap(10); workingSetBar.buildSummaryBarFor(filesMap); @@ -164,7 +165,7 @@ void testScrollCreatedForManyFiles() { void testScrollHeightHintForManyFiles() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(8, false); + Map filesMap = createMockFilesMap(8); workingSetBar.buildSummaryBarFor(filesMap); @@ -190,7 +191,7 @@ void testAllFileRowsRenderedWithScroll() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); int fileCount = 7; - Map filesMap = createMockFilesMap(fileCount, false); + Map filesMap = createMockFilesMap(fileCount); workingSetBar.buildSummaryBarFor(filesMap); @@ -215,7 +216,7 @@ void testAllFileRowsRenderedWithScroll() { void testContentAreaSetInScrolledComposite() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(8, false); + Map filesMap = createMockFilesMap(8); workingSetBar.buildSummaryBarFor(filesMap); @@ -242,7 +243,7 @@ void testContentAreaSetInScrolledComposite() { void testMinHeightSetForScrolledComposite() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(10, false); + Map filesMap = createMockFilesMap(10); workingSetBar.buildSummaryBarFor(filesMap); @@ -266,7 +267,7 @@ void testRebuildSummaryBarChangesScrollBehavior() { workingSetBar = new WorkingSetBar(parent, SWT.NONE); // First build with few files (no scroll) - Map fewFiles = createMockFilesMap(3, false); + Map fewFiles = createMockFilesMap(3); workingSetBar.buildSummaryBarFor(fewFiles); Object changedFiles1 = getFieldValue(workingSetBar, "changedFiles"); @@ -275,7 +276,7 @@ void testRebuildSummaryBarChangesScrollBehavior() { assertNull(scroll1, "No scroll should exist for 3 files"); // Rebuild with many files (should have scroll) - Map manyFiles = createMockFilesMap(10, false); + Map manyFiles = createMockFilesMap(10); workingSetBar.buildSummaryBarFor(manyFiles); Object changedFiles2 = getFieldValue(workingSetBar, "changedFiles"); @@ -294,7 +295,7 @@ void testRebuildSummaryBarChangesScrollBehavior() { void testExpandIconImageWhenExpanded() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(3, false); + Map filesMap = createMockFilesMap(3); workingSetBar.buildSummaryBarFor(filesMap); @@ -322,7 +323,7 @@ void testExpandIconImageWhenExpanded() { void testExpandIconImageWhenCollapsed() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(3, false); + Map filesMap = createMockFilesMap(3); workingSetBar.buildSummaryBarFor(filesMap); @@ -354,7 +355,7 @@ void testExpandIconImageWhenCollapsed() { void testTooltipTextWhenExpanded() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(3, false); + Map filesMap = createMockFilesMap(3); workingSetBar.buildSummaryBarFor(filesMap); @@ -395,7 +396,7 @@ void testTooltipTextWhenExpanded() { void testTooltipTextWhenCollapsed() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(5, false); + Map filesMap = createMockFilesMap(5); workingSetBar.buildSummaryBarFor(filesMap); @@ -436,7 +437,7 @@ void testTooltipTextWhenCollapsed() { void testTooltipAndImageToggleBehavior() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(4, false); + Map filesMap = createMockFilesMap(4); workingSetBar.buildSummaryBarFor(filesMap); @@ -476,7 +477,7 @@ void testTooltipContainsCorrectFileCount() { workingSetBar = new WorkingSetBar(parent, SWT.NONE); // Test with 1 file - Map oneFile = createMockFilesMap(1, false); + Map oneFile = createMockFilesMap(1); workingSetBar.buildSummaryBarFor(oneFile); Object titleBar = getFieldValue(workingSetBar, "titleBar"); @@ -488,7 +489,7 @@ void testTooltipContainsCorrectFileCount() { "Tooltip should contain 'file' (singular)"); // Test with 10 files - Map tenFiles = createMockFilesMap(10, false); + Map tenFiles = createMockFilesMap(10); workingSetBar.buildSummaryBarFor(tenFiles); titleBar = getFieldValue(workingSetBar, "titleBar"); @@ -508,7 +509,7 @@ void testTooltipContainsCorrectFileCount() { void testEmptyFilesMapDoesNotCreateChangedFiles() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map emptyMap = new LinkedHashMap<>(); + Map emptyMap = new LinkedHashMap<>(); workingSetBar.buildSummaryBarFor(emptyMap); @@ -524,11 +525,11 @@ void testEmptyFilesMapDoesNotCreateChangedFiles() { /** * Creates a map of mock files with the specified count. */ - private Map createMockFilesMap(int count, boolean isHandled) { - Map filesMap = new LinkedHashMap<>(); + private Map createMockFilesMap(int count) { + Map filesMap = new LinkedHashMap<>(); for (int i = 0; i < count; i++) { IFile mockFile = createMockFile("TestFile" + i + ".java"); - filesMap.put(mockFile, new FileChangeProperty(FileChangeType.Created)); + filesMap.put(ChangedFile.workspace(mockFile), new FileChangeProperty(FileChangeType.Created)); } return filesMap; } diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileToolTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileToolTest.java index 4bd2c9ed..428d7297 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileToolTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileToolTest.java @@ -5,10 +5,15 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.HashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -19,12 +24,14 @@ import org.eclipse.core.resources.IWorkspace; import org.eclipse.core.resources.IWorkspaceRoot; import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.lsp4j.FileChangeType; import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Shell; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; @@ -52,6 +59,9 @@ class CreateFileToolTest { @Mock private FileToolService mockFileToolService; + @TempDir + private Path tempDir; + private MockedStatic mockedCopilotUi; @BeforeEach @@ -78,6 +88,7 @@ void tearDown() throws Exception { // Clean up test project cleanupTestProject(); + FileToolCacheAccessor.clearCaches(); } private IProject setupTestProject() throws Exception { @@ -251,11 +262,92 @@ void testInvokeWithNullContentReturnsSuccessStatus() throws Exception { assertTrue(newFile.exists()); } + @Test + void testInvokeWithExternalLocalFilePathCreatesFile() throws Exception { + setupMocks(); + Path newFile = tempDir.resolve("external-file.txt"); + + Map input = new HashMap<>(); + input.put("filePath", newFile.toString()); + input.put("content", "test content"); + + CompletableFuture future = createFileTool.invoke(input, null); + LanguageModelToolResult[] results = future.get(); + + assertSuccessResult(results, "File created at"); + assertTrue(Files.exists(newFile)); + assertEquals("test content", Files.readString(newFile)); + verify(mockFileToolService).addChangedFile(ChangedFile.local(newFile), FileChangeType.Created); + } + + @Test + void testInvokeWithExternalLocalFileUriCreatesFile() throws Exception { + setupMocks(); + Path newFile = tempDir.resolve("external-file-uri.txt"); + + Map input = new HashMap<>(); + input.put("filePath", newFile.toUri().toString()); + input.put("content", "test content"); + + CompletableFuture future = createFileTool.invoke(input, null); + LanguageModelToolResult[] results = future.get(); + + assertSuccessResult(results, "File created at"); + assertTrue(Files.exists(newFile)); + assertEquals("test content", Files.readString(newFile)); + verify(mockFileToolService).addChangedFile(ChangedFile.local(newFile), FileChangeType.Created); + } + + @Test + void testOnKeepChangeWithWorkspaceFileClearsOriginalContentCache() { + IFile newFile = mock(IFile.class); + FileToolCacheAccessor.putWorkspaceFileContentCache(newFile, ""); + + createFileTool.onKeepChange(ChangedFile.workspace(newFile)); + + assertNull(FileToolCacheAccessor.getWorkspaceFileContentCache(newFile)); + } + + @Test + void testOnUndoChangeWithWorkspaceFileDeletesFileAndClearsOriginalContentCache() throws Exception { + IProject project = setupTestProject(); + IFile newFile = project.getFile("workspace-file-to-undo.txt"); + newFile.create(new java.io.ByteArrayInputStream("test content".getBytes()), true, null); + FileToolCacheAccessor.putWorkspaceFileContentCache(newFile, ""); + + createFileTool.onUndoChange(ChangedFile.workspace(newFile)); + + assertTrue(!newFile.exists()); + assertNull(FileToolCacheAccessor.getWorkspaceFileContentCache(newFile)); + } + + @Test + void testOnKeepChangeWithExternalLocalFileClearsOriginalContentCache() { + Path newFile = tempDir.resolve("external-file-to-keep.txt"); + FileToolCacheAccessor.putFileContentCache(newFile, ""); + + createFileTool.onKeepChange(ChangedFile.local(newFile)); + + assertNull(FileToolCacheAccessor.getFileContentCache(newFile)); + } + + @Test + void testOnUndoChangeWithExternalLocalFileDeletesFile() throws Exception { + Path newFile = tempDir.resolve("external-file-to-undo.txt"); + Files.writeString(newFile, "test content"); + FileToolCacheAccessor.putFileContentCache(newFile, ""); + + createFileTool.onUndoChange(ChangedFile.local(newFile)); + + assertTrue(Files.notExists(newFile)); + assertNull(FileToolCacheAccessor.getFileContentCache(newFile)); + } + @Test void testInvokeWithInvalidPathReturnsErrorStatus() throws InterruptedException, ExecutionException { // Arrange Map input = new HashMap<>(); - input.put("filePath", "/invalid/path/that/does/not/exist.txt"); + input.put("filePath", "relative/path/that/does/not/exist.txt"); input.put("content", "test content"); // Act @@ -263,7 +355,7 @@ void testInvokeWithInvalidPathReturnsErrorStatus() throws InterruptedException, LanguageModelToolResult[] results = future.get(); // Assert - assertErrorResult(results, "Error creating file"); + assertErrorResult(results, "does not exist in the workspace"); } @Test @@ -286,4 +378,26 @@ void testToolName() { * Note: CoreException and IOException scenarios are difficult to test in unit tests * without complex mocking and would be better covered by integration tests. */ + + private static final class FileToolCacheAccessor extends CreateFileTool { + private static void clearCaches() { + fileContentCache.clear(); + } + + private static void putWorkspaceFileContentCache(IFile file, String content) { + fileContentCache.put(ChangedFile.workspace(file), content); + } + + private static String getWorkspaceFileContentCache(IFile file) { + return fileContentCache.get(ChangedFile.workspace(file)); + } + + private static void putFileContentCache(Path file, String content) { + fileContentCache.put(ChangedFile.local(file), content); + } + + private static String getFileContentCache(Path file) { + return fileContentCache.get(ChangedFile.local(file)); + } + } } diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileToolTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileToolTest.java new file mode 100644 index 00000000..4ffabcbd --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileToolTest.java @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.lsp4j.FileChangeType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; + +@ExtendWith(MockitoExtension.class) +class EditFileToolTest { + + @TempDir + Path tempDir; + + @Mock + private CopilotUi mockCopilotUi; + @Mock + private ChatServiceManager mockChatServiceManager; + @Mock + private FileToolService mockFileToolService; + + private MockedStatic mockedCopilotUi; + + private void setupMocks() { + mockedCopilotUi = mockStatic(CopilotUi.class); + mockedCopilotUi.when(CopilotUi::getPlugin).thenReturn(mockCopilotUi); + when(mockCopilotUi.getChatServiceManager()).thenReturn(mockChatServiceManager); + when(mockChatServiceManager.getFileToolService()).thenReturn(mockFileToolService); + } + + @AfterEach + void tearDown() { + if (mockedCopilotUi != null) { + mockedCopilotUi.close(); + } + FileToolCacheAccessor.clearCaches(); + } + + @Test + void testInvoke_withExternalLocalFilePath_editsFile() throws Exception { + setupMocks(); + Path file = tempDir.resolve("target.txt"); + Files.writeString(file, "original"); + + LanguageModelToolResult[] results = invokeEdit(file.toString(), "updated"); + + assertSuccess(results, "updated"); + assertEquals("updated", Files.readString(file)); + verify(mockFileToolService).addChangedFile(ChangedFile.local(file), FileChangeType.Changed); + } + + @Test + void testInvoke_withExternalLocalFileUri_editsFile() throws Exception { + setupMocks(); + Path file = tempDir.resolve("target.patch"); + Files.writeString(file, "old patch content"); + + LanguageModelToolResult[] results = invokeEdit(file.toUri().toString(), "new patch content"); + + assertSuccess(results, "new patch content"); + assertEquals("new patch content", Files.readString(file)); + verify(mockFileToolService).addChangedFile(ChangedFile.local(file), FileChangeType.Changed); + } + + @Test + void testOnUndoChange_withExternalLocalFile_restoresOriginalContent() throws Exception { + setupMocks(); + Path file = tempDir.resolve("target-to-undo.txt"); + Files.writeString(file, "original"); + + EditFileTool editFileTool = new EditFileTool(); + LanguageModelToolResult[] results = invokeEdit(editFileTool, file.toString(), "updated"); + assertSuccess(results, "updated"); + + editFileTool.onUndoChange(ChangedFile.local(file)); + + assertEquals("original", Files.readString(file)); + } + + @Test + void testInvoke_createThenEditExternalLocalFile_preservesEmptyBaseline() throws Exception { + setupMocks(); + Path file = tempDir.resolve("created-then-edited.txt"); + Path normalizedPath = file.toAbsolutePath().normalize(); + + CreateFileTool createFileTool = new CreateFileTool(); + LanguageModelToolResult[] createResults = invokeCreate(createFileTool, file.toString(), "created content"); + assertSuccess(createResults, "File created at: " + normalizedPath); + assertEquals("", FileToolCacheAccessor.getFileContentCache(normalizedPath)); + + EditFileTool editFileTool = new EditFileTool(); + LanguageModelToolResult[] editResults = invokeEdit(editFileTool, file.toString(), "edited content"); + + assertSuccess(editResults, "edited content"); + assertEquals("edited content", Files.readString(file)); + assertEquals("", FileToolCacheAccessor.getFileContentCache(normalizedPath)); + } + + @Test + void testInvoke_withMissingExternalLocalFile_returnsError() throws Exception { + LanguageModelToolResult[] results = invokeEdit(tempDir.resolve("missing.txt").toString(), "updated"); + + assertNotNull(results); + assertEquals(1, results.length); + assertEquals(ToolInvocationStatus.error, results[0].getStatus()); + } + + private LanguageModelToolResult[] invokeEdit(String filePath, String code) throws Exception { + return invokeEdit(new EditFileTool(), filePath, code); + } + + private LanguageModelToolResult[] invokeEdit(EditFileTool editFileTool, String filePath, String code) + throws Exception { + Map input = new HashMap<>(); + input.put("filePath", filePath); + input.put("code", code); + input.put("explanation", "test edit"); + + return editFileTool.invoke(input, null).get(); + } + + private LanguageModelToolResult[] invokeCreate(CreateFileTool createFileTool, String filePath, String content) + throws Exception { + Map input = new HashMap<>(); + input.put("filePath", filePath); + input.put("content", content); + + return createFileTool.invoke(input, null).get(); + } + + private void assertSuccess(LanguageModelToolResult[] results, String expectedContent) throws IOException { + assertNotNull(results); + assertEquals(1, results.length); + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + assertEquals(expectedContent, results[0].getContent().get(0).getValue()); + } + + private static final class FileToolCacheAccessor extends EditFileTool { + private static void clearCaches() { + fileContentCache.clear(); + } + + private static String getFileContentCache(Path file) { + return fileContentCache.get(ChangedFile.local(file)); + } + } +} \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBar.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBar.java index cc6a8a5a..1c0780a6 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBar.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBar.java @@ -7,7 +7,6 @@ import java.util.List; import java.util.Map; -import org.eclipse.core.resources.IFile; import org.eclipse.e4.ui.services.IStylingEngine; import org.eclipse.osgi.util.NLS; import org.eclipse.swt.SWT; @@ -31,6 +30,7 @@ import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.services.ChatFontService; +import com.microsoft.copilot.eclipse.ui.chat.tools.ChangedFile; import com.microsoft.copilot.eclipse.ui.chat.tools.FileToolService; import com.microsoft.copilot.eclipse.ui.chat.tools.FileToolService.FileChangeProperty; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; @@ -70,7 +70,7 @@ public WorkingSetBar(Composite parent, int style) { * * @param filesMap a map of files and their change status */ - public void buildSummaryBarFor(Map filesMap) { + public void buildSummaryBarFor(Map filesMap) { if (filesMap == null || isDisposed()) { return; } @@ -167,7 +167,7 @@ class WorkingSetTitleBar extends Composite { private Button undoButton; private String changeFilesTitle; - public WorkingSetTitleBar(Composite parent, int style, Map filesMap) { + public WorkingSetTitleBar(Composite parent, int style, Map filesMap) { super(parent, style); GridLayout gl = new GridLayout(3, false); gl.marginWidth = 0; @@ -302,9 +302,10 @@ class ChangedFiles extends Composite { private static final int MAX_VISIBLE_FILES = 5; private final Composite contentArea; private final ScrolledComposite scrolledComposite; + private final WorkbenchLabelProvider labelProvider = new WorkbenchLabelProvider(); private List fileRows; // List to keep track of file rows - public ChangedFiles(Composite parent, int style, Map filesMap) { + public ChangedFiles(Composite parent, int style, Map filesMap) { super(parent, style); // Main layout @@ -313,6 +314,7 @@ public ChangedFiles(Composite parent, int style, Map layout.marginHeight = 0; setLayout(layout); setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + addDisposeListener(e -> labelProvider.dispose()); // Count files long fileCount = filesMap.size(); @@ -345,15 +347,14 @@ public ChangedFiles(Composite parent, int style, Map contentArea.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); } - // TODO: Should share a same instance with ReferencedFile - WorkbenchLabelProvider labelProvider = new WorkbenchLabelProvider(); fileRows = new LinkedList<>(); - for (IFile file : filesMap.keySet()) { + for (ChangedFile file : filesMap.keySet()) { if (file == null) { continue; } - Image image = labelProvider.getImage(file); + Image image = file.isWorkspaceFile() ? labelProvider.getImage(file.getWorkspaceFile()) + : PlatformUI.getWorkbench().getSharedImages().getImage(ISharedImages.IMG_OBJ_FILE); fileRows.add(new FileRow(contentArea, SWT.NONE, image, file)); } @@ -396,7 +397,7 @@ public class FileRow extends Composite { /** * Constructs a new FileRow. */ - public FileRow(Composite parent, int style, Image fileImage, IFile file) { + public FileRow(Composite parent, int style, Image fileImage, ChangedFile file) { super(parent, style); GridLayout layout = new GridLayout(2, false); @@ -434,7 +435,7 @@ public void mouseUp(MouseEvent e) { // File name (bold) Label nameLabel = new Label(fileInfo, SWT.NONE); nameLabel.setText(file.getName()); - nameLabel.setToolTipText(file.getFullPath().toString()); + nameLabel.setToolTipText(file.getDisplayPath()); nameLabel.setLayoutData(new GridData(SWT.BEGINNING, SWT.CENTER, false, false)); nameLabel.addMouseListener(new MouseAdapter() { @Override @@ -466,7 +467,7 @@ public void mouseUp(MouseEvent e) { // File path CLabel pathLabel = new CLabel(fileInfo, SWT.NONE); - pathLabel.setText(file.getFullPath().toString()); + pathLabel.setText(file.getDisplayPath()); pathLabel.setLayoutData(new GridData(SWT.BEGINNING, SWT.CENTER, true, false)); pathLabel.addMouseListener(new MouseAdapter() { @Override diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ChangedFile.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ChangedFile.java new file mode 100644 index 00000000..aed4594c --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ChangedFile.java @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.file.Path; +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.eclipse.core.resources.IFile; + +/** + * Represents a file tracked in the file change summary bar. + */ +public final class ChangedFile { + private final IFile workspaceFile; + private final Path localPath; + + private ChangedFile(IFile workspaceFile, Path localPath) { + this.workspaceFile = workspaceFile; + this.localPath = localPath; + } + + /** + * Creates a changed file entry for a workspace file. + * + * @param file the workspace file + * @return the changed file entry + */ + public static ChangedFile workspace(IFile file) { + return new ChangedFile(Objects.requireNonNull(file), null); + } + + /** + * Creates a changed file entry for a local file. + * + * @param path the local file path + * @return the changed file entry + */ + public static ChangedFile local(Path path) { + return new ChangedFile(null, normalize(path)); + } + + /** + * Returns true if this entry represents a workspace file. + * + * @return true for workspace files, false for local files + */ + public boolean isWorkspaceFile() { + return workspaceFile != null; + } + + /** + * Gets the workspace file for this entry. + * + * @return the workspace file, or null for local files + */ + public IFile getWorkspaceFile() { + return workspaceFile; + } + + /** + * Gets the local path for this entry. + * + * @return the local path, or null for workspace files + */ + public Path getLocalPath() { + return localPath; + } + + /** + * Gets the display name for this file. + * + * @return the file name + */ + public String getName() { + if (workspaceFile != null) { + return workspaceFile.getName(); + } + Path fileName = localPath.getFileName(); + return fileName == null ? localPath.toString() : fileName.toString(); + } + + /** + * Gets the display path for this file. + * + * @return the workspace path or local filesystem path + */ + public String getDisplayPath() { + if (workspaceFile != null) { + return workspaceFile.getFullPath().toString(); + } + return localPath.toString(); + } + + private static Path normalize(Path path) { + return Objects.requireNonNull(path).toAbsolutePath().normalize(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof ChangedFile other)) { + return false; + } + return Objects.equals(workspaceFile, other.workspaceFile) && Objects.equals(localPath, other.localPath); + } + + @Override + public int hashCode() { + return Objects.hash(workspaceFile, localPath); + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("workspaceFile", workspaceFile); + builder.append("localPath", localPath); + return builder.toString(); + } +} \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java index 29a807ce..c7795aeb 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java @@ -5,9 +5,13 @@ import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.util.Arrays; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -19,6 +23,7 @@ import org.eclipse.core.runtime.NullProgressMonitor; import org.eclipse.lsp4j.FileChangeType; +import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.lsp.protocol.InputSchema; import com.microsoft.copilot.eclipse.core.lsp.protocol.InputSchemaPropertyValue; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; @@ -57,7 +62,7 @@ public LanguageModelToolInformation getToolInformation() { // Set the name and description of the tool toolInfo.setName(TOOL_NAME); toolInfo.setDescription(""" - This is a tool for creating a new file in the workspace. + This is a tool for creating a new workspace file or a new file at an absolute local filesystem path. The file will be created with the specified content. """); @@ -90,34 +95,49 @@ public CompletableFuture invoke(Map i return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); } - try { - // Resolve file in workspace - IFile file = FileUtils.getFileFromPath(filePath, false); + String content = StringUtils.isBlank((String) input.get("content")) ? "" : (String) input.get("content"); + result = createFile(filePath, content); - if (file == null) { - result.setStatus(ToolInvocationStatus.error); - result.addContent("Invalid file path: " + filePath + " does not exist in the workspace."); - return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); - } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + private LanguageModelToolResult createFile(String filePath, String content) { + IFile file = FileUtils.getFileFromPath(filePath, false); + + if (file != null && file.getProject().exists()) { + return createWorkspaceFile(file, filePath, content); + } + + Path localPath = getLocalFilePath(filePath); + if (localPath != null) { + return createLocalFile(localPath, content); + } - // Check if file already exists + LanguageModelToolResult result = new LanguageModelToolResult(); + result.setStatus(ToolInvocationStatus.error); + result.addContent("Invalid file path: " + filePath + " does not exist in the workspace."); + return result; + } + + private LanguageModelToolResult createWorkspaceFile(IFile file, String filePath, String content) { + LanguageModelToolResult result = new LanguageModelToolResult(); + + try { if (file.exists()) { result.setStatus(ToolInvocationStatus.error); result.addContent("Failed: file already exists: " + filePath + ". Please use edit file tool to update."); - return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + return result; } - // Create parent folders if needed createParentFolders(file.getParent()); - // Create file with content - String content = StringUtils.isBlank((String) input.get("content")) ? "" : (String) input.get("content"); try (ByteArrayInputStream contentStream = new ByteArrayInputStream( content.getBytes(PlatformUtils.getFileCharset(file)))) { file.create(contentStream, IResource.FORCE, new NullProgressMonitor()); - cacheTheOriginalFileContent(file); + cacheTheOriginalFileContent(ChangedFile.workspace(file), StringUtils.EMPTY); } - CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(file, FileChangeType.Created); + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(ChangedFile.workspace(file), + FileChangeType.Created); file.refreshLocal(IResource.DEPTH_ZERO, new NullProgressMonitor()); result.addContent("File created at: " + file.getFullPath().toOSString()); @@ -130,7 +150,36 @@ public CompletableFuture invoke(Map i result.addContent("Error handling file stream: " + e.getMessage()); } - return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + return result; + } + + private LanguageModelToolResult createLocalFile(Path filePath, String content) { + LanguageModelToolResult result = new LanguageModelToolResult(); + Path normalizedPath = normalizeLocalPath(filePath); + if (Files.exists(normalizedPath, LinkOption.NOFOLLOW_LINKS)) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed: file already exists: " + normalizedPath + ". Please use edit file tool to update."); + return result; + } + + try { + Path parent = normalizedPath.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.writeString(normalizedPath, content, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW); + cacheTheOriginalFileContent(ChangedFile.local(normalizedPath), StringUtils.EMPTY); + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile( + ChangedFile.local(normalizedPath), FileChangeType.Created); + result.addContent("File created at: " + normalizedPath); + result.setStatus(ToolInvocationStatus.success); + } catch (IOException e) { + CopilotCore.LOGGER.error("Error creating local file", e); + result.setStatus(ToolInvocationStatus.error); + result.addContent("Error creating file: " + e.getMessage()); + } + + return result; } /** @@ -152,33 +201,36 @@ private void createParentFolders(IResource parent) throws CoreException { } @Override - public void onKeepAllChanges(List files) { - files.forEach(this::onKeepChange); + public void onKeepChange(ChangedFile file) { + removeCachedFileContent(file); + closeCompareEditor(file); } @Override - public void onKeepChange(IFile file) { + public void onUndoChange(ChangedFile file) throws CoreException, IOException { + deleteCreatedFile(file); + removeCachedFileContent(file); closeCompareEditor(file); } - @Override - public void onUndoAllChanges(List files) throws CoreException { - for (IFile file : files) { - onUndoChange(file); + private void deleteCreatedFile(ChangedFile file) throws CoreException, IOException { + if (file.isWorkspaceFile()) { + IFile workspaceFile = file.getWorkspaceFile(); + if (workspaceFile != null && workspaceFile.exists()) { + workspaceFile.delete(true, new NullProgressMonitor()); + } + return; } + Files.deleteIfExists(file.getLocalPath()); } @Override - public void onUndoChange(IFile file) throws CoreException { - if (file != null && file.exists()) { - file.delete(true, new NullProgressMonitor()); + public void onViewDiff(ChangedFile file) { + if (file.isWorkspaceFile()) { + SwtUtils.invokeOnDisplayThreadAsync(() -> UiUtils.openInEditor(file.getWorkspaceFile())); + return; } - closeCompareEditor(file); - } - - @Override - public void onViewDiff(IFile file) { - SwtUtils.invokeOnDisplayThreadAsync(() -> UiUtils.openInEditor(file)); + SwtUtils.invokeOnDisplayThreadAsync(() -> UiUtils.openLocalFileInEditor(file.getLocalPath())); } @Override diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java index 9a6c532e..6a20fcd6 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java @@ -7,13 +7,14 @@ import java.io.IOException; import java.io.UnsupportedEncodingException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; import java.util.Arrays; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; -import org.eclipse.compare.CompareEditorInput; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IResource; import org.eclipse.core.runtime.CoreException; @@ -52,7 +53,7 @@ public LanguageModelToolInformation getToolInformation() { // Set the name and description of the tool toolInfo.setName(TOOL_NAME); toolInfo.setDescription(""" - Insert new code into an existing file in the workspace. + Insert new code into an existing workspace file or local filesystem file. Use this tool once per file that needs to be modified, even if there are multiple changes for a file. Generate the "explanation" property first. The system is very smart and can understand how to apply your edits to the files, @@ -122,30 +123,8 @@ class Person { public CompletableFuture invoke(Map input, ChatView chatView) { CompletableFuture resultFuture = new CompletableFuture<>(); if (input.get("filePath") instanceof String filePath) { - IFile file = FileUtils.getFileFromPath(filePath, true); - - if (file == null || !file.exists()) { - resultFuture.complete(new LanguageModelToolResult[] { - new LanguageModelToolResult("The file path provided does not exist. Please check the path and try again.", - ToolInvocationStatus.error) }); - return resultFuture; - } - if (input.get("code") instanceof String code) { - CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(file, FileChangeType.Changed); - cacheTheOriginalFileContent(file); - try { - applyChangesToFile(code, file); - } catch (CoreException | IOException e) { - CopilotCore.LOGGER.error("Error replacing file content", e); - resultFuture.complete(new LanguageModelToolResult[] { new LanguageModelToolResult( - "Failed to apply changes to the file: " + e.getMessage(), ToolInvocationStatus.error) }); - return resultFuture; - } - refreshCompareEditorIfOpen(fileContentCache.get(file), file); - // Must return the updated content as a result to the CLS. - resultFuture.complete( - new LanguageModelToolResult[] { new LanguageModelToolResult(code, ToolInvocationStatus.success) }); + resultFuture.complete(editFile(filePath, code)); } else { resultFuture.complete(new LanguageModelToolResult[] { new LanguageModelToolResult("The code provided is not a valid string. Please check the code and try again.", @@ -160,6 +139,60 @@ public CompletableFuture invoke(Map i return resultFuture; } + private LanguageModelToolResult[] editFile(String filePath, String code) { + IFile file = FileUtils.getFileFromPath(filePath, true); + + if (file != null && file.exists()) { + return editWorkspaceFile(file, code); + } + + Path localPath = getLocalFilePath(filePath); + if (localPath != null && Files.isRegularFile(localPath, LinkOption.NOFOLLOW_LINKS)) { + return editLocalFile(localPath, code); + } + + return new LanguageModelToolResult[] { + new LanguageModelToolResult("The file path provided does not exist. Please check the path and try again.", + ToolInvocationStatus.error) }; + } + + private LanguageModelToolResult[] editWorkspaceFile(IFile file, String code) { + ChangedFile changedFile = ChangedFile.workspace(file); + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(changedFile, + FileChangeType.Changed); + cacheTheOriginalFileContent(changedFile); + try { + applyChangesToFile(code, file); + } catch (CoreException | IOException e) { + CopilotCore.LOGGER.error("Error replacing file content", e); + return new LanguageModelToolResult[] { new LanguageModelToolResult( + "Failed to apply changes to the file: " + e.getMessage(), ToolInvocationStatus.error) }; + } + refreshCompareEditorIfOpen(getCachedFileContent(changedFile), changedFile); + return new LanguageModelToolResult[] { new LanguageModelToolResult(code, ToolInvocationStatus.success) }; + } + + private LanguageModelToolResult[] editLocalFile(Path filePath, String code) { + Path normalizedPath = normalizeLocalPath(filePath); + ChangedFile changedFile = ChangedFile.local(normalizedPath); + try { + String originalContent = getCachedFileContent(changedFile); + if (originalContent == null) { + originalContent = Files.readString(normalizedPath, StandardCharsets.UTF_8); + } + Files.writeString(normalizedPath, code, StandardCharsets.UTF_8); + cacheTheOriginalFileContent(changedFile, originalContent); + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(changedFile, + FileChangeType.Changed); + refreshCompareEditorIfOpen(getCachedFileContent(changedFile), changedFile); + return new LanguageModelToolResult[] { new LanguageModelToolResult(code, ToolInvocationStatus.success) }; + } catch (IOException e) { + CopilotCore.LOGGER.error("Error replacing local file content", e); + return new LanguageModelToolResult[] { new LanguageModelToolResult( + "Failed to apply changes to the file: " + e.getMessage(), ToolInvocationStatus.error) }; + } + } + private void applyChangesToFile(String changedContent, IFile file) throws CoreException, IOException { if (!validateEdit(file)) { throw new IllegalStateException("File validation failed for " + file.getFullPath()); @@ -189,55 +222,40 @@ private ByteArrayInputStream getInputStream(String changedContent, IFile file) { } @Override - public void onKeepChange(IFile file) { - fileContentCache.remove(file); + public void onKeepChange(ChangedFile file) { + removeCachedFileContent(file); closeCompareEditor(file); } @Override - public void onKeepAllChanges(List files) { - for (IFile file : files) { - onKeepChange(file); - } - } - - @Override - public void onUndoChange(IFile file) throws CoreException, IOException { + public void onUndoChange(ChangedFile file) throws CoreException, IOException { undoChangesToFile(file); closeCompareEditor(file); } @Override - public void onUndoAllChanges(List files) throws CoreException, IOException { - for (IFile file : files) { - onUndoChange(file); + public void onViewDiff(ChangedFile file) { + if (bringCompareEditorToTopIfOpen(file)) { + return; } + compareStringWithFile(getCachedFileContent(file), file); } - @Override - public void onViewDiff(IFile file) { - CompareEditorInput input = compareEditorInputMap.get(file); - if (input != null) { - if (isCompareEditorOpen(input)) { - bringCompareEditorToTop(input); - return; - } - // Compare editor was closed by the user, remove stale entry and recreate - compareEditorInputMap.remove(file); + private void undoChangesToFile(ChangedFile file) throws CoreException, IOException { + String fileCache = getCachedFileContent(file); + if (fileCache == null) { + return; + } + if (file.isWorkspaceFile()) { + applyChangesToFile(fileCache, file.getWorkspaceFile()); + } else { + Files.writeString(file.getLocalPath(), fileCache, StandardCharsets.UTF_8); } - compareStringWithFile(fileContentCache.get(file), file); + removeCachedFileContent(file); } @Override public void onResolveAllChanges() { cleanupChangedFiles(); } - - private void undoChangesToFile(IFile file) throws CoreException, IOException { - String fileCache = fileContentCache.get(file); - if (fileCache != null) { - applyChangesToFile(fileCache, file); - } - fileContentCache.remove(file); - } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java index 1bf6fc6e..3ad526bf 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java @@ -8,11 +8,17 @@ import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.lang.reflect.InvocationTargetException; +import java.net.URI; +import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; import org.eclipse.compare.CompareConfiguration; import org.eclipse.compare.CompareEditorInput; @@ -30,6 +36,7 @@ import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.IStatus; import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.core.runtime.Status; import org.eclipse.swt.graphics.Image; import org.eclipse.ui.IEditorPart; import org.eclipse.ui.IEditorReference; @@ -49,8 +56,8 @@ * Abstract class for handling file change tool related actions. */ public abstract class FileToolBase extends BaseTool { - protected static Map compareEditorInputMap = new ConcurrentHashMap<>(); - protected static Map fileContentCache = new ConcurrentHashMap<>(); + protected static Map compareEditorInputMap = new ConcurrentHashMap<>(); + protected static Map fileContentCache = new ConcurrentHashMap<>(); @Override public abstract CompletableFuture invoke(Map input, ChatView chatView); @@ -59,7 +66,7 @@ public abstract class FileToolBase extends BaseTool { * Common method to handle cleanup of file changes. */ protected void cleanupChangedFiles() { - for (IFile file : compareEditorInputMap.keySet()) { + for (ChangedFile file : compareEditorInputMap.keySet()) { closeCompareEditor(file); } compareEditorInputMap.clear(); @@ -67,24 +74,62 @@ protected void cleanupChangedFiles() { } /** - * Caches the original content of the file to be compared with the proposed changes. + * Caches the original content of the changed file to be compared with the proposed changes. * - * @param file The file whose original content is to be cached. + * @param file The changed file whose original content is to be cached. */ - protected void cacheTheOriginalFileContent(IFile file) { + protected void cacheTheOriginalFileContent(ChangedFile file) { if (fileContentCache.containsKey(file)) { // We only need to cache the original file content once to keep the initial file content so that we can undo the // entire file edit even the file has been modified for multiple rounds. return; } - try (InputStream inputStream = file.getContents()) { - String content = new String(inputStream.readAllBytes(), PlatformUtils.getFileCharset(file)); - fileContentCache.put(file, content); + try { + fileContentCache.put(file, readCurrentFileContent(file)); } catch (IOException | CoreException e) { CopilotCore.LOGGER.error("Error caching original file content", e); } } + /** + * Caches the original content for a changed file if no baseline exists yet. + * + * @param file The file whose original content is to be cached. + * @param content The content to use as the original baseline. + */ + protected void cacheTheOriginalFileContent(ChangedFile file, String content) { + fileContentCache.putIfAbsent(file, content); + } + + private String readCurrentFileContent(ChangedFile file) throws IOException, CoreException { + if (file.isWorkspaceFile()) { + IFile workspaceFile = file.getWorkspaceFile(); + try (InputStream inputStream = workspaceFile.getContents()) { + return new String(inputStream.readAllBytes(), PlatformUtils.getFileCharset(workspaceFile)); + } + } + return Files.readString(file.getLocalPath(), StandardCharsets.UTF_8); + } + + /** + * Gets the cached original content for a changed file. + * + * @param file The changed file whose cached content should be returned. + * @return the cached content, or null if no content is cached. + */ + protected String getCachedFileContent(ChangedFile file) { + return fileContentCache.get(file); + } + + /** + * Removes the cached original content for a changed file. + * + * @param file The changed file whose cached content should be removed. + */ + protected void removeCachedFileContent(ChangedFile file) { + fileContentCache.remove(file); + } + /** * Validate the edit to ensure the files are writable. * @@ -105,14 +150,12 @@ public void run(IProgressMonitor monitor) throws CoreException { } /** - * Compares the given string with the content of the given file in a compare editor. + * Compares the given string with the content of a changed file in a compare editor. * * @param originalFileContent The original string content of the file to compare with. - * @param file The user's file with the proposed changes has been applied. - * @throws InvocationTargetException If the operation is canceled. - * @throws InterruptedException If the operation is canceled. + * @param file The changed file with the proposed changes applied. */ - protected void compareStringWithFile(String originalFileContent, IFile file) { + protected void compareStringWithFile(String originalFileContent, ChangedFile file) { try { CompareEditorInput input = createCompareEditorInput(originalFileContent, file); input.run(new NullProgressMonitor()); @@ -130,47 +173,12 @@ protected void compareStringWithFile(String originalFileContent, IFile file) { } /** - * Updates the current or creates a new compare editor with the given file content and file. - * - * @param originalFileContent The original string content of the file to compare with. - * @param file The user's file with the proposed changes has been applied. - */ - protected void updateOrCreateCompareStringWithFile(String fileContent, IFile file) { - if (fileContent == null) { - return; - } - - CompareEditorInput input = compareEditorInputMap.get(file); - if (input != null) { - if (fileContent.equals(fileContentCache.get(file))) { - SwtUtils.invokeOnDisplayThreadAsync(() -> { - CompareUI.reuseCompareEditor(input, (IReusableEditor) getCompareEditor(input)); - }); - } else { - CompareEditorInput newInput = createCompareEditorInput(fileContent, file); - compareEditorInputMap.put(file, newInput); - SwtUtils.invokeOnDisplayThreadAsync(() -> { - CompareEditorInput compareEditorInput = compareEditorInputMap.get(file); - if (compareEditorInput != null) { - CompareUI.reuseCompareEditor(compareEditorInput, (IReusableEditor) getCompareEditor(compareEditorInput)); - } - }); - } - bringCompareEditorToTop(input); - } else { - // If not, create a new compare editor - compareStringWithFile(fileContent, file); - } - } - - /** - * Refreshes the compare editor for the given file only if it is already open. Does not open a new editor or steal - * focus. + * Refreshes the compare editor for the given changed file only if it is already open. * * @param fileContent The original file content to compare against. - * @param file The file whose compare editor should be refreshed. + * @param file The changed file whose compare editor should be refreshed. */ - protected void refreshCompareEditorIfOpen(String fileContent, IFile file) { + protected void refreshCompareEditorIfOpen(String fileContent, ChangedFile file) { if (fileContent == null) { return; } @@ -184,11 +192,10 @@ protected void refreshCompareEditorIfOpen(String fileContent, IFile file) { // If the compare editor is closed, remove the input from the map and skip refreshing. compareEditorInputMap.remove(file); return; - } else { - CompareEditorInput compareEditorInput = compareEditorInputMap.get(file); - if (compareEditorInput != null) { - CompareUI.reuseCompareEditor(compareEditorInput, (IReusableEditor) editor); - } + } + CompareEditorInput compareEditorInput = compareEditorInputMap.get(file); + if (compareEditorInput != null) { + CompareUI.reuseCompareEditor(compareEditorInput, (IReusableEditor) editor); } }); } @@ -236,12 +243,11 @@ private IEditorPart getCompareEditor(CompareEditorInput input) { } /** - * Close the compare editor for the given file if it is open. + * Closes the compare editor for the given changed file if it is open. * - * @param file The file to check. - * @return true if the compare editor is open, false otherwise. + * @param file The changed file to check. */ - protected void closeCompareEditor(IFile file) { + protected void closeCompareEditor(ChangedFile file) { CompareEditorInput input = compareEditorInputMap.get(file); if (input != null) { SwtUtils.invokeOnDisplayThread(() -> { @@ -262,70 +268,154 @@ protected void closeCompareEditor(IFile file) { compareEditorInputMap.remove(file); } - private CompareEditorInput createCompareEditorInput(String comparedContent, IFile file) { - // Create a new CompareConfiguration - CompareConfiguration config = new CompareConfiguration(); - config.setLeftLabel(Messages.agent_tool_compareEditor_proposedChangesTitle.replaceAll("\"", "")); - config.setRightLabel(file.getName()); + /** + * Brings the compare editor for a changed file to the top if it is open. + * + * @param file The changed file whose compare editor should be shown. + * @return true if an open compare editor was found, false otherwise. + */ + protected boolean bringCompareEditorToTopIfOpen(ChangedFile file) { + CompareEditorInput input = compareEditorInputMap.get(file); + if (input == null) { + return false; + } + if (isCompareEditorOpen(input)) { + bringCompareEditorToTop(input); + return true; + } + compareEditorInputMap.remove(file); + return false; + } - // Enable editing on the proposed changes side and disable it on the original file side. Eclipse's original side - // and - // changes side are swapped, so we need to set the left side as editable to edit the proposed changes. - config.setLeftEditable(true); - config.setRightEditable(false); + /** + * Normalizes a local path for cache and map lookups. + * + * @param file the local file path + * @return the normalized absolute path + */ + protected Path normalizeLocalPath(Path file) { + return file.toAbsolutePath().normalize(); + } - // Set up the configuration to properly show differences - config.setProperty(CompareConfiguration.USE_OUTLINE_VIEW, Boolean.TRUE); - config.setProperty(CompareConfiguration.SHOW_PSEUDO_CONFLICTS, Boolean.TRUE); - config.setProperty(CompareConfiguration.IGNORE_WHITESPACE, Boolean.FALSE); + /** + * Resolves an absolute local filesystem path from a path or file URI. + * + * @param filePath the path or URI to resolve + * @return the local filesystem path, or null if the input is not an absolute local path + */ + protected Path getLocalFilePath(String filePath) { + try { + if (filePath.startsWith("file:")) { + return Paths.get(new URI(filePath)); + } + Path path = Paths.get(filePath); + return path.isAbsolute() ? path : null; + } catch (IllegalArgumentException | URISyntaxException e) { + CopilotCore.LOGGER.error("Invalid local file path: " + filePath, e); + return null; + } + } + + private CompareEditorInput createWorkspaceCompareEditorInput(String comparedContent, IFile file) { + ChangedFile changedFile = ChangedFile.workspace(file); + EditableFileCompareInput originalFile = new EditableFileCompareInput(file); + return createCompareEditorInputForTarget(comparedContent, originalFile.getName(), originalFile.getType(), + PlatformUtils.getFileCharset(file), () -> originalFile, (diffNode, monitor) -> { + EditableFileCompareInput inputToBeApplied = (EditableFileCompareInput) diffNode.getLeft(); + try (InputStream inputStream = inputToBeApplied.getContents()) { + file.setContents(inputStream, true, true, monitor); + } catch (IOException e) { + CopilotCore.LOGGER.error("Error saving compare editor changes to file", e); + } + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().completeFile(changedFile); + removeCachedFileContent(changedFile); + }); + } + + private CompareEditorInput createLocalCompareEditorInput(String comparedContent, Path file) { + Path normalizedPath = normalizeLocalPath(file); + ChangedFile changedFile = ChangedFile.local(normalizedPath); + EditableFileCompareInput originalFile = new EditableFileCompareInput(normalizedPath); + return createCompareEditorInputForTarget(comparedContent, originalFile.getName(), originalFile.getType(), + StandardCharsets.UTF_8.name(), () -> originalFile, + (diffNode, monitor) -> { + EditableFileCompareInput inputToBeApplied = (EditableFileCompareInput) diffNode.getLeft(); + try (InputStream inputStream = inputToBeApplied.getContents()) { + Files.write(normalizedPath, inputStream.readAllBytes()); + } catch (IOException e) { + CopilotCore.LOGGER.error("Error saving compare editor changes to local file", e); + } + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().completeFile(changedFile); + removeCachedFileContent(changedFile); + }); + } + + private CompareEditorInput createCompareEditorInput(String comparedContent, ChangedFile file) { + if (file.isWorkspaceFile()) { + return createWorkspaceCompareEditorInput(comparedContent, file.getWorkspaceFile()); + } + return createLocalCompareEditorInput(comparedContent, file.getLocalPath()); + } + + private CompareEditorInput createCompareEditorInputForTarget(String comparedContent, String fileName, + String fileExtension, String charset, Supplier originalFileSupplier, + CompareContentSaver contentSaver) { + CompareConfiguration config = createCompareConfiguration(fileName); return new CompareEditorInput(config) { @Override protected Object prepareInput(IProgressMonitor monitor) throws InvocationTargetException, InterruptedException { monitor.beginTask("Calculating differences", 10); - setTitle(Messages.agent_tool_compareEditor_titlePrefix + file.getName()); - // Keep proposedChanges virtual file's name and type same as the originalFile original file's name and type - EditableStringCompareInput proposedChanges = new EditableStringCompareInput(comparedContent, file.getName(), - file.getFileExtension(), PlatformUtils.getFileCharset(file)); - EditableFileCompareInput originalFile = new EditableFileCompareInput(file); - - // Create a diff node with proper configuration for text comparison - DiffNode diffNode = new DiffNode(null, Differencer.CHANGE, null, originalFile, proposedChanges); - + setTitle(Messages.agent_tool_compareEditor_titlePrefix + fileName); + EditableStringCompareInput proposedChanges = new EditableStringCompareInput(comparedContent, fileName, + fileExtension, charset); + DiffNode diffNode = new DiffNode(null, Differencer.CHANGE, null, originalFileSupplier.get(), proposedChanges); monitor.done(); return diffNode; } @Override public void saveChanges(IProgressMonitor monitor) throws CoreException { - // We need to set the right side as editable to save the changes made to the proposed changes. Otherwise, the - // changes won't be saved. if (isDirty()) { config.setRightEditable(true); super.saveChanges(monitor); - // Get the diff node which contains the comparison inputs DiffNode diffNode = (DiffNode) getCompareResult(); if (diffNode != null) { - // Get the right side input (the original file with any edits made) - EditableFileCompareInput inputToBeApplied = (EditableFileCompareInput) diffNode.getLeft(); - - // Save the modified content back to the file - try (InputStream inputStream = inputToBeApplied.getContents()) { - file.setContents(inputStream, true, true, monitor); - } catch (IOException e) { - CopilotCore.LOGGER.error("Error saving compare editor changes to file", e); - } + contentSaver.save(diffNode, monitor); } - - // If user keeps the changes with keyboard shortcut, we also need to complete the file. - CopilotUi.getPlugin().getChatServiceManager().getFileToolService().completeFile(file); - fileContentCache.remove(file); } } }; } + private CompareConfiguration createCompareConfiguration(String rightLabel) { + CompareConfiguration config = new CompareConfiguration(); + config.setLeftLabel(Messages.agent_tool_compareEditor_proposedChangesTitle.replaceAll("\"", "")); + config.setRightLabel(rightLabel); + config.setLeftEditable(true); + config.setRightEditable(false); + config.setProperty(CompareConfiguration.USE_OUTLINE_VIEW, Boolean.TRUE); + config.setProperty(CompareConfiguration.SHOW_PSEUDO_CONFLICTS, Boolean.TRUE); + config.setProperty(CompareConfiguration.IGNORE_WHITESPACE, Boolean.FALSE); + return config; + } + + /** + * Saves the editable compare content back to the target file type. + */ + @FunctionalInterface + private interface CompareContentSaver { + /** + * Saves the edited content represented by a compare diff node. + * + * @param diffNode The diff node containing the editable compare inputs. + * @param monitor The progress monitor for the save operation. + * @throws CoreException if saving through Eclipse APIs fails. + */ + void save(DiffNode diffNode, IProgressMonitor monitor) throws CoreException; + } + /** * Dispose the file change summary bar and related resources. */ @@ -342,8 +432,10 @@ protected void dispose() { /** * Editable file compare input class to handle file content editing on the compare editor. */ - public class EditableFileCompareInput implements ITypedElement, IEncodedStreamContentAccessor, IEditableContent { - private IFile file; + public static final class EditableFileCompareInput implements ITypedElement, IEncodedStreamContentAccessor, + IEditableContent { + private final IFile workspaceFile; + private final Path localFile; private byte[] modifiedContent = null; /** @@ -352,12 +444,27 @@ public class EditableFileCompareInput implements ITypedElement, IEncodedStreamCo * @param file The file to be edited. */ public EditableFileCompareInput(IFile file) { - this.file = file; + this.workspaceFile = file; + this.localFile = null; + } + + /** + * Constructor for EditableFileCompareInput. + * + * @param file The local file to be edited. + */ + EditableFileCompareInput(Path file) { + this.workspaceFile = null; + this.localFile = file.toAbsolutePath().normalize(); } @Override public String getName() { - return file.getName(); + if (workspaceFile != null) { + return workspaceFile.getName(); + } + Path fileName = localFile.getFileName(); + return fileName == null ? localFile.toString() : fileName.toString(); } @Override @@ -367,11 +474,19 @@ public Image getImage() { @Override public String getType() { - return file.getFileExtension(); + if (workspaceFile != null) { + return workspaceFile.getFileExtension(); + } + return getLocalFileExtension(localFile); } + /** + * Gets the workspace file represented by this compare input. + * + * @return the workspace file + */ public IFile getFile() { - return file; + return workspaceFile; } @Override @@ -379,12 +494,19 @@ public InputStream getContents() throws CoreException { if (modifiedContent != null) { return new ByteArrayInputStream(modifiedContent); } - return file.getContents(); + if (workspaceFile != null) { + return workspaceFile.getContents(); + } + try { + return Files.newInputStream(localFile); + } catch (IOException e) { + throw new CoreException(Status.error("Error reading local file", e)); + } } @Override public String getCharset() throws CoreException { - return file.getCharset(); + return workspaceFile == null ? StandardCharsets.UTF_8.name() : workspaceFile.getCharset(); } @Override @@ -401,7 +523,6 @@ public void setContent(byte[] newContent) { public ITypedElement replace(ITypedElement dest, ITypedElement src) { if (src instanceof IStreamContentAccessor sca) { try (InputStream is = sca.getContents()) { - // Just store changes in memory modifiedContent = is.readAllBytes(); } catch (IOException | CoreException e) { CopilotCore.LOGGER.error("Error occurred while replacing file content", e); @@ -409,6 +530,15 @@ public ITypedElement replace(ITypedElement dest, ITypedElement src) { } return this; } + + private static String getLocalFileExtension(Path file) { + String name = file.getFileName() == null ? file.toString() : file.getFileName().toString(); + int index = name.lastIndexOf('.'); + if (index < 0 || index == name.length() - 1) { + return ""; + } + return name.substring(index + 1); + } } /** diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolService.java index 1e57daa9..151cf791 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolService.java @@ -6,13 +6,11 @@ import java.io.IOException; import java.util.ArrayList; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import org.eclipse.core.databinding.observable.sideeffect.ISideEffect; import org.eclipse.core.databinding.observable.value.IObservableValue; import org.eclipse.core.databinding.observable.value.WritableValue; -import org.eclipse.core.resources.IFile; import org.eclipse.core.runtime.CoreException; import org.eclipse.e4.core.services.events.IEventBroker; import org.eclipse.lsp4j.FileChangeType; @@ -35,7 +33,7 @@ * the files to be created or edited and the enable state of the button. */ public class FileToolService extends ChatBaseService { - private IObservableValue> filesObservable; + private IObservableValue> filesObservable; private IObservableValue buttonEnableObservable; private WorkingSetBar workingSetBar; @@ -78,7 +76,7 @@ public void bindWorkingSetBar(ChatView chatView) { ensureRealm(() -> { unbindWorkingSetBar(); filesSideEffect = ISideEffect.create(() -> filesObservable.getValue(), - (Map filesMap) -> { + (Map filesMap) -> { if (filesMap.isEmpty()) { disposeWorkingSetBar(); } else { @@ -154,7 +152,7 @@ public void setWorkingSetBarButtonStatus(boolean status) { /** * Set the changed files for the working set bar. */ - public void setChangedFiles(Map files) { + public void setChangedFiles(Map files) { ensureRealm(() -> { filesObservable.setValue(files); }); @@ -163,7 +161,7 @@ public void setChangedFiles(Map files) { /** * Get the changed files for the working set bar. */ - public Map getChangedFiles() { + public Map getChangedFiles() { return filesObservable.getValue(); } @@ -175,11 +173,11 @@ public WorkingSetBar getWorkingSetBar() { } /** - * Add a newly created file to the working set bar. + * Add a changed file to the working set bar. */ - public void addChangedFile(IFile file, FileChangeType fileChangeType) { + public void addChangedFile(ChangedFile file, FileChangeType fileChangeType) { ensureRealm(() -> { - Map filesMap = new LinkedHashMap<>(filesObservable.getValue()); + Map filesMap = new LinkedHashMap<>(filesObservable.getValue()); if (filesMap.containsKey(file)) { return; } @@ -194,9 +192,9 @@ public void addChangedFile(IFile file, FileChangeType fileChangeType) { * * @param file the file to complete */ - public void completeFile(IFile file) { + public void completeFile(ChangedFile file) { ensureRealm(() -> { - Map filesMap = new LinkedHashMap<>(filesObservable.getValue()); + Map filesMap = new LinkedHashMap<>(filesObservable.getValue()); filesMap.remove(file); filesObservable.setValue(filesMap); @@ -212,7 +210,7 @@ public void completeFile(IFile file) { * @param file the file to get the change type for * @return the file change type, or null if the file is not in the list */ - public FileChangeType getFileChangeTypeOf(IFile file) { + private FileChangeType getFileChangeTypeInternal(ChangedFile file) { FileChangeProperty property = filesObservable.getValue().get(file); if (property != null) { return property.getChangeType(); @@ -226,10 +224,10 @@ public FileChangeType getFileChangeTypeOf(IFile file) { * * @param file the file to keep changes for */ - public void onKeepChange(IFile file) { - if (getFileChangeTypeOf(file) == FileChangeType.Created) { + public void onKeepChange(ChangedFile file) { + if (getFileChangeTypeInternal(file) == FileChangeType.Created) { this.createFileTool.onKeepChange(file); - } else if (getFileChangeTypeOf(file) == FileChangeType.Changed) { + } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { this.editFileTool.onKeepChange(file); } this.completeFile(file); @@ -239,8 +237,13 @@ public void onKeepChange(IFile file) { * Handles the action of keeping all changes to files. */ public void onKeepAllChanges() { - this.createFileTool.onKeepAllChanges(getCreatedFiles()); - this.editFileTool.onKeepAllChanges(getEditedFiles()); + for (ChangedFile file : new ArrayList<>(filesObservable.getValue().keySet())) { + if (getFileChangeTypeInternal(file) == FileChangeType.Created) { + this.createFileTool.onKeepChange(file); + } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { + this.editFileTool.onKeepChange(file); + } + } onResolveAllChanges(); } @@ -249,11 +252,11 @@ public void onKeepAllChanges() { * * @param file the file to undo changes for */ - public void onUndoChange(IFile file) { + public void onUndoChange(ChangedFile file) { try { - if (getFileChangeTypeOf(file) == FileChangeType.Created) { + if (getFileChangeTypeInternal(file) == FileChangeType.Created) { this.createFileTool.onUndoChange(file); - } else if (getFileChangeTypeOf(file) == FileChangeType.Changed) { + } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { this.editFileTool.onUndoChange(file); } } catch (CoreException | IOException e) { @@ -267,8 +270,13 @@ public void onUndoChange(IFile file) { */ public void onUndoAllChanges() { try { - this.createFileTool.onUndoAllChanges(getCreatedFiles()); - this.editFileTool.onUndoAllChanges(getEditedFiles()); + for (ChangedFile file : new ArrayList<>(filesObservable.getValue().keySet())) { + if (getFileChangeTypeInternal(file) == FileChangeType.Created) { + this.createFileTool.onUndoChange(file); + } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { + this.editFileTool.onUndoChange(file); + } + } } catch (CoreException | IOException e) { CopilotCore.LOGGER.error("Error undoing all changes for the files", e); } @@ -280,10 +288,14 @@ public void onUndoAllChanges() { * * @param file the file to view the diff for */ - public void onViewDiff(IFile file) { - if (getFileChangeTypeOf(file) == FileChangeType.Created) { + public void onViewDiff(ChangedFile file) { + FileChangeProperty property = filesObservable.getValue().get(file); + if (property == null) { + return; + } + if (property.getChangeType() == FileChangeType.Created) { this.createFileTool.onViewDiff(file); - } else if (getFileChangeTypeOf(file) == FileChangeType.Changed) { + } else if (property.getChangeType() == FileChangeType.Changed) { this.editFileTool.onViewDiff(file); } } @@ -313,29 +325,8 @@ public void disposeWorkingSetBar() { } } - private List getCreatedFiles() { - List createdFiles = new ArrayList<>(); - for (Map.Entry entry : this.filesObservable.getValue().entrySet()) { - if (entry.getValue().getChangeType() == FileChangeType.Created) { - createdFiles.add(entry.getKey()); - } - } - return createdFiles; - } - - private List getEditedFiles() { - List editedFiles = new ArrayList<>(); - for (Map.Entry entry : this.filesObservable.getValue().entrySet()) { - if (entry.getValue().getChangeType() == FileChangeType.Changed) { - editedFiles.add(entry.getKey()); - } - } - return editedFiles; - } - /** - * Class for file change properties. changeType - The type of file change (new or edited). isCompleted - Whether the - * file change is completed or not. + * Class for file change properties. */ public static class FileChangeProperty { private FileChangeType changeType; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/WorkingSetHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/WorkingSetHandler.java index c7619f7f..7176e9e2 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/WorkingSetHandler.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/WorkingSetHandler.java @@ -4,9 +4,7 @@ package com.microsoft.copilot.eclipse.ui.chat.tools; import java.io.IOException; -import java.util.List; -import org.eclipse.core.resources.IFile; import org.eclipse.core.runtime.CoreException; /** @@ -18,12 +16,7 @@ public interface WorkingSetHandler { * * @param file the file to keep changes for */ - void onKeepChange(IFile file) throws IOException, CoreException; - - /** - * Handles the action of keeping all changes to files. - */ - void onKeepAllChanges(List files) throws IOException, CoreException; + void onKeepChange(ChangedFile file) throws IOException, CoreException; /** * Handles the action of undoing changes to a file. @@ -33,22 +26,14 @@ public interface WorkingSetHandler { * @throws CoreException if an error occurs during the undo operation, such as a failure to delete a file * @throws IOException if an error occurs while writing to the file */ - void onUndoChange(IFile file) throws CoreException, IOException; - - /** - * Handles the action of undoing all changes to files. - * - * @throws CoreException if error occurs during the undo all operation, such as a failure to delete a file - * @throws IOException if an error occurs while writing to the file - */ - void onUndoAllChanges(List files) throws CoreException, IOException; + void onUndoChange(ChangedFile file) throws CoreException, IOException; /** * Handles the action of viewing the diff of a file. * * @param file the file to view the diff for */ - void onViewDiff(IFile file); + void onViewDiff(ChangedFile file); /** * Handles the action of click done button to resolve all changes. diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java index 7370d76f..5d1ab2a9 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java @@ -8,6 +8,8 @@ import java.io.InputStream; import java.net.URI; import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; @@ -29,6 +31,8 @@ import org.eclipse.core.commands.NotHandledException; import org.eclipse.core.commands.ParameterizedCommand; import org.eclipse.core.commands.common.NotDefinedException; +import org.eclipse.core.filesystem.EFS; +import org.eclipse.core.filesystem.IFileStore; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IResource; import org.eclipse.core.runtime.preferences.InstanceScope; @@ -221,6 +225,27 @@ public static IEditorPart openInEditor(IFile file) { return null; } + /** + * Opens the given local filesystem file in an editor. + */ + public static IEditorPart openLocalFileInEditor(Path file) { + if (file == null || !Files.exists(file)) { + CopilotCore.LOGGER.error(new IllegalArgumentException("Cannot open editor: local file is null or doesn't exist")); + return null; + } + + try { + IWorkbenchPage page = getActivePage(); + if (page != null) { + IFileStore fileStore = EFS.getLocalFileSystem().getStore(file.toUri()); + return IDE.openEditorOnFileStore(page, fileStore); + } + } catch (PartInitException e) { + CopilotCore.LOGGER.error(e); + } + return null; + } + /** * Opens the file in the editor. */ From b2628ba9cbb23f5a851d696080b126e989414060 Mon Sep 17 00:00:00 2001 From: Ethan Hou <149548697+ethanyhou@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:40:55 +0800 Subject: [PATCH 06/27] feat: Implement auto compression feature for chat context (#250) --- .../core/events/CopilotEventConstants.java | 10 + .../core/lsp/CopilotLanguageClient.java | 22 ++ .../protocol/CompressionCompletedParams.java | 18 ++ .../protocol/CompressionStartedParams.java | 11 + .../lsp/protocol/CopilotAgentSettings.java | 13 +- .../context-compress/context-compress.md | 163 +++++++++++ .../chat/BaseTurnWidgetPartialRenderTest.java | 276 ++++++++++++++++++ .../LanguageServerSettingManagerTests.java | 4 + .../eclipse/ui/chat/BaseTurnWidget.java | 11 +- .../eclipse/ui/chat/ChatContentViewer.java | 44 ++- .../copilot/eclipse/ui/chat/ChatView.java | 69 ++++- .../eclipse/ui/chat/CopilotTurnWidget.java | 49 ++++ .../eclipse/ui/chat/SubagentMessageBlock.java | 4 +- .../copilot/eclipse/ui/i18n/Messages.java | 1 + .../eclipse/ui/i18n/messages.properties | 1 + .../LanguageServerSettingManager.java | 2 + 16 files changed, 681 insertions(+), 17 deletions(-) create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CompressionCompletedParams.java create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CompressionStartedParams.java create mode 100644 com.microsoft.copilot.eclipse.swtbot.test/test-plans/context-compress/context-compress.md create mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidgetPartialRenderTest.java 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 1e34f256..303c5950 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 @@ -176,4 +176,14 @@ public class CopilotEventConstants { * conversation templates on receipt. */ public static final String TOPIC_CHAT_DID_CHANGE_CUSTOMIZATION_FILES = TOPIC_CHAT + "DID_CHANGE_CUSTOMIZATION_FILES"; + + /** + * Event when automatic conversation compression starts. + */ + public static final String TOPIC_CHAT_COMPRESSION_STARTED = TOPIC_CHAT + "COMPRESSION_STARTED"; + + /** + * Event when automatic conversation compression completes. + */ + public static final String TOPIC_CHAT_COMPRESSION_COMPLETED = TOPIC_CHAT + "COMPRESSION_COMPLETED"; } 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 1e40287a..da7aaee9 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 @@ -43,6 +43,8 @@ import com.microsoft.copilot.eclipse.core.lsp.mcp.McpOauthRequest; import com.microsoft.copilot.eclipse.core.lsp.mcp.McpRuntimeLog; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CompressionCompletedParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CompressionStartedParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationCapabilities; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationContextParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.CurrentEditorContext; @@ -366,6 +368,26 @@ public void onQuotaWarning(QuotaWarningParams params) { } } + /** + * Notify when automatic conversation compression starts. + */ + @JsonNotification("$/copilot/compressionStarted") + public void onCompressionStarted(CompressionStartedParams params) { + if (eventBroker != null) { + eventBroker.post(CopilotEventConstants.TOPIC_CHAT_COMPRESSION_STARTED, params); + } + } + + /** + * Notify when automatic conversation compression completes. + */ + @JsonNotification("$/copilot/compressionCompleted") + public void onCompressionCompleted(CompressionCompletedParams params) { + if (eventBroker != null) { + eventBroker.post(CopilotEventConstants.TOPIC_CHAT_COMPRESSION_COMPLETED, params); + } + } + /** * Reads the contents and stats of a file given its URI. */ diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CompressionCompletedParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CompressionCompletedParams.java new file mode 100644 index 00000000..28869330 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CompressionCompletedParams.java @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +/** + * Parameters for the {@code $/copilot/compressionCompleted} notification sent by the language server when automatic + * conversation compression finishes. The {@code contextInfo} field is optional and may be {@code null}. + */ +public record CompressionCompletedParams( + String conversationId, + int archivedPartitionId, + int newPartitionId, + int summaryLength, + int turnCount, + int durationMs, + ContextSizeInfo contextInfo) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CompressionStartedParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CompressionStartedParams.java new file mode 100644 index 00000000..247826bd --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CompressionStartedParams.java @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +/** + * Parameters for the {@code $/copilot/compressionStarted} notification sent by the language server when automatic + * conversation compression begins. + */ +public record CompressionStartedParams(String conversationId, int partitionId, String reason) { +} 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 e9412e2f..818fbd36 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 @@ -17,6 +17,7 @@ public class CopilotAgentSettings { @SerializedName("maxToolCallingLoop") private int agentMaxRequests; private boolean enableSkills; + private boolean autoCompress; private String transcriptDirectory; @@ -178,6 +179,14 @@ public String getTranscriptDirectory() { return transcriptDirectory; } + public boolean isAutoCompress() { + return autoCompress; + } + + public void setAutoCompress(boolean autoCompress) { + this.autoCompress = autoCompress; + } + public void setTranscriptDirectory(String transcriptDirectory) { this.transcriptDirectory = transcriptDirectory; } @@ -212,7 +221,7 @@ public ToolsSettings getTools() { @Override public int hashCode() { - return Objects.hash(agentMaxRequests, enableSkills, transcriptDirectory, + return Objects.hash(agentMaxRequests, enableSkills, autoCompress, transcriptDirectory, editorHandlesAllConfirmation, autoApproveUnmatchedTerminal, autoApproveUnmatchedFileOp, tools); } @@ -229,6 +238,7 @@ public boolean equals(Object obj) { } CopilotAgentSettings other = (CopilotAgentSettings) obj; return agentMaxRequests == other.agentMaxRequests && enableSkills == other.enableSkills + && autoCompress == other.autoCompress && Objects.equals(transcriptDirectory, other.transcriptDirectory) && editorHandlesAllConfirmation == other.editorHandlesAllConfirmation && autoApproveUnmatchedTerminal == other.autoApproveUnmatchedTerminal @@ -241,6 +251,7 @@ public String toString() { ToStringBuilder builder = new ToStringBuilder(this); builder.append("agentMaxRequests", agentMaxRequests); builder.append("enableSkills", enableSkills); + builder.append("autoCompress", autoCompress); builder.append("transcriptDirectory", transcriptDirectory); builder.append("editorHandlesAllConfirmation", editorHandlesAllConfirmation); builder.append("autoApproveUnmatchedTerminal", autoApproveUnmatchedTerminal); diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/context-compress/context-compress.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/context-compress/context-compress.md new file mode 100644 index 00000000..e3b52987 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/context-compress/context-compress.md @@ -0,0 +1,163 @@ +# Auto Context Compression + +## Overview +Verifies the **Auto Compress** feature that automatically compresses long +conversations to keep context usage within the model's limit. Auto Compress +is always enabled (no user-facing preference). While compression is in +progress, the chat view shows a "Compacting conversation..." spinner below +the latest Copilot turn, and the context size donut updates once it +completes. + +Entry points: +- **Copilot Chat view** → latest Copilot turn (spinner banner appears here). +- **Copilot Chat view** → control bar **Context Size Donut** (updates after + compression completes). + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed (built from + the branch containing the staged Auto Compress changes). +- A valid GitHub Copilot subscription is active (authentication completed). +- A model that supports a finite context window is selected (so the donut and + compression can be exercised — e.g. Claude Sonnet 4.6 or GPT-4.1). +- The Copilot Chat view is open and visible. + +--- + +## Test Cases + +### TC-001: Compacting banner appears when compression starts + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Copilot Chat view is open with a new conversation. + +#### Steps +1. Start a conversation and drive the context usage toward the model limit — + for example, attach several large files and/or run multiple tool-heavy + turns until the **Context Size Donut** approaches its warning threshold + (≥90 %). +2. Continue sending messages until the conversation goes over the threshold + so the server initiates automatic compression. +3. Observe the latest Copilot turn while the server processes the request. + +#### Expected Result +- A small banner appears **below the latest Copilot turn** containing: + - An animated spinner. + - The status text **"Compacting conversation..."**. +- The chat view layout refreshes so the banner is fully visible (not clipped). +- No error dialogs are shown. + +#### 📸 Key Screenshots +- [ ] **Compacting banner** — spinner + "Compacting conversation..." text + rendered under the latest Copilot turn. + +--- + +### TC-002: Compacting banner is dismissed and context donut updates on completion + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- TC-001 has been executed and the "Compacting conversation..." banner is + currently visible. + +#### Steps +1. Wait for the server to finish compression (typically a few seconds). +2. Observe the latest Copilot turn after compression completes. +3. Hover the **Context Size Donut** in the chat view control bar. + +#### Expected Result +- The "Compacting conversation..." banner is removed from the Copilot turn. +- The chat view scroller relayouts cleanly (no leftover blank space, no + clipping). +- The Context Size Donut updates to reflect the new, smaller token usage + (the ring's filled portion shrinks). +- The **Context Window** popup shows the post-compression token breakdown + consistent with the new total. +- The subsequent reply continues to stream normally on top of the freshly + compressed history. + +#### 📸 Key Screenshots +- [ ] **After completion** — Copilot turn without the banner. +- [ ] **Donut after compression** — Context Size Donut showing reduced usage. +- [ ] **Context Window popup** — Token breakdown after compression. + +--- + +### TC-003: Cancelling a chat hides the compacting banner + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- A conversation is set up so the next send will trigger compression + (as in TC-001). + +#### Steps +1. Send the message that triggers compression and wait for the + "Compacting conversation..." banner to appear. +2. While the banner is showing, click the **Cancel** (stop) button in the + chat input action bar. + +#### Expected Result +- The send button is restored from its stop/cancel state back to its normal + send state. +- The "Compacting conversation..." banner is removed from the latest Copilot + turn. +- Any buffered reply text that arrived just before cancellation is rendered + (no missing trailing line). +- The chat view relayouts cleanly so the flushed reply is fully visible. +- The user can immediately send a new message in the same conversation. + +#### 📸 Key Screenshots +- [ ] **After cancel** — banner gone, send button reset, any buffered reply + visible. + +--- + +### TC-004: Compacting banner only updates the matching conversation + +**Type:** `Edge Case` +**Priority:** `P2` + +#### Preconditions +- Two conversations exist in chat history: *Conversation A* (about to + trigger compression) and *Conversation B* (short, well under the limit). + +#### Steps +1. In *Conversation A*, send a message that triggers compression and wait + for the "Compacting conversation..." banner to appear. +2. Without waiting for completion, open chat history and switch to + *Conversation B*. +3. Inspect *Conversation B* for any compaction banner. +4. Switch back to *Conversation A*. + +#### Expected Result +- *Conversation B* never shows a "Compacting conversation..." banner — the + compaction status is scoped to *Conversation A* only. +- When you return to *Conversation A*, its state is consistent with the + compression outcome (banner cleared if it completed in the meantime; new + reply continues to stream if still in progress). +- No errors or stale spinners are left behind in either conversation. + +#### 📸 Key Screenshots +- [ ] **Conversation B during A's compaction** — no banner shown. + +--- + +## Screenshots Checklist +> Consolidated list of all key screenshot moments. + +- [ ] `TC-001` Compacting banner under latest Copilot turn. +- [ ] `TC-002` Copilot turn after compaction completes (banner gone). +- [ ] `TC-002` Context Size Donut after compaction (reduced usage). +- [ ] `TC-002` Context Window popup with post-compaction token breakdown. +- [ ] `TC-003` State after cancel — banner gone, send button reset, buffered + reply visible. +- [ ] `TC-004` Conversation B during Conversation A's compaction (no banner). diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidgetPartialRenderTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidgetPartialRenderTest.java new file mode 100644 index 00000000..b2e0df9b --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidgetPartialRenderTest.java @@ -0,0 +1,276 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +import java.lang.reflect.Field; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.chat.services.AvatarService; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatFontService; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; + +/** + * Verifies that {@link BaseTurnWidget#appendMessage} eagerly renders trailing partial lines via + * {@code renderPartialBuffer}, while deferring rendering for code-fence prefixes and inside code + * blocks where fence detection requires a complete line. + */ +@ExtendWith(MockitoExtension.class) +class BaseTurnWidgetPartialRenderTest { + + private static final String TURN_ID = "turn-1"; + + private Shell shell; + private MockedStatic copilotUiMock; + private CopilotUi mockPlugin; + + @Mock + private ChatServiceManager mockChatServiceManager; + @Mock + private AvatarService mockAvatarService; + @Mock + private ChatFontService mockChatFontService; + + @BeforeEach + void setUp() { + lenient().when(mockChatServiceManager.getAvatarService()).thenReturn(mockAvatarService); + lenient().when(mockChatServiceManager.getChatFontService()).thenReturn(mockChatFontService); + lenient().when(mockAvatarService.getAvatarForCopilot()).thenReturn(null); + + SwtUtils.invokeOnDisplayThread(() -> { + shell = new Shell(Display.getDefault()); + copilotUiMock = mockStatic(CopilotUi.class); + mockPlugin = mock(CopilotUi.class); + copilotUiMock.when(CopilotUi::getPlugin).thenReturn(mockPlugin); + lenient().when(mockPlugin.getChatServiceManager()).thenReturn(mockChatServiceManager); + }); + } + + @AfterEach + void tearDown() { + SwtUtils.invokeOnDisplayThread(() -> { + if (copilotUiMock != null) { + copilotUiMock.close(); + copilotUiMock = null; + } + if (shell != null && !shell.isDisposed()) { + shell.dispose(); + } + }); + } + + @Test + void appendMessage_partialLineWithoutNewline_rendersImmediately() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + widget.appendMessage("Hello world"); + + ChatMarkupViewer viewer = getMarkupViewer(widget); + assertNotNull(viewer, "Partial text without newline should be rendered eagerly to a text block"); + assertTrue(viewer.getTextWidget().getText().contains("Hello world"), + "Expected partial text to be visible, got: '" + viewer.getTextWidget().getText() + "'"); + }); + } + + @Test + void appendMessage_partialAfterCompleteLine_rendersBothCommittedAndPartial() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // First chunk: a complete line plus a trailing partial fragment without a newline. + widget.appendMessage("First line\nSecond "); + + ChatMarkupViewer viewer = getMarkupViewer(widget); + assertNotNull(viewer, "Text block should be created"); + String rendered = viewer.getTextWidget().getText(); + assertTrue(rendered.contains("First line"), + "Committed line should be rendered, got: '" + rendered + "'"); + assertTrue(rendered.contains("Second"), + "Trailing partial fragment should be rendered eagerly, got: '" + rendered + "'"); + + // Append more text completing the partial line — final render must show full content. + widget.appendMessage("line\n"); + rendered = viewer.getTextWidget().getText(); + assertTrue(rendered.contains("First line"), + "First line should still be present after appending more text, got: '" + rendered + "'"); + assertTrue(rendered.contains("Second line"), + "Completed second line should be rendered, got: '" + rendered + "'"); + }); + } + + @Test + void appendMessage_partialIsTripleBacktickFence_defersRendering() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // A confirmed code-fence prefix — must wait for the newline before deciding whether + // to render markup or open a code block. Otherwise the literal backticks would flash + // into the markup viewer. + widget.appendMessage("```"); + + assertNull(getField(widget, "currentTextBlock"), + "Text block must not be created for a confirmed fence prefix"); + assertNull(getField(widget, "currentCodeBlock"), + "Code block must not be created until the fence newline is received"); + }); + } + + @Test + void appendMessage_partialIsTripleBacktickWithLanguage_defersRendering() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + widget.appendMessage("```java"); + + assertNull(getField(widget, "currentTextBlock"), + "Text block must not be created while a fence-with-language is still being streamed"); + assertNull(getField(widget, "currentCodeBlock"), + "Code block must not be created until the fence newline is received"); + }); + } + + @Test + void appendMessage_partialIsSingleBacktick_defersRendering() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // A lone backtick could grow into ``` on the next chunk — defer rendering. + widget.appendMessage("`"); + + assertNull(getField(widget, "currentTextBlock"), + "Text block must not be created for an ambiguous single backtick"); + }); + } + + @Test + void appendMessage_partialIsDoubleBacktick_defersRendering() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + widget.appendMessage("``"); + + assertNull(getField(widget, "currentTextBlock"), + "Text block must not be created for an ambiguous double backtick"); + }); + } + + @Test + void appendMessage_partialBacktickFollowedByText_rendersImmediately() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // A single backtick followed by non-backtick content is inline code, not a fence — + // render eagerly so the user sees the streamed token immediately. + widget.appendMessage("`code"); + + ChatMarkupViewer viewer = getMarkupViewer(widget); + assertNotNull(viewer, "Inline-code partial should be rendered eagerly"); + assertTrue(viewer.getTextWidget().getText().contains("code"), + "Inline-code content should be visible, got: '" + viewer.getTextWidget().getText() + "'"); + }); + } + + @Test + void appendMessage_partialResolvesIntoFence_doesNotLeaveStaleText() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // First send some markdown so a text block exists. + widget.appendMessage("intro text\n"); + ChatMarkupViewer viewer = getMarkupViewer(widget); + assertNotNull(viewer); + assertTrue(viewer.getTextWidget().getText().contains("intro text")); + + // Then start streaming a fence — must NOT append "```" into the markup viewer. + widget.appendMessage("```"); + assertTrue(!viewer.getTextWidget().getText().contains("```"), + "Fence prefix must not leak into the markup viewer, got: '" + viewer.getTextWidget().getText() + "'"); + + // Complete the fence: the code block should open and the markup viewer should not gain + // the fence characters. + widget.appendMessage("java\n"); + assertNotNull(getField(widget, "currentCodeBlock"), + "Code block must open once the fence newline is received"); + assertTrue(!viewer.getTextWidget().getText().contains("```"), + "Fence characters must never appear in the markup viewer"); + }); + } + + @Test + void appendMessage_partialInsideCodeBlock_isNotRenderedAsMarkup() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // Open a code block. + widget.appendMessage("```java\n"); + assertNotNull(getField(widget, "currentCodeBlock"), + "Code block should be open after the opening fence line"); + assertNull(getField(widget, "currentTextBlock"), + "Text block should not exist while we are inside a code block"); + + // Stream a partial code line without a newline — the partial must NOT be rendered + // to the markup viewer, since partial code text has to go through the source viewer + // once a complete line arrives. + widget.appendMessage("int x = 1;"); + + assertNull(getField(widget, "currentTextBlock"), + "Partial text inside a code block must not create a markup text block"); + }); + } + + @Test + void appendMessage_emptyString_doesNotRender() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + widget.appendMessage(""); + + assertNull(getField(widget, "currentTextBlock"), + "Empty message must be a no-op and must not create a text block"); + StringBuilder buffer = (StringBuilder) getField(widget, "messageBuffer"); + assertEquals(0, buffer.length(), "Empty message must not accumulate into the buffer"); + }); + } + + private static ChatMarkupViewer getMarkupViewer(BaseTurnWidget widget) { + Object textBlock = getField(widget, "currentTextBlock"); + return textBlock instanceof ChatMarkupViewer markup ? markup : null; + } + + private static Object getField(Object target, String name) { + Class cls = target.getClass(); + while (cls != null) { + try { + Field f = cls.getDeclaredField(name); + f.setAccessible(true); + return f.get(target); + } catch (NoSuchFieldException e) { + cls = cls.getSuperclass(); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + throw new RuntimeException("Field '" + name + "' not found on " + target.getClass()); + } +} 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 2892f521..8cf28098 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 @@ -58,6 +58,7 @@ void testNoProxy() { settings.getGithubSettings().getCopilotSettings().getAgent() .setEnableSkills(PreferencesUtils.isSkillsEnabled()) .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + settings.getGithubSettings().getCopilotSettings().getAgent().setAutoCompress(true); settings.getGithubSettings().getCopilotSettings().getAgent() .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); settings.getGithubSettings().getCopilotSettings().getAgent() @@ -92,6 +93,7 @@ void testBasicProxy() { settings.getGithubSettings().getCopilotSettings().getAgent() .setEnableSkills(PreferencesUtils.isSkillsEnabled()) .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + settings.getGithubSettings().getCopilotSettings().getAgent().setAutoCompress(true); settings.getGithubSettings().getCopilotSettings().getAgent() .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); settings.getGithubSettings().getCopilotSettings().getAgent() @@ -130,6 +132,7 @@ void testUpdateConfigShouldBeCalledWhenWorkspaceInstructionsEnabledWithContent() CopilotSettings copilotSettings = new CopilotSettings(); copilotSettings.setWorkspaceCopilotInstructions("Test instructions"); copilotSettings.getAgent().setEnableSkills(PreferencesUtils.isSkillsEnabled()); + copilotSettings.getAgent().setAutoCompress(true); CopilotLanguageServerSettings settings = new CopilotLanguageServerSettings(); settings.getGithubSettings().setCopilotSettings(copilotSettings); settings.getGithubSettings().getCopilotSettings().getAgent() @@ -169,6 +172,7 @@ void testUpdateConfigShouldBeCalledWithoutInstructionWhenWorkspaceInstructionsDi expectedSettings.getGithubSettings().getCopilotSettings().getAgent() .setEnableSkills(PreferencesUtils.isSkillsEnabled()) .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + expectedSettings.getGithubSettings().getCopilotSettings().getAgent().setAutoCompress(true); expectedSettings.getGithubSettings().getCopilotSettings().getAgent() .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); expectedSettings.getGithubSettings().getCopilotSettings().getAgent() diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java index 8890a95b..45557687 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java @@ -331,7 +331,7 @@ private void handleSubagentToolCall(AgentToolCall toolCall) { case "completed": // End of subagent block if (currentSubagentBlock != null) { - currentSubagentBlock.notifyTurnEnd(); + currentSubagentBlock.flushMessageBuffer(); inSubagentBlock = false; currentSubagentBlock = null; requestLayout(); @@ -349,7 +349,7 @@ private void handleSubagentToolCall(AgentToolCall toolCall) { case "error": // Handle errors in subagent if (currentSubagentBlock != null) { - currentSubagentBlock.notifyTurnEnd(); + currentSubagentBlock.flushMessageBuffer(); inSubagentBlock = false; currentSubagentBlock = null; } @@ -443,7 +443,7 @@ public void restoreSubagentContent(String toolCallId, CopilotTurnData copilotTur } } - block.notifyTurnEnd(); + block.flushMessageBuffer(); } /** @@ -521,9 +521,10 @@ private void appendTextToTextViewer(String text) { } /** - * Notify the end of the turn. + * Flushes any buffered, not-yet-newline-terminated message text by processing it as a final + * line and clearing the buffer. */ - public void notifyTurnEnd() { + public void flushMessageBuffer() { if (messageBuffer.length() > 0) { this.processMessageLine(messageBuffer.toString()); messageBuffer.setLength(0); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java index 7ef32077..86a0bbf7 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java @@ -68,7 +68,7 @@ public class ChatContentViewer extends ScrolledComposite { private Composite errorWidget; private BaseTurnWidget latestUserTurn; - private BaseTurnWidget latestCopilotTurn; + private CopilotTurnWidget latestCopilotTurn; private BaseTurnWidget latestTurnWidget; // Auto-scroll state management private boolean autoScrollEnabled; @@ -133,7 +133,7 @@ public void controlResized(ControlEvent e) { public void startNewTurn(String workDoneToken, String message) { BaseTurnWidget turnWidget = getLatestOrCreateNewTurnWidget(workDoneToken, false, true); turnWidget.appendMessage(message); - turnWidget.notifyTurnEnd(); + turnWidget.flushMessageBuffer(); refreshScrollerLayout(); scrollToLatestUserTurn(); @@ -158,9 +158,11 @@ public BaseTurnWidget getLatestOrCreateNewTurnWidget(String workDoneToken, boole turnWidget = latestTurnWidget; } else if (isCopilot) { // Create new Copilot turn widget - turnWidget = new CopilotTurnWidget(cmpContent, SWT.NONE, serviceManager, workDoneToken); - latestCopilotTurn = turnWidget; - latestTurnWidget = turnWidget; + CopilotTurnWidget copilotTurnWidget = new CopilotTurnWidget(cmpContent, SWT.NONE, serviceManager, + workDoneToken); + latestCopilotTurn = copilotTurnWidget; + latestTurnWidget = copilotTurnWidget; + turnWidget = copilotTurnWidget; } else { // Create new User turn widget turnWidget = new UserTurnWidget(cmpContent, SWT.NONE, serviceManager, workDoneToken); @@ -235,7 +237,7 @@ public void processTurnEvent(ChatProgressValue value) { thinkingTurn.sealThinking(); updateActiveThinkingBlockId(value.getTurnId(), thinkingTurn); } - turnWidget.notifyTurnEnd(); + turnWidget.flushMessageBuffer(); } refreshScrollerLayout(); @@ -379,6 +381,36 @@ private void processTodoListFromToolCall(ChatServiceManager chatServiceManager, } } + /** + * Shows the compacting status on the latest Copilot turn after flushing any buffered reply text. + */ + public void showCompactingStatusOnLatestCopilotTurn() { + if (latestCopilotTurn == null || latestCopilotTurn.isDisposed()) { + return; + } + // Flush any buffered reply text from the previous round so it is rendered + // above the compacting spinner; otherwise it would be concatenated with + // the next round's reply and produce a single garbled line. + latestCopilotTurn.flushMessageBuffer(); + latestCopilotTurn.showCompactingStatus(); + refreshScrollerLayout(); + } + + /** + * Hides the compacting status on the latest Copilot turn, flushing any buffered reply text + * first as a guard against buffered content that was not flushed by an end progress event. + */ + public void hideCompactingStatusOnLatestCopilotTurn() { + if (latestCopilotTurn == null || latestCopilotTurn.isDisposed()) { + return; + } + // Always flush before hiding; the buffer should be empty at this point, but flush as a guard + // in case a cancel path did not receive an end progress event to flush it. + latestCopilotTurn.flushMessageBuffer(); + latestCopilotTurn.hideCompactingStatus(); + refreshScrollerLayout(); + } + /** * Get an existed turn widget by turn ID. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java index 13e7fa09..89b350df 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java @@ -60,6 +60,8 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatStepStatus; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatStepTitles; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatTurnResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CompressionCompletedParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CompressionStartedParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ContextSizeInfo; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult; @@ -148,6 +150,8 @@ public class ChatView extends ViewPart implements ChatProgressListener, MessageL private EventHandler autoBreakpointToggleHandler; private EventHandler rateLimitWarningHandler; private EventHandler quotaWarningHandler; + private EventHandler compressionStartedHandler; + private EventHandler compressionCompletedHandler; // Context activation for chat view keyboard shortcuts private static final String CHAT_VIEW_CONTEXT = "com.microsoft.copilot.eclipse.chatViewContext"; @@ -398,6 +402,45 @@ public void done(IJobChangeEvent event) { }; this.eventBroker.subscribe(CopilotEventConstants.TOPIC_QUOTA_WARNING, this.quotaWarningHandler); + this.compressionStartedHandler = event -> { + Object data = event.getProperty(IEventBroker.DATA); + if (!(data instanceof CompressionStartedParams params)) { + return; + } + if (!isCompressionForActiveConversation(params.conversationId())) { + return; + } + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (this.chatContentViewer == null || this.chatContentViewer.isDisposed()) { + return; + } + this.chatContentViewer.showCompactingStatusOnLatestCopilotTurn(); + }, parent); + }; + this.eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_COMPRESSION_STARTED, + this.compressionStartedHandler); + + this.compressionCompletedHandler = event -> { + Object data = event.getProperty(IEventBroker.DATA); + if (!(data instanceof CompressionCompletedParams params)) { + return; + } + if (!isCompressionForActiveConversation(params.conversationId())) { + return; + } + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (this.chatContentViewer == null || this.chatContentViewer.isDisposed()) { + return; + } + this.chatContentViewer.hideCompactingStatusOnLatestCopilotTurn(); + if (params.contextInfo() != null) { + this.chatServiceManager.getContextWindowService().updateContextSize(params.contextInfo()); + } + }, parent); + }; + this.eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_COMPRESSION_COMPLETED, + this.compressionCompletedHandler); + // Register part listener to activate/deactivate chat view context for keyboard shortcuts registerPartListener(); } @@ -1209,6 +1252,15 @@ private boolean isProgressForCurrentConversation(ChatProgressValue value) { return StringUtils.equals(progressConversationId, this.conversationId); } + /** + * Checks whether a compression notification targets either the main conversation or the active subagent + * conversation, so the UI can reflect compaction happening at either level. + */ + private boolean isCompressionForActiveConversation(String compressionConversationId) { + return StringUtils.equals(compressionConversationId, this.conversationId) + || StringUtils.equals(compressionConversationId, this.subagentConversationId); + } + /** * Align with @Workspace of vscode, because we are actually indexing the whole workspace, not a single project. * (@Project is only for IntelliJ.) @@ -1377,6 +1429,9 @@ public void onCancel() { if (this.actionBar != null && !this.actionBar.isDisposed()) { this.actionBar.resetSendButton(); } + if (this.chatContentViewer != null && !this.chatContentViewer.isDisposed()) { + this.chatContentViewer.hideCompactingStatusOnLatestCopilotTurn(); + } } private void cancelCurrentTerminalCommand() { @@ -1565,6 +1620,14 @@ public void dispose() { this.eventBroker.unsubscribe(this.quotaWarningHandler); quotaWarningHandler = null; } + if (compressionStartedHandler != null) { + this.eventBroker.unsubscribe(this.compressionStartedHandler); + compressionStartedHandler = null; + } + if (compressionCompletedHandler != null) { + this.eventBroker.unsubscribe(this.compressionCompletedHandler); + compressionCompletedHandler = null; + } } if (this.chatServiceManager != null) { @@ -1821,17 +1884,17 @@ private void restoreTurn(AbstractTurnData turn) { if (userTurn.getMessage() == null || StringUtils.isNotBlank(userTurn.getMessage().getText())) { BaseTurnWidget userTurnWidget = chatContentViewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), false, true); userTurnWidget.appendMessage(userTurn.getMessage().getText()); - userTurnWidget.notifyTurnEnd(); + userTurnWidget.flushMessageBuffer(); return; } } else if (turn instanceof CopilotTurnData copilotTurn) { BaseTurnWidget copilotTurnWidget = chatContentViewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), true, true); restoreCopilotTurnContent(copilotTurn, copilotTurnWidget); - copilotTurnWidget.notifyTurnEnd(); + copilotTurnWidget.flushMessageBuffer(); // Restore model info footer if model name is present - // This must be done AFTER notifyTurnEnd() to ensure footer appears at the bottom + // This must be done AFTER flushMessageBuffer() to ensure footer appears at the bottom ReplyData replyData = copilotTurn.getReply(); if (replyData != null && StringUtils.isNotBlank(replyData.getModelName())) { // Reasoning effort was captured and persisted at send time so the footer reflects what was actually used diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CopilotTurnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CopilotTurnWidget.java index e3efbdd7..f026aa0e 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CopilotTurnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CopilotTurnWidget.java @@ -17,6 +17,7 @@ import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; import com.microsoft.copilot.eclipse.ui.i18n.Messages; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; +import com.microsoft.copilot.eclipse.ui.swt.SpinnerAnimator; import com.microsoft.copilot.eclipse.ui.utils.AccessibilityUtils; import com.microsoft.copilot.eclipse.ui.utils.ModelUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; @@ -25,6 +26,10 @@ * A custom widget that displays a turn for the copilot. */ public class CopilotTurnWidget extends ThinkingTurnWidget { + + private Composite compactingComposite; + private SpinnerAnimator compactingSpinner; + /** * Create the widget. */ @@ -106,6 +111,50 @@ public void renderModelInfo(String modelName, double billingMultiplier, String r } } + /** + * Shows a "Compacting conversation..." spinner below the last message in this turn. + * Must be called on the UI thread. + */ + public void showCompactingStatus() { + if (isDisposed() || compactingComposite != null) { + return; + } + compactingComposite = new Composite(this, SWT.NONE); + GridLayout layout = new GridLayout(2, false); + layout.marginWidth = 0; + layout.marginHeight = 4; + compactingComposite.setLayout(layout); + compactingComposite.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + + Label spinnerLabel = new Label(compactingComposite, SWT.NONE); + spinnerLabel.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false)); + compactingSpinner = new SpinnerAnimator(spinnerLabel); + compactingSpinner.start(); + + Label statusLabel = new Label(compactingComposite, SWT.NONE); + statusLabel.setText(Messages.chat_compacting_conversation); + statusLabel.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, true, false)); + + ensureFooterAtBottom(); + requestLayout(); + } + + /** + * Hides the "Compacting conversation..." spinner. + * Must be called on the UI thread. + */ + public void hideCompactingStatus() { + if (compactingSpinner != null) { + compactingSpinner.stop(); + compactingSpinner = null; + } + if (compactingComposite != null && !compactingComposite.isDisposed()) { + compactingComposite.dispose(); + compactingComposite = null; + } + requestLayout(); + } + @Override protected void createFooter() { footer = new Composite(this, SWT.NONE); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SubagentMessageBlock.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SubagentMessageBlock.java index 45edd8cb..e7dcf908 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SubagentMessageBlock.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SubagentMessageBlock.java @@ -91,9 +91,9 @@ public void appendToolCallStatus(AgentToolCall toolCall) { /** * Notify the end of the subagent turn. */ - public void notifyTurnEnd() { + public void flushMessageBuffer() { if (currentSubagentTurnWidget != null) { - currentSubagentTurnWidget.notifyTurnEnd(); + currentSubagentTurnWidget.flushMessageBuffer(); } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java index 1aef96fc..f8ecd839 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java @@ -224,6 +224,7 @@ public final class Messages extends NLS { public static String context_window_messages; public static String context_window_files; public static String context_window_tool_results; + public static String chat_compacting_conversation; public static String chat_rateLimitBanner_getMoreInfo; public static String chat_rateLimitBanner_closeTooltip; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties index 4cb8235e..16e4f4f1 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties @@ -218,6 +218,7 @@ context_window_user_context=User Context context_window_messages=Messages context_window_files=Attached Files context_window_tool_results=Tool Results +chat_compacting_conversation=Compacting conversation... chat_rateLimitBanner_getMoreInfo=Get more info chat_rateLimitBanner_closeTooltip=Dismiss 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 e3b78dad..be5f6da5 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 @@ -92,6 +92,8 @@ public LanguageServerSettingManager(CopilotLanguageServerConnection conn, IProxy .setAgentMaxRequests(preferenceStore.getInt(Constants.AGENT_MAX_REQUESTS)); getSettings().getGithubSettings().getCopilotSettings().getAgent() .setEnableSkills(PreferencesUtils.isSkillsEnabled()); + getSettings().getGithubSettings().getCopilotSettings().getAgent() + .setAutoCompress(true); // Set transcript directory for CLS session persistence and restoration getSettings().getGithubSettings().getCopilotSettings().getAgent() From 5cc8358c74bef3158ebe169fe4675cc5a4e39cae Mon Sep 17 00:00:00 2001 From: Sheng Chen Date: Tue, 2 Jun 2026 11:14:14 +0800 Subject: [PATCH 07/27] fix: harden MCP extension-point registration against races and stale state (#164) - Synchronize all extMcpInfoMap accesses on the manager monitor so the HashMap is never iterated/mutated concurrently by the async doRegistration() worker, the contribution-point-policy event handler, and the UI-thread approval flow. - approveExtMcpRegistration() now snapshots the map under the lock and opens McpApprovalDialog outside the lock so the worker is not stalled while the user interacts; post-dialog reconciliation re-acquires the lock and the LSP-pushing event publish runs outside the lock. - Mark approvedExtMcpServers volatile and make hasExtMcpRegistration() synchronized for consistent visibility from the UI thread. - Avoid pre-populating in-memory approved servers from the persisted cache at startup; let doRegistration() refresh state and fire TOPIC_MCP_EXTENSION_POINT_REGISTRATION_COMPLETED so the LSP receives the verified set instead of stale data (uninstalled plugin, changed port, removed server). - Fix bundle-state guard in loadMcpRegistrationExtensionPoint() (|| -> &&) so already-active or starting bundles are not pointlessly started again. - LanguageServerSettingManager subscribes to the new completion topic and exposes an overload that accepts the approved JSON directly, avoiding the ChatServiceManager lookup from early-startup paths. - Add regression tests covering plugin uninstall, config change (re-approval required), and unchanged config (approval carry-over). Issue: https://github.com/microsoft/copilot-for-eclipse/issues/153 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: xinyi-gong --- .../core/events/CopilotEventConstants.java | 11 ++ .../McpExtensionPointManagerTest.java | 150 ++++++++++++++++++ .../copilot/eclipse/ui/chat/ActionBar.java | 5 +- .../services/McpExtensionPointManager.java | 115 ++++++++++---- .../LanguageServerSettingManager.java | 35 +++- .../ui/preferences/McpPreferencePage.java | 6 +- 6 files changed, 277 insertions(+), 45 deletions(-) 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 303c5950..61637b76 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 @@ -151,6 +151,17 @@ public class CopilotEventConstants { */ public static final String TOPIC_MCP_SERVER_STATE_CHANGE = TOPIC_MCP + "SERVER_STATE_CHANGE"; + /** + * Event when the MCP registration extension-point manager has finished detecting and verifying + * the contributed servers (either at startup or after a policy toggle / user approval) and the + * approved-servers JSON for the language server is ready to be pushed. + * + *

Payload (via {@code IEventBroker.DATA}): the approved-servers JSON {@code String}, or + * {@code null} when no extension-contributed servers are approved. + */ + public static final String TOPIC_MCP_EXTENSION_POINT_REGISTRATION_COMPLETED = TOPIC_MCP + + "EXTENSION_POINT_REGISTRATION_COMPLETED"; + /** * Event when NES suggestion is accepted. */ diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManagerTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManagerTest.java index 3f60a488..69c3e784 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManagerTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManagerTest.java @@ -4,10 +4,13 @@ package com.microsoft.copilot.eclipse.ui.chat.services; import static org.junit.jupiter.api.Assertions.assertEquals; +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.eq; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -207,4 +210,151 @@ void testUpdateApprovedMcpServerStringWithNullServers() throws Exception { Map resultServers = (Map) result.get("servers"); assertTrue(resultServers.isEmpty(), "Servers map should be empty when MCP servers are null"); } + + @Test + void testDetectChangesDropsApprovedServerWhenPluginUninstalled() throws Exception { + // Regression test for https://github.com/microsoft/copilot-for-eclipse/issues/153 scenario 1: + // a previously approved plugin is no longer providing any MCP server. + IPreferenceStore mockPreferenceStore = mock(IPreferenceStore.class); + when(mockCopilotUi.getPreferenceStore()).thenReturn(mockPreferenceStore); + + Map previouslyApprovedServers = new HashMap<>(); + previouslyApprovedServers.put("server-X", Map.of("url", "http://localhost:9000")); + McpRegistrationInfo persistedInfo = createMcpRegistrationInfo(true, true, "Test Plugin", + previouslyApprovedServers); + Map persisted = new HashMap<>(); + persisted.put("com.example.plugin", persistedInfo); + + // Live extension scan returned nothing (plugin uninstalled or no longer provides servers). + setExtMcpInfoMap(new HashMap<>()); + + invokeDetectChanges(persisted); + + String approvedServers = manager.getApprovedExtMcpServers(); + assertNotNull(approvedServers, "Approved servers JSON should be non-null after detect"); + Map result = gson.fromJson(approvedServers, Map.class); + Map resultServers = (Map) result.get("servers"); + assertTrue(resultServers.isEmpty(), + "Stale approved server must be dropped when the live extension scan returns nothing"); + + // No new approval prompt should be raised because there is no incoming registration to approve. + verify(mockMcpConfigService, never()).setNewExtMcpRegFound(true); + + // The verified state must be persisted so subsequent startups do not resurrect the stale entry. + ArgumentCaptor persistedJson = ArgumentCaptor.forClass(String.class); + verify(mockPreferenceStore, atLeastOnce()).setValue(eq(Constants.MCP_EXTENSION_POINT_CONTRIB), + persistedJson.capture()); + assertEquals("{}", persistedJson.getValue(), + "Persisted contribution map should be empty after the plugin is gone"); + } + + @Test + void testDetectChangesDropsApprovalAndFlagsRedNoticeWhenConfigChanges() throws Exception { + // Regression test for https://github.com/microsoft/copilot-for-eclipse/issues/153 scenario 2: + // the contributing plugin returns a different config than what was previously approved. + IPreferenceStore mockPreferenceStore = mock(IPreferenceStore.class); + when(mockCopilotUi.getPreferenceStore()).thenReturn(mockPreferenceStore); + + Map oldServers = new HashMap<>(); + oldServers.put("server-X", Map.of("url", "http://localhost:9000")); + McpRegistrationInfo persistedInfo = createMcpRegistrationInfo(true, true, "Test Plugin", oldServers); + Map persisted = new HashMap<>(); + persisted.put("com.example.plugin", persistedInfo); + + // Live extension scan returned the same plugin but with a different server config (port change). + Map newServers = new HashMap<>(); + newServers.put("server-X", Map.of("url", "http://localhost:9999")); + McpRegistrationInfo currentInfo = createMcpRegistrationInfo(true, false, "Test Plugin", newServers); + Map currentMap = new HashMap<>(); + currentMap.put("com.example.plugin", currentInfo); + setExtMcpInfoMap(currentMap); + + invokeDetectChanges(persisted); + + String approvedServers = manager.getApprovedExtMcpServers(); + assertNotNull(approvedServers); + Map result = gson.fromJson(approvedServers, Map.class); + Map resultServers = (Map) result.get("servers"); + assertTrue(resultServers.isEmpty(), + "Changed config must not be auto-applied; LSP must not receive the (now unapproved) entry"); + + assertFalse(currentInfo.isApproved(), + "Previously approved entry whose config changed must be marked as unapproved until the user re-approves"); + verify(mockMcpConfigService).setNewExtMcpRegFound(true); + verify(mockPreferenceStore, atLeastOnce()).setValue(eq(Constants.MCP_EXTENSION_POINT_CONTRIB), + org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void testDetectChangesPreservesApprovalWhenConfigUnchanged() throws Exception { + // Companion test: when the live extension scan reports the same config as the persisted cache, + // the previous approval must be carried over so the explicit LSP sync at the end of + // doRegistration() can push the verified servers to the language server. + IPreferenceStore mockPreferenceStore = mock(IPreferenceStore.class); + when(mockCopilotUi.getPreferenceStore()).thenReturn(mockPreferenceStore); + + Map approvedServers = new HashMap<>(); + approvedServers.put("server-X", Map.of("url", "http://localhost:9000")); + McpRegistrationInfo persistedInfo = createMcpRegistrationInfo(true, true, "Test Plugin", approvedServers); + Map persisted = new HashMap<>(); + persisted.put("com.example.plugin", persistedInfo); + + // Live extension scan returned the same servers; default isApproved=false until carry-over runs. + Map sameServers = new HashMap<>(); + sameServers.put("server-X", Map.of("url", "http://localhost:9000")); + McpRegistrationInfo currentInfo = createMcpRegistrationInfo(true, false, "Test Plugin", sameServers); + Map currentMap = new HashMap<>(); + currentMap.put("com.example.plugin", currentInfo); + setExtMcpInfoMap(currentMap); + + invokeDetectChanges(persisted); + + assertTrue(currentInfo.isApproved(), + "When the contributed config matches the persisted JSON, the previous approval must be carried over"); + + String approvedJson = manager.getApprovedExtMcpServers(); + assertNotNull(approvedJson); + Map result = gson.fromJson(approvedJson, Map.class); + Map resultServers = (Map) result.get("servers"); + assertEquals(1, resultServers.size(), + "Carried-over approved server must be present in the approved servers JSON for the LSP sync"); + + // No red-notice should be raised because nothing has changed for the user to re-review. + verify(mockMcpConfigService, never()).setNewExtMcpRegFound(true); + } + + /** + * Reflectively replace the manager's private {@code extMcpInfoMap} so tests can simulate the + * outcome of {@code loadMcpRegistrationExtensionPoint()} without requiring a live OSGi + * extension registry. + */ + private void setExtMcpInfoMap(Map map) throws Exception { + Field field = McpExtensionPointManager.class.getDeclaredField("extMcpInfoMap"); + field.setAccessible(true); + field.set(manager, map); + } + + private void invokeDetectChanges(Map persisted) throws Exception { + // Get the scanned map that was previously set via setExtMcpInfoMap + Field field = McpExtensionPointManager.class.getDeclaredField("extMcpInfoMap"); + field.setAccessible(true); + @SuppressWarnings("unchecked") + Map scannedMap = (Map) field.get(manager); + + // detectChangesInMcpContribs now takes (scannedMap, persistedMap) and no longer + // calls updateApprovedMcpServerString / persistExtMcpInfo (those moved to doRegistration). + Method detectMethod = McpExtensionPointManager.class.getDeclaredMethod("detectChangesInMcpContribs", Map.class, + Map.class); + detectMethod.setAccessible(true); + detectMethod.invoke(manager, scannedMap, persisted); + + // Mirror what doRegistration() does after detectChanges: swap the field and update/persist. + field.set(manager, scannedMap); + Method updateMethod = McpExtensionPointManager.class.getDeclaredMethod("updateApprovedMcpServerString", Map.class); + updateMethod.setAccessible(true); + updateMethod.invoke(manager, scannedMap); + Method persistMethod = McpExtensionPointManager.class.getDeclaredMethod("persistExtMcpInfo", Map.class); + persistMethod.setAccessible(true); + persistMethod.invoke(manager, scannedMap); + } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ActionBar.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ActionBar.java index 0cb765f7..2c98738f 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ActionBar.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ActionBar.java @@ -1097,10 +1097,7 @@ private void showMcpToolContextMenu() { approvalItem.addSelectionListener(new SelectionAdapter() { @Override public void widgetSelected(SelectionEvent e) { - String res = chatServiceManager.getMcpExtensionPointManager().approveExtMcpRegistration(); - if (StringUtils.isNotBlank(res)) { - CopilotUi.getPlugin().getLanguageServerSettingManager().syncMcpRegistrationConfiguration(); - } + chatServiceManager.getMcpExtensionPointManager().approveExtMcpRegistration(); } }); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManager.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManager.java index 759af1a0..07685bda 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManager.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManager.java @@ -52,11 +52,12 @@ public class McpExtensionPointManager { private static final String ELEMENT_PROVIDER = "provider"; private static final String ATTRIBUTE_CLASS = "class"; - private String approvedExtMcpServers; + private volatile String approvedExtMcpServers; private Map extMcpInfoMap = new HashMap<>(); // Key: Plugin-Id(Bundle) private McpConfigService mcpConfigService; private Gson gson; + private IEventBroker eventBroker; /** * Constructor for McpExtensionPointManager. @@ -64,28 +65,36 @@ public class McpExtensionPointManager { public McpExtensionPointManager(McpConfigService mcpConfigService) { gson = new GsonBuilder().disableHtmlEscaping().create(); this.mcpConfigService = mcpConfigService; + this.eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); if (CopilotCore.getPlugin().getFeatureFlags().isMcpContributionPointEnabled()) { initializeExtMcpRegistration(); } - IEventBroker 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()) { initializeExtMcpRegistration(); } else { - extMcpInfoMap.clear(); - approvedExtMcpServers = null; - persistExtMcpInfo(extMcpInfoMap); - mcpConfigService.setNewExtMcpRegFound(false); + // Disabling the contribution point clears the in-memory state shared with doRegistration(). + // Hold the manager monitor so the (non-thread-safe) HashMap clear and the approved-servers + // reset are not observed mid-update by a concurrent doRegistration() running on the async + // worker. + synchronized (this) { + extMcpInfoMap.clear(); + approvedExtMcpServers = null; + persistExtMcpInfo(extMcpInfoMap); + mcpConfigService.setNewExtMcpRegFound(false); + } } }); } private synchronized void initializeExtMcpRegistration() { - // Previously approved servers will be started during Plugin startup. + // Avoid pre-populating the in-memory approved servers from the persisted cache. If we did so, + // the initial syncMcpRegistrationConfiguration() at startup would push potentially stale data + // (server removed by the contributing plugin, port changed, plugin uninstalled) to the language + // server, which would surface a connection failure before doRegistration() can refresh the state. Map persistedMcpContribs = loadPersistedMcpContribs(); - updateApprovedMcpServerString(persistedMcpContribs); // Run the heavy initialization work asynchronously, which has weak relation with Plugin startup. CompletableFuture.runAsync(() -> { @@ -93,16 +102,42 @@ private synchronized void initializeExtMcpRegistration() { }); } - private synchronized void doRegistration(Map persistedMcpContribs) { + private void doRegistration(Map persistedMcpContribs) { + String approvedServersToPublish = null; + boolean shouldPublish = false; try { FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); if (flags != null && !flags.isMcpEnabled()) { return; } - loadMcpRegistrationExtensionPoint(); - detectChangesInMcpContribs(persistedMcpContribs); + // Perform the slow extension-point scan and diff work outside the lock so that + // UI-thread callers (e.g. approveExtMcpRegistration) are not blocked. + Map scannedMap = loadMcpRegistrationExtensionPoint(); + detectChangesInMcpContribs(scannedMap, persistedMcpContribs); + synchronized (this) { + extMcpInfoMap = scannedMap; + updateApprovedMcpServerString(extMcpInfoMap); + persistExtMcpInfo(extMcpInfoMap); + approvedServersToPublish = approvedExtMcpServers; + shouldPublish = true; + } } catch (Exception e) { CopilotCore.LOGGER.error("Error during EXT MCP registration initialization", e); + return; + } + // Publish the verified state outside the synchronized section so the manager monitor is not + // held while subscribers (notably LanguageServerSettingManager) push to the language server. + // The event fires unconditionally on success - including when the persisted JSON is unchanged + // and IPreferenceStore.setValue() therefore short-circuits its property-change notification - + // so the LSP always receives the verified extension-contributed servers. + if (shouldPublish) { + publishRegistrationCompleted(approvedServersToPublish); + } + } + + private void publishRegistrationCompleted(String approvedServersJson) { + if (eventBroker != null) { + eventBroker.post(CopilotEventConstants.TOPIC_MCP_EXTENSION_POINT_REGISTRATION_COMPLETED, approvedServersJson); } } @@ -151,11 +186,12 @@ private void updateApprovedMcpServerString(Map extM /** * Load MCP registration from extension point. */ - private void loadMcpRegistrationExtensionPoint() { + private Map loadMcpRegistrationExtensionPoint() { + Map result = new HashMap<>(); IExtensionRegistry registry = Platform.getExtensionRegistry(); IExtensionPoint extensionPoint = registry.getExtensionPoint(EXTENSION_POINT_ID); if (extensionPoint == null) { - return; + return result; } // Traverse all extensions/bundles. @@ -167,7 +203,7 @@ private void loadMcpRegistrationExtensionPoint() { CopilotCore.LOGGER.error("Cannot find bundle: " + bundleName, null); continue; // Skip inactive plug-ins } - if (bundle.getState() != Bundle.ACTIVE || bundle.getState() != Bundle.STARTING) { + if (bundle.getState() != Bundle.ACTIVE && bundle.getState() != Bundle.STARTING) { try { bundle.start(Bundle.START_ACTIVATION_POLICY); } catch (BundleException e) { @@ -221,9 +257,10 @@ private void loadMcpRegistrationExtensionPoint() { // Update registration info if (!mergedServers.isEmpty()) { - extMcpInfoMap.put(bundleName, new McpRegistrationInfo(isTrusted, isApproved, pluginDisplayName, mergedServers)); + result.put(bundleName, new McpRegistrationInfo(isTrusted, isApproved, pluginDisplayName, mergedServers)); } } + return result; } /** @@ -268,14 +305,16 @@ private boolean isMcpFromSignedBundle(Bundle bundle) { /** * Detect changes in MCP registration from extension point compared to the existing record. */ - private void detectChangesInMcpContribs(Map existingExtMcpInfoMap) { + private void detectChangesInMcpContribs( + Map scannedMap, + Map existingExtMcpInfoMap) { boolean newExtMcpRegFound = false; if (existingExtMcpInfoMap == null) { existingExtMcpInfoMap = Collections.emptyMap(); } // Compare each plugin's current MCP servers with the stored record - for (Map.Entry entry : extMcpInfoMap.entrySet()) { + for (Map.Entry entry : scannedMap.entrySet()) { String contributorName = entry.getKey(); McpRegistrationInfo mcpRegistrationInfo = entry.getValue(); McpRegistrationInfo storedInfo = existingExtMcpInfoMap.get(contributorName); @@ -296,13 +335,6 @@ private void detectChangesInMcpContribs(Map existin if (newExtMcpRegFound) { mcpConfigService.setNewExtMcpRegFound(true); } - - // Always persist the latest MCP registration info, in case some plug-ins are un-installed, or unregister MCP - // servers. - if (!extMcpInfoMap.equals(existingExtMcpInfoMap)) { - updateApprovedMcpServerString(extMcpInfoMap); - persistExtMcpInfo(extMcpInfoMap); - } } /** @@ -310,18 +342,35 @@ private void detectChangesInMcpContribs(Map existin */ public String approveExtMcpRegistration() { Shell shell = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getShell(); - if (extMcpInfoMap.isEmpty()) { - MessageDialog.openInformation(shell, "", "No MCP server registration found"); - return null; + Map dialogInput; + synchronized (this) { + if (extMcpInfoMap.isEmpty()) { + MessageDialog.openInformation(shell, "", "No MCP server registration found"); + return null; + } + // Take a shallow snapshot so the dialog can iterate the map safely even if doRegistration() + // mutates extMcpInfoMap on the async worker. Approval flips happen on the McpRegistrationInfo + // value objects, which are shared between the snapshot and the live map by reference, so the + // user's choices are reflected in extMcpInfoMap once the dialog returns. + dialogInput = new HashMap<>(extMcpInfoMap); } - McpApprovalDialog dialog = new McpApprovalDialog(shell, extMcpInfoMap); + // Open the modal dialog outside the synchronized block so a concurrent doRegistration() worker + // is not stalled on this manager's monitor while the user interacts with the dialog. + McpApprovalDialog dialog = new McpApprovalDialog(shell, dialogInput); dialog.open(); - mcpConfigService.setNewExtMcpRegFound(false); // Reset the flag after user approval - updateApprovedMcpServerString(extMcpInfoMap); - persistExtMcpInfo(extMcpInfoMap); - return approvedExtMcpServers; + String approvedJson; + synchronized (this) { + mcpConfigService.setNewExtMcpRegFound(false); // Reset the flag after user approval + updateApprovedMcpServerString(extMcpInfoMap); + persistExtMcpInfo(extMcpInfoMap); + approvedJson = approvedExtMcpServers; + } + // Publish outside the lock so subscribers (notably LanguageServerSettingManager) are not + // running their LSP push while the manager monitor is held. + publishRegistrationCompleted(approvedJson); + return approvedJson; } private void persistExtMcpInfo(Map extMcpInfoMap) { @@ -396,7 +445,7 @@ public String getMcpServersAsJson() { /** * Check if there is any MCP registration from extension point. */ - public boolean hasExtMcpRegistration() { + public synchronized boolean hasExtMcpRegistration() { return !extMcpInfoMap.isEmpty(); } } 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 be5f6da5..26b2c024 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 @@ -127,6 +127,8 @@ public LanguageServerSettingManager(CopilotLanguageServerConnection conn, IProxy syncMcpRegistrationConfiguration(); } }); + eventBroker.subscribe(CopilotEventConstants.TOPIC_MCP_EXTENSION_POINT_REGISTRATION_COMPLETED, + event -> syncMcpRegistrationConfiguration((String) event.getProperty(IEventBroker.DATA))); } /** @@ -164,7 +166,7 @@ public void propertyChange(PropertyChangeEvent event) { settings.getGithubEnterprise().setUri(preferenceStore.getString(Constants.GITHUB_ENTERPRISE)); singleSetting = new CopilotLanguageServerSettings(null, null, settings.getGithubEnterprise(), null); break; - case Constants.MCP, Constants.MCP_EXTENSION_POINT_CONTRIB: + case Constants.MCP: syncMcpRegistrationConfiguration(); return; case Constants.MCP_TOOLS_STATUS: @@ -255,13 +257,40 @@ private void updateGithubPanicErrorReport() { * Sync MCP registration from both extension points and preference store. */ public void syncMcpRegistrationConfiguration() { + String approvedExtMcpServers = null; + if (CopilotCore.getPlugin().getFeatureFlags().isMcpContributionPointEnabled()) { + // Defensive null-chain: callers from very early startup paths (e.g. CopilotUi.start()) may + // run before the ChatServiceManager singleton field is assigned. The extension-point flow + // delivers its own state via TOPIC_MCP_EXTENSION_POINT_REGISTRATION_COMPLETED, so missing it + // here just means the LSP gets the manual MCP state for now and the verified state arrives + // shortly after via that subscription. + var chatServiceManager = CopilotUi.getPlugin().getChatServiceManager(); + if (chatServiceManager != null) { + McpExtensionPointManager mgr = chatServiceManager.getMcpExtensionPointManager(); + if (mgr != null) { + approvedExtMcpServers = mgr.getApprovedExtMcpServers(); + } + } + } + syncMcpRegistrationConfiguration(approvedExtMcpServers); + } + + /** + * Sync MCP registration to the language server using the supplied extension-contributed approved + * servers JSON. Used by callers that already have the approved JSON in hand - notably the + * subscriber to {@link CopilotEventConstants#TOPIC_MCP_EXTENSION_POINT_REGISTRATION_COMPLETED} - + * so they do not need to traverse {@code CopilotUi.getChatServiceManager()} to look it up. + * + * @param approvedExtMcpServers JSON for the approved extension-contributed MCP servers, or + * {@code null} when none are approved or the contribution point is disabled. + */ + public void syncMcpRegistrationConfiguration(String approvedExtMcpServers) { // From manual configuration settings.setMcpServers(preferenceStore.getString(Constants.MCP)); // From McpRegistration extension point if (CopilotCore.getPlugin().getFeatureFlags().isMcpContributionPointEnabled()) { - McpExtensionPointManager mgr = CopilotUi.getPlugin().getChatServiceManager().getMcpExtensionPointManager(); - settings.addMcpServers(mgr.getApprovedExtMcpServers()); + settings.addMcpServers(approvedExtMcpServers); } syncSingleConfiguration(new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings())); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java index a3682cf9..c58c3947 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java @@ -431,11 +431,7 @@ private void createExtMcpRegistrationArea(Composite parent) { extMcpButton.addSelectionListener(new SelectionAdapter() { @Override public void widgetSelected(SelectionEvent e) { - String res = CopilotUi.getPlugin().getChatServiceManager().getMcpExtensionPointManager() - .approveExtMcpRegistration(); - if (StringUtils.isNotBlank(res)) { - CopilotUi.getPlugin().getLanguageServerSettingManager().syncMcpRegistrationConfiguration(); - } + CopilotUi.getPlugin().getChatServiceManager().getMcpExtensionPointManager().approveExtMcpRegistration(); } }); } From 4244f7b543ab50664b23cb319f323bc34b9de756 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Tue, 2 Jun 2026 13:09:55 +0800 Subject: [PATCH 08/27] fix: Nested scrolling and full-row table selection for auto-approve preferences (#256) --- .../CustomInstructionPreferencePage.java | 14 +-- .../FileOperationAutoApproveSection.java | 5 +- .../ui/preferences/McpAutoApproveSection.java | 3 +- .../TerminalAutoApproveSection.java | 6 +- .../copilot/eclipse/ui/utils/SwtUtils.java | 92 +++++++++++++++++++ 5 files changed, 104 insertions(+), 16 deletions(-) diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java index 437d3aba..79b82234 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java @@ -22,7 +22,6 @@ import org.eclipse.jface.text.ITextViewer; import org.eclipse.swt.SWT; import org.eclipse.swt.custom.StyledText; -import org.eclipse.swt.events.ControlAdapter; import org.eclipse.swt.events.SelectionListener; import org.eclipse.swt.graphics.Point; import org.eclipse.swt.layout.GridData; @@ -269,18 +268,7 @@ private void createProjectInstructionsField(Composite parent, GridLayout gl) { // Set the file location column to take remaining width (table width - project name column width) fileLocationColumn.setWidth(tableGridData.widthHint > 0 ? tableGridData.widthHint - 150 : 400); - // Add resize listener to make the file location column take remaining width - table.addControlListener(new ControlAdapter() { - @Override - public void controlResized(org.eclipse.swt.events.ControlEvent e) { - int tableWidth = table.getClientArea().width; - int projectNameWidth = projectNameColumn.getWidth(); - int remainingWidth = tableWidth - projectNameWidth; - if (remainingWidth > 100) { // Minimum width for file location column - fileLocationColumn.setWidth(remainingWidth); - } - } - }); + SwtUtils.resizeColumnToFillTable(table, fileLocationColumn, 100, projectNameColumn); // Populate table with actual workspace projects populateProjectTable(table); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java index 93bc9d21..c299d40a 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java @@ -112,13 +112,14 @@ private void createContents() { private void createTable(Composite parent) { tableViewer = new TableViewer(parent, - SWT.BORDER | SWT.FULL_SELECTION | SWT.SINGLE); + SWT.BORDER | SWT.FULL_SELECTION | SWT.SINGLE | SWT.V_SCROLL); Table table = tableViewer.getTable(); GridData tableData = new GridData(SWT.FILL, SWT.FILL, true, false); tableData.heightHint = TABLE_HEIGHT_HINT; table.setLayoutData(tableData); table.setHeaderVisible(true); table.setLinesVisible(true); + SwtUtils.forwardVerticalMouseWheelToParentScrollerAtBoundary(table); TableViewerColumn patternCol = new TableViewerColumn(tableViewer, SWT.NONE); @@ -177,6 +178,8 @@ public Color getForeground(Object element) { ? Display.getDefault().getSystemColor(SWT.COLOR_DARK_GRAY) : null; } }); + SwtUtils.resizeColumnToFillTable(table, statusCol.getColumn(), 100, + patternCol.getColumn(), descCol.getColumn()); tableViewer.setContentProvider(ArrayContentProvider.getInstance()); tableViewer.addSelectionChangedListener(e -> updateButtonState()); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java index bc52d63e..357234b4 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java @@ -90,10 +90,11 @@ private void createContents() { // Tree viewer for server/tool approval treeViewer = new CheckboxTreeViewer(group, - SWT.BORDER | SWT.FULL_SELECTION); + SWT.BORDER | SWT.FULL_SELECTION | SWT.V_SCROLL); GridData treeData = new GridData(SWT.FILL, SWT.FILL, true, false); treeData.heightHint = TREE_HEIGHT_HINT; treeViewer.getTree().setLayoutData(treeData); + SwtUtils.forwardVerticalMouseWheelToParentScrollerAtBoundary(treeViewer.getTree()); treeViewer.setContentProvider(new McpTreeContentProvider()); treeViewer.setLabelProvider(new McpTreeLabelProvider()); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/TerminalAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/TerminalAutoApproveSection.java index db41cb10..46664360 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/TerminalAutoApproveSection.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/TerminalAutoApproveSection.java @@ -30,6 +30,7 @@ import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.chat.TerminalAutoApproveRule; import com.microsoft.copilot.eclipse.ui.chat.confirmation.TerminalConfirmationHandler; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; /** * Terminal auto-approve section with a rule table, action buttons, and @@ -90,13 +91,14 @@ private void createContents() { private void createTable(Composite parent) { tableViewer = new TableViewer(parent, - SWT.BORDER | SWT.FULL_SELECTION | SWT.SINGLE); + SWT.BORDER | SWT.FULL_SELECTION | SWT.SINGLE | SWT.V_SCROLL); Table table = tableViewer.getTable(); GridData tableData = new GridData(SWT.FILL, SWT.FILL, true, false); tableData.heightHint = TABLE_HEIGHT_HINT; table.setLayoutData(tableData); table.setHeaderVisible(true); table.setLinesVisible(true); + SwtUtils.forwardVerticalMouseWheelToParentScrollerAtBoundary(table); TableViewerColumn commandCol = new TableViewerColumn(tableViewer, SWT.NONE); @@ -123,6 +125,8 @@ public String getText(Object element) { : Messages.preferences_page_auto_approve_deny; } }); + SwtUtils.resizeColumnToFillTable(table, statusCol.getColumn(), 100, + commandCol.getColumn()); tableViewer.setContentProvider(ArrayContentProvider.getInstance()); tableViewer.addSelectionChangedListener(e -> updateButtonState()); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/SwtUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/SwtUtils.java index ea44da6f..0a054490 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/SwtUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/SwtUtils.java @@ -12,15 +12,25 @@ import org.eclipse.jface.resource.ColorRegistry; import org.eclipse.jface.resource.JFaceResources; import org.eclipse.jface.text.ITextViewer; +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.ScrolledComposite; import org.eclipse.swt.custom.StyledText; import org.eclipse.swt.dnd.Clipboard; import org.eclipse.swt.dnd.TextTransfer; import org.eclipse.swt.dnd.Transfer; +import org.eclipse.swt.events.ControlAdapter; +import org.eclipse.swt.events.ControlEvent; import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.Point; import org.eclipse.swt.graphics.RGB; +import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.ScrollBar; +import org.eclipse.swt.widgets.Scrollable; import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Table; +import org.eclipse.swt.widgets.TableColumn; import org.eclipse.ui.IEditorPart; import org.eclipse.ui.IWorkbench; import org.eclipse.ui.IWorkbenchPage; @@ -272,6 +282,88 @@ public static Color getDefaultGhostTextColor(Display display) { return new Color(display, new RGB(DEFAULT_GHOST_TEXT_SCALE, DEFAULT_GHOST_TEXT_SCALE, DEFAULT_GHOST_TEXT_SCALE)); } + /** + * Forwards vertical mouse wheel scrolling from a nested scrollable to its nearest parent scroller when the nested + * control is already at the scroll boundary. + */ + public static void forwardVerticalMouseWheelToParentScrollerAtBoundary(Scrollable scrollable) { + scrollable.addListener(SWT.MouseWheel, event -> { + if (event.count == 0 || scrollable.isDisposed() + || canScrollVertically(scrollable.getVerticalBar(), event.count)) { + return; + } + + ScrolledComposite parentScroller = findParentScroller(scrollable); + if (parentScroller != null + && canScrollVertically(parentScroller.getVerticalBar(), event.count)) { + event.doit = false; + scrollParentVertically(parentScroller, event.count); + } + }); + } + + private static ScrolledComposite findParentScroller(Scrollable scrollable) { + Composite parent = scrollable.getParent(); + while (parent != null) { + if (parent instanceof ScrolledComposite scrolledComposite) { + return scrolledComposite; + } + parent = parent.getParent(); + } + return null; + } + + private static void scrollParentVertically(ScrolledComposite scrolledComposite, int wheelCount) { + ScrollBar verticalBar = scrolledComposite.getVerticalBar(); + if (verticalBar == null || verticalBar.isDisposed()) { + return; + } + + Point origin = scrolledComposite.getOrigin(); + int minimum = verticalBar.getMinimum(); + int maximum = Math.max(minimum, + verticalBar.getMaximum() - verticalBar.getThumb()); + int delta = -wheelCount * Math.max(1, verticalBar.getIncrement()); + int nextY = Math.max(minimum, Math.min(maximum, origin.y + delta)); + scrolledComposite.setOrigin(origin.x, nextY); + } + + private static boolean canScrollVertically(ScrollBar verticalBar, int wheelCount) { + if (verticalBar == null || verticalBar.isDisposed() + || !verticalBar.getEnabled()) { + return false; + } + + int minimum = verticalBar.getMinimum(); + int maximum = Math.max(minimum, + verticalBar.getMaximum() - verticalBar.getThumb()); + int selection = Math.max(minimum, + Math.min(maximum, verticalBar.getSelection())); + if (wheelCount > 0) { + return selection > minimum; + } + return selection < maximum; + } + + /** + * Resizes a table column to fill the table client area not occupied by the fixed-width columns. + */ + public static void resizeColumnToFillTable(Table table, TableColumn fillColumn, + int minWidth, TableColumn... fixedColumns) { + table.addControlListener(new ControlAdapter() { + @Override + public void controlResized(ControlEvent e) { + int remainingWidth = table.getClientArea().width; + for (TableColumn fixedColumn : fixedColumns) { + remainingWidth -= fixedColumn.getWidth(); + } + if (remainingWidth > minWidth) { + fillColumn.setWidth(remainingWidth); + } + } + }); + } + /** * Copy the given text to the clipboard. */ From 5b2397d3ad37d7a935b1b2cbeed101d4b408596b Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Tue, 2 Jun 2026 13:36:49 +0800 Subject: [PATCH 09/27] fix: File and directory link navigation in chat tool results (#272) --- .../eclipse/core/utils/FileUtilsTests.java | 39 +++++++++++++++ .../copilot/eclipse/core/utils/FileUtils.java | 47 +++++++++++++++++++ .../local-file-edit-and-create-tools.md | 27 +++++++++++ .../chat/FileAnnotationHyperlinkDetector.java | 28 ++++++++--- .../eclipse/ui/chat/tools/CreateFileTool.java | 2 +- .../eclipse/ui/chat/tools/EditFileTool.java | 2 +- .../eclipse/ui/chat/tools/FileToolBase.java | 22 +-------- 7 files changed, 138 insertions(+), 29 deletions(-) create mode 100644 com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/utils/FileUtilsTests.java diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/utils/FileUtilsTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/utils/FileUtilsTests.java new file mode 100644 index 00000000..cbbf6d55 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/utils/FileUtilsTests.java @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class FileUtilsTests { + + @Test + void testGetLocalFilePath_absolutePath_returnsNormalizedPath(@TempDir Path tempDir) { + Path expected = tempDir.resolve("external-file.txt").toAbsolutePath().normalize(); + + assertEquals(expected, FileUtils.getLocalFilePath(expected.toString())); + } + + @Test + void testGetLocalFilePath_fileUriWithFragment_ignoresFragment(@TempDir Path tempDir) { + Path expected = tempDir.resolve("external-file.txt").toAbsolutePath().normalize(); + + assertEquals(expected, FileUtils.getLocalFilePath(expected.toUri() + "#L10")); + } + + @Test + void testGetLocalFilePath_relativePath_returnsNull() { + assertNull(FileUtils.getLocalFilePath("src/main/java/File.java")); + } + + @Test + void testGetLocalFilePath_nonFileUri_returnsNull() { + assertNull(FileUtils.getLocalFilePath("https://example.com/file.java")); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java index 3dd51c39..06bbb611 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java @@ -181,6 +181,53 @@ public static IFile getFileFromUri(String fileUri) { return null; } + /** + * Resolves an absolute local filesystem path from a path or file URI. + * + * @param filePath the path or URI to resolve + * @return the local filesystem path, or null if the input is not an absolute local path + */ + @Nullable + public static Path getLocalFilePath(String filePath) { + if (StringUtils.isBlank(filePath)) { + return null; + } + + try { + // For file URIs, '#' is always a fragment delimiter (literal '#' in filenames is encoded as %23). + if (filePath.startsWith("file:")) { + String uriWithoutFragment = stripFragment(filePath); + return Paths.get(new URI(uriWithoutFragment)).toAbsolutePath().normalize(); + } + + String pathWithoutFragment = stripFragment(filePath); + if (URI_SCHEME_PATTERN.matcher(pathWithoutFragment).find() && !hasDriveLetter(pathWithoutFragment)) { + return null; + } + + // For raw paths, try the full string first since '#' is a valid filename character on Unix/Linux. + // Only fall back to stripping the fragment if the full path doesn't exist. + Path fullPath = Paths.get(filePath); + if (fullPath.isAbsolute()) { + if (Files.exists(fullPath)) { + return fullPath.toAbsolutePath().normalize(); + } + // Fall back: treat '#...' as a line-number fragment + Path strippedPath = Paths.get(pathWithoutFragment); + return strippedPath.isAbsolute() ? strippedPath.toAbsolutePath().normalize() : null; + } + return null; + } catch (IllegalArgumentException | URISyntaxException e) { + CopilotCore.LOGGER.error("Invalid local file path: " + filePath, e); + return null; + } + } + + private static String stripFragment(String pathOrUri) { + int fragmentIndex = pathOrUri.indexOf('#'); + return fragmentIndex > 0 ? pathOrUri.substring(0, fragmentIndex) : pathOrUri; + } + /** * Normalizes a file path or URI string to a proper file URI string. Handles Windows absolute paths, POSIX absolute * paths, and existing URI strings. Line number fragments (e.g., #L123) are preserved. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md index ae46796c..7f9a18cf 100644 --- a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md @@ -192,3 +192,30 @@ Not exercised: #### Key Screenshots - [ ] **Before created-file Undo** -- The summary bar lists `created-local-file.txt`. - [ ] **After created-file Undo** -- The summary bar is clear and the file is absent from disk. + +--- + +## 3. Navigate to local files from tool links + +### TC-006: Tool result links open local files outside the workspace + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in Agent mode. +- The local test directory outside the workspace exists and contains `existing-local-file.txt`. + +#### Steps +1. Send a prompt that causes Agent mode to reference or edit `existing-local-file.txt` by absolute path. +2. When the tool call appears in the Chat view, click the file path link for `existing-local-file.txt`. +3. Verify Eclipse opens `existing-local-file.txt` in an editor. + +#### Expected Result +- File links for paths outside the Eclipse workspace open the local file in an Eclipse editor. +- No error dialog is shown and the Eclipse error log has no local file navigation exception. + +#### Key Screenshots +- [ ] **Local file tool link** -- The tool result shows a clickable absolute path outside the workspace. +- [ ] **External local file editor** -- The external local file opens in an Eclipse editor. diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/FileAnnotationHyperlinkDetector.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/FileAnnotationHyperlinkDetector.java index eef5dc17..2a4b377b 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/FileAnnotationHyperlinkDetector.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/FileAnnotationHyperlinkDetector.java @@ -3,10 +3,13 @@ package com.microsoft.copilot.eclipse.ui.chat; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; + import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IResource; import org.eclipse.core.resources.ResourcesPlugin; -import org.eclipse.core.runtime.Path; import org.eclipse.jface.text.IRegion; import org.eclipse.jface.text.hyperlink.IHyperlink; import org.eclipse.jface.text.hyperlink.URLHyperlink; @@ -59,14 +62,27 @@ public void open() { String urlString = getURLString(); if (urlString.startsWith(LSPEclipseUtils.FILE_URI)) { IResource targetResource = LSPEclipseUtils.findResourceFor(urlString); - if (targetResource != null && targetResource.getType() == IResource.FILE) { - Location location = new Location(); - location.setUri(urlString); - LSPEclipseUtils.openInEditor(location); + if (targetResource != null) { + if (targetResource.getType() == IResource.FILE) { + Location location = new Location(); + location.setUri(urlString); + LSPEclipseUtils.openInEditor(location); + return; + } + if (targetResource.getType() == IResource.FOLDER + || targetResource.getType() == IResource.PROJECT) { + UiUtils.revealInExplorer(targetResource); + return; + } + } + Path localPath = FileUtils.getLocalFilePath(urlString); + if (localPath != null && Files.isRegularFile(localPath, LinkOption.NOFOLLOW_LINKS)) { + UiUtils.openLocalFileInEditor(localPath); return; } } else { - IFile file = ResourcesPlugin.getWorkspace().getRoot().getFile(new Path(urlString)); + IFile file = ResourcesPlugin.getWorkspace().getRoot() + .getFile(new org.eclipse.core.runtime.Path(urlString)); if (file.exists()) { var workbenchWindow = PlatformUI.getWorkbench().getActiveWorkbenchWindow(); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java index c7795aeb..ca9485dd 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java @@ -108,7 +108,7 @@ private LanguageModelToolResult createFile(String filePath, String content) { return createWorkspaceFile(file, filePath, content); } - Path localPath = getLocalFilePath(filePath); + Path localPath = FileUtils.getLocalFilePath(filePath); if (localPath != null) { return createLocalFile(localPath, content); } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java index 6a20fcd6..1260b0a1 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java @@ -146,7 +146,7 @@ private LanguageModelToolResult[] editFile(String filePath, String code) { return editWorkspaceFile(file, code); } - Path localPath = getLocalFilePath(filePath); + Path localPath = FileUtils.getLocalFilePath(filePath); if (localPath != null && Files.isRegularFile(localPath, LinkOption.NOFOLLOW_LINKS)) { return editLocalFile(localPath, code); } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java index 3ad526bf..8d545fd7 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java @@ -8,12 +8,9 @@ import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.lang.reflect.InvocationTargetException; -import java.net.URI; -import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; @@ -297,24 +294,7 @@ protected Path normalizeLocalPath(Path file) { return file.toAbsolutePath().normalize(); } - /** - * Resolves an absolute local filesystem path from a path or file URI. - * - * @param filePath the path or URI to resolve - * @return the local filesystem path, or null if the input is not an absolute local path - */ - protected Path getLocalFilePath(String filePath) { - try { - if (filePath.startsWith("file:")) { - return Paths.get(new URI(filePath)); - } - Path path = Paths.get(filePath); - return path.isAbsolute() ? path : null; - } catch (IllegalArgumentException | URISyntaxException e) { - CopilotCore.LOGGER.error("Invalid local file path: " + filePath, e); - return null; - } - } + private CompareEditorInput createWorkspaceCompareEditorInput(String comparedContent, IFile file) { ChangedFile changedFile = ChangedFile.workspace(file); From 36738902a895b038d507bff03e4fcbd24c079f88 Mon Sep 17 00:00:00 2001 From: jomillerOpen Date: Thu, 4 Jun 2026 01:08:41 -0400 Subject: [PATCH 10/27] [Feat] Have option to use tabs instead of spaces (forced override) (#267) --- .../completion/FormatOptionProviderTests.java | 31 ++++++++----- .../core/format/FormatOptionProvider.java | 45 +++++++++++++++++-- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/completion/FormatOptionProviderTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/completion/FormatOptionProviderTests.java index c87449dc..eeeb756d 100644 --- a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/completion/FormatOptionProviderTests.java +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/completion/FormatOptionProviderTests.java @@ -10,10 +10,15 @@ import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.preferences.IEclipsePreferences; +import org.eclipse.core.runtime.preferences.InstanceScope; import org.eclipse.lsp4j.FormattingOptions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.junit.jupiter.MockitoExtension; import com.microsoft.copilot.eclipse.core.format.FormatOptionProvider; @@ -25,7 +30,9 @@ class FormatOptionProviderTests { private IFile mockFile; private IProject mockProject; - private static final int PREFERENCE_DEFAULT_TAB_SIZE = 4; + private static final String EDITOR_PREF_NODE = "org.eclipse.ui.editors"; + private static final String TAB_WIDTH_KEY = "tabWidth"; + private static final String SPACES_FOR_TABS_KEY = "spacesForTabs"; @BeforeEach void setUp() { @@ -52,20 +59,20 @@ void testGetEclipseDefaultJavaTabCharAndSize() { assertEquals(tabSize, formatOptionProvider.getTabSize(mockFile)); } - @Test - void testGetCopilotDefaultTabCharAndSizeForUnknownLanguage() { - when(mockFile.getFileExtension()).thenReturn("js"); - - assertTrue(formatOptionProvider.useSpace(mockFile)); - assertEquals(PREFERENCE_DEFAULT_TAB_SIZE, formatOptionProvider.getTabSize(mockFile)); - } + @ParameterizedTest @NullSource @ValueSource(strings = { "js" }) + void testUsesEclipseTextEditorFormattingOptionsForUnknownOrNoExtension(String extension) { + when(mockFile.getFileExtension()).thenReturn(extension); - @Test - void testGetCopilotDefaultTabCharAndSizeForNoExtensionFile() { - when(mockFile.getFileExtension()).thenReturn(null); + // Set the Eclipse preferences to be something other than the default (false, 4) + setEditorFormattingPreferences(true, 2); assertTrue(formatOptionProvider.useSpace(mockFile)); - assertEquals(PREFERENCE_DEFAULT_TAB_SIZE, formatOptionProvider.getTabSize(mockFile)); + assertEquals(2, formatOptionProvider.getTabSize(mockFile)); } + private void setEditorFormattingPreferences(boolean useSpaces, int tabSize) { + IEclipsePreferences prefs = InstanceScope.INSTANCE.getNode(EDITOR_PREF_NODE); + prefs.putBoolean(SPACES_FOR_TABS_KEY, useSpaces); + prefs.putInt(TAB_WIDTH_KEY, tabSize); + } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/format/FormatOptionProvider.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/format/FormatOptionProvider.java index 8b06a164..b738929f 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/format/FormatOptionProvider.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/format/FormatOptionProvider.java @@ -9,6 +9,8 @@ import org.apache.commons.lang3.StringUtils; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.Platform; +import org.eclipse.core.runtime.preferences.IPreferencesService; import org.eclipse.lsp4j.FormattingOptions; import com.microsoft.copilot.eclipse.core.CopilotCore; @@ -26,6 +28,9 @@ public class FormatOptionProvider { private static final String CPP_LANGUAGE_ID = "cpp"; private static final String[] CPP_LANGUAGE_EXTENSIONS = new String[] { "cpp", "c++", "cc", "cp", "cxx", "h", "h++", "hh", ".hpp", ".hxx", ".inc", ".inl", ".ipp", ".tcc", ".tpp" }; + private static final String EDITOR_PREF_NODE = "org.eclipse.ui.editors"; + private static final String TAB_WIDTH_KEY = "tabWidth"; + private static final String SPACES_FOR_TABS_KEY = "spacesForTabs"; private static final boolean DEFAULT_USE_SPACE = LanguageFormatReader.PREFERENCE_DEFAULT_TAB_CHAR.equals("space"); private static final int DEFAULT_TAB_SIZE = LanguageFormatReader.PREFERENCE_DEFAULT_TAB_SIZE; @@ -85,13 +90,13 @@ private FormattingOptions getLanguageFormat(IFile file) { IProject project = file.getProject(); if (project == null) { CopilotCore.LOGGER.info("Project is null for file: " + file.getName() + "default format will be applied."); - return null; + return getEclipseTextEditorFormattingOptions(); } String fileExtension = file.getFileExtension(); if (StringUtils.isEmpty(fileExtension)) { CopilotCore.LOGGER.info("File extension is null or empty for file: " + file.getName()); - return null; + return getEclipseTextEditorFormattingOptions(); } else { fileExtension = fileExtension.toLowerCase(); } @@ -110,7 +115,7 @@ private FormattingOptions getLanguageFormat(IFile file) { if (languageFormatReaderForProject == null) { languageFormatReaderForProject = loadFormatReaderForTheProject(languageId, project); if (languageFormatReaderForProject == null) { - return new FormattingOptions(DEFAULT_TAB_SIZE, DEFAULT_USE_SPACE); + return getEclipseTextEditorFormattingOptions(); } projectToLanguageFormatReaderMap.put(project, languageFormatReaderForProject); } @@ -118,6 +123,38 @@ private FormattingOptions getLanguageFormat(IFile file) { return languageFormatReaderForProject.getFormattingOptions(); } + /** + * Attempts to read the Eclipse default text editor preferences for tab size and spaces-for-tabs. + * Falls back to hardcoded defaults if the preferences cannot be read. + */ + private FormattingOptions getEclipseTextEditorFormattingOptions() { + try { + IPreferencesService service = Platform.getPreferencesService(); + + boolean useSpaces = service.getBoolean( + EDITOR_PREF_NODE, + SPACES_FOR_TABS_KEY, + DEFAULT_USE_SPACE, + null + ); + + int tabSize = service.getInt( + EDITOR_PREF_NODE, + TAB_WIDTH_KEY, + DEFAULT_TAB_SIZE, + null + ); + + return new FormattingOptions(tabSize, useSpaces); + } catch (Exception e) { + CopilotCore.LOGGER.info( + "Failed to read Eclipse text editor preferences, using defaults"); + return new FormattingOptions(DEFAULT_TAB_SIZE, DEFAULT_USE_SPACE); + } + } + + + private LanguageFormatReader loadFormatReaderForTheProject(String languageId, IProject project) { switch (languageId) { case JAVA_LANGUAGE_ID: @@ -129,4 +166,4 @@ private LanguageFormatReader loadFormatReaderForTheProject(String languageId, IP return null; } } -} \ No newline at end of file +} From fc585f44685a5c6118a34ef3d90ff3f3c9d4df84 Mon Sep 17 00:00:00 2001 From: Sheng Chen Date: Tue, 16 Jun 2026 12:50:55 +0800 Subject: [PATCH 11/27] build: Prepare for 0.19.0 (#291) * build: Prepare for 0.19.0 * Update what is new * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 20 ++++++ .../META-INF/MANIFEST.MF | 2 +- .../META-INF/MANIFEST.MF | 2 +- .../META-INF/MANIFEST.MF | 2 +- .../META-INF/MANIFEST.MF | 2 +- .../META-INF/MANIFEST.MF | 2 +- .../META-INF/MANIFEST.MF | 2 +- .../META-INF/MANIFEST.MF | 4 +- .../META-INF/MANIFEST.MF | 2 +- .../feature.xml | 2 +- .../category.xml | 2 +- .../META-INF/MANIFEST.MF | 2 +- .../META-INF/MANIFEST.MF | 4 +- .../META-INF/MANIFEST.MF | 6 +- .../META-INF/MANIFEST.MF | 4 +- .../META-INF/MANIFEST.MF | 4 +- .../META-INF/MANIFEST.MF | 8 +-- .../META-INF/MANIFEST.MF | 6 +- .../intro/whatsnew/0.16.0/new_chat_input.png | Bin 5128 -> 0 bytes .../intro/whatsnew/0.16.0/new_selector.png | Bin 42563 -> 0 bytes .../whatsnew/0.16.0/syntax_highlighting.png | Bin 32819 -> 0 bytes .../0.16.0/tool_calling_in_ask_mode.png | Bin 54440 -> 0 bytes .../intro/whatsnew/0.19.0/auto-approve.png | Bin 0 -> 379818 bytes .../intro/whatsnew/WHATISNEW.md | 64 +++++++++--------- pom.xml | 2 +- 25 files changed, 81 insertions(+), 61 deletions(-) delete mode 100644 com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/new_chat_input.png delete mode 100644 com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/new_selector.png delete mode 100644 com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/syntax_highlighting.png delete mode 100644 com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/tool_calling_in_ask_mode.png create mode 100644 com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.19.0/auto-approve.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a08dc11..e58cf111 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.19.0 +### Added +- Improve terminal command execution across Windows and Linux. [PR#247](https://github.com/microsoft/copilot-for-eclipse/pull/247) +- Support editing/creating local files outside the workspace. [PR#248](https://github.com/microsoft/copilot-for-eclipse/pull/248) +- Support automatic chat context compression. [PR#250](https://github.com/microsoft/copilot-for-eclipse/pull/250) +- Support tool auto-approve controls. [PR#255](https://github.com/microsoft/copilot-for-eclipse/pull/255) + +### Fixed +- Copilot reports failure when extension-point contributed MCP server has changed. [#153](https://github.com/microsoft/copilot-for-eclipse/issues/153) +- Subagent panel size is not updated if conversation is canceled. [#169](https://github.com/microsoft/copilot-for-eclipse/issues/169), contributed by [@rsd-darshan](https://github.com/rsd-darshan) +- Thinking effort descriptions are truncated in the model picker hover card. [#233](https://github.com/microsoft/copilot-for-eclipse/issues/233) +- Canceled terminal tool calling status will become ongoing again after chat history restoration. [#239](https://github.com/microsoft/copilot-for-eclipse/issues/239) +- Cannot navigate to the file out of workspace. [#262](https://github.com/microsoft/copilot-for-eclipse/issues/262) +- Read the default text editor preferences from platform instead of defaulting to spaces. [PR#267](https://github.com/microsoft/copilot-for-eclipse/pull/267), contributed by [@jomillerOpen](https://github.com/jomillerOpen) + +### Engineering +- Remove unused message keys and properties across various modules. [PR#208](https://github.com/microsoft/copilot-for-eclipse/pull/208) +- Add endgame skill. [PR#230](https://github.com/microsoft/copilot-for-eclipse/pull/230) +- Fix typo in README upgrade recommendation. [PR#251](https://github.com/microsoft/copilot-for-eclipse/pull/251), contributed by [@evanclan](https://github.com/evanclan) + ## 0.18.0 ### Added - ℹ️ Prepare for the [upcoming usage-based billing](https://github.blog/news-insights/company-news/github-copilot-is-moving-to-usage-based-billing/). We strongly recommend upgrading to this version as soon as possible. [#203](https://github.com/microsoft/copilot-for-eclipse/issues/203) diff --git a/com.microsoft.copilot.eclipse.branding/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.branding/META-INF/MANIFEST.MF index 16926659..f4eb15ed 100644 --- a/com.microsoft.copilot.eclipse.branding/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.branding/META-INF/MANIFEST.MF @@ -2,6 +2,6 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: GitHub Copilot Bundle-SymbolicName: com.microsoft.copilot.eclipse.branding;singleton:=true -Bundle-Version: 0.18.0.qualifier +Bundle-Version: 0.19.0.qualifier Bundle-Vendor: GitHub Copilot Automatic-Module-Name: com.microsoft.copilot.eclipse.branding diff --git a/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/META-INF/MANIFEST.MF index 919f4b2b..a9bdb552 100644 --- a/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/META-INF/MANIFEST.MF @@ -3,7 +3,7 @@ Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core.agent.linux.aarch64 Bundle-SymbolicName: com.microsoft.copilot.eclipse.core.agent.linux.aarch64 Automatic-Module-Name: com.microsoft.copilot.eclipse.core.agent.linux.aarch64 -Bundle-Version: 0.18.0.qualifier +Bundle-Version: 0.19.0.qualifier Bundle-Vendor: GitHub Copilot Fragment-Host: com.microsoft.copilot.eclipse.core Bundle-RequiredExecutionEnvironment: JavaSE-17 diff --git a/com.microsoft.copilot.eclipse.core.agent.linux.x64/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core.agent.linux.x64/META-INF/MANIFEST.MF index d0590971..3de71350 100644 --- a/com.microsoft.copilot.eclipse.core.agent.linux.x64/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core.agent.linux.x64/META-INF/MANIFEST.MF @@ -3,7 +3,7 @@ Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core.agent.linux.x64 Bundle-SymbolicName: com.microsoft.copilot.eclipse.core.agent.linux.x64 Automatic-Module-Name: com.microsoft.copilot.eclipse.core.agent.linux.x64 -Bundle-Version: 0.18.0.qualifier +Bundle-Version: 0.19.0.qualifier Bundle-Vendor: GitHub Copilot Fragment-Host: com.microsoft.copilot.eclipse.core Bundle-RequiredExecutionEnvironment: JavaSE-17 diff --git a/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/META-INF/MANIFEST.MF index 14320936..b340fbec 100644 --- a/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/META-INF/MANIFEST.MF @@ -3,7 +3,7 @@ Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core.agent.macosx.aarch64 Bundle-SymbolicName: com.microsoft.copilot.eclipse.core.agent.macosx.aarch64 Automatic-Module-Name: com.microsoft.copilot.eclipse.core.agent.macosx.aarch64 -Bundle-Version: 0.18.0.qualifier +Bundle-Version: 0.19.0.qualifier Bundle-Vendor: GitHub Copilot Fragment-Host: com.microsoft.copilot.eclipse.core Bundle-RequiredExecutionEnvironment: JavaSE-17 diff --git a/com.microsoft.copilot.eclipse.core.agent.macosx.x64/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core.agent.macosx.x64/META-INF/MANIFEST.MF index 1b554a64..4674eab8 100644 --- a/com.microsoft.copilot.eclipse.core.agent.macosx.x64/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core.agent.macosx.x64/META-INF/MANIFEST.MF @@ -3,7 +3,7 @@ Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core.agent.macosx.x64 Bundle-SymbolicName: com.microsoft.copilot.eclipse.core.agent.macosx.x64 Automatic-Module-Name: com.microsoft.copilot.eclipse.core.agent.macosx.x64 -Bundle-Version: 0.18.0.qualifier +Bundle-Version: 0.19.0.qualifier Bundle-Vendor: GitHub Copilot Fragment-Host: com.microsoft.copilot.eclipse.core Bundle-RequiredExecutionEnvironment: JavaSE-17 diff --git a/com.microsoft.copilot.eclipse.core.agent.win32/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core.agent.win32/META-INF/MANIFEST.MF index 69cc83a5..4bffe01f 100644 --- a/com.microsoft.copilot.eclipse.core.agent.win32/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core.agent.win32/META-INF/MANIFEST.MF @@ -3,7 +3,7 @@ Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core.agent.win32 Bundle-SymbolicName: com.microsoft.copilot.eclipse.core.agent.win32 Automatic-Module-Name: com.microsoft.copilot.eclipse.core.agent.win32 -Bundle-Version: 0.18.0.qualifier +Bundle-Version: 0.19.0.qualifier Bundle-Vendor: GitHub Copilot Fragment-Host: com.microsoft.copilot.eclipse.core Bundle-RequiredExecutionEnvironment: JavaSE-17 diff --git a/com.microsoft.copilot.eclipse.core.test/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core.test/META-INF/MANIFEST.MF index 583fef61..f42e08b2 100644 --- a/com.microsoft.copilot.eclipse.core.test/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core.test/META-INF/MANIFEST.MF @@ -2,14 +2,14 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core.test Bundle-SymbolicName: com.microsoft.copilot.eclipse.core.test;singleton:=true -Bundle-Version: 0.18.0.qualifier +Bundle-Version: 0.19.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-RequiredExecutionEnvironment: JavaSE-17 Fragment-Host: com.microsoft.copilot.eclipse.core Automatic-Module-Name: com.microsoft.copilot.eclipse.core.test Import-Package: org.objenesis;version="[3.4.0,4.0.0)", org.osgi.framework;version="[1.10.0,2.0.0)" -Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.15.0", +Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.19.0", org.mockito.mockito-core;bundle-version="5.14.2", org.eclipse.lsp4e;bundle-version="0.18.1", org.eclipse.jdt.annotation;resolution:=optional, diff --git a/com.microsoft.copilot.eclipse.core/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core/META-INF/MANIFEST.MF index f5a193f2..024935d8 100644 --- a/com.microsoft.copilot.eclipse.core/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core Bundle-SymbolicName: com.microsoft.copilot.eclipse.core;singleton:=true -Bundle-Version: 0.18.0.qualifier +Bundle-Version: 0.19.0.qualifier Bundle-Vendor: GitHub Copilot Export-Package: com.microsoft.copilot.eclipse.core, com.microsoft.copilot.eclipse.core.chat, diff --git a/com.microsoft.copilot.eclipse.feature/feature.xml b/com.microsoft.copilot.eclipse.feature/feature.xml index a9f0b117..2418d3ba 100644 --- a/com.microsoft.copilot.eclipse.feature/feature.xml +++ b/com.microsoft.copilot.eclipse.feature/feature.xml @@ -2,7 +2,7 @@ diff --git a/com.microsoft.copilot.eclipse.repository/category.xml b/com.microsoft.copilot.eclipse.repository/category.xml index b9b1f538..628567ee 100644 --- a/com.microsoft.copilot.eclipse.repository/category.xml +++ b/com.microsoft.copilot.eclipse.repository/category.xml @@ -1,6 +1,6 @@ - + diff --git a/com.microsoft.copilot.eclipse.swtbot.test/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.swtbot.test/META-INF/MANIFEST.MF index 9ccba384..ee703343 100644 --- a/com.microsoft.copilot.eclipse.swtbot.test/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.swtbot.test/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.swtbot.test Bundle-SymbolicName: com.microsoft.copilot.eclipse.swtbot.test;singleton:=true -Bundle-Version: 0.18.0.qualifier +Bundle-Version: 0.19.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-RequiredExecutionEnvironment: JavaSE-17 Automatic-Module-Name: com.microsoft.copilot.eclipse.swtbot.test diff --git a/com.microsoft.copilot.eclipse.terminal.api/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.terminal.api/META-INF/MANIFEST.MF index 07300c74..ffca534c 100644 --- a/com.microsoft.copilot.eclipse.terminal.api/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.terminal.api/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.terminal.api Bundle-SymbolicName: com.microsoft.copilot.eclipse.terminal.api;singleton:=true -Bundle-Version: 0.18.0.qualifier +Bundle-Version: 0.19.0.qualifier Bundle-Vendor: GitHub Copilot Export-Package: com.microsoft.copilot.eclipse.terminal.api Automatic-Module-Name: com.microsoft.copilot.eclipse.terminal.api @@ -10,7 +10,7 @@ Bundle-ActivationPolicy: lazy Bundle-RequiredExecutionEnvironment: JavaSE-17 Import-Package: org.osgi.framework;version="[1.10.0,2.0.0)" Require-Bundle: org.eclipse.core.runtime;bundle-version="[3.30.0,4.0.0)", - com.microsoft.copilot.eclipse.core;bundle-version="0.15.0", + com.microsoft.copilot.eclipse.core;bundle-version="0.19.0", org.eclipse.jface, org.eclipse.swt, org.eclipse.ui.workbench, diff --git a/com.microsoft.copilot.eclipse.ui.jobs/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui.jobs/META-INF/MANIFEST.MF index c9af90e7..eed9986d 100644 --- a/com.microsoft.copilot.eclipse.ui.jobs/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui.jobs/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: GitHub Copilot Jobs Bundle-SymbolicName: com.microsoft.copilot.eclipse.ui.jobs;singleton:=true -Bundle-Version: 0.18.0.qualifier +Bundle-Version: 0.19.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-Activator: com.microsoft.copilot.eclipse.ui.jobs.CopilotJobs Bundle-RequiredExecutionEnvironment: JavaSE-17 @@ -10,8 +10,8 @@ Bundle-Localization: plugin Automatic-Module-Name: com.microsoft.copilot.eclipse.ui.jobs Bundle-ActivationPolicy: lazy Import-Package: org.osgi.framework;version="[1.10.0,2.0.0)" -Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.15.0", - com.microsoft.copilot.eclipse.ui;bundle-version="0.15.0", +Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.19.0", + com.microsoft.copilot.eclipse.ui;bundle-version="0.19.0", jakarta.annotation-api, jakarta.inject.jakarta.inject-api, org.apache.commons.lang3;bundle-version="3.13.0", diff --git a/com.microsoft.copilot.eclipse.ui.terminal.tm/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui.terminal.tm/META-INF/MANIFEST.MF index 76d94d69..71145643 100644 --- a/com.microsoft.copilot.eclipse.ui.terminal.tm/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui.terminal.tm/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.ui.terminal.tm Bundle-SymbolicName: com.microsoft.copilot.eclipse.ui.terminal.tm;singleton:=true -Bundle-Version: 0.18.0.qualifier +Bundle-Version: 0.19.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-RequiredExecutionEnvironment: JavaSE-17 Automatic-Module-Name: com.microsoft.copilot.eclipse.ui.terminal.tm @@ -10,7 +10,7 @@ Bundle-ActivationPolicy: lazy Service-Component: OSGI-INF/component.xml Import-Package: org.osgi.framework;version="[1.10.0,2.0.0)", org.osgi.service.component -Require-Bundle: com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.15.0", +Require-Bundle: com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.19.0", org.eclipse.tm.terminal.view.core, org.eclipse.tm.terminal.view.ui, org.eclipse.tm.terminal.control, diff --git a/com.microsoft.copilot.eclipse.ui.terminal/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui.terminal/META-INF/MANIFEST.MF index bca04bbc..b961e910 100644 --- a/com.microsoft.copilot.eclipse.ui.terminal/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui.terminal/META-INF/MANIFEST.MF @@ -1,7 +1,7 @@ Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.ui.terminal Bundle-SymbolicName: com.microsoft.copilot.eclipse.ui.terminal;singleton:=true -Bundle-Version: 0.18.0.qualifier +Bundle-Version: 0.19.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-RequiredExecutionEnvironment: JavaSE-17 Automatic-Module-Name: com.microsoft.copilot.eclipse.ui.terminal @@ -9,7 +9,7 @@ Bundle-ActivationPolicy: lazy Service-Component: OSGI-INF/component.xml Import-Package: org.osgi.framework;version="[1.10.0,2.0.0)", org.osgi.service.component -Require-Bundle: com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.15.0", +Require-Bundle: com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.19.0", org.eclipse.terminal.view.core, org.eclipse.terminal.view.ui, org.eclipse.terminal.control, diff --git a/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF index 763af993..6d01be66 100644 --- a/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.ui.test Bundle-SymbolicName: com.microsoft.copilot.eclipse.ui.test;singleton:=true -Bundle-Version: 0.18.0.qualifier +Bundle-Version: 0.19.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-RequiredExecutionEnvironment: JavaSE-17 Automatic-Module-Name: com.microsoft.copilot.eclipse.ui.test @@ -14,8 +14,8 @@ Require-Bundle: org.mockito.mockito-core;bundle-version="5.14.2", junit-jupiter-api;bundle-version="5.10.1", junit-jupiter-params;bundle-version="5.10.1", org.mockito.junit-jupiter;bundle-version="5.10.2", - com.microsoft.copilot.eclipse.core;bundle-version="0.15.0", - com.microsoft.copilot.eclipse.ui;bundle-version="0.15.0", + com.microsoft.copilot.eclipse.core;bundle-version="0.19.0", + com.microsoft.copilot.eclipse.ui;bundle-version="0.19.0", org.eclipse.ui;bundle-version="3.205.0", org.eclipse.ui.ide, org.eclipse.ui.workbench.texteditor, @@ -29,4 +29,4 @@ Require-Bundle: org.mockito.mockito-core;bundle-version="5.14.2", org.eclipse.e4.core.services;bundle-version="2.4.200", org.osgi.service.event, com.google.gson, - com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.15.0" + com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.19.0" diff --git a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF index 066f4400..ca0e9872 100644 --- a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.ui Bundle-SymbolicName: com.microsoft.copilot.eclipse.ui;singleton:=true -Bundle-Version: 0.18.0.qualifier +Bundle-Version: 0.19.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-Localization: plugin Export-Package: com.microsoft.copilot.eclipse.ui, @@ -25,8 +25,8 @@ Bundle-RequiredExecutionEnvironment: JavaSE-17 Automatic-Module-Name: com.microsoft.copilot.eclipse.ui Bundle-ActivationPolicy: lazy Import-Package: org.osgi.framework;version="[1.10.0,2.0.0)" -Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.15.0", - com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.15.0", +Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.19.0", + com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.19.0", org.eclipse.ui.ide;bundle-version="3.22.0", org.eclipse.ui.workbench.texteditor;bundle-version="3.17.200", org.eclipse.ui.editors;bundle-version="3.17.100", diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/new_chat_input.png b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/new_chat_input.png deleted file mode 100644 index 642b240cfec0f863a6309e369a5066702451e130..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5128 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 diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/new_selector.png b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/new_selector.png deleted file mode 100644 index f7ae997c521e761d2b8acd6fd3041ce05038f02f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 42563 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 diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/syntax_highlighting.png b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/syntax_highlighting.png deleted file mode 100644 index 71fc1c7852b328e537c3efbd5e20089f3a2a9631..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32819 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 T=pH2k@J~_ptxTzualropD$^r$ diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.19.0/auto-approve.png b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.19.0/auto-approve.png new file mode 100644 index 0000000000000000000000000000000000000000..f9f98e2275f751c02f0e0bbffeb1eb83e551142e GIT binary patch literal 379818 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�e$gxK~AToP5S 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 lI0~%EJngbgV0Hjd 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.name} - 0.18.0-SNAPSHOT + 0.19.0-SNAPSHOT GitHub Copilot 4.0.13 3.6.0 From bbffdcd0ffe1702734c35e244f7b1f34afa99159 Mon Sep 17 00:00:00 2001 From: Sheng Chen Date: Thu, 18 Jun 2026 14:47:45 +0800 Subject: [PATCH 12/27] fix: update @github/copilot-language-server dependencies to version 1.502.5 (#296) --- .../copilot-agent/package-lock.json | 66 +++++++++---------- .../copilot-agent/package.json | 12 ++-- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/com.microsoft.copilot.eclipse.core/copilot-agent/package-lock.json b/com.microsoft.copilot.eclipse.core/copilot-agent/package-lock.json index 07efeace..f389abb6 100644 --- a/com.microsoft.copilot.eclipse.core/copilot-agent/package-lock.json +++ b/com.microsoft.copilot.eclipse.core/copilot-agent/package-lock.json @@ -9,18 +9,18 @@ "version": "0.0.1", "hasInstallScript": true, "dependencies": { - "@github/copilot-language-server": "1.488.0", - "@github/copilot-language-server-darwin-arm64": "1.488.0", - "@github/copilot-language-server-darwin-x64": "1.488.0", - "@github/copilot-language-server-linux-arm64": "1.488.0", - "@github/copilot-language-server-linux-x64": "1.488.0", - "@github/copilot-language-server-win32-x64": "1.488.0" + "@github/copilot-language-server": "1.502.5", + "@github/copilot-language-server-darwin-arm64": "1.502.5", + "@github/copilot-language-server-darwin-x64": "1.502.5", + "@github/copilot-language-server-linux-arm64": "1.502.5", + "@github/copilot-language-server-linux-x64": "1.502.5", + "@github/copilot-language-server-win32-x64": "1.502.5" } }, "node_modules/@github/copilot-language-server": { - "version": "1.488.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.488.0.tgz", - "integrity": "sha512-zCyItvYtqrtQzpdAv6nTjph4Nfws5xTMNAw7cn2gIvBEBHT5NbnaceSwxJm2I96Ll2Jrq4uQG+wifYVMHjQDwg==", + "version": "1.502.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.502.5.tgz", + "integrity": "sha512-vIFbb145TwhDD1KTdJjySAokBcJla6NWsfvqjotM2AsDu3lLtQjPFbt6P90vnMVbQ6Ixc/IbDqsjToaWmXqqIA==", "license": "MIT", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" @@ -29,18 +29,18 @@ "copilot-language-server": "dist/language-server.js" }, "optionalDependencies": { - "@github/copilot-language-server-darwin-arm64": "1.488.0", - "@github/copilot-language-server-darwin-x64": "1.488.0", - "@github/copilot-language-server-linux-arm64": "1.488.0", - "@github/copilot-language-server-linux-x64": "1.488.0", - "@github/copilot-language-server-win32-arm64": "1.488.0", - "@github/copilot-language-server-win32-x64": "1.488.0" + "@github/copilot-language-server-darwin-arm64": "1.502.5", + "@github/copilot-language-server-darwin-x64": "1.502.5", + "@github/copilot-language-server-linux-arm64": "1.502.5", + "@github/copilot-language-server-linux-x64": "1.502.5", + "@github/copilot-language-server-win32-arm64": "1.502.5", + "@github/copilot-language-server-win32-x64": "1.502.5" } }, "node_modules/@github/copilot-language-server-darwin-arm64": { - "version": "1.488.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.488.0.tgz", - "integrity": "sha512-X4jqAKJwNJIM2Ua22UZvPM6/3x7ZiX4lgedvqWadoFA9SJmxF6OlQoe2L6coeC1U8RWj5WEyTqIDkyJkhsF24Q==", + "version": "1.502.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.502.5.tgz", + "integrity": "sha512-3QTwdHjX2qpOwRhKIcXj3PU+wr5FodMcHPRnXK2qMv4xiRtibuuqicFVPF7ckO7Xym2o2FfJHfS8e58NNl278w==", "cpu": [ "arm64" ], @@ -50,9 +50,9 @@ ] }, "node_modules/@github/copilot-language-server-darwin-x64": { - "version": "1.488.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.488.0.tgz", - "integrity": "sha512-JHmdBwxwfZfxWkJLNp4M2bydrkD/TsxPoPeojcIOscrHhTj2riWSe7zqUn61IAdtwG0z2HwK1tGS35KOJhW8+A==", + "version": "1.502.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.502.5.tgz", + "integrity": "sha512-qz3aGl2KtAA/pM65esyXrsv4wBDFoKwUrP4hBAm2XplIPVF931HOluCXGxL0GDU2OMsEAxgFy5xTGODMg1U7kQ==", "cpu": [ "x64" ], @@ -62,9 +62,9 @@ ] }, "node_modules/@github/copilot-language-server-linux-arm64": { - "version": "1.488.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.488.0.tgz", - "integrity": "sha512-t+MZuQl76nMAs0qzAZDb9hr3SC53cW87CmDI+lgfLfXay8cdunNirjI/IloRnXybYYKFCkjvqDl9cYcLdCA1/g==", + "version": "1.502.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.502.5.tgz", + "integrity": "sha512-8xabGJgGzpa4KVDQc1ZYH3elTzyzVRUWJLlBBQbK4vSpNtsnmGOk4TkY7uJZ7uXtaX1/xDD1venbPWWNOABCig==", "cpu": [ "arm64" ], @@ -74,9 +74,9 @@ ] }, "node_modules/@github/copilot-language-server-linux-x64": { - "version": "1.488.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.488.0.tgz", - "integrity": "sha512-hhJ2ZVweqLCk0lptMXyHlXPrkSiHypcpza5gVUwMGMuw7Z87o3SkK8fD5jDpBvKJ0gJTGjPZ2yXzGtErpY20Sw==", + "version": "1.502.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.502.5.tgz", + "integrity": "sha512-7JcmuKj4yOoDjU3kx4JlVPzC2jDES6nFFqUoNAEnKPFPUjIwf4RJKI2heonIUtFnFXZ9JWT09vqe477oO0F80w==", "cpu": [ "x64" ], @@ -86,9 +86,9 @@ ] }, "node_modules/@github/copilot-language-server-win32-arm64": { - "version": "1.488.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-arm64/-/copilot-language-server-win32-arm64-1.488.0.tgz", - "integrity": "sha512-ke8czQwJcOtZpD9CnyBE7nVrFU9oa8nrLMp8xWoxKybs77Rpt36v2CGw535QHrih9f3Hp4dQtFlaRdvjnL1vZg==", + "version": "1.502.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-arm64/-/copilot-language-server-win32-arm64-1.502.5.tgz", + "integrity": "sha512-7/QHmdWSIOKvbr5s5tH1u8/nQ+AfvqqGbPlpZeokv/hTVwCZZ69ntMVGkoSQVKcSAYVL2h97M5+0zNbofYitow==", "cpu": [ "arm64" ], @@ -99,9 +99,9 @@ ] }, "node_modules/@github/copilot-language-server-win32-x64": { - "version": "1.488.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.488.0.tgz", - "integrity": "sha512-TOxA86DcpO7VYfsePj5PmbzqKaTwGXse6OBdMAPKPBBBCiusEI1kmYO3O/ywi8n5cCF1X9LvrqbunNRzokxRug==", + "version": "1.502.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.502.5.tgz", + "integrity": "sha512-9jdvyPZnu2muS9Jo6LtmP33NkpTht/2Gc9pWRrkQizuNabWy7jq2KuJdYf1/vwiyEioMiZznGBv+hscrJ9z5LA==", "cpu": [ "x64" ], diff --git a/com.microsoft.copilot.eclipse.core/copilot-agent/package.json b/com.microsoft.copilot.eclipse.core/copilot-agent/package.json index 06f8e77a..7702b184 100644 --- a/com.microsoft.copilot.eclipse.core/copilot-agent/package.json +++ b/com.microsoft.copilot.eclipse.core/copilot-agent/package.json @@ -12,11 +12,11 @@ "postinstall": "node copy-binaries.js" }, "dependencies": { - "@github/copilot-language-server": "1.488.0", - "@github/copilot-language-server-win32-x64": "1.488.0", - "@github/copilot-language-server-darwin-x64": "1.488.0", - "@github/copilot-language-server-darwin-arm64": "1.488.0", - "@github/copilot-language-server-linux-x64": "1.488.0", - "@github/copilot-language-server-linux-arm64": "1.488.0" + "@github/copilot-language-server": "1.502.5", + "@github/copilot-language-server-win32-x64": "1.502.5", + "@github/copilot-language-server-darwin-x64": "1.502.5", + "@github/copilot-language-server-darwin-arm64": "1.502.5", + "@github/copilot-language-server-linux-x64": "1.502.5", + "@github/copilot-language-server-linux-arm64": "1.502.5" } } From 1c740ce2c29d0d9e866be8f136bf96aa335e4dfd Mon Sep 17 00:00:00 2001 From: Sheng Chen Date: Mon, 22 Jun 2026 10:00:19 +0800 Subject: [PATCH 13/27] fix: keep Preferences dialog stable on Tool Auto Approve page (#298) The Tool Auto Approve page stacked four rule sections (~850px) in an uncapped container, so JFace's PreferenceDialog grew the shell toward screen height and never shrank it. Wrap the sections in a height-capped ScrolledComposite whose computeSize bounds the height reported to the dialog's PageLayout, so the shell stays a normal size and the content scrolls within a single page-level scrollbar. Unify the cap as PreferencePageUtils.STANDARD_CONTENT_HEIGHT (520), shared with McpPreferencePage's two stacked groups, so Copilot preference pages settle at a consistent size. Add a generic preference-pages test plan with a dialog-size regression case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../preference-pages/preference-pages.md | 81 +++++++++++++++++++ .../AutoApprovePreferencePage.java | 34 +++++++- .../ui/preferences/McpPreferencePage.java | 2 +- .../ui/preferences/PreferencePageUtils.java | 9 +++ 4 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 com.microsoft.copilot.eclipse.swtbot.test/test-plans/preference-pages/preference-pages.md diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/preference-pages/preference-pages.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/preference-pages/preference-pages.md new file mode 100644 index 00000000..08555695 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/preference-pages/preference-pages.md @@ -0,0 +1,81 @@ +# GitHub Copilot Preference Pages + +## Overview +Covers behavior shared by **all** GitHub Copilot preference pages +(**Preferences → GitHub Copilot → …**), independent of any single feature. +Every Copilot page is hosted in the same JFace `PreferenceDialog`, so concerns +like dialog sizing, layout stability, and navigation are cross-cutting. + +JFace grows the shared dialog to the **tallest** page visited and never shrinks +it again. To keep the dialog a stable size, each Copilot page keeps its content +within a common target height — `PreferencePageUtils.STANDARD_CONTENT_HEIGHT`. +Pages whose natural content is taller (e.g. **Tool Auto Approve**, which stacks +four rule sections) scroll within a height-capped `ScrolledComposite` instead of +ballooning the dialog. The same constant drives `McpPreferencePage`'s two groups, +so the pages settle at a consistent size. + +Entry points exercised: +- **Preferences → GitHub Copilot** — the root category page (only hyperlinks; no + sign-in, always valid). A stable baseline for size comparisons. +- **Preferences → GitHub Copilot → Tool Auto Approve** — the tallest page; the + regression magnet for dialog ballooning. +- Other child pages (MCP, Completions, Chat, …) — for navigation stability. + +--- + +## Prerequisites +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and activated. +- No sign-in required: the pages below render independently of language-server + data. + +--- + +## 1. Dialog size stability + +### TC-001: Opening a tall page does not balloon the Preferences dialog + +**Type:** `Regression` +**Priority:** `P1` + +#### Steps +1. Open **Window → Preferences** and select **GitHub Copilot** (root page). Note + the dialog's size. +2. In the tree, select **GitHub Copilot → Tool Auto Approve**. +3. Observe the dialog does **not** grow toward screen height — it stays close to + the root page's size. +4. Confirm all four sections (Terminal, File Operation, MCP, Global) are + reachable by scrolling **within the page** using a single page-level vertical + scrollbar. +5. Navigate to a couple of sibling pages (e.g. **MCP**, **Completions**) and back + to Tool Auto Approve; the dialog size stays stable throughout. +6. Close Preferences. + +#### Expected Result +- The Preferences dialog stays a normal, stable size as the selection moves + between Copilot pages; Tool Auto Approve does not balloon toward screen height. +- Tool Auto Approve content is reachable via the page's own single vertical + scrollbar; no second (dialog-level) scrollbar appears. + +#### Reference measurements (Eclipse 2025-03, macOS) +- Root **GitHub Copilot** page dialog height: **~551px**. +- **Tool Auto Approve** dialog height (fixed by the height cap): **~656px** — + deterministic and screen-independent. +- Pre-fix, Tool Auto Approve ballooned to ~screen height (≈986px or + screen-clamped). + +#### 📸 Key Screenshots +- [ ] Dialog on the root **GitHub Copilot** page. +- [ ] Dialog on **Tool Auto Approve** — only slightly taller, content scrolling + within the page (single page scrollbar). + +--- + +## Notes +- Page height is **data-independent** (table/tree height hints are fixed), so MCP + servers loading asynchronously does not change the page height. +- The height cap is unified via `PreferencePageUtils.STANDARD_CONTENT_HEIGHT`, + shared by `AutoApprovePreferencePage` (scroller cap) and `McpPreferencePage` + (two stacked groups). Keep the pages within this height when adding content. +- Use the root **GitHub Copilot** page as the size baseline — it is always valid + and needs no sign-in, whereas the MCP page can enter an invalid state in an + unauthenticated sandbox and pop a JFace validation dialog. diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AutoApprovePreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AutoApprovePreferencePage.java index b4b8b3f9..fb0be2c0 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AutoApprovePreferencePage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AutoApprovePreferencePage.java @@ -6,6 +6,8 @@ import org.eclipse.jface.preference.IPreferenceStore; import org.eclipse.jface.preference.PreferencePage; import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.ScrolledComposite; +import org.eclipse.swt.graphics.Point; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Composite; @@ -51,12 +53,28 @@ protected Control createContents(Composite parent) { Messages.preferences_page_auto_approve_disabled_by_organization); } - Composite root = new Composite(parent, SWT.NONE); + // Wrap the sections in a height-capped ScrolledComposite. The override on + // computeSize bounds the height reported to the dialog's PageLayout so the + // Preferences shell stays stable; the real (taller) content scrolls here. + // This scroller is also the parent the section tables forward wheel events + // to (see forwardVerticalMouseWheelToParentScrollerAtBoundary). + ScrolledComposite scrolled = new ScrolledComposite(parent, SWT.V_SCROLL) { + @Override + public Point computeSize(int widthHint, int heightHint, boolean changed) { + Point size = super.computeSize(widthHint, heightHint, changed); + size.y = Math.min(size.y, PreferencePageUtils.STANDARD_CONTENT_HEIGHT); + return size; + } + }; + scrolled.setExpandHorizontal(true); + scrolled.setExpandVertical(true); + scrolled.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + + Composite root = new Composite(scrolled, SWT.NONE); GridLayout layout = new GridLayout(1, false); layout.marginWidth = 0; layout.marginHeight = 0; root.setLayout(layout); - root.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false)); IPreferenceStore store = getPreferenceStore(); terminalSection = new TerminalAutoApproveSection(root, SWT.NONE); @@ -72,9 +90,17 @@ protected Control createContents(Composite parent) { globalSection = new GlobalAutoApproveSection(root, SWT.NONE); globalSection.loadFromPreferences(store); - root.addDisposeListener(e -> unbindMcpConfigService()); + scrolled.setContent(root); + scrolled.setMinSize(root.computeSize(SWT.DEFAULT, SWT.DEFAULT)); + // Track width so wrapping labels reflow and only vertical scrolling occurs. + scrolled.addListener(SWT.Resize, e -> { + int width = scrolled.getClientArea().width; + scrolled.setMinSize(root.computeSize(width, SWT.DEFAULT)); + }); + + scrolled.addDisposeListener(e -> unbindMcpConfigService()); - return root; + return scrolled; } @Override diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java index c58c3947..c41975d0 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java @@ -83,7 +83,7 @@ public class McpPreferencePage extends FieldEditorPreferencePage implements IWorkbenchPreferencePage { public static final String ID = "com.microsoft.copilot.eclipse.ui.preferences.McpPreferencePage"; - private static final int GROUP_HEIGHT_HINT = 260; + private static final int GROUP_HEIGHT_HINT = PreferencePageUtils.STANDARD_CONTENT_HEIGHT / 2; private static final Gson GSON = new Gson(); private Group toolsGroup; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/PreferencePageUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/PreferencePageUtils.java index ab676ebf..13ed7ea9 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/PreferencePageUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/PreferencePageUtils.java @@ -25,6 +25,15 @@ */ public final class PreferencePageUtils { + /** + * Target content height, in pixels, for Copilot preference pages. JFace grows the shared Preferences dialog to + * the tallest page's preferred height and never shrinks it, so each page keeps its scrollable content within + * this height to hold the dialog at a stable size while the user navigates. Pages enforce it differently: + * {@code McpPreferencePage} divides it across two stacked groups; {@code AutoApprovePreferencePage} caps its + * {@code ScrolledComposite} at this height. + */ + public static final int STANDARD_CONTENT_HEIGHT = 520; + // Private constructor to prevent instantiation private PreferencePageUtils() { } From 799f24b67a1901f51f102f57cc03856f0c15cd4f Mon Sep 17 00:00:00 2001 From: Ethan Hou <149548697+ethanyhou@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:17:25 +0800 Subject: [PATCH 14/27] fix: Updated token pricing model with context size and tier details (#316) --- .../lsp/CopilotLanguageServerConnection.java | 2 +- .../core/lsp/protocol/ContextSizeInfo.java | 1 + .../core/lsp/protocol/CopilotModel.java | 38 +++++++- .../eclipse/core/lsp/protocol/ModelInfo.java | 3 +- .../chat/contextwindow/ContextSizeDonut.java | 2 +- .../contextwindow/ContextWindowPopup.java | 12 +-- .../contextwindow/ContextWindowService.java | 71 ++++++++++++++- .../ui/chat/services/ChatServiceManager.java | 2 +- .../copilot/eclipse/ui/i18n/Messages.java | 2 +- .../eclipse/ui/i18n/messages.properties | 2 +- .../ui/swt/ModelHoverContentProvider.java | 88 ++----------------- .../copilot/eclipse/ui/utils/ModelUtils.java | 56 +++++++++++- 12 files changed, 176 insertions(+), 103 deletions(-) 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 85e26f98..de116abb 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 @@ -721,6 +721,6 @@ private static ModelInfo buildModelInfo(CopilotModel activeModel, String reasoni } String id = activeModel != null ? activeModel.getId() : null; String providerName = activeModel != null ? activeModel.getProviderName() : null; - return new ModelInfo(id, providerName, reasoningEffort); + return new ModelInfo(id, providerName, reasoningEffort, null); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ContextSizeInfo.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ContextSizeInfo.java index 0edd83a8..2331bab7 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ContextSizeInfo.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ContextSizeInfo.java @@ -9,6 +9,7 @@ */ public record ContextSizeInfo( int totalTokenLimit, + int reservedOutputTokens, int systemPromptTokens, int toolDefinitionTokens, int userMessagesTokens, diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotModel.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotModel.java index fe54ea25..dfc8bd3c 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotModel.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotModel.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Objects; +import com.google.gson.annotations.SerializedName; import org.apache.commons.lang3.builder.ToStringBuilder; /** @@ -97,17 +98,46 @@ public String toString() { } /** - * Per-token prices for the model, returned in USD. + * Per-tier token prices, quoted in USD per {@link CopilotModelBillingTokenPrices#batchSize} tokens and applying up + * to {@code maxContext} context tokens. Requests larger than the {@code default} tier's {@code maxContext} are + * billed at the {@code longContext} tier. All components are optional ({@code null} when the server does not provide + * a value). + * + * @param cachePrice the price for cached input tokens + * @param inputPrice the price for input tokens + * @param outputPrice the price for output tokens + * @param maxContext the maximum number of context (input) tokens this tier applies to */ - public record CopilotModelBillingTokenPrices(Double cachePrice, Double inputPrice, Double outputPrice, - Double tokenUnit) { + public record CopilotModelTokenPriceTier(Double cachePrice, Double inputPrice, Double outputPrice, + Integer maxContext) { @Override public String toString() { ToStringBuilder builder = new ToStringBuilder(this); builder.append("cachePrice", cachePrice); builder.append("inputPrice", inputPrice); builder.append("outputPrice", outputPrice); - builder.append("tokenUnit", tokenUnit); + builder.append("maxContext", maxContext); + return builder.toString(); + } + } + + /** + * Per-tier token prices for the model. When token-based billing is enabled the server returns a {@code default} + * tier and, for models that support long context, a {@code longContext} tier. + * + * @param batchSize the number of tokens each tier price is quoted per + * @param defaultTier the {@code default} price tier (deserialized from the {@code default} JSON field) + * @param longContext the {@code longContext} price tier, or {@code null} when the model does not support long + * context + */ + public record CopilotModelBillingTokenPrices(Double batchSize, + @SerializedName("default") CopilotModelTokenPriceTier defaultTier, CopilotModelTokenPriceTier longContext) { + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("batchSize", batchSize); + builder.append("defaultTier", defaultTier); + builder.append("longContext", longContext); return builder.toString(); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ModelInfo.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ModelInfo.java index 729e9768..de61df10 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ModelInfo.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ModelInfo.java @@ -15,6 +15,7 @@ * @param id model identifier (optional) * @param providerName provider name (optional) * @param reasoningEffort user-selected reasoning effort (optional) + * @param contextSize context size (optional) */ -public record ModelInfo(String id, String providerName, String reasoningEffort) { +public record ModelInfo(String id, String providerName, String reasoningEffort, String contextSize) { } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextSizeDonut.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextSizeDonut.java index c6cc8630..6c226499 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextSizeDonut.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextSizeDonut.java @@ -63,7 +63,7 @@ public ContextSizeDonut(Composite parent, ContextWindowService contextWindowServ e.gc.drawArc(arcOffset, arcOffset, arcSize, arcSize, 0, 360); // Used portion starting from 12 o'clock (90°) going clockwise (negative angle) - double pct = Math.min(info.utilizationPercentage(), 100.0); + double pct = Math.min(contextWindowService.getDisplayUtilizationPercentage(info), 100.0); int filledAngle = (int) Math.round(pct / 100.0 * 360); if (filledAngle > 0) { Color filledColor = pct >= 90 ? CssConstants.getDonutWarningColor(e.display) diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextWindowPopup.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextWindowPopup.java index 7f67021a..15f38afd 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextWindowPopup.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextWindowPopup.java @@ -67,12 +67,12 @@ protected void populateContent(Composite parent) { tokenUsageLabel = createSecondaryTextLabel(tokenRow, formatTokenRow(latestInfo)); tokenUsageLabel.setLayoutData(new GridData(SWT.FILL, SWT.NONE, true, false)); percentageLabel = createSecondaryTextLabel(tokenRow, - formatPercentage(latestInfo.utilizationPercentage())); + formatPercentage(contextWindowService.getDisplayUtilizationPercentage(latestInfo))); percentageLabel.setLayoutData(new GridData(SWT.RIGHT, SWT.NONE, false, false)); progressBar = new ContextWindowBar(parent, SWT.NONE); progressBar.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); - progressBar.setPercentage((int) Math.round(latestInfo.utilizationPercentage())); + progressBar.setPercentage((int) Math.round(contextWindowService.getDisplayUtilizationPercentage(latestInfo))); addSeparator(parent, SECTION_SPACING); @@ -107,7 +107,7 @@ private void updateLabels(ContextSizeInfo info) { return; } setLabelText(tokenUsageLabel, formatTokenRow(info)); - setLabelText(percentageLabel, formatPercentage(info.utilizationPercentage())); + setLabelText(percentageLabel, formatPercentage(contextWindowService.getDisplayUtilizationPercentage(info))); setLabelText(systemInstructionsValue, percentageOf(info.systemPromptTokens(), info.totalTokenLimit())); setLabelText(toolDefinitionsValue, @@ -120,7 +120,7 @@ private void updateLabels(ContextSizeInfo info) { setLabelText(toolResultsValue, percentageOf(info.toolResultsTokens(), info.totalTokenLimit())); if (progressBar != null && !progressBar.isDisposed()) { - progressBar.setPercentage((int) Math.round(info.utilizationPercentage())); + progressBar.setPercentage((int) Math.round(contextWindowService.getDisplayUtilizationPercentage(info))); } shell.requestLayout(); } @@ -146,9 +146,9 @@ private static String formatPercentage(double pct) { return String.format("%.1f%%", pct); } - private static String formatTokenRow(ContextSizeInfo info) { + private String formatTokenRow(ContextSizeInfo info) { return MessageFormat.format(Messages.context_window_tokens, - formatTokens(info.totalUsedTokens()), formatTokens(info.totalTokenLimit())); + formatTokens(info.totalUsedTokens()), formatTokens(contextWindowService.getDisplayTokenLimit(info))); } private static String percentageOf(int tokens, int totalLimit) { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextWindowService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextWindowService.java index 412c41ea..d1b7b781 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextWindowService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextWindowService.java @@ -17,6 +17,9 @@ import org.eclipse.swt.widgets.Display; import com.microsoft.copilot.eclipse.core.lsp.protocol.ContextSizeInfo; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; +import com.microsoft.copilot.eclipse.ui.chat.services.ModelService; +import com.microsoft.copilot.eclipse.ui.utils.ModelUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; /** @@ -27,11 +30,15 @@ public class ContextWindowService { private IObservableValue contextSizeObservable; private final Map popupSideEffects = new HashMap<>(); + private final ModelService modelService; /** * Creates the service and initializes the observable state on the UI realm. + * + * @param modelService the model service used to resolve the active model's context window */ - public ContextWindowService() { + public ContextWindowService(ModelService modelService) { + this.modelService = modelService; AtomicReference> observableRef = new AtomicReference<>(); SwtUtils.invokeOnDisplayThread(() -> { Realm realm = Realm.getDefault(); @@ -58,6 +65,68 @@ public ContextSizeInfo getState() { return result.get(); } + /** + * Returns the context-window limit shown in the donut popup. Prefer the active model's resolved full context window, + * falling back to the language-server usage snapshot when the model metadata is unavailable. + * + * @param info the context usage snapshot + * @return the display context-window limit + */ + public int getDisplayTokenLimit(ContextSizeInfo info) { + if (info == null) { + return 0; + } + + Integer outputLimit = getActiveModelOutputLimit(); + if (info.reservedOutputTokens() > 0) { + outputLimit = info.reservedOutputTokens(); + } + if (outputLimit != null && outputLimit > 0) { + return info.totalTokenLimit() + outputLimit; + } + + Integer modelContextWindow = getActiveModelContextWindow(); + return modelContextWindow != null && modelContextWindow > 0 ? modelContextWindow : info.totalTokenLimit(); + } + + /** + * Returns the utilization percentage against the displayed context window. + * + * @param info the context usage snapshot + * @return the utilization percentage + */ + public double getDisplayUtilizationPercentage(ContextSizeInfo info) { + if (info == null) { + return 0; + } + int displayLimit = getDisplayTokenLimit(info); + if (displayLimit <= 0) { + return info.utilizationPercentage(); + } + return (double) info.totalUsedTokens() / displayLimit * 100; + } + + private Integer getActiveModelContextWindow() { + CopilotModel activeModel = getActiveModel(); + if (activeModel == null) { + return null; + } + return ModelUtils.resolveContextWindowSize(activeModel); + } + + private Integer getActiveModelOutputLimit() { + CopilotModel activeModel = getActiveModel(); + if (activeModel == null || activeModel.getCapabilities() == null + || activeModel.getCapabilities().limits() == null) { + return null; + } + return activeModel.getCapabilities().limits().maxOutputTokens(); + } + + private CopilotModel getActiveModel() { + return modelService == null ? null : modelService.getActiveModel(); + } + /** * Updates the current context size state and notifies bound UI. * diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatServiceManager.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatServiceManager.java index ac3d955a..0883074f 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatServiceManager.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatServiceManager.java @@ -54,7 +54,7 @@ public ChatServiceManager() { mcpRuntimeLogger = new McpRuntimeLogger(); persistenceManager = new ConversationPersistenceManager(this.authStatusManager); chatFontService = new ChatFontService(); - contextWindowService = new ContextWindowService(); + contextWindowService = new ContextWindowService(modelService); } /** diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java index f8ecd839..cb3fc2dd 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java @@ -199,7 +199,7 @@ public final class Messages extends NLS { public static String model_billing_multiplier_suffix; public static String model_billing_multiplier_variable; public static String model_preview_suffix; - public static String model_hover_contextSize; + public static String model_hover_contextWindow; public static String model_hover_cost; public static String model_hover_thinkingEffort; public static String model_hover_thinkingEffort_default_suffix; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties index 16e4f4f1..3974ed60 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties @@ -193,7 +193,7 @@ generateCommitMessage_noStagedFiles_message=There are no staged files to generat addToReference_addFile_title=Add File to Chat addToReference_addFolder_title=Add Folder to Chat -model_hover_contextSize=Context Size: +model_hover_contextWindow=Context Window: model_hover_cost=Cost: model_hover_thinkingEffort=Thinking Effort model_hover_thinkingEffort_default_suffix={0} (default) diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ModelHoverContentProvider.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ModelHoverContentProvider.java index a1698ffe..1c963f89 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ModelHoverContentProvider.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ModelHoverContentProvider.java @@ -19,7 +19,6 @@ import org.eclipse.swt.graphics.Rectangle; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; -import org.eclipse.swt.layout.RowLayout; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Display; @@ -27,7 +26,6 @@ import org.eclipse.ui.PlatformUI; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; -import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCapabilitiesLimits; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCapabilitiesSupports; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.services.ModelService; @@ -37,7 +35,7 @@ /** * Renders the full hover UI for model items in the model picker dropdown. The layout consists of the bold title header, - * an optional category badge, an optional degradation warning, and model-specific details such as context size and + * an optional category badge, an optional degradation warning, and model-specific details such as context window and * token pricing. */ public class ModelHoverContentProvider implements IDropdownItemHoverProvider { @@ -50,8 +48,6 @@ public class ModelHoverContentProvider implements IDropdownItemHoverProvider { /** Vertical padding inside a thinking effort row, so the hover background has breathing room. */ private static final int THINKING_EFFORT_ROW_V_PADDING = 2; - private static Image arrowUpIcon; - private static Image arrowDownIcon; private static Image effortCheckIcon; private final CopilotModel model; @@ -80,9 +76,7 @@ public void configureHover(Composite parent, DropdownItem item, Runnable closeRe addWarningRow(parent, model.getDegradationReason()); } - CopilotModelCapabilitiesLimits limits = model.getCapabilities() != null ? model.getCapabilities().limits() : null; - - addContextSizeSection(parent, limits); + addContextWindowSection(parent); addPricingSection(parent, model.getModelPickerPriceCategory()); addThinkingEffortSection(parent, closeRequest); } @@ -95,49 +89,14 @@ private void renderHeader(Composite parent, DropdownItem item) { titleLabel.setLayoutData(headerGd); } - private void addContextSizeSection(Composite parent, CopilotModelCapabilitiesLimits limits) { - if (limits == null) { - return; - } - boolean hasInput = isPositive(limits.maxInputTokens()); - boolean hasOutput = isPositive(limits.maxOutputTokens()); - if (!hasInput && !hasOutput) { + private void addContextWindowSection(Composite parent) { + String contextWindowText = ModelUtils.getContextWindowText(model); + if (StringUtils.isBlank(contextWindowText)) { return; } addSeparator(parent); - - Composite row = createKeyValueRow(parent); - ((GridData) row.getLayoutData()).verticalIndent = SECTION_SPACING; - - // Context Size: - Label keyLabel = createSecondaryTextLabel(row, Messages.model_hover_contextSize); - keyLabel.setLayoutData(new GridData(SWT.LEFT, SWT.NONE, false, false)); - - Composite valueComp = new Composite(row, SWT.NONE); - valueComp.setLayoutData(new GridData(SWT.RIGHT, SWT.NONE, true, false)); - RowLayout valueLayout = new RowLayout(SWT.HORIZONTAL); - valueLayout.marginTop = 0; - valueLayout.marginBottom = 0; - valueLayout.marginLeft = 0; - valueLayout.marginRight = 0; - - // Add spacing between input and output token labels if both are present - if (hasInput && hasOutput) { - valueLayout.spacing = 4; - } else { - valueLayout.spacing = 0; - } - valueComp.setLayout(valueLayout); - - // ex. ↑128K - if (hasInput) { - addArrowTokenLabel(valueComp, true, ModelUtils.formatTokenCount(limits.maxInputTokens())); - } - // ex. ↓16K - if (hasOutput) { - addArrowTokenLabel(valueComp, false, ModelUtils.formatTokenCount(limits.maxOutputTokens())); - } + addKeyValueRow(parent, Messages.model_hover_contextWindow, contextWindowText); } private void addPricingSection(Composite parent, String priceCategory) { @@ -325,42 +284,7 @@ private Composite createKeyValueRow(Composite parent) { return row; } - private void addArrowTokenLabel(Composite parent, boolean isInput, String tokenText) { - GridLayout pairLayout = new GridLayout(2, false); - pairLayout.marginWidth = 0; - pairLayout.marginHeight = 0; - pairLayout.horizontalSpacing = 0; - Composite pairComp = new Composite(parent, SWT.NONE); - pairComp.setLayout(pairLayout); - - initArrowIcons(pairComp); - Label arrowLabel = new Label(pairComp, SWT.NONE); - Image arrowImage = isInput ? arrowUpIcon : arrowDownIcon; - arrowLabel.setImage(arrowImage); - - createSecondaryTextLabel(pairComp, tokenText); - } - - private static void initArrowIcons(Composite parent) { - if (arrowUpIcon == null || arrowUpIcon.isDisposed()) { - boolean isDark = UiUtils.isDarkTheme(); - arrowUpIcon = UiUtils.buildImageFromPngPath(isDark ? "/icons/dropdown/context_size_arrow_up_dark.png" - : "/icons/dropdown/context_size_arrow_up_light.png"); - arrowDownIcon = UiUtils.buildImageFromPngPath(isDark ? "/icons/dropdown/context_size_arrow_down_dark.png" - : "/icons/dropdown/context_size_arrow_down_light.png"); - parent.getDisplay().addListener(SWT.Dispose, e -> disposeStaticIcons()); - } - } - private static void disposeStaticIcons() { - if (arrowUpIcon != null && !arrowUpIcon.isDisposed()) { - arrowUpIcon.dispose(); - arrowUpIcon = null; - } - if (arrowDownIcon != null && !arrowDownIcon.isDisposed()) { - arrowDownIcon.dispose(); - arrowDownIcon = null; - } if (effortCheckIcon != null && !effortCheckIcon.isDisposed()) { effortCheckIcon.dispose(); effortCheckIcon = null; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtils.java index e7d18484..f806727f 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtils.java @@ -14,6 +14,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCapabilities; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCapabilitiesLimits; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCapabilitiesSupports; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelTokenPriceTier; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotScope; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokModel; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokModelCapabilities; @@ -162,15 +163,62 @@ public static String formatPriceCategory(String priceCategory) { /** * Returns the formatted context window size for the model, or {@code null} if unavailable. */ - private static String getContextWindowText(CopilotModel model) { + public static String getContextWindowText(CopilotModel model) { + Integer contextWindow = resolveContextWindowSize(model); + if (contextWindow == null || contextWindow <= 0) { + return null; + } + return formatTokenCount(contextWindow); + } + + /** + * Resolves the user-facing context window size for the model, mirroring the language-server / IntelliJ behavior. + * + *

When the model advertises a {@code default} price tier with its own {@code maxContext} (the input budget), the + * full window is {@code maxContext + maxOutputTokens}. Otherwise, token-based billing models fall back to + * {@code maxInputTokens + maxOutputTokens}, and finally to the advertised {@code maxContextWindowTokens}. + * + * @param model the model + * @return the context window size in tokens, or {@code null} when it cannot be determined + */ + public static Integer resolveContextWindowSize(CopilotModel model) { if (model.getCapabilities() == null || model.getCapabilities().limits() == null) { return null; } - Integer maxContextWindowTokens = model.getCapabilities().limits().maxContextWindowTokens(); - if (maxContextWindowTokens == null || maxContextWindowTokens <= 0) { + CopilotModelCapabilitiesLimits limits = model.getCapabilities().limits(); + Integer maxOutputTokens = limits.maxOutputTokens(); + int output = maxOutputTokens == null ? 0 : maxOutputTokens; + + CopilotModelTokenPriceTier defaultTier = getDefaultTokenPriceTier(model); + if (defaultTier != null && defaultTier.maxContext() != null) { + return defaultTier.maxContext() + output; + } + + // TODO: Remove this legacy fallback after TBB is officially released. + if (isTokenBasedBillingEnabled(model)) { + Integer maxInputTokens = limits.maxInputTokens(); + if (maxInputTokens != null && maxOutputTokens != null) { + return maxInputTokens + maxOutputTokens; + } + } + + return limits.maxContextWindowTokens(); + } + + // TODO: Remove this legacy fallback after TBB is officially released. + private static boolean isTokenBasedBillingEnabled(CopilotModel model) { + return model.getBilling() != null && model.getBilling().tokenBasedBillingEnabled(); + } + + /** + * Returns the model's {@code default} token price tier, or {@code null} when the model carries no token-based + * pricing. + */ + private static CopilotModelTokenPriceTier getDefaultTokenPriceTier(CopilotModel model) { + if (model.getBilling() == null || model.getBilling().tokenPrices() == null) { return null; } - return formatTokenCount(maxContextWindowTokens); + return model.getBilling().tokenPrices().defaultTier(); } /** From 1c72c04b9fea8ba30bb64be1095f11cbf37c5013 Mon Sep 17 00:00:00 2001 From: Sheng Chen Date: Wed, 24 Jun 2026 17:18:21 +0800 Subject: [PATCH 15/27] fix: Adjust menu contribution location for Copilot menu (#292) --- com.microsoft.copilot.eclipse.ui/plugin.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/com.microsoft.copilot.eclipse.ui/plugin.xml b/com.microsoft.copilot.eclipse.ui/plugin.xml index a3d4a20a..8771f7ac 100644 --- a/com.microsoft.copilot.eclipse.ui/plugin.xml +++ b/com.microsoft.copilot.eclipse.ui/plugin.xml @@ -29,7 +29,7 @@ + locationURI="menu:org.eclipse.ui.main.menu?before=help">

Date: Wed, 24 Jun 2026 17:19:34 +0800 Subject: [PATCH 16/27] docs: Add endgame test cases (#310) --- .../cls-session-persistence.md | 30 ++++ .../editor-indentation-preferences.md | 168 ++++++++++++++++++ .../local-file-edit-and-create-tools.md | 65 +++++++ .../test-plans/subagent/subagent.md | 45 +++++ .../thinking-effort/thinking-effort.md | 45 +++++ 5 files changed, 353 insertions(+) create mode 100644 com.microsoft.copilot.eclipse.swtbot.test/test-plans/editor-indentation-preferences/editor-indentation-preferences.md diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/cls-session-persistence/cls-session-persistence.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/cls-session-persistence/cls-session-persistence.md index 454c5b8b..813bffab 100644 --- a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/cls-session-persistence/cls-session-persistence.md +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/cls-session-persistence/cls-session-persistence.md @@ -181,6 +181,34 @@ This feature integrates CLS (Copilot Language Server) server-side session persis --- +### TC-008: Cancelled terminal tool call stays cancelled after restoration + +**Type:** `Regression` +**Priority:** `P0` + +#### Preconditions +- Copilot Chat is open in Agent mode +- A workspace is open so the `run_in_terminal` tool can execute +- A model that supports tool calls is selected + +#### Steps +1. Send a message that makes Copilot use the `run_in_terminal` tool to perform a longer-running task (e.g. "run a command that takes a while, such as `ping -n 30 127.0.0.1`") +2. While the terminal tool call is still running (spinner active on the tool status), click Cancel on the chat response +3. Verify the terminal tool call status updates to the **cancelled** icon (no longer spinning) +4. Click "New Chat" to start a fresh conversation +5. Open chat history and select the original conversation to restore it + +#### Expected Result +- After restoration, the terminal tool call still shows the **cancelled** icon +- The tool call status does **not** revert to running/spinning (no rotating/ongoing indicator reappears) +- The cancelled status is consistent between the live cancel and the restored view + +#### 📸 Key Screenshots +- [ ] **After cancel** — Terminal tool call showing the cancelled icon (not spinning) +- [ ] **After restore** — Same terminal tool call restored with the cancelled icon, not reverted to running + +--- + ## Screenshots Checklist > Consolidated list of all key screenshot moments. @@ -195,3 +223,5 @@ This feature integrates CLS (Copilot Language Server) server-side session persis - [ ] `TC-006` Restored history with successful follow-up and no 400 error - [ ] `TC-007` Original conversation showing assistant reply - [ ] `TC-007` Restored conversation with correctly attributed messages and no duplicates +- [ ] `TC-008` Cancelled terminal tool call showing cancelled icon after cancel +- [ ] `TC-008` Restored terminal tool call still cancelled, not reverted to running diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/editor-indentation-preferences/editor-indentation-preferences.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/editor-indentation-preferences/editor-indentation-preferences.md new file mode 100644 index 00000000..eab55438 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/editor-indentation-preferences/editor-indentation-preferences.md @@ -0,0 +1,168 @@ +# Editor Indentation Preferences for Inline Completions + +## Overview +When Copilot produces an **inline completion (ghost text)**, the indentation it +suggests (tab character vs. spaces and the tab width) should follow the +platform's **default text editor preferences** instead of always defaulting to +spaces with a tab size of 4. + +The formatting options are resolved by `FormatOptionProvider` and sent on each +completion request as `CompletionDocument.insertSpaces` / `tabSize` by +`CompletionProvider`. For Java and C/C++ projects the language-specific +formatter settings continue to apply. For every other case — an unknown +language, a file with no extension, or a file with no project — the provider +now reads the Eclipse text editor preferences +`org.eclipse.ui.editors/spacesForTabs` and `org.eclipse.ui.editors/tabWidth`. +If those preferences cannot be read it falls back to the previous hardcoded +defaults (spaces, tab width 4). + +> **Important:** these options affect **inline completions only**, not the +> Agent-mode file create/edit tools. The verification must therefore be done +> by triggering ghost-text completions in an editor, not by asking Agent mode +> to write a file. + +These preferences are configured under +**Preferences → General → Editors → Text Editors**: +- **"Insert spaces for tabs"** maps to `spacesForTabs`. +- **"Displayed tab width"** maps to `tabWidth`. + +Entry points: +- Window → Preferences → General → Editors → Text Editors +- Typing in a text editor to trigger an inline completion (ghost text) + +Not exercised: +- Direct unit-level invocation of `FormatOptionProvider` (covered by + `FormatOptionProviderTests`). +- Agent-mode file create/edit tools (they do not consult these options). +- Java/C-specific formatter configuration screens. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- The user is signed in to GitHub Copilot and inline completions are enabled. +- A workspace with at least one open project that is **neither a Java nor a + C/C++ project** (a General/empty project), so the editor text preferences + are the deciding factor. +- Knowledge of the current values of **Preferences → General → Editors → + Text Editors → "Insert spaces for tabs"** and **"Displayed tab width"** so + they can be restored after testing. +- Because completion content is model-influenced, prefer a prompt/context that + reliably forces an **indented multi-line suggestion** (e.g. an opening + brace/block), and inspect the **raw whitespace** of the accepted text rather + than relying on the displayed tab width. + +--- + +## 1. Spaces configuration + +### TC-001: Inline completion uses spaces when "insert spaces for tabs" is on + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- A non-Java/non-C project is open in the workspace. +- A file whose language is unknown / has no extension (e.g. a file named + `sample` with code-like content) is open in the editor. + +#### Steps +1. Open **Preferences → General → Editors → Text Editors**, check + **"Insert spaces for tabs"**, set **"Displayed tab width"** to `2`, then + click **Apply and Close**. +2. In the editor, type content that forces an indented multi-line + continuation, for example an opening block and a newline: + `function greet() {` then press Enter so Copilot suggests the indented + body as ghost text. +3. Accept the inline completion (Tab). +4. Reveal whitespace ("Show whitespace characters") or inspect the raw bytes + of the inserted lines. + +#### Expected Result +- The accepted completion indents the body with **spaces** (not tabs), at + **2 spaces** per level, matching the configured tab width. +- No error dialog appears and the Eclipse error log has no formatting + exception. + +#### 📸 Key Screenshots +- [ ] **Editor preferences** — "Insert spaces for tabs" checked, tab width 2. +- [ ] **Accepted completion whitespace** — Indentation shown as 2-space groups. + +--- + +## 2. Tabs configuration + +### TC-002: Inline completion uses tabs, and switching the preference updates the next request + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- A non-Java/non-C project is open in the workspace. +- An unknown-language / extensionless file is open in the editor. + +#### Steps +1. Open **Preferences → General → Editors → Text Editors**, **uncheck** + "Insert spaces for tabs", set **"Displayed tab width"** to `4`, then click + **Apply and Close**. +2. In the editor, trigger an indented multi-line completion (as in TC-001) and + accept it. Reveal whitespace / inspect raw bytes. +3. Change editor preferences to **spaces**, tab width `2`. Apply and close. +4. Trigger another indented completion in the same or a new unknown-language + file and accept it. Reveal whitespace. + +#### Expected Result +- The first accepted completion indents with **tab characters** (`\t`). +- After switching the preference, the next completion indents with **2 + spaces**. +- The provider reads the current editor preference value on **each completion + request** rather than caching the first observed value. +- No error dialog appears and the Eclipse error log has no formatting + exception. + +#### 📸 Key Screenshots +- [ ] **Tabs completion** — Tab indentation under the tabs preference. +- [ ] **Spaces completion** — 2-space indentation after switching the preference. + +--- + +## 3. Regression — language-specific projects are unaffected + +### TC-003: Java completion still uses the Java formatter settings, not editor preferences + +**Type:** `Regression` +**Priority:** `P0` + +#### Preconditions +- A **Java** project is open in the workspace with its own + formatter/indentation settings (e.g., tabs for Java). +- Editor preferences (General → Text Editors) are set to a **different** + value than the Java formatter (e.g., spaces, tab width 2). +- A `.java` source file is open in the editor. + +#### Steps +1. In the Java file, trigger an indented multi-line inline completion (e.g. + inside a method body) and accept it. +2. Reveal whitespace / inspect raw bytes of the inserted lines. + +#### Expected Result +- The Java completion follows the **Java formatter** indentation settings, not + the generic text editor preferences. +- Only unknown/extensionless/projectless cases consult the editor + preferences; Java (and C/C++) behavior is unchanged. + +#### 📸 Key Screenshots +- [ ] **Java completion whitespace** — Indentation matches the Java formatter, + independent of the General text-editor preference. + +--- + +## Screenshots Checklist + +- [ ] TC-001 — Editor preferences (spaces, width 2) + accepted completion with + 2-space indentation. +- [ ] TC-002 — Tabs completion vs. spaces completion after a preference switch. +- [ ] TC-003 — Java completion indentation unchanged by the General + text-editor preference. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md index 7f9a18cf..0e55246e 100644 --- a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md @@ -219,3 +219,68 @@ Not exercised: #### Key Screenshots - [ ] **Local file tool link** -- The tool result shows a clickable absolute path outside the workspace. - [ ] **External local file editor** -- The external local file opens in an Eclipse editor. + +### TC-007: Workspace directory and project links reveal in the Project Explorer + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- The Eclipse workbench is open with at least one project (e.g., `demo`) that contains a sub-folder (e.g., `src`). +- The **Project Explorer** view is open. +- Copilot Chat is open in Agent mode. + +#### Steps +1. Send a prompt that causes Agent mode to reference the workspace sub-folder by path (for example, ask it to list the + contents of the `src` folder so the tool result renders a directory link). +2. When the tool call appears in the Chat view, click the directory link for the `src` folder. +3. Verify the `src` folder is selected and revealed in the **Project Explorer** (no external browser opens). +4. Send a prompt that causes Agent mode to reference the project root, then click the project link in the tool result. +5. Verify the project is selected and revealed in the **Project Explorer**. + +#### Expected Result +- Clicking a workspace folder or project link reveals and selects that resource in the Project Explorer. +- No external browser or web page is opened for directory/project links. +- No error dialog is shown and the Eclipse error log has no navigation exception. + +#### Key Screenshots +- [ ] **Directory tool link** -- The tool result shows a clickable workspace folder link. +- [ ] **Folder revealed** -- The `src` folder is selected and revealed in the Project Explorer. +- [ ] **Project revealed** -- The project root is selected and revealed in the Project Explorer. + +#### Notes on failure modes +- Clicking the directory link opens a browser or does nothing -- the folder/project branch may not route through + `UiUtils.revealInExplorer`. +- The resource is opened in an editor instead of being revealed -- folder/project types may be incorrectly treated as + files. + +### TC-008: File URI link with a line-number fragment opens the external local file + +**Type:** `Edge Case` +**Priority:** `P2` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in Agent mode. +- The local test directory outside the workspace exists and contains `existing-local-file.txt` with multiple lines. + +#### Steps +1. Send a prompt that causes Agent mode to reference `existing-local-file.txt` by absolute path with a line-number + fragment (for example, a link ending in `#L10` or a `file:` URI with a `#L10` fragment). +2. When the tool call appears in the Chat view, click the file path link. +3. Verify Eclipse opens `existing-local-file.txt` in an editor (the trailing line-number fragment is treated as a + fragment, not part of the file name). + +#### Expected Result +- The line-number fragment is stripped when resolving the local path, so the correct external file opens. +- Eclipse does not report a missing file named `existing-local-file.txt#L10` or a path-resolution error. +- No error dialog is shown and the Eclipse error log has no local file navigation exception. + +#### Key Screenshots +- [ ] **Fragment file link** -- The tool result shows a clickable local path with a `#L10` fragment. +- [ ] **External file opened** -- The external local file opens in an Eclipse editor despite the fragment. + +#### Notes on failure modes +- Eclipse reports the file cannot be found -- the `#` fragment may not be stripped before resolving the local path. +- A relative-path or `https:` link is unexpectedly opened as a local file -- the URI-scheme guard in + `FileUtils.getLocalFilePath` may be bypassed. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/subagent/subagent.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/subagent/subagent.md index ed950cc6..49b667ab 100644 --- a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/subagent/subagent.md +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/subagent/subagent.md @@ -15,6 +15,7 @@ Entry points exercised: - Agent mode with a prompt that triggers `run_subagent`. - Chat history panel: switching to a different conversation and back. - Cancel (stop) button during subagent execution. +- Cancel button on a subagent tool-confirmation dialog. - `conversation/destroy` LSP call on session switch. Not exercised: @@ -222,3 +223,47 @@ Not exercised: #### Key Screenshots - [ ] **After restore** — conversation unchanged, no extra messages. + +--- + +## 6. Tool-confirmation dialog cancel layout + +### TC-007: Subagent panel collapses after confirmation dialog is cancelled + +**Type:** `Regression` +**Priority:** `P1` + +#### Preconditions +- The Chat view is open in Agent mode. +- A new conversation (fresh session). +- The selected agent triggers a tool that requires confirmation + (e.g., a command that needs to run in the terminal). + +#### Steps +1. Send a prompt that triggers subagent execution where the subagent + wants to run a command (e.g., `scan for CVEs`, or + `run ls in the terminal`). +2. Wait for the subagent's tool **confirmation dialog** to appear inside + the subagent card (Allow / Skip buttons). +3. Click **Skip** on the confirmation dialog. +4. Observe the subagent card area after the dialog disposes. + +#### Expected Result +- The subagent panel resizes/collapses to fit its remaining content. +- **No empty/blank gap** is left at the bottom of the subagent area where + the confirmation dialog used to be (the bug in #169). +- A skip label/message is shown where appropriate, and the scroller + minimum height is recomputed in a single layout pass. +- The send button returns to the send state. + +#### Key Screenshots +- [ ] **Confirmation dialog visible** — Allow / Skip buttons shown + inside the subagent card. +- [ ] **After skip** — subagent panel collapsed with no empty area below. + +#### Notes on failure modes +- Empty area left below the subagent card → `cancelConfirmation()` did not + trigger `requestRefreshScrollerLayout()`; the `ScrolledComposite` + `setMinHeight()` was never recomputed after the dialog disposal. +- Verify the Allow path is unaffected: clicking **Allow** on a + separate invocation should still lay out correctly (no regression). diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/thinking-effort/thinking-effort.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/thinking-effort/thinking-effort.md index 02f9e900..3eacc8d1 100644 --- a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/thinking-effort/thinking-effort.md +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/thinking-effort/thinking-effort.md @@ -79,3 +79,48 @@ Entry points: - Model A still shows **`High`**; Model B still shows **`Low`**. - Each model's effort is independent — changing one did not affect the other. +--- + +## TC-003: Thinking effort descriptions are fully readable in the hover card + +**Type:** `Regression` +**Priority:** `P1` + +Regression guard for +[issue #233](https://github.com/microsoft/copilot-for-eclipse/issues/233) / +[PR #234](https://github.com/microsoft/copilot-for-eclipse/pull/234). The hover +card previously clamped its width to a fixed maximum (`LONG_POPUP_WIDTH = 300`), +which truncated the longer per-level description text in the **Thinking effort** +section. The fix removes that cap so the hover shell uses its natural packed +width (only enforcing a `250` px minimum), letting every description wrap and +render in full. + +#### Preconditions +- A reasoning-capable model (e.g. Claude Sonnet 4.6) advertising multiple + effort levels, each with a descriptive sub-label (e.g. + `Low — Faster responses, less reasoning`, + `Medium — Balanced reasoning and speed`, + `High — Deeper reasoning, slower responses`). + +#### Steps +1. Open the Copilot Chat view and click the model picker to open the dropdown. +2. Hover over the reasoning-capable model to open its hover card. +3. Locate the **Thinking effort** section and read each effort level's + description line in full. +4. Visually compare the right edge of the longest description against the hover card border. + +#### Expected Result +- The hover card is wide enough to display the full description for every effort + level — no text is clipped, cut off, or replaced with an ellipsis at the right + edge. +- Each description ends with its natural last word (or wraps onto a second line) + rather than being truncated mid-word against the card border. +- The hover card width is never narrower than the `250` px minimum, so short + content still renders cleanly. + +#### 📸 Key Screenshots +- [ ] **Hover card — full descriptions** — Thinking effort section showing each + level's complete, untruncated description text. +- [ ] **Right-edge close-up** — the longest description's final word fully + visible inside the card border (no clipping/ellipsis). + From fb60576e210676196a7d28ca82ca8f0eff514284 Mon Sep 17 00:00:00 2001 From: Sheng Chen Date: Thu, 25 Jun 2026 09:26:32 +0800 Subject: [PATCH 17/27] =?UTF-8?q?docs:=20Add=20test=20plan=20for=20Run=20I?= =?UTF-8?q?n=20Terminal=20tool=20with=20multi-line=20PowerShe=E2=80=A6=20(?= =?UTF-8?q?#309)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../run-in-terminal/run-in-terminal.md | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 com.microsoft.copilot.eclipse.swtbot.test/test-plans/run-in-terminal/run-in-terminal.md diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/run-in-terminal/run-in-terminal.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/run-in-terminal/run-in-terminal.md new file mode 100644 index 00000000..fb6de18c --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/run-in-terminal/run-in-terminal.md @@ -0,0 +1,112 @@ +# Run In Terminal Tool + +## Overview +Verify that Agent mode invokes `run_in_terminal` with commands that preserve multi-line shell syntax and execute +build commands from the correct imported project root. These cases guard against command flattening and wrong +working-directory selection in terminal tool calls. + +--- + +## Test Cases + +### TC-001: Multi-line PowerShell command executes correctly + +**Type:** `Regression` +**Priority:** `P0` + +#### Preconditions +- Eclipse IDE is open with the GitHub Copilot for Eclipse plugin installed and activated. +- The user is signed in to GitHub Copilot. +- Copilot Chat is open in Agent mode. +- The terminal shell used by `run_in_terminal` is PowerShell. +- Terminal tool confirmation is enabled, or the tester can inspect the generated command before allowing execution. + +#### Steps +1. Start a new Copilot Chat conversation in Agent mode. +2. Send a prompt that asks the agent to run this exact multi-line PowerShell command in the terminal and report the + output: + ```powershell + 1..3 | ForEach-Object { + Write-Output "item=$_" + } + ``` +3. When the `run_in_terminal` confirmation appears, verify that the displayed command keeps the pipeline and block + body as a multi-line command, including the opening `{`, the `Write-Output` line, and the closing `}`. +4. Allow the command to run. +5. Wait for the terminal tool call to complete and inspect the terminal output returned to the agent. + +#### Expected Result +- The command executes successfully without PowerShell parser errors caused by dropped newlines, missing braces, or + truncated block content. +- The output contains exactly these item lines, in order: + ```text + item=1 + item=2 + item=3 + ``` +- The agent response reports the same three output lines and does not claim the command failed. + +#### 📸 Key Screenshots +- [ ] **Multi-line command confirmation** — `run_in_terminal` confirmation showing the full multi-line PowerShell + command. +- [ ] **Multi-line command output** — Terminal or agent output showing `item=1`, `item=2`, and `item=3`. + +--- + +### TC-002: Maven verify command runs from imported project root + +**Type:** `Regression` +**Priority:** `P0` + +#### Preconditions +- Eclipse IDE is open with the GitHub Copilot for Eclipse plugin installed and activated. +- The user is signed in to GitHub Copilot. +- Copilot Chat is open in Agent mode. +- A Maven project is imported into the Eclipse workspace and visible in Project Explorer. +- The imported project root contains `pom.xml` and either a Maven wrapper (`mvnw` / `mvnw.cmd`) or uses a system + `mvn` installation. +- Terminal tool confirmation is enabled, or the tester can inspect the generated command before allowing execution. + +#### Steps +1. Import a Maven project into the Eclipse workspace if one is not already present. +2. Start a new Copilot Chat conversation in Agent mode. +3. Ask the agent to run Maven `verify` for the imported project. +4. When the `run_in_terminal` confirmation appears, inspect the generated command before allowing it. +5. Allow the command to run after confirming the tool will execute from the imported Maven project root. Valid evidence + includes either an explicit `cd ""` in the command or a terminal prompt/working directory + that already points at the imported project root. +6. Wait for the terminal tool call to complete or reach the normal build result for that project. + +#### Expected Result +- The terminal executes Maven `verify` from the imported Maven project root path. +- The execution directory is the project directory that contains the imported project's `pom.xml`, not the Eclipse + workspace root, a parent folder, or an unrelated project. +- The tool satisfies this either by starting the terminal with the imported project root as its working directory or by + using an equivalent explicit directory change before running `verify`, such as: + ```powershell + cd "" + .\mvnw.cmd verify + ``` + or: + ```powershell + cd "" + mvn verify + ``` +- The terminal starts Maven from that project root, so Maven resolves the expected `pom.xml`. + +#### 📸 Key Screenshots +- [ ] **Imported Maven project** — Project Explorer showing the selected/imported Maven project root with `pom.xml`. +- [ ] **Verify execution location** — `run_in_terminal` confirmation or terminal prompt showing the command will execute + from the imported Maven project root. +- [ ] **Verify command output** — Terminal output showing Maven started from the selected project root. + +--- + +## Screenshots Checklist +> Consolidated list of all key screenshot moments. + +- [ ] `TC-001` Multi-line command confirmation +- [ ] `TC-001` Multi-line command output +- [ ] `TC-002` Imported Maven project +- [ ] `TC-002` Verify execution location +- [ ] `TC-002` Verify command output From ff439e6a14d22f423659be3f7b209c86e3343d6b Mon Sep 17 00:00:00 2001 From: Dietrich Travkin <10887297+travkin79@users.noreply.github.com> Date: Thu, 25 Jun 2026 07:10:52 +0200 Subject: [PATCH 18/27] Fix #113: Resize model drop-down list for optional scroll bar (#285) Co-authored-by: Sheng Chen --- .../copilot/eclipse/ui/swt/DropdownPopup.java | 71 +++++++++++++++++-- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownPopup.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownPopup.java index 7b9dc2f0..344eb79c 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownPopup.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownPopup.java @@ -28,6 +28,7 @@ import org.eclipse.swt.widgets.Shell; import org.eclipse.ui.PlatformUI; +import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; @@ -219,6 +220,7 @@ void open(Point location, List groups, String selectedItemId, shell.pack(); constrainHeightIfNeeded(); + reserveScrollbarSpaceIfNeeded(contentSize); adjustBounds(location, anchorHeight); scrollToFocusedItem(); shell.setVisible(true); @@ -234,14 +236,71 @@ private void constrainHeightIfNeeded() { Rectangle lastBounds = lastVisible.getBounds(); int maxContentHeight = lastBounds.y + lastBounds.height; - int scrollBarWidth = 0; - if (scrolledComposite.getVerticalBar() != null) { - scrollBarWidth = scrolledComposite.getVerticalBar().getSize().x; - } - Point shellSize = shell.getSize(); int newHeight = maxContentHeight + 2 * POPUP_MARGIN; - shell.setSize(shellSize.x + scrollBarWidth, newHeight); + shell.setSize(shellSize.x, newHeight); + } + + /** + * Ensures the vertical scrollbar never hides item content, which manifests differently across platforms. + * + *
    + *
  • Classic (space-reserving) scrollbars (typical on Windows and many GTK themes) shrink the + * {@link ScrolledComposite} client area. With {@link ScrolledComposite#setExpandHorizontal(boolean)} the + * content is stretched to that reduced width, so the shell must be widened by the reserved trough width to + * restore the full natural content width.
  • + *
  • Overlay scrollbars (the default on modern GNOME, e.g. RHEL 9) do not reduce the client + * area; the scrollbar is painted on top of the right edge of the content, covering the right-aligned suffix + * labels. Widening the shell alone does not help because the suffix stays pinned to the (new) right edge, + * still under the overlay. Instead we stop horizontal expansion, keep the content at its natural width, and + * widen the shell so an empty scrollbar-width strip is left on the right for the overlay to paint over.
  • + *
+ * + * @param contentSize the natural (unconstrained) size of the item container + */ + private void reserveScrollbarSpaceIfNeeded(Point contentSize) { + if (items.size() <= MAX_VISIBLE_ITEMS || items.isEmpty()) { + return; + } + + if (scrolledComposite == null || scrolledComposite.isDisposed() || scrolledComposite.getVerticalBar() == null) { + return; + } + + // Fix #113: We adapt the drop-down dialog's size only for Linux (where the issue appeared), + // in order to avoid potential regressions, + // see https://github.com/microsoft/copilot-for-eclipse/pull/285#issuecomment-4686907980 + if (PlatformUtils.isLinux()) { + // Force a layout so the client area reflects the constrained height before we measure it. + // The layout operation doesn't lead to flickering, since it is not user-visible at this point. + shell.layout(true, true); + int scrollBarWidth = scrolledComposite.getVerticalBar().getSize().x; + if (scrollBarWidth <= 0) { + return; + } + Rectangle clientArea = scrolledComposite.getClientArea(); + Point shellSize = shell.getSize(); + if (clientArea.width < contentSize.x) { + // Classic scrollbar: the trough shrank the client area. Widen the shell to restore full content width. + int deficit = contentSize.x - clientArea.width; + shell.setSize(shellSize.x + deficit, shellSize.y); + } else { + // Overlay scrollbar: client area was not reduced, so the bar paints over the content's right edge. Pin the + // content to its natural width and reserve just enough room on the right for the overlay. + scrolledComposite.setExpandHorizontal(false); + Control content = scrolledComposite.getContent(); + if (content != null && !content.isDisposed()) { + content.setSize(contentSize); + } + int extraWidth = Math.max(0, scrollBarWidth - ITEM_H_PADDING); + shell.setSize(shellSize.x + extraWidth, shellSize.y); + } + } else { + // Behavior before fixing #113 + int scrollBarWidth = scrolledComposite.getVerticalBar().getSize().x; + Point shellSize = shell.getSize(); + shell.setSize(shellSize.x + scrollBarWidth, shellSize.y); + } } private void adjustBounds(Point location, int anchorHeight) { From 710528eac01966e6f48b7eb1534254ffaa13e33e Mon Sep 17 00:00:00 2001 From: Sheng Chen Date: Thu, 25 Jun 2026 14:55:40 +0800 Subject: [PATCH 19/27] feat: Add profile to skip tests during UI probe execution (#290) * feat: Add profile to skip tests during UI probe execution * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/skills/ui-action/REFERENCE.md | 169 ++++++++++ .github/skills/ui-action/SKILL.md | 315 +++--------------- .../pom.xml | 25 +- com.microsoft.copilot.eclipse.ui.test/pom.xml | 23 ++ 4 files changed, 267 insertions(+), 265 deletions(-) create mode 100644 .github/skills/ui-action/REFERENCE.md diff --git a/.github/skills/ui-action/REFERENCE.md b/.github/skills/ui-action/REFERENCE.md new file mode 100644 index 00000000..65d4fb3e --- /dev/null +++ b/.github/skills/ui-action/REFERENCE.md @@ -0,0 +1,169 @@ +# SWTBot Probe Reference + +## Runner behavior + +`ProbeRunner` is a JUnit 4 test launched by Tycho under a real Eclipse workbench +with `useUIHarness=true` and `useUIThread=false`. It loads the JSON array from +`-Dprobe.script`, executes each `ProbeStep` through `StepExecutor`, writes result +artifacts, and fails the Maven build if any failed step has `failFast` enabled. + +Before the bot starts, the runner pre-populates configuration-scope preferences +so Quick Start, What's New, Welcome, and "Terminal Support Unavailable" dialogs +do not block normal probes. + +`screenshot` and failure screenshots capture the active workbench shell with +`java.awt.Robot`, then fall back to SWTBot's full-display capture if needed. +Screenshots are diagnostic artifacts only; JSON assertions drive pass/fail. + +## Running probes + +Default command from the repository root: + + # macOS / Linux + ./mvnw clean verify -Dprobe.script=probe-scripts/.json + # Windows (PowerShell) + .\mvnw.cmd clean verify -Dprobe.script=probe-scripts/.json + +The narrower module command is useful only after a green root build: + +```bash +./mvnw -pl com.microsoft.copilot.eclipse.swtbot.test -am -Dprobe.script=probe-scripts/.json verify +``` + +The module-only shortcut can fail dependency resolution or reuse stale bundles, +especially after source edits. If widget markers or other code changes appear to +be ignored, run the root `clean verify` command. + +Platform notes: + +- Linux headless: prefix the command with `xvfb-run -a`. +- macOS: keep `${swtbot.platformArgLine}` in + `com.microsoft.copilot.eclipse.swtbot.test/pom.xml` so the `swtbot-osx` + profile can inject `-XstartOnFirstThread`. +- macOS screenshots: the JVM needs Screen Recording permission. Blank or + wallpaper-only PNGs usually mean permission is missing. +- Do not set `-Djava.awt.headless=true`; `Robot` cannot capture screenshots in + headless AWT mode. +- Windows HiDPI: workbench shell bounds are DIP coordinates while `Robot` + expects raw pixels, so captures can clip at scaling above 100%. + +Windows stale cache recovery: + +```powershell +Remove-Item -Recurse -Force $env:USERPROFILE\.m2\repository\com\microsoft\copilot\eclipse +``` + +## Results + +Artifacts are written under +`com.microsoft.copilot.eclipse.swtbot.test/target/probe-results/`: + +```text +results.json # pass/fail summary +workspace.log # sandbox Eclipse .metadata/.log +screenshots/ + .png # from screenshot steps + FAILED-stepNN-.png # auto-captured on step failure +ui-dumps/.xml # from dumpUi steps +``` + +Quick pass/fail checks: + +```bash +jq '{passed, failed, failed_steps: [.steps[] | select(.status=="failed") | {index, action, message}]}' \ + com.microsoft.copilot.eclipse.swtbot.test/target/probe-results/results.json +``` + +```powershell +Get-Content com.microsoft.copilot.eclipse.swtbot.test/target/probe-results/results.json | + ConvertFrom-Json | Select-Object passed, failed, durationMs +``` + +Maven exit code `0` and `results.json` `.failed == 0` means pass. Otherwise, +open the failed step message, `FAILED-stepNN-*.png`, nearest `ui-dumps/*.xml`, +and `workspace.log`. Many "widget not found" failures are downstream of a +Copilot language server startup or authentication failure. + +## Authentication + +The Copilot JS agent reads its GitHub token from the host's standard Copilot +store (`%USERPROFILE%\AppData\Local\github-copilot\apps.json` on Windows; +`~/.config/github-copilot/apps.json` elsewhere). The Tycho JVM inherits it. + +- Signed-in host required for probes that exercise chat completion. +- To probe unauthenticated state, assert the "Sign in to GitHub" UI instead of + polling internal status managers. +- CI-side auth bootstrap is out of scope for this skill. + +## Actions + +Set `"failFast": false` on a step to record a failure without aborting the probe. + +| `action` | Required | Optional | Notes | +|---|---|---|---| +| `screenshot` | - | `id` | PNG of the active workbench window written to `screenshots/`. | +| `sleep` | - | `timeoutSec` default `1` | Plain `Thread.sleep`. Prefer waits over sleeps. | +| `waitForIdle` | - | - | Flushes the SWT event queue. | +| `pressKey` | `key` | `locator` | Supports `ENTER`, `RETURN`, `CR`, `ESC`, `ESCAPE`, `TAB`, `SPACE`, `BS`, `BACKSPACE`, `DELETE`. Uses SWTBot `pressShortcut`; with a text/styled-text locator it targets that widget, otherwise the active shell. | +| `showView` | `idRef` | - | Opens an Eclipse view via `IWorkbenchPage#showView`. | +| `closeView` | `idRef` | - | Hides the view if present. | +| `invokeCommand` | `idRef` | - | Runs a registered Eclipse command via `IHandlerService#executeCommand`. | +| `click` | `locator` | - | Focuses text/styled-text widgets; otherwise reflectively invokes `click()` on the matched widget. | +| `typeIn` | `locator`, `text` | - | Sets text on text/styled-text widgets. | +| `clearElement` | `locator` | - | Clears text/styled-text widgets. | +| `waitFor` | `locator` | `timeoutSec` default `30` | Polls until the locator resolves. | +| `waitForMethod` | `locator`, `method` | `timeoutSec` default `30`, `expectedValue` | Polls a no-arg getter on the located widget until it returns non-null/non-empty, or equals `expectedValue`. | +| `assertExists` | `locator` | `shouldExist` default `true` | Asserts presence or absence. | +| `dumpUi` | - | `id` | Writes shell widget hierarchy XML with class names and SWTBot widget IDs. | +| `newSession` | - | - | Copilot-specific shortcut for the `newChatSession` command. | + +## Locators + +Locators are JSON objects with a `by` discriminator. SWTBot is not XPath-based; +extend `Locator` and `StepExecutor` when the existing vocabulary is not enough. + +| `by` | Other fields | What it finds | +|---|---|---| +| `viewId` | `id` | Eclipse view by ID (`bot.viewById`). | +| `label` | `text` | First label with the given text. | +| `button` | `text` | First button with the given text. | +| `buttonWithTooltip` | `tooltip` | First button whose tooltip matches; useful for icon-only buttons like Send. | +| `text` | `index` default `0` | Nth text field. | +| `styledText` | - | First `StyledText`. | +| `tree` | `labels` array | Tree path under the active tree. | +| `cssId` | `value` | Widget whose `CssConstants.CSS_ID_KEY` data equals `value`. | +| `cssClass` | `value` | Widget whose `CssConstants.CSS_CLASS_NAME_KEY` contains `value` as a whitespace-separated token. | +| `widgetId` | `value` | Widget tagged with `setData("org.eclipse.swtbot.widget.key", value)`. Preferred for widgets you own. | +| `widgetClass` | `value` | First widget whose `getClass().getSimpleName()` equals `value`. Use only when you cannot tag the widget. | + +Stable IDs and signals currently used by probes: + +- `widgetId`: `user-turn`, `copilot-turn`, `model-picker`. +- `cssClass`: `model-info-label` appears only after a Copilot turn has completed. +- `cssId`: `chat-container`, `chat-content-wrapper`, `chat-content-viewer`, + `chat-action-bar-wrapper`, `chat-action-bar`, `chat-history-viewer`. + +## Troubleshooting + +| Symptom | Likely cause / fix | +|---|---| +| `IllegalArgumentException: Missing required field: ...` | Step JSON is missing a field required by that action. Check the action table. | +| `AssertionError: waitFor timed out: locator ...` | Widget did not appear. Add `dumpUi` before the failing step, confirm class/id, or raise `timeoutSec`. | +| `assertExists failed: ... shouldExist=true` | Locator did not match. Confirm the `by` type matches the widget family. | +| `click not supported on ` | Wrapper has no `click()` method. Use a more specific locator, such as `button` instead of `label`. | +| Tests skipped: "No probe script specified" | Pass `-Dprobe.script=probe-scripts/.json`. | +| `model-info-label` wait times out | Usually authentication or language-server startup. Inspect `workspace.log`. | +| All screenshots are blank or tiny identical PNGs | `Robot` capture failed. On macOS, grant Screen Recording permission to the JVM and re-run. | +| Widget-id markers missing from `dumpUi` | Stale bundle cache or stale module jar. Run root `./mvnw clean verify`. | + +## Extending the vocabulary + +Probe support lives in `com.microsoft.copilot.eclipse.swtbot.test/src/.../probe/`: + +- `ProbeStep.java` and `Locator.java`: JSON shape. +- `StepExecutor.java`: action dispatch, locator resolution, screenshots, dumps. +- `ProbeRunner.java`: runner loop, reporting, and workbench setup. + +Add UI-level primitives that mimic user behavior. Do not add actions that call +private production methods, reach into OSGi services, or mutate app state behind +the UI; those hide real UX bugs and break on internal refactors. diff --git a/.github/skills/ui-action/SKILL.md b/.github/skills/ui-action/SKILL.md index a586d100..5b4740cc 100644 --- a/.github/skills/ui-action/SKILL.md +++ b/.github/skills/ui-action/SKILL.md @@ -1,19 +1,18 @@ --- name: ui-action -description: Write or update a SWTBot JSON probe script (e.g. from a test plan), then validate it locally by running the `ProbeRunner` Tycho test against the Copilot for Eclipse plugin. Use this whenever you need end-to-end UI validation against a real Eclipse workbench. +description: Authors and validates SWTBot JSON probe scripts for GitHub Copilot for Eclipse UI flows against a real Eclipse workbench. Use when creating or updating probe-scripts, converting test plans to UI probes, validating end-to-end Eclipse UI behavior, or troubleshooting ProbeRunner failures. --- -# Authoring and Validating SWTBot Probe Scripts (Eclipse) +# SWTBot Probe Scripts -Use this skill to write or update a JSON probe script and validate it by -invoking the `com.microsoft.copilot.eclipse.swtbot.test` Tycho bundle locally. -Probe scripts are JSON, so no Java is compiled per test case. +Use this skill to create or update JSON probes for `com.microsoft.copilot.eclipse.swtbot.test`. +Probes drive the workbench through SWTBot actions and run as Tycho tests; no Java is compiled per test case. ## Quick start -1. Drop a probe at `com.microsoft.copilot.eclipse.swtbot.test/probe-scripts/-.json` - (e.g. `chat-send-receive-001.json`). Start every probe with this preamble that - settles the workbench and opens the Copilot Chat view: +1. Create one focused JSON script at + `com.microsoft.copilot.eclipse.swtbot.test/probe-scripts/-.json`. +2. Start by settling the workbench and opening the view under test: ```json [ @@ -25,275 +24,63 @@ Probe scripts are JSON, so no Java is compiled per test case. ] ``` -2. Run it (cross-platform command; use `./mvnw` on macOS/Linux): +3. Add user-level steps only, such as `click`, `typeIn`, `clearElement`, `pressKey`, + `invokeCommand`, `waitFor`, `waitForMethod`, `assertExists`, `screenshot`, or + `dumpUi`. Prefer `widgetId` locators for widgets the plugin owns. +4. Run from the repository root: - ```powershell + ```bash ./mvnw clean verify -Dprobe.script=probe-scripts/.json ``` - Root `clean verify` is the recommended default. Tycho prefers each - bundle's freshly-packaged jar in `/target/` and silently falls - back to the stale Maven cache if the jar is missing — making `setData` - markers and other source edits appear to be ignored. + Use root `clean verify` as the default. Tycho can reuse stale bundle jars + when only the SWTBot module is run, making source edits appear ignored. Use + `xvfb-run -a` on Linux headless. +5. Read `com.microsoft.copilot.eclipse.swtbot.test/target/probe-results/results.json`, + `workspace.log`, `screenshots/`, and `ui-dumps/`. - The narrower `./mvnw -pl com.microsoft.copilot.eclipse.swtbot.test -am - -Dprobe.script=... verify` shortcut is **experimental**: in practice - it can fail dependency resolution (observed: `Missing requirement: - com.microsoft.copilot.eclipse.core 0.0.0`, with Tycho choosing the - wrong `osgi.arch` on aarch64). Use only as a follow-up, after a green - root `clean verify`. +If `-Dprobe.script` is unset, `ProbeRunner` skips itself so ordinary `./mvnw verify` runs are unaffected. -3. Read results under `com.microsoft.copilot.eclipse.swtbot.test/target/probe-results/`: +## Authoring workflow - ``` - results.json # pass/fail summary - workspace.log # sandbox Eclipse .metadata/.log - screenshots/ - .png # from `screenshot` steps - FAILED-stepNN-.png # auto-captured on step failure - ui-dumps/.xml # from `dumpUi` steps - ``` - -If `-Dprobe.script` is unset the test is skipped, so ordinary `./mvnw verify` -runs are unaffected. - -> **Linux headless:** prefix with `xvfb-run -a`. **macOS:** the swtbot test -> pom carries a `swtbot-osx` profile that injects `-XstartOnFirstThread` -> into the test JVM via the `swtbot.platformArgLine` placeholder -> interpolated into ``. If you ever edit -> `com.microsoft.copilot.eclipse.swtbot.test/pom.xml`, **keep the -> `${swtbot.platformArgLine}` token in ``** — replacing it with a -> plain literal silently drops the macOS arg and the workbench fails with -> `SWTException: Invalid thread access`. **Stale cache recovery:** -> `Remove-Item -Recurse -Force $env:USERPROFILE\.m2\repository\com\microsoft\copilot\eclipse` -> then re-run `clean verify`. - -## How the runner works - -`ProbeRunner` is a JUnit 4 test Tycho launches under a real Eclipse workbench -(`useUIHarness=true`, `useUIThread=false`). It: - -1. Loads the JSON probe pointed at by `-Dprobe.script`. -2. Walks each step via `SWTWorkbenchBot`, wrapping every step in a uniform - try/catch so one failure doesn't crash the run. -3. Writes `results.json`, screenshots (PNG — `SWTBotPreferences.SCREENSHOT_FORMAT` - is pinned), and widget-tree dumps. -4. Fails the Maven build if any `failFast` step failed. - -Before the bot starts, the runner pre-populates configuration-scope -preferences so Quick Start, What's New, Welcome, and "Terminal Support -Unavailable" dialogs don't pop during a probe — probes see a clean workbench. - -`screenshot` and `dumpUi` capture from the **on-screen workbench shell**: -`StepExecutor#captureWorkbenchShell` resolves the active workbench -`Shell`'s bounds, then snapshots those screen pixels with -`java.awt.Robot.createScreenCapture` (with SWTBot's full-display -`bot.captureScreenshot` as a final fallback). SWT's own GC-based capture -(`new GC(display).copyArea(...)` and `Shell#print(GC)`) returns blank -images on macOS Cocoa for workbench shells — Robot is the workaround. -Caveats: - -- **macOS:** the JVM running the tests needs **Screen Recording** - permission (System Settings → Privacy & Security → Screen Recording). - Without it Robot returns blank or wallpaper-only frames. -- **Headless AWT:** `-Djava.awt.headless=true` makes `new Robot()` throw, - and the SWTBot fallback is also blank on Cocoa. Don't set it. -- **Windows HiDPI:** `Shell#getBounds()` returns DIP coordinates; Robot - expects raw screen pixels. At display scaling > 100% the captured - region is undersized / clipped. (At 100% — typical CI runners — it's - a pixel-perfect match.) - -## Authoring rules - -### Drive the UI, don't reach into services - -Probes **must** interact with the workbench the way a user does — SWTBot -clicks, typing, menu navigation, keyboard shortcuts, or `invokeCommand` -against a registered Eclipse command id (the same `IHandlerService#executeCommand` -path that menus and key bindings trigger — useful when an entry point is -buried in a popup like the Copilot status-bar menu, which is awkward to -drive with SWTBot). Do not add actions that reflectively invoke private -methods, call OSGi services to flip state, or read private fields; those -hide real UX bugs and break on every internal refactor. If a scenario needs -a new primitive (e.g. a chat-mode dropdown picker), extend the -action/locator vocabulary in `StepExecutor` with a UI-level primitive, -not a service shortcut. +- Convert one scenario/test case into one probe. Split unrelated behaviors so each failure screenshot has one meaning. +- Drive the UI the way a user does. `invokeCommand` is allowed for registered Eclipse command IDs; do not call + OSGi services, private methods, or test-only shortcuts. +- Pair screenshots with machine-checked assertions. Screenshots never fail a step; `assertExists`, `waitFor`, and + `waitForMethod` decide pass/fail. +- Wait for asynchronous UI state before acting. For chat, wait for the `model-picker` selected model before Send, + then wait for `user-turn` and `model-info-label`. +- When a locator fails, insert `dumpUi` before the failure and inspect `ui-dumps/*.xml` for widget class names and + SWTBot widget IDs. +- For unauthenticated chat probes, assert the "Sign in to GitHub" UI. Do not poll internal auth managers. -### Tag widgets for `widgetId` locators - -SWTBot's blessed identification convention is -`widget.setData("org.eclipse.swtbot.widget.key", "")` at widget -construction, located from tests with the `widgetId` locator. Zero runtime -cost, far more robust than class-name or creation-order lookup. Prefer -`widgetId` over `widgetClass` whenever you control the widget's construction; -use `widgetClass` only for platform / third-party widgets you can't tag. - -Current tags: `user-turn` (`UserTurnWidget`), `copilot-turn` -(`CopilotTurnWidget`), `model-picker` (chat-model `DropdownButton`). - -### Screenshots are artifacts; assertions catch regressions - -`screenshot` never fails a step. Validation has two layers: - -- **Structural** (machine-checked): `assertExists`, `waitFor`, specific - locators. These failures land in `results.json` and drive the build exit - code — the agent judges pass/fail from here. -- **Visual** (agent-checked): after the run, open the PNGs under - `screenshots/` with your vision tool and describe what you see. No - baseline-image comparison in JSON (cross-platform font rendering makes - pixel diffs flaky). - -Pair screenshots with assertions around every non-trivial interaction: +## Chat send skeleton ```json -{ "action": "screenshot", "id": "before-send" }, -{ "action": "click", "locator": { "by": "buttonWithTooltip", "tooltip": "Send" } }, -{ "action": "waitForIdle" }, -{ "action": "assertExists", "locator": { "by": "widgetId", "value": "user-turn" } }, -{ "action": "screenshot", "id": "after-send" } +[ + { "action": "waitForIdle" }, + { "action": "showView", "idRef": "com.microsoft.copilot.eclipse.ui.chat.ChatView" }, + { "action": "waitFor", "locator": { "by": "styledText" }, "timeoutSec": 30 }, + { "action": "clearElement", "locator": { "by": "styledText" } }, + { "action": "typeIn", "locator": { "by": "styledText" }, "text": "hello" }, + { "action": "waitForMethod", "locator": { "by": "widgetId", "value": "model-picker" }, + "method": "getSelectedItemId", "timeoutSec": 60 }, + { "action": "click", "locator": { "by": "buttonWithTooltip", "tooltip": "Send" } }, + { "action": "waitFor", "locator": { "by": "widgetId", "value": "user-turn" }, "timeoutSec": 30 }, + { "action": "waitFor", "locator": { "by": "cssClass", "value": "model-info-label" }, "timeoutSec": 120 }, + { "action": "assertExists", "locator": { "by": "widgetId", "value": "copilot-turn" } }, + { "action": "screenshot", "id": "agent-response" } +] ``` -When a locator is wrong, insert a `dumpUi` step and inspect -`ui-dumps/*.xml` to find the real widget class / id. - -### Authentication prerequisite - -The Copilot JS agent reads its GitHub token from the host's standard Copilot -store (`%USERPROFILE%\AppData\Local\github-copilot\apps.json` on Windows; -`~/.config/github-copilot/apps.json` elsewhere) — the Tycho JVM inherits it. - -- **Signed-in host required** for any probe that exercises chat. Sign in via - any Copilot client (Eclipse plugin, VS Code, `gh auth login`). -- **Probing unauthed state:** assert on the "Sign in to GitHub" button that - replaces the chat input; don't poll internal status managers. -- CI-side auth bootstrap is out of scope for this skill. - -## Reading results & interpreting failures - -Maven exit code `0` **and** `results.json` `.failed == 0` → overall pass. -Otherwise open the failed step's `message`, its `FAILED-stepNN-*.png`, the -nearest `ui-dumps/*.xml`, **and** `workspace.log` — many "widget not found" -failures are downstream of a Copilot LS that never started or authenticated. -Look for `!ENTRY com.microsoft.copilot.eclipse` entries and NPEs on -`CopilotLanguageServer.*`. - -| Symptom | What's wrong / fix | -|---|---| -| `IllegalArgumentException: Missing required field: …` | Step JSON missing a field required by that action (see action table). | -| `AssertionError: waitFor timed out: locator …` | Widget not appearing. Add a `dumpUi` before the failing step, check class/id, or raise `timeoutSec`. | -| `assertExists failed: … shouldExist=true` | Locator didn't match. Confirm `by` matches the widget family. | -| `click not supported on ` | Wrapper has no `click()`; use the right `by` (e.g. `button`, not `label`). | -| Tests skipped: "No probe script specified" | Pass `-Dprobe.script=probe-scripts/.json`. | -| `model-info-label` wait times out | Usually auth; open `workspace.log`. | -| All screenshots are blank / identical ~4KB PNGs across every step | Robot capture failed. macOS: grant Screen Recording permission to the JVM and re-run. Verify by file size — real workbench frames are 50KB+; a uniform 4KB family means the on-screen capture path didn't run or returned empty pixels. | -| Widget-id markers missing from `dumpUi` | Stale Maven cache — run full `clean verify` (see Quick start). | - -Quick pass/fail check: - -```powershell -Get-Content com.microsoft.copilot.eclipse.swtbot.test/target/probe-results/results.json | - ConvertFrom-Json | Select-Object passed, failed, durationMs -``` - -```bash -jq '{passed, failed, failed_steps: [.steps[] | select(.status=="failed") | {index, action, message}]}' \ - com.microsoft.copilot.eclipse.swtbot.test/target/probe-results/results.json -``` - -## Action reference - -Set `"failFast": false` on a step to record a failure without aborting the probe. - -| `action` | Required | Optional | Notes | -|---|---|---|---| -| `screenshot` | — | `id` | PNG of the active workbench window written to `screenshots/`. | -| `sleep` | — | `timeoutSec` (default 1) | Plain `Thread.sleep`. | -| `waitForIdle` | — | — | Flushes the SWT event queue. | -| `pressKey` | `key` | `locator` | Accepts `ENTER`/`CR`, `ESC`/`ESCAPE`, `TAB`, `SPACE`, `BS`/`BACKSPACE`, `DELETE`. Without `locator`, presses on the active shell; with a `locator` targeting a text / styledText widget, sends the key directly (needed for chat input ENTER-to-send). | -| `showView` | `idRef` (Eclipse view id) | — | Opens via `IWorkbenchPage#showView`. | -| `closeView` | `idRef` | — | Hides the view if present. | -| `invokeCommand` | `idRef` (command id) | — | Runs via `IHandlerService#executeCommand`. | -| `click` | `locator` | — | Reflective `click()` on matched widget. | -| `typeIn` | `locator`, `text` | — | Works on text / styled-text widgets. | -| `clearElement` | `locator` | — | Sets text to empty. | -| `waitFor` | `locator` | `timeoutSec` (default 30) | Polls until locator resolves. | -| `waitForMethod` | `locator`, `method` | `timeoutSec` (default 30), `expectedValue` | Polls until a no-arg getter on the located widget returns non-null (and non-empty for `String`), or — if `expectedValue` is set — until `toString()` equals it. Invoked reflectively via the class hierarchy. Use for UI-exposed state not reachable via finders, e.g. `DropdownButton.getSelectedItemId()`. | -| `assertExists` | `locator` | `shouldExist` (default true) | Asserts presence / absence. | -| `dumpUi` | — | `id` | Writes widget hierarchy XML. | -| `newSession` | — | — | Copilot-specific: triggers `newChatSession` command. | - -## Locator reference - -Locators are JSON objects with a `by` discriminator. SWTBot is **not -XPath-based** — stick to this vocabulary; extend `Locator` / `StepExecutor` -if you need a new finder. - -| `by` | Other fields | What it finds | -|---|---|---| -| `viewId` | `id` | Eclipse view by id (`bot.viewById`). | -| `label` | `text` | First label with the given text. | -| `button` | `text` | First button with the given text. | -| `buttonWithTooltip` | `tooltip` | First button whose tooltip matches (use for icon-only buttons like **Send**). | -| `text` | `index` (default 0) | Nth text field. | -| `styledText` | — | First StyledText (editors / chat input). | -| `tree` | `labels` (array) | Tree path under the active tree. | -| `cssId` | `value` | Widget whose `CssConstants.CSS_ID_KEY` equals `value` (e.g. `chat-content-viewer`, `chat-action-bar`). Walks the full shell tree. | -| `cssClass` | `value` | Widget whose `CssConstants.CSS_CLASS_NAME_KEY` contains `value` as a token. `model-info-label` is only set on a **completed** Copilot turn, so waiting on it is a reliable "response received" signal. | -| `widgetId` | `value` | Widget tagged with `setData("org.eclipse.swtbot.widget.key", value)`. **Preferred** identifier for widgets you own. | -| `widgetClass` | `value` | First widget whose `getClass().getSimpleName()` equals `value` (walks the full shell tree). Fallback for widgets you can't tag. | - -## Canonical example: send a prompt and verify a response - -```json -{ "action": "clearElement", "locator": { "by": "styledText" } }, -{ "action": "typeIn", "locator": { "by": "styledText" }, "text": "your prompt" }, -{ "action": "screenshot", "id": "typed" }, - -{ "action": "waitForMethod", - "locator": { "by": "widgetId", "value": "model-picker" }, - "method": "getSelectedItemId", - "timeoutSec": 60 }, - -{ "action": "click", "locator": { "by": "buttonWithTooltip", "tooltip": "Send" } }, - -{ "action": "waitFor", "locator": { "by": "widgetId", "value": "user-turn" }, - "timeoutSec": 30 }, -{ "action": "assertExists", "locator": { "by": "widgetId", "value": "user-turn" } }, - -{ "action": "waitFor", "locator": { "by": "cssClass", "value": "model-info-label" }, - "timeoutSec": 120 }, -{ "action": "screenshot", "id": "agent-response" }, -{ "action": "assertExists", "locator": { "by": "widgetId", "value": "copilot-turn" } } -``` - -Key signals: - -- `model-picker.getSelectedItemId()` non-null — the workbench has resolved an - active chat model. Sending before this NPEs in `onSendInternal` with - "activeModel is null" because auth + model fetch complete asynchronously. -- `user-turn` — the user turn has rendered (Send button dispatched the prompt). -- `model-info-label` — only appears once a Copilot turn has **completed** - (rendered in the turn's footer at end of streaming); the reliable "response - fully received" signal. -- `copilot-turn` — the assistant turn container; good secondary assertion. - -Without a signed-in host the language server can't complete a turn, so -`model-info-label` never appears and the step times out — itself a useful -diagnostic (check `workspace.log`). - -## Extending the vocabulary - -Everything the probe understands lives in -`com.microsoft.copilot.eclipse.swtbot.test/src/.../probe/`: +## Pass/fail judgment -- `ProbeStep.java` / `Locator.java` — JSON shape. -- `StepExecutor.java` — action dispatch + locator resolution. -- `ProbeRunner.java` — runner loop, reporting, screenshots. +A probe passes only when Maven exits `0` and `results.json` has `"failed": 0`. +On failure, inspect the failed step message, `FAILED-stepNN-*.png`, nearest `ui-dumps/*.xml`, and `workspace.log`. -Add a case to the `switch` in `StepExecutor#execute` (or `resolve`) and the new -action is usable from every probe. +## Extending probes -### Keep one probe focused +The vocabulary lives in `com.microsoft.copilot.eclipse.swtbot.test/src/.../probe/`. +Add UI-level primitives in `ProbeStep`, `Locator`, and `StepExecutor`, then update [REFERENCE.md](REFERENCE.md). -Each JSON script represents one test case. Split unrelated behaviours into -separate probes so a single `FAILED-step…` screenshot tells you what broke. \ No newline at end of file +See [REFERENCE.md](REFERENCE.md) for action/locator tables, platform notes, result queries, troubleshooting, and current stable widget IDs. diff --git a/com.microsoft.copilot.eclipse.core.test/pom.xml b/com.microsoft.copilot.eclipse.core.test/pom.xml index 3231acca..dcc3fca9 100644 --- a/com.microsoft.copilot.eclipse.core.test/pom.xml +++ b/com.microsoft.copilot.eclipse.core.test/pom.xml @@ -14,4 +14,27 @@ true - \ No newline at end of file + + + + skip-tests-during-ui-probe + + + probe.script + + + + + + org.eclipse.tycho + tycho-surefire-plugin + ${tycho-version} + + true + + + + + + + diff --git a/com.microsoft.copilot.eclipse.ui.test/pom.xml b/com.microsoft.copilot.eclipse.ui.test/pom.xml index 2514c9b6..d6296186 100644 --- a/com.microsoft.copilot.eclipse.ui.test/pom.xml +++ b/com.microsoft.copilot.eclipse.ui.test/pom.xml @@ -15,6 +15,29 @@ true + + + skip-tests-during-ui-probe + + + probe.script + + + + + + org.eclipse.tycho + tycho-surefire-plugin + ${tycho-version} + + true + + + + + + + From 645bf6c5896a1e9d3132aefd50d93aab61e23ec6 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Tue, 30 Jun 2026 12:30:20 +0800 Subject: [PATCH 20/27] fix - reduce streaming render cost with incremental layout and event batching (#321) --- .../eclipse/ui/chat/BaseTurnWidget.java | 4 +- .../eclipse/ui/chat/ChatContentViewer.java | 407 ++++++++++++------ .../copilot/eclipse/ui/chat/ChatView.java | 22 +- .../eclipse/ui/chat/ThinkingBlock.java | 3 +- .../ui/chat/services/AgentToolService.java | 2 +- 5 files changed, 290 insertions(+), 148 deletions(-) diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java index 45557687..1218ce5a 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java @@ -654,8 +654,8 @@ public CompletableFuture requestToolExecuti this.confirmDialog.addDisposeListener(e -> { Composite ancestor = this.getParent(); while (ancestor != null && !ancestor.isDisposed()) { - if (ancestor instanceof ChatContentViewer) { - ((ChatContentViewer) ancestor).requestRefreshScrollerLayout(); + if (ancestor instanceof ChatContentViewer viewer) { + SwtUtils.invokeOnDisplayThreadAsync(() -> viewer.refreshLayoutFull(), viewer); break; } ancestor = ancestor.getParent(); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java index 86a0bbf7..7188df57 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java @@ -7,7 +7,10 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Queue; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Pattern; @@ -23,6 +26,7 @@ import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.ScrollBar; import org.eclipse.ui.PlatformUI; @@ -70,9 +74,18 @@ public class ChatContentViewer extends ScrolledComposite { private BaseTurnWidget latestUserTurn; private CopilotTurnWidget latestCopilotTurn; private BaseTurnWidget latestTurnWidget; - // Auto-scroll state management private boolean autoScrollEnabled; + /** Streaming events queued by LSP threads and drained on the UI thread in batches. */ + private final Queue pendingEvents = new ConcurrentLinkedQueue<>(); + private final AtomicBoolean drainScheduled = new AtomicBoolean(false); + + /** Client-area width of the last layout pass; a change forces a full re-measure (text re-wraps). */ + private int lastLayoutWidth = -1; + + /** Guards against scheduling more than one pending async refresh from a burst of resize events. */ + private final AtomicBoolean refreshScheduled = new AtomicBoolean(false); + /** * Create the composite. * @@ -98,7 +111,9 @@ public ChatContentViewer(Composite parent, int style, ChatServiceManager service this.addControlListener(new ControlAdapter() { @Override public void controlResized(ControlEvent e) { - refreshScrollerLayout(); + // setMinHeight re-fires controlResized; coalesce into one async incremental pass so the + // resize handler never recurses synchronously into layout. + coalesceAsync(refreshScheduled, ChatContentViewer.this::refreshLayoutIncremental); } }); @@ -135,7 +150,7 @@ public void startNewTurn(String workDoneToken, String message) { turnWidget.appendMessage(message); turnWidget.flushMessageBuffer(); - refreshScrollerLayout(); + refreshLayoutFull(); scrollToLatestUserTurn(); // Reset auto-scroll for new conversation turn autoScrollEnabled = true; @@ -186,123 +201,140 @@ public void setConversationId(String conversationId) { } /** - * Process turn event. + * Process turn event. Events are queued and drained in batches on the UI thread so the LSP thread + * is never blocked and multiple in-flight events coalesce into a single layout pass. */ public void processTurnEvent(ChatProgressValue value) { - SwtUtils.invokeOnDisplayThread(() -> { - if (!turns.containsKey(value.getTurnId())) { - CopilotCore.LOGGER.error(new IllegalStateException("turnId not found: " + value.getTurnId())); - return; - } - BaseTurnWidget turnWidget = turns.get(value.getTurnId()); - if (turnWidget == null) { - appendMessageToTheLatestTurn(value.getReply()); - } - - ChatServiceManager chatServiceManager = CopilotUi.getPlugin().getChatServiceManager(); - - if (value.getKind() == WorkDoneProgressKind.report) { - if (turnWidget instanceof ThinkingTurnWidget thinkingTurn) { - thinkingTurn.setConversationContext(conversationId, value.getTurnId()); - thinkingTurn.appendThinking(value.getThinking()); - updateActiveThinkingBlockId(value.getTurnId(), thinkingTurn); - if (hasRenderableOutput(value)) { - // Seal before appending the reply so the spinner stops and the title is fetched. - thinkingTurn.sealThinking(); - } - } + pendingEvents.offer(value); + coalesceAsync(drainScheduled, this::drainPendingEvents); + } - if (value.getAgentRounds() != null && !value.getAgentRounds().isEmpty()) { - // Handle agent mode responses - AgentRound agentRound = value.getAgentRounds().get(0); + private void drainPendingEvents() { + if (isDisposed()) { + pendingEvents.clear(); + return; + } + ChatProgressValue event; + while ((event = pendingEvents.poll()) != null) { + doProcessTurnEvent(event); + } + refreshLayoutIncremental(); + scrollToBottomIfAutoScroll(); + // Events may have arrived while draining; schedule a follow-up drain if so. + if (!pendingEvents.isEmpty()) { + coalesceAsync(drainScheduled, this::drainPendingEvents); + } + } - if (agentRound.getReply() != null) { - turnWidget.appendMessage(agentRound.getReply()); - } + private void doProcessTurnEvent(ChatProgressValue value) { + if (!turns.containsKey(value.getTurnId())) { + CopilotCore.LOGGER.error(new IllegalStateException("turnId not found: " + value.getTurnId())); + return; + } + BaseTurnWidget turnWidget = turns.get(value.getTurnId()); + if (turnWidget == null) { + CopilotCore.LOGGER.error(new IllegalStateException("turnWidget not found: " + value.getTurnId())); + appendMessageToTheLatestTurn(value.getReply()); + return; + } - if (agentRound.getToolCalls() != null && !agentRound.getToolCalls().isEmpty()) { - AgentToolCall toolCall = agentRound.getToolCalls().get(0); - turnWidget.appendToolCallStatus(toolCall); + ChatServiceManager chatServiceManager = CopilotUi.getPlugin().getChatServiceManager(); - // Extract and process todo list from tool result details - processTodoListFromToolCall(chatServiceManager, value.getConversationId(), toolCall); - } - } else { - // Handle chat mode responses - turnWidget.appendMessage(value.getReply()); - } - } else if (value.getKind() == WorkDoneProgressKind.end) { - // Seal any in-progress thinking block before the turn ends. - if (turnWidget instanceof ThinkingTurnWidget thinkingTurn) { + if (value.getKind() == WorkDoneProgressKind.report) { + if (turnWidget instanceof ThinkingTurnWidget thinkingTurn) { + thinkingTurn.setConversationContext(conversationId, value.getTurnId()); + thinkingTurn.appendThinking(value.getThinking()); + updateActiveThinkingBlockId(value.getTurnId(), thinkingTurn); + if (hasRenderableOutput(value)) { + // Seal before appending the reply so the spinner stops and the title is fetched. thinkingTurn.sealThinking(); - updateActiveThinkingBlockId(value.getTurnId(), thinkingTurn); } - turnWidget.flushMessageBuffer(); } - refreshScrollerLayout(); - // Auto-scroll to bottom if enabled - if (shouldAutoScrollToBottom()) { - scrollToBottom(); - } + if (value.getAgentRounds() != null && !value.getAgentRounds().isEmpty()) { + // Handle agent mode responses + AgentRound agentRound = value.getAgentRounds().get(0); + + if (agentRound.getReply() != null) { + turnWidget.appendMessage(agentRound.getReply()); + } + + if (agentRound.getToolCalls() != null && !agentRound.getToolCalls().isEmpty()) { + AgentToolCall toolCall = agentRound.getToolCalls().get(0); + turnWidget.appendToolCallStatus(toolCall); - String errMsg = value.getErrorMessage(); - if (StringUtils.isNotEmpty(errMsg)) { - errMsg = REQUEST_ID_SUFFIX.matcher(errMsg).replaceAll(StringUtils.EMPTY).trim(); + // Extract and process todo list from tool result details + processTodoListFromToolCall(chatServiceManager, value.getConversationId(), toolCall); + } + } else { + // Handle chat mode responses + turnWidget.appendMessage(value.getReply()); } - String reason = value.getErrorReason(); - if (StringUtils.isNotEmpty(reason) && reason.equals("model_not_supported")) { - // TODO: add enable button for better UX. - errMsg = Messages.chat_model_unsupported_message; + } else if (value.getKind() == WorkDoneProgressKind.end) { + // Seal any in-progress thinking block before the turn ends. + if (turnWidget instanceof ThinkingTurnWidget thinkingTurn) { + thinkingTurn.sealThinking(); + updateActiveThinkingBlockId(value.getTurnId(), thinkingTurn); } - if (StringUtils.isNotEmpty(errMsg)) { - // TODO: Remove this legacy fallback after TBB is officially released. - // When the language server has not enabled token-based billing yet, fall back to the - // original main-branch 402 behavior: replace the message with a plan-driven fallback - // notice, switch to the fallback model, refresh quota, and replay the previous input. - CheckQuotaResult quotaStatus = this.serviceManager.getAuthStatusManager().getQuotaStatus(); - CopilotModel fallbackModel = null; - if (!quotaStatus.tokenBasedBillingEnabled() && value.getCode() == 402) { - CopilotPlan userPlan = quotaStatus.copilotPlan(); - fallbackModel = this.serviceManager.getModelService().getFallbackModel(); - String fallbackModelName = fallbackModel != null ? fallbackModel.getModelName() - : Messages.chat_noQuotaView_fallbackModel; - - if (MenuUtils.isCfiPlan(userPlan)) { - // Pro, Pro+ and Max message - errMsg = String.format(Messages.chat_noQuotaView_proProplusWarnMsg, fallbackModelName); - } else if (userPlan == CopilotPlan.business || userPlan == CopilotPlan.enterprise) { - // CE and CB message - errMsg = String.format(Messages.chat_noQuotaView_cbCeWarnMsg, fallbackModelName); - } + turnWidget.flushMessageBuffer(); + } + + String errMsg = value.getErrorMessage(); + if (StringUtils.isNotEmpty(errMsg)) { + errMsg = REQUEST_ID_SUFFIX.matcher(errMsg).replaceAll(StringUtils.EMPTY).trim(); + } + String reason = value.getErrorReason(); + if (StringUtils.isNotEmpty(reason) && reason.equals("model_not_supported")) { + // TODO: add enable button for better UX. + errMsg = Messages.chat_model_unsupported_message; + } + if (StringUtils.isNotEmpty(errMsg)) { + // TODO: Remove this legacy fallback after TBB is officially released. + // When the language server has not enabled token-based billing yet, fall back to the + // original main-branch 402 behavior: replace the message with a plan-driven fallback + // notice, switch to the fallback model, refresh quota, and replay the previous input. + CheckQuotaResult quotaStatus = this.serviceManager.getAuthStatusManager().getQuotaStatus(); + CopilotModel fallbackModel = null; + if (!quotaStatus.tokenBasedBillingEnabled() && value.getCode() == 402) { + CopilotPlan userPlan = quotaStatus.copilotPlan(); + fallbackModel = this.serviceManager.getModelService().getFallbackModel(); + String fallbackModelName = fallbackModel != null ? fallbackModel.getModelName() + : Messages.chat_noQuotaView_fallbackModel; + + if (MenuUtils.isCfiPlan(userPlan)) { + // Pro, Pro+ and Max message + errMsg = String.format(Messages.chat_noQuotaView_proProplusWarnMsg, fallbackModelName); + } else if (userPlan == CopilotPlan.business || userPlan == CopilotPlan.enterprise) { + // CE and CB message + errMsg = String.format(Messages.chat_noQuotaView_cbCeWarnMsg, fallbackModelName); } + } - renderWarnMessageWithUpgradePlanButton(errMsg, value.getCode(), value.getErrorModelProviderName()); - - // TODO: Remove this legacy fallback after TBB is officially released. - // Only replay the previous input when a fallback model is actually available; otherwise - // setFallBackModelAsActiveModel() is a no-op and re-posting the input with the same - // active model would just trigger the same 402 again. - if (!quotaStatus.tokenBasedBillingEnabled() && value.getCode() == 402 - && quotaStatus.copilotPlan() != CopilotPlan.free - && fallbackModel != null) { - // Detach the failed turn so the replayed response creates a new Copilot turn below the - // warning, instead of streaming into the same turn that just rendered the warn widget. - this.latestTurnWidget = null; - this.latestCopilotTurn = null; - - this.serviceManager.getModelService().setFallBackModelAsActiveModel(); - this.serviceManager.getAuthStatusManager().checkQuota(); - - String previousInput = this.serviceManager.getUserPreferenceService().getPreviousInput(StringUtils.EMPTY); - if (StringUtils.isNotEmpty(previousInput)) { - IEventBroker eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); - Map properties = Map.of("previousInput", previousInput, "needCreateUserTurn", false); - eventBroker.post(CopilotEventConstants.TOPIC_CHAT_ON_SEND, properties); - } + renderWarnMessageWithUpgradePlanButton(errMsg, value.getCode(), value.getErrorModelProviderName()); + + // TODO: Remove this legacy fallback after TBB is officially released. + // Only replay the previous input when a fallback model is actually available; otherwise + // setFallBackModelAsActiveModel() is a no-op and re-posting the input with the same + // active model would just trigger the same 402 again. + if (!quotaStatus.tokenBasedBillingEnabled() && value.getCode() == 402 + && quotaStatus.copilotPlan() != CopilotPlan.free + && fallbackModel != null) { + // Detach the failed turn so the replayed response creates a new Copilot turn below the + // warning, instead of streaming into the same turn that just rendered the warn widget. + this.latestTurnWidget = null; + this.latestCopilotTurn = null; + + this.serviceManager.getModelService().setFallBackModelAsActiveModel(); + this.serviceManager.getAuthStatusManager().checkQuota(); + + String previousInput = this.serviceManager.getUserPreferenceService().getPreviousInput(StringUtils.EMPTY); + if (StringUtils.isNotEmpty(previousInput)) { + IEventBroker eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); + Map properties = Map.of("previousInput", previousInput, "needCreateUserTurn", false); + eventBroker.post(CopilotEventConstants.TOPIC_CHAT_ON_SEND, properties); } } - }, this); + } } /** Returns the active thinking block ID last observed while processing this turn's progress. */ @@ -393,7 +425,8 @@ public void showCompactingStatusOnLatestCopilotTurn() { // the next round's reply and produce a single garbled line. latestCopilotTurn.flushMessageBuffer(); latestCopilotTurn.showCompactingStatus(); - refreshScrollerLayout(); + refreshLayoutFull(); + scrollToBottomIfAutoScroll(); } /** @@ -408,7 +441,8 @@ public void hideCompactingStatusOnLatestCopilotTurn() { // in case a cancel path did not receive an end progress event to flush it. latestCopilotTurn.flushMessageBuffer(); latestCopilotTurn.hideCompactingStatus(); - refreshScrollerLayout(); + refreshLayoutFull(); + scrollToBottomIfAutoScroll(); } /** @@ -420,7 +454,7 @@ public BaseTurnWidget getTurnWidget(String turnId) { private void renderWarnMessageWithUpgradePlanButton(String errorMessage, int code, String modelProviderName) { latestTurnWidget.createWarnDialog(errorMessage, code, modelProviderName); - refreshScrollerLayout(); + refreshLayoutFull(); scrollToLatestUserTurn(); } @@ -432,28 +466,78 @@ public void renderErrorMessage(String errorMessage) { this.errorWidget.dispose(); } this.errorWidget = new ErrorWidget(cmpContent, SWT.BOTTOM, errorMessage); - refreshScrollerLayout(); + refreshLayoutFull(); scrollToLatestUserTurn(); } /** - * Schedules a single async {@link #refreshScrollerLayout()} call so that multiple dispose/layout - * events that arrive in the same event-loop tick are coalesced into one pass. + * Coalesces a burst of calls into a single async pass on the UI thread. While a pass is already + * scheduled, further calls are dropped; {@code scheduled} is cleared right before {@code task} runs + * so work arriving during the task re-schedules a follow-up pass. This breaks synchronous + * re-entrancy (e.g. a layout pass writing the scroller min size re-fires {@code controlResized}) + * without a re-entrancy guard, while idempotent writes still converge to a fixed point. + * + * @param scheduled the per-task latch guarding against duplicate scheduling + * @param task the work to run once on the next UI-thread turn + */ + private void coalesceAsync(AtomicBoolean scheduled, Runnable task) { + if (scheduled.compareAndSet(false, true)) { + SwtUtils.invokeOnDisplayThreadAsync(() -> { + scheduled.set(false); + task.run(); + }, this); + } + } + + /** + * Full re-measure entry point for external callers. Layout only; scrolling is a separate concern + * handled by callers via {@link #scrollToBottomIfAutoScroll()}. + */ + public void refreshLayoutFull() { + refreshLayout(MeasureMode.FULL); + } + + /** + * Incremental re-measure of just the trailing (streaming) turns. Layout only; scrolling is handled + * separately by callers via {@link #scrollToBottomIfAutoScroll()}. */ - public void requestRefreshScrollerLayout() { - SwtUtils.invokeOnDisplayThreadAsync(() -> refreshScrollerLayout(), this); + private void refreshLayoutIncremental() { + refreshLayout(MeasureMode.INCREMENTAL); } /** - * Update the size of scrolled composite when there are content updates. + * Selects how many turns {@link #refreshLayout(MeasureMode)} re-measures. + */ + private enum MeasureMode { + /** Recursively re-measure every turn. O(n) in the number of turns. */ + FULL, + /** Only flush the trailing (mutating) turns; sealed turns keep cached sizes. O(1). */ + INCREMENTAL + } + + /** + * Re-measure the scroller and update its min size. + * + * @param mode {@link MeasureMode#FULL} recursively re-measures every turn; {@link + * MeasureMode#INCREMENTAL} only flushes the trailing (mutating) turns while sealed turns keep + * cached sizes, keeping the pass O(1). A width change always upgrades to a full measure because + * text re-wraps. */ - public void refreshScrollerLayout() { + private void refreshLayout(MeasureMode mode) { if (this.isDisposed()) { return; } - Rectangle clientArea = this.getClientArea(); - Point containerSize = cmpContent.computeSize(clientArea.width, SWT.DEFAULT); + int width = clientArea.width; + boolean fullMeasure = mode == MeasureMode.FULL || width != lastLayoutWidth; + lastLayoutWidth = width; + + if (!fullMeasure) { + // Only the trailing turns can grow/change during streaming. + flushTrailingTurnCaches(); + } + + Point containerSize = cmpContent.computeSize(width, SWT.DEFAULT, fullMeasure); // Use the default size as a fallback if (latestUserTurn == null) { @@ -461,22 +545,68 @@ public void refreshScrollerLayout() { return; } - Point userTurnSize = latestUserTurn.computeSize(SWT.DEFAULT, SWT.DEFAULT); - Point copilotTurnSize = latestCopilotTurn == null ? new Point(0, 0) - : latestCopilotTurn.computeSize(SWT.DEFAULT, SWT.DEFAULT); + // Measure at the actual column width, not SWT.DEFAULT: unconstrained width collapses + // soft-wrapped text to a single line and under-estimates the height. roundedHeight must match + // the laid-out height shouldAutoScrollToBottom() reads via getBounds(), or the padding branch + // below reserves phantom whitespace while auto-scroll fires into it (issue #259 flicker). + int userTurnHeight = latestUserTurn.computeSize(width, SWT.DEFAULT).y; + int copilotTurnHeight = latestCopilotTurn == null || latestCopilotTurn.isDisposed() ? 0 + : latestCopilotTurn.computeSize(width, SWT.DEFAULT).y; // Calculate the content height, so that the latest user turn is able to be put at the top of the client area. int contentHeight = 0; - int roundedHeight = userTurnSize.y + copilotTurnSize.y; + int roundedHeight = userTurnHeight + copilotTurnHeight; if (roundedHeight < clientArea.height) { contentHeight = clientArea.height + containerSize.y - roundedHeight; } else { contentHeight = containerSize.y; } - this.setMinHeight(contentHeight); - this.setMinWidth(containerSize.x); - this.layout(true, true); + // Only write min size when it changes: setMin* re-fires controlResized, so skipping no-op writes + // lets the coalesced async refresh converge to a fixed point. + if (this.getMinHeight() != contentHeight) { + this.setMinHeight(contentHeight); + } + if (this.getMinWidth() != containerSize.x) { + this.setMinWidth(containerSize.x); + } + // Incremental layout: only re-position the latest (growing) copilot turn instead of recursing + // into all past turns as conversations grow longer. + if (latestCopilotTurn != null && !latestCopilotTurn.isDisposed()) { + cmpContent.layout(new Control[] {latestCopilotTurn}); + } else { + cmpContent.layout(true, false); + } + this.layout(true, false); + } + + /** + * Scrolls the viewport to the bottom when auto-scroll is currently enabled. Kept separate from the + * layout pass so refresh and scroll stay independent concerns; callers invoke this after a refresh + * when they want the latest content to stay in view. + */ + public void scrollToBottomIfAutoScroll() { + if (shouldAutoScrollToBottom()) { + scrollToBottom(); + } + } + + /** + * Flushes the cached layout sizes of the trailing (mutating) turns so the next + * {@code computeSize(width, DEFAULT, false)} re-measures them while sealed historical turns stay + * cached, keeping the layout pass O(1) in the number of historical turns. + */ + private void flushTrailingTurnCaches() { + List dirty = new ArrayList<>(2); + if (latestUserTurn != null && !latestUserTurn.isDisposed()) { + dirty.add(latestUserTurn); + } + if (latestCopilotTurn != null && !latestCopilotTurn.isDisposed()) { + dirty.add(latestCopilotTurn); + } + if (!dirty.isEmpty()) { + cmpContent.layout(dirty.toArray(new Control[0])); + } } /** @@ -493,16 +623,34 @@ private boolean shouldAutoScrollToBottom() { } Rectangle clientArea = this.getClientArea(); - Point userTurnSize = latestUserTurn.computeSize(SWT.DEFAULT, SWT.DEFAULT); - Point copilotTurnSize = latestCopilotTurn == null ? new Point(0, 0) - : latestCopilotTurn.computeSize(SWT.DEFAULT, SWT.DEFAULT); - - int roundedHeight = userTurnSize.y + copilotTurnSize.y; + // Use the freshly laid-out bounds rather than computeSize(): the incremental streaming pass + // repositions the trailing turns but does not flush their computeSize cache, so computeSize + // would return a stale height and the auto-scroll trigger would fire seconds too late. + // getBounds() reflects the just-applied layout. + int roundedHeight = currentTurnLaidOutHeight(); // Only auto-scroll when content height exceeds the visible area return roundedHeight >= clientArea.height; } + /** + * Returns the height of the latest (streaming) turn from its applied layout bounds. Falls back to + * {@code computeSize} only when bounds are not yet available (before the first layout pass). + */ + private int currentTurnLaidOutHeight() { + int height = 0; + if (latestUserTurn != null && !latestUserTurn.isDisposed()) { + int userHeight = latestUserTurn.getBounds().height; + height += userHeight > 0 ? userHeight : latestUserTurn.computeSize(SWT.DEFAULT, SWT.DEFAULT).y; + } + if (latestCopilotTurn != null && !latestCopilotTurn.isDisposed()) { + int copilotHeight = latestCopilotTurn.getBounds().height; + height += copilotHeight > 0 ? copilotHeight + : latestCopilotTurn.computeSize(SWT.DEFAULT, SWT.DEFAULT).y; + } + return height; + } + /** * Scroll to the bottom. */ @@ -523,9 +671,7 @@ private void scrollToLatestUserTurn() { return; } - // Use async execution to ensure layout is computed before reading positions. - // Using sync execution would read positions before the layout is complete, - // resulting in incorrect scroll position (always scrolling to 0). + // Async so layout is computed before reading positions; reading synchronously scrolls to 0. SwtUtils.invokeOnDisplayThreadAsync(() -> { if (this.isDisposed() || latestUserTurn.isDisposed()) { return; @@ -537,6 +683,7 @@ private void scrollToLatestUserTurn() { @Override public void dispose() { + pendingEvents.clear(); super.dispose(); for (BaseTurnWidget turn : turns.values()) { turn.dispose(); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java index 89b350df..3cf6ff19 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java @@ -32,7 +32,6 @@ import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Display; -import org.eclipse.swt.widgets.ScrollBar; import org.eclipse.ui.IPartListener2; import org.eclipse.ui.IWorkbenchPartReference; import org.eclipse.ui.PlatformUI; @@ -1819,16 +1818,10 @@ public void scrollContentToBottom() { return; } - chatContentViewer.getDisplay().asyncExec(() -> { - if (chatContentViewer.isDisposed()) { - return; - } - chatContentViewer.refreshScrollerLayout(); - ScrollBar verticalBar = chatContentViewer.getVerticalBar(); - if (verticalBar != null && !verticalBar.isDisposed()) { - chatContentViewer.setOrigin(0, verticalBar.getMaximum()); - } - }); + SwtUtils.invokeOnDisplayThreadAsync(() -> { + chatContentViewer.refreshLayoutFull(); + chatContentViewer.scrollToBottomIfAutoScroll(); + }, chatContentViewer); } /** @@ -1847,8 +1840,11 @@ private void renderModelInfoInTurnWidget(String turnId, String modelName, double if (turnWidget instanceof CopilotTurnWidget copilotWidget) { copilotWidget.renderModelInfo(modelName, billingMultiplier, reasoningEffort); - // Refresh the scroller layout to ensure the footer is visible - SwtUtils.invokeOnDisplayThreadAsync(() -> this.chatContentViewer.refreshScrollerLayout(), this.chatContentViewer); + // Refresh the scroller layout to ensure the footer is visible. + SwtUtils.invokeOnDisplayThreadAsync(() -> { + this.chatContentViewer.refreshLayoutFull(); + this.chatContentViewer.scrollToBottomIfAutoScroll(); + }, this.chatContentViewer); } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlock.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlock.java index 8e974b9e..e273986f 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlock.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlock.java @@ -315,7 +315,6 @@ private void refreshBody() { body.requestLayout(); updateScrollerDuringStreaming(); - refreshEnclosingScroller(); } /** Resize the scroller to fit content (up to max height) and auto-scroll to bottom if enabled. */ @@ -499,7 +498,7 @@ private void refreshEnclosingScroller() { Composite p = getParent(); while (p != null && !p.isDisposed()) { if (p instanceof ChatContentViewer) { - ((ChatContentViewer) p).refreshScrollerLayout(); + ((ChatContentViewer) p).refreshLayoutFull(); return; } p = p.getParent(); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java index be236d9c..c7a013c2 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java @@ -294,7 +294,7 @@ public CompletableFuture onToolConfirmation SwtUtils.invokeOnDisplayThread(() -> { ref.set(activeTurnWidget.requestToolExecutionConfirmation( content, params.getInput())); - boundChatView.getChatContentViewer().refreshScrollerLayout(); + boundChatView.getChatContentViewer().refreshLayoutFull(); }); CompletableFuture future = ref.get(); From 73e72b75c4fe2bd1227d430f09f2ad5786083264 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Thu, 2 Jul 2026 11:20:58 +0800 Subject: [PATCH 21/27] feat: Auto-approve reads of Copilot customization files (#331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Auto-approve reads of Copilot customization files Reading a Copilot customization file (skill, instruction, prompt, or agent) is normal agent operation, but these reads previously triggered a confirmation dialog — including workspace instructions caught by the default deny rule and user-global files outside the workspace. Recognize these reads precisely from the language server, which is the source of truth for where the files live (including user-configured and global locations). New copilot/customSkill|customPrompt|customInstruction| customAgent/list requests report each file's on-disk URI; a read is auto-approved when it exactly matches a reported file or sits inside a reported skill folder (covering SKILL.md and its helper files). Editing is never auto-approved — only reads. - Add CustomizationFileService, refreshed per type on the matching custom*/didChange notification, exposing an immutable path snapshot. - Hook FileOperationConfirmationHandler to auto-approve FILE_READ of a recognized customization file before the outside-workspace check. - Add FileUtils.isPathWithin for skill-folder containment. Tests: FileUtilsTests (isPathWithin), FileOperationConfirmationHandlerTests (isCustomizationRead), and TC-010/TC-011 in the auto-approve test plan. * rename refresh functions and merge workspaceFoldersParams * refactor: Decouple customization file service via event bus * rename refreshAll to refreshAllAsync --- .../service/CustomizationFileService.java | 144 ++++++++++++++++++ .../chat/service/IChatServiceManager.java | 5 + .../service/ICustomizationFileService.java | 39 +++++ .../core/events/CopilotEventConstants.java | 4 +- .../core/lsp/CopilotLanguageClient.java | 31 +++- .../core/lsp/CopilotLanguageServer.java | 37 ++++- .../lsp/CopilotLanguageServerConnection.java | 45 +++++- .../protocol/ConversationTemplatesParams.java | 21 --- .../lsp/protocol/CustomizationFileInfo.java | 16 ++ .../lsp/protocol/WorkspaceFoldersParams.java | 20 +++ .../file-operation-auto-approve.md | 73 +++++++++ ...FileOperationConfirmationHandlerTests.java | 90 +++++++++++ .../FileOperationConfirmationHandler.java | 47 ++++++ .../chat/services/ChatCompletionService.java | 9 +- .../ui/chat/services/ChatServiceManager.java | 10 ++ 15 files changed, 557 insertions(+), 34 deletions(-) create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/CustomizationFileService.java create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/ICustomizationFileService.java delete mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplatesParams.java create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CustomizationFileInfo.java create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/WorkspaceFoldersParams.java diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/CustomizationFileService.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/CustomizationFileService.java new file mode 100644 index 00000000..c94c14f7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/CustomizationFileService.java @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat.service; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +import org.eclipse.e4.core.contexts.EclipseContextFactory; +import org.eclipse.e4.core.services.events.IEventBroker; +import org.eclipse.lsp4j.WorkspaceFolder; +import org.osgi.framework.FrameworkUtil; +import org.osgi.service.event.EventHandler; + +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; +import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CustomizationFileInfo; +import com.microsoft.copilot.eclipse.core.utils.FileUtils; +import com.microsoft.copilot.eclipse.core.utils.WorkspaceUtils; + +/** + * Default {@link ICustomizationFileService} backed by the language server's {@code copilot/custom*} + * list requests. Each customization type is fetched and snapshotted independently. + */ +public class CustomizationFileService implements ICustomizationFileService { + + private final CopilotLanguageServerConnection lsConnection; + private final IEventBroker eventBroker; + private final EventHandler customizationFilesChangedHandler; + + private volatile Set promptFiles = Set.of(); + private volatile Set instructionFiles = Set.of(); + private volatile Set agentFiles = Set.of(); + private volatile Set skillFolders = Set.of(); + + private volatile Set customizationFiles = Set.of(); + + /** + * Creates the service and subscribes to customization-file change events. + * + * @param lsConnection the language server connection used to issue the list requests + */ + public CustomizationFileService(CopilotLanguageServerConnection lsConnection) { + this.lsConnection = lsConnection; + this.eventBroker = EclipseContextFactory + .getServiceContext(FrameworkUtil.getBundle(getClass()).getBundleContext()).get(IEventBroker.class); + this.customizationFilesChangedHandler = event -> { + if (event.getProperty(IEventBroker.DATA) instanceof CustomizationType type) { + CompletableFuture.runAsync(() -> refreshType(type, WorkspaceUtils.listWorkspaceFolders())); + } + }; + if (eventBroker != null) { + eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_DID_CHANGE_CUSTOMIZATION_FILES, + customizationFilesChangedHandler); + } + } + + /** Unsubscribes from customization-file change events. */ + public void dispose() { + if (eventBroker != null) { + eventBroker.unsubscribe(customizationFilesChangedHandler); + } + } + + @Override + public Set getCustomizationFiles() { + return customizationFiles; + } + + @Override + public Set getSkillFolders() { + return skillFolders; + } + + @Override + public void refreshAllAsync() { + CompletableFuture.runAsync(() -> { + List workspaceFolders = WorkspaceUtils.listWorkspaceFolders(); + for (CustomizationType type : CustomizationType.values()) { + refreshType(type, workspaceFolders); + } + }); + } + + private void refreshType(CustomizationType type, List workspaceFolders) { + switch (type) { + case SKILL -> toPaths(lsConnection.listCustomSkills(workspaceFolders)).thenAccept(paths -> { + Set folders = new HashSet<>(); + for (Path skillFile : paths) { + Path parent = skillFile.getParent(); + if (parent != null) { + folders.add(parent); + } + } + this.skillFolders = Set.copyOf(folders); + }); + case PROMPT -> toPaths(lsConnection.listCustomPrompts(workspaceFolders)).thenAccept(paths -> { + this.promptFiles = Set.copyOf(paths); + rebuildCustomizationFiles(); + }); + case INSTRUCTION -> toPaths(lsConnection.listCustomInstructions(workspaceFolders)).thenAccept(paths -> { + this.instructionFiles = Set.copyOf(paths); + rebuildCustomizationFiles(); + }); + case AGENT -> toPaths(lsConnection.listCustomAgents(workspaceFolders)).thenAccept(paths -> { + this.agentFiles = Set.copyOf(paths); + rebuildCustomizationFiles(); + }); + default -> { + // No other customization types. + } + } + } + + private synchronized void rebuildCustomizationFiles() { + Set all = new HashSet<>(promptFiles); + all.addAll(instructionFiles); + all.addAll(agentFiles); + this.customizationFiles = Set.copyOf(all); + } + + private CompletableFuture> toPaths(CompletableFuture future) { + return future.thenApply(infos -> { + List paths = new ArrayList<>(); + if (infos != null) { + for (CustomizationFileInfo info : infos) { + Path path = FileUtils.getLocalFilePath(info.uri()); + if (path != null) { + paths.add(path.toAbsolutePath().normalize()); + } + } + } + return paths; + }).exceptionally(ex -> { + CopilotCore.LOGGER.error("Failed to list customization files", ex); + return List.of(); + }); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/IChatServiceManager.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/IChatServiceManager.java index 65aa911e..d6b0784f 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/IChatServiceManager.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/IChatServiceManager.java @@ -17,4 +17,9 @@ public interface IChatServiceManager { * Get the MCP config service. */ IMcpConfigService getMcpConfigService(); + + /** + * Get the customization file service tracking skill/prompt/instruction/agent file locations. + */ + ICustomizationFileService getCustomizationFileService(); } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/ICustomizationFileService.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/ICustomizationFileService.java new file mode 100644 index 00000000..736df418 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/ICustomizationFileService.java @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat.service; + +import java.nio.file.Path; +import java.util.Set; + +/** + * Maintains an up-to-date view of the Copilot customization files (skills, prompts, instructions, + * and agents) reported by the language server, keyed by their on-disk location. + * + */ +public interface ICustomizationFileService { + + /** The customization kinds, each backed by a distinct language-server list request. */ + enum CustomizationType { + SKILL, PROMPT, INSTRUCTION, AGENT + } + + /** + * Returns the absolute paths of single-file customization files (prompts, instructions, agents). + * The returned set is an immutable snapshot. + */ + Set getCustomizationFiles(); + + /** + * Returns the absolute paths of skill folders (the directory containing each {@code SKILL.md}). + * A read of any file within one of these folders is a skill read. The returned set is an immutable + * snapshot. + */ + Set getSkillFolders(); + + /** + * Refreshes every customization type from the language server. Used for the initial load. + * Fire-and-forget; the work completes asynchronously off the caller's thread. + */ + void refreshAllAsync(); +} 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 61637b76..1a2d3c9e 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 @@ -183,8 +183,8 @@ public class CopilotEventConstants { public static final String TOPIC_QUOTA_WARNING = TOPIC_QUOTA + "WARNING"; /** - * Event when custom prompts, skills, agents, or instructions change on the language server. Clients should re-fetch - * conversation templates on receipt. + * Event when custom prompts, skills, agents, or instructions change on the language server. The event data is the + * {@link com.microsoft.copilot.eclipse.core.chat.service.ICustomizationFileService.CustomizationType} that changed. */ 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 da7aaee9..cf0e81ad 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 @@ -37,6 +37,7 @@ import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.FeatureFlags; import com.microsoft.copilot.eclipse.core.chat.service.IChatServiceManager; +import com.microsoft.copilot.eclipse.core.chat.service.ICustomizationFileService.CustomizationType; import com.microsoft.copilot.eclipse.core.chat.service.IMcpConfigService; import com.microsoft.copilot.eclipse.core.chat.service.IReferencedFileService; import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; @@ -264,24 +265,42 @@ public void onRateLimitWarning(RateLimitWarningParams params) { } /** - * Notify when custom skills change (global or workspace). Signal-only; clients re-fetch templates. + * Notify when custom skills change (global or workspace). */ @JsonNotification("copilot/customSkill/didChange") public void onDidChangeCustomSkill(Object params) { - notifyCustomizationFilesChanged(); + postCustomizationFilesChanged(CustomizationType.SKILL); } /** - * Notify when custom prompts change (global or workspace). Signal-only; clients re-fetch templates. + * Notify when custom prompts change (global or workspace). */ @JsonNotification("copilot/customPrompt/didChange") public void onDidChangeCustomPrompt(Object params) { - notifyCustomizationFilesChanged(); + postCustomizationFilesChanged(CustomizationType.PROMPT); } - private void notifyCustomizationFilesChanged() { + /** + * Notify when custom instructions change (global or workspace). + */ + @JsonNotification("copilot/customInstruction/didChange") + public void onDidChangeCustomInstruction(Object params) { + postCustomizationFilesChanged(CustomizationType.INSTRUCTION); + } + + /** + * Notify when custom agents change (global or workspace). + */ + @JsonNotification("copilot/customAgent/didChange") + public void onDidChangeCustomAgent(Object params) { + postCustomizationFilesChanged(CustomizationType.AGENT); + } + + // Broadcasts the change on the event bus; subscribers (the customization-file service and the + // slash-command service) react without this class depending on them directly. + private void postCustomizationFilesChanged(CustomizationType type) { if (eventBroker != null) { - eventBroker.post(CopilotEventConstants.TOPIC_CHAT_DID_CHANGE_CUSTOMIZATION_FILES, null); + eventBroker.post(CopilotEventConstants.TOPIC_CHAT_DID_CHANGE_CUSTOMIZATION_FILES, type); } } 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 8f74d5dd..f238d933 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 @@ -29,10 +29,10 @@ 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; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CustomizationFileInfo; import com.microsoft.copilot.eclipse.core.lsp.protocol.DidShowInlineEditParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleResponse; @@ -51,6 +51,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.TelemetryExceptionParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.UpdateConversationToolsStatusParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.UpdateMcpToolsStatusParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.WorkspaceFoldersParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokApiKey; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokListApiKeyResponse; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokListModelParams; @@ -146,7 +147,39 @@ public interface CopilotLanguageServer extends LanguageServer { * @param params includes workspace folders for discovering workspace-specific prompt files and skills */ @JsonRequest("conversation/templates") - CompletableFuture listTemplates(ConversationTemplatesParams params); + CompletableFuture listTemplates(WorkspaceFoldersParams params); + + /** + * List custom skill files (each carries its on-disk {@code uri}). + * + * @param params includes the workspace folders to scan + */ + @JsonRequest("copilot/customSkill/list") + CompletableFuture listCustomSkills(WorkspaceFoldersParams params); + + /** + * List custom prompt files (each carries its on-disk {@code uri}). + * + * @param params includes the workspace folders to scan + */ + @JsonRequest("copilot/customPrompt/list") + CompletableFuture listCustomPrompts(WorkspaceFoldersParams params); + + /** + * List custom instruction files (each carries its on-disk {@code uri}). + * + * @param params includes the workspace folders to scan + */ + @JsonRequest("copilot/customInstruction/list") + CompletableFuture listCustomInstructions(WorkspaceFoldersParams params); + + /** + * List custom agent files (each carries its on-disk {@code uri}). + * + * @param params includes the workspace folders to scan + */ + @JsonRequest("copilot/customAgent/list") + CompletableFuture listCustomAgents(WorkspaceFoldersParams 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 de116abb..1c853da5 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 @@ -46,10 +46,10 @@ 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; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CustomizationFileInfo; import com.microsoft.copilot.eclipse.core.lsp.protocol.DidChangeCopilotWatchedFilesParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.DidShowInlineEditParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleParams; @@ -72,6 +72,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.Turn; import com.microsoft.copilot.eclipse.core.lsp.protocol.UpdateConversationToolsStatusParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.UpdateMcpToolsStatusParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.WorkspaceFoldersParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokApiKey; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokListApiKeyResponse; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokListModelParams; @@ -374,11 +375,51 @@ public CompletableFuture addConversationTurn(String workDoneToke */ public CompletableFuture listConversationTemplates(List workspaceFolders) { Function> fn = server -> { - return ((CopilotLanguageServer) server).listTemplates(new ConversationTemplatesParams(workspaceFolders)); + return ((CopilotLanguageServer) server).listTemplates(new WorkspaceFoldersParams(workspaceFolders)); }; return this.languageServerWrapper.execute(fn); } + /** + * List custom skill files, each carrying its on-disk {@code uri}. + * + * @param workspaceFolders the workspace folders to scan + */ + public CompletableFuture listCustomSkills(List workspaceFolders) { + return this.languageServerWrapper.execute(server -> + ((CopilotLanguageServer) server).listCustomSkills(new WorkspaceFoldersParams(workspaceFolders))); + } + + /** + * List custom prompt files, each carrying its on-disk {@code uri}. + * + * @param workspaceFolders the workspace folders to scan + */ + public CompletableFuture listCustomPrompts(List workspaceFolders) { + return this.languageServerWrapper.execute(server -> + ((CopilotLanguageServer) server).listCustomPrompts(new WorkspaceFoldersParams(workspaceFolders))); + } + + /** + * List custom instruction files, each carrying its on-disk {@code uri}. + * + * @param workspaceFolders the workspace folders to scan + */ + public CompletableFuture listCustomInstructions(List workspaceFolders) { + return this.languageServerWrapper.execute(server -> + ((CopilotLanguageServer) server).listCustomInstructions(new WorkspaceFoldersParams(workspaceFolders))); + } + + /** + * List custom agent files, each carrying its on-disk {@code uri}. + * + * @param workspaceFolders the workspace folders to scan + */ + public CompletableFuture listCustomAgents(List workspaceFolders) { + return this.languageServerWrapper.execute(server -> + ((CopilotLanguageServer) server).listCustomAgents(new WorkspaceFoldersParams(workspaceFolders))); + } + /** * List the conversation modes. */ 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 deleted file mode 100644 index 6b384b94..00000000 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplatesParams.java +++ /dev/null @@ -1,21 +0,0 @@ -// 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/CustomizationFileInfo.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CustomizationFileInfo.java new file mode 100644 index 00000000..c164b1dd --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CustomizationFileInfo.java @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +/** + * Metadata for a Copilot customization file (skill, prompt, instruction, or agent) as returned by + * the language server's customization list requests. + * + * @param id the stable identifier, when provided by the language server + * @param name the user-facing name + * @param uri the on-disk file URI, or {@code null} for entries without a backing file + * @param storage the storage scope reported by the language server (for example {@code local} or {@code user}) + */ +public record CustomizationFileInfo(String id, String name, String uri, String storage) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/WorkspaceFoldersParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/WorkspaceFoldersParams.java new file mode 100644 index 00000000..5f3e3574 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/WorkspaceFoldersParams.java @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.List; + +import org.eclipse.lsp4j.WorkspaceFolder; + +/** + * Generic parameters for language-server requests scoped to a set of workspace folders. + * + * @param workspaceFolders the workspace folders to scan + */ +public record WorkspaceFoldersParams(List workspaceFolders) { + /** Compact constructor that defensively copies the folders, defaulting {@code null} to empty. */ + public WorkspaceFoldersParams { + workspaceFolders = workspaceFolders != null ? List.copyOf(workspaceFolders) : List.of(); + } +} diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-operation-auto-approve/file-operation-auto-approve.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-operation-auto-approve/file-operation-auto-approve.md index 2a76f5f9..e5ddf4e8 100644 --- a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-operation-auto-approve/file-operation-auto-approve.md +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-operation-auto-approve/file-operation-auto-approve.md @@ -340,3 +340,76 @@ approval #### 📸 Key Screenshots - [ ] Preference page after reset — only defaults. - [ ] `.java` file shows confirmation dialog post-reset. + +--- + +## 9. Customization file reads (skills, instructions, prompts, agents) + +Reading a Copilot customization file is always auto-approved, even with +"Auto approve file operations not covered by rules" **unchecked** and even +when a deny rule would otherwise match (e.g. the default `.github/instructions/*` +rule). The exemption is read-only and covers both workspace and user-global +(`~/.copilot`, `~/.claude`, `~/.agents`) locations. Recognized files: +`//**` (the whole skill folder, including helper files), +`*.instructions.md`, `*.prompt.md`, `*.agent.md`, and the fixed names +`copilot-instructions.md`, `git-commit-instructions.md`, `AGENTS.md`, `CLAUDE.md`. + +### TC-010: Customization reads auto-approve; edits and ordinary files still prompt + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- "Auto approve file operations not covered by rules" is **unchecked**. +- The project contains `.github/skills/demo/SKILL.md` (whose body also tells + Copilot to read a sibling `notes.md`), `.github/instructions/coding.instructions.md`, + and `.github/copilot-instructions.md`. + +#### Steps +1. In Agent Mode, invoke the demo skill (or ask Copilot to read + `.github/skills/demo/SKILL.md`). +2. Observe **no confirmation** for `SKILL.md` **or** the referenced `notes.md`. +3. Type: `read the project instructions and summarize them`. +4. Observe **no confirmation** for `coding.instructions.md` — even though the + default `.github/instructions/*` deny rule matches. +5. Type: `read .github/copilot-instructions.md`. +6. Observe **no confirmation**. +7. Type: `add a comment line to .github/skills/demo/SKILL.md`. +8. Observe **confirmation dialog appears** — editing is not exempt. +9. Type: `read src/demo/App.java`. +10. Observe **confirmation dialog appears** — ordinary files are unaffected. + +#### Expected Result +- Reads of skill files (and their helpers), instruction/prompt/agent files, and + the fixed well-known files auto-approve with no dialog. +- Editing a customization file still prompts. +- Ordinary (non-customization) reads still prompt. + +#### 📸 Key Screenshots +- [ ] Skill `SKILL.md` + `notes.md` read with no confirmation. +- [ ] Instruction file read with no confirmation despite the deny rule. +- [ ] Edit to `SKILL.md` shows a confirmation dialog. + +--- + +### TC-011: User-global customization files auto-approve + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- "Auto approve file operations not covered by rules" is **unchecked**. +- A user-global skill exists, e.g. `~/.copilot/skills/demo-global/SKILL.md`. + +#### Steps +1. In Agent Mode, invoke the global demo skill (or ask Copilot to read its + `SKILL.md`). +2. Observe **no confirmation** — global customization files are exempt even + though they live outside the workspace (an ordinary outside-workspace file + would still prompt, per TC-006). + +#### Expected Result +- User-global customization reads auto-approve without a dialog. + +#### 📸 Key Screenshots +- [ ] Global skill read with no confirmation. diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandlerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandlerTests.java index 5d9b9573..ba60a62e 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandlerTests.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandlerTests.java @@ -9,15 +9,18 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; +import java.nio.file.Path; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import com.google.gson.Gson; import org.eclipse.jface.preference.IPreferenceStore; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; @@ -46,6 +49,9 @@ class FileOperationConfirmationHandlerTests { private AttachedFileRegistry attachedFileRegistry; private FileOperationConfirmationHandler handler; + @TempDir + private Path customizationBase; + @BeforeEach void setUp() { attachedFileRegistry = new AttachedFileRegistry(); @@ -677,6 +683,90 @@ void evaluate_priorityOrder_sessionFolderBeatsOutsideWorkspace() { assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } + // --- customization file read recognition (isCustomizationRead) --- + + private Set customizationFiles() { + return Set.of( + customizationBase.resolve(".github/prompts/example.prompt.md"), + customizationBase.resolve(".github/instructions/coding.instructions.md"), + customizationBase.resolve(".github/agents/my.agent.md")); + } + + private Set skillFolders() { + return Set.of(customizationBase.resolve(".github/skills/demo")); + } + + @Test + void isCustomizationRead_promptFileExactMatch_isTrue() { + Path prompt = customizationBase.resolve(".github/prompts/example.prompt.md"); + assertTrue(FileOperationConfirmationHandler.isCustomizationRead( + prompt, customizationFiles(), skillFolders())); + } + + @Test + void isCustomizationRead_instructionFileExactMatch_isTrue() { + Path instruction = customizationBase.resolve(".github/instructions/coding.instructions.md"); + assertTrue(FileOperationConfirmationHandler.isCustomizationRead( + instruction, customizationFiles(), skillFolders())); + } + + @Test + void isCustomizationRead_agentFileExactMatch_isTrue() { + Path agent = customizationBase.resolve(".github/agents/my.agent.md"); + assertTrue(FileOperationConfirmationHandler.isCustomizationRead( + agent, customizationFiles(), skillFolders())); + } + + @Test + void isCustomizationRead_skillMarkdownInsideFolder_isTrue() { + Path skill = customizationBase.resolve(".github/skills/demo/SKILL.md"); + assertTrue(FileOperationConfirmationHandler.isCustomizationRead( + skill, customizationFiles(), skillFolders())); + } + + @Test + void isCustomizationRead_skillHelperFileInsideFolder_isTrue() { + Path helper = customizationBase.resolve(".github/skills/demo/notes.md"); + assertTrue(FileOperationConfirmationHandler.isCustomizationRead( + helper, customizationFiles(), skillFolders())); + } + + @Test + void isCustomizationRead_nestedSkillHelperFile_isTrue() { + Path helper = customizationBase.resolve(".github/skills/demo/scripts/run.py"); + assertTrue(FileOperationConfirmationHandler.isCustomizationRead( + helper, customizationFiles(), skillFolders())); + } + + @Test + void isCustomizationRead_unrelatedFile_isFalse() { + Path other = customizationBase.resolve("src/Main.java"); + assertFalse(FileOperationConfirmationHandler.isCustomizationRead( + other, customizationFiles(), skillFolders())); + } + + @Test + void isCustomizationRead_promptOutsideReportedSet_isFalse() { + // A *.prompt.md the language server did not report must not be auto-approved. + Path loose = customizationBase.resolve("docs/other.prompt.md"); + assertFalse(FileOperationConfirmationHandler.isCustomizationRead( + loose, customizationFiles(), skillFolders())); + } + + @Test + void isCustomizationRead_fileInUnreportedSkillFolder_isFalse() { + Path other = customizationBase.resolve(".github/skills/other/SKILL.md"); + assertFalse(FileOperationConfirmationHandler.isCustomizationRead( + other, customizationFiles(), skillFolders())); + } + + @Test + void isCustomizationRead_emptyInputs_isFalse() { + Path prompt = customizationBase.resolve(".github/prompts/example.prompt.md"); + assertFalse(FileOperationConfirmationHandler.isCustomizationRead( + prompt, Set.of(), Set.of())); + } + // --- Helpers --- private void stubRules(List rules) { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandler.java index 313c56c2..ba861ed4 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandler.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandler.java @@ -28,8 +28,11 @@ import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; import com.microsoft.copilot.eclipse.core.chat.FileOperationAutoApproveRule; +import com.microsoft.copilot.eclipse.core.chat.service.IChatServiceManager; +import com.microsoft.copilot.eclipse.core.chat.service.ICustomizationFileService; import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata; +import com.microsoft.copilot.eclipse.core.utils.FileUtils; import com.microsoft.copilot.eclipse.ui.chat.Messages; /** @@ -171,6 +174,17 @@ private ConfirmationResult evaluateAutoApprovalEnabled( } } + // Auto-approve reads of Copilot customization files (skills, instructions, prompts, agents) + // discovered by the language server. Reading them is normal agent operation; edits still prompt. + if (isFileRead(params)) { + Path localPath = FileUtils.getLocalFilePath(filePath); + ICustomizationFileService service = getCustomizationFileService(); + if (localPath != null && service != null && isCustomizationRead( + localPath, service.getCustomizationFiles(), service.getSkillFolders())) { + return ConfirmationResult.AUTO_APPROVED; + } + } + // Files outside workspace always require confirmation if (isOutsideWorkspace(params)) { return ConfirmationResult.needsConfirmation(buildContent(params)); @@ -338,6 +352,39 @@ private boolean isOutsideWorkspace(InvokeClientToolConfirmationParams params) { return false; } + private static boolean isFileRead(InvokeClientToolConfirmationParams params) { + return FileToolType.fromValue(ConfirmationHandler.extractToolType(params)) + == FileToolType.FILE_READ; + } + + private static ICustomizationFileService getCustomizationFileService() { + IChatServiceManager chatServiceManager = CopilotCore.getPlugin().getChatServiceManager(); + return chatServiceManager == null ? null : chatServiceManager.getCustomizationFileService(); + } + + /** + * Returns whether reading {@code file} is a customization-file read that is safe to auto-approve. + * A read matches when the file exactly equals a language-server-reported customization file, or + * sits inside a reported skill folder (covering {@code SKILL.md} and the helper files it uses). + * + * @param file the absolute path being read + * @param customizationFiles reported single-file customizations (prompts, instructions, agents) + * @param skillFolders reported skill folders (each directory containing a {@code SKILL.md}) + * @return {@code true} when the read is a recognized customization read + */ + public static boolean isCustomizationRead(Path file, Set customizationFiles, Set skillFolders) { + Path normalized = file.toAbsolutePath().normalize(); + if (customizationFiles.contains(normalized)) { + return true; + } + for (Path skillFolder : skillFolders) { + if (normalized.startsWith(skillFolder)) { + return true; + } + } + return false; + } + /** Normalizes a file path for case-insensitive, separator-agnostic comparison. */ private static String normalizePath(String path) { return ConfirmationHandler.normalizePath(path); 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 cff99834..1f0953cd 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 @@ -30,6 +30,7 @@ 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.chat.service.ICustomizationFileService.CustomizationType; 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; @@ -76,7 +77,13 @@ public ChatCompletionService(CopilotLanguageServerConnection lsConnection, AuthS ResourcesPlugin.getWorkspace().addResourceChangeListener(skillFileListener, IResourceChangeEvent.POST_CHANGE); this.eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); if (this.eventBroker != null) { - this.customPromptsChangedHandler = event -> fetchAsync(); + // Templates only surface skills and prompts, so ignore instruction/agent changes. + this.customPromptsChangedHandler = event -> { + Object type = event.getProperty(IEventBroker.DATA); + if (type == CustomizationType.SKILL || type == CustomizationType.PROMPT) { + fetchAsync(); + } + }; this.eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_DID_CHANGE_CUSTOMIZATION_FILES, customPromptsChangedHandler); } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatServiceManager.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatServiceManager.java index 0883074f..62e6c4a6 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatServiceManager.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatServiceManager.java @@ -5,6 +5,7 @@ import com.microsoft.copilot.eclipse.core.AuthStatusManager; import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.service.CustomizationFileService; import com.microsoft.copilot.eclipse.core.chat.service.IChatServiceManager; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; import com.microsoft.copilot.eclipse.core.persistence.ConversationPersistenceManager; @@ -29,6 +30,7 @@ public class ChatServiceManager implements IChatServiceManager { private ReferencedFileService referencedFileService; private McpConfigService mcpConfigService; private McpExtensionPointManager mcpExtensionPointManager; + private CustomizationFileService customizationFileService; private McpRuntimeLogger mcpRuntimeLogger; private ConversationPersistenceManager persistenceManager; @@ -51,6 +53,8 @@ public ChatServiceManager() { referencedFileService = new ReferencedFileService(); mcpConfigService = new McpConfigService(); mcpExtensionPointManager = new McpExtensionPointManager(mcpConfigService); + customizationFileService = new CustomizationFileService(this.lsConnection); + customizationFileService.refreshAllAsync(); mcpRuntimeLogger = new McpRuntimeLogger(); persistenceManager = new ConversationPersistenceManager(this.authStatusManager); chatFontService = new ChatFontService(); @@ -140,6 +144,11 @@ public McpConfigService getMcpConfigService() { return mcpConfigService; } + @Override + public CustomizationFileService getCustomizationFileService() { + return customizationFileService; + } + /** * Get the persistence manager. * @@ -202,6 +211,7 @@ public void dispose() { this.agentToolService.dispose(); this.referencedFileService.dispose(); this.mcpConfigService.dispose(); + this.customizationFileService.dispose(); this.contextWindowService.dispose(); if (this.byokService != null) { this.byokService.dispose(); From 22ab0375d7b623aa155932d4dbeaf38d014eb22b Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Fri, 3 Jul 2026 13:04:14 +0800 Subject: [PATCH 22/27] feat: Surface organization-enabled custom (BYOK) models (#333) --- .../core/lsp/protocol/CopilotModelTests.java | 88 +++++++++++++++++++ .../copilot-agent/package-lock.json | 66 +++++++------- .../copilot-agent/package.json | 12 +-- .../lsp/CopilotLanguageServerConnection.java | 16 ++-- .../core/lsp/protocol/CopilotModel.java | 38 +++++++- .../eclipse/core/lsp/protocol/ModelInfo.java | 8 +- .../eclipse/ui/utils/ModelUtilsTests.java | 22 +++++ .../ui/chat/ModelPickerGroupsBuilder.java | 2 +- .../copilot/eclipse/ui/i18n/Messages.java | 1 + .../eclipse/ui/i18n/messages.properties | 1 + .../ui/swt/ModelHoverContentProvider.java | 33 +++++++ .../copilot/eclipse/ui/utils/ModelUtils.java | 5 ++ 12 files changed, 236 insertions(+), 56 deletions(-) create mode 100644 com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotModelTests.java diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotModelTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotModelTests.java new file mode 100644 index 00000000..37f7b85d --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotModelTests.java @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +import com.google.gson.Gson; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCustomModel; + +/** + * Tests for {@link CopilotModel}, focusing on the {@code customModel} metadata that carries organization- and + * enterprise-contributed custom (BYOK) models exposed through {@code copilot/models}. + */ +class CopilotModelTests { + + private final Gson gson = new Gson(); + + @Test + void testDeserialize_populatesCustomModelMetadata() { + String json = """ + { + "modelFamily": "custom", + "modelName": "Sonnet (Org)", + "id": "claude-sonnet-org", + "scopes": ["chat-panel", "agent-panel"], + "customModel": { + "keyName": "Contoso Azure Key", + "ownerName": "Contoso", + "ownerType": "organization", + "provider": "azure" + } + } + """; + + CopilotModel model = gson.fromJson(json, CopilotModel.class); + + assertNotNull(model.getCustomModel()); + assertEquals("Contoso Azure Key", model.getCustomModel().keyName()); + assertEquals("Contoso", model.getCustomModel().ownerName()); + assertEquals("organization", model.getCustomModel().ownerType()); + assertEquals("azure", model.getCustomModel().provider()); + } + + @Test + void testDeserialize_customModelAbsentIsNull() { + String json = """ + { + "modelFamily": "gpt-4o", + "modelName": "GPT-4o", + "id": "gpt-4o", + "scopes": ["chat-panel"] + } + """; + + CopilotModel model = gson.fromJson(json, CopilotModel.class); + + assertNull(model.getCustomModel()); + } + + @Test + void testEqualsAndHashCode_accountForCustomModel() { + CopilotModel base = new CopilotModel(); + base.setId("claude-sonnet-org"); + base.setModelName("Sonnet (Org)"); + base.setCustomModel(new CopilotModelCustomModel("Contoso Azure Key", "Contoso", "organization", "azure")); + + CopilotModel same = new CopilotModel(); + same.setId("claude-sonnet-org"); + same.setModelName("Sonnet (Org)"); + same.setCustomModel(new CopilotModelCustomModel("Contoso Azure Key", "Contoso", "organization", "azure")); + + CopilotModel differentOwner = new CopilotModel(); + differentOwner.setId("claude-sonnet-org"); + differentOwner.setModelName("Sonnet (Org)"); + differentOwner.setCustomModel(new CopilotModelCustomModel("Contoso Azure Key", "Fabrikam", "organization", + "azure")); + + assertEquals(base, same); + assertEquals(base.hashCode(), same.hashCode()); + assertNotEquals(base, differentOwner); + } +} diff --git a/com.microsoft.copilot.eclipse.core/copilot-agent/package-lock.json b/com.microsoft.copilot.eclipse.core/copilot-agent/package-lock.json index f389abb6..eda43152 100644 --- a/com.microsoft.copilot.eclipse.core/copilot-agent/package-lock.json +++ b/com.microsoft.copilot.eclipse.core/copilot-agent/package-lock.json @@ -9,18 +9,18 @@ "version": "0.0.1", "hasInstallScript": true, "dependencies": { - "@github/copilot-language-server": "1.502.5", - "@github/copilot-language-server-darwin-arm64": "1.502.5", - "@github/copilot-language-server-darwin-x64": "1.502.5", - "@github/copilot-language-server-linux-arm64": "1.502.5", - "@github/copilot-language-server-linux-x64": "1.502.5", - "@github/copilot-language-server-win32-x64": "1.502.5" + "@github/copilot-language-server": "1.509.5", + "@github/copilot-language-server-darwin-arm64": "1.509.5", + "@github/copilot-language-server-darwin-x64": "1.509.5", + "@github/copilot-language-server-linux-arm64": "1.509.5", + "@github/copilot-language-server-linux-x64": "1.509.5", + "@github/copilot-language-server-win32-x64": "1.509.5" } }, "node_modules/@github/copilot-language-server": { - "version": "1.502.5", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.502.5.tgz", - "integrity": "sha512-vIFbb145TwhDD1KTdJjySAokBcJla6NWsfvqjotM2AsDu3lLtQjPFbt6P90vnMVbQ6Ixc/IbDqsjToaWmXqqIA==", + "version": "1.509.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.509.5.tgz", + "integrity": "sha512-cAZVbN5UHrm+ILqroO2NY2gbI7mYojwmiOClCWj8yPD0svXJKSVmmmDonkVrvNZ1VxW3tIbCbE1LRTL6HVN/9g==", "license": "MIT", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" @@ -29,18 +29,18 @@ "copilot-language-server": "dist/language-server.js" }, "optionalDependencies": { - "@github/copilot-language-server-darwin-arm64": "1.502.5", - "@github/copilot-language-server-darwin-x64": "1.502.5", - "@github/copilot-language-server-linux-arm64": "1.502.5", - "@github/copilot-language-server-linux-x64": "1.502.5", - "@github/copilot-language-server-win32-arm64": "1.502.5", - "@github/copilot-language-server-win32-x64": "1.502.5" + "@github/copilot-language-server-darwin-arm64": "1.509.5", + "@github/copilot-language-server-darwin-x64": "1.509.5", + "@github/copilot-language-server-linux-arm64": "1.509.5", + "@github/copilot-language-server-linux-x64": "1.509.5", + "@github/copilot-language-server-win32-arm64": "1.509.5", + "@github/copilot-language-server-win32-x64": "1.509.5" } }, "node_modules/@github/copilot-language-server-darwin-arm64": { - "version": "1.502.5", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.502.5.tgz", - "integrity": "sha512-3QTwdHjX2qpOwRhKIcXj3PU+wr5FodMcHPRnXK2qMv4xiRtibuuqicFVPF7ckO7Xym2o2FfJHfS8e58NNl278w==", + "version": "1.509.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.509.5.tgz", + "integrity": "sha512-eGb5bHN1OVUEnQYCop1+Hd4z6CR2fZK0iqFRu0pdGeQ3/y370trt2KR+omJv354ibsCUmpDUIqZXb6hOiIZk/Q==", "cpu": [ "arm64" ], @@ -50,9 +50,9 @@ ] }, "node_modules/@github/copilot-language-server-darwin-x64": { - "version": "1.502.5", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.502.5.tgz", - "integrity": "sha512-qz3aGl2KtAA/pM65esyXrsv4wBDFoKwUrP4hBAm2XplIPVF931HOluCXGxL0GDU2OMsEAxgFy5xTGODMg1U7kQ==", + "version": "1.509.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.509.5.tgz", + "integrity": "sha512-EonG1QHCx7qmiPc0EsVYSk9oBeWtwHXw0h3fMFOWKN37HOKImMiDeC7JBjv1kG/q9XzHGGhkYjkvReebMKZoOg==", "cpu": [ "x64" ], @@ -62,9 +62,9 @@ ] }, "node_modules/@github/copilot-language-server-linux-arm64": { - "version": "1.502.5", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.502.5.tgz", - "integrity": "sha512-8xabGJgGzpa4KVDQc1ZYH3elTzyzVRUWJLlBBQbK4vSpNtsnmGOk4TkY7uJZ7uXtaX1/xDD1venbPWWNOABCig==", + "version": "1.509.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.509.5.tgz", + "integrity": "sha512-f1SYaxh8drXeZ2Sf5A/gjFC5gg0qnUdwYUWXA2vpJrMZ2bMbylacGS9hwhkwk9jDaHkeOmjOWWh+g2csdLgVTw==", "cpu": [ "arm64" ], @@ -74,9 +74,9 @@ ] }, "node_modules/@github/copilot-language-server-linux-x64": { - "version": "1.502.5", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.502.5.tgz", - "integrity": "sha512-7JcmuKj4yOoDjU3kx4JlVPzC2jDES6nFFqUoNAEnKPFPUjIwf4RJKI2heonIUtFnFXZ9JWT09vqe477oO0F80w==", + "version": "1.509.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.509.5.tgz", + "integrity": "sha512-T0ihY6w2H7sdKMaF0k7pHBQV3wTj4Uzq38kx8pGKAFTupUIgivMUHOWysiLdsBgQ9rafByIMzHR3slnpYcH1SQ==", "cpu": [ "x64" ], @@ -86,9 +86,9 @@ ] }, "node_modules/@github/copilot-language-server-win32-arm64": { - "version": "1.502.5", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-arm64/-/copilot-language-server-win32-arm64-1.502.5.tgz", - "integrity": "sha512-7/QHmdWSIOKvbr5s5tH1u8/nQ+AfvqqGbPlpZeokv/hTVwCZZ69ntMVGkoSQVKcSAYVL2h97M5+0zNbofYitow==", + "version": "1.509.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-arm64/-/copilot-language-server-win32-arm64-1.509.5.tgz", + "integrity": "sha512-T4fyhZIJCU+ETyp3vcIw0Y+sIvcsnq8C59s2l7RR9jX6lwHacwsU2PUcRYQqETfWOkM5YX7wzKiThLxvsOAc9g==", "cpu": [ "arm64" ], @@ -99,9 +99,9 @@ ] }, "node_modules/@github/copilot-language-server-win32-x64": { - "version": "1.502.5", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.502.5.tgz", - "integrity": "sha512-9jdvyPZnu2muS9Jo6LtmP33NkpTht/2Gc9pWRrkQizuNabWy7jq2KuJdYf1/vwiyEioMiZznGBv+hscrJ9z5LA==", + "version": "1.509.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.509.5.tgz", + "integrity": "sha512-tv12/MayELMseV7V+ghdC/AY5GgKh/lyncAU/A7eEpPt8ACNEErBZ51mriAT1F/EqSfB1iEUKVtgZ4/+eTe9Fw==", "cpu": [ "x64" ], diff --git a/com.microsoft.copilot.eclipse.core/copilot-agent/package.json b/com.microsoft.copilot.eclipse.core/copilot-agent/package.json index 7702b184..d6c12fb7 100644 --- a/com.microsoft.copilot.eclipse.core/copilot-agent/package.json +++ b/com.microsoft.copilot.eclipse.core/copilot-agent/package.json @@ -12,11 +12,11 @@ "postinstall": "node copy-binaries.js" }, "dependencies": { - "@github/copilot-language-server": "1.502.5", - "@github/copilot-language-server-win32-x64": "1.502.5", - "@github/copilot-language-server-darwin-x64": "1.502.5", - "@github/copilot-language-server-darwin-arm64": "1.502.5", - "@github/copilot-language-server-linux-x64": "1.502.5", - "@github/copilot-language-server-linux-arm64": "1.502.5" + "@github/copilot-language-server": "1.509.5", + "@github/copilot-language-server-win32-x64": "1.509.5", + "@github/copilot-language-server-darwin-x64": "1.509.5", + "@github/copilot-language-server-darwin-arm64": "1.509.5", + "@github/copilot-language-server-linux-x64": "1.509.5", + "@github/copilot-language-server-linux-arm64": "1.509.5" } } 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 1c853da5..5844c4f7 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 @@ -749,19 +749,15 @@ private String getModelName(CopilotModel activeModel) { } /** - * Builds the {@link ModelInfo} payload to forward with chat requests. Returns {@code null} when no reasoning effort - * is available so we do not send redundant {@code id}/{@code providerName} fields ahead of the future migration - * away from the legacy {@code model}/{@code modelProviderName} fields. Today the language server only consumes - * {@code modelInfo.reasoningEffort}, so suppressing the payload when there is nothing meaningful to forward keeps - * the protocol surface minimal and avoids implicit behaviour changes if the server starts honouring id/providerName - * before the client migration lands. + * Builds the {@link ModelInfo} payload for chat requests. Always sends the concrete model id (which the server + * prefers over the legacy {@code model} family field), since the family is not unique across models. Returns + * {@code null} when no model id is available. */ private static ModelInfo buildModelInfo(CopilotModel activeModel, String reasoningEffort) { - if (StringUtils.isBlank(reasoningEffort)) { + if (activeModel == null || StringUtils.isBlank(activeModel.getId())) { return null; } - String id = activeModel != null ? activeModel.getId() : null; - String providerName = activeModel != null ? activeModel.getProviderName() : null; - return new ModelInfo(id, providerName, reasoningEffort, null); + String effort = StringUtils.isBlank(reasoningEffort) ? null : reasoningEffort; + return new ModelInfo(activeModel.getId(), activeModel.getProviderName(), effort, null); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotModel.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotModel.java index dfc8bd3c..b766f49c 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotModel.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotModel.java @@ -25,6 +25,7 @@ public class CopilotModel { private boolean isChatFallback; private CopilotModelCapabilities capabilities; private CopilotModelBilling billing; + private CopilotModelCustomModel customModel; private String degradationReason; private String providerName; private String modelPickerCategory; @@ -158,6 +159,28 @@ public String toString() { } } + /** + * Metadata describing a custom (BYOK) model that is contributed to the user by an organization or enterprise. When + * present, the model is served through a key that the owner configured, so it is surfaced in the model picker + * automatically without the user having to register their own API key. + * + * @param keyName the display name of the API key the model is served through + * @param ownerName the name of the organization, enterprise, or user that contributed the model + * @param ownerType the type of the owner (e.g. {@code organization}, {@code enterprise}, {@code user}) + * @param provider the underlying model provider (e.g. {@code azure}, {@code openai}) + */ + public record CopilotModelCustomModel(String keyName, String ownerName, String ownerType, String provider) { + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("keyName", keyName); + builder.append("ownerName", ownerName); + builder.append("ownerType", ownerType); + builder.append("provider", provider); + return builder.toString(); + } + } + public String getModelFamily() { return modelFamily; } @@ -246,6 +269,14 @@ public void setBilling(CopilotModelBilling billing) { this.billing = billing; } + public CopilotModelCustomModel getCustomModel() { + return customModel; + } + + public void setCustomModel(CopilotModelCustomModel customModel) { + this.customModel = customModel; + } + public String getDegradationReason() { return degradationReason; } @@ -300,6 +331,7 @@ public boolean equals(Object obj) { } CopilotModel other = (CopilotModel) obj; return Objects.equals(billing, other.billing) && Objects.equals(capabilities, other.capabilities) + && Objects.equals(customModel, other.customModel) && Objects.equals(degradationReason, other.degradationReason) && Objects.equals(id, other.id) && isChatDefault == other.isChatDefault && isChatFallback == other.isChatFallback && Objects.equals(modelFamily, other.modelFamily) && Objects.equals(modelName, other.modelName) @@ -312,8 +344,9 @@ public boolean equals(Object obj) { @Override public int hashCode() { - return Objects.hash(billing, capabilities, degradationReason, id, isChatDefault, isChatFallback, modelFamily, - modelName, modelPickerCategory, modelPickerPriceCategory, modelPolicy, preview, providerName, scopes, vendor); + return Objects.hash(billing, capabilities, customModel, degradationReason, id, isChatDefault, isChatFallback, + modelFamily, modelName, modelPickerCategory, modelPickerPriceCategory, modelPolicy, preview, providerName, + scopes, vendor); } @Override @@ -330,6 +363,7 @@ public String toString() { builder.append("isChatFallback", isChatFallback); builder.append("capabilities", capabilities); builder.append("billing", billing); + builder.append("customModel", customModel); builder.append("degradationReason", degradationReason); builder.append("providerName", providerName); builder.append("modelPickerCategory", modelPickerCategory); diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ModelInfo.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ModelInfo.java index de61df10..e7ff3e84 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ModelInfo.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ModelInfo.java @@ -7,10 +7,10 @@ * Optional model metadata associated with a conversation turn. Mirrors the {@code modelInfo} field on the * {@code conversation/create} and {@code conversation/turn} LSP requests and responses. * - *

The {@code id} and {@code providerName} fields are reserved for a future migration away from the legacy - * {@code model} / {@code modelProviderName} fields and should not be relied upon today. The {@code reasoningEffort} - * field carries the user-selected reasoning effort level (e.g. {@code low}, {@code medium}, {@code high}) when the - * model surfaces selectable effort levels. + *

The language server resolves the turn's model from {@code id} (and {@code providerName} for BYOK models) in + * preference to the legacy {@code model} / {@code modelProviderName} request fields, so {@code id} should carry the + * concrete model id of the user-selected model. The {@code reasoningEffort} field carries the user-selected reasoning + * effort level (e.g. {@code low}, {@code medium}, {@code high}) when the model surfaces selectable effort levels. * * @param id model identifier (optional) * @param providerName provider name (optional) diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtilsTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtilsTests.java index e8448ea6..e2185223 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtilsTests.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtilsTests.java @@ -17,6 +17,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCapabilities; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCapabilitiesLimits; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCapabilitiesSupports; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCustomModel; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotScope; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokModel; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokModelCapabilities; @@ -150,6 +151,27 @@ void testSupportsReasoningEffortLevel_falseWhenCapabilitiesMissing() { assertFalse(ModelUtils.supportsReasoningEffortLevel(null)); } + @Test + void testGetModelSuffix_customModelUsesProvider() { + // Organization-contributed custom models arrive without a providerName but carry their provider in the + // custom-model metadata; the suffix should surface that provider like a BYOK model. + CopilotModel model = new CopilotModel(); + model.setModelName("Sonnet (Org)"); + model.setCustomModel(new CopilotModelCustomModel("Contoso Azure Key", "Contoso", "organization", "Azure")); + + assertEquals("Azure", ModelUtils.getModelSuffix(model, null)); + } + + @Test + void testGetModelSuffix_providerNameTakesPrecedenceOverCustomModel() { + CopilotModel model = new CopilotModel(); + model.setModelName("GPT-4o"); + model.setProviderName("OpenAI"); + model.setCustomModel(new CopilotModelCustomModel("Key", "Contoso", "organization", "Azure")); + + assertEquals("OpenAI", ModelUtils.getModelSuffix(model, null)); + } + @Test void testIsAutoModel() { CopilotModel auto = new CopilotModel(); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ModelPickerGroupsBuilder.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ModelPickerGroupsBuilder.java index fb838528..46a245ee 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ModelPickerGroupsBuilder.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ModelPickerGroupsBuilder.java @@ -66,7 +66,7 @@ public static List build(Map modelMap, List customModels = new ArrayList<>(); for (CopilotModel model : modelMap.values()) { - if (model.getProviderName() != null) { + if (model.getProviderName() != null || model.getCustomModel() != null) { customModels.add(model); } else if (model.getBilling() != null) { if (model.getBilling().isPremium()) { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java index cb3fc2dd..642b71b1 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java @@ -203,6 +203,7 @@ public final class Messages extends NLS { public static String model_hover_cost; public static String model_hover_thinkingEffort; public static String model_hover_thinkingEffort_default_suffix; + public static String model_hover_customModelInfo; public static String model_reasoningEffort_none; public static String model_reasoningEffort_low; public static String model_reasoningEffort_medium; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties index 3974ed60..bb6b4400 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties @@ -197,6 +197,7 @@ model_hover_contextWindow=Context Window: model_hover_cost=Cost: model_hover_thinkingEffort=Thinking Effort model_hover_thinkingEffort_default_suffix={0} (default) +model_hover_customModelInfo={0} is contributed by {1} using {2} model_reasoningEffort_none=None model_reasoningEffort_low=Low model_reasoningEffort_medium=Medium diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ModelHoverContentProvider.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ModelHoverContentProvider.java index 1c963f89..425a6b54 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ModelHoverContentProvider.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ModelHoverContentProvider.java @@ -27,6 +27,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCapabilitiesSupports; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCustomModel; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.services.ModelService; import com.microsoft.copilot.eclipse.ui.i18n.Messages; @@ -76,6 +77,8 @@ public void configureHover(Composite parent, DropdownItem item, Runnable closeRe addWarningRow(parent, model.getDegradationReason()); } + addCustomModelInfoSection(parent, item.getLabel()); + addContextWindowSection(parent); addPricingSection(parent, model.getModelPickerPriceCategory()); addThinkingEffortSection(parent, closeRequest); @@ -89,6 +92,36 @@ private void renderHeader(Composite parent, DropdownItem item) { titleLabel.setLayoutData(headerGd); } + /** + * Renders the "contributed by" line for organization/enterprise-contributed custom (BYOK) models. Mirrors the + * IntelliJ model tooltip: the row only appears when the model carries custom-model metadata with a non-blank owner + * and key name, communicating that the model was provided by an owner through a specific key. + * + * @param parent the hover composite to render into + * @param displayedName the model name as shown in the hover header + */ + private void addCustomModelInfoSection(Composite parent, String displayedName) { + CopilotModelCustomModel customModel = model.getCustomModel(); + if (customModel == null) { + return; + } + String ownerName = StringUtils.trimToNull(customModel.ownerName()); + String keyName = StringUtils.trimToNull(customModel.keyName()); + if (ownerName == null || keyName == null) { + return; + } + + String modelLabel = StringUtils.defaultIfBlank(displayedName, model.getModelName()); + String infoText = NLS.bind(Messages.model_hover_customModelInfo, + new Object[] { modelLabel, ownerName, keyName }); + Label infoLabel = new Label(parent, SWT.WRAP); + infoLabel.setText(infoText); + setCssClass(infoLabel, POPUP_SECONDARY_TEXT_CLASS); + GridData gd = new GridData(SWT.FILL, SWT.NONE, true, false); + gd.verticalIndent = SECTION_SPACING; + infoLabel.setLayoutData(gd); + } + private void addContextWindowSection(Composite parent) { String contextWindowText = ModelUtils.getContextWindowText(model); if (StringUtils.isBlank(contextWindowText)) { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtils.java index f806727f..5919742d 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtils.java @@ -101,6 +101,11 @@ public static String getModelSuffix(CopilotModel model, String reasoningEffort) if (model.getProviderName() != null) { return model.getProviderName(); } + // Organization/enterprise-contributed custom models arrive through copilot/models without a providerName, but + // still carry their underlying provider in the custom-model metadata. Surface it so they read like BYOK models. + if (model.getCustomModel() != null && StringUtils.isNotBlank(model.getCustomModel().provider())) { + return model.getCustomModel().provider(); + } if (isAutoModel(model)) { return Messages.model_billing_multiplier_variable; } From 073b82a3ec5d3b6cc2257ec18fd96079f16a4d04 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Fri, 3 Jul 2026 13:17:12 +0800 Subject: [PATCH 23/27] fix - chat not scrolling to the newest messages in long conversations (#334) --- .../ui/chat/ChatContentViewerTest.java | 115 ++++++ .../eclipse/ui/chat/ChatContentViewer.java | 372 +++++++++++------- 2 files changed, 346 insertions(+), 141 deletions(-) diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewerTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewerTest.java index b181bd9a..16595f17 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewerTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewerTest.java @@ -9,14 +9,17 @@ import static org.mockito.Mockito.when; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.List; import java.util.Map; import org.eclipse.lsp4j.WorkDoneProgressKind; import org.eclipse.swt.SWT; import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.ScrollBar; import org.eclipse.swt.widgets.Shell; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -104,6 +107,118 @@ private Map getTurnsMap(ChatContentViewer viewer) { return (Map) getFieldValue(viewer, "turns"); } + @Test + void testClampOffset_clampsToScrollableRange() { + SwtUtils.invokeOnDisplayThread(() -> { + sizeViewer(400, 300); + setFieldValue(viewer, "totalHeight", 1000); + int maxOffset = invokeIntMethod(viewer, "maxOffset"); + + Assertions.assertTrue(maxOffset > 0, "content taller than the viewport should be scrollable"); + Assertions.assertEquals(0, invokeIntMethod(viewer, "clampOffset", -50), + "negative offset should clamp to 0"); + Assertions.assertEquals(maxOffset, invokeIntMethod(viewer, "clampOffset", 5000), + "offset past the end should clamp to maxOffset"); + Assertions.assertEquals(200, invokeIntMethod(viewer, "clampOffset", 200), + "in-range offset should be left unchanged"); + }); + } + + @Test + void testMaxOffset_isZeroWhenContentFitsViewport() { + SwtUtils.invokeOnDisplayThread(() -> { + sizeViewer(400, 300); + setFieldValue(viewer, "totalHeight", 200); + + Assertions.assertEquals(0, invokeIntMethod(viewer, "maxOffset"), + "content shorter than the viewport should not be scrollable"); + }); + } + + @Test + void testUpdateScrollBar_rangeMatchesTotalHeight() { + SwtUtils.invokeOnDisplayThread(() -> { + sizeViewer(400, 300); + setFieldValue(viewer, "totalHeight", 1000); + setFieldValue(viewer, "scrollOffset", 120); + + invokeVoidMethod(viewer, "updateScrollBar", 300); + + ScrollBar bar = viewer.getVerticalBar(); + Assertions.assertTrue(bar.getEnabled(), "scrollbar should be enabled when content overflows"); + Assertions.assertEquals(1000, bar.getMaximum(), "scrollbar maximum should equal totalHeight"); + Assertions.assertEquals(300, bar.getThumb(), "scrollbar thumb should equal the viewport height"); + Assertions.assertEquals(120, bar.getSelection(), "scrollbar selection should equal scrollOffset"); + }); + } + + @Test + void testUpdateScrollBar_disabledWhenContentFits() { + SwtUtils.invokeOnDisplayThread(() -> { + sizeViewer(400, 300); + setFieldValue(viewer, "totalHeight", 200); + + invokeVoidMethod(viewer, "updateScrollBar", 300); + + Assertions.assertFalse(viewer.getVerticalBar().getEnabled(), + "scrollbar should be disabled when all content fits"); + }); + } + + @Test + void testIsViewportAtBottom_togglesWhenLeavingAndReturning() { + SwtUtils.invokeOnDisplayThread(() -> { + sizeViewer(400, 300); + setFieldValue(viewer, "totalHeight", 1000); + int maxOffset = invokeIntMethod(viewer, "maxOffset"); + + setFieldValue(viewer, "scrollOffset", maxOffset); + Assertions.assertTrue(invokeBooleanMethod(viewer, "isViewportAtBottom"), + "pinned to the very bottom should report at-bottom"); + + setFieldValue(viewer, "scrollOffset", 0); + Assertions.assertFalse(invokeBooleanMethod(viewer, "isViewportAtBottom"), + "scrolled up to the top should report not-at-bottom"); + + // Just inside the bottom threshold (SCROLL_THRESHOLD = 100) should count as at-bottom again. + setFieldValue(viewer, "scrollOffset", maxOffset - 50); + Assertions.assertTrue(invokeBooleanMethod(viewer, "isViewportAtBottom"), + "back within the bottom threshold should report at-bottom again"); + }); + } + + /** Gives the viewer (and its content child) a deterministic client area for scroll-model math. */ + private void sizeViewer(int width, int height) { + viewer.setSize(width, height); + viewer.layout(true, true); + } + + private int invokeIntMethod(Object target, String name, int arg) { + return (int) invokeMethod(target, name, new Class[] {int.class}, arg); + } + + private int invokeIntMethod(Object target, String name) { + return (int) invokeMethod(target, name, new Class[] {}); + } + + private boolean invokeBooleanMethod(Object target, String name) { + return (boolean) invokeMethod(target, name, new Class[] {}); + } + + private void invokeVoidMethod(Object target, String name, int arg) { + invokeMethod(target, name, new Class[] {int.class}, arg); + } + + private Object invokeMethod(Object target, String name, Class[] paramTypes, Object... args) { + try { + Method method = target.getClass().getDeclaredMethod(name, paramTypes); + method.setAccessible(true); + return method.invoke(target, args); + } catch (Exception e) { + throw new RuntimeException("Failed to invoke method " + name, e); + } + } + private Object getFieldValue(Object target, String fieldName) { try { Field field = target.getClass().getDeclaredField(fieldName); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java index 7188df57..83e197e7 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java @@ -5,6 +5,7 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Queue; @@ -18,13 +19,10 @@ import org.eclipse.e4.core.services.events.IEventBroker; import org.eclipse.lsp4j.WorkDoneProgressKind; import org.eclipse.swt.SWT; -import org.eclipse.swt.custom.ScrolledComposite; -import org.eclipse.swt.events.ControlAdapter; -import org.eclipse.swt.events.ControlEvent; -import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Font; +import org.eclipse.swt.graphics.GC; import org.eclipse.swt.graphics.Rectangle; import org.eclipse.swt.layout.GridData; -import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.ScrollBar; @@ -50,8 +48,14 @@ /** * Widget to display chat content. + * + *

A self-managed, windowed vertical scroller (not a {@link org.eclipse.swt.custom.ScrolledComposite}): + * on Windows a classic scroller cannot move very tall content far enough to reveal the newest messages. + * Here {@code cmpContent} is pinned to the viewport and each turn is positioned in + * viewport-local coordinates; off-window turns are parked with {@code setVisible(false)}, so no child is + * ever given an out-of-range native coordinate.

*/ -public class ChatContentViewer extends ScrolledComposite { +public class ChatContentViewer extends Composite { private static final int SCROLL_THRESHOLD = 100; @@ -86,6 +90,18 @@ public class ChatContentViewer extends ScrolledComposite { /** Guards against scheduling more than one pending async refresh from a burst of resize events. */ private final AtomicBoolean refreshScheduled = new AtomicBoolean(false); + /** Current logical scroll position (top of the viewport in content coordinates). */ + private int scrollOffset; + + /** Full logical content height in pixels; may far exceed any native coordinate limit. */ + private int totalHeight; + + /** Cached measured heights keyed by row control identity; invalidated on width change. */ + private final Map heightCache = new IdentityHashMap<>(); + + /** Cached font line height (px), the unit for one scroll line; recomputed when the font changes. */ + private int cachedLineHeight = -1; + /** * Create the composite. * @@ -93,47 +109,46 @@ public class ChatContentViewer extends ScrolledComposite { * @param style the style */ public ChatContentViewer(Composite parent, int style, ChatServiceManager serviceManager) { - super(parent, style | SWT.V_SCROLL); - this.setExpandHorizontal(true); - this.setExpandVertical(true); - this.setLayout(new GridLayout(1, true)); + super(parent, style | SWT.V_SCROLL | SWT.DOUBLE_BUFFERED); + // Null layout: children are positioned manually by relayoutWindow() so SWT never stacks them into + // one oversized composite. + this.setLayout(null); this.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); this.setData(CssConstants.CSS_ID_KEY, "chat-content-viewer"); this.cmpContent = new Composite(this, SWT.NONE); - GridLayout gl = new GridLayout(1, true); - gl.marginHeight = 0; - gl.marginWidth = 0; - this.cmpContent.setLayout(gl); - this.cmpContent.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); - this.setContent(this.cmpContent); - - this.addControlListener(new ControlAdapter() { - @Override - public void controlResized(ControlEvent e) { - // setMinHeight re-fires controlResized; coalesce into one async incremental pass so the - // resize handler never recurses synchronously into layout. - coalesceAsync(refreshScheduled, ChatContentViewer.this::refreshLayoutIncremental); - } + this.cmpContent.setLayout(null); + + this.addListener(SWT.Resize, e -> { + layoutContentArea(); + coalesceAsync(refreshScheduled, this::refreshLayoutIncremental); }); - // Listen for user scroll events to manage auto-scroll behavior ScrollBar verticalBar = this.getVerticalBar(); if (verticalBar != null) { verticalBar.addListener(SWT.Selection, event -> { - int selection = verticalBar.getSelection(); - int maximum = verticalBar.getMaximum(); - int thumb = verticalBar.getThumb(); - - // If scrolled to bottom, keep auto-scroll enabled - // Otherwise disable it (user is viewing history) - int threshold = SCROLL_THRESHOLD; - int maxScrollPosition = maximum - thumb; - boolean isAtBottom = selection >= (maxScrollPosition - threshold); - autoScrollEnabled = isAtBottom; + scrollOffset = verticalBar.getSelection(); + autoScrollEnabled = isViewportAtBottom(); + relayoutWindow(); }); } + // The wheel does not move the scrollbar on a manually-laid-out composite, so handle it explicitly. + this.addListener(SWT.MouseWheel, event -> { + if (totalHeight <= getClientArea().height) { + return; + } + // event.count is the OS-provided number of lines to scroll for this notch; a font line height + // turns it into pixels, so one notch moves whole text lines like a native scrollable. + int newOffset = clampOffset(scrollOffset - event.count * lineHeight()); + if (newOffset != scrollOffset) { + scrollOffset = newOffset; + autoScrollEnabled = isViewportAtBottom(); + relayoutWindow(); + } + event.doit = false; + }); + this.turns = new HashMap<>(); this.activeThinkingBlockIds = new ConcurrentHashMap<>(); @@ -471,11 +486,9 @@ public void renderErrorMessage(String errorMessage) { } /** - * Coalesces a burst of calls into a single async pass on the UI thread. While a pass is already - * scheduled, further calls are dropped; {@code scheduled} is cleared right before {@code task} runs - * so work arriving during the task re-schedules a follow-up pass. This breaks synchronous - * re-entrancy (e.g. a layout pass writing the scroller min size re-fires {@code controlResized}) - * without a re-entrancy guard, while idempotent writes still converge to a fixed point. + * Coalesces a burst of calls into a single async pass on the UI thread, clearing {@code scheduled} + * right before {@code task} runs so work arriving during the task re-schedules a follow-up pass. + * Breaks synchronous re-entrancy without a re-entrancy guard. * * @param scheduled the per-task latch guarding against duplicate scheduling * @param task the work to run once on the next UI-thread turn @@ -509,19 +522,15 @@ private void refreshLayoutIncremental() { * Selects how many turns {@link #refreshLayout(MeasureMode)} re-measures. */ private enum MeasureMode { - /** Recursively re-measure every turn. O(n) in the number of turns. */ + /** Re-measure every turn. */ FULL, - /** Only flush the trailing (mutating) turns; sealed turns keep cached sizes. O(1). */ + /** Only re-measure the trailing (mutating) turns; sealed turns keep cached sizes. */ INCREMENTAL } /** - * Re-measure the scroller and update its min size. - * - * @param mode {@link MeasureMode#FULL} recursively re-measures every turn; {@link - * MeasureMode#INCREMENTAL} only flushes the trailing (mutating) turns while sealed turns keep - * cached sizes, keeping the pass O(1). A width change always upgrades to a full measure because - * text re-wraps. + * Re-measures turns and re-runs the windowing pass. {@link MeasureMode#INCREMENTAL} keeps sealed + * turns' cached heights; a width change forces a full re-measure because text re-wraps. */ private void refreshLayout(MeasureMode mode) { if (this.isDisposed()) { @@ -532,133 +541,200 @@ private void refreshLayout(MeasureMode mode) { boolean fullMeasure = mode == MeasureMode.FULL || width != lastLayoutWidth; lastLayoutWidth = width; - if (!fullMeasure) { - // Only the trailing turns can grow/change during streaming. - flushTrailingTurnCaches(); - } - - Point containerSize = cmpContent.computeSize(width, SWT.DEFAULT, fullMeasure); - - // Use the default size as a fallback - if (latestUserTurn == null) { - this.setMinSize(containerSize); - return; - } - - // Measure at the actual column width, not SWT.DEFAULT: unconstrained width collapses - // soft-wrapped text to a single line and under-estimates the height. roundedHeight must match - // the laid-out height shouldAutoScrollToBottom() reads via getBounds(), or the padding branch - // below reserves phantom whitespace while auto-scroll fires into it (issue #259 flicker). - int userTurnHeight = latestUserTurn.computeSize(width, SWT.DEFAULT).y; - int copilotTurnHeight = latestCopilotTurn == null || latestCopilotTurn.isDisposed() ? 0 - : latestCopilotTurn.computeSize(width, SWT.DEFAULT).y; - - // Calculate the content height, so that the latest user turn is able to be put at the top of the client area. - int contentHeight = 0; - int roundedHeight = userTurnHeight + copilotTurnHeight; - if (roundedHeight < clientArea.height) { - contentHeight = clientArea.height + containerSize.y - roundedHeight; + if (fullMeasure) { + heightCache.clear(); } else { - contentHeight = containerSize.y; + invalidateTrailingTurnHeights(); } - // Only write min size when it changes: setMin* re-fires controlResized, so skipping no-op writes - // lets the coalesced async refresh converge to a fixed point. - if (this.getMinHeight() != contentHeight) { - this.setMinHeight(contentHeight); - } - if (this.getMinWidth() != containerSize.x) { - this.setMinWidth(containerSize.x); - } - // Incremental layout: only re-position the latest (growing) copilot turn instead of recursing - // into all past turns as conversations grow longer. - if (latestCopilotTurn != null && !latestCopilotTurn.isDisposed()) { - cmpContent.layout(new Control[] {latestCopilotTurn}); - } else { - cmpContent.layout(true, false); - } - this.layout(true, false); + layoutContentArea(); + relayoutWindow(); } /** - * Scrolls the viewport to the bottom when auto-scroll is currently enabled. Kept separate from the - * layout pass so refresh and scroll stay independent concerns; callers invoke this after a refresh - * when they want the latest content to stay in view. + * Scrolls to the bottom when auto-scroll is enabled. The bottom padding reserved by {@link + * #relayoutWindow()} makes this pin the latest user turn to the top while the round is short, then + * follow the real bottom once it grows past the viewport. */ public void scrollToBottomIfAutoScroll() { - if (shouldAutoScrollToBottom()) { - scrollToBottom(); + if (this.isDisposed() || latestUserTurn == null || latestUserTurn.isDisposed()) { + return; } + if (!autoScrollEnabled) { + return; + } + scrollOffset = Integer.MAX_VALUE; + relayoutWindow(); } /** - * Flushes the cached layout sizes of the trailing (mutating) turns so the next - * {@code computeSize(width, DEFAULT, false)} re-measures them while sealed historical turns stay - * cached, keeping the layout pass O(1) in the number of historical turns. + * Drops the cached heights of the trailing (mutating) turns so they are re-measured next pass, while + * sealed historical turns keep their cached size. */ - private void flushTrailingTurnCaches() { - List dirty = new ArrayList<>(2); + private void invalidateTrailingTurnHeights() { if (latestUserTurn != null && !latestUserTurn.isDisposed()) { - dirty.add(latestUserTurn); + heightCache.remove(latestUserTurn); } if (latestCopilotTurn != null && !latestCopilotTurn.isDisposed()) { - dirty.add(latestCopilotTurn); + heightCache.remove(latestCopilotTurn); } - if (!dirty.isEmpty()) { - cmpContent.layout(dirty.toArray(new Control[0])); + if (errorWidget != null && !errorWidget.isDisposed()) { + heightCache.remove(errorWidget); + } + } + + /** Pins {@code cmpContent} to the current viewport rectangle so it is never grown or moved. */ + private void layoutContentArea() { + if (cmpContent == null || cmpContent.isDisposed()) { + return; } + Rectangle clientArea = this.getClientArea(); + cmpContent.setBounds(0, 0, Math.max(0, clientArea.width), Math.max(0, clientArea.height)); } /** - * Check if auto-scroll to bottom is needed. Only scroll when auto-scroll is enabled (user hasn't manually scrolled - * during response). + * The core windowing pass: measures every turn (cached), positions the ones intersecting the + * viewport in viewport-local coordinates, and parks the rest with {@code setVisible(false)} so no + * native child ever gets an out-of-range coordinate. */ - private boolean shouldAutoScrollToBottom() { - if (this.isDisposed() || latestUserTurn == null) { - return false; + private void relayoutWindow() { + if (this.isDisposed() || cmpContent == null || cmpContent.isDisposed()) { + return; + } + Rectangle clientArea = this.getClientArea(); + int width = clientArea.width; + int viewport = clientArea.height; + if (width <= 0 || viewport <= 0) { + return; } - if (!autoScrollEnabled) { - return false; + Control[] children = cmpContent.getChildren(); + int[] tops = new int[children.length]; + int[] heights = new int[children.length]; + boolean[] remeasured = new boolean[children.length]; + int running = 0; + int latestUserTop = -1; + for (int i = 0; i < children.length; i++) { + if (children[i] == latestUserTurn) { + latestUserTop = running; + } + boolean wasCached = heightCache.containsKey(children[i]); + int height = measuredHeight(children[i], width); + tops[i] = running; + heights[i] = height; + // A cache miss means the turn was (re)measured this pass: its width changed or its content + // mutated, so its internal GridLayout must be re-run. + remeasured[i] = !wasCached; + running += height; + } + int rawHeight = running; + + // Bottom padding (virtual, no widget): when the last round is shorter than the viewport, reserve + // whitespace below it so the latest user turn can pin to the top instead of floating mid-screen. + // Without it maxOffset is too small, so the new message cannot reach the top and "scroll to + // bottom" misaligns with the real maximum, breaking auto-scroll. + int bottomPadding = 0; + if (latestUserTop >= 0) { + int lastRoundHeight = rawHeight - latestUserTop; + if (lastRoundHeight < viewport) { + bottomPadding = viewport - lastRoundHeight; + } } + totalHeight = rawHeight + bottomPadding; + scrollOffset = clampOffset(scrollOffset); - Rectangle clientArea = this.getClientArea(); - // Use the freshly laid-out bounds rather than computeSize(): the incremental streaming pass - // repositions the trailing turns but does not flush their computeSize cache, so computeSize - // would return a stale height and the auto-scroll trigger would fire seconds too late. - // getBounds() reflects the just-applied layout. - int roundedHeight = currentTurnLaidOutHeight(); + for (int i = 0; i < children.length; i++) { + Control child = children[i]; + if (child.isDisposed()) { + continue; + } + int y = tops[i] - scrollOffset; + if (y + heights[i] > 0 && y < viewport) { + child.setBounds(0, y, width, heights[i]); + if (!child.getVisible()) { + child.setVisible(true); + } + // Run the turn's own layout so its GridLayout children (wrapped text, code blocks, footers) + // reflow. + if (remeasured[i] && child instanceof Composite composite) { + composite.layout(); + } + } else if (child.getVisible()) { + child.setVisible(false); + } + } - // Only auto-scroll when content height exceeds the visible area - return roundedHeight >= clientArea.height; + updateScrollBar(viewport); } - /** - * Returns the height of the latest (streaming) turn from its applied layout bounds. Falls back to - * {@code computeSize} only when bounds are not yet available (before the first layout pass). - */ - private int currentTurnLaidOutHeight() { - int height = 0; - if (latestUserTurn != null && !latestUserTurn.isDisposed()) { - int userHeight = latestUserTurn.getBounds().height; - height += userHeight > 0 ? userHeight : latestUserTurn.computeSize(SWT.DEFAULT, SWT.DEFAULT).y; + /** Returns the measured height of a row, using the identity cache when available. */ + private int measuredHeight(Control child, int width) { + if (child == null || child.isDisposed()) { + return 0; } - if (latestCopilotTurn != null && !latestCopilotTurn.isDisposed()) { - int copilotHeight = latestCopilotTurn.getBounds().height; - height += copilotHeight > 0 ? copilotHeight - : latestCopilotTurn.computeSize(SWT.DEFAULT, SWT.DEFAULT).y; + Integer cached = heightCache.get(child); + if (cached != null) { + return cached; } + int height = child.computeSize(width, SWT.DEFAULT, true).y; + heightCache.put(child, height); return height; } + private void updateScrollBar(int viewport) { + ScrollBar verticalBar = this.getVerticalBar(); + if (verticalBar == null) { + return; + } + if (totalHeight <= viewport) { + int safeViewport = Math.max(1, viewport); + verticalBar.setValues(0, 0, safeViewport, safeViewport, lineHeight(), safeViewport); + verticalBar.setEnabled(false); + return; + } + verticalBar.setEnabled(true); + verticalBar.setValues(scrollOffset, 0, totalHeight, viewport, lineHeight(), viewport); + } + + private int maxOffset() { + return Math.max(0, totalHeight - getClientArea().height); + } + + private int clampOffset(int offset) { + return Math.max(0, Math.min(offset, maxOffset())); + } + + private boolean isViewportAtBottom() { + return scrollOffset >= maxOffset() - SCROLL_THRESHOLD; + } + + /** One scroll "line" in pixels: the current font's line height. Cached until the font changes. */ + private int lineHeight() { + if (cachedLineHeight < 0) { + GC gc = new GC(this); + try { + gc.setFont(getFont()); + cachedLineHeight = Math.max(1, gc.getFontMetrics().getHeight()); + } finally { + gc.dispose(); + } + } + return cachedLineHeight; + } + + @Override + public void setFont(Font font) { + super.setFont(font); + cachedLineHeight = -1; + } + /** * Scroll to the bottom. */ private void scrollToBottom() { - ScrollBar verticalBar = this.getVerticalBar(); - if (verticalBar != null) { - this.setOrigin(0, verticalBar.getMaximum()); - } + autoScrollEnabled = true; + scrollOffset = Integer.MAX_VALUE; + relayoutWindow(); } /** @@ -666,24 +742,38 @@ private void scrollToBottom() { */ private void scrollToLatestUserTurn() { // Scroll to the bottom as a fallback. - if (latestUserTurn == null) { + if (latestUserTurn == null || latestUserTurn.isDisposed()) { scrollToBottom(); return; } - // Async so layout is computed before reading positions; reading synchronously scrolls to 0. + // Async so heights are measured before reading positions. SwtUtils.invokeOnDisplayThreadAsync(() -> { if (this.isDisposed() || latestUserTurn.isDisposed()) { return; } - Point turnLocation = latestUserTurn.getLocation(); - this.setOrigin(0, turnLocation.y); + scrollOffset = clampOffset(topOf(latestUserTurn)); + relayoutWindow(); }, this); } + /** Returns the cumulative top offset (in content coordinates) of the given row control. */ + private int topOf(Control target) { + int width = this.getClientArea().width; + int running = 0; + for (Control child : cmpContent.getChildren()) { + if (child == target) { + break; + } + running += measuredHeight(child, width); + } + return running; + } + @Override public void dispose() { pendingEvents.clear(); + heightCache.clear(); super.dispose(); for (BaseTurnWidget turn : turns.values()) { turn.dispose(); From de828cc7ac60f026d862ae86ef73727995a1424b Mon Sep 17 00:00:00 2001 From: Sheng Chen Date: Tue, 7 Jul 2026 15:49:46 +0800 Subject: [PATCH 24/27] fix: consolidate control validation into layoutNow (#340) * fix: consolidate control validation into layoutNow requestLayout and flushPendingLayoutRequests each filtered out null/ disposed controls via a shared validControls helper before delegating to layoutNow, duplicating the same defensive check at every call site. layoutNow now performs its own null/disposed filtering right where it dereferences the controls (getShell(), shell.layout(...)), so the invariant is enforced in exactly one place instead of trusted by two separate callers. requestLayout becomes a pure dispatcher: it no longer pre-filters, it just forwards the raw controls to pendingLayoutRequests or layoutNow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: repair syntax broken by autofix commit in requestLayout The prior "Potential fix for pull request finding" commit (04e76827) badly spliced a null-check into requestLayout, leaving a dangling duplicate `layoutNow(changed); }` outside the method body and dedenting the whole method. This left invalid Java that failed the Tycho/ECJ compile in CI. Restores correct syntax/indentation while keeping the null-guard around Collections.addAll(pendingLayoutRequests, changed), which is the same defensive check layoutNow already applies to its own `controls` parameter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../copilot/eclipse/ui/chat/ChatView.java | 78 ++++++++++++++++++- .../eclipse/ui/chat/ReferencedFile.java | 9 ++- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java index 3cf6ff19..c5aa62c6 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java @@ -4,7 +4,9 @@ package com.microsoft.copilot.eclipse.ui.chat; import java.util.ArrayList; +import java.util.Collections; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -32,6 +34,7 @@ import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; import org.eclipse.ui.IPartListener2; import org.eclipse.ui.IWorkbenchPartReference; import org.eclipse.ui.PlatformUI; @@ -164,6 +167,9 @@ public class ChatView extends ViewPart implements ChatProgressListener, MessageL private IContextActivation chatViewContextActivation; private IPartListener2 partListener; + /** Controls whose {@link #requestLayout(Control...)} call was suppressed while this view was hidden. */ + private final Set pendingLayoutRequests = new LinkedHashSet<>(); + @Override public void createPartControl(Composite parent) { this.parent = parent; @@ -517,7 +523,12 @@ public void partOpened(IWorkbenchPartReference partRef) { @Override public void partVisible(IWorkbenchPartReference partRef) { - // No action needed + // Replay layout requests skipped while this view was hidden behind another part in the same + // stack, targeted at just the specific control(s) that changed -- avoids a blind full-tree + // relayout of the (possibly long) chat conversation. + if (partRef.getPart(false) == ChatView.this) { + flushPendingLayoutRequests(); + } } }; @@ -1702,16 +1713,75 @@ public void dispose() { getSite().getPage().removePartListener(this.partListener); this.partListener = null; } + pendingLayoutRequests.clear(); deactivateChatViewContext(); super.dispose(); } /** - * Layout the view. + * Request a layout of the given control(s), which must be part of this view's widget tree. + * + *

Passing every control whose content actually changed (rather than a blind {@code + * parent.layout(true, true)}) lets SWT flush exactly those controls' cached sizes -- and only the + * ancestor chains leading to them -- so it never touches unrelated subtrees like the chat + * conversation history. Passing only a subset of what changed (e.g. a text label but not a + * sibling icon label that also changed) will leave the omitted control's cached size stale. + * + *

Skipped while the view isn't visible (e.g. stacked behind another part) to avoid layout cost + * on every editor switch; {@code changed} is instead remembered and replayed by {@link + * #flushPendingLayoutRequests()} once the view becomes visible again. + * + * @param changed every control whose content/layout data just changed + */ + public void requestLayout(Control... changed) { + if (getSite() == null || getSite().getPage() == null || !getSite().getPage().isPartVisible(this)) { + if (changed != null) { + Collections.addAll(pendingLayoutRequests, changed); + } + return; + } + layoutNow(changed); + } + + /** + * Replays layout requests that were suppressed by {@link #requestLayout(Control...)} while this + * view was hidden, targeted at just the specific control(s) that changed. */ - public void layout(boolean changed, boolean all) { - parent.layout(changed, all); + private void flushPendingLayoutRequests() { + if (pendingLayoutRequests.isEmpty()) { + return; + } + // Snapshot and clear before replaying: a control's requestLayout() could in principle + // synchronously trigger a new call back into requestLayout(Control...), which would otherwise + // mutate pendingLayoutRequests while it's being iterated. + Set toFlush = new LinkedHashSet<>(pendingLayoutRequests); + pendingLayoutRequests.clear(); + layoutNow(toFlush.toArray(new Control[0])); + } + + /** + * Flushes cached sizes for exactly the given controls (and the ancestor chains leading to them) + * and lays them out, in a single batched pass. Silently ignores {@code null} or disposed + * controls. + */ + private void layoutNow(Control... controls) { + List valid = new ArrayList<>(); + if (controls != null) { + for (Control control : controls) { + if (control != null && !control.isDisposed()) { + valid.add(control); + } + } + } + if (valid.isEmpty()) { + return; + } + Shell shell = valid.get(0).getShell(); + if (shell == null || shell.isDisposed()) { + return; + } + shell.layout(valid.toArray(new Control[0]), SWT.DEFER); } /** diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ReferencedFile.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ReferencedFile.java index db63a981..e1bf9b25 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ReferencedFile.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ReferencedFile.java @@ -170,7 +170,14 @@ protected void setFile(@Nullable IResource file) { setLayoutData(layoutData); ChatView chatView = UiUtils.getView(Constants.CHAT_VIEW_ID, ChatView.class); if (chatView != null) { - chatView.layout(true, true); + // Pass every child whose content setFile()/setupXDisplay() can touch (icon image, file name + // text/CSS class, close button image) -- not just this composite or a single child. SWT's + // targeted requestLayout() only flushes cached sizes for the exact controls it's given (plus + // their ancestor chains up to cmpFileRef and beyond); passing a subset silently leaves the + // others' cached sizes stale (previously: a clipped icon, and before that a chip that didn't + // shrink). Since all three are descendants of this composite, their ancestor walk-up already + // covers this chip's own RowData/visibility change too -- no need to pass `this` separately. + chatView.requestLayout(lblfileIcon, lblFileName, lblClose); } } From 63b1303eb611aeddde2ed05958c44c60d563fd22 Mon Sep 17 00:00:00 2001 From: Raghunandana Date: Tue, 7 Jul 2026 13:34:22 +0200 Subject: [PATCH 25/27] In Agent mode: Auto scroll to prompts like 'Continue'. (#26) * In Agent mode: Auto scroll to prompts like 'Continue'. User must be made aware some action is needed from them to continue the work by agent. Usually this happens when "Too many requests" or "Command line run prompt" etc. see https://github.com/microsoft/copilot-eclipse-feedback/issues/184 * Fix Checkstyle violations in BaseTurnWidget - Wrap the WarnWidget construction line that exceeded 120 characters - Fix indentation of the return statement to match the enclosing block These were causing the CI build (Checkstyle) to fail on PR #26. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Height adjustment review fix. We can use the target control height directly. No need to calculate by hint. --------- Co-authored-by: Sheng Chen Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../eclipse/ui/chat/BaseTurnWidget.java | 25 ++++++-- .../eclipse/ui/chat/ChatContentViewer.java | 60 ++++++++++++++++++- .../copilot/eclipse/ui/utils/SwtUtils.java | 22 +++++++ 3 files changed, 102 insertions(+), 5 deletions(-) diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java index 1218ce5a..9f099223 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java @@ -23,6 +23,7 @@ import org.eclipse.ui.PlatformUI; import org.osgi.service.event.EventHandler; +import com.microsoft.copilot.eclipse.core.Constants; import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; @@ -36,6 +37,7 @@ import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData; import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData; import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; +import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.services.AvatarService; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; @@ -604,16 +606,16 @@ protected void ensureFooterAtBottom() { * @param code the server error code * @param modelProviderName the BYOK model-provider name, or {@code null} for built-in models */ - protected void createWarnDialog(String message, int code, String modelProviderName) { + protected Composite createWarnDialog(String message, int code, String modelProviderName) { // TODO: Remove this legacy fallback after TBB is officially released. // When the language server has not enabled token-based billing yet, restore the original // main-branch warning behavior (no plan-driven actions; single upgrade button on the legacy // 30-day free trial message). if (!this.serviceManager.getAuthStatusManager().getQuotaStatus().tokenBasedBillingEnabled()) { - new WarnWidget(this, SWT.BOTTOM, message, code); + WarnWidget warnWidget = new WarnWidget(this, SWT.BOTTOM, message, code); ensureFooterAtBottom(); requestLayout(); - return; + return warnWidget; } boolean byokQuotaExceeded = QuotaActions.isByokQuotaExceeded(code, modelProviderName); String displayMessage = byokQuotaExceeded ? Messages.chat_warnWidget_byokQuotaUsageMessage : message; @@ -627,9 +629,11 @@ protected void createWarnDialog(String message, int code, String modelProviderNa && quotaStatus.premiumInteractions().overagePermitted(); canUpgradePlan = quotaStatus.canUpgradePlan(); } - new WarnWidget(this, SWT.NONE, displayMessage, planForActions, overageEnabled, canUpgradePlan); + WarnWidget warnWidget = + new WarnWidget(this, SWT.NONE, displayMessage, planForActions, overageEnabled, canUpgradePlan); ensureFooterAtBottom(); requestLayout(); + return warnWidget; } /** @@ -666,6 +670,19 @@ public CompletableFuture requestToolExecuti this.getParent().requestLayout(); + // Ensure the chat content viewer scrolls to show the newly created confirmation + // dialog. Walk up the composite hierarchy to find a ChatContentViewer + // and request scrolling. Use async exec because layout needs to complete first. + SwtUtils.invokeOnDisplayThreadAsync(() -> { + ChatContentViewer viewer = SwtUtils.findParentOfType(this.getParent(), ChatContentViewer.class); + if (viewer != null) { + if (this.confirmDialog != null && !this.confirmDialog.isDisposed()) { + viewer.showControl(this.confirmDialog); + } + } + + }, this.getParent()); + return toolConfirmationFuture; } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java index 83e197e7..b3db64b4 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java @@ -468,9 +468,17 @@ public BaseTurnWidget getTurnWidget(String turnId) { } private void renderWarnMessageWithUpgradePlanButton(String errorMessage, int code, String modelProviderName) { - latestTurnWidget.createWarnDialog(errorMessage, code, modelProviderName); + Composite warnWidget = latestTurnWidget.createWarnDialog(errorMessage, code, modelProviderName); refreshLayoutFull(); scrollToLatestUserTurn(); + // Ensure the chat content viewer scrolls to show the newly created warning banner. Walk up the composite hierarchy + // to find a ChatContentViewer and request scrolling. Use async exec because layout needs to complete first. + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (warnWidget != null && !warnWidget.isDisposed()) { + showControl(warnWidget); + } + + }, this.getParent()); } /** @@ -483,6 +491,12 @@ public void renderErrorMessage(String errorMessage) { this.errorWidget = new ErrorWidget(cmpContent, SWT.BOTTOM, errorMessage); refreshLayoutFull(); scrollToLatestUserTurn(); + // Ensure the chat content viewer scrolls to show the newly created error banner. + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (this.errorWidget != null && !this.errorWidget.isDisposed()) { + this.showControl(this.errorWidget); + } + }, this.getParent()); } /** @@ -770,6 +784,50 @@ private int topOf(Control target) { return running; } + /** + * Scrolls the viewport to make {@code target} visible, equivalent to + * {@link org.eclipse.swt.custom.ScrolledComposite#showControl(Control)}. + * + *

Walks up the widget tree to find the direct child of {@code cmpContent} that contains + * {@code target}, computes the content-coordinate position of {@code target} by summing + * the turn's {@link #topOf} value with the local y offsets down to {@code target}, then + * adjusts {@link #scrollOffset} by the minimum amount needed to bring {@code target} fully + * into the viewport.

+ */ + public void showControl(Composite target) { + if (target == null || target.isDisposed()) { + return; + } + // Walk up to find the direct child of cmpContent that is the ancestor of target. + Control ancestor = target; + while (ancestor != null && ancestor.getParent() != cmpContent) { + ancestor = ancestor.getParent(); + } + if (ancestor == null || ancestor.getParent() != cmpContent) { + return; + } + // Content-coordinate top of the enclosing turn widget. + int ancestorTop = topOf(ancestor); + // Accumulate the local y offset by walking from target up to (but not including) ancestor. + int localY = 0; + for (Control c = target; c != ancestor; c = c.getParent()) { + localY += c.getLocation().y; + } + int targetTop = ancestorTop + localY; + int targetBottom = targetTop + target.getSize().y; + int viewport = getClientArea().height; + // Scroll the minimum amount: down if target is below the visible area, up if above. + int newOffset = scrollOffset; + if (targetBottom > scrollOffset + viewport) { + newOffset = targetBottom - viewport; + } + if (targetTop < newOffset) { + newOffset = targetTop; + } + scrollOffset = clampOffset(newOffset); + relayoutWindow(); + } + @Override public void dispose() { pendingEvents.clear(); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/SwtUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/SwtUtils.java index 0a054490..73180b8c 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/SwtUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/SwtUtils.java @@ -48,8 +48,30 @@ private SwtUtils() { } private static final String INLINE_ANNOTATION_COLOR_KEY = "org.eclipse.ui.editors.inlineAnnotationColor"; + private static final int DEFAULT_GHOST_TEXT_SCALE = 128; + /** + * Walks up the parent chain of the given control and returns the first ancestor that is an instance of the specified + * type, or {@code null} if none is found. + * + * @param the target type + * @param control the starting control (may be {@code null}) + * @param type the class to search for + * @return the first matching ancestor, or {@code null} + */ + @Nullable + public static T findParentOfType(Control control, Class type) { + Control current = control; + while (current != null) { + if (type.isInstance(current)) { + return type.cast(current); + } + current = current.getParent(); + } + return null; + } + /** * Invokes the given runnable on the display thread. */ From 6754ab7ac33e944dc7464a912abccaac375b4af2 Mon Sep 17 00:00:00 2001 From: Sheng Chen Date: Wed, 8 Jul 2026 14:49:23 +0800 Subject: [PATCH 26/27] Prepare for 0.20.0 (#342) --- CHANGELOG.md | 15 +++++ .../META-INF/MANIFEST.MF | 2 +- .../META-INF/MANIFEST.MF | 2 +- .../META-INF/MANIFEST.MF | 2 +- .../META-INF/MANIFEST.MF | 2 +- .../META-INF/MANIFEST.MF | 2 +- .../META-INF/MANIFEST.MF | 2 +- .../META-INF/MANIFEST.MF | 4 +- .../META-INF/MANIFEST.MF | 2 +- .../feature.xml | 2 +- .../category.xml | 2 +- .../META-INF/MANIFEST.MF | 2 +- .../META-INF/MANIFEST.MF | 4 +- .../META-INF/MANIFEST.MF | 6 +- .../META-INF/MANIFEST.MF | 4 +- .../META-INF/MANIFEST.MF | 4 +- .../META-INF/MANIFEST.MF | 8 +-- .../META-INF/MANIFEST.MF | 6 +- .../whatsnew/0.17.0/context_window_usage.png | Bin 22650 -> 0 bytes .../whatsnew/0.17.0/new_combo_picker.png | Bin 61778 -> 0 bytes .../intro/whatsnew/0.20.0/custom-model.png | Bin 0 -> 33088 bytes .../intro/whatsnew/WHATISNEW.md | 52 +++++++----------- pom.xml | 2 +- 23 files changed, 65 insertions(+), 60 deletions(-) delete mode 100644 com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.17.0/context_window_usage.png delete mode 100644 com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.17.0/new_combo_picker.png create mode 100644 com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.20.0/custom-model.png diff --git a/CHANGELOG.md b/CHANGELOG.md index e58cf111..bdd3076e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.20.0 +### Added +- Support organization-enabled custom (BYOK) models automatically in the model picker. [PR#333](https://github.com/microsoft/copilot-for-eclipse/pull/333) + +### Changed +- Move Copilot Menu to the left of the Help menu. [#287](https://github.com/microsoft/copilot-for-eclipse/issues/287) + +### Fixed +- Chat cannot be scrolled down to see the newest messages in long conversations. [#63](https://github.com/microsoft/copilot-for-eclipse/issues/63) +- Copilot asks read permission for a global skill file. [#318](https://github.com/microsoft/copilot-for-eclipse/issues/318) +- Detailed model information on dropdown hover is cropped on Linux. [#113](https://github.com/microsoft/copilot-for-eclipse/issues/113), contributed by [@travkin79](https://github.com/travkin79) +- Automatically scroll the chat view to prompts requiring user action (e.g. "Continue") in Agent mode. [#120](https://github.com/microsoft/copilot-for-eclipse/issues/120), contributed by [@raghucssit](https://github.com/raghucssit) +- Agents become extremely slow due to expensive chat view re-layout on every streamed chunk. [#259](https://github.com/microsoft/copilot-for-eclipse/issues/259) +- UI freezes on editor switch when the Chat view has a long conversation. [#335](https://github.com/microsoft/copilot-for-eclipse/issues/335) + ## 0.19.0 ### Added - Improve terminal command execution across Windows and Linux. [PR#247](https://github.com/microsoft/copilot-for-eclipse/pull/247) diff --git a/com.microsoft.copilot.eclipse.branding/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.branding/META-INF/MANIFEST.MF index f4eb15ed..7a539765 100644 --- a/com.microsoft.copilot.eclipse.branding/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.branding/META-INF/MANIFEST.MF @@ -2,6 +2,6 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: GitHub Copilot Bundle-SymbolicName: com.microsoft.copilot.eclipse.branding;singleton:=true -Bundle-Version: 0.19.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Automatic-Module-Name: com.microsoft.copilot.eclipse.branding diff --git a/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/META-INF/MANIFEST.MF index a9bdb552..e13e44f3 100644 --- a/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/META-INF/MANIFEST.MF @@ -3,7 +3,7 @@ Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core.agent.linux.aarch64 Bundle-SymbolicName: com.microsoft.copilot.eclipse.core.agent.linux.aarch64 Automatic-Module-Name: com.microsoft.copilot.eclipse.core.agent.linux.aarch64 -Bundle-Version: 0.19.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Fragment-Host: com.microsoft.copilot.eclipse.core Bundle-RequiredExecutionEnvironment: JavaSE-17 diff --git a/com.microsoft.copilot.eclipse.core.agent.linux.x64/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core.agent.linux.x64/META-INF/MANIFEST.MF index 3de71350..b8a762ac 100644 --- a/com.microsoft.copilot.eclipse.core.agent.linux.x64/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core.agent.linux.x64/META-INF/MANIFEST.MF @@ -3,7 +3,7 @@ Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core.agent.linux.x64 Bundle-SymbolicName: com.microsoft.copilot.eclipse.core.agent.linux.x64 Automatic-Module-Name: com.microsoft.copilot.eclipse.core.agent.linux.x64 -Bundle-Version: 0.19.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Fragment-Host: com.microsoft.copilot.eclipse.core Bundle-RequiredExecutionEnvironment: JavaSE-17 diff --git a/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/META-INF/MANIFEST.MF index b340fbec..d2a33578 100644 --- a/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/META-INF/MANIFEST.MF @@ -3,7 +3,7 @@ Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core.agent.macosx.aarch64 Bundle-SymbolicName: com.microsoft.copilot.eclipse.core.agent.macosx.aarch64 Automatic-Module-Name: com.microsoft.copilot.eclipse.core.agent.macosx.aarch64 -Bundle-Version: 0.19.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Fragment-Host: com.microsoft.copilot.eclipse.core Bundle-RequiredExecutionEnvironment: JavaSE-17 diff --git a/com.microsoft.copilot.eclipse.core.agent.macosx.x64/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core.agent.macosx.x64/META-INF/MANIFEST.MF index 4674eab8..a6a5e998 100644 --- a/com.microsoft.copilot.eclipse.core.agent.macosx.x64/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core.agent.macosx.x64/META-INF/MANIFEST.MF @@ -3,7 +3,7 @@ Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core.agent.macosx.x64 Bundle-SymbolicName: com.microsoft.copilot.eclipse.core.agent.macosx.x64 Automatic-Module-Name: com.microsoft.copilot.eclipse.core.agent.macosx.x64 -Bundle-Version: 0.19.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Fragment-Host: com.microsoft.copilot.eclipse.core Bundle-RequiredExecutionEnvironment: JavaSE-17 diff --git a/com.microsoft.copilot.eclipse.core.agent.win32/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core.agent.win32/META-INF/MANIFEST.MF index 4bffe01f..b9c752e5 100644 --- a/com.microsoft.copilot.eclipse.core.agent.win32/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core.agent.win32/META-INF/MANIFEST.MF @@ -3,7 +3,7 @@ Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core.agent.win32 Bundle-SymbolicName: com.microsoft.copilot.eclipse.core.agent.win32 Automatic-Module-Name: com.microsoft.copilot.eclipse.core.agent.win32 -Bundle-Version: 0.19.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Fragment-Host: com.microsoft.copilot.eclipse.core Bundle-RequiredExecutionEnvironment: JavaSE-17 diff --git a/com.microsoft.copilot.eclipse.core.test/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core.test/META-INF/MANIFEST.MF index f42e08b2..efcd8dba 100644 --- a/com.microsoft.copilot.eclipse.core.test/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core.test/META-INF/MANIFEST.MF @@ -2,14 +2,14 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core.test Bundle-SymbolicName: com.microsoft.copilot.eclipse.core.test;singleton:=true -Bundle-Version: 0.19.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-RequiredExecutionEnvironment: JavaSE-17 Fragment-Host: com.microsoft.copilot.eclipse.core Automatic-Module-Name: com.microsoft.copilot.eclipse.core.test Import-Package: org.objenesis;version="[3.4.0,4.0.0)", org.osgi.framework;version="[1.10.0,2.0.0)" -Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.19.0", +Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.20.0", org.mockito.mockito-core;bundle-version="5.14.2", org.eclipse.lsp4e;bundle-version="0.18.1", org.eclipse.jdt.annotation;resolution:=optional, diff --git a/com.microsoft.copilot.eclipse.core/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core/META-INF/MANIFEST.MF index 024935d8..9b5eb2d0 100644 --- a/com.microsoft.copilot.eclipse.core/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core Bundle-SymbolicName: com.microsoft.copilot.eclipse.core;singleton:=true -Bundle-Version: 0.19.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Export-Package: com.microsoft.copilot.eclipse.core, com.microsoft.copilot.eclipse.core.chat, diff --git a/com.microsoft.copilot.eclipse.feature/feature.xml b/com.microsoft.copilot.eclipse.feature/feature.xml index 2418d3ba..f53843fa 100644 --- a/com.microsoft.copilot.eclipse.feature/feature.xml +++ b/com.microsoft.copilot.eclipse.feature/feature.xml @@ -2,7 +2,7 @@ diff --git a/com.microsoft.copilot.eclipse.repository/category.xml b/com.microsoft.copilot.eclipse.repository/category.xml index 628567ee..394c11c3 100644 --- a/com.microsoft.copilot.eclipse.repository/category.xml +++ b/com.microsoft.copilot.eclipse.repository/category.xml @@ -1,6 +1,6 @@ - + diff --git a/com.microsoft.copilot.eclipse.swtbot.test/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.swtbot.test/META-INF/MANIFEST.MF index ee703343..8fbc5c1a 100644 --- a/com.microsoft.copilot.eclipse.swtbot.test/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.swtbot.test/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.swtbot.test Bundle-SymbolicName: com.microsoft.copilot.eclipse.swtbot.test;singleton:=true -Bundle-Version: 0.19.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-RequiredExecutionEnvironment: JavaSE-17 Automatic-Module-Name: com.microsoft.copilot.eclipse.swtbot.test diff --git a/com.microsoft.copilot.eclipse.terminal.api/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.terminal.api/META-INF/MANIFEST.MF index ffca534c..75b5a994 100644 --- a/com.microsoft.copilot.eclipse.terminal.api/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.terminal.api/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.terminal.api Bundle-SymbolicName: com.microsoft.copilot.eclipse.terminal.api;singleton:=true -Bundle-Version: 0.19.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Export-Package: com.microsoft.copilot.eclipse.terminal.api Automatic-Module-Name: com.microsoft.copilot.eclipse.terminal.api @@ -10,7 +10,7 @@ Bundle-ActivationPolicy: lazy Bundle-RequiredExecutionEnvironment: JavaSE-17 Import-Package: org.osgi.framework;version="[1.10.0,2.0.0)" Require-Bundle: org.eclipse.core.runtime;bundle-version="[3.30.0,4.0.0)", - com.microsoft.copilot.eclipse.core;bundle-version="0.19.0", + com.microsoft.copilot.eclipse.core;bundle-version="0.20.0", org.eclipse.jface, org.eclipse.swt, org.eclipse.ui.workbench, diff --git a/com.microsoft.copilot.eclipse.ui.jobs/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui.jobs/META-INF/MANIFEST.MF index eed9986d..7c499bb2 100644 --- a/com.microsoft.copilot.eclipse.ui.jobs/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui.jobs/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: GitHub Copilot Jobs Bundle-SymbolicName: com.microsoft.copilot.eclipse.ui.jobs;singleton:=true -Bundle-Version: 0.19.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-Activator: com.microsoft.copilot.eclipse.ui.jobs.CopilotJobs Bundle-RequiredExecutionEnvironment: JavaSE-17 @@ -10,8 +10,8 @@ Bundle-Localization: plugin Automatic-Module-Name: com.microsoft.copilot.eclipse.ui.jobs Bundle-ActivationPolicy: lazy Import-Package: org.osgi.framework;version="[1.10.0,2.0.0)" -Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.19.0", - com.microsoft.copilot.eclipse.ui;bundle-version="0.19.0", +Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.20.0", + com.microsoft.copilot.eclipse.ui;bundle-version="0.20.0", jakarta.annotation-api, jakarta.inject.jakarta.inject-api, org.apache.commons.lang3;bundle-version="3.13.0", diff --git a/com.microsoft.copilot.eclipse.ui.terminal.tm/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui.terminal.tm/META-INF/MANIFEST.MF index 71145643..0dd06aa3 100644 --- a/com.microsoft.copilot.eclipse.ui.terminal.tm/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui.terminal.tm/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.ui.terminal.tm Bundle-SymbolicName: com.microsoft.copilot.eclipse.ui.terminal.tm;singleton:=true -Bundle-Version: 0.19.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-RequiredExecutionEnvironment: JavaSE-17 Automatic-Module-Name: com.microsoft.copilot.eclipse.ui.terminal.tm @@ -10,7 +10,7 @@ Bundle-ActivationPolicy: lazy Service-Component: OSGI-INF/component.xml Import-Package: org.osgi.framework;version="[1.10.0,2.0.0)", org.osgi.service.component -Require-Bundle: com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.19.0", +Require-Bundle: com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.20.0", org.eclipse.tm.terminal.view.core, org.eclipse.tm.terminal.view.ui, org.eclipse.tm.terminal.control, diff --git a/com.microsoft.copilot.eclipse.ui.terminal/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui.terminal/META-INF/MANIFEST.MF index b961e910..5fdd2792 100644 --- a/com.microsoft.copilot.eclipse.ui.terminal/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui.terminal/META-INF/MANIFEST.MF @@ -1,7 +1,7 @@ Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.ui.terminal Bundle-SymbolicName: com.microsoft.copilot.eclipse.ui.terminal;singleton:=true -Bundle-Version: 0.19.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-RequiredExecutionEnvironment: JavaSE-17 Automatic-Module-Name: com.microsoft.copilot.eclipse.ui.terminal @@ -9,7 +9,7 @@ Bundle-ActivationPolicy: lazy Service-Component: OSGI-INF/component.xml Import-Package: org.osgi.framework;version="[1.10.0,2.0.0)", org.osgi.service.component -Require-Bundle: com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.19.0", +Require-Bundle: com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.20.0", org.eclipse.terminal.view.core, org.eclipse.terminal.view.ui, org.eclipse.terminal.control, diff --git a/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF index 6d01be66..9d33676c 100644 --- a/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.ui.test Bundle-SymbolicName: com.microsoft.copilot.eclipse.ui.test;singleton:=true -Bundle-Version: 0.19.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-RequiredExecutionEnvironment: JavaSE-17 Automatic-Module-Name: com.microsoft.copilot.eclipse.ui.test @@ -14,8 +14,8 @@ Require-Bundle: org.mockito.mockito-core;bundle-version="5.14.2", junit-jupiter-api;bundle-version="5.10.1", junit-jupiter-params;bundle-version="5.10.1", org.mockito.junit-jupiter;bundle-version="5.10.2", - com.microsoft.copilot.eclipse.core;bundle-version="0.19.0", - com.microsoft.copilot.eclipse.ui;bundle-version="0.19.0", + com.microsoft.copilot.eclipse.core;bundle-version="0.20.0", + com.microsoft.copilot.eclipse.ui;bundle-version="0.20.0", org.eclipse.ui;bundle-version="3.205.0", org.eclipse.ui.ide, org.eclipse.ui.workbench.texteditor, @@ -29,4 +29,4 @@ Require-Bundle: org.mockito.mockito-core;bundle-version="5.14.2", org.eclipse.e4.core.services;bundle-version="2.4.200", org.osgi.service.event, com.google.gson, - com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.19.0" + com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.20.0" diff --git a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF index ca0e9872..dc86d49a 100644 --- a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.ui Bundle-SymbolicName: com.microsoft.copilot.eclipse.ui;singleton:=true -Bundle-Version: 0.19.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-Localization: plugin Export-Package: com.microsoft.copilot.eclipse.ui, @@ -25,8 +25,8 @@ Bundle-RequiredExecutionEnvironment: JavaSE-17 Automatic-Module-Name: com.microsoft.copilot.eclipse.ui Bundle-ActivationPolicy: lazy Import-Package: org.osgi.framework;version="[1.10.0,2.0.0)" -Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.19.0", - com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.19.0", +Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.20.0", + com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.20.0", org.eclipse.ui.ide;bundle-version="3.22.0", org.eclipse.ui.workbench.texteditor;bundle-version="3.17.200", org.eclipse.ui.editors;bundle-version="3.17.100", diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.17.0/context_window_usage.png b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.17.0/context_window_usage.png deleted file mode 100644 index 7d62deef9aa8c421011f39e0454ffa358c662c20..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22650 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
0>=dGf$gqFgex9N< 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 diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.20.0/custom-model.png b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.20.0/custom-model.png new file mode 100644 index 0000000000000000000000000000000000000000..b767bbdb6b80bb060bc4f8ecad11fd9d2658d7b9 GIT binary patch literal 33088 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 zOLYL9s${base.name} - 0.19.0-SNAPSHOT + 0.20.0-SNAPSHOT GitHub Copilot 4.0.13 3.6.0 From 65db401cdb4ea09c8a6c301126427a2ca6cd5480 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Thu, 9 Jul 2026 14:08:05 +0800 Subject: [PATCH 27/27] fix - stale code block height during incremental chat layout (#343) --- .../ui/chat/SourceViewerComposite.java | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SourceViewerComposite.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SourceViewerComposite.java index b661f77b..b1e737a8 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SourceViewerComposite.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SourceViewerComposite.java @@ -213,6 +213,24 @@ private Button createActionButton(Composite parent, int style, Image image, Stri return result; } + @Override + public Point computeSize(int widthHint, int heightHint, boolean changed) { + if (this.sourceViewer == null) { + return super.computeSize(widthHint, heightHint, changed); + } + + StyledText textWidget = this.sourceViewer.getTextWidget(); + if (textWidget == null || textWidget.isDisposed()) { + return super.computeSize(widthHint, heightHint, changed); + } + + Point textSize = textWidget.computeSize(widthHint, SWT.DEFAULT, changed); + Rectangle trim = computeTrim(0, 0, textSize.x, getSourceViewerHeight(textWidget, textSize)); + int width = widthHint == SWT.DEFAULT ? trim.width : widthHint; + int height = heightHint == SWT.DEFAULT ? trim.height : heightHint; + return new Point(Math.max(0, width), Math.max(0, height)); + } + private void refreshScrollerLayout() { if (this.sourceViewer == null) { return; @@ -224,16 +242,20 @@ private void refreshScrollerLayout() { } Point size = textWidget.computeSize(SWT.DEFAULT, SWT.DEFAULT); Rectangle clientArea = this.getClientArea(); - // remove scroll-bar height - ScrollBar horizontalBar = textWidget.getHorizontalBar(); - int scrollbarHeight = horizontalBar != null ? horizontalBar.getSize().y : 0; - int height = size.y - scrollbarHeight; + int height = getSourceViewerHeight(textWidget, size); // Set bounds on SourceViewer's control (the direct child), not just the textWidget this.sourceViewer.getControl().setBounds(0, 0, clientArea.width, height); textWidget.redraw(); }); } + private int getSourceViewerHeight(StyledText textWidget, Point textSize) { + // remove scroll-bar height + ScrollBar horizontalBar = textWidget.getHorizontalBar(); + int scrollbarHeight = horizontalBar != null ? horizontalBar.getSize().y : 0; + return Math.max(0, textSize.y - scrollbarHeight); + } + private void insert(Event e) { String content = this.sourceViewer.getDocument().get(); if (StringUtils.isNotEmpty(content)) {