From 99de6902120d213d325f0f0feffd6c9d034c6990 Mon Sep 17 00:00:00 2001 From: Ethan Hou Date: Mon, 11 May 2026 15:36:19 +0800 Subject: [PATCH 1/9] feat: Refactor quota usage menu and UI updates --- .../eclipse/core/AuthStatusManagerTests.java | 2 +- .../eclipse/core/AuthStatusManager.java | 2 +- .../lsp/protocol/quota/CheckQuotaResult.java | 110 ++------ .../core/lsp/protocol/quota/CopilotPlan.java | 2 +- .../core/lsp/protocol/quota/Quota.java | 96 +++---- .../protocol/quota/QuotaSnapshotParams.java | 20 ++ .../eclipse/ui/chat/ChatContentViewer.java | 9 +- .../ui/chat/services/ModelService.java | 2 +- .../ui/handlers/QuotaTextCalculator.java | 83 ++++-- .../ui/handlers/ShowMenuBarMenuHandler.java | 184 +++++++------ .../ui/handlers/ShowStatusBarMenuHandler.java | 144 +++++------ .../copilot/eclipse/ui/i18n/Messages.java | 30 ++- .../eclipse/ui/i18n/messages.properties | 31 ++- .../copilot/eclipse/ui/utils/MenuUtils.java | 241 ++++++++++++++++++ 14 files changed, 593 insertions(+), 363 deletions(-) create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaSnapshotParams.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/MenuUtils.java diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/AuthStatusManagerTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/AuthStatusManagerTests.java index 0514c259..64481d68 100644 --- a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/AuthStatusManagerTests.java +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/AuthStatusManagerTests.java @@ -55,7 +55,7 @@ void testSignInConfirm() throws InterruptedException, ExecutionException { when(mockResult.getUser()).thenReturn(mockedUser); when(mockResult.getStatus()).thenReturn(CopilotStatusResult.OK); when(mockConnection.signInConfirm(userCode)).thenReturn(CompletableFuture.completedFuture(mockResult)); - when(mockConnection.checkQuota()).thenReturn(CompletableFuture.completedFuture(new CheckQuotaResult())); + when(mockConnection.checkQuota()).thenReturn(CompletableFuture.completedFuture(CheckQuotaResult.empty())); CopilotStatusResult result = authStatusManager.signInConfirm(userCode); diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/AuthStatusManager.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/AuthStatusManager.java index b6e598c8..21284a15 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/AuthStatusManager.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/AuthStatusManager.java @@ -182,7 +182,7 @@ public void setQuotaStatus(CheckQuotaResult checkQuotaResult) { */ public CheckQuotaResult getQuotaStatus() { if (this.checkQuotaResult == null) { - this.checkQuotaResult = new CheckQuotaResult(); + this.checkQuotaResult = CheckQuotaResult.empty(); } return this.checkQuotaResult; } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CheckQuotaResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CheckQuotaResult.java index 64f34dc4..fcda0063 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CheckQuotaResult.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CheckQuotaResult.java @@ -3,90 +3,34 @@ package com.microsoft.copilot.eclipse.core.lsp.protocol.quota; -import java.util.Objects; - -import org.apache.commons.lang3.builder.ToStringBuilder; - /** - * Result of the checkQuota request. + * Result of the {@code checkQuota} request. + * + * @param chat chat quota snapshot + * @param completions completions quota snapshot + * @param premiumInteractions premium interactions quota snapshot + * @param resetDate ISO-8601 local date when the monthly allowance resets, or {@code null} + * @param resetDateUtc ISO-8601 instant when the monthly allowance resets in UTC, or {@code null} + * @param copilotPlan the user's Copilot plan + * @param tokenBasedBillingEnabled whether the user's billing is token-based */ -public class CheckQuotaResult { - private Quota chat; - private Quota completions; - private Quota premiumInteractions; - private String resetDate; - private CopilotPlan copilotPlan; - - public Quota getChatQuota() { - return chat; - } - - public void setChatQuota(Quota chat) { - this.chat = chat; - } - - public Quota getCompletionsQuota() { - return completions; - } - - public void setCompletionsQuota(Quota completions) { - this.completions = completions; - } - - public Quota getPremiumInteractionsQuota() { - return premiumInteractions; - } - - public void setPremiumInteractionsQuota(Quota premiumInteractions) { - this.premiumInteractions = premiumInteractions; - } - - public String getResetDate() { - return resetDate; - } - - public void setResetDate(String resetDate) { - this.resetDate = resetDate; - } - - public CopilotPlan getCopilotPlan() { - return copilotPlan; - } - - public void setCopilotPlan(CopilotPlan copilotPlan) { - this.copilotPlan = copilotPlan; - } - - @Override - public int hashCode() { - return Objects.hash(chat, completions, copilotPlan, premiumInteractions, resetDate); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null) { - return false; - } - if (getClass() != obj.getClass()) { - return false; - } - CheckQuotaResult other = (CheckQuotaResult) obj; - return Objects.equals(chat, other.chat) && Objects.equals(completions, other.completions) - && copilotPlan == other.copilotPlan && Objects.equals(premiumInteractions, other.premiumInteractions) - && Objects.equals(resetDate, other.resetDate); - } - - @Override - public String toString() { - ToStringBuilder builder = new ToStringBuilder(this); - builder.append("chat", chat); - builder.append("completions", completions); - builder.append("premiumInteractions", premiumInteractions); - builder.append("resetDate", resetDate); - builder.append("copilotPlan", copilotPlan); - return builder.toString(); +public record CheckQuotaResult( + Quota chat, + Quota completions, + Quota premiumInteractions, + String resetDate, + String resetDateUtc, + CopilotPlan copilotPlan, + boolean tokenBasedBillingEnabled) { + + private static final CheckQuotaResult EMPTY = + new CheckQuotaResult(null, null, null, null, null, null, false); + + /** + * Returns an empty {@link CheckQuotaResult} used as a placeholder before the language server + * supplies real quota data. + */ + public static CheckQuotaResult empty() { + return EMPTY; } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CopilotPlan.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CopilotPlan.java index 674dc6a0..d0b2b222 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CopilotPlan.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CopilotPlan.java @@ -7,5 +7,5 @@ * Enum representing the different Copilot plans. */ public enum CopilotPlan { - free, individual, individual_pro, business, enterprise + free, individual, individual_pro, individual_max, business, enterprise } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/Quota.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/Quota.java index 9d712884..75ec40c1 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/Quota.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/Quota.java @@ -5,60 +5,41 @@ import java.util.Objects; -import org.apache.commons.lang3.builder.ToStringBuilder; - /** - * Completions quota information. + * Quota information for a single tracked category (chat, completions, or premium interactions). + * + *

Equality intentionally excludes {@link #timeStamp} so that two snapshots with the same + * display-meaningful state compare equal even when the language server stamps a different + * production time on each refresh. + * + * @param percentRemaining percentage of the quota remaining; clamped into {@code [0.0, 100.0]} by + * the accessor since the language server may report drift slightly outside that range + * @param unlimited whether this category has no monthly limit + * @param overagePermitted whether the user has enabled additional paid usage beyond the allowance + * @param overageCount additional paid units already consumed, when reported + * @param entitlement total monthly allowance, when reported + * @param quotaRemaining absolute units remaining in the monthly allowance, when reported + * @param timeStamp ISO-8601 timestamp of when the snapshot was produced by the language server; + * not part of {@link #equals(Object)} / {@link #hashCode()} */ -public class Quota { - private double percentRemaining; - private boolean unlimited; - private boolean overagePermitted; - - /** - * Creates a new CompletionsQuota quota information with default values. - */ - public Quota() { - this.percentRemaining = 0.0; - this.unlimited = false; - this.overagePermitted = false; - } +public record Quota( + double percentRemaining, + boolean unlimited, + boolean overagePermitted, + double overageCount, + double entitlement, + double quotaRemaining, + String timeStamp) { /** - * Gets the percentage of the quota remaining within the range of 0.0 to 100.0. + * Returns the percentage of the quota remaining, clamped into the {@code [0.0, 100.0]} range. */ - public double getPercentRemaining() { + public Quota { if (percentRemaining < 0.0) { - return 0.0; + percentRemaining = 0.0; } else if (percentRemaining > 100.0) { - return 100.0; + percentRemaining = 100.0; } - return percentRemaining; - } - - public void setPercentRemaining(double percentRemaining) { - this.percentRemaining = percentRemaining; - } - - public boolean isUnlimited() { - return unlimited; - } - - public void setUnlimited(boolean unlimited) { - this.unlimited = unlimited; - } - - public boolean isOveragePermitted() { - return overagePermitted; - } - - public void setOveragePermitted(boolean overagePermitted) { - this.overagePermitted = overagePermitted; - } - - @Override - public int hashCode() { - return Objects.hash(overagePermitted, percentRemaining, unlimited); } @Override @@ -66,24 +47,19 @@ public boolean equals(Object obj) { if (this == obj) { return true; } - if (obj == null) { + if (!(obj instanceof Quota other)) { return false; } - if (getClass() != obj.getClass()) { - return false; - } - Quota other = (Quota) obj; - return overagePermitted == other.overagePermitted - && Double.doubleToLongBits(percentRemaining) == Double.doubleToLongBits(other.percentRemaining) - && unlimited == other.unlimited; + return Double.compare(percentRemaining, other.percentRemaining) == 0 + && unlimited == other.unlimited + && overagePermitted == other.overagePermitted + && Double.compare(overageCount, other.overageCount) == 0 + && Double.compare(entitlement, other.entitlement) == 0 + && Double.compare(quotaRemaining, other.quotaRemaining) == 0; } @Override - public String toString() { - ToStringBuilder builder = new ToStringBuilder(this); - builder.append("percentRemaining", percentRemaining); - builder.append("unlimited", unlimited); - builder.append("overagePermitted", overagePermitted); - return builder.toString(); + public int hashCode() { + return Objects.hash(percentRemaining, unlimited, overagePermitted, overageCount, entitlement, quotaRemaining); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaSnapshotParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaSnapshotParams.java new file mode 100644 index 00000000..d4500ad1 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaSnapshotParams.java @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol.quota; + +/** + * Snapshot of a single quota bucket (chat, completions, or premium interactions) shipped with + * {@code copilot/quotaChange} and {@code copilot/quotaWarning} notifications. + * + * @param quota total entitlement + * @param used computed amount used (entitlement * (1 - percentRemaining / 100)) + * @param percentRemaining percentage of the quota remaining (0-100) + * @param overageUsed overage amount consumed + * @param overageEnabled whether overages are permitted + * @param resetDate ISO 8601 timestamp when the quota resets, or empty when unknown + * @param unlimited true when the quota is unlimited + */ +public record QuotaSnapshotParams(double quota, double used, double percentRemaining, double overageUsed, + boolean overageEnabled, String resetDate, boolean unlimited) { +} 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 a36a66cb..06c3e4c0 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 @@ -38,6 +38,7 @@ import com.microsoft.copilot.eclipse.ui.chat.services.TodoListService; import com.microsoft.copilot.eclipse.ui.i18n.Messages; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; +import com.microsoft.copilot.eclipse.ui.utils.MenuUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; /** @@ -218,13 +219,13 @@ public void processTurnEvent(ChatProgressValue value) { if (StringUtils.isNotEmpty(errMsg)) { // TODO: remove this error message replacement if statement when the CLS side warn message is aligned. if (value.getCode() == 402) { - CopilotPlan userPlan = this.serviceManager.getAuthStatusManager().getQuotaStatus().getCopilotPlan(); + CopilotPlan userPlan = this.serviceManager.getAuthStatusManager().getQuotaStatus().copilotPlan(); CopilotModel fallbackModel = this.serviceManager.getModelService().getFallbackModel(); String fallbackModelName = fallbackModel != null ? fallbackModel.getModelName() : Messages.chat_noQuotaView_fallbackModel; - if (userPlan == CopilotPlan.individual || userPlan == CopilotPlan.individual_pro) { - // Pro and Pro+ message + 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 @@ -235,7 +236,7 @@ public void processTurnEvent(ChatProgressValue value) { renderWarnMessageWithUpgradePlanButton(errMsg, value.getCode()); if (value.getCode() == 402 - && this.serviceManager.getAuthStatusManager().getQuotaStatus().getCopilotPlan() != CopilotPlan.free) { + && this.serviceManager.getAuthStatusManager().getQuotaStatus().copilotPlan() != CopilotPlan.free) { this.serviceManager.getModelService().setFallBackModelAsActiveModel(); this.serviceManager.getAuthStatusManager().checkQuota(); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ModelService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ModelService.java index 3233fa33..2d302d05 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ModelService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ModelService.java @@ -424,7 +424,7 @@ public void bindModelPicker(final DropdownButton picker) { }, (Map modelMap) -> { if (!picker.isDisposed()) { boolean showAddPremiumModelOption = this.authStatusManager.getQuotaStatus() - .getCopilotPlan() == CopilotPlan.free; + .copilotPlan() == CopilotPlan.free; // TODO: need to remove this logic after group policy is available FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); boolean showByokManageOption = flags == null || flags.isByokEnabled(); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/QuotaTextCalculator.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/QuotaTextCalculator.java index 15519b40..605dc10c 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/QuotaTextCalculator.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/QuotaTextCalculator.java @@ -3,6 +3,7 @@ package com.microsoft.copilot.eclipse.ui.handlers; +import org.eclipse.osgi.util.NLS; import org.eclipse.swt.graphics.GC; import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CheckQuotaResult; @@ -10,6 +11,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.Quota; import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; import com.microsoft.copilot.eclipse.ui.i18n.Messages; +import com.microsoft.copilot.eclipse.ui.utils.MenuUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; /** @@ -39,65 +41,102 @@ private int calculateMaxWidth() { int max = 0; if (!PlatformUtils.isWindows()) { max = Math.max(max, - gc.stringExtent(Messages.menu_quota_codeCompletions + getPercentUsed(quotaResult.getCompletionsQuota())).x); + gc.stringExtent(Messages.menu_quota_codeCompletions + getPercentUsed(quotaResult.completions())).x); max = Math.max(max, - gc.stringExtent(Messages.menu_quota_chatMessages + getPercentUsed(quotaResult.getChatQuota())).x); - if (quotaResult.getCopilotPlan() != CopilotPlan.free) { + gc.stringExtent(Messages.menu_quota_chatMessages + getPercentUsed(quotaResult.chat())).x); + if (quotaResult.copilotPlan() != CopilotPlan.free) { max = Math.max(max, gc.stringExtent( - Messages.menu_quota_premiumRequests + getPercentUsed(quotaResult.getPremiumInteractionsQuota())).x); + getPremiumRequestsLabel() + getPremiumRequestsSuffix()).x); } max += PADDING_WIDTH; } return max; } + /** + * Returns the label used for the monthly limit row. CFI (Copilot for Individuals) plans show + * "Included credits"; all other paid plans show "Monthly limit". + */ + private String getPremiumRequestsLabel() { + if (MenuUtils.isCfiPlan(quotaResult.copilotPlan())) { + return Messages.menu_quota_includedCredits; + } + return Messages.menu_quota_monthlyLimit; + } + + /** + * Returns the tooltip used for the premium requests row. CFI (Copilot for Individuals) plans get + * the "included credits" tooltip; all other paid plans get the "monthly limit" tooltip. + */ + public String getPremiumRequestsTooltip() { + if (MenuUtils.isCfiPlan(quotaResult.copilotPlan())) { + return Messages.menu_quota_includedCreditsTooltip; + } + return Messages.menu_quota_monthlyLimitTooltip; + } + /** * Returns the aligned text for code completions quota. */ public String getCompletionText() { - return getAlignedQuotaText(Messages.menu_quota_codeCompletions, quotaResult.getCompletionsQuota()); + return getAlignedQuotaText(Messages.menu_quota_codeCompletions, getPercentUsed(quotaResult.completions())); } /** * Returns the aligned text for chat messages quota. */ public String getChatText() { - return getAlignedQuotaText(Messages.menu_quota_chatMessages, quotaResult.getChatQuota()); + return getAlignedQuotaText(Messages.menu_quota_chatMessages, getPercentUsed(quotaResult.chat())); + } + + /** + * Returns the aligned text for the monthly limit row, sourced from the premium interactions quota. + * CFI (Copilot for Individuals) plans label this row "Included credits" and display the absolute + * "{used}/{entitlement} AI credits used" suffix instead of a percentage. + */ + public String getPremiumRequestsText() { + return getAlignedQuotaText(getPremiumRequestsLabel(), getPremiumRequestsSuffix()); } /** - * Returns the aligned text for premium requests quota. + * Returns the suffix used for the premium requests row. CFI (Copilot for Individuals) plans get + * the absolute "{used}/{entitlement} AI credits used" form; other paid plans get the standard + * "{percent}% used" form. */ - public String getPremiumText() { - return getAlignedQuotaText(Messages.menu_quota_premiumRequests, quotaResult.getPremiumInteractionsQuota()); + private String getPremiumRequestsSuffix() { + Quota premiumQuota = quotaResult.premiumInteractions(); + if (MenuUtils.isCfiPlan(quotaResult.copilotPlan()) && premiumQuota != null && !premiumQuota.unlimited()) { + long entitlement = Math.round(premiumQuota.entitlement()); + long used = Math.max(0, entitlement - Math.round(premiumQuota.quotaRemaining())); + return NLS.bind(Messages.menu_quota_aiCreditsUsedFormat, used, entitlement); + } + return getPercentUsed(premiumQuota); } /** - * Helper method to generate aligned quota text for any quota type. + * Helper method to generate aligned quota text with a caller-supplied suffix. * - * @param messagePrefix the message prefix (e.g., "Code completions") - * @param quota the quota object to get percentage from + * @param messagePrefix the message prefix (e.g., "Included credits") + * @param quotaText the suffix to display on the right (e.g., "12/300 AI credits used") * @return the aligned quota text */ - private String getAlignedQuotaText(String messagePrefix, Quota quota) { + private String getAlignedQuotaText(String messagePrefix, String quotaText) { if (PlatformUtils.isWindows()) { // windows supports align the text via \t - return messagePrefix.trim() + "\t" + getPercentUsed(quota); + return messagePrefix.trim() + "\t" + quotaText; } - String quotaText = getPercentUsed(quota); int currentWidth = gc.stringExtent(messagePrefix + quotaText).x; int spacesToAdd = (int) Math.round((maxWidth - currentWidth) / (double) spaceWidth) + 1; return UiUtils.getAlignedText(gc, messagePrefix, UiUtils.HAIR_SPACE, quotaText, spacesToAdd, maxWidth); } private String getPercentUsed(Quota quota) { - if (quota.isUnlimited()) { - return "Included"; - } - double percent = Math.max(0, 100 - quota.getPercentRemaining()); - if (percent < 0.1) { - return "0%"; + if (quota.unlimited()) { + return Messages.menu_quota_included; } - return String.format("%.1f", Math.round(percent * 10) / 10.0) + "%"; + double percent = Math.max(0, 100 - quota.percentRemaining()); + String formattedPercent = percent < 0.1 ? "0" + : String.format("%.1f", Math.round(percent * 10) / 10.0); + return NLS.bind(Messages.menu_quota_percentUsedFormat, formattedPercent); } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/ShowMenuBarMenuHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/ShowMenuBarMenuHandler.java index 4c4dec7a..51824319 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/ShowMenuBarMenuHandler.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/ShowMenuBarMenuHandler.java @@ -4,8 +4,6 @@ package com.microsoft.copilot.eclipse.ui.handlers; import java.lang.reflect.Field; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -15,6 +13,7 @@ import org.eclipse.jface.action.IContributionItem; import org.eclipse.jface.action.Separator; import org.eclipse.jface.resource.ImageDescriptor; +import org.eclipse.osgi.util.NLS; import org.eclipse.swt.graphics.GC; import org.eclipse.ui.PlatformUI; import org.eclipse.ui.actions.CompoundContributionItem; @@ -29,11 +28,13 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CheckQuotaResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.Quota; import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.UiConstants; import com.microsoft.copilot.eclipse.ui.i18n.Messages; import com.microsoft.copilot.eclipse.ui.preferences.LanguageServerSettingManager; +import com.microsoft.copilot.eclipse.ui.utils.MenuUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; @@ -45,6 +46,7 @@ public class ShowMenuBarMenuHandler extends CompoundContributionItem implements private CommandContributionItem chatUsageItem; private CommandContributionItem completionsUsageItem; private CommandContributionItem premiumRequestsUsageItem; + private CommandContributionItem allowanceResetItem; @Override public void initialize(IServiceLocator serviceLocator) { @@ -65,6 +67,10 @@ public void initialize(IServiceLocator serviceLocator) { premiumRequestsUsageItem.dispose(); premiumRequestsUsageItem = null; } + if (allowanceResetItem != null) { + allowanceResetItem.dispose(); + allowanceResetItem = null; + } } }); } @@ -81,8 +87,11 @@ protected IContributionItem[] getContributionItems() { items.add(createCommandItem("com.microsoft.copilot.eclipse.commands.signIn", Messages.menu_signToGitHub, UiUtils.buildImageDescriptorFromPngPath("/icons/signin.png"))); } else if (CopilotStatusResult.OK.equals(status)) { + String userName = authStatusManager.getUserName(); + String planLabel = MenuUtils.getPlanLabel(authStatusManager.getQuotaStatus().copilotPlan()); + String userLabel = planLabel != null ? NLS.bind(Messages.menu_userPlanFormat, userName, planLabel) : userName; items.add(createCommandItem("com.microsoft.copilot.eclipse.commands.disabledDoNothing", - authStatusManager.getUserName(), authStatusManager.getUserName(), null)); + userLabel, userName, null)); } // menu: copilot Usage @@ -133,110 +142,77 @@ protected IContributionItem[] getContributionItems() { private void addCopilotUsageItems(AuthStatusManager authStatusManager, List items) { // menu: Copilot useage CheckQuotaResult quotaStatus = authStatusManager.getQuotaStatus(); - if (authStatusManager.isNotSignedInOrNotAuthorized() || quotaStatus.getCompletionsQuota() == null - || quotaStatus.getChatQuota() == null || StringUtils.isEmpty(quotaStatus.getResetDate())) { + if (authStatusManager.isNotSignedInOrNotAuthorized() || quotaStatus.completions() == null + || quotaStatus.chat() == null || StringUtils.isEmpty(quotaStatus.resetDate())) { return; } // TODO: remove reset date null check when the CLS is ready for all IDEs. items.add(new Separator()); - // Calculate percentRemaining based on plan - double percentRemaining; - if (quotaStatus.getCopilotPlan() == CopilotPlan.free) { - // For free plan, consider completions and chat quotas - percentRemaining = Math.min(quotaStatus.getCompletionsQuota().getPercentRemaining(), - quotaStatus.getChatQuota().getPercentRemaining()); - } else { - // For paid plans, also consider premium interactions quota - if (quotaStatus.getCompletionsQuota() == null) { - // If completions quota is not available, set percentRemaining to 0 - percentRemaining = 0; - } else { - percentRemaining = Math.min(quotaStatus.getCompletionsQuota().getPercentRemaining(), - Math.min(quotaStatus.getChatQuota().getPercentRemaining(), - quotaStatus.getPremiumInteractionsQuota().getPercentRemaining())); - } - } - ImageDescriptor icon; - // Set icon based on the lowest percentRemaining - if (percentRemaining <= 10) { - icon = UiUtils.buildImageDescriptorFromPngPath("/icons/quota/usage_red.png"); - } else if (percentRemaining > 10 && percentRemaining <= 25) { - icon = UiUtils.buildImageDescriptorFromPngPath("/icons/quota/usage_yellow.png"); - } else { - icon = UiUtils.buildImageDescriptorFromPngPath("/icons/quota/usage_blue.png"); - } + ImageDescriptor usageIcon = MenuUtils.getUsageIcon(MenuUtils.calculatePercentRemaining(quotaStatus)); + ImageDescriptor blankIcon = MenuUtils.getBlankIcon(); + CopilotPlan plan = quotaStatus.copilotPlan(); + Quota premiumQuota = quotaStatus.premiumInteractions(); + boolean isOrgUnlimited = MenuUtils.isOrgUnlimited(quotaStatus); + boolean hasNonOrgPremiumQuota = MenuUtils.hasNonOrgPremiumQuota(quotaStatus); + // For non-free plans with a Monthly limit row, the usage icon belongs on that row instead of the header. + ImageDescriptor headerIcon = hasNonOrgPremiumQuota ? blankIcon : usageIcon; Map parameters = Map.of(UiConstants.OPEN_URL_PARAMETER_NAME, UiConstants.MANAGE_COPILOT_URL); items.add(createCommandItem(UiConstants.OPEN_URL_COMMAND_ID, Messages.menu_quota_copilotUsage, parameters, - Messages.menu_quota_manageCopilotTooltip, icon)); + Messages.menu_quota_manageCopilotTooltip, headerIcon)); GC gc = new GC(PlatformUI.getWorkbench().getDisplay()); QuotaTextCalculator calculator = new QuotaTextCalculator(gc, quotaStatus); try { - // Premium requests usage when rest plans are unlimited - if (quotaStatus.getCopilotPlan() != CopilotPlan.free && quotaStatus.getCompletionsQuota().isUnlimited() - && quotaStatus.getChatQuota().isUnlimited()) { - String premiumRequestsText = calculator.getPremiumText(); + if (plan == CopilotPlan.free) { + // Free plan: only show Code Completions and Chat Messages rows + this.completionsUsageItem = createCommandItem("com.microsoft.copilot.eclipse.commands.enabledDoNothing", + calculator.getCompletionText(), blankIcon); + items.add(this.completionsUsageItem); + + this.chatUsageItem = createCommandItem("com.microsoft.copilot.eclipse.commands.enabledDoNothing", + calculator.getChatText(), blankIcon); + items.add(this.chatUsageItem); + } else if (isOrgUnlimited) { + // Business / Enterprise with unlimited premium interactions: show informational message + items.add(createCommandItem("com.microsoft.copilot.eclipse.commands.disabledDoNothing", + Messages.menu_quota_unlimitedOrgMessage, null)); + } else if (premiumQuota != null) { + // Other paid plans: show only the Monthly limit row sourced from premium interactions this.premiumRequestsUsageItem = createCommandItem("com.microsoft.copilot.eclipse.commands.enabledDoNothing", - premiumRequestsText, UiUtils.buildImageDescriptorFromPngPath("/icons/blank.png")); + calculator.getPremiumRequestsText(), calculator.getPremiumRequestsTooltip(), usageIcon); items.add(this.premiumRequestsUsageItem); } - - // Code completions useage - String codeCompletionsText = calculator.getCompletionText(); - this.completionsUsageItem = createCommandItem("com.microsoft.copilot.eclipse.commands.enabledDoNothing", - codeCompletionsText, UiUtils.buildImageDescriptorFromPngPath("/icons/blank.png")); - items.add(this.completionsUsageItem); - - // Chat messages usage - String chatMessagesText = calculator.getChatText(); - this.chatUsageItem = createCommandItem("com.microsoft.copilot.eclipse.commands.enabledDoNothing", - chatMessagesText, UiUtils.buildImageDescriptorFromPngPath("/icons/blank.png")); - items.add(this.chatUsageItem); - - // Premium requests usage - if (quotaStatus.getCopilotPlan() != CopilotPlan.free) { - // Premium requests usage when either of the rest plans is not unlimited - if (!quotaStatus.getCompletionsQuota().isUnlimited() || !quotaStatus.getChatQuota().isUnlimited()) { - String premiumRequestsText = calculator.getPremiumText(); - this.premiumRequestsUsageItem = createCommandItem("com.microsoft.copilot.eclipse.commands.enabledDoNothing", - premiumRequestsText, UiUtils.buildImageDescriptorFromPngPath("/icons/blank.png")); - items.add(this.premiumRequestsUsageItem); - } - - CommandContributionItem additionalPremiumRequestsDesc = createCommandItem( - "com.microsoft.copilot.eclipse.commands.disabledDoNothing", - Messages.menu_quota_additionalPremiumRequests - + (quotaStatus.getPremiumInteractionsQuota().isOveragePermitted() ? Messages.menu_quota_enabled - : Messages.menu_quota_disabled), - null); - items.add(additionalPremiumRequestsDesc); - } } finally { gc.dispose(); } // Allowance reset date - if (!StringUtils.isEmpty(quotaStatus.getResetDate())) { - LocalDate resetDate = LocalDate.parse(quotaStatus.getResetDate()); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy"); - items.add(createCommandItem("com.microsoft.copilot.eclipse.commands.disabledDoNothing", - Messages.menu_quota_allowanceReset + resetDate.format(formatter), null)); + if (MenuUtils.shouldShowAllowanceResetRow(quotaStatus)) { + this.allowanceResetItem = createCommandItem("com.microsoft.copilot.eclipse.commands.disabledDoNothing", + MenuUtils.formatAllowanceReset(quotaStatus), null); + items.add(this.allowanceResetItem); } // Upsell actions based on the user's plan ImageDescriptor upgradeIcon = UiUtils.buildImageDescriptorFromPngPath("/icons/quota/upgrade.png"); - if (quotaStatus.getCopilotPlan() == CopilotPlan.free) { - // If the user is on a free plan, show a link to upgrade. - items.add(createCommandItem("com.microsoft.copilot.eclipse.commands.upgradeCopilotPlan", - Messages.menu_quota_updateCopilotToPro, Messages.menu_quota_updateCopilotToProPlus, upgradeIcon)); - } else if (quotaStatus.getCopilotPlan() != CopilotPlan.business - && quotaStatus.getCopilotPlan() != CopilotPlan.enterprise) { - // If the user is not on a free plan / business plan / enterprise plan, show a link to manage subscription. - items.add(createCommandItem(UiConstants.OPEN_URL_COMMAND_ID, Messages.menu_quota_managePaidPremiumRequests, + + // For non-free users (excluding org-unlimited business/enterprise): + // show "Enable Additional Usage" or "Increase Budget" depending on overage state. + // The overage row uses the same predicate as the Monthly limit row. + if (hasNonOrgPremiumQuota) { + items.add(createCommandItem(UiConstants.OPEN_URL_COMMAND_ID, MenuUtils.getOverageRowLabel(premiumQuota), Map.of(UiConstants.OPEN_URL_PARAMETER_NAME, UiConstants.MANAGE_COPILOT_OVERAGE_URL), upgradeIcon)); } + + // For free / individual / individual_pro users, show an Upgrade Plan row. When the overage row is + // already showing the upgrade icon directly above, this row uses the blank icon to avoid duplication. + if (MenuUtils.shouldShowUpgradePlanRow(plan)) { + ImageDescriptor upgradePlanIcon = hasNonOrgPremiumQuota ? blankIcon : upgradeIcon; + items.add(createCommandItem("com.microsoft.copilot.eclipse.commands.upgradeCopilotPlan", + Messages.menu_quota_upgradePlan, upgradePlanIcon)); + } // Create a CompletableFuture to update quota information CopilotCore.getPlugin().getAuthStatusManager().checkQuota().thenAccept(this::updateQuotaItems); } @@ -277,19 +253,31 @@ private void updateQuotaItems(CheckQuotaResult quotaResult) { private void updateQuotaActionTexts(CheckQuotaResult quotaResult, GC gc) { QuotaTextCalculator calculator = new QuotaTextCalculator(gc, quotaResult); - if (this.chatUsageItem != null && quotaResult.getChatQuota() != null) { + if (this.chatUsageItem != null && quotaResult.chat() != null) { String chatMessagesText = calculator.getChatText(); - updateCommandItemLabel(this.chatUsageItem, chatMessagesText); + setCommandItemField(this.chatUsageItem, "label", chatMessagesText); } - if (this.completionsUsageItem != null && quotaResult.getCompletionsQuota() != null) { + if (this.completionsUsageItem != null && quotaResult.completions() != null) { String codeCompletionsText = calculator.getCompletionText(); - updateCommandItemLabel(this.completionsUsageItem, codeCompletionsText); + setCommandItemField(this.completionsUsageItem, "label", codeCompletionsText); } - if (this.premiumRequestsUsageItem != null && quotaResult.getPremiumInteractionsQuota() != null) { - String premiumRequestsText = calculator.getPremiumText(); - updateCommandItemLabel(this.premiumRequestsUsageItem, premiumRequestsText); + if (this.premiumRequestsUsageItem != null && quotaResult.premiumInteractions() != null) { + String monthlyLimitText = calculator.getPremiumRequestsText(); + setCommandItemField(this.premiumRequestsUsageItem, "label", monthlyLimitText); + // Refresh the usage icon (red/yellow/blue) to reflect the latest percent remaining. + setCommandItemField(this.premiumRequestsUsageItem, "icon", + MenuUtils.getUsageIcon(MenuUtils.calculatePercentRemaining(quotaResult))); + } + + // Refresh the allowance-reset row label, which switches between "Reset in N days..." and + // "No usage yet" depending on whether any of the tracked quotas have been consumed. When the + // predicate flips off (e.g. plan changed to unlimited mid-session), skip the update and leave + // the stale label until the menu is rebuilt rather than rendering an empty disabled row. + if (this.allowanceResetItem != null && MenuUtils.shouldShowAllowanceResetRow(quotaResult)) { + setCommandItemField(this.allowanceResetItem, "label", MenuUtils.formatAllowanceReset(quotaResult)); + this.allowanceResetItem.update(); } if (this.chatUsageItem != null) { @@ -304,18 +292,18 @@ private void updateQuotaActionTexts(CheckQuotaResult quotaResult, GC gc) { } /** - * Updates the label of a CommandContributionItem. - * - * @param item The CommandContributionItem to update - * @param newLabel The new label to set + * Reflectively assigns a private field on {@link CommandContributionItem}. The platform does not + * expose mutators for {@code label}/{@code icon}, so this is used to refresh the menu entries in + * place. A failure here means a future Eclipse version renamed the field and the menu will stop + * refreshing - log it so the regression is visible. */ - private void updateCommandItemLabel(CommandContributionItem item, String newLabel) { + private void setCommandItemField(CommandContributionItem item, String fieldName, Object value) { try { - Field labelField = CommandContributionItem.class.getDeclaredField("label"); - labelField.setAccessible(true); - labelField.set(item, newLabel); - } catch (Exception e) { - // Skip updating the label if reflection fails + Field field = CommandContributionItem.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(item, value); + } catch (NoSuchFieldException | IllegalAccessException | IllegalArgumentException e) { + CopilotCore.LOGGER.error("Failed to update CommandContributionItem field '" + fieldName + "'", e); } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/ShowStatusBarMenuHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/ShowStatusBarMenuHandler.java index 7ee20969..cedefcd0 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/ShowStatusBarMenuHandler.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/ShowStatusBarMenuHandler.java @@ -3,8 +3,6 @@ package com.microsoft.copilot.eclipse.ui.handlers; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; import java.util.Map; import java.util.Objects; @@ -22,6 +20,7 @@ import org.eclipse.jface.action.MenuManager; import org.eclipse.jface.action.Separator; import org.eclipse.jface.resource.ImageDescriptor; +import org.eclipse.osgi.util.NLS; import org.eclipse.swt.graphics.GC; import org.eclipse.swt.widgets.Menu; import org.eclipse.swt.widgets.Shell; @@ -38,11 +37,13 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CheckQuotaResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.Quota; import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.UiConstants; import com.microsoft.copilot.eclipse.ui.i18n.Messages; import com.microsoft.copilot.eclipse.ui.preferences.LanguageServerSettingManager; +import com.microsoft.copilot.eclipse.ui.utils.MenuUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; @@ -57,6 +58,7 @@ public class ShowStatusBarMenuHandler extends CopilotHandler implements IElement private Action completionRemainingAction; private Action chatRemainingAction; private Action premiumRequestsAction; + private Action allowanceResetAction; /** * Constructor for ShowStatusBarMenuHandler. @@ -75,6 +77,9 @@ public ShowStatusBarMenuHandler() { if (premiumRequestsAction != null) { premiumRequestsAction = null; } + if (allowanceResetAction != null) { + allowanceResetAction = null; + } } }); } @@ -154,8 +159,18 @@ private void updateQuotaActionTexts(CheckQuotaResult quotaResult, GC gc) { if (chatRemainingAction != null) { chatRemainingAction.setText(calculator.getChatText()); } - if (premiumRequestsAction != null && quotaResult.getCopilotPlan() != CopilotPlan.free) { - premiumRequestsAction.setText(calculator.getPremiumText()); + if (premiumRequestsAction != null && quotaResult.copilotPlan() != CopilotPlan.free) { + premiumRequestsAction.setText(calculator.getPremiumRequestsText()); + // Refresh the usage icon (red/yellow/blue) to reflect the latest percent remaining. + premiumRequestsAction.setImageDescriptor( + MenuUtils.getUsageIcon(MenuUtils.calculatePercentRemaining(quotaResult))); + } + // Refresh the allowance-reset row label, which switches between "Reset in N days..." and + // "No usage yet" depending on whether any of the tracked quotas have been consumed. When the + // predicate flips off (e.g. plan changed to unlimited mid-session), skip the update and leave + // the stale label until the menu is rebuilt rather than rendering an empty disabled row. + if (allowanceResetAction != null && MenuUtils.shouldShowAllowanceResetRow(quotaResult)) { + allowanceResetAction.setText(MenuUtils.formatAllowanceReset(quotaResult)); } } @@ -216,113 +231,90 @@ private void addSignInOrUsernameAction(MenuManager menuManager) { UiUtils.buildImageDescriptorFromPngPath("/icons/signin.png"), handlerService, "com.microsoft.copilot.eclipse.commands.signIn", true); } else if (CopilotStatusResult.OK.equals(status)) { - MenuActionFactory.createMenuAction(menuManager, authStatusManager.getUserName(), authStatusManager.getUserName(), + String userName = authStatusManager.getUserName(); + String planLabel = MenuUtils.getPlanLabel(authStatusManager.getQuotaStatus().copilotPlan()); + String userLabel = planLabel != null ? NLS.bind(Messages.menu_userPlanFormat, userName, planLabel) : userName; + MenuActionFactory.createMenuAction(menuManager, userLabel, userName, null, handlerService, "com.microsoft.copilot.eclipse.commands.disabledDoNothing", false); } } private void addCopilotUsageAction(MenuManager menuManager) { CheckQuotaResult quotaStatus = CopilotCore.getPlugin().getAuthStatusManager().getQuotaStatus(); - if (quotaStatus.getCompletionsQuota() == null || quotaStatus.getChatQuota() == null - || StringUtils.isEmpty(quotaStatus.getResetDate())) { + if (quotaStatus.completions() == null || quotaStatus.chat() == null + || StringUtils.isEmpty(quotaStatus.resetDate())) { // skip quota status menu if quotas are not available // TODO: remove reset date null check when the CLS is ready for all IDEs. return; } - // Calculate percentRemaining based on plan - double percentRemaining; - if (quotaStatus.getCopilotPlan() == CopilotPlan.free) { - // For free plan, consider completions and chat quotas - percentRemaining = Math.min(quotaStatus.getCompletionsQuota().getPercentRemaining(), - quotaStatus.getChatQuota().getPercentRemaining()); - } else { - // For paid plans, also consider premium interactions quota - percentRemaining = Math.min(quotaStatus.getCompletionsQuota().getPercentRemaining(), - Math.min(quotaStatus.getChatQuota().getPercentRemaining(), - quotaStatus.getPremiumInteractionsQuota().getPercentRemaining())); - } - - ImageDescriptor icon; - // Set icon based on the lowest percentRemaining - if (percentRemaining <= 10) { - icon = UiUtils.buildImageDescriptorFromPngPath("/icons/quota/usage_red.png"); - } else if (percentRemaining > 10 && percentRemaining <= 25) { - icon = UiUtils.buildImageDescriptorFromPngPath("/icons/quota/usage_yellow.png"); - } else { - icon = UiUtils.buildImageDescriptorFromPngPath("/icons/quota/usage_blue.png"); - } + ImageDescriptor usageIcon = MenuUtils.getUsageIcon(MenuUtils.calculatePercentRemaining(quotaStatus)); + ImageDescriptor blankIcon = MenuUtils.getBlankIcon(); + CopilotPlan plan = quotaStatus.copilotPlan(); + Quota premiumQuota = quotaStatus.premiumInteractions(); + boolean isOrgUnlimited = MenuUtils.isOrgUnlimited(quotaStatus); + boolean hasNonOrgPremiumQuota = MenuUtils.hasNonOrgPremiumQuota(quotaStatus); + // For non-free plans with a Monthly limit row, the usage icon belongs on that row instead of the header. + ImageDescriptor headerIcon = hasNonOrgPremiumQuota ? blankIcon : usageIcon; Map parameters = Map.of(UiConstants.OPEN_URL_PARAMETER_NAME, UiConstants.MANAGE_COPILOT_URL); MenuActionFactory.createMenuAction(menuManager, Messages.menu_quota_copilotUsage, - Messages.menu_quota_manageCopilotTooltip, icon, handlerService, UiConstants.OPEN_URL_COMMAND_ID, parameters, - true); + Messages.menu_quota_manageCopilotTooltip, headerIcon, handlerService, UiConstants.OPEN_URL_COMMAND_ID, + parameters, true); GC gc = new GC(PlatformUI.getWorkbench().getDisplay()); QuotaTextCalculator calculator = new QuotaTextCalculator(gc, quotaStatus); try { - // Premium requests usage when rest plans are unlimited - if (quotaStatus.getCopilotPlan() != CopilotPlan.free && quotaStatus.getCompletionsQuota().isUnlimited() - && quotaStatus.getChatQuota().isUnlimited()) { - String premiumRequestsText = calculator.getPremiumText(); - premiumRequestsAction = MenuActionFactory.createMenuAction(menuManager, premiumRequestsText, - UiUtils.buildImageDescriptorFromPngPath("/icons/blank.png"), handlerService, + if (plan == CopilotPlan.free) { + // Free plan: only show Code Completions and Chat Messages rows + completionRemainingAction = MenuActionFactory.createMenuAction(menuManager, calculator.getCompletionText(), + blankIcon, handlerService, "com.microsoft.copilot.eclipse.commands.enabledDoNothing", true); - } - // Code completions usage - String codeCompletionsText = calculator.getCompletionText(); - completionRemainingAction = MenuActionFactory.createMenuAction(menuManager, codeCompletionsText, - UiUtils.buildImageDescriptorFromPngPath("/icons/blank.png"), handlerService, - "com.microsoft.copilot.eclipse.commands.enabledDoNothing", true); - - // Chat messages usage - String chatMessagesText = calculator.getChatText(); - chatRemainingAction = MenuActionFactory.createMenuAction(menuManager, chatMessagesText, - UiUtils.buildImageDescriptorFromPngPath("/icons/blank.png"), handlerService, - "com.microsoft.copilot.eclipse.commands.enabledDoNothing", true); - - // Premium requests usage - if (quotaStatus.getCopilotPlan() != CopilotPlan.free) { - // Premium requests usage when either of the rest plans is not unlimited - if (!quotaStatus.getCompletionsQuota().isUnlimited() || !quotaStatus.getChatQuota().isUnlimited()) { - String premiumRequestsText = calculator.getPremiumText(); - premiumRequestsAction = MenuActionFactory.createMenuAction(menuManager, premiumRequestsText, - UiUtils.buildImageDescriptorFromPngPath("/icons/blank.png"), handlerService, - "com.microsoft.copilot.eclipse.commands.enabledDoNothing", true); - } - - MenuActionFactory.createMenuAction(menuManager, - Messages.menu_quota_additionalPremiumRequests - + (quotaStatus.getPremiumInteractionsQuota().isOveragePermitted() ? Messages.menu_quota_enabled - : Messages.menu_quota_disabled), + chatRemainingAction = MenuActionFactory.createMenuAction(menuManager, calculator.getChatText(), + blankIcon, handlerService, + "com.microsoft.copilot.eclipse.commands.enabledDoNothing", true); + } else if (isOrgUnlimited) { + // Business / Enterprise with unlimited premium interactions: show informational message + MenuActionFactory.createMenuAction(menuManager, Messages.menu_quota_unlimitedOrgMessage, handlerService, "com.microsoft.copilot.eclipse.commands.disabledDoNothing", false); + } else if (premiumQuota != null) { + // Other paid plans: show only the Monthly limit row sourced from premium interactions + premiumRequestsAction = MenuActionFactory.createMenuAction(menuManager, calculator.getPremiumRequestsText(), + calculator.getPremiumRequestsTooltip(), usageIcon, handlerService, + "com.microsoft.copilot.eclipse.commands.enabledDoNothing", true); } + // Paid plan with no premium-interactions quota yet: render nothing extra until the next refresh. } finally { gc.dispose(); } // Allowance reset date - if (!StringUtils.isEmpty(quotaStatus.getResetDate())) { - LocalDate resetDate = LocalDate.parse(quotaStatus.getResetDate()); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy"); - MenuActionFactory.createMenuAction(menuManager, Messages.menu_quota_allowanceReset + resetDate.format(formatter), + if (MenuUtils.shouldShowAllowanceResetRow(quotaStatus)) { + allowanceResetAction = MenuActionFactory.createMenuAction(menuManager, + MenuUtils.formatAllowanceReset(quotaStatus), handlerService, "com.microsoft.copilot.eclipse.commands.disabledDoNothing", false); } // Upsell actions based on the user's plan ImageDescriptor upgradeIcon = UiUtils.buildImageDescriptorFromPngPath("/icons/quota/upgrade.png"); - if (quotaStatus.getCopilotPlan() == CopilotPlan.free) { - // If the user is on a free plan, show a link to upgrade. - MenuActionFactory.createMenuAction(menuManager, Messages.menu_quota_updateCopilotToPro, upgradeIcon, - handlerService, "com.microsoft.copilot.eclipse.commands.upgradeCopilotPlan", true); - } else if (quotaStatus.getCopilotPlan() != CopilotPlan.business - && quotaStatus.getCopilotPlan() != CopilotPlan.enterprise) { - // If the user is not on a free plan / business plan / enterprise plan, show a link to manage subscription. - MenuActionFactory.createMenuAction(menuManager, Messages.menu_quota_managePaidPremiumRequests, upgradeIcon, + + // For non-free users (excluding org-unlimited business/enterprise): + // show "Enable Additional Usage" or "Increase Budget" depending on overage state. + // The overage row uses the same predicate as the Monthly limit row. + if (hasNonOrgPremiumQuota) { + MenuActionFactory.createMenuAction(menuManager, MenuUtils.getOverageRowLabel(premiumQuota), upgradeIcon, handlerService, UiConstants.OPEN_URL_COMMAND_ID, Map.of(UiConstants.OPEN_URL_PARAMETER_NAME, UiConstants.MANAGE_COPILOT_OVERAGE_URL), true); } + + // For free / individual / individual_pro users, show an Upgrade Plan row. When the overage row is + // already showing the upgrade icon directly above, this row uses the blank icon to avoid duplication. + if (MenuUtils.shouldShowUpgradePlanRow(plan)) { + ImageDescriptor upgradePlanIcon = hasNonOrgPremiumQuota ? blankIcon : upgradeIcon; + MenuActionFactory.createMenuAction(menuManager, Messages.menu_quota_upgradePlan, upgradePlanIcon, handlerService, + "com.microsoft.copilot.eclipse.commands.upgradeCopilotPlan", true); + } } private void addOpenChatViewAction(MenuManager menuManager) { 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 96e7d091..72e04f35 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 @@ -31,15 +31,29 @@ public final class Messages extends NLS { public static String menu_quota_copilotUsage; public static String menu_quota_codeCompletions; public static String menu_quota_chatMessages; - public static String menu_quota_premiumRequests; - public static String menu_quota_additionalPremiumRequests; - public static String menu_quota_enabled; - public static String menu_quota_disabled; - public static String menu_quota_allowanceReset; + public static String menu_quota_monthlyLimit; + public static String menu_quota_includedCredits; + public static String menu_quota_includedCreditsTooltip; + public static String menu_quota_monthlyLimitTooltip; + public static String menu_quota_percentUsedFormat; + public static String menu_quota_aiCreditsUsedFormat; + public static String menu_quota_included; + public static String menu_userPlanFormat; + public static String menu_quota_noUsageYet; + public static String menu_quota_allowanceReset_today; + public static String menu_quota_allowanceReset_singular; + public static String menu_quota_allowanceReset_plural; public static String menu_quota_manageCopilotTooltip; - public static String menu_quota_updateCopilotToPro; - public static String menu_quota_updateCopilotToProPlus; - public static String menu_quota_managePaidPremiumRequests; + public static String menu_quota_enableAdditionalUsage; + public static String menu_quota_increaseBudget; + public static String menu_quota_upgradePlan; + public static String menu_quota_unlimitedOrgMessage; + public static String menu_quota_plan_free; + public static String menu_quota_plan_individual; + public static String menu_quota_plan_individualPro; + public static String menu_quota_plan_individualMax; + public static String menu_quota_plan_business; + public static String menu_quota_plan_enterprise; public static String signInDialog_title; public static String signInDialog_button_cancel; public static String signInDialog_button_copyOpen; 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 2c77af76..6b964b01 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 @@ -20,14 +20,29 @@ menu_quota_copilotUsage=Copilot Usage menu_quota_manageCopilotTooltip=Manage Copilot menu_quota_codeCompletions=Code Completions menu_quota_chatMessages=Chat Messages -menu_quota_premiumRequests=Premium Requests -menu_quota_additionalPremiumRequests=Additional paid premium requests -menu_quota_enabled=enabled. -menu_quota_disabled=disabled. -menu_quota_allowanceReset=Allowance resets -menu_quota_updateCopilotToPro=Upgrade to Copilot Pro -menu_quota_updateCopilotToProPlus=Upgrade to Copilot Pro+ -menu_quota_managePaidPremiumRequests=Manage paid premium requests +menu_quota_monthlyLimit=Monthly Limit +menu_quota_includedCredits=Included Credits +menu_quota_includedCredits=Included Credits +menu_quota_includedCreditsTooltip=AI credits included with your plan, reset monthly. Enable additional usage to continue with pay-as-you-go credits once you run out of your included usage. +menu_quota_monthlyLimitTooltip=Usage limit per month, resets automatically. AI credit usage counts toward your monthly limit. Ask your account manager about increasing your overage when monthly limit is hit. +menu_quota_percentUsedFormat={0}% used +menu_quota_aiCreditsUsedFormat={0}/{1} AI credits used +menu_quota_included=Included +menu_userPlanFormat={0} - {1} +menu_quota_noUsageYet=No usage yet +menu_quota_allowanceReset_today=Resets today +menu_quota_allowanceReset_singular=Reset in 1 day on {0} +menu_quota_allowanceReset_plural=Reset in {0} days on {1} +menu_quota_enableAdditionalUsage=Enable Additional Usage +menu_quota_increaseBudget=Increase Budget +menu_quota_upgradePlan=Upgrade Plan +menu_quota_unlimitedOrgMessage=You have no monthly limit on AI credits usage set by your organization +menu_quota_plan_free=Copilot Free Plan +menu_quota_plan_individual=Copilot Pro Plan +menu_quota_plan_individualPro=Copilot Pro+ Plan +menu_quota_plan_individualMax=Copilot Max Plan +menu_quota_plan_business=Business Plan +menu_quota_plan_enterprise=Enterprise Plan signInDialog_title=Sign In to GitHub signInDialog_button_cancel=Cancel diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/MenuUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/MenuUtils.java new file mode 100644 index 00000000..d07847ee --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/MenuUtils.java @@ -0,0 +1,241 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.utils; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.util.Optional; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.resource.ImageDescriptor; +import org.eclipse.osgi.util.NLS; + +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CheckQuotaResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.Quota; +import com.microsoft.copilot.eclipse.ui.i18n.Messages; + +/** + * Shared helpers used by the Copilot status-bar and menu-bar usage menus. + */ +public final class MenuUtils { + + private static final DateTimeFormatter ALLOWANCE_RESET_DATE_FORMATTER = + DateTimeFormatter.ofPattern("MMMM d, yyyy"); + + private MenuUtils() { + } + + /** + * Returns the localized plan label for the given plan, or {@code null} if the plan is unknown. + */ + public static String getPlanLabel(CopilotPlan plan) { + if (plan == null) { + return null; + } + switch (plan) { + case free: + return Messages.menu_quota_plan_free; + case individual: + return Messages.menu_quota_plan_individual; + case individual_pro: + return Messages.menu_quota_plan_individualPro; + case individual_max: + return Messages.menu_quota_plan_individualMax; + case business: + return Messages.menu_quota_plan_business; + case enterprise: + return Messages.menu_quota_plan_enterprise; + default: + return null; + } + } + + /** + * Returns the percent-remaining used to pick the usage icon, based on the user's plan. + */ + public static double calculatePercentRemaining(CheckQuotaResult quotaStatus) { + CopilotPlan plan = quotaStatus.copilotPlan(); + Quota premiumQuota = quotaStatus.premiumInteractions(); + if (plan == CopilotPlan.free) { + Quota completionsQuota = quotaStatus.completions(); + Quota chatQuota = quotaStatus.chat(); + if (completionsQuota == null || chatQuota == null) { + return 100; + } + return Math.min(completionsQuota.percentRemaining(), chatQuota.percentRemaining()); + } + if (premiumQuota != null) { + return premiumQuota.percentRemaining(); + } + return 100; + } + + /** + * Returns the image descriptor for the usage row based on the lowest percentRemaining. + */ + public static ImageDescriptor getUsageIcon(double percentRemaining) { + if (percentRemaining <= 10) { + return UiUtils.buildImageDescriptorFromPngPath("/icons/quota/usage_red.png"); + } + if (percentRemaining <= 25) { + return UiUtils.buildImageDescriptorFromPngPath("/icons/quota/usage_yellow.png"); + } + return UiUtils.buildImageDescriptorFromPngPath("/icons/quota/usage_blue.png"); + } + + /** + * Returns the shared blank icon descriptor used for indented usage rows. + */ + public static ImageDescriptor getBlankIcon() { + return UiUtils.buildImageDescriptorFromPngPath("/icons/blank.png"); + } + + /** + * True when the user is on a Business / Enterprise plan with no monthly premium-interactions limit. + */ + public static boolean isOrgUnlimited(CheckQuotaResult quotaStatus) { + CopilotPlan plan = quotaStatus.copilotPlan(); + Quota premiumQuota = quotaStatus.premiumInteractions(); + return (plan == CopilotPlan.business || plan == CopilotPlan.enterprise) + && premiumQuota != null && premiumQuota.unlimited(); + } + + /** + * True when the user has a non-org premium-interactions quota - i.e. a paid plan with a + * populated, non-org-unlimited {@link CheckQuotaResult#premiumInteractions()}. This single + * predicate gates both the Monthly limit display row and the overage upsell row ("Enable + * Additional Usage" / "Increase Budget"): without metered premium data the upsell has no data + * to act on and would mislead the user. + */ + public static boolean hasNonOrgPremiumQuota(CheckQuotaResult quotaStatus) { + if (quotaStatus.copilotPlan() == CopilotPlan.free) { + return false; + } + if (isOrgUnlimited(quotaStatus)) { + return false; + } + return quotaStatus.premiumInteractions() != null; + } + + /** + * True when the "Upgrade Plan" row should be shown for the given plan. + */ + public static boolean shouldShowUpgradePlanRow(CopilotPlan plan) { + return plan == CopilotPlan.free || plan == CopilotPlan.individual || plan == CopilotPlan.individual_pro; + } + + /** + * True when the plan is a CFI (Copilot for Individuals) plan: individual, individual_pro, or + * individual_max. + */ + public static boolean isCfiPlan(CopilotPlan plan) { + return plan == CopilotPlan.individual || plan == CopilotPlan.individual_pro + || plan == CopilotPlan.individual_max; + } + + /** + * Returns the label for the overage upsell row depending on the current overage state. + */ + public static String getOverageRowLabel(Quota premiumQuota) { + boolean overageEnabled = premiumQuota != null && premiumQuota.overagePermitted(); + return overageEnabled ? Messages.menu_quota_increaseBudget : Messages.menu_quota_enableAdditionalUsage; + } + + /** + * True when the allowance-reset row should be shown. The row is hidden when there is no monthly + * allowance to reset (premium-interactions quota is unlimited), when no reset date was supplied, + * or when the supplied reset date cannot be parsed. + */ + public static boolean shouldShowAllowanceResetRow(CheckQuotaResult quotaStatus) { + Quota premiumQuota = quotaStatus.premiumInteractions(); + if (premiumQuota != null && premiumQuota.unlimited()) { + return false; + } + return parseResetDate(quotaStatus).isPresent(); + } + + /** + * True when none of the quotas tracked for the user's plan have any usage yet. For free plans this + * means both the chat and completions quotas are at 0% used; for paid plans this means the premium + * interactions quota is at 0% used. + */ + public static boolean noUsageYet(CheckQuotaResult quotaStatus) { + if (quotaStatus.copilotPlan() == CopilotPlan.free) { + return isUnused(quotaStatus.chat()) && isUnused(quotaStatus.completions()); + } + return isUnused(quotaStatus.premiumInteractions()); + } + + /** + * Formats the allowance-reset row label. Returns {@link Messages#menu_quota_noUsageYet} when the + * user has not consumed any of the tracked quotas yet (see {@link #noUsageYet}); otherwise + * returns {@code "Resets today on {date}"} / {@code "Reset in 1 day on {date}"} / {@code "Reset + * in {n} days on {date}"} depending on {@code n}. + * + *

Callers must gate with {@link #shouldShowAllowanceResetRow}; this method + * assumes a parseable reset date is present. + */ + public static String formatAllowanceReset(CheckQuotaResult quotaStatus) { + if (noUsageYet(quotaStatus)) { + return Messages.menu_quota_noUsageYet; + } + LocalDate resetDate = parseResetDate(quotaStatus).orElseThrow( + () -> new IllegalStateException("formatAllowanceReset called without a parseable reset date; " + + "callers must gate with shouldShowAllowanceResetRow")); + long days = Math.max(0, ChronoUnit.DAYS.between(LocalDate.now(), resetDate)); + String formattedDate = resetDate.format(ALLOWANCE_RESET_DATE_FORMATTER); + if (days == 0) { + return Messages.menu_quota_allowanceReset_today; + } + if (days == 1) { + return NLS.bind(Messages.menu_quota_allowanceReset_singular, formattedDate); + } + return NLS.bind(Messages.menu_quota_allowanceReset_plural, days, formattedDate); + } + + /** + * Parses the reset date supplied by the language server, preferring {@code resetDateUtc} (an ISO + * instant resolved against the local time zone) over {@code resetDate} (a local ISO date) since + * the instant form carries more precision. Returns an empty optional when neither field is + * parseable. + */ + private static Optional parseResetDate(CheckQuotaResult quotaStatus) { + String utc = quotaStatus.resetDateUtc(); + if (StringUtils.isNotBlank(utc)) { + try { + return Optional.of(Instant.parse(utc).atZone(ZoneId.systemDefault()).toLocalDate()); + } catch (DateTimeParseException e) { + CopilotCore.LOGGER.error("Unparseable quota resetDateUtc: " + utc, e); + } + } + String local = quotaStatus.resetDate(); + if (StringUtils.isNotBlank(local)) { + try { + return Optional.of(LocalDate.parse(local)); + } catch (DateTimeParseException e) { + CopilotCore.LOGGER.error("Unparseable quota resetDate: " + local, e); + } + } + return Optional.empty(); + } + + /** + * True when the quota has been measured but no allowance has been consumed yet (matches the + * {@code "0% used"} display threshold in {@code QuotaTextCalculator#getPercentUsed}). Unlimited + * quotas are treated as "used" so that the no-usage message is not shown when the limit is + * irrelevant. + */ + private static boolean isUnused(Quota quota) { + if (quota == null || quota.unlimited()) { + return false; + } + return (100 - quota.percentRemaining()) < 0.1; + } +} From 3116cfdd6dcb29770017fb1057404fcc8b9bae3c Mon Sep 17 00:00:00 2001 From: Ethan Hou Date: Mon, 11 May 2026 16:56:28 +0800 Subject: [PATCH 2/9] Address comments. Co-authored-by: Copilot --- .../copilot/eclipse/ui/handlers/QuotaTextCalculator.java | 5 ++++- .../microsoft/copilot/eclipse/ui/i18n/messages.properties | 1 - .../com/microsoft/copilot/eclipse/ui/utils/MenuUtils.java | 5 +++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/QuotaTextCalculator.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/QuotaTextCalculator.java index 605dc10c..e7b12cff 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/QuotaTextCalculator.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/QuotaTextCalculator.java @@ -44,7 +44,7 @@ private int calculateMaxWidth() { gc.stringExtent(Messages.menu_quota_codeCompletions + getPercentUsed(quotaResult.completions())).x); max = Math.max(max, gc.stringExtent(Messages.menu_quota_chatMessages + getPercentUsed(quotaResult.chat())).x); - if (quotaResult.copilotPlan() != CopilotPlan.free) { + if (quotaResult.copilotPlan() != CopilotPlan.free && quotaResult.premiumInteractions() != null) { max = Math.max(max, gc.stringExtent( getPremiumRequestsLabel() + getPremiumRequestsSuffix()).x); } @@ -131,6 +131,9 @@ private String getAlignedQuotaText(String messagePrefix, String quotaText) { } private String getPercentUsed(Quota quota) { + if (quota == null) { + return ""; + } if (quota.unlimited()) { return Messages.menu_quota_included; } 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 6b964b01..98d221e0 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 @@ -22,7 +22,6 @@ menu_quota_codeCompletions=Code Completions menu_quota_chatMessages=Chat Messages menu_quota_monthlyLimit=Monthly Limit menu_quota_includedCredits=Included Credits -menu_quota_includedCredits=Included Credits menu_quota_includedCreditsTooltip=AI credits included with your plan, reset monthly. Enable additional usage to continue with pay-as-you-go credits once you run out of your included usage. menu_quota_monthlyLimitTooltip=Usage limit per month, resets automatically. AI credit usage counts toward your monthly limit. Ask your account manager about increasing your overage when monthly limit is hit. menu_quota_percentUsedFormat={0}% used diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/MenuUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/MenuUtils.java index d07847ee..ba6468e9 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/MenuUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/MenuUtils.java @@ -176,8 +176,9 @@ public static boolean noUsageYet(CheckQuotaResult quotaStatus) { /** * Formats the allowance-reset row label. Returns {@link Messages#menu_quota_noUsageYet} when the * user has not consumed any of the tracked quotas yet (see {@link #noUsageYet}); otherwise - * returns {@code "Resets today on {date}"} / {@code "Reset in 1 day on {date}"} / {@code "Reset - * in {n} days on {date}"} depending on {@code n}. + * returns {@code "Resets today"} / {@code "Reset in 1 day on {date}"} / {@code "Reset in {n} + * days on {date}"} depending on {@code n}. The 0-day case intentionally omits the date since it + * is implied by "today". * *

Callers must gate with {@link #shouldShowAllowanceResetRow}; this method * assumes a parseable reset date is present. From a3bc5740726039f5d57d8f09be68c5e4cea5bcd7 Mon Sep 17 00:00:00 2001 From: Ethan Hou Date: Tue, 12 May 2026 10:19:48 +0800 Subject: [PATCH 3/9] feat: Implement TBB quota warning notifications and related UI components --- .../core/lsp/CopilotLanguageClientTests.java | 27 ++++ .../core/events/CopilotEventConstants.java | 10 ++ .../core/lsp/CopilotLanguageClient.java | 12 ++ .../lsp/protocol/quota/CheckQuotaResult.java | 34 +++- .../core/lsp/protocol/quota/CopilotPlan.java | 2 +- .../lsp/protocol/quota/IntervalQuota.java | 17 ++ .../core/lsp/protocol/quota/Quota.java | 44 ++++- .../lsp/protocol/quota/QuotaChangeParams.java | 20 +++ .../quota/QuotaWarningNotification.java | 11 ++ .../protocol/quota/QuotaWarningParams.java | 23 +++ .../copilot/eclipse/ui/i18n/MessagesTest.java | 3 + .../QuotaWarningNotificationPopupTest.java | 152 ++++++++++++++++++ .../META-INF/MANIFEST.MF | 1 + .../copilot/eclipse/ui/CopilotUi.java | 7 + .../eclipse/ui/chat/ChatContentViewer.java | 5 +- .../copilot/eclipse/ui/i18n/Messages.java | 3 + .../eclipse/ui/i18n/messages.properties | 5 + .../QuotaNotificationManager.java | 62 +++++++ .../QuotaWarningNotificationPopup.java | 140 ++++++++++++++++ 19 files changed, 571 insertions(+), 7 deletions(-) create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/IntervalQuota.java create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaChangeParams.java create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningNotification.java create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningParams.java create mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaWarningNotificationPopupTest.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaNotificationManager.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaWarningNotificationPopup.java diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java index 0ebad63b..300fb4ff 100644 --- a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java @@ -10,12 +10,14 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.lang.reflect.Field; import java.util.HashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import org.eclipse.core.resources.IFile; +import org.eclipse.e4.core.services.events.IEventBroker; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -28,10 +30,12 @@ import com.microsoft.copilot.eclipse.core.FeatureFlags; import com.microsoft.copilot.eclipse.core.chat.service.IChatServiceManager; import com.microsoft.copilot.eclipse.core.chat.service.IReferencedFileService; +import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; 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; import com.microsoft.copilot.eclipse.core.lsp.protocol.DidChangeFeatureFlagsParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.QuotaWarningNotification; import com.microsoft.copilot.eclipse.core.utils.FileUtils; @ExtendWith(MockitoExtension.class) @@ -48,6 +52,9 @@ class CopilotLanguageClientTests { @Mock private IReferencedFileService fileService; + @Mock + private IEventBroker eventBroker; + @BeforeEach void setUp() { client = new CopilotLanguageClient(); @@ -131,4 +138,24 @@ void testOnDidChangeFeatureFlagsWithEmptyFeatureFlags() { verify(mockFeatureFlags).setByokEnabled(true); } } + + @Test + void testOnQuotaWarning_PostsNotificationToEventBroker() { + QuotaWarningNotification notification = new QuotaWarningNotification("Approaching quota", 90.0); + setEventBroker(eventBroker); + + client.onQuotaWarning(notification); + + verify(eventBroker).post(CopilotEventConstants.TOPIC_QUOTA_WARNING, notification); + } + + private void setEventBroker(IEventBroker broker) { + try { + Field field = CopilotLanguageClient.class.getDeclaredField("eventBroker"); + field.setAccessible(true); + field.set(client, broker); + } catch (ReflectiveOperationException e) { + throw new AssertionError("Failed to inject event broker", e); + } + } } \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/events/CopilotEventConstants.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/events/CopilotEventConstants.java index abd9f57f..f6e09fb1 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 @@ -30,6 +30,11 @@ public class CopilotEventConstants { */ private static final String TOPIC_MCP = TOPIC_BASE + "MCP/"; + /** + * Topic for quota events. + */ + private static final String TOPIC_QUOTA = TOPIC_BASE + "QUOTA/"; + /** * Topic for Next Edit Suggestion (NES) events. */ @@ -160,4 +165,9 @@ public class CopilotEventConstants { * Event when a rate limit warning is received from the language server. */ public static final String TOPIC_RATE_LIMIT_WARNING = TOPIC_CHAT + "RATE_LIMIT_WARNING"; + + /** + * Event when a quota warning notification is received from the language server. + */ + public static final String TOPIC_QUOTA_WARNING = TOPIC_QUOTA + "WARNING"; } 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 f2bde857..8836686b 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 @@ -64,6 +64,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.policy.DidChangePolicyParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.QuotaWarningNotification; import com.microsoft.copilot.eclipse.core.utils.FileUtils; import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; @@ -332,6 +333,17 @@ public CompletableFuture onCodingAgentMessage(CodingAg return CompletableFuture.completedFuture(result); } + /** + * Notify when a quota warning is received from the language server. + */ + @JsonNotification("copilot/quotaWarning") + public void onQuotaWarning(QuotaWarningNotification notification) { + CopilotCore.LOGGER.info("Quota warning received: " + notification); + if (eventBroker != null) { + eventBroker.post(CopilotEventConstants.TOPIC_QUOTA_WARNING, notification); + } + } + /** * 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/quota/CheckQuotaResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CheckQuotaResult.java index 64f34dc4..28e2446e 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CheckQuotaResult.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CheckQuotaResult.java @@ -14,6 +14,8 @@ public class CheckQuotaResult { private Quota chat; private Quota completions; private Quota premiumInteractions; + private IntervalQuota immediateUsageInterval; + private IntervalQuota extendedUsageInterval; private String resetDate; private CopilotPlan copilotPlan; @@ -41,6 +43,28 @@ public void setPremiumInteractionsQuota(Quota premiumInteractions) { this.premiumInteractions = premiumInteractions; } + /** + * Gets the immediate usage interval quota (for individual plans). + */ + public IntervalQuota getImmediateUsageInterval() { + return immediateUsageInterval; + } + + public void setImmediateUsageInterval(IntervalQuota immediateUsageInterval) { + this.immediateUsageInterval = immediateUsageInterval; + } + + /** + * Gets the extended usage interval quota (for individual plans). + */ + public IntervalQuota getExtendedUsageInterval() { + return extendedUsageInterval; + } + + public void setExtendedUsageInterval(IntervalQuota extendedUsageInterval) { + this.extendedUsageInterval = extendedUsageInterval; + } + public String getResetDate() { return resetDate; } @@ -59,7 +83,8 @@ public void setCopilotPlan(CopilotPlan copilotPlan) { @Override public int hashCode() { - return Objects.hash(chat, completions, copilotPlan, premiumInteractions, resetDate); + return Objects.hash(chat, completions, copilotPlan, extendedUsageInterval, + immediateUsageInterval, premiumInteractions, resetDate); } @Override @@ -75,7 +100,10 @@ public boolean equals(Object obj) { } CheckQuotaResult other = (CheckQuotaResult) obj; return Objects.equals(chat, other.chat) && Objects.equals(completions, other.completions) - && copilotPlan == other.copilotPlan && Objects.equals(premiumInteractions, other.premiumInteractions) + && copilotPlan == other.copilotPlan + && Objects.equals(extendedUsageInterval, other.extendedUsageInterval) + && Objects.equals(immediateUsageInterval, other.immediateUsageInterval) + && Objects.equals(premiumInteractions, other.premiumInteractions) && Objects.equals(resetDate, other.resetDate); } @@ -85,6 +113,8 @@ public String toString() { builder.append("chat", chat); builder.append("completions", completions); builder.append("premiumInteractions", premiumInteractions); + builder.append("immediateUsageInterval", immediateUsageInterval); + builder.append("extendedUsageInterval", extendedUsageInterval); builder.append("resetDate", resetDate); builder.append("copilotPlan", copilotPlan); return builder.toString(); diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CopilotPlan.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CopilotPlan.java index 674dc6a0..d0b2b222 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CopilotPlan.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CopilotPlan.java @@ -7,5 +7,5 @@ * Enum representing the different Copilot plans. */ public enum CopilotPlan { - free, individual, individual_pro, business, enterprise + free, individual, individual_pro, individual_max, business, enterprise } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/IntervalQuota.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/IntervalQuota.java new file mode 100644 index 00000000..96cd0d91 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/IntervalQuota.java @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol.quota; + +/** + * Interval-based quota information, used for immediateUsageInterval and extendedUsageInterval. + */ +public record IntervalQuota( + double percentRemaining, + boolean unlimited, + boolean overagePermitted, + Integer entitlement, + Integer quotaRemaining, + String timeStamp, + String resetAt) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/Quota.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/Quota.java index 9d712884..ab201051 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/Quota.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/Quota.java @@ -14,6 +14,9 @@ public class Quota { private double percentRemaining; private boolean unlimited; private boolean overagePermitted; + private Integer entitlement; + private Integer quotaRemaining; + private String timeStamp; /** * Creates a new CompletionsQuota quota information with default values. @@ -56,9 +59,42 @@ public void setOveragePermitted(boolean overagePermitted) { this.overagePermitted = overagePermitted; } + /** + * Gets the total entitlement (quota limit). + */ + public Integer getEntitlement() { + return entitlement; + } + + public void setEntitlement(Integer entitlement) { + this.entitlement = entitlement; + } + + /** + * Gets the remaining quota count. + */ + public Integer getQuotaRemaining() { + return quotaRemaining; + } + + public void setQuotaRemaining(Integer quotaRemaining) { + this.quotaRemaining = quotaRemaining; + } + + /** + * Gets the timestamp of the quota snapshot. + */ + public String getTimeStamp() { + return timeStamp; + } + + public void setTimeStamp(String timeStamp) { + this.timeStamp = timeStamp; + } + @Override public int hashCode() { - return Objects.hash(overagePermitted, percentRemaining, unlimited); + return Objects.hash(entitlement, overagePermitted, percentRemaining, quotaRemaining, timeStamp, unlimited); } @Override @@ -73,8 +109,9 @@ public boolean equals(Object obj) { return false; } Quota other = (Quota) obj; - return overagePermitted == other.overagePermitted + return Objects.equals(entitlement, other.entitlement) && overagePermitted == other.overagePermitted && Double.doubleToLongBits(percentRemaining) == Double.doubleToLongBits(other.percentRemaining) + && Objects.equals(quotaRemaining, other.quotaRemaining) && Objects.equals(timeStamp, other.timeStamp) && unlimited == other.unlimited; } @@ -84,6 +121,9 @@ public String toString() { builder.append("percentRemaining", percentRemaining); builder.append("unlimited", unlimited); builder.append("overagePermitted", overagePermitted); + builder.append("entitlement", entitlement); + builder.append("quotaRemaining", quotaRemaining); + builder.append("timeStamp", timeStamp); return builder.toString(); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaChangeParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaChangeParams.java new file mode 100644 index 00000000..23e3197c --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaChangeParams.java @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol.quota; + +import com.google.gson.annotations.SerializedName; + +/** + * Parameters for the {@code copilot/quotaChange} notification, sent by the language server + * whenever the user's quota usage changes. + * + * @param chat current chat quota snapshot, when available + * @param completions current completions quota snapshot, when available + * @param premiumInteractions current premium interactions quota snapshot, when available + * @param copilotPlan the user's Copilot plan (e.g. free, individual, individual_pro, individual_max, + * business, enterprise) + */ +public record QuotaChangeParams(QuotaSnapshotParams chat, QuotaSnapshotParams completions, + @SerializedName("premium_interactions") QuotaSnapshotParams premiumInteractions, String copilotPlan) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningNotification.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningNotification.java new file mode 100644 index 00000000..91e05e04 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningNotification.java @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol.quota; + +/** + * Parameters for the "copilot/quotaWarning" notification. Sent by the language server when the user's AI quota exceeds + * the warning threshold. + */ +public record QuotaWarningNotification(String message, double percentUsed) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningParams.java new file mode 100644 index 00000000..537d1a3d --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningParams.java @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol.quota; + +import com.google.gson.annotations.SerializedName; + +/** + * Parameters for the {@code copilot/quotaWarning} notification, sent by the language server when + * the user crosses a quota usage threshold or starts consuming overages. + * + * @param title warning title (e.g. "Copilot Quota Usage Alert") + * @param message human-readable warning message + * @param severity severity level, either {@code "warning"} or {@code "info"} + * @param chat current chat quota snapshot, when available + * @param completions current completions quota snapshot, when available + * @param premiumInteractions current premium interactions quota snapshot, when available + * @param copilotPlan the user's Copilot plan + */ +public record QuotaWarningParams(String title, String message, String severity, QuotaSnapshotParams chat, + QuotaSnapshotParams completions, + @SerializedName("premium_interactions") QuotaSnapshotParams premiumInteractions, String copilotPlan) { +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/i18n/MessagesTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/i18n/MessagesTest.java index 850a457c..d855f404 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/i18n/MessagesTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/i18n/MessagesTest.java @@ -14,5 +14,8 @@ void testMessagesInitialization() { // Ensure that the static fields are initialized assertNotNull(Messages.menu_signToGitHub); assertNotNull(Messages.menu_signOutOfGitHub); + assertNotNull(Messages.quotaWarning_title); + assertNotNull(Messages.quotaWarning_closeButton); + assertNotNull(Messages.quotaWarning_increaseBudgetButton); } } \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaWarningNotificationPopupTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaWarningNotificationPopupTest.java new file mode 100644 index 00000000..5500b332 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaWarningNotificationPopupTest.java @@ -0,0 +1,152 @@ +package com.microsoft.copilot.eclipse.ui.notifications; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.lang.reflect.Method; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Event; +import org.eclipse.swt.widgets.Label; +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.mockito.MockedStatic; +import org.mockito.Mockito; + +import com.microsoft.copilot.eclipse.ui.UiConstants; +import com.microsoft.copilot.eclipse.ui.i18n.Messages; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; +import com.microsoft.copilot.eclipse.ui.utils.UiUtils; + +class QuotaWarningNotificationPopupTest { + + private static final String WARNING_MESSAGE = "You have used 90% of your quota."; + + private Shell shell; + private Composite parent; + + @BeforeEach + void setUp() { + SwtUtils.invokeOnDisplayThread(() -> { + shell = new Shell(Display.getDefault()); + parent = new Composite(shell, SWT.NONE); + }); + } + + @AfterEach + void tearDown() { + SwtUtils.invokeOnDisplayThread(() -> { + if (shell != null && !shell.isDisposed()) { + shell.dispose(); + } + }); + } + + @Test + void testCreateNotificationTitle_UsesLocalizedTitleText() { + SwtUtils.invokeOnDisplayThread(() -> { + QuotaWarningNotificationPopup popup = new QuotaWarningNotificationPopup(Display.getDefault(), WARNING_MESSAGE, + 1000); + Image icon = new Image(Display.getDefault(), 1, 1); + + try (MockedStatic uiUtilsMock = Mockito.mockStatic(UiUtils.class)) { + uiUtilsMock.when(() -> UiUtils.buildImageFromPngPath("/icons/github_copilot.png")).thenReturn(icon); + + Control title = invokeCreateNotificationTitle(popup, parent); + Label titleLabel = findLabelByText((Composite) title, Messages.quotaWarning_title); + + assertNotNull(titleLabel); + assertEquals(Messages.quotaWarning_title, titleLabel.getText()); + } finally { + if (!icon.isDisposed()) { + icon.dispose(); + } + } + }); + } + + @Test + void testCreateNotificationContent_UsesMessageAndBudgetAction() { + SwtUtils.invokeOnDisplayThread(() -> { + QuotaWarningNotificationPopup popup = new QuotaWarningNotificationPopup(Display.getDefault(), WARNING_MESSAGE, + 1000); + + try (MockedStatic uiUtilsMock = Mockito.mockStatic(UiUtils.class)) { + uiUtilsMock.when(() -> UiUtils.openLink(UiConstants.MANAGE_COPILOT_OVERAGE_URL)).thenReturn(true); + + Composite content = invokeCreateNotificationContent(popup, parent, WARNING_MESSAGE); + Label messageLabel = findLabelByText(content, WARNING_MESSAGE); + Button closeButton = findButtonByText(content, Messages.quotaWarning_closeButton); + Button increaseBudgetButton = findButtonByText(content, Messages.quotaWarning_increaseBudgetButton); + + assertNotNull(messageLabel); + assertNotNull(closeButton); + assertNotNull(increaseBudgetButton); + + increaseBudgetButton.notifyListeners(SWT.Selection, new Event()); + + uiUtilsMock.verify(() -> UiUtils.openLink(UiConstants.MANAGE_COPILOT_OVERAGE_URL)); + } + }); + } + + private Control invokeCreateNotificationTitle(QuotaWarningNotificationPopup popup, Composite titleParent) { + try { + Method method = QuotaWarningNotificationPopup.class.getDeclaredMethod("createNotificationTitle", + Composite.class); + method.setAccessible(true); + return (Control) method.invoke(popup, titleParent); + } catch (ReflectiveOperationException e) { + throw new AssertionError("Failed to invoke createNotificationTitle", e); + } + } + + private Composite invokeCreateNotificationContent(QuotaWarningNotificationPopup popup, Composite contentParent, + String message) { + try { + Method method = QuotaWarningNotificationPopup.class.getDeclaredMethod("createNotificationContent", + Composite.class, String.class); + method.setAccessible(true); + return (Composite) method.invoke(popup, contentParent, message); + } catch (ReflectiveOperationException e) { + throw new AssertionError("Failed to invoke createNotificationContent", e); + } + } + + private Label findLabelByText(Composite parentControl, String text) { + for (Control child : parentControl.getChildren()) { + if (child instanceof Label label && text.equals(label.getText())) { + return label; + } + if (child instanceof Composite composite) { + Label nestedLabel = findLabelByText(composite, text); + if (nestedLabel != null) { + return nestedLabel; + } + } + } + return null; + } + + private Button findButtonByText(Composite parentControl, String text) { + for (Control child : parentControl.getChildren()) { + if (child instanceof Button button && text.equals(button.getText())) { + return button; + } + if (child instanceof Composite composite) { + Button nestedButton = findButtonByText(composite, text); + if (nestedButton != null) { + return nestedButton; + } + } + } + return null; + } +} \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF index a6ae5073..2edd6538 100644 --- a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF @@ -32,6 +32,7 @@ Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.15.0", org.eclipse.ui;bundle-version="3.205.0", org.eclipse.ui.navigator;bundle-version="3.12.200", org.eclipse.jface.text;bundle-version="3.24.200", + org.eclipse.jface.notifications;bundle-version="0.7.0", org.eclipse.core.runtime;bundle-version="[3.30.0,4.0.0)", org.eclipse.core.expressions, org.eclipse.jdt.annotation;resolution:=optional, diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/CopilotUi.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/CopilotUi.java index 66c472b4..f0623fd5 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/CopilotUi.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/CopilotUi.java @@ -33,6 +33,7 @@ import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; import com.microsoft.copilot.eclipse.ui.completion.EditorLifecycleListener; import com.microsoft.copilot.eclipse.ui.completion.EditorsManager; +import com.microsoft.copilot.eclipse.ui.notifications.QuotaNotificationManager; import com.microsoft.copilot.eclipse.ui.preferences.LanguageServerSettingManager; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; @@ -49,6 +50,7 @@ public class CopilotUi extends AbstractUIPlugin { private EditorsManager editorsManager; private ChatServiceManager chatServiceManager; private LanguageServerSettingManager settingMgr; + private QuotaNotificationManager quotaNotificationManager; public static final String INIT_JOB_FAMILY = "com.microsoft.copilot.eclipse.ui.initJob"; @@ -114,6 +116,7 @@ protected IStatus run(IProgressMonitor monitor) { // some server to client request that needs to be handled with UI logics. CopilotCore.getPlugin().setChatServiceManager(chatServiceManager); CopilotUi.this.copilotStatusManager = new CopilotStatusManager(); + CopilotUi.this.quotaNotificationManager = new QuotaNotificationManager(); settingMgr.syncMcpRegistrationConfiguration(); registerPartListener(); @@ -226,6 +229,10 @@ public void stop(BundleContext context) throws Exception { if (this.chatServiceManager != null) { this.chatServiceManager.dispose(); } + + if (this.quotaNotificationManager != null) { + this.quotaNotificationManager.dispose(); + } } public EditorsManager getEditorsManager() { 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 a36a66cb..29f78528 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 @@ -223,8 +223,9 @@ public void processTurnEvent(ChatProgressValue value) { String fallbackModelName = fallbackModel != null ? fallbackModel.getModelName() : Messages.chat_noQuotaView_fallbackModel; - if (userPlan == CopilotPlan.individual || userPlan == CopilotPlan.individual_pro) { - // Pro and Pro+ message + if (userPlan == CopilotPlan.individual || userPlan == CopilotPlan.individual_pro + || userPlan == CopilotPlan.individual_max) { + // Paid individual plan message errMsg = String.format(Messages.chat_noQuotaView_proProplusWarnMsg, fallbackModelName); } else if (userPlan == CopilotPlan.business || userPlan == CopilotPlan.enterprise) { // CE and CB message 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 96e7d091..35f41c0b 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 @@ -188,6 +188,9 @@ public final class Messages extends NLS { public static String model_hover_cost; public static String chat_actionBar_modePicker_Tooltip; public static String chat_actionBar_modelPicker_Tooltip; + public static String quotaWarning_title; + public static String quotaWarning_closeButton; + public static String quotaWarning_increaseBudgetButton; public static String context_window_title; public static String context_window_tokens; public static String context_window_system; 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 2c77af76..ac3770bd 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 @@ -184,6 +184,11 @@ model_hover_contextWindow=Context Window: model_hover_cost=Cost: chat_actionBar_modePicker_Tooltip=Set Agents chat_actionBar_modelPicker_Tooltip=Pick Model{0} + +quotaWarning_title=GitHub Copilot +quotaWarning_closeButton=Close +quotaWarning_increaseBudgetButton=Increase Budget + context_window_title=Context Window context_window_tokens={0} / {1} tokens context_window_system=System diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaNotificationManager.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaNotificationManager.java new file mode 100644 index 00000000..c9dd4830 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaNotificationManager.java @@ -0,0 +1,62 @@ +package com.microsoft.copilot.eclipse.ui.notifications; + +import org.eclipse.e4.core.services.events.IEventBroker; +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.PlatformUI; +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.protocol.quota.QuotaWarningNotification; + +/** + * Handles quota notifications from the language server and displays + * them as JFace notification popups. + */ +public class QuotaNotificationManager { + + private static final int NOTIFICATION_DELAY_MS = 10000; + + private final IEventBroker eventBroker; + private EventHandler quotaWarningEventHandler; + + /** + * Creates a new QuotaNotificationManager and subscribes to quota events. + */ + public QuotaNotificationManager() { + this.eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); + if (eventBroker != null) { + this.quotaWarningEventHandler = event -> { + Object data = event.getProperty(IEventBroker.DATA); + if (data instanceof QuotaWarningNotification notification) { + showQuotaWarningNotification(notification); + } + }; + eventBroker.subscribe(CopilotEventConstants.TOPIC_QUOTA_WARNING, quotaWarningEventHandler); + } + } + + /** + * Unsubscribes the quota event handler. + */ + public void dispose() { + if (eventBroker != null && quotaWarningEventHandler != null) { + eventBroker.unsubscribe(quotaWarningEventHandler); + quotaWarningEventHandler = null; + } + } + + private void showQuotaWarningNotification(QuotaWarningNotification notification) { + Display display = PlatformUI.getWorkbench().getDisplay(); + if (display == null || display.isDisposed()) { + return; + } + display.asyncExec(() -> { + try { + new QuotaWarningNotificationPopup(display, notification.message(), NOTIFICATION_DELAY_MS).open(); + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to show quota warning notification", e); + } + }); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaWarningNotificationPopup.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaWarningNotificationPopup.java new file mode 100644 index 00000000..c23d83fd --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaWarningNotificationPopup.java @@ -0,0 +1,140 @@ +package com.microsoft.copilot.eclipse.ui.notifications; + +import org.eclipse.jface.notifications.NotificationPopup; +import org.eclipse.jface.resource.JFaceResources; +import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.Image; +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.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Listener; +import org.eclipse.swt.widgets.Shell; + +import com.microsoft.copilot.eclipse.ui.UiConstants; +import com.microsoft.copilot.eclipse.ui.i18n.Messages; +import com.microsoft.copilot.eclipse.ui.utils.UiUtils; + +/** + * Popup for quota warning notifications. + */ +public class QuotaWarningNotificationPopup { + + private static final int ALIGN_SPACING = 8; + private static final String BORDER_INSTALLED_KEY = "quotaWarningNotificationPopup.borderInstalled"; + + private final Display display; + private final String message; + private final int delayMs; + + /** + * Creates a popup for a quota warning message. + * + * @param display the target display + * @param message the quota warning message + * @param delayMs the popup auto-close delay in milliseconds + */ + public QuotaWarningNotificationPopup(Display display, String message, int delayMs) { + this.display = display; + this.message = message; + this.delayMs = delayMs; + } + + /** + * Opens the popup. + */ + public void open() { + NotificationPopup.forDisplay(display).title(parent -> createNotificationTitle(parent), false).fadeIn(true) + .delay(delayMs).content(parent -> createNotificationContent(parent, message)).open(); + } + + private Control createNotificationTitle(Composite parent) { + addBorder(parent.getShell()); + + Composite title = new Composite(parent, SWT.NONE); + GridLayout layout = new GridLayout(2, false); + layout.marginWidth = 4; + layout.marginHeight = 4; + title.setLayout(layout); + title.setCursor(parent.getDisplay().getSystemCursor(SWT.CURSOR_ARROW)); + + Label iconLabel = new Label(title, SWT.NONE); + Image icon = UiUtils.buildImageFromPngPath("/icons/github_copilot.png"); + iconLabel.setImage(icon); + iconLabel.setCursor(parent.getDisplay().getSystemCursor(SWT.CURSOR_ARROW)); + parent.addDisposeListener(e -> { + if (icon != null && !icon.isDisposed()) { + icon.dispose(); + } + }); + + Label titleLabel = new Label(title, SWT.NONE); + titleLabel.setText(Messages.quotaWarning_title); + titleLabel.setFont(JFaceResources.getFontRegistry().getBold(JFaceResources.DEFAULT_FONT)); + + return title; + } + + /** + * Needs to add a border to this notification popup since the chat view has the same color with the notification + * background in dark mode. The boundary of the notification will become unclear without the border. + */ + private void addBorder(Shell shell) { + if (Boolean.TRUE.equals(shell.getData(BORDER_INSTALLED_KEY))) { + return; + } + + Color borderColor = shell.getDisplay().getSystemColor(SWT.COLOR_WIDGET_NORMAL_SHADOW); + shell.addPaintListener(e -> { + Rectangle bounds = shell.getClientArea(); + e.gc.setForeground(borderColor); + e.gc.setLineWidth(1); + e.gc.drawRectangle(0, 0, bounds.width - 1, bounds.height - 1); + }); + shell.setData(BORDER_INSTALLED_KEY, Boolean.TRUE); + } + + private Composite createNotificationContent(Composite parent, String notificationMessage) { + GridLayout layout = new GridLayout(2, false); + layout.marginWidth = ALIGN_SPACING; + layout.marginHeight = ALIGN_SPACING; + layout.marginBottom = ALIGN_SPACING; + layout.horizontalSpacing = ALIGN_SPACING; + Composite content = new Composite(parent, SWT.NONE); + content.setLayout(layout); + + new Label(content, SWT.NONE).setImage(parent.getDisplay().getSystemImage(SWT.ICON_WARNING)); + + Label messageLabel = new Label(content, SWT.WRAP); + messageLabel.setText(notificationMessage); + GridData messageLayoutData = new GridData(SWT.FILL, SWT.NONE, true, true); + messageLabel.setLayoutData(messageLayoutData); + + Composite buttons = new Composite(content, SWT.NONE); + GridLayout buttonLayout = new GridLayout(2, true); + buttonLayout.horizontalSpacing = ALIGN_SPACING; + buttonLayout.marginWidth = 0; + buttons.setLayout(buttonLayout); + buttons.setLayoutData(new GridData(SWT.RIGHT, SWT.NONE, true, false, 2, 1)); + + createButton(buttons, Messages.quotaWarning_closeButton, e -> parent.getShell().close()); + createButton(buttons, Messages.quotaWarning_increaseBudgetButton, e -> { + UiUtils.openLink(UiConstants.MANAGE_COPILOT_OVERAGE_URL); + parent.getShell().close(); + }); + + return content; + } + + private void createButton(Composite parent, String label, Listener listener) { + Button button = new Button(parent, SWT.PUSH); + button.setText(label); + button.setLayoutData(new GridData(SWT.FILL, SWT.NONE, true, false)); + button.addListener(SWT.Selection, listener); + } +} \ No newline at end of file From 9536b0553b49481b8a4d032507a2ad45ce6739fa Mon Sep 17 00:00:00 2001 From: Ethan Hou Date: Wed, 13 May 2026 15:03:24 +0800 Subject: [PATCH 4/9] feat: Implement quota warning handling and UI updates for TBB --- .../core/lsp/CopilotLanguageClientTests.java | 3 +- .../core/lsp/protocol/ChatCreateResult.java | 3 - .../core/lsp/protocol/ChatProgressValue.java | 9 ++ .../core/lsp/protocol/ChatTurnResult.java | 3 - .../core/lsp/protocol/ConversationError.java | 19 ++- .../quota/QuotaWarningNotification.java | 43 ++++- .../persistence/ConversationDataFactory.java | 1 + .../core/persistence/CopilotTurnData.java | 19 ++- .../copilot/eclipse/ui/i18n/MessagesTest.java | 5 +- .../QuotaWarningNotificationPopupTest.java | 152 ------------------ .../copilot/eclipse/ui/CopilotUi.java | 7 - .../copilot/eclipse/ui/chat/ActionBar.java | 75 +++++++-- .../copilot/eclipse/ui/chat/BannerAction.java | 14 ++ .../eclipse/ui/chat/BaseTurnWidget.java | 21 ++- .../eclipse/ui/chat/ChatContentViewer.java | 49 ++---- .../copilot/eclipse/ui/chat/ChatView.java | 34 +++- .../copilot/eclipse/ui/chat/Messages.java | 1 + .../copilot/eclipse/ui/chat/QuotaActions.java | 88 ++++++++++ .../copilot/eclipse/ui/chat/StaticBanner.java | 100 +++++++----- .../copilot/eclipse/ui/chat/WarnWidget.java | 71 +++++--- .../eclipse/ui/chat/messages.properties | 1 + .../ui/chat/services/TodoListService.java | 5 +- .../ui/chat/tools/FileToolService.java | 6 +- .../copilot/eclipse/ui/i18n/Messages.java | 12 +- .../eclipse/ui/i18n/messages.properties | 17 +- .../QuotaNotificationManager.java | 62 ------- .../QuotaWarningNotificationPopup.java | 140 ---------------- 27 files changed, 442 insertions(+), 518 deletions(-) delete mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaWarningNotificationPopupTest.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BannerAction.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java delete mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaNotificationManager.java delete mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaWarningNotificationPopup.java diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java index 300fb4ff..52edec74 100644 --- a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java @@ -141,7 +141,8 @@ void testOnDidChangeFeatureFlagsWithEmptyFeatureFlags() { @Test void testOnQuotaWarning_PostsNotificationToEventBroker() { - QuotaWarningNotification notification = new QuotaWarningNotification("Approaching quota", 90.0); + QuotaWarningNotification notification = new QuotaWarningNotification( + "Copilot Quota Usage Alert", "Approaching quota", "warning", null, null); setEventBroker(eventBroker); client.onQuotaWarning(notification); diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatCreateResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatCreateResult.java index 040e6b17..4dafbe0a 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatCreateResult.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatCreateResult.java @@ -6,15 +6,12 @@ import java.util.Objects; import org.apache.commons.lang3.builder.ToStringBuilder; -import org.eclipse.lsp4j.jsonrpc.validation.NonNull; /** * Result of a chat creation. */ public class ChatCreateResult { - @NonNull private String conversationId; - @NonNull private String turnId; private String agentSlug; private String modelName; diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatProgressValue.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatProgressValue.java index aa95925a..3929af0b 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatProgressValue.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatProgressValue.java @@ -101,6 +101,15 @@ public String getErrorReason() { return error != null ? error.getReason() : null; } + /** + * Returns the BYOK model-provider name from the error payload, when present. + * + * @return the name of the BYOK model provider that produced the error, or {@code null} for built-in models. + */ + public String getErrorModelProviderName() { + return error != null ? error.getModelProviderName() : null; + } + public List getAgentRounds() { return editAgentRounds; } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatTurnResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatTurnResult.java index 47af6b3a..20eefa8e 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatTurnResult.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatTurnResult.java @@ -6,15 +6,12 @@ import java.util.Objects; import org.apache.commons.lang3.builder.ToStringBuilder; -import org.eclipse.lsp4j.jsonrpc.validation.NonNull; /** * Result of a chat turn. */ public class ChatTurnResult { - @NonNull private String conversationId; - @NonNull private String turnId; private String agentSlug; private String modelName; diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationError.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationError.java index d4c883bc..a8b52a77 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationError.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationError.java @@ -19,6 +19,7 @@ public class ConversationError { private String reason; private boolean responseIsIncomplete; private boolean responseIsFiltered; + private String modelProviderName; public void setMessage(String message) { this.message = message; @@ -40,6 +41,10 @@ public void setResponseIsFiltered(boolean responseIsFiltered) { this.responseIsFiltered = responseIsFiltered; } + public void setModelProviderName(String modelProviderName) { + this.modelProviderName = modelProviderName; + } + public String getMessage() { return message; } @@ -60,9 +65,17 @@ public boolean getResponseIsFiltered() { return responseIsFiltered; } + /** + * The name of the model provider that produced the error, when the failing request was routed to a custom + * Bring-Your-Own-Key (BYOK) model. {@code null} or blank for built-in Copilot models. + */ + public String getModelProviderName() { + return modelProviderName; + } + @Override public int hashCode() { - return Objects.hash(message, code, reason, responseIsIncomplete, responseIsFiltered); + return Objects.hash(message, code, reason, responseIsIncomplete, responseIsFiltered, modelProviderName); } @Override @@ -75,7 +88,8 @@ public boolean equals(Object o) { } ConversationError that = (ConversationError) o; return Objects.equals(message, that.message) && code == that.code && Objects.equals(reason, that.reason) - && responseIsIncomplete == that.responseIsIncomplete && responseIsFiltered == that.responseIsFiltered; + && responseIsIncomplete == that.responseIsIncomplete && responseIsFiltered == that.responseIsFiltered + && Objects.equals(modelProviderName, that.modelProviderName); } @Override @@ -86,6 +100,7 @@ public String toString() { builder.append("reason", reason); builder.append("responseIsIncomplete", responseIsIncomplete); builder.append("responseIsFiltered", responseIsFiltered); + builder.append("modelProviderName", modelProviderName); return builder.toString(); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningNotification.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningNotification.java index 91e05e04..7ab33faa 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningNotification.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningNotification.java @@ -3,9 +3,46 @@ package com.microsoft.copilot.eclipse.core.lsp.protocol.quota; +import com.google.gson.annotations.SerializedName; + /** - * Parameters for the "copilot/quotaWarning" notification. Sent by the language server when the user's AI quota exceeds - * the warning threshold. + * Parameters for the {@code copilot/quotaWarning} notification. Sent by the language server when the user's AI quota + * exceeds a warning threshold. + * + * @param title the popup title supplied by the language server + * @param message the popup body message + * @param severity the language-server severity hint (e.g. {@code "info"} or {@code "warning"}); used by the client to + * decide which icon to render on the banner. May be {@code null}. + * @param copilotPlan the user's Copilot plan + * @param premiumInteractions the premium-interactions snapshot for the warning, or {@code null} when the language + * server does not include it */ -public record QuotaWarningNotification(String message, double percentUsed) { +public record QuotaWarningNotification( + String title, + String message, + String severity, + CopilotPlan copilotPlan, + @SerializedName("premium_interactions") PremiumInteractions premiumInteractions) { + + /** + * Premium-interactions snapshot embedded in a {@link QuotaWarningNotification}. The shape is dictated by the + * language server and differs from {@link Quota}. + * + * @param quota total monthly premium-interactions allowance + * @param used premium interactions consumed so far this period + * @param percentRemaining percentage of the allowance remaining + * @param overageUsed additional paid interactions consumed beyond the allowance + * @param overageEnabled whether the user has enabled paid overage + * @param resetDate ISO-8601 instant when the monthly allowance resets, or {@code null} + * @param unlimited whether this quota has no monthly limit + */ + public record PremiumInteractions( + double quota, + double used, + double percentRemaining, + double overageUsed, + boolean overageEnabled, + String resetDate, + boolean unlimited) { + } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationDataFactory.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationDataFactory.java index f7ea370e..63dfceb0 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationDataFactory.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationDataFactory.java @@ -179,6 +179,7 @@ private void applyConversationError(ReplyData reply, ConversationError error) { ErrorData errorData = new ErrorData(); errorData.setMessage(error.getMessage()); errorData.setCode(error.getCode()); + errorData.setModelProviderName(error.getModelProviderName()); ErrorMessageData em = new ErrorMessageData(); em.setError(errorData); reply.getErrorMessages().add(em); diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/CopilotTurnData.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/CopilotTurnData.java index d614bf87..5df84f97 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/CopilotTurnData.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/CopilotTurnData.java @@ -440,6 +440,7 @@ public String toString() { public static class ErrorData { private String message; private int code; + private String modelProviderName; private Map data; public String getMessage() { @@ -458,6 +459,18 @@ public void setCode(int code) { this.code = code; } + /** + * The BYOK model provider responsible for the error, or {@code null} when the failing model was a + * built-in Copilot model. + */ + public String getModelProviderName() { + return modelProviderName; + } + + public void setModelProviderName(String modelProviderName) { + this.modelProviderName = modelProviderName; + } + public Map getData() { return data; } @@ -468,7 +481,7 @@ public void setData(Map data) { @Override public int hashCode() { - return Objects.hash(code, data, message); + return Objects.hash(code, data, message, modelProviderName); } @Override @@ -483,7 +496,8 @@ public boolean equals(Object obj) { return false; } ErrorData other = (ErrorData) obj; - return code == other.code && Objects.equals(data, other.data) && Objects.equals(message, other.message); + return code == other.code && Objects.equals(data, other.data) && Objects.equals(message, other.message) + && Objects.equals(modelProviderName, other.modelProviderName); } @Override @@ -491,6 +505,7 @@ public String toString() { ToStringBuilder builder = new ToStringBuilder(this); builder.append("message", message); builder.append("code", code); + builder.append("modelProviderName", modelProviderName); builder.append("data", data); return builder.toString(); } diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/i18n/MessagesTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/i18n/MessagesTest.java index d855f404..0ac97045 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/i18n/MessagesTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/i18n/MessagesTest.java @@ -14,8 +14,5 @@ void testMessagesInitialization() { // Ensure that the static fields are initialized assertNotNull(Messages.menu_signToGitHub); assertNotNull(Messages.menu_signOutOfGitHub); - assertNotNull(Messages.quotaWarning_title); - assertNotNull(Messages.quotaWarning_closeButton); - assertNotNull(Messages.quotaWarning_increaseBudgetButton); } -} \ No newline at end of file +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaWarningNotificationPopupTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaWarningNotificationPopupTest.java deleted file mode 100644 index 5500b332..00000000 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaWarningNotificationPopupTest.java +++ /dev/null @@ -1,152 +0,0 @@ -package com.microsoft.copilot.eclipse.ui.notifications; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - -import java.lang.reflect.Method; - -import org.eclipse.swt.SWT; -import org.eclipse.swt.graphics.Image; -import org.eclipse.swt.widgets.Button; -import org.eclipse.swt.widgets.Composite; -import org.eclipse.swt.widgets.Control; -import org.eclipse.swt.widgets.Display; -import org.eclipse.swt.widgets.Event; -import org.eclipse.swt.widgets.Label; -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.mockito.MockedStatic; -import org.mockito.Mockito; - -import com.microsoft.copilot.eclipse.ui.UiConstants; -import com.microsoft.copilot.eclipse.ui.i18n.Messages; -import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; -import com.microsoft.copilot.eclipse.ui.utils.UiUtils; - -class QuotaWarningNotificationPopupTest { - - private static final String WARNING_MESSAGE = "You have used 90% of your quota."; - - private Shell shell; - private Composite parent; - - @BeforeEach - void setUp() { - SwtUtils.invokeOnDisplayThread(() -> { - shell = new Shell(Display.getDefault()); - parent = new Composite(shell, SWT.NONE); - }); - } - - @AfterEach - void tearDown() { - SwtUtils.invokeOnDisplayThread(() -> { - if (shell != null && !shell.isDisposed()) { - shell.dispose(); - } - }); - } - - @Test - void testCreateNotificationTitle_UsesLocalizedTitleText() { - SwtUtils.invokeOnDisplayThread(() -> { - QuotaWarningNotificationPopup popup = new QuotaWarningNotificationPopup(Display.getDefault(), WARNING_MESSAGE, - 1000); - Image icon = new Image(Display.getDefault(), 1, 1); - - try (MockedStatic uiUtilsMock = Mockito.mockStatic(UiUtils.class)) { - uiUtilsMock.when(() -> UiUtils.buildImageFromPngPath("/icons/github_copilot.png")).thenReturn(icon); - - Control title = invokeCreateNotificationTitle(popup, parent); - Label titleLabel = findLabelByText((Composite) title, Messages.quotaWarning_title); - - assertNotNull(titleLabel); - assertEquals(Messages.quotaWarning_title, titleLabel.getText()); - } finally { - if (!icon.isDisposed()) { - icon.dispose(); - } - } - }); - } - - @Test - void testCreateNotificationContent_UsesMessageAndBudgetAction() { - SwtUtils.invokeOnDisplayThread(() -> { - QuotaWarningNotificationPopup popup = new QuotaWarningNotificationPopup(Display.getDefault(), WARNING_MESSAGE, - 1000); - - try (MockedStatic uiUtilsMock = Mockito.mockStatic(UiUtils.class)) { - uiUtilsMock.when(() -> UiUtils.openLink(UiConstants.MANAGE_COPILOT_OVERAGE_URL)).thenReturn(true); - - Composite content = invokeCreateNotificationContent(popup, parent, WARNING_MESSAGE); - Label messageLabel = findLabelByText(content, WARNING_MESSAGE); - Button closeButton = findButtonByText(content, Messages.quotaWarning_closeButton); - Button increaseBudgetButton = findButtonByText(content, Messages.quotaWarning_increaseBudgetButton); - - assertNotNull(messageLabel); - assertNotNull(closeButton); - assertNotNull(increaseBudgetButton); - - increaseBudgetButton.notifyListeners(SWT.Selection, new Event()); - - uiUtilsMock.verify(() -> UiUtils.openLink(UiConstants.MANAGE_COPILOT_OVERAGE_URL)); - } - }); - } - - private Control invokeCreateNotificationTitle(QuotaWarningNotificationPopup popup, Composite titleParent) { - try { - Method method = QuotaWarningNotificationPopup.class.getDeclaredMethod("createNotificationTitle", - Composite.class); - method.setAccessible(true); - return (Control) method.invoke(popup, titleParent); - } catch (ReflectiveOperationException e) { - throw new AssertionError("Failed to invoke createNotificationTitle", e); - } - } - - private Composite invokeCreateNotificationContent(QuotaWarningNotificationPopup popup, Composite contentParent, - String message) { - try { - Method method = QuotaWarningNotificationPopup.class.getDeclaredMethod("createNotificationContent", - Composite.class, String.class); - method.setAccessible(true); - return (Composite) method.invoke(popup, contentParent, message); - } catch (ReflectiveOperationException e) { - throw new AssertionError("Failed to invoke createNotificationContent", e); - } - } - - private Label findLabelByText(Composite parentControl, String text) { - for (Control child : parentControl.getChildren()) { - if (child instanceof Label label && text.equals(label.getText())) { - return label; - } - if (child instanceof Composite composite) { - Label nestedLabel = findLabelByText(composite, text); - if (nestedLabel != null) { - return nestedLabel; - } - } - } - return null; - } - - private Button findButtonByText(Composite parentControl, String text) { - for (Control child : parentControl.getChildren()) { - if (child instanceof Button button && text.equals(button.getText())) { - return button; - } - if (child instanceof Composite composite) { - Button nestedButton = findButtonByText(composite, text); - if (nestedButton != null) { - return nestedButton; - } - } - } - return null; - } -} \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/CopilotUi.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/CopilotUi.java index f0623fd5..66c472b4 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/CopilotUi.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/CopilotUi.java @@ -33,7 +33,6 @@ import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; import com.microsoft.copilot.eclipse.ui.completion.EditorLifecycleListener; import com.microsoft.copilot.eclipse.ui.completion.EditorsManager; -import com.microsoft.copilot.eclipse.ui.notifications.QuotaNotificationManager; import com.microsoft.copilot.eclipse.ui.preferences.LanguageServerSettingManager; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; @@ -50,7 +49,6 @@ public class CopilotUi extends AbstractUIPlugin { private EditorsManager editorsManager; private ChatServiceManager chatServiceManager; private LanguageServerSettingManager settingMgr; - private QuotaNotificationManager quotaNotificationManager; public static final String INIT_JOB_FAMILY = "com.microsoft.copilot.eclipse.ui.initJob"; @@ -116,7 +114,6 @@ protected IStatus run(IProgressMonitor monitor) { // some server to client request that needs to be handled with UI logics. CopilotCore.getPlugin().setChatServiceManager(chatServiceManager); CopilotUi.this.copilotStatusManager = new CopilotStatusManager(); - CopilotUi.this.quotaNotificationManager = new QuotaNotificationManager(); settingMgr.syncMcpRegistrationConfiguration(); registerPartListener(); @@ -229,10 +226,6 @@ public void stop(BundleContext context) throws Exception { if (this.chatServiceManager != null) { this.chatServiceManager.dispose(); } - - if (this.quotaNotificationManager != null) { - this.quotaNotificationManager.dispose(); - } } public EditorsManager getEditorsManager() { 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 4e2d8bac..2c60e31c 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 @@ -65,6 +65,7 @@ import com.microsoft.copilot.eclipse.core.chat.CustomChatModeManager; import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatMode; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; import com.microsoft.copilot.eclipse.core.utils.ChatMessageUtils; import com.microsoft.copilot.eclipse.core.utils.FileUtils; import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; @@ -118,6 +119,7 @@ public class ActionBar extends Composite implements NewConversationListener { private Image autoBreakpointDisabledImage; private ContextSizeDonut contextSizeDonut; private StaticBanner staticBanner; + private Composite inputArea; private ChatServiceManager chatServiceManager; IEventBroker eventBroker; @@ -163,13 +165,28 @@ public ActionBar(Composite parent, int style, ChatServiceManager chatServiceMana }; this.eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_DID_CHANGE_FEATURE_FLAGS, featureFlagsChangedEventHandler); - Composite actionBar = new Composite(this, style | SWT.BORDER); + // Transparent wrapper for the optional TodoListBar / WorkingSetBar and the bordered input below. + // StaticBanner is created as a sibling of inputArea so it stays structurally above the whole stack. + this.inputArea = new Composite(this, SWT.NONE); + GridLayout glInputArea = new GridLayout(1, false); + glInputArea.marginWidth = 0; + glInputArea.marginHeight = 0; + glInputArea.marginLeft = 0; + glInputArea.marginRight = 0; + glInputArea.marginTop = 0; + glInputArea.marginBottom = 0; + glInputArea.horizontalSpacing = 0; + glInputArea.verticalSpacing = 0; + this.inputArea.setLayout(glInputArea); + this.inputArea.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + + Composite borderedActionBar = new Composite(this.inputArea, style | SWT.BORDER); GridLayout gl = new GridLayout(1, false); gl.marginHeight = 5; gl.verticalSpacing = 0; - actionBar.setLayout(gl); - actionBar.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); - actionBar.setData(CssConstants.CSS_ID_KEY, "chat-action-bar"); + borderedActionBar.setLayout(gl); + borderedActionBar.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + borderedActionBar.setData(CssConstants.CSS_ID_KEY, "chat-action-bar"); RowLayout rowLayout = new RowLayout(); rowLayout.wrap = true; @@ -185,7 +202,7 @@ public ActionBar(Composite parent, int style, ChatServiceManager chatServiceMana rowLayout.marginTop = 0; rowLayout.marginBottom = 10; rowLayout.center = true; - this.cmpFileRef = new Composite(actionBar, SWT.NONE); + this.cmpFileRef = new Composite(borderedActionBar, SWT.NONE); this.cmpFileRef.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); this.cmpFileRef.setLayout(rowLayout); new AddContextButton(this.cmpFileRef); @@ -197,7 +214,7 @@ public ActionBar(Composite parent, int style, ChatServiceManager chatServiceMana ModelService modelService = chatServiceManager.getModelService(); modelService.bindActionBarForSupportVisionChange(this); - ChatInputTextViewer tv = new ChatInputTextViewer(actionBar, chatServiceManager); + ChatInputTextViewer tv = new ChatInputTextViewer(borderedActionBar, chatServiceManager); tv.setEditable(true); tv.addTextListener(new ITextListener() { @Override @@ -279,7 +296,7 @@ private void updateTableLayout(Table table) { glActionArea.marginLeft = 0; glActionArea.marginTop = 5; glActionArea.marginBottom = -5; - this.cmpActionArea = new Composite(actionBar, SWT.NONE); + this.cmpActionArea = new Composite(borderedActionBar, SWT.NONE); this.cmpActionArea.setLayout(glActionArea); this.cmpActionArea.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); @@ -947,11 +964,33 @@ private List selectFile() { } /** - * Show the static banner above the bordered action bar area. + * Show the rate-limit static banner above the input area, with a single "Get more info" action link. * * @param message the message to display + * @param warning {@code true} for the warning icon; {@code false} for the info icon */ - public void createStaticBanner(String message) { + public void createRateLimitBanner(String message, boolean warning) { + List actions = List.of( + new BannerAction(Messages.chat_rateLimitBanner_getMoreInfo, UiConstants.COPILOT_RATE_LIMIT_INFO_URL)); + showStaticBanner(message, actions, warning); + } + + /** + * Show the quota-warning static banner above the input area. Action links are sourced from + * {@link QuotaActions#forPlan(CopilotPlan)} so they stay in sync with the inline {@link WarnWidget}. + * + * @param message the message to display + * @param plan the user's Copilot plan, or {@code null} for no action links + * @param warning {@code true} for the warning icon; {@code false} for the info icon + */ + public void createQuotaWarningBanner(String message, CopilotPlan plan, boolean warning) { + List bannerActions = QuotaActions.forPlan(plan).stream() + .map(action -> new BannerAction(action.label(), action.url())) + .toList(); + showStaticBanner(message, bannerActions, warning); + } + + private void showStaticBanner(String message, List actions, boolean warning) { if (isDisposed()) { return; } @@ -959,16 +998,24 @@ public void createStaticBanner(String message) { this.staticBanner.dispose(); } - this.staticBanner = new StaticBanner(this, SWT.NONE, message, Messages.chat_rateLimitBanner_getMoreInfo, - UiConstants.COPILOT_RATE_LIMIT_INFO_URL, Messages.chat_rateLimitBanner_closeTooltip); - // Position the banner above the first child (the bordered action bar composite) - if (getChildren().length > 0) { - this.staticBanner.moveAbove(getChildren()[0]); + this.staticBanner = new StaticBanner(this, SWT.NONE, message, actions, + Messages.chat_rateLimitBanner_closeTooltip, warning); + // Keep the banner above the inputArea sibling, the only other child of this composite. + if (this.inputArea != null && !this.inputArea.isDisposed()) { + this.staticBanner.moveAbove(this.inputArea); } this.staticBanner.show(); requestLayout(); } + /** + * Returns the input-area wrapper that owns {@code TodoListBar}, {@code WorkingSetBar}, and the bordered chat input. + * Services creating those top bars should parent them here so the sibling {@code StaticBanner} stays above. + */ + public Composite getInputArea() { + return this.inputArea; + } + /** * Dispose the current static banner, if present. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BannerAction.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BannerAction.java new file mode 100644 index 00000000..06edf184 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BannerAction.java @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +/** + * A clickable action rendered below the message of a {@link StaticBanner}. Each action is a localized label paired + * with the URL that should be opened when the user activates it. + * + * @param text the visible link label + * @param url the target URL opened on activation + */ +public record BannerAction(String text, String url) { +} 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 e1de0abc..58a72ea4 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 @@ -28,6 +28,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; import com.microsoft.copilot.eclipse.core.persistence.ConversationDataFactory; import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData; import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData; @@ -414,7 +415,8 @@ public void restoreSubagentContent(String toolCallId, CopilotTurnData copilotTur CopilotTurnData.ErrorData errorData = errorMessageData.getError(); String errorMessage = errorData != null ? errorData.getMessage() : ""; int errorCode = errorData != null ? errorData.getCode() : 0; - subagentWidget.createWarnDialog(errorMessage, errorCode); + String modelProviderName = errorData != null ? errorData.getModelProviderName() : null; + subagentWidget.createWarnDialog(errorMessage, errorCode, modelProviderName); } } } @@ -557,10 +559,21 @@ protected void createFooter() { } /** - * Create a warning dialog to the turn widget. + * Render a warning dialog under the turn. BYOK 402 (402 + non-blank provider name) shows the BYOK message with no + * actions; plain 402 shows the server message plus plan-driven actions; other codes show the server message only. + * + * @param message the server error message + * @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) { - new WarnWidget(this, SWT.BOTTOM, message, code); + protected void createWarnDialog(String message, int code, String modelProviderName) { + boolean byokQuotaExceeded = QuotaActions.isByokQuotaExceeded(code, modelProviderName); + String displayMessage = byokQuotaExceeded ? Messages.chat_warnWidget_byokQuotaUsageMessage : message; + CopilotPlan planForActions = null; + if (code == 402 && !byokQuotaExceeded) { + planForActions = this.serviceManager.getAuthStatusManager().getQuotaStatus().copilotPlan(); + } + new WarnWidget(this, SWT.BOTTOM, displayMessage, planForActions); requestLayout(); } 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 06c3e4c0..c0f98987 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 @@ -8,9 +8,9 @@ import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Pattern; import org.apache.commons.lang3.StringUtils; -import org.eclipse.e4.core.services.events.IEventBroker; import org.eclipse.lsp4j.WorkDoneProgressKind; import org.eclipse.swt.SWT; import org.eclipse.swt.custom.ScrolledComposite; @@ -22,10 +22,8 @@ import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.ScrollBar; -import org.eclipse.ui.PlatformUI; import com.microsoft.copilot.eclipse.core.CopilotCore; -import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentRound; import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; @@ -48,6 +46,13 @@ public class ChatContentViewer extends ScrolledComposite { private static final int SCROLL_THRESHOLD = 100; + /** + * Matches the trailing "| Request ID: ..." and "GitHub Request ID: ..." segments that the + * language server appends to user-facing error messages. + */ + private static final Pattern REQUEST_ID_SUFFIX = + Pattern.compile("\\s*\\|?\\s*(?:GitHub\\s+)?Request\\s+ID:\\s*\\S+\\.?", Pattern.CASE_INSENSITIVE); + private ChatServiceManager serviceManager; private Composite cmpContent; @@ -211,42 +216,16 @@ public void processTurnEvent(ChatProgressValue value) { } 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 error message replacement if statement when the CLS side warn message is aligned. - if (value.getCode() == 402) { - CopilotPlan userPlan = this.serviceManager.getAuthStatusManager().getQuotaStatus().copilotPlan(); - CopilotModel 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()); - - if (value.getCode() == 402 - && this.serviceManager.getAuthStatusManager().getQuotaStatus().copilotPlan() != CopilotPlan.free) { - 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()); } }, this); } @@ -297,8 +276,8 @@ public BaseTurnWidget getTurnWidget(String turnId) { return turns.get(turnId); } - private void renderWarnMessageWithUpgradePlanButton(String errorMessage, int code) { - latestTurnWidget.createWarnDialog(errorMessage, code); + private void renderWarnMessageWithUpgradePlanButton(String errorMessage, int code, String modelProviderName) { + latestTurnWidget.createWarnDialog(errorMessage, code, modelProviderName); refreshScrollerLayout(); scrollToLatestUserTurn(); } 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 4fec954b..7ac067a4 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 @@ -62,6 +62,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.TodoItem; import com.microsoft.copilot.eclipse.core.lsp.protocol.Turn; import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.QuotaWarningNotification; import com.microsoft.copilot.eclipse.core.persistence.AbstractTurnData; import com.microsoft.copilot.eclipse.core.persistence.ConversationPersistenceManager; import com.microsoft.copilot.eclipse.core.persistence.ConversationXmlData; @@ -137,9 +138,17 @@ public class ChatView extends ViewPart implements ChatProgressListener, MessageL private EventHandler codingAgentMessageHandler; private EventHandler autoBreakpointToggleHandler; private EventHandler rateLimitWarningHandler; + private EventHandler quotaWarningHandler; // Context activation for chat view keyboard shortcuts private static final String CHAT_VIEW_CONTEXT = "com.microsoft.copilot.eclipse.chatViewContext"; + + /** + * Percentage-remaining threshold below which the rate-limit banner switches from the informational + * icon to the warning icon. + */ + private static final double RATE_LIMIT_WARNING_THRESHOLD_PERCENT_REMAINING = 25.0; + private IContextActivation chatViewContextActivation; private IPartListener2 partListener; @@ -354,13 +363,29 @@ public void done(IJobChangeEvent event) { if (data instanceof RateLimitWarningParams params) { SwtUtils.invokeOnDisplayThreadAsync(() -> { if (actionBar != null && !actionBar.isDisposed()) { - actionBar.createStaticBanner(params.message()); + RateLimitWarningParams.RateLimit rateLimit = params.rateLimit(); + boolean warning = rateLimit != null + && rateLimit.percentRemaining() <= RATE_LIMIT_WARNING_THRESHOLD_PERCENT_REMAINING; + actionBar.createRateLimitBanner(params.message(), warning); } }, parent); } }; this.eventBroker.subscribe(CopilotEventConstants.TOPIC_RATE_LIMIT_WARNING, this.rateLimitWarningHandler); + this.quotaWarningHandler = event -> { + Object data = event.getProperty(IEventBroker.DATA); + if (data instanceof QuotaWarningNotification notification) { + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (actionBar != null && !actionBar.isDisposed()) { + boolean warning = "warning".equalsIgnoreCase(notification.severity()); + actionBar.createQuotaWarningBanner(notification.message(), notification.copilotPlan(), warning); + } + }, parent); + } + }; + this.eventBroker.subscribe(CopilotEventConstants.TOPIC_QUOTA_WARNING, this.quotaWarningHandler); + // Register part listener to activate/deactivate chat view context for keyboard shortcuts registerPartListener(); } @@ -1395,6 +1420,10 @@ public void dispose() { this.eventBroker.unsubscribe(this.rateLimitWarningHandler); rateLimitWarningHandler = null; } + if (quotaWarningHandler != null) { + this.eventBroker.unsubscribe(this.quotaWarningHandler); + quotaWarningHandler = null; + } } if (this.chatServiceManager != null) { @@ -1701,7 +1730,8 @@ private void restoreCopilotTurnContent(CopilotTurnData copilotTurn, BaseTurnWidg SwtUtils.invokeOnDisplayThread(() -> { String errorMessage = errorData != null ? errorData.getMessage() : Messages.chat_warnWidget_defaultErrorMsg; int errorCode = errorData != null ? errorData.getCode() : 0; - turnWidget.createWarnDialog(errorMessage, errorCode); + String modelProviderName = errorData != null ? errorData.getModelProviderName() : null; + turnWidget.createWarnDialog(errorMessage, errorCode, modelProviderName); }, parent); } } 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 20117a07..f54fa9af 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 @@ -19,6 +19,7 @@ public final class Messages extends NLS { public static String confirmDialog_keepChangesButton; public static String confirmDialog_undoChangesButton; public static String chat_warnWidget_defaultErrorMsg; + public static String chat_warnWidget_byokQuotaUsageMessage; public static String configureModes; public static String agentMessageWidget_openInBrowserButton; public static String agentMessageWidget_openInBrowserTooltip; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java new file mode 100644 index 00000000..82cfdd91 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.util.List; + +import org.apache.commons.lang3.StringUtils; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; +import com.microsoft.copilot.eclipse.ui.UiConstants; +import com.microsoft.copilot.eclipse.ui.i18n.Messages; + +/** + * Plan-driven call-to-action list shown when the user's quota is approaching or exceeded. Shared by the + * {@link StaticBanner}-based quota notification (rendered via {@code ActionBar#createQuotaWarningBanner}) + * and the inline {@link WarnWidget} rendered under a chat turn on a 402 error response, so both surfaces + * stay in sync when a new plan or call-to-action is added. + */ +public final class QuotaActions { + + /** + * Single quota call-to-action. + * + * @param label visible button or link label + * @param tooltip tooltip text used by the inline {@link WarnWidget}; ignored by {@link StaticBanner} + * @param url target URL opened on activation + * @param primary {@code true} when this action should be visually emphasised (e.g. {@code btn-primary} + * styling on a push button); ignored by {@link StaticBanner}, which renders all actions as links + */ + public record QuotaAction(String label, String tooltip, String url, boolean primary) { + } + + /** + * Returns the ordered list of {@link QuotaAction}s appropriate for the supplied plan. + * + *

Mapping: + *

+ * + * @param plan the user's Copilot plan, or {@code null} when unknown + * @return an immutable, possibly empty list; never {@code null} + */ + public static List forPlan(CopilotPlan plan) { + if (plan == null) { + return List.of(); + } + QuotaAction upgradePrimary = new QuotaAction(Messages.menu_quota_upgradePlan, + Messages.chat_noQuotaView_updatePlanButton_Tooltip, + UiConstants.COPILOT_UPGRADE_PLAN_URL, true); + QuotaAction upgradeSecondary = new QuotaAction(Messages.menu_quota_upgradePlan, + Messages.chat_noQuotaView_updatePlanButton_Tooltip, + UiConstants.COPILOT_UPGRADE_PLAN_URL, false); + QuotaAction enableOverage = new QuotaAction(Messages.menu_quota_enableAdditionalUsage, + Messages.chat_noQuotaView_enableAdditionalUsageButton_tooltip, + UiConstants.MANAGE_COPILOT_OVERAGE_URL, true); + QuotaAction viewYourPlan = new QuotaAction(Messages.chat_quotaBanner_viewYourPlan, + Messages.chat_noQuotaView_viewYourPlanButton_Tooltip, + UiConstants.MANAGE_COPILOT_URL, true); + return switch (plan) { + case free -> List.of(upgradePrimary); + case individual, individual_pro -> List.of(enableOverage, upgradeSecondary); + case individual_max -> List.of(enableOverage); + case business, enterprise -> List.of(viewYourPlan); + }; + } + + /** + * Returns {@code true} when an error response represents a Bring-Your-Own-Key (BYOK) quota-exceeded + * condition, i.e. a {@code 402} from the language server that also carries a non-blank model + * provider name. BYOK usage is governed by the customer's provider account rather than the user's + * Copilot plan, so callers should suppress the plan-driven {@link #forPlan(CopilotPlan)} actions + * and substitute the BYOK-specific message in this case. + * + * @param code the error code from the language server + * @param modelProviderName the BYOK model-provider name from the server payload, or {@code null} + * @return {@code true} when the error should be rendered as a BYOK quota notice + */ + public static boolean isByokQuotaExceeded(int code, String modelProviderName) { + return code == 402 && StringUtils.isNotBlank(modelProviderName); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StaticBanner.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StaticBanner.java index e5203cea..ec340b7e 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StaticBanner.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StaticBanner.java @@ -3,8 +3,9 @@ package com.microsoft.copilot.eclipse.ui.chat; +import java.util.List; + import org.apache.commons.lang3.StringUtils; -import org.eclipse.osgi.util.NLS; import org.eclipse.swt.SWT; import org.eclipse.swt.events.MouseAdapter; import org.eclipse.swt.events.MouseEvent; @@ -22,31 +23,35 @@ import com.microsoft.copilot.eclipse.ui.utils.UiUtils; /** - * A reusable banner widget that displays an informational message with an inline link and a close button. Shows an info - * icon, the provided message with an appended link, and a dismiss (×) button. + * Reusable, dismissible banner shown above the chat input. Layout is a 3-column grid: severity icon, wrapping message, + * close button; an optional second row of action links is added when {@code actions} is non-empty. + * + *

The banner is constructed hidden and layout-excluded; call {@link #show()} to reveal it. Callers must pass + * already-localized strings. * *

Usage example: * - *

var banner = new StaticBanner(parent, SWT.NONE, "You've used 90% of your rate limit.", "Get more info",
- *     "https://example.com", "Dismiss");
+ * 
+ * var banner = new StaticBanner(parent, SWT.NONE, "You've used 75% of your monthly quota.",
+ *     List.of(new BannerAction("Upgrade Plan", "https://example.com/upgrade")), "Dismiss", true);
  * banner.show();
  * 
*/ public class StaticBanner extends Composite { - private Link messageLink; + private Label messageLabel; /** - * Create a static informational banner. + * Create a hidden static banner; call {@link #show()} to reveal. {@link SWT#BORDER} is always applied. * * @param parent the parent composite - * @param style the SWT style - * @param message the informational message to display - * @param linkText the text for the inline link (e.g. "Get more info") - * @param linkUrl the URL to open when the link is clicked - * @param closeTooltip the tooltip for the close button + * @param style additional SWT style bits + * @param message the message to display ({@code null} treated as empty) + * @param actions optional action links below the message; entries with blank label or URL are skipped + * @param closeTooltip tooltip for the close (×) button + * @param warning {@code true} for the warning icon; {@code false} for the info icon */ - public StaticBanner(Composite parent, int style, String message, String linkText, String linkUrl, - String closeTooltip) { + public StaticBanner(Composite parent, int style, String message, List actions, String closeTooltip, + boolean warning) { super(parent, style | SWT.BORDER); GridLayout layout = new GridLayout(3, false); @@ -56,26 +61,19 @@ public StaticBanner(Composite parent, int style, String message, String linkText setLayout(layout); setLayoutData(new GridData(SWT.FILL, SWT.NONE, true, false)); - // Info icon + // Severity icon Label iconLabel = new Label(this, SWT.NONE); - Image infoImage = PlatformUI.getWorkbench().getSharedImages().getImage(ISharedImages.IMG_OBJS_INFO_TSK); - iconLabel.setImage(infoImage); + String iconKey = warning ? ISharedImages.IMG_OBJS_WARN_TSK : ISharedImages.IMG_OBJS_INFO_TSK; + Image iconImage = PlatformUI.getWorkbench().getSharedImages().getImage(iconKey); + iconLabel.setImage(iconImage); iconLabel.setLayoutData(new GridData(SWT.LEFT, SWT.TOP, false, false)); - // Message + inline link - this.messageLink = new Link(this, SWT.WRAP); - this.messageLink.setText(buildMessageText(message, linkText, linkUrl)); - this.messageLink.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); - this.messageLink.addSelectionListener(new SelectionAdapter() { - @Override - public void widgetSelected(SelectionEvent e) { - if (StringUtils.isNotBlank(linkUrl)) { - UiUtils.openLink(linkUrl); - } - } - }); + // Wrapping message text + this.messageLabel = new Label(this, SWT.WRAP); + this.messageLabel.setText(StringUtils.defaultString(message)); + this.messageLabel.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); - // Close button + // Close (×) button Label closeButton = new Label(this, SWT.NONE); Image closeImage = PlatformUI.getWorkbench().getSharedImages().getImage(ISharedImages.IMG_ELCL_REMOVE); closeButton.setImage(closeImage); @@ -89,13 +87,35 @@ public void mouseUp(MouseEvent e) { } }); + // Optional action-link row, aligned under the message column. + List safeActions = actions == null ? List.of() : actions; + if (!safeActions.isEmpty()) { + // Spacer to align with the icon column above. + new Label(this, SWT.NONE).setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false)); + + GridLayout actionLayout = new GridLayout(safeActions.size(), false); + actionLayout.marginWidth = 0; + actionLayout.marginHeight = 0; + actionLayout.horizontalSpacing = 12; + actionLayout.verticalSpacing = 0; + GridData actionRowData = new GridData(SWT.FILL, SWT.CENTER, true, false); + actionRowData.horizontalSpan = 2; + Composite actionRow = new Composite(this, SWT.NONE); + actionRow.setLayout(actionLayout); + actionRow.setLayoutData(actionRowData); + + for (BannerAction action : safeActions) { + addActionLink(actionRow, action); + } + } + setVisible(false); GridData gd = (GridData) getLayoutData(); gd.exclude = true; } /** - * Show the banner. + * Reveal the banner and re-layout the parent. No-op if disposed. */ public void show() { if (isDisposed()) { @@ -118,13 +138,19 @@ private void disposeBanner() { } } - private static String buildMessageText(String message, String linkText, String linkUrl) { - String safeMessage = escapeForLink(message); - if (StringUtils.isBlank(linkText) || StringUtils.isBlank(linkUrl)) { - return safeMessage; + private static void addActionLink(Composite parent, BannerAction action) { + if (action == null || StringUtils.isBlank(action.text()) || StringUtils.isBlank(action.url())) { + return; } - return NLS.bind(com.microsoft.copilot.eclipse.ui.i18n.Messages.chat_staticBanner_messageWithLink, safeMessage, - escapeForLink(linkText)); + Link link = new Link(parent, SWT.NONE); + link.setText("" + escapeForLink(action.text()) + ""); + link.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false)); + link.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + UiUtils.openLink(action.url()); + } + }); } private static String escapeForLink(String text) { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WarnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WarnWidget.java index 72955327..d68500f5 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WarnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WarnWidget.java @@ -3,6 +3,8 @@ package com.microsoft.copilot.eclipse.ui.chat; +import java.util.List; + import org.eclipse.swt.SWT; import org.eclipse.swt.custom.StyledText; import org.eclipse.swt.events.SelectionAdapter; @@ -16,12 +18,15 @@ import org.eclipse.ui.ISharedImages; import org.eclipse.ui.PlatformUI; -import com.microsoft.copilot.eclipse.ui.i18n.Messages; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; +import com.microsoft.copilot.eclipse.ui.chat.QuotaActions.QuotaAction; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; /** - * Widget to display a message when the user has no quota. + * Widget that displays a warning message under a chat turn, optionally followed by plan-driven action buttons sourced + * from {@link QuotaActions#forPlan(CopilotPlan)}. Presentation-only: the caller decides the message and whether to + * pass a plan. */ public class WarnWidget extends Composite { private int buttonLeftMargin; @@ -30,30 +35,29 @@ public class WarnWidget extends Composite { * Create the composite. * * @param parent the parent composite - * @param message the message to display + * @param style the SWT style bits + * @param message the message to display ({@code null} treated as empty) + * @param userPlan the user's Copilot plan to render plan-driven action buttons, or {@code null} for no buttons */ - public WarnWidget(Composite parent, int style, String message, int code) { + public WarnWidget(Composite parent, int style, String message, CopilotPlan userPlan) { super(parent, style | SWT.BORDER); - setLayout(new GridLayout(1, true)); + GridLayout outerLayout = new GridLayout(1, true); + outerLayout.verticalSpacing = 0; + setLayout(outerLayout); setLayoutData(new GridData(SWT.FILL, SWT.NONE, true, false)); buildWarnLabelWithIcon(message); - // Render the button based on the error code. See: - // https://github.com/microsoft/copilot-client/blob/77f8f28e1a1e2efb51b6f92649bd9d085b8b64f5/lib/src/conversation/fetchPostProcessor.ts#L232-L248 - if (code == 402) { - // TODO: This is just a temporary solution. We need compose a dialog to support any warn message once issue - // https://github.com/microsoft/copilot-client/issues/405 is resolved. - if (message.toLowerCase().contains("upgrade to copilot pro (30-day free trial)")) { - buildUpdatePlanButton(); - } + if (userPlan != null) { + buildActionButtons(userPlan); } parent.layout(); } private void buildWarnLabelWithIcon(String message) { Composite composite = new Composite(this, SWT.NONE); - composite.setLayout(new GridLayout(2, false)); + GridLayout warnLayout = new GridLayout(2, false); + composite.setLayout(warnLayout); composite.setLayoutData(new GridData(SWT.LEFT, SWT.NONE, true, false)); Label iconLabel = new Label(composite, SWT.TOP); @@ -62,7 +66,8 @@ private void buildWarnLabelWithIcon(String message) { GridData iconGd = new GridData(SWT.LEFT, SWT.TOP, false, false); iconGd.verticalIndent = 4; iconLabel.setLayoutData(iconGd); - buttonLeftMargin = warnImage.getBounds().width + iconGd.verticalIndent; + buttonLeftMargin = warnLayout.marginWidth + warnLayout.marginLeft + warnImage.getBounds().width + + warnLayout.horizontalSpacing; ChatMarkupViewer textLabel = new ChatMarkupViewer(composite, SWT.LEFT | SWT.WRAP); StyledText styledText = textLabel.getTextWidget(); @@ -73,22 +78,40 @@ private void buildWarnLabelWithIcon(String message) { requestLayout(); } - private void buildUpdatePlanButton() { - Composite composite = new Composite(this, SWT.NONE); + /** + * Render plan-driven action buttons for a quota-exceeded warning, kept in sync with the quota {@link StaticBanner}. + */ + private void buildActionButtons(CopilotPlan userPlan) { + List actions = QuotaActions.forPlan(userPlan); + if (actions.isEmpty()) { + return; + } + RowLayout layout = new RowLayout(SWT.HORIZONTAL); - layout.marginLeft = this.buttonLeftMargin; // Add margin to the left of the buttons to align with the message + layout.marginLeft = this.buttonLeftMargin; // Align with the message text + layout.marginTop = 0; layout.spacing = 10; + + Composite composite = new Composite(this, SWT.NONE); composite.setLayout(layout); - Button updatePlanButton = new Button(composite, SWT.PUSH); - updatePlanButton.setText(Messages.chat_noQuotaView_updatePlanButton); - updatePlanButton.setToolTipText(Messages.chat_noQuotaView_updatePlanButton_Tooltip); - updatePlanButton.addSelectionListener(new SelectionAdapter() { + for (QuotaAction action : actions) { + addButton(composite, action.label(), action.tooltip(), action.url(), action.primary()); + } + } + + private static void addButton(Composite parent, String label, String tooltip, String link, boolean primary) { + Button button = new Button(parent, SWT.PUSH); + button.setText(label); + button.setToolTipText(tooltip); + button.addSelectionListener(new SelectionAdapter() { @Override public void widgetSelected(org.eclipse.swt.events.SelectionEvent event) { - UiUtils.openLink(Messages.chat_noQuotaView_updatePlanLink); + UiUtils.openLink(link); } }); - updatePlanButton.setData(CssConstants.CSS_CLASS_NAME_KEY, "btn-primary"); + if (primary) { + button.setData(CssConstants.CSS_CLASS_NAME_KEY, "btn-primary"); + } } } 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 acfc43c0..960ecc03 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 @@ -1,5 +1,6 @@ chat_chatContentView_errorTemplate=Sorry, your request failed: %s. Request id: %s. chat_warnWidget_defaultErrorMsg=An error occurred. Please try again. +chat_warnWidget_byokQuotaUsageMessage=You have exceeded your quota for BYOK (Bring Your Own Key) models. chat_toolCall_genericError=Tool call failed. chat_toolCall_errorTemplate={0}: {1} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TodoListService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TodoListService.java index adddc2cd..d8856b2e 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TodoListService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TodoListService.java @@ -69,9 +69,10 @@ public void bindTodoListBar(ChatView chatView) { disposeTodoListBar(); } else { if (this.todoListBar == null || this.todoListBar.isDisposed()) { - this.todoListBar = new TodoListBar(chatView.getActionBar(), SWT.NONE); + this.todoListBar = new TodoListBar(chatView.getActionBar().getInputArea(), SWT.NONE); } - // Always position TodoListBar at the very top + // Always position TodoListBar at the very top of the inputArea (still below the + // StaticBanner, which lives on the outer ActionBar as a sibling of inputArea). this.todoListBar.moveAbove(null); this.todoListBar.buildTodoListBar(todoList); } 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 a5571718..1e57daa9 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 @@ -83,9 +83,11 @@ public void bindWorkingSetBar(ChatView chatView) { disposeWorkingSetBar(); } else { if (this.workingSetBar == null || this.workingSetBar.isDisposed()) { - this.workingSetBar = new WorkingSetBar(chatView.getActionBar(), SWT.NONE); + this.workingSetBar = new WorkingSetBar(chatView.getActionBar().getInputArea(), SWT.NONE); } - // Position WorkingSetBar below TodoListBar (if present), otherwise at top + // Position WorkingSetBar below TodoListBar (if present), otherwise at the top of + // inputArea. The StaticBanner sits on the outer ActionBar as a sibling of inputArea, + // so it remains above this bar regardless of this call. positionWorkingSetBar(chatView); this.workingSetBar.buildSummaryBarFor(filesMap); } 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 84557985..b65ece46 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 @@ -155,10 +155,9 @@ public final class Messages extends NLS { public static String chat_noQuotaView_fallbackModel; public static String chat_noQuotaView_updatePlanButton; public static String chat_noQuotaView_updatePlanButton_Tooltip; - public static String chat_noQuotaView_updatePlanLink; - public static String chat_noQuotaView_enablePremiumRequestsButton; - public static String chat_noQuotaView_enablePremiumRequestsButton_tooltip; - public static String chat_noQuotaView_enablePremiumRequestsLink; + public static String chat_noQuotaView_enableAdditionalUsageButton; + public static String chat_noQuotaView_enableAdditionalUsageButton_tooltip; + public static String chat_noQuotaView_viewYourPlanButton_Tooltip; public static String chat_noQuotaView_proProplusWarnMsg; public static String chat_noQuotaView_cbCeWarnMsg; public static String chat_currentReferencedFile_description; @@ -214,9 +213,6 @@ public final class Messages extends NLS { public static String model_hover_cost; public static String chat_actionBar_modePicker_Tooltip; public static String chat_actionBar_modelPicker_Tooltip; - public static String quotaWarning_title; - public static String quotaWarning_closeButton; - public static String quotaWarning_increaseBudgetButton; public static String context_window_title; public static String context_window_tokens; public static String context_window_system; @@ -226,9 +222,9 @@ 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_staticBanner_messageWithLink; public static String chat_rateLimitBanner_getMoreInfo; public static String chat_rateLimitBanner_closeTooltip; + public static String chat_quotaBanner_viewYourPlan; static { // initialize resource bundle 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 34eec396..7edc6c43 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 @@ -134,12 +134,11 @@ chat_agentModeView_attachContextSuffix=to attach context chat_loadingView_title=Loading chat_loadingView_description=Initializing Copilot... chat_noQuotaView_fallbackModel=fallback model -chat_noQuotaView_updatePlanButton=Upgrade to Copilot Pro -chat_noQuotaView_updatePlanButton_Tooltip=Update your plan to Copilot Pro -chat_noQuotaView_updatePlanLink=https://aka.ms/github-copilot-upgrade-plan -chat_noQuotaView_enablePremiumRequestsButton=Enable additional paid premium requests -chat_noQuotaView_enablePremiumRequestsButton_tooltip=Enable additional paid premium requests -chat_noQuotaView_enablePremiumRequestsLink=https://aka.ms/github-copilot-manage-overage +chat_noQuotaView_updatePlanButton=Upgrade Plan +chat_noQuotaView_updatePlanButton_Tooltip=Upgrade your Copilot plan +chat_noQuotaView_enableAdditionalUsageButton=Enable Additional Usage +chat_noQuotaView_enableAdditionalUsageButton_tooltip=Pay-as-you-go usage of additional AI credits once your run out of your included usage. Set a budget to cap your maximum monthly spend. +chat_noQuotaView_viewYourPlanButton_Tooltip=View your Copilot plan chat_noQuotaView_proProplusWarnMsg=You have exceeded your premium request allowance. We have automatically switched you to **%s** which is included with your plan. [Enable additional paid premium requests](https://aka.ms/github-copilot-manage-overage) to continue using premium models. chat_noQuotaView_cbCeWarnMsg=You have exceeded your free request allowance. We have automatically switched you to **%s** which is included with your plan. To enable additional paid premium requests, contact your organization admin. chat_noAuthView_title=No Copilot subscription found @@ -210,10 +209,6 @@ model_hover_cost=Cost: chat_actionBar_modePicker_Tooltip=Set Agents chat_actionBar_modelPicker_Tooltip=Pick Model{0} -quotaWarning_title=GitHub Copilot -quotaWarning_closeButton=Close -quotaWarning_increaseBudgetButton=Increase Budget - context_window_title=Context Window context_window_tokens={0} / {1} tokens context_window_system=System @@ -224,6 +219,6 @@ context_window_messages=Messages context_window_files=Attached Files context_window_tool_results=Tool Results -chat_staticBanner_messageWithLink={0} {1} chat_rateLimitBanner_getMoreInfo=Get more info chat_rateLimitBanner_closeTooltip=Dismiss +chat_quotaBanner_viewYourPlan=View your plan diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaNotificationManager.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaNotificationManager.java deleted file mode 100644 index c9dd4830..00000000 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaNotificationManager.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.microsoft.copilot.eclipse.ui.notifications; - -import org.eclipse.e4.core.services.events.IEventBroker; -import org.eclipse.swt.widgets.Display; -import org.eclipse.ui.PlatformUI; -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.protocol.quota.QuotaWarningNotification; - -/** - * Handles quota notifications from the language server and displays - * them as JFace notification popups. - */ -public class QuotaNotificationManager { - - private static final int NOTIFICATION_DELAY_MS = 10000; - - private final IEventBroker eventBroker; - private EventHandler quotaWarningEventHandler; - - /** - * Creates a new QuotaNotificationManager and subscribes to quota events. - */ - public QuotaNotificationManager() { - this.eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); - if (eventBroker != null) { - this.quotaWarningEventHandler = event -> { - Object data = event.getProperty(IEventBroker.DATA); - if (data instanceof QuotaWarningNotification notification) { - showQuotaWarningNotification(notification); - } - }; - eventBroker.subscribe(CopilotEventConstants.TOPIC_QUOTA_WARNING, quotaWarningEventHandler); - } - } - - /** - * Unsubscribes the quota event handler. - */ - public void dispose() { - if (eventBroker != null && quotaWarningEventHandler != null) { - eventBroker.unsubscribe(quotaWarningEventHandler); - quotaWarningEventHandler = null; - } - } - - private void showQuotaWarningNotification(QuotaWarningNotification notification) { - Display display = PlatformUI.getWorkbench().getDisplay(); - if (display == null || display.isDisposed()) { - return; - } - display.asyncExec(() -> { - try { - new QuotaWarningNotificationPopup(display, notification.message(), NOTIFICATION_DELAY_MS).open(); - } catch (Exception e) { - CopilotCore.LOGGER.error("Failed to show quota warning notification", e); - } - }); - } -} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaWarningNotificationPopup.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaWarningNotificationPopup.java deleted file mode 100644 index c23d83fd..00000000 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaWarningNotificationPopup.java +++ /dev/null @@ -1,140 +0,0 @@ -package com.microsoft.copilot.eclipse.ui.notifications; - -import org.eclipse.jface.notifications.NotificationPopup; -import org.eclipse.jface.resource.JFaceResources; -import org.eclipse.swt.SWT; -import org.eclipse.swt.graphics.Color; -import org.eclipse.swt.graphics.Image; -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.Control; -import org.eclipse.swt.widgets.Display; -import org.eclipse.swt.widgets.Label; -import org.eclipse.swt.widgets.Listener; -import org.eclipse.swt.widgets.Shell; - -import com.microsoft.copilot.eclipse.ui.UiConstants; -import com.microsoft.copilot.eclipse.ui.i18n.Messages; -import com.microsoft.copilot.eclipse.ui.utils.UiUtils; - -/** - * Popup for quota warning notifications. - */ -public class QuotaWarningNotificationPopup { - - private static final int ALIGN_SPACING = 8; - private static final String BORDER_INSTALLED_KEY = "quotaWarningNotificationPopup.borderInstalled"; - - private final Display display; - private final String message; - private final int delayMs; - - /** - * Creates a popup for a quota warning message. - * - * @param display the target display - * @param message the quota warning message - * @param delayMs the popup auto-close delay in milliseconds - */ - public QuotaWarningNotificationPopup(Display display, String message, int delayMs) { - this.display = display; - this.message = message; - this.delayMs = delayMs; - } - - /** - * Opens the popup. - */ - public void open() { - NotificationPopup.forDisplay(display).title(parent -> createNotificationTitle(parent), false).fadeIn(true) - .delay(delayMs).content(parent -> createNotificationContent(parent, message)).open(); - } - - private Control createNotificationTitle(Composite parent) { - addBorder(parent.getShell()); - - Composite title = new Composite(parent, SWT.NONE); - GridLayout layout = new GridLayout(2, false); - layout.marginWidth = 4; - layout.marginHeight = 4; - title.setLayout(layout); - title.setCursor(parent.getDisplay().getSystemCursor(SWT.CURSOR_ARROW)); - - Label iconLabel = new Label(title, SWT.NONE); - Image icon = UiUtils.buildImageFromPngPath("/icons/github_copilot.png"); - iconLabel.setImage(icon); - iconLabel.setCursor(parent.getDisplay().getSystemCursor(SWT.CURSOR_ARROW)); - parent.addDisposeListener(e -> { - if (icon != null && !icon.isDisposed()) { - icon.dispose(); - } - }); - - Label titleLabel = new Label(title, SWT.NONE); - titleLabel.setText(Messages.quotaWarning_title); - titleLabel.setFont(JFaceResources.getFontRegistry().getBold(JFaceResources.DEFAULT_FONT)); - - return title; - } - - /** - * Needs to add a border to this notification popup since the chat view has the same color with the notification - * background in dark mode. The boundary of the notification will become unclear without the border. - */ - private void addBorder(Shell shell) { - if (Boolean.TRUE.equals(shell.getData(BORDER_INSTALLED_KEY))) { - return; - } - - Color borderColor = shell.getDisplay().getSystemColor(SWT.COLOR_WIDGET_NORMAL_SHADOW); - shell.addPaintListener(e -> { - Rectangle bounds = shell.getClientArea(); - e.gc.setForeground(borderColor); - e.gc.setLineWidth(1); - e.gc.drawRectangle(0, 0, bounds.width - 1, bounds.height - 1); - }); - shell.setData(BORDER_INSTALLED_KEY, Boolean.TRUE); - } - - private Composite createNotificationContent(Composite parent, String notificationMessage) { - GridLayout layout = new GridLayout(2, false); - layout.marginWidth = ALIGN_SPACING; - layout.marginHeight = ALIGN_SPACING; - layout.marginBottom = ALIGN_SPACING; - layout.horizontalSpacing = ALIGN_SPACING; - Composite content = new Composite(parent, SWT.NONE); - content.setLayout(layout); - - new Label(content, SWT.NONE).setImage(parent.getDisplay().getSystemImage(SWT.ICON_WARNING)); - - Label messageLabel = new Label(content, SWT.WRAP); - messageLabel.setText(notificationMessage); - GridData messageLayoutData = new GridData(SWT.FILL, SWT.NONE, true, true); - messageLabel.setLayoutData(messageLayoutData); - - Composite buttons = new Composite(content, SWT.NONE); - GridLayout buttonLayout = new GridLayout(2, true); - buttonLayout.horizontalSpacing = ALIGN_SPACING; - buttonLayout.marginWidth = 0; - buttons.setLayout(buttonLayout); - buttons.setLayoutData(new GridData(SWT.RIGHT, SWT.NONE, true, false, 2, 1)); - - createButton(buttons, Messages.quotaWarning_closeButton, e -> parent.getShell().close()); - createButton(buttons, Messages.quotaWarning_increaseBudgetButton, e -> { - UiUtils.openLink(UiConstants.MANAGE_COPILOT_OVERAGE_URL); - parent.getShell().close(); - }); - - return content; - } - - private void createButton(Composite parent, String label, Listener listener) { - Button button = new Button(parent, SWT.PUSH); - button.setText(label); - button.setLayoutData(new GridData(SWT.FILL, SWT.NONE, true, false)); - button.addListener(SWT.Selection, listener); - } -} \ No newline at end of file From 89a9b431e4d8fae3bf1db0ca75e51d9102cc177f Mon Sep 17 00:00:00 2001 From: Ethan Hou Date: Wed, 13 May 2026 16:22:12 +0800 Subject: [PATCH 5/9] Removed redundant code. --- .../core/lsp/CopilotLanguageClientTests.java | 30 +-------- .../core/lsp/CopilotLanguageClient.java | 8 +-- .../lsp/protocol/quota/IntervalQuota.java | 17 ----- .../lsp/protocol/quota/QuotaChangeParams.java | 20 ------ .../quota/QuotaWarningNotification.java | 48 -------------- .../copilot/eclipse/ui/chat/ChatView.java | 8 +-- .../QuotaNotificationManager.java | 65 +++++++++++++++++++ 7 files changed, 74 insertions(+), 122 deletions(-) delete mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/IntervalQuota.java delete mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaChangeParams.java delete mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningNotification.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaNotificationManager.java diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java index 52edec74..52e9f3c7 100644 --- a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java @@ -10,14 +10,12 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import java.lang.reflect.Field; import java.util.HashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import org.eclipse.core.resources.IFile; -import org.eclipse.e4.core.services.events.IEventBroker; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -30,12 +28,10 @@ import com.microsoft.copilot.eclipse.core.FeatureFlags; import com.microsoft.copilot.eclipse.core.chat.service.IChatServiceManager; import com.microsoft.copilot.eclipse.core.chat.service.IReferencedFileService; -import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; 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; import com.microsoft.copilot.eclipse.core.lsp.protocol.DidChangeFeatureFlagsParams; -import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.QuotaWarningNotification; import com.microsoft.copilot.eclipse.core.utils.FileUtils; @ExtendWith(MockitoExtension.class) @@ -52,9 +48,6 @@ class CopilotLanguageClientTests { @Mock private IReferencedFileService fileService; - @Mock - private IEventBroker eventBroker; - @BeforeEach void setUp() { client = new CopilotLanguageClient(); @@ -138,25 +131,4 @@ void testOnDidChangeFeatureFlagsWithEmptyFeatureFlags() { verify(mockFeatureFlags).setByokEnabled(true); } } - - @Test - void testOnQuotaWarning_PostsNotificationToEventBroker() { - QuotaWarningNotification notification = new QuotaWarningNotification( - "Copilot Quota Usage Alert", "Approaching quota", "warning", null, null); - setEventBroker(eventBroker); - - client.onQuotaWarning(notification); - - verify(eventBroker).post(CopilotEventConstants.TOPIC_QUOTA_WARNING, notification); - } - - private void setEventBroker(IEventBroker broker) { - try { - Field field = CopilotLanguageClient.class.getDeclaredField("eventBroker"); - field.setAccessible(true); - field.set(client, broker); - } catch (ReflectiveOperationException e) { - throw new AssertionError("Failed to inject event broker", e); - } - } -} \ No newline at end of file +} 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 b0d18227..53580fd1 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 @@ -64,7 +64,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.policy.DidChangePolicyParams; -import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.QuotaWarningNotification; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.QuotaWarningParams; import com.microsoft.copilot.eclipse.core.utils.FileUtils; import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; @@ -358,10 +358,10 @@ public CompletableFuture onCodingAgentMessage(CodingAg * Notify when a quota warning is received from the language server. */ @JsonNotification("copilot/quotaWarning") - public void onQuotaWarning(QuotaWarningNotification notification) { - CopilotCore.LOGGER.info("Quota warning received: " + notification); + public void onQuotaWarning(QuotaWarningParams params) { + CopilotCore.LOGGER.info("Quota warning received: " + params); if (eventBroker != null) { - eventBroker.post(CopilotEventConstants.TOPIC_QUOTA_WARNING, notification); + eventBroker.post(CopilotEventConstants.TOPIC_QUOTA_WARNING, params); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/IntervalQuota.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/IntervalQuota.java deleted file mode 100644 index 96cd0d91..00000000 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/IntervalQuota.java +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -package com.microsoft.copilot.eclipse.core.lsp.protocol.quota; - -/** - * Interval-based quota information, used for immediateUsageInterval and extendedUsageInterval. - */ -public record IntervalQuota( - double percentRemaining, - boolean unlimited, - boolean overagePermitted, - Integer entitlement, - Integer quotaRemaining, - String timeStamp, - String resetAt) { -} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaChangeParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaChangeParams.java deleted file mode 100644 index 23e3197c..00000000 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaChangeParams.java +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -package com.microsoft.copilot.eclipse.core.lsp.protocol.quota; - -import com.google.gson.annotations.SerializedName; - -/** - * Parameters for the {@code copilot/quotaChange} notification, sent by the language server - * whenever the user's quota usage changes. - * - * @param chat current chat quota snapshot, when available - * @param completions current completions quota snapshot, when available - * @param premiumInteractions current premium interactions quota snapshot, when available - * @param copilotPlan the user's Copilot plan (e.g. free, individual, individual_pro, individual_max, - * business, enterprise) - */ -public record QuotaChangeParams(QuotaSnapshotParams chat, QuotaSnapshotParams completions, - @SerializedName("premium_interactions") QuotaSnapshotParams premiumInteractions, String copilotPlan) { -} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningNotification.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningNotification.java deleted file mode 100644 index 7ab33faa..00000000 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningNotification.java +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -package com.microsoft.copilot.eclipse.core.lsp.protocol.quota; - -import com.google.gson.annotations.SerializedName; - -/** - * Parameters for the {@code copilot/quotaWarning} notification. Sent by the language server when the user's AI quota - * exceeds a warning threshold. - * - * @param title the popup title supplied by the language server - * @param message the popup body message - * @param severity the language-server severity hint (e.g. {@code "info"} or {@code "warning"}); used by the client to - * decide which icon to render on the banner. May be {@code null}. - * @param copilotPlan the user's Copilot plan - * @param premiumInteractions the premium-interactions snapshot for the warning, or {@code null} when the language - * server does not include it - */ -public record QuotaWarningNotification( - String title, - String message, - String severity, - CopilotPlan copilotPlan, - @SerializedName("premium_interactions") PremiumInteractions premiumInteractions) { - - /** - * Premium-interactions snapshot embedded in a {@link QuotaWarningNotification}. The shape is dictated by the - * language server and differs from {@link Quota}. - * - * @param quota total monthly premium-interactions allowance - * @param used premium interactions consumed so far this period - * @param percentRemaining percentage of the allowance remaining - * @param overageUsed additional paid interactions consumed beyond the allowance - * @param overageEnabled whether the user has enabled paid overage - * @param resetDate ISO-8601 instant when the monthly allowance resets, or {@code null} - * @param unlimited whether this quota has no monthly limit - */ - public record PremiumInteractions( - double quota, - double used, - double percentRemaining, - double overageUsed, - boolean overageEnabled, - String resetDate, - boolean unlimited) { - } -} 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 7ac067a4..e1e64331 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 @@ -62,7 +62,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.TodoItem; import com.microsoft.copilot.eclipse.core.lsp.protocol.Turn; import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; -import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.QuotaWarningNotification; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.QuotaWarningParams; import com.microsoft.copilot.eclipse.core.persistence.AbstractTurnData; import com.microsoft.copilot.eclipse.core.persistence.ConversationPersistenceManager; import com.microsoft.copilot.eclipse.core.persistence.ConversationXmlData; @@ -375,11 +375,11 @@ public void done(IJobChangeEvent event) { this.quotaWarningHandler = event -> { Object data = event.getProperty(IEventBroker.DATA); - if (data instanceof QuotaWarningNotification notification) { + if (data instanceof QuotaWarningParams params) { SwtUtils.invokeOnDisplayThreadAsync(() -> { if (actionBar != null && !actionBar.isDisposed()) { - boolean warning = "warning".equalsIgnoreCase(notification.severity()); - actionBar.createQuotaWarningBanner(notification.message(), notification.copilotPlan(), warning); + boolean warning = "warning".equalsIgnoreCase(params.severity()); + actionBar.createQuotaWarningBanner(params.message(), params.copilotPlan(), warning); } }, parent); } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaNotificationManager.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaNotificationManager.java new file mode 100644 index 00000000..292cb712 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaNotificationManager.java @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.notifications; + +import org.eclipse.e4.core.services.events.IEventBroker; +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.PlatformUI; +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.protocol.quota.QuotaWarningParams; + +/** + * Handles quota notifications from the language server and displays + * them as JFace notification popups. + */ +public class QuotaNotificationManager { + + private static final int NOTIFICATION_DELAY_MS = 10000; + + private final IEventBroker eventBroker; + private EventHandler quotaWarningEventHandler; + + /** + * Creates a new QuotaNotificationManager and subscribes to quota events. + */ + public QuotaNotificationManager() { + this.eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); + if (eventBroker != null) { + this.quotaWarningEventHandler = event -> { + Object data = event.getProperty(IEventBroker.DATA); + if (data instanceof QuotaWarningParams params) { + showQuotaWarningNotification(params); + } + }; + eventBroker.subscribe(CopilotEventConstants.TOPIC_QUOTA_WARNING, quotaWarningEventHandler); + } + } + + /** + * Unsubscribes the quota event handler. + */ + public void dispose() { + if (eventBroker != null && quotaWarningEventHandler != null) { + eventBroker.unsubscribe(quotaWarningEventHandler); + quotaWarningEventHandler = null; + } + } + + private void showQuotaWarningNotification(QuotaWarningParams params) { + Display display = PlatformUI.getWorkbench().getDisplay(); + if (display == null || display.isDisposed()) { + return; + } + display.asyncExec(() -> { + try { + new QuotaWarningNotificationPopup(display, params.message(), NOTIFICATION_DELAY_MS).open(); + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to show quota warning notification", e); + } + }); + } +} From 7c5ce0ee012b60d0b2bd45702a61c0e70e6c4f56 Mon Sep 17 00:00:00 2001 From: Ethan Hou Date: Wed, 13 May 2026 16:42:48 +0800 Subject: [PATCH 6/9] Address comments. --- .../core/lsp/CopilotLanguageClient.java | 1 - .../protocol/quota/QuotaWarningParams.java | 2 +- .../copilot/eclipse/ui/chat/QuotaActions.java | 3 + .../eclipse/ui/i18n/messages.properties | 2 +- .../QuotaNotificationManager.java | 65 ------------------- 5 files changed, 5 insertions(+), 68 deletions(-) delete mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaNotificationManager.java 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 53580fd1..c3897ad9 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 @@ -359,7 +359,6 @@ public CompletableFuture onCodingAgentMessage(CodingAg */ @JsonNotification("copilot/quotaWarning") public void onQuotaWarning(QuotaWarningParams params) { - CopilotCore.LOGGER.info("Quota warning received: " + params); if (eventBroker != null) { eventBroker.post(CopilotEventConstants.TOPIC_QUOTA_WARNING, params); } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningParams.java index 537d1a3d..8462a934 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningParams.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningParams.java @@ -19,5 +19,5 @@ */ public record QuotaWarningParams(String title, String message, String severity, QuotaSnapshotParams chat, QuotaSnapshotParams completions, - @SerializedName("premium_interactions") QuotaSnapshotParams premiumInteractions, String copilotPlan) { + @SerializedName("premium_interactions") QuotaSnapshotParams premiumInteractions, CopilotPlan copilotPlan) { } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java index 82cfdd91..3d7f0493 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java @@ -31,6 +31,9 @@ public final class QuotaActions { public record QuotaAction(String label, String tooltip, String url, boolean primary) { } + private QuotaActions() { + } + /** * Returns the ordered list of {@link QuotaAction}s appropriate for the supplied plan. * 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 7edc6c43..76aac295 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 @@ -137,7 +137,7 @@ chat_noQuotaView_fallbackModel=fallback model chat_noQuotaView_updatePlanButton=Upgrade Plan chat_noQuotaView_updatePlanButton_Tooltip=Upgrade your Copilot plan chat_noQuotaView_enableAdditionalUsageButton=Enable Additional Usage -chat_noQuotaView_enableAdditionalUsageButton_tooltip=Pay-as-you-go usage of additional AI credits once your run out of your included usage. Set a budget to cap your maximum monthly spend. +chat_noQuotaView_enableAdditionalUsageButton_tooltip=Pay-as-you-go usage of additional AI credits once you run out of your included usage. Set a budget to cap your maximum monthly spend. chat_noQuotaView_viewYourPlanButton_Tooltip=View your Copilot plan chat_noQuotaView_proProplusWarnMsg=You have exceeded your premium request allowance. We have automatically switched you to **%s** which is included with your plan. [Enable additional paid premium requests](https://aka.ms/github-copilot-manage-overage) to continue using premium models. chat_noQuotaView_cbCeWarnMsg=You have exceeded your free request allowance. We have automatically switched you to **%s** which is included with your plan. To enable additional paid premium requests, contact your organization admin. diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaNotificationManager.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaNotificationManager.java deleted file mode 100644 index 292cb712..00000000 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/notifications/QuotaNotificationManager.java +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -package com.microsoft.copilot.eclipse.ui.notifications; - -import org.eclipse.e4.core.services.events.IEventBroker; -import org.eclipse.swt.widgets.Display; -import org.eclipse.ui.PlatformUI; -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.protocol.quota.QuotaWarningParams; - -/** - * Handles quota notifications from the language server and displays - * them as JFace notification popups. - */ -public class QuotaNotificationManager { - - private static final int NOTIFICATION_DELAY_MS = 10000; - - private final IEventBroker eventBroker; - private EventHandler quotaWarningEventHandler; - - /** - * Creates a new QuotaNotificationManager and subscribes to quota events. - */ - public QuotaNotificationManager() { - this.eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); - if (eventBroker != null) { - this.quotaWarningEventHandler = event -> { - Object data = event.getProperty(IEventBroker.DATA); - if (data instanceof QuotaWarningParams params) { - showQuotaWarningNotification(params); - } - }; - eventBroker.subscribe(CopilotEventConstants.TOPIC_QUOTA_WARNING, quotaWarningEventHandler); - } - } - - /** - * Unsubscribes the quota event handler. - */ - public void dispose() { - if (eventBroker != null && quotaWarningEventHandler != null) { - eventBroker.unsubscribe(quotaWarningEventHandler); - quotaWarningEventHandler = null; - } - } - - private void showQuotaWarningNotification(QuotaWarningParams params) { - Display display = PlatformUI.getWorkbench().getDisplay(); - if (display == null || display.isDisposed()) { - return; - } - display.asyncExec(() -> { - try { - new QuotaWarningNotificationPopup(display, params.message(), NOTIFICATION_DELAY_MS).open(); - } catch (Exception e) { - CopilotCore.LOGGER.error("Failed to show quota warning notification", e); - } - }); - } -} From 464a8172f3be74c8a81f900674aca74a087a09ff Mon Sep 17 00:00:00 2001 From: Ethan Hou Date: Wed, 13 May 2026 17:52:43 +0800 Subject: [PATCH 7/9] refactor: Clean up unused imports and update warning widget layout --- com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF | 3 +-- .../microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java | 2 +- .../microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java | 3 --- .../src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java | 4 ---- .../microsoft/copilot/eclipse/ui/i18n/messages.properties | 5 ----- 5 files changed, 2 insertions(+), 15 deletions(-) diff --git a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF index 2edd6538..0778e096 100644 --- a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF @@ -31,8 +31,7 @@ Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.15.0", org.eclipse.ui.editors;bundle-version="3.17.100", org.eclipse.ui;bundle-version="3.205.0", org.eclipse.ui.navigator;bundle-version="3.12.200", - org.eclipse.jface.text;bundle-version="3.24.200", - org.eclipse.jface.notifications;bundle-version="0.7.0", + org.eclipse.jface.text;bundle-version="3.24.200", org.eclipse.core.runtime;bundle-version="[3.30.0,4.0.0)", org.eclipse.core.expressions, org.eclipse.jdt.annotation;resolution:=optional, 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 58a72ea4..bb57433a 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 @@ -573,7 +573,7 @@ protected void createWarnDialog(String message, int code, String modelProviderNa if (code == 402 && !byokQuotaExceeded) { planForActions = this.serviceManager.getAuthStatusManager().getQuotaStatus().copilotPlan(); } - new WarnWidget(this, SWT.BOTTOM, displayMessage, planForActions); + new WarnWidget(this, SWT.NONE, displayMessage, planForActions); requestLayout(); } 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 c0f98987..33b4a1ae 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 @@ -27,16 +27,13 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentRound; import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; -import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; import com.microsoft.copilot.eclipse.core.lsp.protocol.TodoItem; import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolSpecificData; -import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; import com.microsoft.copilot.eclipse.ui.chat.services.TodoListService; import com.microsoft.copilot.eclipse.ui.i18n.Messages; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; -import com.microsoft.copilot.eclipse.ui.utils.MenuUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; /** 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 b65ece46..47cf65eb 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 @@ -152,14 +152,10 @@ public final class Messages extends NLS { public static String chat_addContext_tooltip; public static String chat_filePicker_title; public static String chat_filePicker_message; - public static String chat_noQuotaView_fallbackModel; - public static String chat_noQuotaView_updatePlanButton; public static String chat_noQuotaView_updatePlanButton_Tooltip; public static String chat_noQuotaView_enableAdditionalUsageButton; public static String chat_noQuotaView_enableAdditionalUsageButton_tooltip; public static String chat_noQuotaView_viewYourPlanButton_Tooltip; - public static String chat_noQuotaView_proProplusWarnMsg; - public static String chat_noQuotaView_cbCeWarnMsg; public static String chat_currentReferencedFile_description; public static String chat_turnWidget_copilot; public static String chat_turnWidget_user; 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 76aac295..f628ff9e 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 @@ -133,14 +133,10 @@ chat_agentModeView_configureMcpSuffix=to configure MCP server chat_agentModeView_attachContextSuffix=to attach context chat_loadingView_title=Loading chat_loadingView_description=Initializing Copilot... -chat_noQuotaView_fallbackModel=fallback model -chat_noQuotaView_updatePlanButton=Upgrade Plan chat_noQuotaView_updatePlanButton_Tooltip=Upgrade your Copilot plan chat_noQuotaView_enableAdditionalUsageButton=Enable Additional Usage chat_noQuotaView_enableAdditionalUsageButton_tooltip=Pay-as-you-go usage of additional AI credits once you run out of your included usage. Set a budget to cap your maximum monthly spend. chat_noQuotaView_viewYourPlanButton_Tooltip=View your Copilot plan -chat_noQuotaView_proProplusWarnMsg=You have exceeded your premium request allowance. We have automatically switched you to **%s** which is included with your plan. [Enable additional paid premium requests](https://aka.ms/github-copilot-manage-overage) to continue using premium models. -chat_noQuotaView_cbCeWarnMsg=You have exceeded your free request allowance. We have automatically switched you to **%s** which is included with your plan. To enable additional paid premium requests, contact your organization admin. chat_noAuthView_title=No Copilot subscription found chat_noAuthView_description=Request a license from your organization manager or sign up for a 60 day trial chat_noAuthView_checkSubButton=Check subscription plans @@ -208,7 +204,6 @@ model_hover_contextWindow=Context Window: model_hover_cost=Cost: chat_actionBar_modePicker_Tooltip=Set Agents chat_actionBar_modelPicker_Tooltip=Pick Model{0} - context_window_title=Context Window context_window_tokens={0} / {1} tokens context_window_system=System From 828fd5cca95423965c0fd83ab14eb17b64d42780 Mon Sep 17 00:00:00 2001 From: Ethan Hou Date: Thu, 14 May 2026 21:53:31 +0800 Subject: [PATCH 8/9] feat: updated quota warning msg with overage support --- .../copilot/eclipse/ui/chat/ActionBar.java | 8 +++--- .../eclipse/ui/chat/BaseTurnWidget.java | 8 ++++-- .../copilot/eclipse/ui/chat/ChatView.java | 4 ++- .../copilot/eclipse/ui/chat/QuotaActions.java | 27 ++++++++++--------- .../copilot/eclipse/ui/chat/WarnWidget.java | 14 +++++----- 5 files changed, 37 insertions(+), 24 deletions(-) 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 2c60e31c..b2be770f 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 @@ -977,14 +977,16 @@ public void createRateLimitBanner(String message, boolean warning) { /** * Show the quota-warning static banner above the input area. Action links are sourced from - * {@link QuotaActions#forPlan(CopilotPlan)} so they stay in sync with the inline {@link WarnWidget}. + * {@link QuotaActions#forPlan(CopilotPlan, boolean)} so they stay in sync with the inline {@link WarnWidget}. * * @param message the message to display * @param plan the user's Copilot plan, or {@code null} for no action links + * @param overageEnabled whether additional paid usage is already enabled for the user; switches the + * "Enable Additional Usage" label to "Increase Budget" * @param warning {@code true} for the warning icon; {@code false} for the info icon */ - public void createQuotaWarningBanner(String message, CopilotPlan plan, boolean warning) { - List bannerActions = QuotaActions.forPlan(plan).stream() + public void createQuotaWarningBanner(String message, CopilotPlan plan, boolean overageEnabled, boolean warning) { + List bannerActions = QuotaActions.forPlan(plan, overageEnabled).stream() .map(action -> new BannerAction(action.label(), action.url())) .toList(); showStaticBanner(message, bannerActions, warning); 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 bb57433a..6abaa3ea 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 @@ -570,10 +570,14 @@ protected void createWarnDialog(String message, int code, String modelProviderNa boolean byokQuotaExceeded = QuotaActions.isByokQuotaExceeded(code, modelProviderName); String displayMessage = byokQuotaExceeded ? Messages.chat_warnWidget_byokQuotaUsageMessage : message; CopilotPlan planForActions = null; + boolean overageEnabled = false; if (code == 402 && !byokQuotaExceeded) { - planForActions = this.serviceManager.getAuthStatusManager().getQuotaStatus().copilotPlan(); + var quotaStatus = this.serviceManager.getAuthStatusManager().getQuotaStatus(); + planForActions = quotaStatus.copilotPlan(); + overageEnabled = quotaStatus.premiumInteractions() != null + && quotaStatus.premiumInteractions().overagePermitted(); } - new WarnWidget(this, SWT.NONE, displayMessage, planForActions); + new WarnWidget(this, SWT.NONE, displayMessage, planForActions, overageEnabled); requestLayout(); } 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 e1e64331..c932acad 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 @@ -379,7 +379,9 @@ public void done(IJobChangeEvent event) { SwtUtils.invokeOnDisplayThreadAsync(() -> { if (actionBar != null && !actionBar.isDisposed()) { boolean warning = "warning".equalsIgnoreCase(params.severity()); - actionBar.createQuotaWarningBanner(params.message(), params.copilotPlan(), warning); + boolean overageEnabled = params.premiumInteractions() != null + && params.premiumInteractions().overageEnabled(); + actionBar.createQuotaWarningBanner(params.message(), params.copilotPlan(), overageEnabled, warning); } }, parent); } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java index 3d7f0493..46943eeb 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java @@ -40,17 +40,21 @@ private QuotaActions() { *

Mapping: *

    *
  • {@code free} → "Upgrade Plan" (primary)
  • - *
  • {@code individual}, {@code individual_pro} → "Enable Additional Usage" (primary) + - * "Upgrade Plan" (secondary)
  • - *
  • {@code individual_max} → "Enable Additional Usage" (primary)
  • - *
  • {@code business}, {@code enterprise} → "View your plan" (primary)
  • + *
  • {@code individual}, {@code individual_pro} → "Enable Additional Usage" / + * "Increase Budget" (primary) + "Upgrade Plan" (secondary)
  • + *
  • {@code individual_max} → "Enable Additional Usage" / "Increase Budget" (primary)
  • + *
  • {@code business}, {@code enterprise} → empty list
  • *
  • {@code null} → empty list
  • *
* + *

The "Enable Additional Usage" label is replaced with "Increase Budget" when {@code overageEnabled} + * is {@code true}, matching the IntelliJ quota dialog wording. + * * @param plan the user's Copilot plan, or {@code null} when unknown + * @param overageEnabled {@code true} when additional paid usage is already enabled for the user * @return an immutable, possibly empty list; never {@code null} */ - public static List forPlan(CopilotPlan plan) { + public static List forPlan(CopilotPlan plan, boolean overageEnabled) { if (plan == null) { return List.of(); } @@ -60,17 +64,16 @@ public static List forPlan(CopilotPlan plan) { QuotaAction upgradeSecondary = new QuotaAction(Messages.menu_quota_upgradePlan, Messages.chat_noQuotaView_updatePlanButton_Tooltip, UiConstants.COPILOT_UPGRADE_PLAN_URL, false); - QuotaAction enableOverage = new QuotaAction(Messages.menu_quota_enableAdditionalUsage, + String overageLabel = overageEnabled ? Messages.menu_quota_increaseBudget + : Messages.menu_quota_enableAdditionalUsage; + QuotaAction manageOverage = new QuotaAction(overageLabel, Messages.chat_noQuotaView_enableAdditionalUsageButton_tooltip, UiConstants.MANAGE_COPILOT_OVERAGE_URL, true); - QuotaAction viewYourPlan = new QuotaAction(Messages.chat_quotaBanner_viewYourPlan, - Messages.chat_noQuotaView_viewYourPlanButton_Tooltip, - UiConstants.MANAGE_COPILOT_URL, true); return switch (plan) { case free -> List.of(upgradePrimary); - case individual, individual_pro -> List.of(enableOverage, upgradeSecondary); - case individual_max -> List.of(enableOverage); - case business, enterprise -> List.of(viewYourPlan); + case individual, individual_pro -> List.of(manageOverage, upgradeSecondary); + case individual_max -> List.of(manageOverage); + case business, enterprise -> List.of(); }; } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WarnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WarnWidget.java index d68500f5..8772cc80 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WarnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WarnWidget.java @@ -25,8 +25,8 @@ /** * Widget that displays a warning message under a chat turn, optionally followed by plan-driven action buttons sourced - * from {@link QuotaActions#forPlan(CopilotPlan)}. Presentation-only: the caller decides the message and whether to - * pass a plan. + * from {@link QuotaActions#forPlan(CopilotPlan, boolean)}. Presentation-only: the caller decides the message and + * whether to pass a plan. */ public class WarnWidget extends Composite { private int buttonLeftMargin; @@ -38,8 +38,10 @@ public class WarnWidget extends Composite { * @param style the SWT style bits * @param message the message to display ({@code null} treated as empty) * @param userPlan the user's Copilot plan to render plan-driven action buttons, or {@code null} for no buttons + * @param overageEnabled whether additional paid usage is already enabled for the user; switches the + * "Enable Additional Usage" label to "Increase Budget" */ - public WarnWidget(Composite parent, int style, String message, CopilotPlan userPlan) { + public WarnWidget(Composite parent, int style, String message, CopilotPlan userPlan, boolean overageEnabled) { super(parent, style | SWT.BORDER); GridLayout outerLayout = new GridLayout(1, true); outerLayout.verticalSpacing = 0; @@ -49,7 +51,7 @@ public WarnWidget(Composite parent, int style, String message, CopilotPlan userP buildWarnLabelWithIcon(message); if (userPlan != null) { - buildActionButtons(userPlan); + buildActionButtons(userPlan, overageEnabled); } parent.layout(); } @@ -81,8 +83,8 @@ private void buildWarnLabelWithIcon(String message) { /** * Render plan-driven action buttons for a quota-exceeded warning, kept in sync with the quota {@link StaticBanner}. */ - private void buildActionButtons(CopilotPlan userPlan) { - List actions = QuotaActions.forPlan(userPlan); + private void buildActionButtons(CopilotPlan userPlan, boolean overageEnabled) { + List actions = QuotaActions.forPlan(userPlan, overageEnabled); if (actions.isEmpty()) { return; } From fbafcd264b012f760f0bc304ffb6655ebf32b167 Mon Sep 17 00:00:00 2001 From: Ethan Hou Date: Thu, 14 May 2026 22:00:16 +0800 Subject: [PATCH 9/9] Address comments. --- .../copilot/eclipse/core/lsp/protocol/ChatCreateResult.java | 3 +++ .../copilot/eclipse/core/lsp/protocol/ChatTurnResult.java | 3 +++ 2 files changed, 6 insertions(+) diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatCreateResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatCreateResult.java index 4dafbe0a..040e6b17 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatCreateResult.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatCreateResult.java @@ -6,12 +6,15 @@ import java.util.Objects; import org.apache.commons.lang3.builder.ToStringBuilder; +import org.eclipse.lsp4j.jsonrpc.validation.NonNull; /** * Result of a chat creation. */ public class ChatCreateResult { + @NonNull private String conversationId; + @NonNull private String turnId; private String agentSlug; private String modelName; diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatTurnResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatTurnResult.java index 20eefa8e..47af6b3a 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatTurnResult.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatTurnResult.java @@ -6,12 +6,15 @@ import java.util.Objects; import org.apache.commons.lang3.builder.ToStringBuilder; +import org.eclipse.lsp4j.jsonrpc.validation.NonNull; /** * Result of a chat turn. */ public class ChatTurnResult { + @NonNull private String conversationId; + @NonNull private String turnId; private String agentSlug; private String modelName;