diff --git a/.github/instructions/sessions.instructions.md b/.github/instructions/sessions.instructions.md
index 88189a950f964f..cb56021ab83606 100644
--- a/.github/instructions/sessions.instructions.md
+++ b/.github/instructions/sessions.instructions.md
@@ -11,6 +11,76 @@ When working on files under `src/vs/sessions/`, use these skills for detailed gu
- **`sessions`** skill — covers the full architecture: layering, folder structure, chat widget, menus, contributions, entry points, and development guidelines
+## Architecture at a Glance
+
+```
+vs/sessions (Agents Window) ← this layer
+ ↓ imports from
+vs/workbench ← standard VS Code
+ ↓
+vs/editor → vs/platform → vs/base
+```
+
+**Layer rule:** `vs/sessions` imports from `vs/workbench` and below. `vs/workbench` must **never** import from `vs/sessions`.
+
+**Internal layers** (see `src/vs/sessions/LAYERS.md`):
+```
+Entry Points → contrib/* / contrib/providers/* / services/* → browser/ & common/ (core)
+```
+
+**Key constraint:** `contrib/*` must NOT import from `contrib/providers/*`. Providers are the most permissive contrib layer and may import from non-provider contribs, services, core, and sibling providers.
+
+## Core Services
+
+| Service | Interface file | Purpose |
+|---------|---------------|---------|
+| `ISessionsManagementService` | `services/sessions/common/sessionsManagement.ts` | Active session tracking, navigation, CRUD operations |
+| `ISessionsProvidersService` | `services/sessions/common/sessionsProvider.ts` | Provider registry (register/unregister/lookup) |
+| `ISession` / `IChat` | `services/sessions/common/session.ts` | Session and chat data interfaces with observable properties |
+
+## Key Development Patterns
+
+### Registering Contributions
+
+All features register through the contribution model and must be imported in entry points:
+- `sessions.common.main.ts` — cross-platform contributions
+- `sessions.desktop.main.ts` — desktop/Electron-specific
+- `sessions.web.main.ts` — web-specific
+
+### Menu Registration
+
+Always use `Menus.*` from `browser/menus.ts` — never `MenuId.*` from `vs/platform/actions`:
+- `Menus.TitleBarLeftLayout` / `Menus.TitleBarRightLayout` — titlebar actions
+- `Menus.SidebarTitle` — sidebar header actions
+- `Menus.AuxiliaryBarTitle` — auxiliary bar header actions
+- `Menus.ChatBarTitle` — chat bar header actions
+
+### Context Keys
+
+All sessions-specific context keys live in `common/contextkeys.ts`:
+- `IsNewChatSessionContext` — whether showing the new session view
+- `ActiveSessionProviderIdContext` — which provider owns the active session
+- `ActiveSessionTypeContext` — session type of the active session
+- `IsPhoneLayoutContext` — whether in phone layout mode
+- `ChatBarVisibleContext` / `ChatBarFocusContext` — chat bar state
+
+### Observable Patterns
+
+```typescript
+// Subscribe to session state changes
+this._register(autorun(reader => {
+ const session = this.sessionsManagementService.activeSession.read(reader);
+ const title = session?.title.read(reader);
+ // React to changes
+}));
+
+// Batch updates
+transaction(tx => {
+ this._title.set(newTitle, tx);
+ this._status.set(newStatus, tx);
+});
+```
+
## Mobile Component Architecture
The Agents window has an established mobile architecture (documented in `src/vs/sessions/MOBILE.md`). When adding phone-specific UI — bottom sheets, action sheets, mobile pickers, or any interaction that differs from desktop — follow these rules:
@@ -34,3 +104,9 @@ The Agents window can run on touch-capable platforms (notably iOS). Follow these
- Do not use `EventType.MOUSE_DOWN`, `EventType.MOUSE_UP`, or `EventType.MOUSE_MOVE` with `addDisposableListener` directly — on iOS, these events don't fire because the platform uses pointer events. Use `addDisposableGenericMouseDownListener`, `addDisposableGenericMouseUpListener`, or `addDisposableGenericMouseMoveListener` instead, which automatically select the correct event type per platform.
- For custom clickable elements (e.g. picker triggers, title bar pills, or other `
`/`
` elements styled as buttons) that open pickers or menus on click, listen to **both** `EventType.CLICK` and `TouchEventType.Tap` and call `Gesture.addTarget` on the element. On touch devices, including iOS, VS Code relies on the gesture system to emit `TouchEventType.Tap`, and `EventType.CLICK` alone may not reliably fire there. The base `Button` class already does this correctly, so this rule applies to custom non-`` trigger elements.
- Add `touch-action: manipulation` in CSS on custom clickable elements (e.g. picker triggers, title bar pills, or other ``/`
` elements styled as buttons) to eliminate the 300ms tap delay on touch devices. This is not needed for native `` elements or standard VS Code widgets (quick picks, context menus, action bar items) which already handle touch behavior.
+
+## Learnings
+
+- Always check `src/vs/sessions/LAYERS.md` before adding cross-module imports — layering violations are enforced by ESLint and will fail CI.
+- When creating new views, remember to import the contribution in the entry point — missing this causes the view to not appear.
+- Session state flows through observables, not events. If you find yourself adding `onDid*` events for session state, convert to `IObservable` instead.
diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md
index ee2f9f270d8412..1e3cdc7385a141 100644
--- a/.github/skills/sessions/SKILL.md
+++ b/.github/skills/sessions/SKILL.md
@@ -3,67 +3,38 @@ name: sessions
description: Agents window architecture — covers the agents-first app, layering, folder structure, chat widget, menus, contributions, entry points, and development guidelines. Use when implementing features or fixing issues in the Agents window.
---
-When working on the Agents window (`src/vs/sessions/`), always read the relevant specification document before making changes. If you modify the implementation, you **must** update the corresponding spec to keep it in sync.
+## Before Making Any Changes
-## Specification Documents
-
-| Document | Path | Covers |
-|----------|------|--------|
-| Overview | `src/vs/sessions/README.md` | Architecture overview, folder conventions |
-| Layer rules | `src/vs/sessions/LAYERS.md` | Import restriction rules for all sessions layers (enforced by ESLint) |
-| Layout spec | `src/vs/sessions/LAYOUT.md` | Grid structure, parts, titlebar, per-session layout state, CSS |
-| Mobile spec | `src/vs/sessions/MOBILE.md` | Mobile component architecture, phone-specific UI patterns |
-| Sessions spec | `src/vs/sessions/SESSIONS.md` | Sessions architecture — layers, provider model, core interfaces, data flow |
-| AI Customizations | `src/vs/sessions/AI_CUSTOMIZATIONS.md` | AI customization editor and tree view design |
-
-## Engineering Principles
-
-### Layering and Dependencies
-
-- **Respect the layer hierarchy.** `vs/sessions` sits above `vs/workbench` — it may import from workbench and below, but workbench must never import from sessions. See `LAYERS.md` for the full internal layer rules.
-- **Keep providers isolated.** Session providers (`contrib/providers/*`) are implementation details of specific backends. Non-provider contributions (`contrib/*`) must not import from providers — extract shared symbols to `services/` or `common/` instead.
-- **Validate layers before committing.** Run `npm run valid-layers-check` to catch violations. Run `npm run compile-check-ts-native` for TypeScript errors — never use raw `tsc`.
-
-### Separation of Concerns
-
-- **Use the contribution model.** Features register through `registerWorkbenchContribution2` and `registerAction2`, imported by entry points (`sessions.common.main.ts`, `sessions.desktop.main.ts`). Don't wire features directly into core workbench code.
-- **Prefer composition over modification.** Extend existing classes (e.g., `AgentSessionsChatWidget` wraps `ChatWidget`) rather than modifying shared workbench components. This keeps the sessions layer decoupled.
-- **Use services for cross-cutting concerns.** Shared state belongs in services (`ISessionsManagementService`, `ISessionsProvidersService`), not passed through component hierarchies. Declare service dependencies in constructors via dependency injection.
+**MANDATORY:** Before writing or modifying any code in `src/vs/sessions/`, you **must** read these documents:
-### Reactive State and Observables
+1. **`.github/instructions/coding-guidelines.instructions.md`** — Naming conventions, code style, string localization, disposable management, and DI patterns.
+2. **`.github/instructions/source-code-organization.instructions.md`** — Layers, target environments, dependency injection, and folder structure conventions.
-- **Expose mutable state as observables.** Session properties (`title`, `status`, `changes`, etc.) use `IObservable` for reactive UI binding. Use `observableValue`, `derived`, and `autorun` — not events — for state that drives UI updates.
-- **Batch related state changes in transactions.** When updating multiple observables together, wrap in `transaction(tx => { ... })` to avoid intermediate renders.
+Then read the relevant spec for the area you are changing (see table below). If you modify the implementation, you **must** update the corresponding spec to keep it in sync.
-### Window Isolation
-
-- **Scope registrations to the Agents window.** Views and contributions that should not appear in regular VS Code use `WindowVisibility.Sessions` in their registration.
-- **Use dedicated menu IDs.** The Agents window defines its own menus in `browser/menus.ts` (`Menus.*`). Never use shared `MenuId.*` constants for Agents window UI.
-- **Use dedicated storage keys.** Prefix with `workbench.agentsession.*` or `workbench.chatbar.*` to avoid conflicts with regular workbench state.
-
-### Layout Stability
-
-- **Maintain fixed positions.** The Agents layout is intentionally non-configurable — no settings-based position customization. New parts go in the right section of the grid.
-- **Preserve no-op stubs.** Unsupported workbench features (zen mode, centered layout, etc.) remain as no-ops — never throw errors for unsupported API calls.
-- **Manage pane composite lifecycle.** When toggling part visibility, always manage the associated pane composites (open default view container on show, dispose on hide).
-
-### Code Organization
-
-- **Core** (layout, parts, shell services) → `browser/`
-- **Feature contributions** (views, actions, editors) → `contrib//browser/`
-- **Session providers** (compute backends) → `contrib/providers//`
-- **Shared service interfaces** → `services//common/`
+## Specification Documents
-## General VS Code Guidelines
+| Document | Path | When to read |
+|----------|------|-------------|
+| Layer rules | `src/vs/sessions/LAYERS.md` | Before adding any cross-module imports. Defines the internal layer hierarchy (`core` → `services` → `contrib` → `providers`) with ESLint-enforced import restrictions. Key rule: `contrib/*` must NOT import from `contrib/providers/*`. |
+| Layout spec | `src/vs/sessions/LAYOUT.md` | Before changing any part, grid structure, titlebar, or CSS. Documents the fixed grid layout (Sidebar \| ChatBar \| AuxiliaryBar), part positions, the modal editor system, per-session layout state persistence, and the titlebar's three-section design. |
+| Sessions spec | `src/vs/sessions/SESSIONS.md` | Before changing session/provider interfaces or data flow. Covers the pluggable provider model (`ISessionsProvider` → `ISessionsProvidersService` → `ISessionsManagementService`), `ISession`/`IChat` interfaces, observable state propagation, workspace/folder model, and session type system. |
+| Sessions list spec | `src/vs/sessions/SESSIONS_LIST.md` | Before changing the sessions sidebar list. Covers the tree widget (`WorkbenchObjectTree`), renderers, grouping (workspace/date), filtering (type/status/archived/read), pinning, read/unread state, workspace capping, mobile adaptations, storage keys, and registered actions. |
+| Mobile spec | `src/vs/sessions/MOBILE.md` | Before adding any phone-specific UI. Covers the mobile part subclass architecture, viewport classification (phone < 640px), `MobileTitlebarPart`, drawer-based sidebar, `MobilePickerSheet`, view/action gating with `IsPhoneLayoutContext`, and the desktop → mobile component mapping. |
+| AI Customizations | `src/vs/sessions/AI_CUSTOMIZATIONS.md` | Before working on the customization editor or tree view. Documents the management editor (in `vs/workbench`) and the tree view/overview (in `vs/sessions/contrib/aiCustomizationTreeView`). |
-The Agents window follows all standard VS Code engineering practices. See these instruction files for the full rules:
+## Common Pitfalls
-- **Source Code Organization** — `.github/instructions/source-code-organization.instructions.md` (layers, target environments, DI, contribution rules)
-- **Coding Guidelines** — `.github/instructions/coding-guidelines.instructions.md` (naming conventions, code style, string localization, disposable management, DI patterns)
-- **Writing Tests** — `.github/instructions/writing-tests.instructions.md` (unit/integration tests, `ensureNoDisposablesAreLeakedInTestSuite`, snapshot testing, clean teardown)
+- **Wrong menu IDs**: Never use `MenuId.*` from `vs/platform/actions` for Agents window UI. Always use `Menus.*` from `browser/menus.ts`.
+- **Events instead of observables**: Session state must flow through `IObservable`, not `Event`. Use `autorun`/`derived` for reactive UI, not `onDid*` event listeners.
+- **Importing from providers**: Non-provider `contrib/*` code must never import from `contrib/providers/*`. Extract shared interfaces to `services/` or `common/`.
+- **Missing entry point import**: New contribution files must be imported in the appropriate `sessions.*.main.ts` entry point to be loaded (for example `sessions.common.main.ts`, `sessions.desktop.main.ts`, `sessions.web.main.ts`, or `sessions.web.main.internal.ts`).
+- **Modifying workbench code**: Prefer extending/wrapping workbench classes in the sessions layer over modifying shared workbench components.
## Validating Changes
+You **must** run these checks before declaring work complete:
+
1. `npm run compile-check-ts-native` — TypeScript compilation check. **Do not run `tsc` directly.**
-2. `npm run valid-layers-check` — layering violations (see `LAYERS.md`)
-3. `scripts/test.sh --grep ` — unit tests (see Writing Tests instructions)
+2. `npm run valid-layers-check` — **MANDATORY.** Catches layering violations. If this fails, fix the imports before proceeding.
+3. `scripts/test.sh --grep ` — unit tests for affected areas
diff --git a/build/rspack/package-lock.json b/build/rspack/package-lock.json
index 1fdf97f60eb47c..6ce456c7a10a63 100644
--- a/build/rspack/package-lock.json
+++ b/build/rspack/package-lock.json
@@ -2220,9 +2220,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
- "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
+ "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"dev": true,
"funding": [
{
diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json
index df5570d9811512..87782713621d6c 100644
--- a/extensions/copilot/package-lock.json
+++ b/extensions/copilot/package-lock.json
@@ -13,7 +13,7 @@
"@anthropic-ai/claude-agent-sdk": "0.2.112",
"@anthropic-ai/sdk": "^0.82.0",
"@github/blackbird-external-ingest-utils": "^0.3.0",
- "@github/copilot": "^1.0.39",
+ "@github/copilot": "1.0.39",
"@google/genai": "^1.22.0",
"@humanwhocodes/gitignore-to-minimatch": "1.0.2",
"@microsoft/tiktokenizer": "^1.0.10",
@@ -4899,9 +4899,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
- "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
@@ -4927,9 +4927,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
- "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
+ "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
@@ -14880,22 +14880,22 @@
"license": "MIT"
},
"node_modules/protobufjs": {
- "version": "7.5.5",
- "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz",
- "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==",
+ "version": "7.5.8",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz",
+ "integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
- "@protobufjs/codegen": "^2.0.4",
+ "@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
- "@protobufjs/inquire": "^1.1.0",
+ "@protobufjs/inquire": "^1.1.1",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
- "@protobufjs/utf8": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
},
diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json
index a12de99ce35eb1..3b2f912818c663 100644
--- a/extensions/copilot/package.json
+++ b/extensions/copilot/package.json
@@ -4,7 +4,6 @@
"description": "AI chat features powered by Copilot",
"version": "0.49.0",
"build": "1",
- "internalAIKey": "1058ec22-3c95-4951-8443-f26c1f325911",
"completionsCoreVersion": "1.378.1799",
"internalLargeStorageAriaKey": "ec712b3202c5462fb6877acae7f1f9d7-c19ad55e-3e3c-4f99-984b-827f6d95bd9e-6917",
"ariaKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255",
@@ -6708,7 +6707,7 @@
"@anthropic-ai/claude-agent-sdk": "0.2.112",
"@anthropic-ai/sdk": "^0.82.0",
"@github/blackbird-external-ingest-utils": "^0.3.0",
- "@github/copilot": "^1.0.39",
+ "@github/copilot": "1.0.39",
"@google/genai": "^1.22.0",
"@humanwhocodes/gitignore-to-minimatch": "1.0.2",
"@microsoft/tiktokenizer": "^1.0.10",
diff --git a/extensions/copilot/src/extension/chatInputNotification/vscode-node/chatInputNotification.contribution.ts b/extensions/copilot/src/extension/chatInputNotification/vscode-node/chatInputNotification.contribution.ts
index 9296a811929194..35863ddf05e129 100644
--- a/extensions/copilot/src/extension/chatInputNotification/vscode-node/chatInputNotification.contribution.ts
+++ b/extensions/copilot/src/extension/chatInputNotification/vscode-node/chatInputNotification.contribution.ts
@@ -40,9 +40,14 @@ export class ChatInputNotificationContribution extends Disposable {
/** Whether a copilot token was present on the last {@link _update} call. */
private _hadCopilotToken = false;
- private readonly _shownQuotaThresholds = new Set();
- private readonly _shownSessionThresholds = new Set();
- private readonly _shownWeeklyThresholds = new Set();
+ /**
+ * Previous percent-used values for threshold crossing detection.
+ * `undefined` means no data has been seen yet — the first value
+ * establishes a baseline without triggering a notification.
+ */
+ private _prevQuotaPercentUsed: number | undefined;
+ private _prevSessionPercentUsed: number | undefined;
+ private _prevWeeklyPercentUsed: number | undefined;
constructor(
@IAuthenticationService private readonly _authService: IAuthenticationService,
@@ -62,11 +67,11 @@ export class ChatInputNotificationContribution extends Disposable {
const wasSignedIn = this._hadCopilotToken;
this._hadCopilotToken = hasCopilotToken;
- // Detect signed-in → signed-out transition: clear thresholds and hide.
+ // Detect signed-in → signed-out transition: clear state and hide.
if (wasSignedIn && !hasCopilotToken) {
- this._shownQuotaThresholds.clear();
- this._shownSessionThresholds.clear();
- this._shownWeeklyThresholds.clear();
+ this._prevQuotaPercentUsed = undefined;
+ this._prevSessionPercentUsed = undefined;
+ this._prevWeeklyPercentUsed = undefined;
this._hideNotification();
this._showingExhausted = false;
return;
@@ -86,7 +91,7 @@ export class ChatInputNotificationContribution extends Disposable {
if (isQuotaNotificationEligible) {
const quotaWarning = this._computeQuotaWarning();
if (quotaWarning) {
- this._showQuotaApproachingWarning(quotaWarning);
+ this._fetchAndShowQuotaWarning(quotaWarning);
return;
}
}
@@ -105,60 +110,96 @@ export class ChatInputNotificationContribution extends Disposable {
}
}
- // --- Threshold computation -----------------------------------------------
+ // --- Fetch and show quota warning ----------------------------------------
+
+ /**
+ * Fetches up-to-date quota data before showing a threshold notification,
+ * ensuring the displayed percentage reflects the latest server state.
+ */
+ private async _fetchAndShowQuotaWarning(fallbackWarning: IQuotaWarning): Promise {
+ try {
+ await this._chatQuotaService.refreshQuota();
+ const freshInfo = this._chatQuotaService.quotaInfo;
+ if (freshInfo && !freshInfo.unlimited) {
+ this._showQuotaApproachingWarning({
+ percentUsed: Math.floor(100 - freshInfo.percentRemaining),
+ resetDate: freshInfo.resetDate,
+ });
+ } else {
+ this._showQuotaApproachingWarning(fallbackWarning);
+ }
+ } catch {
+ this._showQuotaApproachingWarning(fallbackWarning);
+ }
+ }
+
+ // --- Threshold crossing detection ----------------------------------------
private _computeQuotaWarning(): IQuotaWarning | undefined {
const info = this._chatQuotaService.quotaInfo;
if (!info || info.unlimited) {
+ this._prevQuotaPercentUsed = undefined;
return undefined;
}
- return this._checkThreshold(info, this._shownQuotaThresholds);
+ const percentUsed = 100 - info.percentRemaining;
+ const crossed = this._findCrossedThreshold(percentUsed, this._prevQuotaPercentUsed);
+ this._prevQuotaPercentUsed = percentUsed;
+ if (crossed !== undefined) {
+ return { percentUsed: Math.floor(percentUsed), resetDate: info.resetDate };
+ }
+ return undefined;
}
private _computeRateLimitWarning(): IRateLimitWarning | undefined {
const { session, weekly } = this._chatQuotaService.rateLimitInfo;
- const sessionWarning = this._checkThreshold(session, this._shownSessionThresholds);
- if (sessionWarning) {
- return { ...sessionWarning, type: 'session' };
+
+ // Always update both prev values so neither becomes stale.
+ const sessionWarning = this._checkCrossing(session, this._prevSessionPercentUsed);
+ this._prevSessionPercentUsed = sessionWarning.newPrev;
+
+ const weeklyWarning = this._checkCrossing(weekly, this._prevWeeklyPercentUsed);
+ this._prevWeeklyPercentUsed = weeklyWarning.newPrev;
+
+ if (sessionWarning.warning) {
+ return { ...sessionWarning.warning, type: 'session' };
}
- const weeklyWarning = this._checkThreshold(weekly, this._shownWeeklyThresholds);
- if (weeklyWarning) {
- return { ...weeklyWarning, type: 'weekly' };
+ if (weeklyWarning.warning) {
+ return { ...weeklyWarning.warning, type: 'weekly' };
}
return undefined;
}
- /**
- * Checks whether a quota/rate-limit info has crossed a new threshold
- * that hasn't been shown yet. Clears stale thresholds when usage drops.
- */
- private _checkThreshold(info: IChatQuota | undefined, shownThresholds: Set): { percentUsed: number; resetDate: Date } | undefined {
- if (!info) {
- shownThresholds.clear();
- return undefined;
- }
- if (info.unlimited) {
- return undefined;
+ private _checkCrossing(
+ info: IChatQuota | undefined,
+ prevPercentUsed: number | undefined,
+ ): { newPrev: number | undefined; warning?: { percentUsed: number; resetDate: Date } } {
+ if (!info || info.unlimited) {
+ return { newPrev: undefined };
}
-
const percentUsed = 100 - info.percentRemaining;
+ const crossed = this._findCrossedThreshold(percentUsed, prevPercentUsed);
+ return {
+ newPrev: percentUsed,
+ warning: crossed !== undefined
+ ? { percentUsed: Math.floor(percentUsed), resetDate: info.resetDate }
+ : undefined,
+ };
+ }
- // Clear thresholds that are no longer crossed (usage dropped)
- for (const threshold of shownThresholds) {
- if (percentUsed < threshold) {
- shownThresholds.delete(threshold);
- }
+ /**
+ * Returns the highest threshold that was newly crossed, or `undefined`.
+ * A threshold is "crossed" when the previous value was below it and the
+ * current value is at or above it. When `previous` is `undefined` (first
+ * data arrival), no crossing is detected — only the baseline is stored.
+ */
+ private _findCrossedThreshold(current: number, previous: number | undefined): number | undefined {
+ if (previous === undefined) {
+ return undefined;
}
-
- // Walk thresholds highest-first so we report the most severe crossed threshold
for (let i = THRESHOLDS.length - 1; i >= 0; i--) {
const threshold = THRESHOLDS[i];
- if (percentUsed >= threshold && !shownThresholds.has(threshold)) {
- // Mark this and all lower thresholds as shown
- for (let j = 0; j <= i; j++) {
- shownThresholds.add(THRESHOLDS[j]);
- }
- return { percentUsed: Math.floor(percentUsed), resetDate: info.resetDate };
+ if (previous < threshold && current >= threshold) {
+ return threshold;
}
}
return undefined;
diff --git a/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/chatInputNotification.contribution.spec.ts b/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/chatInputNotification.contribution.spec.ts
index d3ccb49c4279ca..d2434e099747b3 100644
--- a/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/chatInputNotification.contribution.spec.ts
+++ b/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/chatInputNotification.contribution.spec.ts
@@ -80,6 +80,7 @@ function createQuotaService(opts?: {
setLastCopilotUsage: vi.fn(),
resetTurnCredits: vi.fn(),
clearQuota: vi.fn(),
+ refreshQuota: vi.fn().mockResolvedValue(undefined),
} as unknown as IChatQuotaService;
return { quotaService, emitter };
}
@@ -116,7 +117,7 @@ describe('ChatInputNotificationContribution', () => {
contribution?.dispose();
});
- // --- sign-out behaviour (the PR change) ---------------------------------
+ // --- sign-out behaviour --------------------------------------------------
describe('sign-out clears state and hides notification', () => {
test('hides notification when copilot token disappears (sign out)', () => {
@@ -136,31 +137,34 @@ describe('ChatInputNotificationContribution', () => {
expect(mockNotification.hide).toHaveBeenCalled();
});
- test('re-shows threshold notification after sign-out + sign-in', () => {
- setup(
- {},
- { quotaInfo: makeQuota(5) }, // 95% used → crosses 95 threshold
- );
+ test('shows newly crossed threshold after sign-out + sign-in', async () => {
+ setup({}, { quotaInfo: makeQuota(60) }); // 40% used — baseline
- // First update: threshold notification shown
+ // Establish baseline
quotaEmitter.fire();
- expect(mockNotification.show).toHaveBeenCalledTimes(1);
- mockNotification.show.mockClear();
- // Fire again — same threshold already shown, no new notification
+ // Cross 50% threshold → notification shown
+ (quotaService as any).quotaInfo = makeQuota(50);
quotaEmitter.fire();
- expect(mockNotification.show).not.toHaveBeenCalled();
+ await Promise.resolve();
+ expect(mockNotification.show).toHaveBeenCalledTimes(1);
+ mockNotification.show.mockClear();
- // Sign out → copilot token cleared → thresholds cleared
+ // Sign out → prev values cleared
(authService as any).copilotToken = undefined;
authEmitter.fire();
- // Sign back in
+ // Sign back in — quota still at 50% → baseline stored, no notification
(authService as any).copilotToken = { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true };
- authEmitter.fire();
+ quotaEmitter.fire();
+ expect(mockNotification.show).not.toHaveBeenCalled();
- // Threshold was cleared, so it should re-show
+ // Usage increases past 75% → new threshold fires
+ (quotaService as any).quotaInfo = makeQuota(25);
+ quotaEmitter.fire();
+ await Promise.resolve();
expect(mockNotification.show).toHaveBeenCalled();
+ expect(mockNotification.message).toBe('Credits at 75%');
});
test('sign-out resets showingExhausted flag', () => {
@@ -169,12 +173,10 @@ describe('ChatInputNotificationContribution', () => {
{ quotaExhausted: true },
);
- // Show exhausted notification
quotaEmitter.fire();
expect(mockNotification.show).toHaveBeenCalled();
mockNotification.show.mockClear();
- // Sign out — copilot token cleared
(authService as any).copilotToken = undefined;
authEmitter.fire();
@@ -185,20 +187,15 @@ describe('ChatInputNotificationContribution', () => {
(quotaService as any).rateLimitInfo = { session: undefined, weekly: undefined };
authEmitter.fire();
- // Should NOT call hide again (showingExhausted was reset on sign-out)
- // and should NOT show a new notification (no thresholds crossed)
expect(mockNotification.show).not.toHaveBeenCalled();
});
test('sign-out while no notification was active is harmless', () => {
setup();
- // No quota events fired yet → no notification created
(authService as any).copilotToken = undefined;
authEmitter.fire();
- // hide is only called on the notification object; since none was
- // created, this should not throw.
expect(mockNotification.hide).not.toHaveBeenCalled();
});
@@ -208,8 +205,6 @@ describe('ChatInputNotificationContribution', () => {
{ quotaExhausted: true },
);
- // Anonymous UBB user has a copilotToken but no GitHub session.
- // They should still see the exhausted notification.
quotaEmitter.fire();
expect(mockNotification.show).toHaveBeenCalled();
@@ -229,6 +224,154 @@ describe('ChatInputNotificationContribution', () => {
});
});
+ // --- threshold crossing (window reload / sign-in) ------------------------
+
+ describe('threshold crossing on reload and sign-in', () => {
+ test('first data arrival stores baseline without notification', () => {
+ setup(
+ { anyGitHubSession: { accessToken: 'tok' } },
+ { quotaInfo: makeQuota(25) }, // 75% used — already above 50% and 75%
+ );
+
+ quotaEmitter.fire();
+
+ expect(mockNotification.show).not.toHaveBeenCalled();
+ });
+
+ test('notifies when crossing a new threshold after baseline', async () => {
+ setup(
+ { anyGitHubSession: { accessToken: 'tok' } },
+ { quotaInfo: makeQuota(40) }, // 60% used — baseline
+ );
+
+ quotaEmitter.fire();
+ expect(mockNotification.show).not.toHaveBeenCalled();
+
+ // Usage crosses 75%
+ (quotaService as any).quotaInfo = makeQuota(25);
+ quotaEmitter.fire();
+ await Promise.resolve();
+
+ expect(mockNotification.show).toHaveBeenCalled();
+ expect(mockNotification.message).toBe('Credits at 75%');
+ });
+
+ test('first rate limit data stores baseline without notification', () => {
+ setup(
+ { anyGitHubSession: { accessToken: 'tok' } },
+ { session: makeQuota(10) }, // 90% session used
+ );
+
+ quotaEmitter.fire();
+
+ expect(mockNotification.show).not.toHaveBeenCalled();
+ });
+
+ test('notifies when crossing a threshold from below', async () => {
+ setup(
+ { anyGitHubSession: { accessToken: 'tok' } },
+ { quotaInfo: makeQuota(60) }, // 40% used — below all thresholds
+ );
+
+ quotaEmitter.fire();
+ expect(mockNotification.show).not.toHaveBeenCalled();
+
+ (quotaService as any).quotaInfo = makeQuota(50); // 50% used
+ quotaEmitter.fire();
+ await Promise.resolve();
+
+ expect(mockNotification.show).toHaveBeenCalled();
+ expect(mockNotification.message).toBe('Credits at 50%');
+ });
+
+ test('sign-out clears baseline so next sign-in re-establishes it', () => {
+ setup(
+ {},
+ { quotaInfo: makeQuota(25) }, // 75% used
+ );
+
+ // Establish baseline
+ quotaEmitter.fire();
+ expect(mockNotification.show).not.toHaveBeenCalled();
+
+ // Sign out → prev values cleared
+ (authService as any).copilotToken = undefined;
+ authEmitter.fire();
+
+ // Sign back in — first data stores new baseline, no notification
+ (authService as any).copilotToken = { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true };
+ quotaEmitter.fire();
+ expect(mockNotification.show).not.toHaveBeenCalled();
+ });
+
+ test('late sign-in stores baseline then fires on new crossing', async () => {
+ setup({ copilotToken: undefined }, {});
+
+ // Sign in — quota data arrives at 60%
+ (authService as any).copilotToken = { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true };
+ (quotaService as any).quotaInfo = makeQuota(40); // 60% used
+ quotaEmitter.fire();
+ expect(mockNotification.show).not.toHaveBeenCalled();
+
+ // Usage crosses 75% → notification fires
+ (quotaService as any).quotaInfo = makeQuota(25);
+ quotaEmitter.fire();
+ await Promise.resolve();
+
+ expect(mockNotification.show).toHaveBeenCalled();
+ expect(mockNotification.message).toBe('Credits at 75%');
+ });
+
+ test('not signed in → 0% → sign out → 60% does not fire 50% threshold', async () => {
+ setup({ copilotToken: undefined }, {});
+
+ // Sign in at 0%
+ (authService as any).copilotToken = { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true };
+ (quotaService as any).quotaInfo = makeQuota(100); // 0% used
+ quotaEmitter.fire();
+ expect(mockNotification.show).not.toHaveBeenCalled();
+
+ // Sign out → prev cleared
+ (authService as any).copilotToken = undefined;
+ authEmitter.fire();
+
+ // Sign in at 60% — baseline stored, no notification
+ (authService as any).copilotToken = { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true };
+ (quotaService as any).quotaInfo = makeQuota(40); // 60% used
+ quotaEmitter.fire();
+ expect(mockNotification.show).not.toHaveBeenCalled();
+
+ // Usage crosses 75% → notification fires
+ (quotaService as any).quotaInfo = makeQuota(25);
+ quotaEmitter.fire();
+ await Promise.resolve();
+
+ expect(mockNotification.show).toHaveBeenCalled();
+ expect(mockNotification.message).toBe('Credits at 75%');
+ });
+
+ test('sign-out + sign-in at higher level does not fire stale crossing', () => {
+ setup(
+ {},
+ { quotaInfo: makeQuota(60) }, // 40% used
+ );
+
+ quotaEmitter.fire();
+ expect(mockNotification.show).not.toHaveBeenCalled();
+
+ // Sign out
+ (authService as any).copilotToken = undefined;
+ authEmitter.fire();
+
+ // Sign into different account at 75% — baseline stored, no notification
+ (authService as any).copilotToken = { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true };
+ (quotaService as any).quotaInfo = makeQuota(25); // 75%
+ quotaEmitter.fire();
+
+ expect(mockNotification.show).not.toHaveBeenCalled();
+ });
+ });
+
// --- basic notification lifecycle ----------------------------------------
describe('quota exhausted', () => {
@@ -253,7 +396,6 @@ describe('ChatInputNotificationContribution', () => {
quotaEmitter.fire();
expect(mockNotification.show).toHaveBeenCalled();
- // Quota replenished
(quotaService as any).quotaExhausted = false;
quotaEmitter.fire();
@@ -262,44 +404,56 @@ describe('ChatInputNotificationContribution', () => {
});
describe('quota approaching threshold', () => {
- test('shows warning at 50% used', () => {
+ test('shows warning when crossing 50% threshold', async () => {
setup(
{ anyGitHubSession: { accessToken: 'tok' } },
- { quotaInfo: makeQuota(50) }, // 50% used
+ { quotaInfo: makeQuota(60) }, // 40% used — baseline
);
quotaEmitter.fire();
+ (quotaService as any).quotaInfo = makeQuota(50); // 50% used
+ quotaEmitter.fire();
+ await Promise.resolve();
+
expect(mockNotification.show).toHaveBeenCalled();
expect(mockNotification.message).toBe('Credits at 50%');
});
- test('does not re-show the same threshold', () => {
+ test('does not re-show the same threshold', async () => {
setup(
{ anyGitHubSession: { accessToken: 'tok' } },
- { quotaInfo: makeQuota(50) },
+ { quotaInfo: makeQuota(60) }, // 40% used — baseline
);
quotaEmitter.fire();
+ (quotaService as any).quotaInfo = makeQuota(50);
+ quotaEmitter.fire();
+ await Promise.resolve();
expect(mockNotification.show).toHaveBeenCalledTimes(1);
mockNotification.show.mockClear();
quotaEmitter.fire();
+ await Promise.resolve();
expect(mockNotification.show).not.toHaveBeenCalled();
});
- test('shows higher threshold when usage increases', () => {
+ test('shows higher threshold when usage increases', async () => {
setup(
{ anyGitHubSession: { accessToken: 'tok' } },
- { quotaInfo: makeQuota(50) }, // 50% used
+ { quotaInfo: makeQuota(60) }, // 40% used — baseline
);
quotaEmitter.fire();
+ (quotaService as any).quotaInfo = makeQuota(50); // 50% used
+ quotaEmitter.fire();
+ await Promise.resolve();
expect(mockNotification.show).toHaveBeenCalledTimes(1);
mockNotification.show.mockClear();
(quotaService as any).quotaInfo = makeQuota(10); // 90% used
quotaEmitter.fire();
+ await Promise.resolve();
expect(mockNotification.show).toHaveBeenCalled();
expect(mockNotification.message).toBe('Credits at 90%');
@@ -310,9 +464,11 @@ describe('ChatInputNotificationContribution', () => {
test('shows session rate limit warning', () => {
setup(
{ anyGitHubSession: { accessToken: 'tok' } },
- { session: makeQuota(25) }, // 75% used
+ { session: makeQuota(60) }, // 40% session used — baseline
);
+ quotaEmitter.fire();
+ (quotaService as any).rateLimitInfo = { session: makeQuota(25), weekly: undefined }; // 75% used
quotaEmitter.fire();
expect(mockNotification.show).toHaveBeenCalled();
@@ -323,9 +479,11 @@ describe('ChatInputNotificationContribution', () => {
test('shows weekly rate limit warning', () => {
setup(
{ anyGitHubSession: { accessToken: 'tok' } },
- { weekly: makeQuota(10) }, // 90% used
+ { weekly: makeQuota(60) }, // 40% weekly used — baseline
);
+ quotaEmitter.fire();
+ (quotaService as any).rateLimitInfo = { session: undefined, weekly: makeQuota(10) }; // 90% used
quotaEmitter.fire();
expect(mockNotification.show).toHaveBeenCalled();
@@ -346,13 +504,17 @@ describe('ChatInputNotificationContribution', () => {
expect(mockNotification.message).toBe('Credit Limit Reached');
});
- test('threshold warning takes priority over rate limit', () => {
+ test('threshold warning takes priority over rate limit', async () => {
setup(
{ anyGitHubSession: { accessToken: 'tok' } },
- { quotaInfo: makeQuota(10), session: makeQuota(25) }, // 90% quota, 75% session
+ { quotaInfo: makeQuota(60), session: makeQuota(60) }, // 40% used — baselines
);
quotaEmitter.fire();
+ (quotaService as any).quotaInfo = makeQuota(10); // 90% quota used
+ (quotaService as any).rateLimitInfo = { session: makeQuota(25), weekly: undefined }; // 75% session used
+ quotaEmitter.fire();
+ await Promise.resolve();
expect(mockNotification.message).toBe('Credits at 90%');
});
@@ -367,8 +529,6 @@ describe('ChatInputNotificationContribution', () => {
quotaEmitter.fire();
- // User was never signed in, so no transition occurred —
- // notifications should still flow through normally.
expect(mockNotification.show).toHaveBeenCalled();
expect(mockNotification.message).toBe('Credit Limit Reached');
});
@@ -422,9 +582,11 @@ describe('ChatInputNotificationContribution', () => {
test('still shows rate limit warning for PRU user', () => {
setup(
{ copilotToken: { isFreeUser: false, isNoAuthUser: false, isManagedPlan: false, isUsageBasedBilling: false } },
- { session: makeQuota(25) }, // 75% used
+ { session: makeQuota(60) }, // 40% session used — baseline
);
+ quotaEmitter.fire();
+ (quotaService as any).rateLimitInfo = { session: makeQuota(25), weekly: undefined }; // 75% used
quotaEmitter.fire();
expect(mockNotification.show).toHaveBeenCalled();
diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts
index 205ef34bc1aac7..d432a7e6287ebe 100644
--- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts
+++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts
@@ -276,17 +276,6 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib
modelTooltip = getModelCapabilitiesDescription(endpoint);
}
- let modelCategory: { label: string; order: number } | undefined;
- if (endpoint instanceof AutoChatEndpoint) {
- modelCategory = { label: '', order: Number.MIN_SAFE_INTEGER };
- } else if (endpoint.isPremium === undefined || this._authenticationService.copilotToken?.isFreeUser) {
- modelCategory = { label: vscode.l10n.t("Copilot Models"), order: 0 };
- } else if (endpoint.isPremium) {
- modelCategory = { label: vscode.l10n.t("Premium Models"), order: 1 };
- } else {
- modelCategory = { label: vscode.l10n.t("Standard Models"), order: 0 };
- }
-
// Counting tokens requires instantiating the tokenizers, which makes this process use a lot of memory.
// Let's cache the results across extension activations
const baseCount = await this._promptBaseCountCache.getBaseCount(endpoint);
@@ -304,7 +293,6 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib
const customModel = endpoint.customModel;
modelDetail = customModel.owner_name;
modelTooltip = vscode.l10n.t('{0} is contributed by {1} using {2}.', sanitizedModelName, customModel.owner_name, customModel.key_name);
- modelCategory = { label: vscode.l10n.t("Custom Models"), order: 2 };
}
const session = this._authenticationService.anyGitHubSession;
@@ -322,7 +310,6 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib
multiplierNumeric: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.multiplier,
priceCategory: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.priceCategory,
detail: modelDetail,
- category: modelCategory,
statusIcon: endpoint.degradationReason ? new vscode.ThemeIcon('warning') : undefined,
version: endpoint.version,
maxInputTokens: endpoint.modelMaxPromptTokens - baseCount - BaseTokensPerCompletion,
diff --git a/extensions/copilot/src/extension/extension/vscode-node/services.ts b/extensions/copilot/src/extension/extension/vscode-node/services.ts
index 38c4a21925b0fd..ed3801b5d132e3 100644
--- a/extensions/copilot/src/extension/extension/vscode-node/services.ts
+++ b/extensions/copilot/src/extension/extension/vscode-node/services.ts
@@ -182,11 +182,10 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio
builder.define(IImageService, new SyncDescriptor(VSCodeImageServiceImpl));
builder.define(ITelemetryUserConfig, new SyncDescriptor(TelemetryUserConfigImpl, [undefined, undefined]));
- const internalAIKey = extensionContext.extension.packageJSON.internalAIKey ?? '';
- const internalLargeEventAIKey = extensionContext.extension.packageJSON.internalLargeStorageAriaKey ?? '';
+ const internalAIKey = extensionContext.extension.packageJSON.internalLargeStorageAriaKey ?? '';
const ariaKey = extensionContext.extension.packageJSON.ariaKey ?? '';
if (isTestMode || isScenarioAutomation) {
- setupTelemetry(builder, extensionContext, internalAIKey, internalLargeEventAIKey, ariaKey);
+ setupTelemetry(builder, extensionContext, internalAIKey, ariaKey);
// If we're in testing mode, then most code will be called from an actual test,
// and not from here. However, some objects will capture the `accessor` we pass
// here and then re-use it later. This is particularly the case for those objects
@@ -194,7 +193,7 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio
// method parameters.
builder.define(ICopilotTokenManager, getOrCreateTestingCopilotTokenManager(env.devDeviceId));
} else {
- setupTelemetry(builder, extensionContext, internalAIKey, internalLargeEventAIKey, ariaKey);
+ setupTelemetry(builder, extensionContext, internalAIKey, ariaKey);
builder.define(ICopilotTokenManager, new SyncDescriptor(VSCodeCopilotTokenManager));
}
@@ -325,13 +324,12 @@ function setupMSFTExperimentationService(builder: IInstantiationServiceBuilder,
}
}
-function setupTelemetry(builder: IInstantiationServiceBuilder, extensionContext: ExtensionContext, internalAIKey: string, internalLargeEventAIKey: string, externalAIKey: string) {
+function setupTelemetry(builder: IInstantiationServiceBuilder, extensionContext: ExtensionContext, internalAIKey: string, externalAIKey: string) {
if (ExtensionMode.Production === extensionContext.extensionMode && !isScenarioAutomation) {
builder.define(ITelemetryService, new SyncDescriptor(TelemetryService, [
extensionContext.extension.packageJSON.name,
internalAIKey,
- internalLargeEventAIKey,
externalAIKey,
APP_INSIGHTS_KEY_STANDARD,
APP_INSIGHTS_KEY_ENHANCED,
diff --git a/extensions/copilot/src/platform/authentication/common/copilotToken.ts b/extensions/copilot/src/platform/authentication/common/copilotToken.ts
index ae8968f27d4060..f20de388488f41 100644
--- a/extensions/copilot/src/platform/authentication/common/copilotToken.ts
+++ b/extensions/copilot/src/platform/authentication/common/copilotToken.ts
@@ -345,8 +345,6 @@ export interface TokenEnvelope {
codesearch: boolean;
/** Whether content exclusion (.copilotignore) is enabled. */
copilotignore_enabled: boolean;
- /** Whether VS Code electron fetcher v2 is enabled. */
- vsc_electron_fetcher_v2: boolean;
// Consent settings
/** 'enabled', 'disabled', or 'unconfigured' for public code suggestions. */
@@ -365,8 +363,6 @@ export interface TokenEnvelope {
limited_user_reset_date?: number | null;
/** Organization tracking IDs if user has org access. */
organization_list?: string[];
- /** Notification to show in editor on successful token retrieval. */
- user_notification?: NotificationEnvelope;
}
/**
@@ -419,7 +415,6 @@ const tokenEnvelopeValidator = vObj({
code_review_enabled: vBoolean(),
codesearch: vBoolean(),
copilotignore_enabled: vBoolean(),
- vsc_electron_fetcher_v2: vBoolean(),
public_suggestions: vEnum('enabled', 'disabled', 'unconfigured'),
telemetry: vEnum('enabled', 'disabled'),
endpoints: vObj({
@@ -434,8 +429,7 @@ const tokenEnvelopeValidator = vObj({
completions: vRequired(vNumber()),
})),
limited_user_reset_date: vNullable(vNumber()),
- organization_list: vArray(vString()),
- user_notification: notificationEnvelopeValidator,
+ organization_list: vArray(vString())
});
const standardErrorEnvelopeValidator = vObj({
@@ -565,7 +559,6 @@ export function createTestExtendedTokenInfo(overrides?: Partial;
}
export const IChatQuotaService = createServiceIdentifier('IChatQuotaService');
diff --git a/extensions/copilot/src/platform/chat/common/chatQuotaServiceImpl.ts b/extensions/copilot/src/platform/chat/common/chatQuotaServiceImpl.ts
index ed3bbc0eedde84..b765b6ddf3bbe8 100644
--- a/extensions/copilot/src/platform/chat/common/chatQuotaServiceImpl.ts
+++ b/extensions/copilot/src/platform/chat/common/chatQuotaServiceImpl.ts
@@ -3,11 +3,13 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
+import { RequestType } from '@vscode/copilot-api';
import { Emitter } from '../../../util/vs/base/common/event';
import { Disposable } from '../../../util/vs/base/common/lifecycle';
import { IAuthenticationService } from '../../authentication/common/authentication';
+import { ICAPIClientService } from '../../endpoint/common/capiClient';
import { ILogService } from '../../log/common/logService';
-import { IHeaders } from '../../networking/common/fetcherService';
+import { FetchOptions, IHeaders, Response } from '../../networking/common/fetcherService';
import { CopilotUserQuotaInfo, IChatQuota, IChatQuotaService, QuotaSnapshots } from './chatQuotaService';
export class ChatQuotaService extends Disposable implements IChatQuotaService {
@@ -23,6 +25,7 @@ export class ChatQuotaService extends Disposable implements IChatQuotaService {
constructor(
@IAuthenticationService private readonly _authService: IAuthenticationService,
@ILogService private readonly _logService: ILogService,
+ @ICAPIClientService private readonly _capiClientService: ICAPIClientService,
) {
super();
this._rateLimitInfo = { session: undefined, weekly: undefined };
@@ -118,6 +121,30 @@ export class ChatQuotaService extends Disposable implements IChatQuotaService {
}
}
+ async refreshQuota(): Promise {
+ const githubToken = this._authService.anyGitHubSession?.accessToken;
+ if (!githubToken) {
+ return;
+ }
+ try {
+ const options: FetchOptions = {
+ callSite: 'copilot-quota-refresh',
+ headers: {
+ Authorization: `token ${githubToken}`,
+ 'X-GitHub-Api-Version': '2025-04-01',
+ },
+ retryFallbacks: true,
+ expectJSON: true,
+ };
+ const response = await this._capiClientService.makeRequest(options, { type: RequestType.CopilotUserInfo });
+ const data: CopilotUserQuotaInfo = await response.json();
+ this._processUserInfoQuotaSnapshot(data);
+ this._logService.trace('[ChatQuota] refreshQuota: fetched up-to-date quota data');
+ } catch (error) {
+ this._logService.trace(`[ChatQuota] refreshQuota: failed to fetch quota data: ${error}`);
+ }
+ }
+
private _processHeaderValue(header: string): IChatQuota | undefined {
try {
// Parse URL encoded string into key-value pairs
diff --git a/extensions/copilot/src/platform/chat/test/common/chatQuotaServiceImpl.spec.ts b/extensions/copilot/src/platform/chat/test/common/chatQuotaServiceImpl.spec.ts
index 1adf5bc814cdc3..ae11ab0b7784a8 100644
--- a/extensions/copilot/src/platform/chat/test/common/chatQuotaServiceImpl.spec.ts
+++ b/extensions/copilot/src/platform/chat/test/common/chatQuotaServiceImpl.spec.ts
@@ -3,12 +3,20 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
-import { describe, expect, test } from 'vitest';
+import { describe, expect, test, vi } from 'vitest';
import { Emitter } from '../../../../util/vs/base/common/event';
import { IAuthenticationService } from '../../../authentication/common/authentication';
+import { ICAPIClientService } from '../../../endpoint/common/capiClient';
import { TestLogService } from '../../../testing/common/testLogService';
import { ChatQuotaService } from '../../common/chatQuotaServiceImpl';
+function createMockCapiClientService(): ICAPIClientService {
+ return {
+ _serviceBrand: undefined,
+ makeRequest: vi.fn(),
+ } as unknown as ICAPIClientService;
+}
+
function createMockAuthService(): IAuthenticationService {
return {
_serviceBrand: undefined,
@@ -46,7 +54,7 @@ type SnapshotData = { quota_id: string; entitlement: number; remaining: number;
describe('ChatQuotaService', () => {
function create() {
- return new ChatQuotaService(createMockAuthService(), new TestLogService());
+ return new ChatQuotaService(createMockAuthService(), new TestLogService(), createMockCapiClientService());
}
const TURN_A = 'turn-a';
@@ -456,7 +464,7 @@ describe('ChatQuotaService', () => {
describe('processUserInfoQuotaSnapshot via auth change', () => {
test('free user reads from chat snapshot', () => {
const { authService, emitter, setToken } = createMockAuthServiceWithEmitter({ isFreeUser: true });
- const svc = new ChatQuotaService(authService, new TestLogService());
+ const svc = new ChatQuotaService(authService, new TestLogService(), createMockCapiClientService());
setToken(makeQuotaInfo({
chat: { percent_remaining: 30, overage_permitted: false, overage_count: 0, entitlement: 100 },
@@ -474,7 +482,7 @@ describe('ChatQuotaService', () => {
test('paid user reads from premium_interactions snapshot', () => {
const { authService, emitter, setToken } = createMockAuthServiceWithEmitter({ isFreeUser: false });
- const svc = new ChatQuotaService(authService, new TestLogService());
+ const svc = new ChatQuotaService(authService, new TestLogService(), createMockCapiClientService());
setToken(makeQuotaInfo({
chat: { percent_remaining: 30, overage_permitted: false, overage_count: 0, entitlement: 100 },
@@ -492,7 +500,7 @@ describe('ChatQuotaService', () => {
test('fires onDidChange when quota is updated', () => {
const { authService, emitter, setToken } = createMockAuthServiceWithEmitter({ isFreeUser: true });
- const svc = new ChatQuotaService(authService, new TestLogService());
+ const svc = new ChatQuotaService(authService, new TestLogService(), createMockCapiClientService());
let changeCount = 0;
svc.onDidChange(() => changeCount++);
@@ -504,7 +512,7 @@ describe('ChatQuotaService', () => {
test('no-ops when copilotToken has no quotaInfo', () => {
const { authService, emitter } = createMockAuthServiceWithEmitter({ isFreeUser: true });
- const svc = new ChatQuotaService(authService, new TestLogService());
+ const svc = new ChatQuotaService(authService, new TestLogService(), createMockCapiClientService());
(authService as any).copilotToken = { isFreeUser: true, quotaInfo: undefined };
emitter.fire();
diff --git a/extensions/copilot/src/platform/telemetry/common/msftTelemetrySender.ts b/extensions/copilot/src/platform/telemetry/common/msftTelemetrySender.ts
index 0bbc00c2509dc2..a50df50a05ce73 100644
--- a/extensions/copilot/src/platform/telemetry/common/msftTelemetrySender.ts
+++ b/extensions/copilot/src/platform/telemetry/common/msftTelemetrySender.ts
@@ -17,7 +17,6 @@ export interface ITelemetryReporter extends ITelemetrySender {
export class BaseMsftTelemetrySender implements IMSFTTelemetrySender {
// Telemetry reporter used for collecting telemetry on internal Microsoft customers
protected _internalTelemetryReporter: ITelemetryReporter | undefined;
- protected _internalLargeEventTelemetryReporter: ITelemetryReporter | undefined;
private _externalTelemetryReporter: ITelemetryReporter;
protected readonly _disposables: DisposableStore = new DisposableStore();
@@ -29,9 +28,9 @@ export class BaseMsftTelemetrySender implements IMSFTTelemetrySender {
constructor(
copilotTokenStore: ICopilotTokenStore,
- private readonly _createTelemetryReporter: (internal: boolean, largeEvents: boolean) => ITelemetryReporter
+ private readonly _createTelemetryReporter: (internal: boolean) => ITelemetryReporter
) {
- this._externalTelemetryReporter = this._createTelemetryReporter(false, false);
+ this._externalTelemetryReporter = this._createTelemetryReporter(false);
this.processToken(copilotTokenStore.copilotToken);
this._disposables.add(copilotTokenStore.onDidStoreUpdate(() => this.processToken(copilotTokenStore.copilotToken)));
}
@@ -50,9 +49,6 @@ export class BaseMsftTelemetrySender implements IMSFTTelemetrySender {
properties = { ...properties, 'common.tid': this._tid, 'common.userName': this._username ?? 'undefined' };
measurements = { ...measurements, 'common.isVscodeTeamMember': this._vscodeTeamMember ? 1 : 0 };
this._internalTelemetryReporter.sendRawTelemetryEvent(eventName, properties, measurements);
- if (this._internalLargeEventTelemetryReporter) { // Also duplicate events to the large data store for testing of the pipeline
- this._internalLargeEventTelemetryReporter.sendRawTelemetryEvent(eventName, properties, measurements);
- }
}
/**
@@ -106,15 +102,12 @@ export class BaseMsftTelemetrySender implements IMSFTTelemetrySender {
this._isInternal = !!token?.isInternal;
if (this._isInternal) {
- this._internalTelemetryReporter ??= this._createTelemetryReporter(true, false);
- this._internalLargeEventTelemetryReporter ??= this._createTelemetryReporter(true, true);
+ this._internalTelemetryReporter ??= this._createTelemetryReporter(true);
}
if (!token || !this._isInternal) {
this._internalTelemetryReporter?.dispose();
this._internalTelemetryReporter = undefined;
- this._internalLargeEventTelemetryReporter?.dispose();
- this._internalLargeEventTelemetryReporter = undefined;
return;
}
}
diff --git a/extensions/copilot/src/platform/telemetry/test/node/telemetry.spec.ts b/extensions/copilot/src/platform/telemetry/test/node/telemetry.spec.ts
index 07e443c86644be..21ee3eee166fcc 100644
--- a/extensions/copilot/src/platform/telemetry/test/node/telemetry.spec.ts
+++ b/extensions/copilot/src/platform/telemetry/test/node/telemetry.spec.ts
@@ -97,7 +97,7 @@ suite('Microsoft Telemetry Sender', function () {
test('should send internal telemetry event', () => {
sender.sendInternalTelemetryEvent('testInternalEvent', { foo: 'bar' }, { 'testMeasure': 1 });
- expect(mockInternalReporter.sendRawTelemetryEvent).toHaveBeenCalledTimes(2);
+ expect(mockInternalReporter.sendRawTelemetryEvent).toHaveBeenCalledOnce();
expect(mockInternalReporter.sendRawTelemetryEvent).toHaveBeenCalledWith(
'testInternalEvent',
{ foo: 'bar', 'common.tid': 'testTid', 'common.userName': 'testUser' },
diff --git a/extensions/copilot/src/platform/telemetry/vscode-node/microsoftTelemetrySender.ts b/extensions/copilot/src/platform/telemetry/vscode-node/microsoftTelemetrySender.ts
index e6d45bd0a969e0..1271f990629dd4 100644
--- a/extensions/copilot/src/platform/telemetry/vscode-node/microsoftTelemetrySender.ts
+++ b/extensions/copilot/src/platform/telemetry/vscode-node/microsoftTelemetrySender.ts
@@ -10,16 +10,13 @@ import { BaseMsftTelemetrySender } from '../common/msftTelemetrySender';
export class MicrosoftTelemetrySender extends BaseMsftTelemetrySender {
constructor(
internalAIKey: string,
- internalLargeEventAIKey: string,
externalAIKey: string,
tokenStore: ICopilotTokenStore,
customFetcher: CustomFetcher
) {
- const telemetryReporterFactory = (internal: boolean, largeEventReporter: boolean) => {
- if (internal && !largeEventReporter) {
+ const telemetryReporterFactory = (internal: boolean) => {
+ if (internal) {
return new TelemetryReporter(internalAIKey, undefined, undefined, customFetcher);
- } else if (internal && largeEventReporter) {
- return new TelemetryReporter(internalLargeEventAIKey, undefined, undefined, customFetcher);
} else {
return new TelemetryReporter(externalAIKey, undefined, undefined, customFetcher);
}
diff --git a/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts b/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts
index d5fd0384b9bc9e..b03c5e1a747d76 100644
--- a/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts
+++ b/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts
@@ -23,7 +23,6 @@ export class TelemetryService extends BaseTelemetryService {
constructor(
extensionName: string,
internalMSFTAIKey: string,
- internalLargeEventMSFTAIKey: string,
externalMSFTAIKey: string,
externalGHAIKey: string,
estrictedGHAIKey: string,
@@ -46,7 +45,6 @@ export class TelemetryService extends BaseTelemetryService {
};
const microsoftTelemetrySender = new MicrosoftTelemetrySender(
internalMSFTAIKey,
- internalLargeEventMSFTAIKey,
externalMSFTAIKey,
tokenStore,
customFetcher
diff --git a/extensions/markdown-language-features/media/markdown.css b/extensions/markdown-language-features/media/markdown.css
index 5a02627888dd71..53179e10f816a5 100644
--- a/extensions/markdown-language-features/media/markdown.css
+++ b/extensions/markdown-language-features/media/markdown.css
@@ -453,3 +453,47 @@ pre {
.vscode-dark td {
border-color: rgba(255, 255, 255, 0.18);
}
+
+/* Front matter rendering */
+table.frontmatter {
+ margin-bottom: 16px;
+ border-collapse: collapse;
+}
+
+table.frontmatter th,
+table.frontmatter td {
+ padding: 6px 13px;
+ border: 1px solid var(--vscode-widget-border, rgba(127, 127, 127, 0.35));
+ text-align: left;
+ vertical-align: top;
+}
+
+table.frontmatter th {
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+table.frontmatter td > ul {
+ margin: 0;
+ padding-left: 1.2em;
+}
+
+pre.frontmatter {
+ margin-bottom: 16px;
+}
+
+.frontmatter-error {
+ margin-bottom: 16px;
+ padding: 8px 13px;
+ border-left: 4px solid var(--vscode-editorError-foreground, #f48771);
+ background: var(--vscode-inputValidation-errorBackground, rgba(244, 135, 113, 0.1));
+ color: var(--vscode-editorError-foreground, #f48771);
+}
+
+.frontmatter-error pre {
+ margin: 6px 0 0;
+ white-space: pre-wrap;
+ color: inherit;
+ background: transparent;
+ padding: 0;
+}
diff --git a/extensions/markdown-language-features/package-lock.json b/extensions/markdown-language-features/package-lock.json
index 15fed36ad63190..15c5980ba38648 100644
--- a/extensions/markdown-language-features/package-lock.json
+++ b/extensions/markdown-language-features/package-lock.json
@@ -13,14 +13,14 @@
"dompurify": "^3.4.1",
"highlight.js": "^11.8.0",
"markdown-it": "^12.3.2",
- "markdown-it-front-matter": "^0.2.4",
"morphdom": "^2.7.7",
"picomatch": "^2.3.2",
"punycode": "^2.3.1",
"vscode-languageclient": "^8.0.2",
"vscode-languageserver-textdocument": "^1.0.11",
"vscode-markdown-languageserver": "0.5.0-alpha.15",
- "vscode-uri": "^3.0.3"
+ "vscode-uri": "^3.0.3",
+ "yaml": "^2.8.3"
},
"devDependencies": {
"@types/dompurify": "^3.0.5",
@@ -494,11 +494,6 @@
"markdown-it": "bin/markdown-it.js"
}
},
- "node_modules/markdown-it-front-matter": {
- "version": "0.2.4",
- "resolved": "https://registry.npmjs.org/markdown-it-front-matter/-/markdown-it-front-matter-0.2.4.tgz",
- "integrity": "sha512-25GUs0yjS2hLl8zAemVndeEzThB1p42yxuDEKbd4JlL3jiz+jsm6e56Ya8B0VREOkNxLYB4TTwaoPJ3ElMmW+w=="
- },
"node_modules/markdown-it/node_modules/entities": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz",
@@ -730,6 +725,21 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
+ },
+ "node_modules/yaml": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
}
}
}
diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json
index cd2e94bbb48b76..d8232ce2219279 100644
--- a/extensions/markdown-language-features/package.json
+++ b/extensions/markdown-language-features/package.json
@@ -771,6 +771,22 @@
"%configuration.markdown.preview.openMarkdownLinks.inPreview%",
"%configuration.markdown.preview.openMarkdownLinks.inEditor%"
]
+ },
+ "markdown.preview.frontMatter": {
+ "type": "string",
+ "default": "table",
+ "scope": "resource",
+ "markdownDescription": "%configuration.markdown.preview.frontMatter.description%",
+ "enum": [
+ "hide",
+ "codeBlock",
+ "table"
+ ],
+ "enumDescriptions": [
+ "%configuration.markdown.preview.frontMatter.hide%",
+ "%configuration.markdown.preview.frontMatter.codeBlock%",
+ "%configuration.markdown.preview.frontMatter.table%"
+ ]
}
}
},
@@ -860,14 +876,14 @@
"dompurify": "^3.4.1",
"highlight.js": "^11.8.0",
"markdown-it": "^12.3.2",
- "markdown-it-front-matter": "^0.2.4",
"morphdom": "^2.7.7",
"picomatch": "^2.3.2",
"punycode": "^2.3.1",
"vscode-languageclient": "^8.0.2",
"vscode-languageserver-textdocument": "^1.0.11",
"vscode-markdown-languageserver": "0.5.0-alpha.15",
- "vscode-uri": "^3.0.3"
+ "vscode-uri": "^3.0.3",
+ "yaml": "^2.8.3"
},
"devDependencies": {
"@types/dompurify": "^3.0.5",
diff --git a/extensions/markdown-language-features/package.nls.json b/extensions/markdown-language-features/package.nls.json
index dc8f65048c998d..b4ac4c7f58a78d 100644
--- a/extensions/markdown-language-features/package.nls.json
+++ b/extensions/markdown-language-features/package.nls.json
@@ -37,6 +37,10 @@
"configuration.markdown.preview.openMarkdownLinks.description": "Controls how links to other Markdown files in the Markdown preview should be opened.",
"configuration.markdown.preview.openMarkdownLinks.inEditor": "Try to open links in the editor.",
"configuration.markdown.preview.openMarkdownLinks.inPreview": "Try to open links in the Markdown preview.",
+ "configuration.markdown.preview.frontMatter.description": "Controls how YAML front matter (delimited by `---`) at the start of a Markdown file is rendered in the preview.",
+ "configuration.markdown.preview.frontMatter.hide": "Do not render front matter.",
+ "configuration.markdown.preview.frontMatter.codeBlock": "Render front matter as a code block.",
+ "configuration.markdown.preview.frontMatter.table": "Render front matter as a table of keys and values.",
"configuration.markdown.links.openLocation.description": "Controls where links in Markdown files should be opened.",
"configuration.markdown.links.openLocation.currentGroup": "Open links in the active editor group.",
"configuration.markdown.links.openLocation.beside": "Open links beside the active editor.",
diff --git a/extensions/markdown-language-features/src/extensions/yamlPreamble/yamlPreamble.ts b/extensions/markdown-language-features/src/extensions/yamlPreamble/yamlPreamble.ts
new file mode 100644
index 00000000000000..417386e232e40e
--- /dev/null
+++ b/extensions/markdown-language-features/src/extensions/yamlPreamble/yamlPreamble.ts
@@ -0,0 +1,200 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import type MarkdownIt from 'markdown-it';
+import type Token from 'markdown-it/lib/token.mjs';
+import * as vscode from 'vscode';
+import * as yaml from 'yaml';
+import { escapeHtml } from '../../util/dom';
+
+export type FrontMatterRenderStyle = 'hide' | 'codeBlock' | 'table';
+
+const FRONT_MATTER_TOKEN = 'front_matter';
+const MARKER = '---';
+
+interface IFrontMatterMeta {
+ readonly content: string;
+}
+
+/**
+ * Extends a `markdown-it` instance with parsing and rendering support for YAML
+ * front matter at the start of a Markdown document.
+ *
+ * Front matter is delimited by lines containing only `---`. How (or whether) the parsed
+ * front matter is rendered in the preview is controlled by the `markdown.preview.frontMatter`
+ * setting.
+ */
+export function extendMarkdownIt(md: MarkdownIt): MarkdownIt {
+ md.block.ruler.before('fence', FRONT_MATTER_TOKEN, frontMatterRule, {
+ alt: ['paragraph', 'reference', 'blockquote', 'list']
+ });
+
+ md.renderer.rules[FRONT_MATTER_TOKEN] = renderFrontMatter;
+
+ return md;
+}
+
+const frontMatterRule = (state: MarkdownIt.StateBlock, startLine: number, endLine: number, silent: boolean): boolean => {
+ if (startLine !== 0 || state.tShift[startLine] !== 0) {
+ return false;
+ }
+
+ const firstLineStart = state.bMarks[startLine];
+ const firstLineEnd = state.eMarks[startLine];
+ const firstLine = state.src.slice(firstLineStart, firstLineEnd).replace(/\s+$/, '');
+
+ if (firstLine !== MARKER) {
+ return false;
+ }
+
+ let nextLine = startLine + 1;
+ let foundEnd = false;
+ for (; nextLine < endLine; nextLine++) {
+ if (state.tShift[nextLine] !== 0) {
+ continue;
+ }
+ const lineStart = state.bMarks[nextLine];
+ const lineEnd = state.eMarks[nextLine];
+ const line = state.src.slice(lineStart, lineEnd).replace(/\s+$/, '');
+ if (line === MARKER) {
+ foundEnd = true;
+ break;
+ }
+ }
+
+ if (!foundEnd) {
+ return false;
+ }
+
+ if (silent) {
+ return true;
+ }
+
+ const contentStart = state.bMarks[startLine + 1];
+ const contentEnd = state.bMarks[nextLine];
+ const rawContent = state.src.slice(contentStart, contentEnd).replace(/\n$/, '');
+
+ const token = state.push(FRONT_MATTER_TOKEN, '', 0);
+ token.block = true;
+ token.hidden = false;
+ token.markup = MARKER;
+ token.map = [startLine, nextLine + 1];
+ const meta: IFrontMatterMeta = { content: rawContent };
+ token.meta = meta;
+
+ state.line = nextLine + 1;
+ return true;
+};
+
+function renderFrontMatter(tokens: Token[], idx: number, options: MarkdownIt.Options, env: unknown): string {
+ const meta = tokens[idx].meta as IFrontMatterMeta | undefined;
+ if (!meta) {
+ return '';
+ }
+
+ const currentDocument = (env as { currentDocument?: vscode.Uri } | undefined)?.currentDocument;
+ const style = getFrontMatterRenderStyle(currentDocument);
+
+ switch (style) {
+ case 'codeBlock':
+ return renderAsCodeBlock(meta, options);
+ case 'table':
+ return renderAsTable(meta);
+ case 'hide':
+ default:
+ return '';
+ }
+}
+
+function getFrontMatterRenderStyle(resource: vscode.Uri | undefined): FrontMatterRenderStyle {
+ const config = vscode.workspace.getConfiguration('markdown', resource ?? null);
+ const value = config.get('preview.frontMatter', 'table');
+ switch (value) {
+ case 'codeBlock':
+ case 'table':
+ case 'hide':
+ return value;
+ default:
+ return 'table';
+ }
+}
+
+function renderAsCodeBlock(meta: IFrontMatterMeta, options: MarkdownIt.Options): string {
+ let highlighted: string | undefined;
+ if (typeof options.highlight === 'function') {
+ try {
+ highlighted = options.highlight(meta.content, 'yaml', '') || undefined;
+ } catch {
+ highlighted = undefined;
+ }
+ }
+ if (highlighted?.startsWith('${body} \n`;
+}
+
+function renderAsTable(meta: IFrontMatterMeta): string {
+ const result = parseEntries(meta);
+ if (result.error !== undefined) {
+ return renderError(result.error);
+ }
+ if (!result.entries.length) {
+ return '';
+ }
+ const rows = result.entries.map(([key, value]) =>
+ `${escapeHtml(key)} ${formatValueHtml(value)} `
+ ).join('');
+ return `\n`;
+}
+
+function renderError(message: string): string {
+ const label = vscode.l10n.t('Failed to parse front matter');
+ return `${escapeHtml(label)} ${escapeHtml(message)} \n`;
+}
+
+interface IParseResult {
+ readonly entries: readonly [string, unknown][];
+ readonly error?: string;
+}
+
+function parseEntries(meta: IFrontMatterMeta): IParseResult {
+ try {
+ const parsed = yaml.parse(meta.content);
+ if (parsed === null || parsed === undefined) {
+ return { entries: [] };
+ }
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return { entries: [['', parsed]] };
+ }
+ return { entries: Object.entries(parsed as Record) };
+ } catch (e) {
+ return { entries: [], error: e instanceof Error ? e.message : String(e) };
+ }
+}
+
+function formatValueHtml(value: unknown): string {
+ if (value === null || value === undefined) {
+ return '';
+ }
+ if (Array.isArray(value)) {
+ if (!value.length) {
+ return '';
+ }
+ return `${value.map(v => `${formatValueHtml(v)} `).join('')} `;
+ }
+ if (typeof value === 'object') {
+ return `${escapeHtml(yaml.stringify(value).trimEnd())}`;
+ }
+ return escapeHtml(formatScalar(value));
+}
+
+function formatScalar(value: unknown): string {
+ if (value instanceof Date) {
+ return value.toISOString();
+ }
+ return String(value);
+}
diff --git a/extensions/markdown-language-features/src/markdownEngine.ts b/extensions/markdown-language-features/src/markdownEngine.ts
index 4ed3186c3807d4..d1aacf9a8b1716 100644
--- a/extensions/markdown-language-features/src/markdownEngine.ts
+++ b/extensions/markdown-language-features/src/markdownEngine.ts
@@ -5,6 +5,7 @@
import type MarkdownIt from 'markdown-it';
import * as vscode from 'vscode';
+import { extendMarkdownIt as extendMarkdownItWithFrontMatter } from './extensions/yamlPreamble/yamlPreamble';
import { ILogger } from './logging';
import { MarkdownContributionProvider } from './markdownExtensions';
import { MarkdownPreviewConfiguration } from './preview/previewConfig';
@@ -144,20 +145,7 @@ export class MarkdownItEngine implements IMdParser {
}
}
- const frontMatterPlugin = await import('markdown-it-front-matter');
- // Extract rules from front matter plugin and apply at a lower precedence
- let fontMatterRule: any;
- frontMatterPlugin.default({
- block: {
- ruler: {
- before: (_id: any, _id2: any, rule: any) => { fontMatterRule = rule; }
- }
- }
- }, () => { /* noop */ });
-
- md.block.ruler.before('fence', 'front_matter', fontMatterRule, {
- alt: ['paragraph', 'reference', 'blockquote', 'list']
- });
+ md = extendMarkdownItWithFrontMatter(md);
this.#addImageRenderer(md);
this.#addFencedRenderer(md);
diff --git a/extensions/markdown-language-features/src/preview/previewConfig.ts b/extensions/markdown-language-features/src/preview/previewConfig.ts
index ac240b97eb71cb..821bbb7840595c 100644
--- a/extensions/markdown-language-features/src/preview/previewConfig.ts
+++ b/extensions/markdown-language-features/src/preview/previewConfig.ts
@@ -17,6 +17,7 @@ export class MarkdownPreviewConfiguration {
public readonly previewLineBreaks: boolean;
public readonly previewLinkify: boolean;
public readonly previewTypographer: boolean;
+ public readonly previewFrontMatter: string;
public readonly doubleClickToSwitchToEditor: boolean;
public readonly scrollEditorWithPreview: boolean;
@@ -46,6 +47,7 @@ export class MarkdownPreviewConfiguration {
this.previewLineBreaks = !!markdownConfig.get('preview.breaks', false);
this.previewLinkify = !!markdownConfig.get('preview.linkify', true);
this.previewTypographer = !!markdownConfig.get('preview.typographer', false);
+ this.previewFrontMatter = markdownConfig.get('preview.frontMatter', 'table');
this.doubleClickToSwitchToEditor = !!markdownConfig.get('preview.doubleClickToSwitchToEditor', true);
this.markEditorSelection = !!markdownConfig.get('preview.markEditorSelection', true);
diff --git a/extensions/markdown-language-features/src/test/engine.test.ts b/extensions/markdown-language-features/src/test/engine.test.ts
index cd3322f704ae95..b9f980e19fe430 100644
--- a/extensions/markdown-language-features/src/test/engine.test.ts
+++ b/extensions/markdown-language-features/src/test/engine.test.ts
@@ -49,4 +49,73 @@ suite('markdown.engine', () => {
assert.deepStrictEqual([...result.containingImages], ['img.png', 'http://example.org/img.png', './img2.png']);
});
});
+
+ suite('front-matter', () => {
+ const settingName = 'preview.frontMatter';
+ const input = '---\ntitle: Hello\n---\n\n# World';
+
+ let originalValue: string | undefined;
+
+ suiteSetup(() => {
+ originalValue = vscode.workspace.getConfiguration('markdown').inspect(settingName)?.globalValue;
+ });
+
+ suiteTeardown(async () => {
+ await vscode.workspace.getConfiguration('markdown').update(settingName, originalValue, vscode.ConfigurationTarget.Global);
+ });
+
+ async function setStyle(style: string) {
+ await vscode.workspace.getConfiguration('markdown').update(settingName, style, vscode.ConfigurationTarget.Global);
+ }
+
+ test('Hides front matter when style is "hide"', async () => {
+ await setStyle('hide');
+ const engine = createNewMarkdownEngine();
+ assert.strictEqual(
+ (await engine.render(input)).html,
+ 'World \n'
+ );
+ });
+
+ test('Renders front matter as a code block when style is "codeBlock"', async () => {
+ await setStyle('codeBlock');
+ const engine = createNewMarkdownEngine();
+ const html = (await engine.render(input)).html;
+ assert.match(html, /]*class="[^"]*frontmatter[^"]*"[^>]*>[\s\S]*<\/pre>/);
+ assert.ok(html.includes('title'), `Expected front matter content to be rendered. Got: ${html}`);
+ assert.ok(html.includes(' {
+ await setStyle('table');
+ const engine = createNewMarkdownEngine();
+ assert.strictEqual(
+ (await engine.render(input)).html,
+ ' \n'
+ + 'World \n'
+ );
+ });
+
+ test('Shows an error when front matter has invalid YAML', async () => {
+ await setStyle('table');
+ const engine = createNewMarkdownEngine();
+ const html = (await engine.render('---\nfoo: [unclosed\n---\n\n# Body')).html;
+ assert.match(html, //);
+ assert.ok(html.includes('
{
+ await setStyle('table');
+ const engine = createNewMarkdownEngine();
+ const html = (await engine.render('# World\n\n---\ntitle: Hello\n---')).html;
+ assert.ok(!html.includes(' '), `Expected no front matter table. Got: ${html}`);
+ });
+
+ test('Ignores front matter without a closing delimiter', async () => {
+ await setStyle('table');
+ const engine = createNewMarkdownEngine();
+ const html = (await engine.render('---\ntitle: Hello\n\n# World')).html;
+ assert.ok(!html.includes(''), `Expected no front matter table. Got: ${html}`);
+ });
+ });
});
diff --git a/extensions/markdown-language-features/src/typings/ref.d.ts b/extensions/markdown-language-features/src/typings/ref.d.ts
deleted file mode 100644
index d16ef8dc397018..00000000000000
--- a/extensions/markdown-language-features/src/typings/ref.d.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-/*---------------------------------------------------------------------------------------------
- * Copyright (c) Microsoft Corporation. All rights reserved.
- * Licensed under the MIT License. See License.txt in the project root for license information.
- *--------------------------------------------------------------------------------------------*/
-
-declare module 'markdown-it-front-matter';
diff --git a/extensions/markdown-language-features/src/util/dom.ts b/extensions/markdown-language-features/src/util/dom.ts
index 16c825c68ff577..68cd47cdbf16c1 100644
--- a/extensions/markdown-language-features/src/util/dom.ts
+++ b/extensions/markdown-language-features/src/util/dom.ts
@@ -11,3 +11,12 @@ export function escapeAttribute(value: string | vscode.Uri): string {
.replace(/'/g, ''');
}
+export function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
diff --git a/extensions/mermaid-chat-features/package.json b/extensions/mermaid-chat-features/package.json
index 592f55d370259e..f7f74fb4ed396d 100644
--- a/extensions/mermaid-chat-features/package.json
+++ b/extensions/mermaid-chat-features/package.json
@@ -84,6 +84,9 @@
"viewType": "vscode.chat-mermaid-features.chatOutputItem",
"mimeTypes": [
"text/vnd.mermaid"
+ ],
+ "codeBlockLanguageIdentifiers": [
+ "mermaid"
]
}
],
diff --git a/package-lock.json b/package-lock.json
index 8d2dea8215a6c2..59778f0b112235 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8488,9 +8488,9 @@
"dev": true
},
"node_modules/fast-uri": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
- "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
+ "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"dev": true,
"funding": [
{
@@ -11291,9 +11291,9 @@
}
},
"node_modules/hono": {
- "version": "4.12.14",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
- "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
+ "version": "4.12.18",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
+ "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts
index 32ef81ec820710..da9869f11f4c93 100644
--- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts
+++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts
@@ -78,7 +78,7 @@ export class InlineCompletionsModel extends Disposable {
return false;
}
- return isSuggestionInViewport(this._editor, state.inlineSuggestion);
+ return isSuggestionInViewport(this._editor, state.inlineSuggestion, reader);
});
public get isAcceptingPartially() { return this._isAcceptingPartially; }
diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css
index 05324b5ba014ee..11577533b56f0a 100644
--- a/src/vs/platform/actionWidget/browser/actionWidget.css
+++ b/src/vs/platform/actionWidget/browser/actionWidget.css
@@ -89,6 +89,7 @@
-webkit-user-select: none;
user-select: none;
border-radius: 0;
+ pointer-events: none;
}
.action-widget .monaco-scrollable-element .monaco-list-rows .monaco-list-row.separator:hover,
diff --git a/src/vs/platform/agentHost/node/agentHostHeadlessTerminal.ts b/src/vs/platform/agentHost/node/agentHostHeadlessTerminal.ts
index 2e7d8f4e94523f..32fe36c2138d66 100644
--- a/src/vs/platform/agentHost/node/agentHostHeadlessTerminal.ts
+++ b/src/vs/platform/agentHost/node/agentHostHeadlessTerminal.ts
@@ -4,7 +4,8 @@
*--------------------------------------------------------------------------------------------*/
import { Emitter, Event } from '../../../base/common/event.js';
-import { Disposable } from '../../../base/common/lifecycle.js';
+import { DeferredPromise } from '../../../base/common/async.js';
+import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js';
import type { ILogService } from '../../log/common/log.js';
import pkg from '@xterm/headless';
@@ -91,6 +92,32 @@ export class AgentHostHeadlessTerminal extends Disposable {
return this._terminal.modes.bracketedPasteMode;
}
+ isInAltBuffer(): boolean {
+ return this._terminal.buffer.active === this._terminal.buffer.alternate;
+ }
+
+ createAltBufferPromise(store: DisposableStore): Promise {
+ const deferred = new DeferredPromise();
+ const complete = () => {
+ if (!deferred.isSettled) {
+ this._logService.debug('[AgentHostHeadlessTerminal] Detected alternate buffer entry');
+ deferred.complete();
+ }
+ };
+
+ if (this.isInAltBuffer()) {
+ complete();
+ } else {
+ store.add(this._terminal.buffer.onBufferChange(() => {
+ if (this.isInAltBuffer()) {
+ complete();
+ }
+ }));
+ }
+
+ return deferred.p;
+ }
+
clear(): void {
// xterm.clear() preserves the visible line content; emulate a terminal
// clear sequence so future terminal-state reads match a user-visible clear.
diff --git a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts
index 1d745b376950fc..a20c31e941cb21 100644
--- a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts
+++ b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts
@@ -113,6 +113,7 @@ export interface IAgentHostTerminalManager {
onExit(uri: string, cb: (exitCode: number) => void): IDisposable;
onClaimChanged(uri: string, cb: (claim: TerminalClaim) => void): IDisposable;
onCommandFinished(uri: string, cb: (event: ICommandFinishedEvent) => void): IDisposable;
+ createAltBufferPromise(uri: string, store: DisposableStore): Promise;
getContent(uri: string): string | undefined;
getClaim(uri: string): TerminalClaim | undefined;
hasTerminal(uri: string): boolean;
@@ -503,6 +504,14 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe
return terminal.onCommandFinishedEmitter.event(cb);
}
+ createAltBufferPromise(uri: string, store: DisposableStore): Promise {
+ const terminal = this._terminals.get(uri);
+ if (!terminal?.headlessTerminal) {
+ return new Promise(() => { });
+ }
+ return terminal.headlessTerminal.createAltBufferPromise(store);
+ }
+
/** Get accumulated scrollback content for a terminal as raw text. */
getContent(uri: string): string | undefined {
const terminal = this._terminals.get(uri);
diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts
index f3ee37ccb25f6e..c4813fa8f47800 100644
--- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts
+++ b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts
@@ -29,6 +29,7 @@ const DEFAULT_TIMEOUT_MS = 120_000;
* The full sentinel format is: `<<_EXIT_>>`.
*/
const SENTINEL_PREFIX = '<< | undefined;
/** Set of shell ids currently executing a command and unsafe to share. */
private readonly _busyShellIds = new Set();
+ /** Release listeners for shells held after a tool returns while the command is still running. */
+ private readonly _heldShellReleaseListeners = new Map();
private readonly _onDidAssociateTerminal = this._register(new Emitter<{ toolCallId: string; terminalUri: string; displayName: string }>());
readonly onDidAssociateTerminal: Event<{ toolCallId: string; terminalUri: string; displayName: string }> = this._onDidAssociateTerminal.event;
@@ -114,6 +117,10 @@ export class ShellManager extends Disposable {
super();
this._register(toDisposable(() => {
+ for (const store of this._heldShellReleaseListeners.values()) {
+ store.dispose();
+ }
+ this._heldShellReleaseListeners.clear();
for (const shell of this._shells.values()) {
if (this._terminalManager.hasTerminal(shell.terminalUri)) {
this._terminalManager.disposeTerminal(shell.terminalUri);
@@ -212,6 +219,22 @@ export class ShellManager extends Disposable {
};
}
+ holdShellUntilCommandFinishes(shell: IManagedShell): void {
+ if (this._heldShellReleaseListeners.has(shell.id)) {
+ return;
+ }
+
+ const store = new DisposableStore();
+ const release = () => {
+ this._busyShellIds.delete(shell.id);
+ this._heldShellReleaseListeners.delete(shell.id);
+ store.dispose();
+ };
+ store.add(this._terminalManager.onCommandFinished(shell.terminalUri, release));
+ store.add(this._terminalManager.onExit(shell.terminalUri, release));
+ this._heldShellReleaseListeners.set(shell.id, store);
+ }
+
private _trackToolCall(toolCallId: string, shellId: string): void {
this._toolCallShells.set(toolCallId, shellId);
const shell = this._shells.get(shellId);
@@ -248,6 +271,8 @@ export class ShellManager extends Disposable {
if (!shell) {
return false;
}
+ this._heldShellReleaseListeners.get(id)?.dispose();
+ this._heldShellReleaseListeners.delete(id);
this._terminalManager.disposeTerminal(shell.terminalUri);
this._shells.delete(id);
this._busyShellIds.delete(id);
@@ -323,6 +348,11 @@ function prepareOutputForModel(rawOutput: string): string {
// Tool implementations
// ---------------------------------------------------------------------------
+interface IShellExecutionResult {
+ readonly toolResult: ToolResultObject;
+ readonly keepShellBusy?: boolean;
+}
+
function makeSuccessResult(text: string): ToolResultObject {
return { textResultForLlm: text, resultType: 'success' };
}
@@ -331,19 +361,47 @@ function makeFailureResult(text: string, error?: string): ToolResultObject {
return { textResultForLlm: text, resultType: 'failure', error };
}
+function makeExecutionResult(toolResult: ToolResultObject, options?: { keepShellBusy?: boolean }): IShellExecutionResult {
+ return { toolResult, keepShellBusy: options?.keepShellBusy };
+}
+
+function makeBackgroundExecutionResult(text: string): IShellExecutionResult {
+ return makeExecutionResult(makeSuccessResult(text), { keepShellBusy: true });
+}
+
+function makeAltBufferExecutionResult(): IShellExecutionResult {
+ return makeExecutionResult(makeFailureResult(ALT_BUFFER_MESSAGE, 'alternateBuffer'), { keepShellBusy: true });
+}
+
+function registerAltBufferHandler(
+ shell: IManagedShell,
+ terminalManager: IAgentHostTerminalManager,
+ logService: ILogService,
+ disposables: DisposableStore,
+ finish: (result: IShellExecutionResult) => void,
+): void {
+ void terminalManager.createAltBufferPromise(shell.terminalUri, disposables).then(() => {
+ logService.info('[ShellTool] Command entered alternate buffer');
+ finish(makeAltBufferExecutionResult());
+ });
+}
+
async function executeCommandInShell(
shell: IManagedShell,
command: string,
timeoutMs: number,
terminalManager: IAgentHostTerminalManager,
logService: ILogService,
-): Promise {
+): Promise {
const result = terminalManager.supportsCommandDetection(shell.terminalUri)
? await executeCommandWithShellIntegration(shell, command, timeoutMs, terminalManager, logService)
: await executeCommandWithSentinel(shell, command, timeoutMs, terminalManager, logService);
return {
...result,
- textResultForLlm: `Shell ID: ${shell.id}\n${result.textResultForLlm}`,
+ toolResult: {
+ ...result.toolResult,
+ textResultForLlm: `Shell ID: ${shell.id}\n${result.toolResult.textResultForLlm}`,
+ },
};
}
@@ -358,17 +416,12 @@ async function executeCommandWithShellIntegration(
timeoutMs: number,
terminalManager: IAgentHostTerminalManager,
logService: ILogService,
-): Promise {
+): Promise {
const disposables = new DisposableStore();
- await terminalManager.sendText(shell.terminalUri, `${prefixForHistorySuppression(shell.shellType)}${command}`, {
- shouldExecute: true,
- bracketedPasteMode: shouldUseBracketedPasteMode(command),
- });
-
- return new Promise(resolve => {
+ const result = new Promise(resolve => {
let resolved = false;
- const finish = (result: ToolResultObject) => {
+ const finish = (result: IShellExecutionResult) => {
if (resolved) {
return;
}
@@ -382,23 +435,25 @@ async function executeCommandWithShellIntegration(
const exitCode = event.exitCode ?? 0;
logService.info(`[ShellTool] Command completed (shell integration) with exit code ${exitCode}`);
if (exitCode === 0) {
- finish(makeSuccessResult(`Exit code: ${exitCode}\n${output}`));
+ finish(makeExecutionResult(makeSuccessResult(`Exit code: ${exitCode}\n${output}`)));
} else {
- finish(makeFailureResult(`Exit code: ${exitCode}\n${output}`));
+ finish(makeExecutionResult(makeFailureResult(`Exit code: ${exitCode}\n${output}`)));
}
}));
+ registerAltBufferHandler(shell, terminalManager, logService, disposables, finish);
+
disposables.add(terminalManager.onExit(shell.terminalUri, (exitCode: number) => {
logService.info(`[ShellTool] Shell exited unexpectedly with code ${exitCode}`);
const fullContent = terminalManager.getContent(shell.terminalUri) ?? '';
const output = prepareOutputForModel(fullContent);
- finish(makeFailureResult(`Shell exited with code ${exitCode}\n${output}`));
+ finish(makeExecutionResult(makeFailureResult(`Shell exited with code ${exitCode}\n${output}`)));
}));
disposables.add(terminalManager.onClaimChanged(shell.terminalUri, (claim) => {
if (claim.kind === TerminalClaimKind.Session && !claim.toolCallId) {
logService.info(`[ShellTool] Continuing in background (claim narrowed)`);
- finish(makeSuccessResult('The user chose to continue this command in the background. The terminal is still running.'));
+ finish(makeBackgroundExecutionResult('The user chose to continue this command in the background. The terminal is still running.'));
}
}));
@@ -406,13 +461,26 @@ async function executeCommandWithShellIntegration(
logService.warn(`[ShellTool] Command timed out after ${timeoutMs}ms`);
const fullContent = terminalManager.getContent(shell.terminalUri) ?? '';
const output = prepareOutputForModel(fullContent);
- finish(makeFailureResult(
+ finish(makeExecutionResult(makeFailureResult(
`Command timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${output}`,
'timeout',
- ));
+ )));
}, timeoutMs);
disposables.add(toDisposable(() => clearTimeout(timer)));
+
});
+
+ try {
+ await terminalManager.sendText(shell.terminalUri, `${prefixForHistorySuppression(shell.shellType)}${command}`, {
+ shouldExecute: true,
+ bracketedPasteMode: shouldUseBracketedPasteMode(command),
+ });
+ } catch (err) {
+ disposables.dispose();
+ throw err;
+ }
+
+ return result;
}
/**
@@ -425,7 +493,7 @@ async function executeCommandWithSentinel(
timeoutMs: number,
terminalManager: IAgentHostTerminalManager,
logService: ILogService,
-): Promise {
+): Promise {
const sentinelId = makeSentinelId();
const sentinelCmd = buildSentinelCommand(sentinelId, shell.shellType);
const disposables = new DisposableStore();
@@ -433,15 +501,9 @@ async function executeCommandWithSentinel(
const contentBefore = terminalManager.getContent(shell.terminalUri) ?? '';
const offsetBefore = contentBefore.length;
- await terminalManager.sendText(shell.terminalUri, `${prefixForHistorySuppression(shell.shellType)}${command}`, {
- shouldExecute: true,
- bracketedPasteMode: shouldUseBracketedPasteMode(command),
- });
- await terminalManager.sendText(shell.terminalUri, sentinelCmd, { shouldExecute: true });
-
- return new Promise(resolve => {
+ const result = new Promise(resolve => {
let resolved = false;
- const finish = (result: ToolResultObject) => {
+ const finish = (result: IShellExecutionResult) => {
if (resolved) {
return;
}
@@ -462,9 +524,9 @@ async function executeCommandWithSentinel(
const output = prepareOutputForModel(parsed.outputBeforeSentinel);
logService.info(`[ShellTool] Command completed with exit code ${parsed.exitCode}`);
if (parsed.exitCode === 0) {
- finish(makeSuccessResult(`Exit code: ${parsed.exitCode}\n${output}`));
+ finish(makeExecutionResult(makeSuccessResult(`Exit code: ${parsed.exitCode}\n${output}`)));
} else {
- finish(makeFailureResult(`Exit code: ${parsed.exitCode}\n${output}`));
+ finish(makeExecutionResult(makeFailureResult(`Exit code: ${parsed.exitCode}\n${output}`)));
}
}
};
@@ -473,18 +535,20 @@ async function executeCommandWithSentinel(
checkForSentinel();
}));
+ registerAltBufferHandler(shell, terminalManager, logService, disposables, finish);
+
disposables.add(terminalManager.onExit(shell.terminalUri, (exitCode: number) => {
logService.info(`[ShellTool] Shell exited unexpectedly with code ${exitCode}`);
const fullContent = terminalManager.getContent(shell.terminalUri) ?? '';
const newContent = fullContent.substring(offsetBefore);
const output = prepareOutputForModel(newContent);
- finish(makeFailureResult(`Shell exited with code ${exitCode}\n${output}`));
+ finish(makeExecutionResult(makeFailureResult(`Shell exited with code ${exitCode}\n${output}`)));
}));
disposables.add(terminalManager.onClaimChanged(shell.terminalUri, (claim) => {
if (claim.kind === TerminalClaimKind.Session && !claim.toolCallId) {
logService.info(`[ShellTool] Continuing in background (claim narrowed)`);
- finish(makeSuccessResult('The user chose to continue this command in the background. The terminal is still running.'));
+ finish(makeBackgroundExecutionResult('The user chose to continue this command in the background. The terminal is still running.'));
}
}));
@@ -493,15 +557,28 @@ async function executeCommandWithSentinel(
const fullContent = terminalManager.getContent(shell.terminalUri) ?? '';
const newContent = fullContent.substring(offsetBefore);
const output = prepareOutputForModel(newContent);
- finish(makeFailureResult(
+ finish(makeExecutionResult(makeFailureResult(
`Command timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${output}`,
'timeout',
- ));
+ )));
}, timeoutMs);
disposables.add(toDisposable(() => clearTimeout(timer)));
checkForSentinel();
});
+
+ try {
+ await terminalManager.sendText(shell.terminalUri, `${prefixForHistorySuppression(shell.shellType)}${command}`, {
+ shouldExecute: true,
+ bracketedPasteMode: shouldUseBracketedPasteMode(command),
+ });
+ await terminalManager.sendText(shell.terminalUri, sentinelCmd, { shouldExecute: true });
+ } catch (err) {
+ disposables.dispose();
+ throw err;
+ }
+
+ return result;
}
// ---------------------------------------------------------------------------
@@ -558,10 +635,18 @@ export async function createShellTools(
invocation.toolCallId,
invocation.toolCallId,
);
+ let shouldReleaseShell = true;
try {
- return await executeCommandInShell(ref.object, args.command, timeoutMs, terminalManager, logService);
+ const result = await executeCommandInShell(ref.object, args.command, timeoutMs, terminalManager, logService);
+ if (result.keepShellBusy) {
+ shouldReleaseShell = false;
+ shellManager.holdShellUntilCommandFinishes(ref.object);
+ }
+ return result.toolResult;
} finally {
- ref.dispose();
+ if (shouldReleaseShell) {
+ ref.dispose();
+ }
}
},
};
diff --git a/src/vs/platform/agentHost/test/node/agentHostHeadlessTerminal.test.ts b/src/vs/platform/agentHost/test/node/agentHostHeadlessTerminal.test.ts
index 156cc05b877f3b..babd3647cd25de 100644
--- a/src/vs/platform/agentHost/test/node/agentHostHeadlessTerminal.test.ts
+++ b/src/vs/platform/agentHost/test/node/agentHostHeadlessTerminal.test.ts
@@ -82,6 +82,26 @@ suite('AgentHostHeadlessTerminal', () => {
assert.strictEqual(terminal.isBracketedPasteMode(), false);
});
+ test('resolves alt-buffer promise on alternate buffer entry', async () => {
+ const terminal = createTerminal();
+ const altBufferStore = disposables.add(new DisposableStore());
+ const altBufferPromise = terminal.createAltBufferPromise(altBufferStore);
+ let resolved = false;
+ void altBufferPromise.then(() => resolved = true);
+
+ assert.strictEqual(terminal.isInAltBuffer(), false);
+ await terminal.writePtyData('\x1b[?1049h');
+ assert.strictEqual(terminal.isInAltBuffer(), true);
+ await altBufferPromise;
+ assert.strictEqual(resolved, true);
+
+ await terminal.writePtyData('\x1b[?1049h');
+ assert.strictEqual(terminal.isInAltBuffer(), true);
+
+ await terminal.writePtyData('\x1b[?1049l');
+ assert.strictEqual(terminal.isInAltBuffer(), false);
+ });
+
test('ignores writes after dispose', async () => {
const terminal = createTerminal();
const responses: string[] = [];
diff --git a/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts
index ec3c6410f4addc..cfdfbf65e9c99f 100644
--- a/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts
+++ b/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts
@@ -361,6 +361,67 @@ suite('AgentHostTerminalManager – command detection integration', () => {
assert.deepStrictEqual(pty.writes, ['\x1b[1;4R']);
});
+ test('resolves alt-buffer promise from headless terminal data', async () => {
+ const logService = new NullLogService();
+ const stateManager = disposables.add(new AgentHostStateManager(logService));
+ const configurationService = disposables.add(new AgentConfigurationService(stateManager, logService));
+ const productService = { _serviceBrand: undefined, applicationName: 'vscode' } as IProductService;
+ const pty = new TestPty();
+ const manager = disposables.add(new TestAgentHostTerminalManager(stateManager, logService, productService, configurationService, pty));
+ const uri = 'agenthost-terminal://test/alt-buffer';
+
+ const createTerminal = manager.createTerminal({
+ terminal: uri,
+ claim: { kind: TerminalClaimKind.Client, clientId: 'test-client' },
+ cwd: process.cwd(),
+ cols: 80,
+ rows: 24,
+ }, { shell: '/bin/bash' });
+
+ await pty.dataListenerRegistered.p;
+ pty.fireData('prompt');
+ await createTerminal;
+
+ const altBufferStore = disposables.add(new DisposableStore());
+ const altBufferPromise = manager.createAltBufferPromise(uri, altBufferStore);
+
+ pty.fireData('\x1b[?1049h');
+
+ await altBufferPromise;
+ });
+
+ test('disposed alt-buffer promise listener does not resolve', async () => {
+ const logService = new NullLogService();
+ const stateManager = disposables.add(new AgentHostStateManager(logService));
+ const configurationService = disposables.add(new AgentConfigurationService(stateManager, logService));
+ const productService = { _serviceBrand: undefined, applicationName: 'vscode' } as IProductService;
+ const pty = new TestPty();
+ const manager = disposables.add(new TestAgentHostTerminalManager(stateManager, logService, productService, configurationService, pty));
+ const uri = 'agenthost-terminal://test/alt-buffer-disposed';
+
+ const createTerminal = manager.createTerminal({
+ terminal: uri,
+ claim: { kind: TerminalClaimKind.Client, clientId: 'test-client' },
+ cwd: process.cwd(),
+ cols: 80,
+ rows: 24,
+ }, { shell: '/bin/bash' });
+
+ await pty.dataListenerRegistered.p;
+ pty.fireData('prompt');
+ await createTerminal;
+
+ const altBufferStore = new DisposableStore();
+ const altBufferPromise = manager.createAltBufferPromise(uri, altBufferStore);
+ let didEnterAltBuffer = false;
+ void altBufferPromise.then(() => didEnterAltBuffer = true);
+ altBufferStore.dispose();
+ pty.fireData('\x1b[?1049h');
+ await timeout(10);
+
+ assert.strictEqual(didEnterAltBuffer, false);
+ });
+
test('server-handled CPR queries are stripped from client-facing data', () => {
function filter(data: string): string {
return removeServerHandledTerminalQueries(data, { pendingData: '' });
diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts
index fc3ccc12e63323..17e695b9a3f340 100644
--- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts
+++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts
@@ -91,6 +91,7 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager {
onExit(): IDisposable { return Disposable.None; }
onClaimChanged(): IDisposable { return Disposable.None; }
onCommandFinished(): IDisposable { return Disposable.None; }
+ createAltBufferPromise(_uri: string, _store: DisposableStore): Promise { return new Promise(() => { }); }
getContent(): string | undefined { return undefined; }
getClaim(): undefined { return undefined; }
hasTerminal(): boolean { return false; }
diff --git a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts
index d27d40448e8ce6..3574568adb2edc 100644
--- a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts
+++ b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts
@@ -8,7 +8,7 @@ import type { ToolInvocation, ToolResultObject } from '@github/copilot-sdk';
import { DeferredPromise } from '../../../../base/common/async.js';
import { URI } from '../../../../base/common/uri.js';
import * as platform from '../../../../base/common/platform.js';
-import { Emitter } from '../../../../base/common/event.js';
+import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable, DisposableStore, type IDisposable } from '../../../../base/common/lifecycle.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { IInstantiationService } from '../../../instantiation/common/instantiation.js';
@@ -16,7 +16,7 @@ import { InstantiationService } from '../../../instantiation/common/instantiatio
import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js';
import { ILogService, NullLogService } from '../../../log/common/log.js';
import type { CreateTerminalParams } from '../../common/state/protocol/commands.js';
-import type { TerminalClaim, TerminalInfo } from '../../common/state/protocol/state.js';
+import { TerminalClaimKind, type TerminalClaim, type TerminalInfo } from '../../common/state/protocol/state.js';
import { formatTerminalText, IAgentHostTerminalManager, type ICommandFinishedEvent, type ISendTextOptions } from '../../node/agentHostTerminalManager.js';
import { createShellTools, isMultilineCommand, ShellManager, prefixForHistorySuppression, shellTypeForExecutable } from '../../node/copilot/copilotShellTools.js';
@@ -31,6 +31,11 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager {
commandDetectionSupported = false;
readonly commandFinishedListenerRegistered = new DeferredPromise();
private readonly _onCommandFinished = new Emitter();
+ private readonly _onExit = new Emitter();
+ private readonly _onClaimChanged = new Emitter();
+ private readonly _onDidSendText = new Emitter();
+ readonly onDidSendText = this._onDidSendText.event;
+ private readonly _altBufferPromises: DeferredPromise[] = [];
async createTerminal(params: CreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise {
this.created.push({ params, options: { ...options, shell: options?.shell ?? this.defaultShell } });
@@ -41,14 +46,28 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager {
async sendText(uri: string, data: string, options: ISendTextOptions): Promise {
this.sentTexts.push({ uri, data, options });
this.writeInput(uri, formatTerminalText(data, options));
+ this._onDidSendText.fire();
}
onData(): IDisposable { return Disposable.None; }
- onExit(): IDisposable { return Disposable.None; }
- onClaimChanged(): IDisposable { return Disposable.None; }
+ onExit(_uri: string, cb: (exitCode: number) => void): IDisposable { return this._onExit.event(cb); }
+ onClaimChanged(_uri: string, cb: (claim: TerminalClaim) => void): IDisposable { return this._onClaimChanged.event(cb); }
onCommandFinished(_uri: string, cb: (event: ICommandFinishedEvent) => void): IDisposable {
this.commandFinishedListenerRegistered.complete();
return this._onCommandFinished.event(cb);
}
+ createAltBufferPromise(_uri: string, store: DisposableStore): Promise {
+ const deferred = new DeferredPromise();
+ this._altBufferPromises.push(deferred);
+ store.add({
+ dispose: () => {
+ const index = this._altBufferPromises.indexOf(deferred);
+ if (index !== -1) {
+ this._altBufferPromises.splice(index, 1);
+ }
+ }
+ });
+ return deferred.p;
+ }
getContent(): string | undefined { return undefined; }
getClaim(): TerminalClaim | undefined { return undefined; }
hasTerminal(uri: string): boolean { return this.existingTerminalUris.has(uri); }
@@ -59,6 +78,13 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager {
getTerminalState(): undefined { return undefined; }
async getDefaultShell(): Promise { return this.defaultShell; }
fireCommandFinished(event: ICommandFinishedEvent): void { this._onCommandFinished.fire(event); }
+ fireExit(exitCode: number): void { this._onExit.fire(exitCode); }
+ fireClaimChanged(claim: TerminalClaim): void { this._onClaimChanged.fire(claim); }
+ fireDidEnterAltBuffer(): void {
+ for (const promise of [...this._altBufferPromises]) {
+ promise.complete();
+ }
+ }
}
suite('CopilotShellTools', () => {
@@ -78,6 +104,33 @@ suite('CopilotShellTools', () => {
return { instantiationService, terminalManager };
}
+ async function waitForSentTexts(terminalManager: TestAgentHostTerminalManager, count: number): Promise {
+ while (terminalManager.sentTexts.length < count) {
+ const didTimeOut = await new Promise(resolve => {
+ const disposables = new DisposableStore();
+ const listener = Event.once(terminalManager.onDidSendText)(() => {
+ disposables.dispose();
+ resolve(false);
+ });
+ disposables.add(listener);
+ const handle = setTimeout(() => {
+ disposables.dispose();
+ resolve(true);
+ }, 1000);
+ disposables.add({ dispose: () => clearTimeout(handle) });
+ });
+ if (didTimeOut) {
+ assert.fail(`Timed out waiting for ${count} sendText calls; saw ${terminalManager.sentTexts.length}`);
+ }
+ }
+ }
+
+ function markCreatedTerminalsExist(terminalManager: TestAgentHostTerminalManager): void {
+ for (const created of terminalManager.created) {
+ terminalManager.existingTerminalUris.add(created.params.terminal);
+ }
+ }
+
test('uses session working directory for created shells', async () => {
const { instantiationService, terminalManager } = createServices();
const worktreePath = URI.file('/workspace/worktree').fsPath;
@@ -219,6 +272,202 @@ suite('CopilotShellTools', () => {
assert.strictEqual(terminalManager.writes[0].data, ' echo first\recho second\r');
});
+ test('primary shell tool returns alternateBuffer when shell integration enters alt buffer', async () => {
+ const { instantiationService, terminalManager } = createServices();
+ terminalManager.commandDetectionSupported = true;
+ const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined));
+ const tools = await createShellTools(shellManager, terminalManager, new NullLogService());
+ const bashTool = tools.find(tool => tool.name === 'bash');
+ assert.ok(bashTool);
+
+ const invocation: ToolInvocation = {
+ sessionId: 'session-1',
+ toolCallId: 'tool-1',
+ toolName: 'bash',
+ arguments: { command: 'vim README.md', timeout: 1000 },
+ };
+ const resultPromise = bashTool.handler({ command: 'vim README.md', timeout: 1000 }, invocation) as Promise;
+ await waitForSentTexts(terminalManager, 1);
+ terminalManager.fireDidEnterAltBuffer();
+ const result = await resultPromise;
+
+ assert.strictEqual(result.resultType, 'failure');
+ assert.strictEqual(result.error, 'alternateBuffer');
+ assert.match(result.textResultForLlm, /opened the alternate buffer/);
+ });
+
+ test('primary shell tool returns alternateBuffer when sentinel fallback enters alt buffer', async () => {
+ const { instantiationService, terminalManager } = createServices();
+ const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined));
+ const tools = await createShellTools(shellManager, terminalManager, new NullLogService());
+ const bashTool = tools.find(tool => tool.name === 'bash');
+ assert.ok(bashTool);
+
+ const invocation: ToolInvocation = {
+ sessionId: 'session-1',
+ toolCallId: 'tool-1',
+ toolName: 'bash',
+ arguments: { command: 'vim README.md', timeout: 1000 },
+ };
+ const resultPromise = bashTool.handler({ command: 'vim README.md', timeout: 1000 }, invocation) as Promise;
+ await waitForSentTexts(terminalManager, 2);
+ terminalManager.fireDidEnterAltBuffer();
+ const result = await resultPromise;
+
+ assert.strictEqual(result.resultType, 'failure');
+ assert.strictEqual(result.error, 'alternateBuffer');
+ assert.match(result.textResultForLlm, /opened the alternate buffer/);
+ });
+
+ test('alt-buffer shell is released when command finishes', async () => {
+ const { instantiationService, terminalManager } = createServices();
+ terminalManager.commandDetectionSupported = true;
+ const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined));
+ const tools = await createShellTools(shellManager, terminalManager, new NullLogService());
+ const bashTool = tools.find(tool => tool.name === 'bash');
+ assert.ok(bashTool);
+
+ const invocation: ToolInvocation = {
+ sessionId: 'session-1',
+ toolCallId: 'tool-1',
+ toolName: 'bash',
+ arguments: { command: 'vim README.md', timeout: 1000 },
+ };
+ const resultPromise = bashTool.handler({ command: 'vim README.md', timeout: 1000 }, invocation) as Promise;
+ await waitForSentTexts(terminalManager, 1);
+ terminalManager.fireDidEnterAltBuffer();
+ const result = await resultPromise;
+ assert.strictEqual(result.error, 'alternateBuffer');
+ markCreatedTerminalsExist(terminalManager);
+ const shell = shellManager.listShells()[0];
+
+ terminalManager.fireCommandFinished({ commandId: 'cmd-1', exitCode: 0, command: 'vim README.md', output: '' });
+ const next = await shellManager.getOrCreateShell('bash', 'turn-2', 'tool-2');
+
+ assert.strictEqual(next.object.id, shell.id);
+ assert.strictEqual(terminalManager.created.length, 1);
+ next.dispose();
+ });
+
+ test('alt-buffer shell is not immediately reused', async () => {
+ const { instantiationService, terminalManager } = createServices();
+ terminalManager.commandDetectionSupported = true;
+ const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined));
+ const tools = await createShellTools(shellManager, terminalManager, new NullLogService());
+ const bashTool = tools.find(tool => tool.name === 'bash');
+ assert.ok(bashTool);
+
+ const invocation: ToolInvocation = {
+ sessionId: 'session-1',
+ toolCallId: 'tool-1',
+ toolName: 'bash',
+ arguments: { command: 'vim README.md', timeout: 1000 },
+ };
+ const resultPromise = bashTool.handler({ command: 'vim README.md', timeout: 1000 }, invocation) as Promise;
+ await waitForSentTexts(terminalManager, 1);
+ terminalManager.fireDidEnterAltBuffer();
+ const result = await resultPromise;
+ assert.strictEqual(result.error, 'alternateBuffer');
+ markCreatedTerminalsExist(terminalManager);
+ const shell = shellManager.listShells()[0];
+
+ const next = await shellManager.getOrCreateShell('bash', 'turn-2', 'tool-2');
+
+ assert.notStrictEqual(next.object.id, shell.id);
+ assert.strictEqual(terminalManager.created.length, 2);
+ next.dispose();
+ });
+
+ test('backgrounded shell is not immediately reused', async () => {
+ const { instantiationService, terminalManager } = createServices();
+ terminalManager.commandDetectionSupported = true;
+ const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined));
+ const tools = await createShellTools(shellManager, terminalManager, new NullLogService());
+ const bashTool = tools.find(tool => tool.name === 'bash');
+ assert.ok(bashTool);
+
+ const invocation: ToolInvocation = {
+ sessionId: 'session-1',
+ toolCallId: 'tool-1',
+ toolName: 'bash',
+ arguments: { command: 'sleep 100', timeout: 1000 },
+ };
+ const resultPromise = bashTool.handler({ command: 'sleep 100', timeout: 1000 }, invocation) as Promise;
+ await waitForSentTexts(terminalManager, 1);
+ terminalManager.fireClaimChanged({ kind: TerminalClaimKind.Session, session: 'copilot:/session-1', turnId: 'turn-1' });
+ const result = await resultPromise;
+ assert.strictEqual(result.resultType, 'success');
+ assert.match(result.textResultForLlm, /continue this command in the background/);
+ markCreatedTerminalsExist(terminalManager);
+ const shell = shellManager.listShells()[0];
+
+ const next = await shellManager.getOrCreateShell('bash', 'turn-2', 'tool-2');
+
+ assert.notStrictEqual(next.object.id, shell.id);
+ assert.strictEqual(terminalManager.created.length, 2);
+ next.dispose();
+ });
+
+ test('backgrounded shell is released when command finishes', async () => {
+ const { instantiationService, terminalManager } = createServices();
+ terminalManager.commandDetectionSupported = true;
+ const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined));
+ const tools = await createShellTools(shellManager, terminalManager, new NullLogService());
+ const bashTool = tools.find(tool => tool.name === 'bash');
+ assert.ok(bashTool);
+
+ const invocation: ToolInvocation = {
+ sessionId: 'session-1',
+ toolCallId: 'tool-1',
+ toolName: 'bash',
+ arguments: { command: 'sleep 100', timeout: 1000 },
+ };
+ const resultPromise = bashTool.handler({ command: 'sleep 100', timeout: 1000 }, invocation) as Promise;
+ await waitForSentTexts(terminalManager, 1);
+ terminalManager.fireClaimChanged({ kind: TerminalClaimKind.Session, session: 'copilot:/session-1', turnId: 'turn-1' });
+ const result = await resultPromise;
+ assert.strictEqual(result.resultType, 'success');
+ markCreatedTerminalsExist(terminalManager);
+ const shell = shellManager.listShells()[0];
+
+ terminalManager.fireCommandFinished({ commandId: 'cmd-1', exitCode: 0, command: 'sleep 100', output: '' });
+ const next = await shellManager.getOrCreateShell('bash', 'turn-2', 'tool-2');
+
+ assert.strictEqual(next.object.id, shell.id);
+ assert.strictEqual(terminalManager.created.length, 1);
+ next.dispose();
+ });
+
+ test('backgrounded shell is released when terminal exits', async () => {
+ const { instantiationService, terminalManager } = createServices();
+ terminalManager.commandDetectionSupported = true;
+ const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined));
+ const tools = await createShellTools(shellManager, terminalManager, new NullLogService());
+ const bashTool = tools.find(tool => tool.name === 'bash');
+ assert.ok(bashTool);
+
+ const invocation: ToolInvocation = {
+ sessionId: 'session-1',
+ toolCallId: 'tool-1',
+ toolName: 'bash',
+ arguments: { command: 'sleep 100', timeout: 1000 },
+ };
+ const resultPromise = bashTool.handler({ command: 'sleep 100', timeout: 1000 }, invocation) as Promise;
+ await waitForSentTexts(terminalManager, 1);
+ terminalManager.fireClaimChanged({ kind: TerminalClaimKind.Session, session: 'copilot:/session-1', turnId: 'turn-1' });
+ const result = await resultPromise;
+ assert.strictEqual(result.resultType, 'success');
+ markCreatedTerminalsExist(terminalManager);
+ const shell = shellManager.listShells()[0];
+
+ terminalManager.fireExit(0);
+ const next = await shellManager.getOrCreateShell('bash', 'turn-2', 'tool-2');
+
+ assert.strictEqual(next.object.id, shell.id);
+ assert.strictEqual(terminalManager.created.length, 1);
+ next.dispose();
+ });
+
test('primary shell tool only forces bracketed paste for single-line commands on macOS', async () => {
const { instantiationService, terminalManager } = createServices();
const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined));
diff --git a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts
index faa69ebd589bbe..6e6c87109e504d 100644
--- a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts
+++ b/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts
@@ -174,7 +174,7 @@ class TestableSSHRemoteAgentHostMainService extends SSHRemoteAgentHostMainServic
hangRelayCreationOnCall: number | undefined;
/** Public override so tests can shorten the relay creation timeout. */
- override relayCreationTimeoutMs: number = 30_000;
+ protected override relayCreationTimeoutMs: number = 30_000;
/** Stored onMessage callbacks from relays, most recent last. */
private readonly _relayMessageCallbacks: Array<(data: string) => void> = [];
@@ -279,6 +279,11 @@ class TestableSSHRemoteAgentHostMainService extends SSHRemoteAgentHostMainServic
this._relayCloseCallbacks[this._relayCloseCallbacks.length - 1]();
}
}
+
+ /** Sets the relay creation timeout; exposed for tests only. */
+ setRelayCreationTimeoutForTest(ms: number): void {
+ this.relayCreationTimeoutMs = ms;
+ }
}
suite('SSHRemoteAgentHostMainService - connect flow', () => {
@@ -1017,7 +1022,7 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => {
assert.strictEqual(originalClient.ended, false);
// Use a short timeout so the test completes quickly.
- service.relayCreationTimeoutMs = 50;
+ service.setRelayCreationTimeoutForTest(50);
// Make the *reconnect* call's relay creation hang (the second relay).
service.hangRelayCreationOnCall = 2;
diff --git a/src/vs/platform/configuration/common/configuration.ts b/src/vs/platform/configuration/common/configuration.ts
index e28c4a34a0a104..55eaae5451c234 100644
--- a/src/vs/platform/configuration/common/configuration.ts
+++ b/src/vs/platform/configuration/common/configuration.ts
@@ -302,7 +302,7 @@ function doRemoveFromValueTree(valueTree: IStringDictionary | unknown,
if (Object.keys(valueTreeRecord).indexOf(first) !== -1) {
const value = valueTreeRecord[first];
- if (typeof value === 'object' && !Array.isArray(value)) {
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
doRemoveFromValueTree(value, segments);
if (Object.keys(value as object).length === 0) {
delete valueTreeRecord[first];
diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md
index dce70400a3ed49..74f3d1157011d0 100644
--- a/src/vs/sessions/SESSIONS.md
+++ b/src/vs/sessions/SESSIONS.md
@@ -67,6 +67,10 @@ Providers can import from all layers below them (core, services, non-provider co
- [Copilot Chat Sessions Provider](contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md) — wraps `ChatSessionsService`, metadata contract, workspace derivation
- [Remote Agent Host Provider](contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md) — remote connections, per-host provider instances
+### Related Specifications
+
+- [Sessions List](SESSIONS_LIST.md) — UI surface for browsing sessions: tree widget, grouping, filtering, pinning, read/unread state, mobile adaptations
+
---
## Key Concepts
diff --git a/src/vs/sessions/SESSIONS_LIST.md b/src/vs/sessions/SESSIONS_LIST.md
new file mode 100644
index 00000000000000..fffac6383522c6
--- /dev/null
+++ b/src/vs/sessions/SESSIONS_LIST.md
@@ -0,0 +1,186 @@
+# Sessions List
+
+The sessions list is the primary navigation surface in the Agents Window. It occupies the **Sidebar** and presents all sessions from all registered providers as a grouped, filterable, sortable list.
+
+---
+
+## Overview
+
+The sessions list (`SessionsView` + `SessionsList`) displays every session known to `ISessionsManagementService`. Sessions are aggregated from all registered providers and shown in collapsible **sections**. The user can group, sort, filter, pin, and archive sessions. Selecting a session navigates to it.
+
+### Key Files
+
+| File | Purpose |
+|------|---------|
+| `contrib/sessions/browser/views/sessionsView.ts` | `SessionsView` — ViewPane with header, new-session button, sort/group/filter persistence |
+| `contrib/sessions/browser/views/sessionsList.ts` | `SessionsList` — tree control, grouping/filtering logic, menu IDs, context keys |
+| `contrib/sessions/browser/views/sessionsListModelService.ts` | `ISessionsListModelService` — pin/read state (UI-only, not synced to providers) |
+| `contrib/sessions/browser/views/sessionsViewActions.ts` | All registered actions (sort, group, filter, pin, archive, rename, navigate) |
+
+---
+
+## Features
+
+### Session Row
+
+Each session row displays:
+
+- **Status icon** — animated indicator for InProgress / NeedsInput / Error / Completed / Unread
+- **Title** — the session's display title (observable)
+- **Workspace badge** — folder/worktree/cloud icon + label (hidden when redundant with section header)
+- **Diff stats** — `+insertions −deletions` when the session has pending changes
+- **Status description or timestamp** — InProgress/NeedsInput/Error show a status message; otherwise a relative timestamp
+- **Approval row** (optional) — pending agent approvals with an "Allow" button
+
+### Grouping
+
+Sessions are organized into sections with fixed priority:
+
+```
+1. Pinned ← always first
+2. Regular ← grouped by workspace or date
+3. Done/Archived ← always last
+```
+
+Two grouping modes (user-switchable):
+
+- **By Workspace** (default) — one section per workspace label, sorted alphabetically. "Unknown" workspace sorts last.
+- **By Date** — sections: Today, Yesterday, Last 7 Days, Older.
+
+Archived sessions always go to the "Done" section regardless of grouping mode. Archive wins over pin — an archived session is never shown in Pinned.
+
+### Sorting
+
+- **By Created** (default) — `createdAt` descending
+- **By Updated** — `updatedAt` descending
+
+### Workspace Group Capping
+
+When grouping by workspace, the list shows only **primary** workspace sections by default:
+
+- A workspace qualifies as primary if it has recent activity (last 4 days), matches the open window's folder, or contains the most recently updated session
+- Remaining workspaces collapse behind a "Show N more" toggle
+- Within each workspace, sessions beyond 5 also show a "Show more" toggle
+- The find widget bypasses all capping
+
+### Filtering
+
+Multiple filter dimensions combine:
+
+| Filter | Default | Effect |
+|--------|---------|--------|
+| Session type | All shown | Hides sessions of specific types (per available session types) |
+| Status | All shown | Hides sessions by `SessionStatus` (InProgress, NeedsInput, Error, Completed, Untitled) |
+| Archived | Hidden | Shows/hides the Done section |
+| Read | All shown | Optionally shows only unread sessions |
+| Agent host | All | Scopes to a specific agent host provider |
+
+The **active session is always visible** even if it would be excluded by filters.
+
+### Find
+
+A built-in find widget filters the list by session title and section label. When active, it bypasses workspace group capping so all matching sessions are visible.
+
+### Pinning
+
+Pinned sessions appear in a dedicated "Pinned" section at the top. Pin state is managed by `ISessionsListModelService` and persisted locally (not synced to providers).
+
+### Read / Unread
+
+- Sessions start as **unread**
+- A session becomes **read** when the user opens it or explicitly marks it
+- A session becomes **unread** when it completes in the background (transitions from InProgress to a terminal status while not active)
+
+### Navigation
+
+- **Clicking a session** marks it read and calls `SessionsManagementService.openSession()`
+- **Active session tracking** — the list auto-scrolls to and selects the active session via an `autorun` on `activeSession`
+- **Keyboard shortcuts** — `Ctrl/Cmd+1..9` opens sessions by index; `Ctrl+Alt+-` / `Ctrl+Alt+Shift+-` for back/forward navigation
+- **Mobile** — opening a session also closes the sidebar drawer
+
+### Mobile
+
+On phone layout (`IsPhoneLayoutContext`):
+
+- Session rows are taller for touch targets; inline toolbars are always visible (no hover)
+- A **filter chips** row appears below the header with status toggles (Completed, In Progress, Failed) and a Sort chip
+- Sort/Group options open as a **bottom sheet** instead of a menu
+
+---
+
+## Menu Entry Points
+
+The sessions list defines menu IDs that contributions can target to add actions. All are exported from `sessionsList.ts` and `sessionsView.ts`.
+
+### Session Item Menus
+
+| Menu | Constant | Where it appears | Use for |
+|------|----------|------------------|---------|
+| `SessionItemToolbar` | `SessionItemToolbarMenuId` | Inline toolbar on each session row (hover on desktop, always on mobile) | Primary actions like pin, archive. Group `navigation` for icons, other groups for overflow. |
+| `SessionItemContextMenu` | `SessionItemContextMenuId` | Right-click context menu on session rows | Secondary actions like rename, mark read/unread. Groups: `0_pin`, `0_read`, `1_edit`. |
+
+### Section Header Menu
+
+| Menu | Constant | Where it appears | Use for |
+|------|----------|------------------|---------|
+| `SessionSectionToolbar` | `SessionSectionToolbarMenuId` | Toolbar on section headers (Pinned, workspace groups, Done) | Section-scoped actions like "New Session for Workspace", "Archive All", "Restore All". |
+
+### View Title Menus
+
+| Menu | Constant | Where it appears | Use for |
+|------|----------|------------------|---------|
+| `SessionsViewPaneFilterSubMenu` | `SessionsViewFilterSubMenu` | Filter/sort dropdown in the view title bar | Sort, group, and workspace capping toggles. |
+| `SessionsViewPaneFilterOptionsSubMenu` | `SessionsViewFilterOptionsSubMenu` | Nested under the filter sub-menu | Session type and status filter checkboxes. |
+
+### Contributing an Action
+
+Register an `Action2` and target one of the menu IDs above. Use the context keys (below) in `when` clauses to scope the action to the right sessions or sections.
+
+```typescript
+registerAction2(class MySessionAction extends Action2 {
+ constructor() {
+ super({
+ id: 'myExtension.mySessionAction',
+ title: localize2('myAction', "My Action"),
+ menu: {
+ id: SessionItemContextMenuId,
+ group: '1_edit',
+ when: ContextKeyExpr.equals('chatSessionType', 'my-session-type'),
+ },
+ });
+ }
+ run(accessor: ServicesAccessor, ...args: unknown[]): void {
+ // action logic
+ }
+});
+```
+
+---
+
+## Context Keys
+
+Context keys available for `when` clauses when contributing to session list menus.
+
+### Per-Session Item
+
+| Key | Type | Description |
+|-----|------|-------------|
+| `sessionItem.isPinned` | boolean | Whether the session is pinned |
+| `sessionItem.isArchived` | boolean | Whether the session is archived |
+| `sessionItem.isRead` | boolean | Whether the session has been read |
+| `sessionItem.hasBranchName` | boolean | Whether the session has a git branch name |
+| `chatSessionType` | string | Session type ID (use to scope actions to specific providers) |
+| `ChatSessionProviderIdContext` | string | Provider ID |
+
+### Per-Section
+
+| Key | Type | Description |
+|-----|------|-------------|
+| `sessionSection.type` | string | `'pinned'`, `'archived'`, `'workspace:'`, `'today'`, etc. |
+
+### View-Level
+
+| Key | Type | Description |
+|-----|------|-------------|
+| `sessionsViewPane.grouping` | string | Current grouping mode (`'workspace'` or `'date'`) |
+| `sessionsViewPane.sorting` | string | Current sorting mode (`'created'` or `'updated'`) |
diff --git a/src/vs/sessions/browser/parts/titlebarPart.ts b/src/vs/sessions/browser/parts/titlebarPart.ts
index 3a4735f9961bf3..da648497315e7b 100644
--- a/src/vs/sessions/browser/parts/titlebarPart.ts
+++ b/src/vs/sessions/browser/parts/titlebarPart.ts
@@ -29,6 +29,7 @@ import { IEditorGroupsContainer } from '../../../workbench/services/editor/commo
import { CodeWindow, mainWindow } from '../../../base/browser/window.js';
import { safeIntl } from '../../../base/common/date.js';
import { ITitlebarPart, ITitleProperties, ITitleVariable, IAuxiliaryTitlebarPart } from '../../../workbench/browser/parts/titlebar/titlebarPart.js';
+import { WindowTitle } from '../../../workbench/browser/parts/titlebar/windowTitle.js';
import { Menus } from '../menus.js';
import { IsNewChatSessionContext } from '../../common/contextkeys.js';
@@ -75,8 +76,8 @@ export class TitlebarPart extends Part implements ITitlebarPart {
//#endregion
- private rootContainer!: HTMLElement;
- private windowControlsContainer: HTMLElement | undefined;
+ protected rootContainer!: HTMLElement;
+ protected windowControlsContainer: HTMLElement | undefined;
private leftContent!: HTMLElement;
private leftToolbarContainer!: HTMLElement;
@@ -271,7 +272,7 @@ export class TitlebarPart extends Part implements ITitlebarPart {
}
}
- private onContextMenu(e: MouseEvent): void {
+ protected onContextMenu(e: MouseEvent): void {
const event = new StandardMouseEvent(getWindow(this.element), e);
this.contextMenuService.showContextMenu({
getAnchor: () => event,
@@ -473,5 +474,17 @@ export class TitleService extends MultiWindowParts implements ITit
}
}
+ private _windowTitle: WindowTitle | undefined;
+
+ get windowTitle(): WindowTitle {
+ // The Agents window title bar does not render `window.title`, so we
+ // lazily construct a `WindowTitle` only when a consumer (e.g. a custom
+ // command center widget) actually asks for one.
+ if (!this._windowTitle) {
+ this._windowTitle = this._register(this.instantiationService.createInstance(WindowTitle, mainWindow));
+ }
+ return this._windowTitle;
+ }
+
//#endregion
}
diff --git a/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts b/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts
index 57201bedf435ad..258927e65adabc 100644
--- a/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts
+++ b/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts
@@ -4,20 +4,25 @@
*--------------------------------------------------------------------------------------------*/
import { IDisposable } from '../../../../base/common/lifecycle.js';
-import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { CustomizationHarnessServiceBase, createVSCodeHarnessDescriptor, IHarnessDescriptor } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js';
import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js';
import { BUILTIN_STORAGE } from '../common/builtinPromptsStorage.js';
import { SessionType } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
-// eslint-disable-next-line local/code-import-patterns
-import { LOCAL_SESSION_ENABLED_SETTING } from '../../providers/copilotChatSessions/browser/copilotChatSessionsProvider.js';
+import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
+
+/**
+ * The session type that supports local harness customization.
+ * Hardcoded for now — ideally providers would declare harness support explicitly.
+ */
+const LOCAL_HARNESS_SESSION_TYPE = 'local';
/**
* Sessions-window override of the customization harness service.
*
- * The Local harness is registered when the `sessions.chat.localAgent.enabled`
- * setting is true (the default). When the setting is toggled, the harness is
- * dynamically added or removed so that the Customizations editor reflects the
+ * The Local harness is registered when a provider offers a session type
+ * matching {@link LOCAL_HARNESS_SESSION_TYPE}. When providers are added or
+ * removed (or their session types change), the harness is dynamically
+ * added or removed so that the Customizations editor reflects the
* current state.
*
* The Copilot CLI extension provides its harness (with `itemProvider`) via
@@ -30,7 +35,7 @@ export class SessionsCustomizationHarnessService extends CustomizationHarnessSer
constructor(
@IPromptsService promptsService: IPromptsService,
- @IConfigurationService configurationService: IConfigurationService,
+ @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService,
) {
const localExtras = [PromptsStorage.extension, BUILTIN_STORAGE];
const localHarness = createVSCodeHarnessDescriptor(localExtras);
@@ -41,17 +46,18 @@ export class SessionsCustomizationHarnessService extends CustomizationHarnessSer
promptsService,
);
- // Register the local harness dynamically so it can be toggled
- // when the `sessions.chat.localAgent.enabled` setting changes.
- if (configurationService.getValue(LOCAL_SESSION_ENABLED_SETTING) !== false) {
- this._localHarnessRegistration = this.registerExternalHarness(localHarness);
- }
+ const sync = () => this._syncLocalHarness(localHarness, this._hasLocalSessionType());
- configurationService.onDidChangeConfiguration(e => {
- if (e.affectsConfiguration(LOCAL_SESSION_ENABLED_SETTING)) {
- this._syncLocalHarness(localHarness, configurationService.getValue(LOCAL_SESSION_ENABLED_SETTING) !== false);
- }
- });
+ this.sessionsManagementService.onDidChangeSessionTypes(sync);
+
+ // Initial sync
+ sync();
+ }
+
+ private _hasLocalSessionType(): boolean {
+ return this.sessionsManagementService.getAllSessionTypes().some(
+ t => t.id === LOCAL_HARNESS_SESSION_TYPE
+ );
}
private _syncLocalHarness(descriptor: IHarnessDescriptor, enabled: boolean): void {
diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostModelPicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostModelPicker.test.ts
index 924c2a0cc7877b..74221ca405f0fa 100644
--- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostModelPicker.test.ts
+++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostModelPicker.test.ts
@@ -23,7 +23,6 @@ function makeModel(identifier: string): ILanguageModelChatMetadataAndIdentifier
maxOutputTokens: 4096,
isDefaultForLocation: {},
isUserSelectable: true,
- modelPickerCategory: undefined,
targetChatSessionType: 'agent-host-copilotcli',
},
};
diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts
index e166a34e463689..0ffe6c966c80f2 100644
--- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts
+++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts
@@ -48,7 +48,7 @@ import { IAuthenticationService } from '../../../../../workbench/services/authen
import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js';
import { SessionStatus } from '../../../../services/sessions/common/session.js';
import { remoteAgentHostSessionTypeId } from '../common/remoteAgentHostSessionType.js';
-import { createRemoteAgentHarnessDescriptor, RemoteAgentCustomizationItemProvider, RemoteAgentPluginController } from './remoteAgentHostCustomizationHarness.js';
+import { createRemoteAgentCustomizationItemProvider, createRemoteAgentHarnessDescriptor, RemoteAgentPluginController } from './remoteAgentHostCustomizationHarness.js';
import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js';
import { SyncedCustomizationBundler } from './syncedCustomizationBundler.js';
import { ISSHRemoteAgentHostService } from '../../../../../platform/agentHost/common/sshRemoteAgentHost.js';
@@ -628,7 +628,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
this._notificationService,
this._customizationWorkspaceService,
));
- const itemProvider = agentStore.add(new RemoteAgentCustomizationItemProvider(agent, loggedConnection, sanitized, pluginController, this._fileService, this._logService));
+ const itemProvider = agentStore.add(createRemoteAgentCustomizationItemProvider(agent, loggedConnection, sanitized, pluginController, this._fileService, this._logService));
const syncProvider = agentStore.add(new AgentCustomizationSyncProvider(sessionType, this._storageService));
const harnessDescriptor = createRemoteAgentHarnessDescriptor(sessionType, displayName, pluginController, itemProvider, syncProvider);
agentStore.add(this._customizationHarnessService.registerExternalHarness(harnessDescriptor));
diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts
index c353a3b38d12b1..baa4776bd6cb35 100644
--- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts
+++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts
@@ -3,15 +3,10 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
-import { CancellationToken } from '../../../../../base/common/cancellation.js';
+
import { Codicon } from '../../../../../base/common/codicons.js';
-import { Emitter, Event } from '../../../../../base/common/event.js';
import { Disposable } from '../../../../../base/common/lifecycle.js';
-import { ResourceMap } from '../../../../../base/common/map.js';
-import { extname } from '../../../../../base/common/path.js';
-import { basename, joinPath } from '../../../../../base/common/resources.js';
-import { SKILL_FILENAME } from '../../../../../workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.js';
-import { PromptFileParser } from '../../../../../workbench/contrib/chat/common/promptSyntax/promptFileParser.js';
+import { basename } from '../../../../../base/common/resources.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { URI } from '../../../../../base/common/uri.js';
import { localize } from '../../../../../nls.js';
@@ -19,95 +14,24 @@ import { AgentHostConfigKey, getAgentHostConfiguredCustomizations } from '../../
import { agentHostUri } from '../../../../../platform/agentHost/common/agentHostFileSystemProvider.js';
import { IFileService } from '../../../../../platform/files/common/files.js';
import { ILogService } from '../../../../../platform/log/common/log.js';
-import { AGENT_HOST_SCHEME, fromAgentHostUri, toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js';
+import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js';
import type { IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js';
import { ActionType } from '../../../../../platform/agentHost/common/state/sessionActions.js';
-import { type AgentInfo, type CustomizationRef, type RootState, type SessionCustomization, CustomizationStatus } from '../../../../../platform/agentHost/common/state/sessionState.js';
+import { type AgentInfo, type CustomizationRef } from '../../../../../platform/agentHost/common/state/sessionState.js';
import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
import { INotificationService } from '../../../../../platform/notification/common/notification.js';
import { AICustomizationManagementSection, IAICustomizationWorkspaceService, type IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';
-import { type IHarnessDescriptor, type ICustomizationItem, type ICustomizationItemAction, type ICustomizationItemProvider } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js';
+import { type IHarnessDescriptor, type ICustomizationItem, type ICustomizationItemAction } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js';
import { PromptsType } from '../../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js';
import { PromptsStorage } from '../../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js';
import { BUILTIN_STORAGE } from '../../../chat/common/builtinPromptsStorage.js';
import { AgentCustomizationSyncProvider } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationSyncProvider.js';
-import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js';
-
-export { AgentCustomizationSyncProvider as RemoteAgentSyncProvider } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationSyncProvider.js';
-
-const REMOTE_HOST_GROUP = 'remote-host';
-const REMOTE_CLIENT_GROUP = 'remote-client';
-
-/**
- * Returns `true` for the synthetic "VS Code Synced Data" bundle plugin,
- * which is an implementation detail of the customization sync pipeline
- * and should not be surfaced as a standalone item in the UI.
- */
-function isSyntheticBundle(customization: CustomizationRef): boolean {
- try {
- return URI.parse(customization.uri).scheme === SYNCED_CUSTOMIZATION_SCHEME;
- } catch {
- return false;
- }
-}
-
-/**
- * Maps a plugin sub-directory name to the {@link PromptsType}
- * its files represent. Returns `undefined` for unknown directories.
- */
-function promptsTypeForPluginDir(dir: string): PromptsType | undefined {
- switch (dir) {
- case 'rules': return PromptsType.instructions;
- case 'commands': return PromptsType.prompt;
- case 'agents': return PromptsType.agent;
- case 'skills': return PromptsType.skill;
- default: return undefined;
- }
-}
-
-/**
- * Strips conventional prompt file extensions so we can show `foo`
- * for `foo.prompt.md`, `foo.instructions.md`, etc.
- */
-function stripPromptFileExtensions(filename: string): string {
- const ext = extname(filename);
- if (!ext) {
- return filename;
- }
- const stem = filename.slice(0, -ext.length);
- const dotInStem = stem.lastIndexOf('.');
- return dotInStem > 0 ? stem.slice(0, dotInStem) : stem;
-}
-
-interface IExpandedPlugin {
- readonly nonce: string | undefined;
- readonly children: readonly ICustomizationItem[];
-}
-
-/**
- * Maps a {@link CustomizationStatus} enum value to the string literal
- * expected by {@link ICustomizationItem.status}.
- */
-function toStatusString(status: CustomizationStatus | undefined): 'loading' | 'loaded' | 'degraded' | 'error' | undefined {
- switch (status) {
- case CustomizationStatus.Loading: return 'loading';
- case CustomizationStatus.Loaded: return 'loaded';
- case CustomizationStatus.Degraded: return 'degraded';
- case CustomizationStatus.Error: return 'error';
- default: return undefined;
- }
-}
+import { AgentCustomizationItemProvider } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.js';
function customizationKey(customization: CustomizationRef): string {
return customization.uri;
}
-function customizationItemKey(customization: CustomizationRef, clientId: string | undefined): string {
- return clientId !== undefined
- ? `${customizationKey(customization)}::${clientId}`
- : customizationKey(customization);
-}
-
/**
* Owns the client-side UI commands for configuring plugins on a remote
* agent host. The actual source of truth lives in the host's root config.
@@ -204,8 +128,9 @@ export class RemoteAgentPluginController extends Disposable {
}
/**
- * Provider that exposes a remote agent's configured plugins as
- * {@link ICustomizationItem} entries for the plugin management widget.
+ * Creates a {@link AgentCustomizationItemProvider} that exposes a
+ * remote agent's configured plugins as {@link ICustomizationItem}
+ * entries for the plugin management widget.
*
* Each plugin is also **expanded** into its individual customization
* files (agents, skills, instructions, prompts) by reading the plugin
@@ -213,336 +138,33 @@ export class RemoteAgentPluginController extends Disposable {
* children appear in per-type sections (Skills, Agents, etc.) while
* the parent plugin item appears in the Plugins section.
*/
-export class RemoteAgentCustomizationItemProvider extends Disposable implements ICustomizationItemProvider {
- private readonly _onDidChange = this._register(new Emitter());
- readonly onDidChange: Event = this._onDidChange.event;
-
- private _agentCustomizations: readonly CustomizationRef[];
- private _sessionCustomizations: readonly SessionCustomization[] | undefined;
-
- /** Cache: pluginUri → last expansion (keyed by nonce so we re-fetch on content change). */
- private readonly _expansionCache = new ResourceMap();
-
- constructor(
- private readonly _agentInfo: AgentInfo,
- private readonly _connection: IAgentConnection,
- private readonly _connectionAuthority: string,
- private readonly _controller: RemoteAgentPluginController,
- private readonly _fileService: IFileService,
- private readonly _logService: ILogService,
- ) {
- super();
- this._agentCustomizations = this._readRootCustomizations(this._connection.rootState.value) ?? _agentInfo.customizations ?? [];
-
- this._register(this._connection.rootState.onDidChange(rootState => {
- const next = this._readRootCustomizations(rootState) ?? this._readAgentCustomizations(rootState) ?? this._agentCustomizations;
- if (next !== this._agentCustomizations) {
- this._agentCustomizations = next;
- this._onDidChange.fire();
- }
- }));
-
- this._register(this._connection.onDidAction(envelope => {
- if (envelope.action.type === ActionType.SessionCustomizationsChanged) {
- const customizations = (envelope.action as { customizations?: SessionCustomization[] }).customizations;
- if (customizations && customizations !== this._sessionCustomizations) {
- this._sessionCustomizations = customizations;
- this._onDidChange.fire();
- }
- }
- }));
- }
-
- private _readRootCustomizations(rootState: RootState | Error | undefined): readonly CustomizationRef[] | undefined {
- if (!rootState || rootState instanceof Error || !rootState.config) {
- return undefined;
- }
-
- return getAgentHostConfiguredCustomizations(rootState.config?.values);
- }
-
- private _readAgentCustomizations(rootState: RootState | Error | undefined): readonly CustomizationRef[] | undefined {
- if (!rootState || rootState instanceof Error) {
- return undefined;
- }
-
- return rootState.agents.find(agent => agent.provider === this._agentInfo.provider)?.customizations;
- }
-
- private toRemoteUri(customization: CustomizationRef): URI {
- const original = URI.parse(customization.uri);
- // The synthetic synced-customization bundle lives in the client's
- // in-memory filesystem. Don't wrap it as an agent-host:// URI —
- // the server doesn't have this scheme registered, so wrapping it
- // would make expansion (and any direct read) fail.
- if (original.scheme === SYNCED_CUSTOMIZATION_SCHEME) {
- return original;
- }
- return toAgentHostUri(original, this._connectionAuthority);
- }
-
- private toBadge(customization: CustomizationRef, fromClient: boolean): { badge?: string; badgeTooltip?: string; groupKey?: string } {
- if (fromClient) {
- return {
- groupKey: REMOTE_CLIENT_GROUP,
- };
- }
-
- return {
- groupKey: REMOTE_HOST_GROUP,
- };
- }
-
- private getRemovePluginAction(customization: CustomizationRef): ICustomizationItemAction {
- return {
- id: 'remoteAgentHost.removeConfiguredPlugin',
- label: localize('remoteAgentHost.removeConfiguredPlugin', "Remove from Remote Host"),
- icon: Codicon.trash,
- run: () => this._controller.removeConfiguredPlugin(customization),
- };
- }
-
- private toItem(customization: CustomizationRef, sessionCustomization?: SessionCustomization): ICustomizationItem {
- const clientId = sessionCustomization?.clientId; // set if the configuration came from the client
- const badge = this.toBadge(customization, clientId !== undefined);
- const uri = this.toRemoteUri(customization);
- return {
- itemKey: customizationItemKey(customization, clientId),
- uri: uri,
- type: 'plugin',
- name: customization.displayName,
- description: customization.description,
- storage: PromptsStorage.plugin,
- status: toStatusString(sessionCustomization?.status),
- statusMessage: sessionCustomization?.statusMessage,
- enabled: sessionCustomization?.enabled ?? true,
- badge: badge.badge,
- badgeTooltip: badge.badgeTooltip,
- groupKey: badge.groupKey,
- extensionId: undefined,
- pluginUri: uri,
- userInvocable: undefined,
- actions: clientId === undefined ? [this.getRemovePluginAction(customization)] : undefined,
- };
- }
-
- async provideChatSessionCustomizations(token: CancellationToken): Promise {
- const items = new Map();
-
- // Build parent plugin items keyed by customization ref
- type PluginMeta = { item: ICustomizationItem; nonce: string | undefined; status: ReturnType; statusMessage: string | undefined; enabled: boolean | undefined; childGroupKey: string; isBundleItem: boolean };
- const plugins: PluginMeta[] = [];
-
- for (const customization of this._agentCustomizations) {
- const item = this.toItem(customization);
- items.set(customizationItemKey(customization, undefined), item);
- plugins.push({ item, nonce: customization.nonce, status: undefined, statusMessage: undefined, enabled: undefined, childGroupKey: REMOTE_HOST_GROUP, isBundleItem: false });
- }
-
- for (const sessionCustomization of this._sessionCustomizations ?? []) {
- const isBundleItem = isSyntheticBundle(sessionCustomization.customization);
- const isClientSynced = sessionCustomization.clientId !== undefined;
- const childGroupKey = isClientSynced ? REMOTE_CLIENT_GROUP : REMOTE_HOST_GROUP;
-
- // Always show session customizations as distinct plugin entries —
- // client-synced items appear in the "Local" group, host-owned in
- // the "Remote" group. The synthetic bundle is an implementation
- // detail and is not shown as a standalone entry, but is still
- // expanded below so individual user files appear in per-type tabs.
- let item: ICustomizationItem;
- if (!isBundleItem) {
- item = this.toItem(sessionCustomization.customization, sessionCustomization);
- items.set(customizationItemKey(sessionCustomization.customization, sessionCustomization.clientId), item);
- } else {
- // create a dummy parent item for the synthetic bundle, it does not go into the items map, just need it to expand.
- item = { uri: this.toRemoteUri(sessionCustomization.customization), type: 'plugin', name: '', storage: PromptsStorage.plugin, groupKey: childGroupKey, extensionId: undefined, pluginUri: undefined };
- }
-
- // Always expand plugin contents so individual files are visible.
- plugins.push({
- item,
- nonce: sessionCustomization.customization.nonce,
- status: toStatusString(sessionCustomization.status),
- statusMessage: sessionCustomization.statusMessage,
- enabled: sessionCustomization.enabled,
- childGroupKey,
- isBundleItem,
- });
- }
-
- // Expand each plugin directory in parallel to discover individual
- // skills, agents, instructions, and prompts inside.
- const expansions = await Promise.all(plugins.map(p => this._expandPluginContents(p.item.uri, p.nonce, p.childGroupKey, p.isBundleItem, token)));
- if (token.isCancellationRequested) {
- return [];
- }
-
- for (let i = 0; i < plugins.length; i++) {
- const p = plugins[i];
- for (const child of expansions[i]) {
- // Children inherit the parent plugin's status/enabled state.
- items.set(`${p.item.itemKey ?? p.item.uri.toString()}::${child.type}::${child.name}`, {
- ...child,
- status: p.status,
- statusMessage: p.statusMessage,
- enabled: p.enabled,
- });
- }
- }
-
- return [...items.values()];
- }
-
- /**
- * Reads a plugin's directory contents through the agent-host
- * filesystem provider and returns one {@link ICustomizationItem} per
- * supported file (agents/skills/instructions/prompts).
- *
- * Cached by `(uri, nonce)`; a different nonce invalidates the entry.
- */
- private async _expandPluginContents(pluginUri: URI, nonce: string | undefined, groupKey: string, isBundleItem: boolean, token: CancellationToken): Promise {
- const cached = this._expansionCache.get(pluginUri);
- if (cached && cached.nonce === nonce) {
- return cached.children;
- }
-
- // pluginUri is already an agent-host:// URI (from toRemoteUri),
- // so use it directly as the filesystem root.
- const fsRoot = pluginUri;
- const children: ICustomizationItem[] = [];
- try {
- if (!await this._fileService.canHandleResource(fsRoot)) {
- return [];
- }
- if (token.isCancellationRequested) {
- return [];
- }
-
- const dirNames = ['agents', 'skills', 'commands', 'rules'] as const;
- const subdirs = dirNames.map(name => ({ name, resource: URI.joinPath(fsRoot, name) }));
- const stats = await this._fileService.resolveAll(subdirs.map(s => ({ resource: s.resource })));
-
- if (token.isCancellationRequested) {
- return [];
- }
-
- for (let i = 0; i < subdirs.length; i++) {
- const stat = stats[i];
- if (!stat.success || !stat.stat?.isDirectory || !stat.stat.children) {
- continue;
- }
- const promptType = promptsTypeForPluginDir(subdirs[i].name);
- if (!promptType) {
- continue;
- }
- children.push(...await this._collectFromTypeDir(stat.stat.children, pluginUri, promptType, groupKey, isBundleItem, token));
- }
- children.sort((a, b) => `${a.type}:${a.name}`.localeCompare(`${b.type}:${b.name}`));
- } catch (err) {
- this._logService.trace(`[RemoteAgentCustomizationItemProvider] Failed to expand plugin ${pluginUri.toString()}: ${err}`);
- return [];
- }
-
- this._expansionCache.set(pluginUri, { nonce, children });
- return children;
- }
-
- /**
- * Emits one {@link ICustomizationItem} per child of a per-type
- * sub-folder. Skills are conventionally folders containing
- * `SKILL.md`, and synced bundles may preserve per-skill
- * subdirectories; flat skill files can still appear for legacy
- * bundles, so both layouts are accepted.
- *
- * For skills, the `SKILL.md` frontmatter is read so that the item's
- * description (and a frontmatter-supplied name, when present) can be
- * surfaced — without it the UI would only show the folder name with
- * no description.
- */
- private async _collectFromTypeDir(entries: readonly { name: string; resource: URI; isDirectory: boolean }[], pluginUri: URI, promptType: PromptsType, groupKey: string, isBundleItem: boolean, token: CancellationToken): Promise {
- type Entry = { name: string; resource: URI; isDirectory: boolean };
- const eligible: Entry[] = [];
- for (const child of entries) {
- // Skip dotfiles (e.g. .DS_Store)
- if (child.name.startsWith('.')) {
- continue;
- }
- if (promptType !== PromptsType.skill && child.isDirectory) {
- continue;
- }
- eligible.push(child);
- }
-
- const skillMetadata = promptType === PromptsType.skill
- ? await Promise.all(eligible.map(child => this._readSkillMetadata(child, token)))
- : undefined;
- if (token.isCancellationRequested) {
- return [];
- }
-
- const items: ICustomizationItem[] = [];
- for (let i = 0; i < eligible.length; i++) {
- const child = eligible[i];
- let displayName: string;
- let description: string | undefined;
- let uri = child.resource;
- let userInvocable: boolean | undefined;
- if (promptType === PromptsType.skill) {
- const meta = skillMetadata![i];
- // For folder-style skills the canonical resource for the skill
- // is its `SKILL.md`; downstream code (slash-command resolution,
- // chat input decorations) calls `parseNew(item.uri)` and would
- // otherwise try to read the directory as a file. If we couldn't
- // read `SKILL.md`, skip the entry rather than emit a URI that
- // will fail to parse downstream.
- if (child.isDirectory) {
- if (!meta) {
- continue;
- }
- uri = joinPath(child.resource, SKILL_FILENAME);
- }
- const fallbackName = child.isDirectory ? child.name : stripPromptFileExtensions(child.name);
- displayName = meta?.name ?? fallbackName;
- description = meta?.description;
- userInvocable = meta?.userInvocable;
- } else {
- displayName = stripPromptFileExtensions(child.name);
- }
- items.push({
- uri,
- type: promptType,
- name: displayName,
- description,
- storage: PromptsStorage.plugin,
- groupKey,
- extensionId: undefined,
- pluginUri: isBundleItem ? undefined : pluginUri,
- userInvocable
- } satisfies ICustomizationItem);
- }
- return items;
- }
-
- /**
- * Reads `SKILL.md` for a skill entry and returns its frontmatter
- * `name` / `description`. Returns `undefined` when the file cannot
- * be read or parsed — the caller falls back to the folder name and
- * leaves the description empty.
- */
- private async _readSkillMetadata(entry: { name: string; resource: URI; isDirectory: boolean }, token: CancellationToken): Promise<{ name: string | undefined; description: string | undefined; userInvocable: boolean | undefined } | undefined> {
- const skillFileUri = entry.isDirectory ? joinPath(entry.resource, SKILL_FILENAME) : entry.resource;
- try {
- const content = await this._fileService.readFile(skillFileUri);
- if (token.isCancellationRequested) {
+export function createRemoteAgentCustomizationItemProvider(
+ agentInfo: AgentInfo,
+ connection: IAgentConnection,
+ connectionAuthority: string,
+ controller: RemoteAgentPluginController,
+ fileService: IFileService,
+ logService: ILogService,
+): AgentCustomizationItemProvider {
+ return new AgentCustomizationItemProvider(
+ agentInfo,
+ connection,
+ connectionAuthority,
+ fileService,
+ logService,
+ (customization, clientId) => {
+ if (clientId !== undefined) {
+ // Customization came from the client; we don't allow actions on these since they're read-only reflections of client state.
return undefined;
}
- const parsed = new PromptFileParser().parse(skillFileUri, content.value.toString());
- return { name: parsed.header?.name, description: parsed.header?.description, userInvocable: parsed.header?.userInvocable };
- } catch (err) {
- this._logService.trace(`[RemoteAgentCustomizationItemProvider] Failed to read skill metadata ${skillFileUri.toString()}: ${err}`);
- return undefined;
- }
- }
+ return [{
+ id: 'remoteAgentHost.removeConfiguredPlugin',
+ label: localize('remoteAgentHost.removeConfiguredPlugin', "Remove from Remote Host"),
+ icon: Codicon.trash,
+ run: () => controller.removeConfiguredPlugin(customization),
+ }];
+ },
+ );
}
/**
@@ -553,7 +175,7 @@ export function createRemoteAgentHarnessDescriptor(
harnessId: string,
displayName: string,
controller: RemoteAgentPluginController,
- itemProvider: RemoteAgentCustomizationItemProvider,
+ itemProvider: AgentCustomizationItemProvider,
syncProvider: AgentCustomizationSyncProvider,
): IHarnessDescriptor {
const allSources = [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, BUILTIN_STORAGE];
diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts
index c6c60f55b7c6a9..0f626f66c8a847 100644
--- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts
+++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts
@@ -20,7 +20,12 @@ import { INotificationService } from '../../../../../../platform/notification/co
import { URI } from '../../../../../../base/common/uri.js';
import { IAICustomizationWorkspaceService } from '../../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';
import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js';
-import { RemoteAgentCustomizationItemProvider, RemoteAgentPluginController } from '../../browser/remoteAgentHostCustomizationHarness.js';
+import { createRemoteAgentCustomizationItemProvider, RemoteAgentPluginController } from '../../browser/remoteAgentHostCustomizationHarness.js';
+import { CustomizationHarnessServiceBase, IHarnessDescriptor } from '../../../../../../workbench/contrib/chat/common/customizationHarnessService.js';
+import { PromptsStorage } from '../../../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js';
+import { MockPromptsService } from '../../../../../../workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.js';
+import { ThemeIcon } from '../../../../../../base/common/themables.js';
+import { Codicon } from '../../../../../../base/common/codicons.js';
class MockAgentConnection extends mock() {
declare readonly _serviceBrand: undefined;
@@ -140,7 +145,7 @@ suite('RemoteAgentHostCustomizationHarness', () => {
override async resolveAll() { return []; }
};
- const provider = disposables.add(new RemoteAgentCustomizationItemProvider(
+ const provider = disposables.add(createRemoteAgentCustomizationItemProvider(
createAgentInfo([pluginA, pluginB]),
connection,
'test-authority',
@@ -180,7 +185,7 @@ suite('RemoteAgentHostCustomizationHarness', () => {
override async resolveAll() { return []; }
};
- const provider = disposables.add(new RemoteAgentCustomizationItemProvider(
+ const provider = disposables.add(createRemoteAgentCustomizationItemProvider(
createAgentInfo([hostScoped]),
connection,
'test-authority',
@@ -231,7 +236,7 @@ suite('RemoteAgentHostCustomizationHarness', () => {
override async resolveAll() { return []; }
};
- const provider = disposables.add(new RemoteAgentCustomizationItemProvider(
+ const provider = disposables.add(createRemoteAgentCustomizationItemProvider(
createAgentInfo([hostPlugin]),
connection,
'test-authority',
@@ -329,7 +334,7 @@ suite('RemoteAgentHostCustomizationHarness', () => {
}
};
- const provider = disposables.add(new RemoteAgentCustomizationItemProvider(
+ const provider = disposables.add(createRemoteAgentCustomizationItemProvider(
createAgentInfo([]),
connection,
'test-authority',
@@ -383,7 +388,7 @@ suite('RemoteAgentHostCustomizationHarness', () => {
override async resolveAll() { return []; }
};
- const provider = disposables.add(new RemoteAgentCustomizationItemProvider(
+ const provider = disposables.add(createRemoteAgentCustomizationItemProvider(
createAgentInfo([]),
connection,
'test-authority',
@@ -437,7 +442,7 @@ suite('RemoteAgentHostCustomizationHarness', () => {
override async resolveAll() { return []; }
};
- const provider = disposables.add(new RemoteAgentCustomizationItemProvider(
+ const provider = disposables.add(createRemoteAgentCustomizationItemProvider(
createAgentInfo([pluginRef]),
connection,
'test-authority',
@@ -483,7 +488,7 @@ suite('RemoteAgentHostCustomizationHarness', () => {
override async resolveAll() { return []; }
};
- const provider = disposables.add(new RemoteAgentCustomizationItemProvider(
+ const provider = disposables.add(createRemoteAgentCustomizationItemProvider(
createAgentInfo([pluginRef]),
connection,
'test-authority',
@@ -532,7 +537,7 @@ suite('RemoteAgentHostCustomizationHarness', () => {
override async resolveAll() { return []; }
};
- const provider = disposables.add(new RemoteAgentCustomizationItemProvider(
+ const provider = disposables.add(createRemoteAgentCustomizationItemProvider(
createAgentInfo([hostPlugin]),
connection,
'test-authority',
@@ -620,7 +625,7 @@ suite('RemoteAgentHostCustomizationHarness', () => {
override async resolveAll() { return []; }
};
- const provider = disposables.add(new RemoteAgentCustomizationItemProvider(
+ const provider = disposables.add(createRemoteAgentCustomizationItemProvider(
createAgentInfo([]),
connection,
'test-authority',
@@ -696,7 +701,7 @@ suite('RemoteAgentHostCustomizationHarness', () => {
}
};
- const provider = disposables.add(new RemoteAgentCustomizationItemProvider(
+ const provider = disposables.add(createRemoteAgentCustomizationItemProvider(
createAgentInfo([plugin]),
connection,
'test-authority',
@@ -715,5 +720,78 @@ suite('RemoteAgentHostCustomizationHarness', () => {
{ name: 'legacy', description: undefined, uri: 'vscode-agent-host://test/plugins/skills-bundle/skills/legacy.skill.md' },
].sort((a, b) => a.name.localeCompare(b.name)),
);
+
+ // Each expanded (non-bundle) item must carry a `pluginUri` so that
+ // downstream slash-command resolution can build a `plugin:`-prefixed
+ // command id via `getCanonicalPluginCommandId`.
+ const expectedPluginUri = 'vscode-agent-host://test-authority/file/-/plugins/skills-bundle';
+ for (const skillItem of skillItems) {
+ assert.strictEqual(skillItem.pluginUri?.toString(), expectedPluginUri, `skill ${skillItem.name} should carry pluginUri`);
+ }
+ });
+
+ test('CustomizationHarnessService.getSlashCommands prefixes discovered skill names with the plugin id', async () => {
+ const connection = disposables.add(new MockAgentConnection());
+ const controller = disposables.add(new RemoteAgentPluginController(
+ 'Test Host',
+ 'test-authority',
+ connection,
+ {} as IFileDialogService,
+ createNotificationService(),
+ {} as IAICustomizationWorkspaceService,
+ ));
+ const plugin: CustomizationRef = { uri: 'file:///plugins/skills-bundle', displayName: 'Skills Bundle' };
+
+ connection.setRootState({ agents: [createAgentInfo([plugin])] });
+
+ const skillsDirChildren: IFileStat[] = [
+ { name: 'lint', resource: URI.parse('vscode-agent-host://test/plugins/skills-bundle/skills/lint'), isFile: false, isDirectory: true, isSymbolicLink: false, children: undefined },
+ ];
+
+ const fileService = new class extends mock() {
+ override async canHandleResource() { return true; }
+ override async resolveAll(toResolve: { resource: URI }[]): Promise {
+ return toResolve.map(({ resource }) => {
+ if (resource.path.endsWith('/skills')) {
+ return {
+ success: true,
+ stat: { name: 'skills', resource, isFile: false, isDirectory: true, isSymbolicLink: false, children: skillsDirChildren },
+ };
+ }
+ return { success: false };
+ });
+ }
+ override async readFile(resource: URI): Promise {
+ if (resource.path.endsWith('/lint/SKILL.md')) {
+ const content = '---\nname: Lint\ndescription: A lint skill\n---\n';
+ return { resource, name: 'SKILL.md', value: VSBuffer.fromString(content), mtime: 0, ctime: 0, etag: '', size: content.length, readonly: false, locked: false, executable: false };
+ }
+ throw new Error('ENOENT');
+ }
+ };
+
+ const provider = disposables.add(createRemoteAgentCustomizationItemProvider(
+ createAgentInfo([plugin]),
+ connection,
+ 'test-authority',
+ controller,
+ fileService,
+ new NullLogService(),
+ ));
+
+ const harnessId = 'remote-agent-host-test';
+ const descriptor: IHarnessDescriptor = {
+ id: harnessId,
+ label: 'Remote Agent Host (test)',
+ icon: ThemeIcon.fromId(Codicon.remote.id),
+ getStorageSourceFilter: () => ({ sources: [PromptsStorage.plugin] }),
+ itemProvider: provider,
+ };
+ const harnessService = disposables.add(new CustomizationHarnessServiceBase([descriptor], harnessId, new MockPromptsService()));
+
+ const commands = await harnessService.getSlashCommands(harnessId, CancellationToken.None);
+ const skillCommand = commands.find(c => c.type === PromptsType.skill);
+ assert.ok(skillCommand, 'should have a skill slash command');
+ assert.strictEqual(skillCommand.name, 'skills-bundle:lint', 'skill command name should be plugin-prefixed');
});
});
diff --git a/src/vs/sessions/electron-browser/parts/titlebarPart.ts b/src/vs/sessions/electron-browser/parts/titlebarPart.ts
index 638f04ce2c2fae..ae368d1bf22c1c 100644
--- a/src/vs/sessions/electron-browser/parts/titlebarPart.ts
+++ b/src/vs/sessions/electron-browser/parts/titlebarPart.ts
@@ -4,7 +4,10 @@
*--------------------------------------------------------------------------------------------*/
import { getZoomFactor } from '../../../base/browser/browser.js';
-import { getWindow, getWindowId } from '../../../base/browser/dom.js';
+import { $, addDisposableListener, append, EventType, getWindow, getWindowId, hide, show } from '../../../base/browser/dom.js';
+import { Codicon } from '../../../base/common/codicons.js';
+import { Event } from '../../../base/common/event.js';
+import { ThemeIcon } from '../../../base/common/themables.js';
import { IConfigurationService } from '../../../platform/configuration/common/configuration.js';
import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js';
import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js';
@@ -13,7 +16,7 @@ import { INativeHostService } from '../../../platform/native/common/native.js';
import { IProductService } from '../../../platform/product/common/productService.js';
import { IStorageService } from '../../../platform/storage/common/storage.js';
import { IThemeService } from '../../../platform/theme/common/themeService.js';
-import { useWindowControlsOverlay } from '../../../platform/window/common/window.js';
+import { hasNativeTitlebar, useWindowControlsOverlay } from '../../../platform/window/common/window.js';
import { IsWindowAlwaysOnTopContext } from '../../../workbench/common/contextkeys.js';
import { IHostService } from '../../../workbench/services/host/browser/host.js';
import { IWorkbenchLayoutService, Parts } from '../../../workbench/services/layout/browser/layoutService.js';
@@ -21,11 +24,14 @@ import { IAuxiliaryTitlebarPart } from '../../../workbench/browser/parts/titleba
import { IEditorGroupsContainer } from '../../../workbench/services/editor/common/editorGroupsService.js';
import { CodeWindow, mainWindow } from '../../../base/browser/window.js';
import { TitlebarPart, TitleService } from '../../browser/parts/titlebarPart.js';
-import { isMacintosh } from '../../../base/common/platform.js';
+import { isMacintosh, isWindows } from '../../../base/common/platform.js';
import { localize } from '../../../nls.js';
export class NativeTitlebarPart extends TitlebarPart {
+ private maxRestoreControl: HTMLElement | undefined;
+ private resizer: HTMLElement | undefined;
+
private cachedWindowControlStyles: { bgColor: string; fgColor: string } | undefined;
private cachedWindowControlHeight: number | undefined;
@@ -64,7 +70,83 @@ export class NativeTitlebarPart extends TitlebarPart {
}
window.document.title = agentsTitle;
- return super.createContentArea(parent);
+ const result = super.createContentArea(parent);
+ const targetWindow = getWindow(parent);
+ const targetWindowId = getWindowId(targetWindow);
+
+ // Custom Window Controls (Native Windows/Linux) when window.controlsStyle is "custom"
+ if (
+ !hasNativeTitlebar(this.configurationService) && // not for native title bars
+ !useWindowControlsOverlay(this.configurationService) && // not when controls are natively drawn
+ this.windowControlsContainer
+ ) {
+
+ // Minimize
+ const minimizeIcon = append(this.windowControlsContainer, $('div.window-icon.window-minimize' + ThemeIcon.asCSSSelector(Codicon.chromeMinimize)));
+ this._register(addDisposableListener(minimizeIcon, EventType.CLICK, () => {
+ this.nativeHostService.minimizeWindow({ targetWindowId });
+ }));
+
+ // Restore
+ this.maxRestoreControl = append(this.windowControlsContainer, $('div.window-icon.window-max-restore'));
+ this._register(addDisposableListener(this.maxRestoreControl, EventType.CLICK, async () => {
+ const maximized = await this.nativeHostService.isMaximized({ targetWindowId });
+ if (maximized) {
+ return this.nativeHostService.unmaximizeWindow({ targetWindowId });
+ }
+
+ return this.nativeHostService.maximizeWindow({ targetWindowId });
+ }));
+
+ // Close
+ const closeIcon = append(this.windowControlsContainer, $('div.window-icon.window-close' + ThemeIcon.asCSSSelector(Codicon.chromeClose)));
+ this._register(addDisposableListener(closeIcon, EventType.CLICK, () => {
+ this.nativeHostService.closeWindow({ targetWindowId });
+ }));
+
+ // Resizer
+ this.resizer = append(this.rootContainer, $('div.resizer'));
+ this._register(Event.runAndSubscribe(this.layoutService.onDidChangeWindowMaximized, ({ windowId, maximized }) => {
+ if (windowId === targetWindowId) {
+ this.onDidChangeWindowMaximized(maximized);
+ }
+ }, { windowId: targetWindowId, maximized: this.layoutService.isWindowMaximized(targetWindow) }));
+ }
+
+ // Window System Context Menu
+ // See https://github.com/electron/electron/issues/24893
+ if (isWindows && !hasNativeTitlebar(this.configurationService)) {
+ this._register(this.nativeHostService.onDidTriggerWindowSystemContextMenu(({ windowId, x, y }) => {
+ if (targetWindowId !== windowId) {
+ return;
+ }
+
+ const zoomFactor = getZoomFactor(getWindow(this.element));
+ this.onContextMenu(new MouseEvent(EventType.MOUSE_UP, { clientX: x / zoomFactor, clientY: y / zoomFactor }));
+ }));
+ }
+
+ return result;
+ }
+
+ private onDidChangeWindowMaximized(maximized: boolean): void {
+ if (this.maxRestoreControl) {
+ if (maximized) {
+ this.maxRestoreControl.classList.remove(...ThemeIcon.asClassNameArray(Codicon.chromeMaximize));
+ this.maxRestoreControl.classList.add(...ThemeIcon.asClassNameArray(Codicon.chromeRestore));
+ } else {
+ this.maxRestoreControl.classList.remove(...ThemeIcon.asClassNameArray(Codicon.chromeRestore));
+ this.maxRestoreControl.classList.add(...ThemeIcon.asClassNameArray(Codicon.chromeMaximize));
+ }
+ }
+
+ if (this.resizer) {
+ if (maximized) {
+ hide(this.resizer);
+ } else {
+ show(this.resizer);
+ }
+ }
}
private async handleWindowsAlwaysOnTop(targetWindowId: number, contextKeyService: IContextKeyService): Promise {
diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts
index 4331eaaead95f4..829e4ac43bba50 100644
--- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts
+++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts
@@ -142,7 +142,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen
private onDidReplaceSession(from: ISession, to: ISession): void {
if (this._activeSession.get()?.sessionId === from.sessionId) {
- this.setActiveSession(to);
+ this.setActiveSession(to, /* force */ true);
}
// Always fire the change event so the SessionsList refreshes even when
// the user navigated to a different session while the new one was
@@ -430,9 +430,9 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen
this._isNewChatInSessionContext.set(true);
}
- private setActiveSession(session: ISession | undefined): void {
+ private setActiveSession(session: ISession | undefined, force?: boolean): void {
const previousSession = this._activeSession.get();
- if (previousSession?.sessionId === session?.sessionId) {
+ if (!force && previousSession?.sessionId === session?.sessionId) {
return;
}
@@ -478,10 +478,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen
// Create the active chat observable
const activeChatObs = observableValue(`activeChat-${session.sessionId}`, initialChat);
this._activeChatObservable = activeChatObs;
- const activeSession: IActiveSession = {
- ...session,
- activeChat: activeChatObs,
- };
+ const activeSession = new ActiveSession(session, activeChatObs);
this._activeSession.set(activeSession, undefined);
@@ -705,6 +702,43 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen
}
}
+/**
+ * Wraps an {@link ISession} with an active chat observable to form an
+ * {@link IActiveSession}. Delegates all {@link ISession} property accesses
+ * to the wrapped session so the active session always reflects the latest
+ * session state without a stale shallow copy.
+ */
+class ActiveSession implements IActiveSession {
+ constructor(
+ private readonly _session: ISession,
+ readonly activeChat: IObservable,
+ ) { }
+
+ get sessionId() { return this._session.sessionId; }
+ get resource() { return this._session.resource; }
+ get providerId() { return this._session.providerId; }
+ get sessionType() { return this._session.sessionType; }
+ get icon() { return this._session.icon; }
+ get createdAt() { return this._session.createdAt; }
+ get workspace() { return this._session.workspace; }
+ get title() { return this._session.title; }
+ get updatedAt() { return this._session.updatedAt; }
+ get status() { return this._session.status; }
+ get changes() { return this._session.changes; }
+ get changesets() { return this._session.changesets; }
+ get modelId() { return this._session.modelId; }
+ get mode() { return this._session.mode; }
+ get loading() { return this._session.loading; }
+ get isArchived() { return this._session.isArchived; }
+ get isRead() { return this._session.isRead; }
+ get description() { return this._session.description; }
+ get lastTurnEnd() { return this._session.lastTurnEnd; }
+ get chats() { return this._session.chats; }
+ get mainChat() { return this._session.mainChat; }
+ get capabilities() { return this._session.capabilities; }
+ get deduplicationKey() { return this._session.deduplicationKey; }
+}
+
registerSingleton(ISessionsManagementService, SessionsManagementService, InstantiationType.Delayed);
/**
diff --git a/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts b/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts
index 08d5b729a21092..0f7f57ab656e3b 100644
--- a/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts
+++ b/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts
@@ -7,6 +7,7 @@ import { VSBuffer } from '../../../base/common/buffer.js';
import { Disposable, IDisposable } from '../../../base/common/lifecycle.js';
import { URI, UriComponents } from '../../../base/common/uri.js';
import { ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js';
+import { ILogService } from '../../../platform/log/common/log.js';
import { IChatOutputRendererService } from '../../contrib/chat/browser/chatOutputItemRenderer.js';
import { IExtHostContext } from '../../services/extensions/common/extHostCustomers.js';
import { ExtHostChatOutputRendererShape, ExtHostContext, MainThreadChatOutputRendererShape } from '../common/extHost.protocol.js';
@@ -24,6 +25,7 @@ export class MainThreadChatOutputRenderer extends Disposable implements MainThre
extHostContext: IExtHostContext,
private readonly _mainThreadWebview: MainThreadWebviews,
@IChatOutputRendererService private readonly _rendererService: IChatOutputRendererService,
+ @ILogService private readonly _logService: ILogService,
) {
super();
this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostChatOutputRenderer);
@@ -37,22 +39,30 @@ export class MainThreadChatOutputRenderer extends Disposable implements MainThre
}
$registerChatOutputRenderer(viewType: string, extensionId: ExtensionIdentifier, extensionLocation: UriComponents): void {
- this._rendererService.registerRenderer(viewType, {
- renderOutputPart: async (mime, data, webview, token) => {
+ const existingRegistration = this.registeredRenderers.get(viewType);
+ if (existingRegistration) {
+ this._logService.warn(`Re-registering chat output renderer for view type '${viewType}' from extension '${extensionId.value}'.`);
+ existingRegistration.dispose();
+ }
+
+ const disposable = this._rendererService.registerRenderer(viewType, {
+ renderOutputPart: async (mime, data, webview, context, token) => {
const webviewHandle = `chat-output-${++this._webviewHandlePool}`;
this._mainThreadWebview.addWebview(webviewHandle, webview, {
serializeBuffersForPostMessage: true,
});
- return this._proxy.$renderChatOutput(viewType, mime, VSBuffer.wrap(data), webviewHandle, token);
+ return this._proxy.$renderChatOutput(viewType, mime, VSBuffer.wrap(data), webviewHandle, context, token);
},
}, {
extension: { id: extensionId, location: URI.revive(extensionLocation) }
});
+ this.registeredRenderers.set(viewType, disposable);
}
$unregisterChatOutputRenderer(viewType: string): void {
this.registeredRenderers.get(viewType)?.dispose();
+ this.registeredRenderers.delete(viewType);
}
}
diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts
index 05f5ff408eb2c8..4a874a6e9ad93f 100644
--- a/src/vs/workbench/api/common/extHost.protocol.ts
+++ b/src/vs/workbench/api/common/extHost.protocol.ts
@@ -1894,8 +1894,14 @@ export interface MainThreadChatOutputRendererShape extends IDisposable {
$unregisterChatOutputRenderer(viewType: string): void;
}
+export interface IChatOutputRenderContextDto {
+ readonly codeBlockContext?: {
+ readonly languageIdentifier: string;
+ };
+}
+
export interface ExtHostChatOutputRendererShape {
- $renderChatOutput(viewType: string, mime: string, valueData: VSBuffer, webviewHandle: string, token: CancellationToken): Promise;
+ $renderChatOutput(viewType: string, mime: string, valueData: VSBuffer, webviewHandle: string, context: IChatOutputRenderContextDto, token: CancellationToken): Promise;
}
export interface MainThreadProfileContentHandlersShape {
diff --git a/src/vs/workbench/api/common/extHostChatOutputRenderer.ts b/src/vs/workbench/api/common/extHostChatOutputRenderer.ts
index 444faf9e46f9b6..8ecaa31b69a5ed 100644
--- a/src/vs/workbench/api/common/extHostChatOutputRenderer.ts
+++ b/src/vs/workbench/api/common/extHostChatOutputRenderer.ts
@@ -5,7 +5,7 @@
import type * as vscode from 'vscode';
import { CancellationToken } from '../../../base/common/cancellation.js';
-import { ExtHostChatOutputRendererShape, IMainContext, MainContext, MainThreadChatOutputRendererShape } from './extHost.protocol.js';
+import { ExtHostChatOutputRendererShape, type IChatOutputRenderContextDto, IMainContext, MainContext, MainThreadChatOutputRendererShape } from './extHost.protocol.js';
import { Disposable } from './extHostTypes.js';
import { ExtHostWebviews } from './extHostWebview.js';
import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js';
@@ -41,7 +41,7 @@ export class ExtHostChatOutputRenderer implements ExtHostChatOutputRendererShape
});
}
- async $renderChatOutput(viewType: string, mime: string, valueData: VSBuffer, webviewHandle: string, token: CancellationToken): Promise {
+ async $renderChatOutput(viewType: string, mime: string, valueData: VSBuffer, webviewHandle: string, context: IChatOutputRenderContextDto, token: CancellationToken): Promise {
const entry = this._renderers.get(viewType);
if (!entry) {
throw new Error(`No chat output renderer registered for: ${viewType}`);
@@ -52,6 +52,6 @@ export class ExtHostChatOutputRenderer implements ExtHostChatOutputRendererShape
webview: extHostWebview,
onDidDispose: extHostWebview._onDidDispose,
});
- return entry.renderer.renderChatOutput(Object.freeze({ mime, value: valueData.buffer }), chatOutputWebview, {}, token);
+ return entry.renderer.renderChatOutput(Object.freeze({ mime, value: valueData.buffer }), chatOutputWebview, Object.freeze(context), token);
}
}
diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts
index 17eeef6f4ab983..107cc44b4af9c7 100644
--- a/src/vs/workbench/api/common/extHostLanguageModels.ts
+++ b/src/vs/workbench/api/common/extHostLanguageModels.ts
@@ -19,7 +19,6 @@ import { createDecorator } from '../../../platform/instantiation/common/instanti
import { ILogService } from '../../../platform/log/common/log.js';
import { Progress } from '../../../platform/progress/common/progress.js';
import { IChatMessage, IChatResponsePart, ILanguageModelChatInfoOptions, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatRequestOptions } from '../../contrib/chat/common/languageModels.js';
-import { DEFAULT_MODEL_PICKER_CATEGORY } from '../../contrib/chat/common/widget/input/modelPickerWidget.js';
import { INTERNAL_AUTH_PROVIDER_PREFIX } from '../../services/authentication/common/authentication.js';
import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js';
import { SerializableObjectWithBuffers } from '../../services/extensions/common/proxyIdentifier.js';
@@ -235,7 +234,6 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape {
statusIcon: m.statusIcon,
targetChatSessionType: m.targetChatSessionType,
configurationSchema: m.configurationSchema as IJSONSchema | undefined,
- modelPickerCategory: m.category ?? DEFAULT_MODEL_PICKER_CATEGORY,
capabilities: m.capabilities ? {
vision: m.capabilities.imageInput,
editTools: m.capabilities.editTools,
diff --git a/src/vs/workbench/browser/parts/editor/editor.contribution.ts b/src/vs/workbench/browser/parts/editor/editor.contribution.ts
index f7f69c514bea55..55b2e787779329 100644
--- a/src/vs/workbench/browser/parts/editor/editor.contribution.ts
+++ b/src/vs/workbench/browser/parts/editor/editor.contribution.ts
@@ -424,7 +424,7 @@ MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: TOGGLE_DIFF_SID
MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: SHOW_EDITORS_IN_GROUP, title: localize('showOpenedEditors', "Show Opened Editors") }, group: '3_open', order: 10, when: EditorPartModalContext.toNegated() /* not applicable to modal editor */ });
MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: CLOSE_EDITORS_IN_GROUP_COMMAND_ID, title: localize('closeAll', "Close All") }, group: '5_close', order: 10, when: EditorPartModalContext.toNegated() /* not applicable to modal editor */ });
MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: CLOSE_SAVED_EDITORS_COMMAND_ID, title: localize('closeAllSaved', "Close Saved") }, group: '5_close', order: 20, when: EditorPartModalContext.toNegated() /* not applicable to modal editor */ });
-MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: TOGGLE_KEEP_EDITORS_COMMAND_ID, title: localize('togglePreviewMode', "Enable Preview Editors"), toggled: ContextKeyExpr.has('config.workbench.editor.enablePreview') }, group: '7_settings', order: 10 });
+MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: TOGGLE_KEEP_EDITORS_COMMAND_ID, title: localize('togglePreviewMode', "Enable Preview Editors"), toggled: ContextKeyExpr.has('config.workbench.editor.enablePreview') }, group: '7_settings', order: 10, when: EditorPartModalContext.toNegated() /* not applicable to modal editor */ });
MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: TOGGLE_MAXIMIZE_EDITOR_GROUP, title: localize('maximizeGroup', "Maximize Group") }, group: '8_group_operations', order: 5, when: ContextKeyExpr.and(EditorPartMaximizedEditorGroupContext.negate(), EditorPartMultipleEditorGroupsContext) });
MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: TOGGLE_MAXIMIZE_EDITOR_GROUP, title: localize('unmaximizeGroup', "Unmaximize Group") }, group: '8_group_operations', order: 5, when: EditorPartMaximizedEditorGroupContext });
MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: TOGGLE_LOCK_GROUP_COMMAND_ID, title: localize('lockGroup', "Lock Group"), toggled: ActiveEditorGroupLockedContext }, group: '8_group_operations', order: 10, when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), EditorPartModalContext.toNegated()) /* already a primary action for aux windows, not applicable to modal editor */ });
diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts
index d03b23148a5005..6a984815807c06 100644
--- a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts
+++ b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts
@@ -216,6 +216,10 @@ export class BrowserTitleService extends MultiWindowParts i
}
}
+ get windowTitle(): WindowTitle {
+ return this.mainPart.windowTitle;
+ }
+
//#endregion
}
@@ -292,7 +296,7 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart {
private readonly isCompactContextKey: IContextKey;
- private readonly windowTitle: WindowTitle;
+ readonly windowTitle: WindowTitle;
protected readonly instantiationService: IInstantiationService;
diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts
new file mode 100644
index 00000000000000..27a88354d62ebc
--- /dev/null
+++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts
@@ -0,0 +1,416 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { CancellationToken } from '../../../../../../base/common/cancellation.js';
+import { Disposable } from '../../../../../../base/common/lifecycle.js';
+import { Emitter, Event } from '../../../../../../base/common/event.js';
+import { ResourceMap } from '../../../../../../base/common/map.js';
+import { extname } from '../../../../../../base/common/path.js';
+import { joinPath } from '../../../../../../base/common/resources.js';
+import { URI } from '../../../../../../base/common/uri.js';
+import { CustomizationStatus, type SessionCustomization, type AgentInfo, type CustomizationRef, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js';
+import { ILogService } from '../../../../../../platform/log/common/log.js';
+import { ICustomizationItem, ICustomizationItemAction, ICustomizationItemProvider } from '../../../common/customizationHarnessService.js';
+import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js';
+import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../services/agentHost/common/agentHostFileSystemService.js';
+import { IFileService } from '../../../../../../platform/files/common/files.js';
+import { type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js';
+import { ActionType } from '../../../../../../platform/agentHost/common/state/sessionActions.js';
+import { getAgentHostConfiguredCustomizations } from '../../../../../../platform/agentHost/common/agentHostCustomizationConfig.js';
+import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js';
+import { SKILL_FILENAME } from '../../../common/promptSyntax/config/promptFileLocations.js';
+import { PromptFileParser } from '../../../common/promptSyntax/promptFileParser.js';
+import { PromptsType } from '../../../common/promptSyntax/promptTypes.js';
+
+
+const REMOTE_HOST_GROUP = 'remote-host';
+const REMOTE_CLIENT_GROUP = 'remote-client';
+
+
+export class AgentCustomizationItemProvider extends Disposable implements ICustomizationItemProvider {
+ private readonly _onDidChange = this._register(new Emitter());
+ readonly onDidChange: Event = this._onDidChange.event;
+
+ private _agentCustomizations: readonly CustomizationRef[];
+ private _sessionCustomizations: readonly SessionCustomization[] | undefined;
+
+ /** Cache: pluginUri → last expansion (keyed by nonce so we re-fetch on content change). */
+ private readonly _expansionCache = new ResourceMap<{ nonce: string | undefined; children: readonly ICustomizationItem[] }>();
+
+ constructor(
+ private readonly _agentInfo: AgentInfo,
+ connection: IAgentConnection,
+ private readonly _connectionAuthority: string,
+ private readonly _fileService: IFileService,
+ private readonly _logService: ILogService,
+ private readonly _getItemActions?: (customization: CustomizationRef, clientId: string | undefined) => ICustomizationItemAction[] | undefined,
+ ) {
+ super();
+ const rootStateSubscription = connection.rootState;
+ this._agentCustomizations = this._readRootCustomizations(rootStateSubscription.value) ?? this._agentInfo.customizations ?? [];
+
+ this._register(rootStateSubscription.onDidChange(rootState => {
+ const next = this._readRootCustomizations(rootState) ?? this._readAgentCustomizations(rootState) ?? this._agentCustomizations;
+ if (next !== this._agentCustomizations) {
+ this._agentCustomizations = next;
+ this._onDidChange.fire();
+ }
+ }));
+
+ this._register(connection.onDidAction(envelope => {
+ if (envelope.action.type === ActionType.SessionCustomizationsChanged) {
+ const customizations = envelope.action.customizations;
+ if (customizations !== this._sessionCustomizations) {
+ this._sessionCustomizations = customizations;
+ this._onDidChange.fire();
+ }
+ }
+ }));
+ }
+
+
+ private _readRootCustomizations(rootState: RootState | Error | undefined): readonly CustomizationRef[] | undefined {
+ if (!rootState || rootState instanceof Error || !rootState.config) {
+ return undefined;
+ }
+
+ return getAgentHostConfiguredCustomizations(rootState.config?.values);
+ }
+
+ private _readAgentCustomizations(rootState: RootState | Error | undefined): readonly CustomizationRef[] | undefined {
+ if (!rootState || rootState instanceof Error) {
+ return undefined;
+ }
+
+ return rootState.agents.find(agent => agent.provider === this._agentInfo.provider)?.customizations;
+ }
+
+ private toRemoteUri(customization: CustomizationRef): URI {
+ const original = URI.parse(customization.uri);
+ // The synthetic synced-customization bundle lives in the client's
+ // in-memory filesystem. Don't wrap it as an agent-host:// URI —
+ // the server doesn't have this scheme registered, so wrapping it
+ // would make expansion (and any direct read) fail.
+ if (original.scheme === SYNCED_CUSTOMIZATION_SCHEME) {
+ return original;
+ }
+ return toAgentHostUri(original, this._connectionAuthority);
+ }
+
+ private toBadge(customization: CustomizationRef, fromClient: boolean): { badge?: string; badgeTooltip?: string; groupKey?: string } {
+ if (fromClient) {
+ return {
+ groupKey: REMOTE_CLIENT_GROUP,
+ };
+ }
+
+ return {
+ groupKey: REMOTE_HOST_GROUP,
+ };
+ }
+
+ private toItem(customization: CustomizationRef, sessionCustomization?: SessionCustomization): ICustomizationItem {
+ const clientId = sessionCustomization?.clientId; // set if the configuration came from the client
+ const badge = this.toBadge(customization, clientId !== undefined);
+ const uri = this.toRemoteUri(customization);
+ return {
+ itemKey: customizationItemKey(customization, clientId),
+ uri: uri,
+ type: 'plugin',
+ name: customization.displayName,
+ description: customization.description,
+ storage: PromptsStorage.plugin,
+ status: toStatusString(sessionCustomization?.status),
+ statusMessage: sessionCustomization?.statusMessage,
+ enabled: sessionCustomization?.enabled ?? true,
+ badge: badge.badge,
+ badgeTooltip: badge.badgeTooltip,
+ groupKey: badge.groupKey,
+ extensionId: undefined,
+ pluginUri: uri,
+ userInvocable: undefined,
+ actions: this._getItemActions?.(customization, clientId),
+ };
+ }
+
+ async provideChatSessionCustomizations(token: CancellationToken): Promise {
+ const items = new Map();
+
+ // Build parent plugin items keyed by customization ref
+ type PluginMeta = { item: ICustomizationItem; nonce: string | undefined; status: ReturnType; statusMessage: string | undefined; enabled: boolean | undefined; childGroupKey: string; isBundleItem: boolean };
+ const plugins: PluginMeta[] = [];
+
+ for (const customization of this._agentCustomizations) {
+ const item = this.toItem(customization);
+ items.set(customizationItemKey(customization, undefined), item);
+ plugins.push({ item, nonce: customization.nonce, status: undefined, statusMessage: undefined, enabled: undefined, childGroupKey: REMOTE_HOST_GROUP, isBundleItem: false });
+ }
+
+ for (const sessionCustomization of this._sessionCustomizations ?? []) {
+ const isBundleItem = isSyntheticBundle(sessionCustomization.customization);
+ const isClientSynced = sessionCustomization.clientId !== undefined;
+ const childGroupKey = isClientSynced ? REMOTE_CLIENT_GROUP : REMOTE_HOST_GROUP;
+
+ // Always show session customizations as distinct plugin entries —
+ // client-synced items appear in the "Local" group, host-owned in
+ // the "Remote" group. The synthetic bundle is an implementation
+ // detail and is not shown as a standalone entry, but is still
+ // expanded below so individual user files appear in per-type tabs.
+ let item: ICustomizationItem;
+ if (!isBundleItem) {
+ item = this.toItem(sessionCustomization.customization, sessionCustomization);
+ items.set(customizationItemKey(sessionCustomization.customization, sessionCustomization.clientId), item);
+ } else {
+ // create a dummy parent item for the synthetic bundle, it does not go into the items map, just need it to expand.
+ item = { uri: this.toRemoteUri(sessionCustomization.customization), type: 'plugin', name: '', storage: PromptsStorage.plugin, groupKey: childGroupKey, extensionId: undefined, pluginUri: undefined };
+ }
+
+ // Always expand plugin contents so individual files are visible.
+ plugins.push({
+ item,
+ nonce: sessionCustomization.customization.nonce,
+ status: toStatusString(sessionCustomization.status),
+ statusMessage: sessionCustomization.statusMessage,
+ enabled: sessionCustomization.enabled,
+ childGroupKey,
+ isBundleItem,
+ });
+ }
+
+ // Expand each plugin directory in parallel to discover individual
+ // skills, agents, instructions, and prompts inside.
+ const expansions = await Promise.all(plugins.map(p => this._expandPluginContents(p.item.uri, p.nonce, p.childGroupKey, p.isBundleItem, token)));
+ if (token.isCancellationRequested) {
+ return [];
+ }
+
+ for (let i = 0; i < plugins.length; i++) {
+ const p = plugins[i];
+ for (const child of expansions[i]) {
+ // Children inherit the parent plugin's status/enabled state.
+ items.set(`${p.item.itemKey ?? p.item.uri.toString()}::${child.type}::${child.name}`, {
+ ...child,
+ status: p.status,
+ statusMessage: p.statusMessage,
+ enabled: p.enabled,
+ });
+ }
+ }
+
+ return [...items.values()];
+ }
+
+ /**
+ * Reads a plugin's directory contents through the agent-host
+ * filesystem provider and returns one {@link ICustomizationItem} per
+ * supported file (agents/skills/instructions/prompts).
+ *
+ * Cached by `(uri, nonce)`; a different nonce invalidates the entry.
+ */
+ private async _expandPluginContents(pluginUri: URI, nonce: string | undefined, groupKey: string, isBundleItem: boolean, token: CancellationToken): Promise {
+ const cached = this._expansionCache.get(pluginUri);
+ if (cached && cached.nonce === nonce) {
+ return cached.children;
+ }
+
+ // pluginUri is already an agent-host:// URI (from toRemoteUri),
+ // so use it directly as the filesystem root.
+ const fsRoot = pluginUri;
+ const children: ICustomizationItem[] = [];
+ try {
+ if (!await this._fileService.canHandleResource(fsRoot)) {
+ return [];
+ }
+ if (token.isCancellationRequested) {
+ return [];
+ }
+
+ const dirNames = ['agents', 'skills', 'commands', 'rules'] as const;
+ const subdirs = dirNames.map(name => ({ name, resource: URI.joinPath(fsRoot, name) }));
+ const stats = await this._fileService.resolveAll(subdirs.map(s => ({ resource: s.resource })));
+
+ if (token.isCancellationRequested) {
+ return [];
+ }
+
+ for (let i = 0; i < subdirs.length; i++) {
+ const stat = stats[i];
+ if (!stat.success || !stat.stat?.isDirectory || !stat.stat.children) {
+ continue;
+ }
+ const promptType = promptsTypeForPluginDir(subdirs[i].name);
+ if (!promptType) {
+ continue;
+ }
+ children.push(...await this._collectFromTypeDir(stat.stat.children, pluginUri, promptType, groupKey, isBundleItem, token));
+ }
+ children.sort((a, b) => `${a.type}:${a.name}`.localeCompare(`${b.type}:${b.name}`));
+ } catch (err) {
+ this._logService.trace(`[AgentCustomizationItemProvider] Failed to expand plugin ${pluginUri.toString()}: ${err}`);
+ return [];
+ }
+
+ this._expansionCache.set(pluginUri, { nonce, children });
+ return children;
+ }
+
+ /**
+ * Emits one {@link ICustomizationItem} per child of a per-type
+ * sub-folder. Skills are conventionally folders containing
+ * `SKILL.md`, and synced bundles may preserve per-skill
+ * subdirectories; flat skill files can still appear for legacy
+ * bundles, so both layouts are accepted.
+ *
+ * For skills, the `SKILL.md` frontmatter is read so that the item's
+ * description (and a frontmatter-supplied name, when present) can be
+ * surfaced — without it the UI would only show the folder name with
+ * no description.
+ */
+ private async _collectFromTypeDir(entries: readonly { name: string; resource: URI; isDirectory: boolean }[], pluginUri: URI, promptType: PromptsType, groupKey: string, isBundleItem: boolean, token: CancellationToken): Promise {
+ type Entry = { name: string; resource: URI; isDirectory: boolean };
+ const eligible: Entry[] = [];
+ for (const child of entries) {
+ // Skip dotfiles (e.g. .DS_Store)
+ if (child.name.startsWith('.')) {
+ continue;
+ }
+ if (promptType !== PromptsType.skill && child.isDirectory) {
+ continue;
+ }
+ eligible.push(child);
+ }
+
+ const skillMetadata = promptType === PromptsType.skill
+ ? await Promise.all(eligible.map(child => this._readSkillMetadata(child, token)))
+ : undefined;
+ if (token.isCancellationRequested) {
+ return [];
+ }
+
+ const items: ICustomizationItem[] = [];
+ for (let i = 0; i < eligible.length; i++) {
+ const child = eligible[i];
+ let displayName: string;
+ let description: string | undefined;
+ let uri = child.resource;
+ let userInvocable: boolean | undefined;
+ if (promptType === PromptsType.skill) {
+ const meta = skillMetadata![i];
+ // For folder-style skills the canonical resource for the skill
+ // is its `SKILL.md`; downstream code (slash-command resolution,
+ // chat input decorations) calls `parseNew(item.uri)` and would
+ // otherwise try to read the directory as a file. If we couldn't
+ // read `SKILL.md`, skip the entry rather than emit a URI that
+ // will fail to parse downstream.
+ if (child.isDirectory) {
+ if (!meta) {
+ continue;
+ }
+ uri = joinPath(child.resource, SKILL_FILENAME);
+ }
+ const fallbackName = child.isDirectory ? child.name : stripPromptFileExtensions(child.name);
+ displayName = meta?.name ?? fallbackName;
+ description = meta?.description;
+ userInvocable = meta?.userInvocable;
+ } else {
+ displayName = stripPromptFileExtensions(child.name);
+ }
+ items.push({
+ uri,
+ type: promptType,
+ name: displayName,
+ description,
+ storage: PromptsStorage.plugin,
+ groupKey,
+ extensionId: undefined,
+ pluginUri: isBundleItem ? undefined : pluginUri,
+ userInvocable
+ } satisfies ICustomizationItem);
+ }
+ return items;
+ }
+
+ /**
+ * Reads `SKILL.md` for a skill entry and returns its frontmatter
+ * `name` / `description`. Returns `undefined` when the file cannot
+ * be read or parsed — the caller falls back to the folder name and
+ * leaves the description empty.
+ */
+ private async _readSkillMetadata(entry: { name: string; resource: URI; isDirectory: boolean }, token: CancellationToken): Promise<{ name: string | undefined; description: string | undefined; userInvocable: boolean | undefined } | undefined> {
+ const skillFileUri = entry.isDirectory ? joinPath(entry.resource, SKILL_FILENAME) : entry.resource;
+ try {
+ const content = await this._fileService.readFile(skillFileUri);
+ if (token.isCancellationRequested) {
+ return undefined;
+ }
+ const parsed = new PromptFileParser().parse(skillFileUri, content.value.toString());
+ return { name: parsed.header?.name, description: parsed.header?.description, userInvocable: parsed.header?.userInvocable };
+ } catch (err) {
+ this._logService.trace(`[RemoteAgentCustomizationItemProvider] Failed to read skill metadata ${skillFileUri.toString()}: ${err}`);
+ return undefined;
+ }
+ }
+}
+
+function toStatusString(status: CustomizationStatus | undefined): 'loading' | 'loaded' | 'degraded' | 'error' | undefined {
+ switch (status) {
+ case CustomizationStatus.Loading: return 'loading';
+ case CustomizationStatus.Loaded: return 'loaded';
+ case CustomizationStatus.Degraded: return 'degraded';
+ case CustomizationStatus.Error: return 'error';
+ default: return undefined;
+ }
+}
+
+function customizationKey(customization: CustomizationRef): string {
+ return customization.uri;
+}
+
+function customizationItemKey(customization: CustomizationRef, clientId: string | undefined): string {
+ return clientId !== undefined
+ ? `${customizationKey(customization)}::${clientId}`
+ : customizationKey(customization);
+}
+
+/**
+ * Returns `true` for the synthetic "VS Code Synced Data" bundle plugin,
+ * which is an implementation detail of the customization sync pipeline
+ * and should not be surfaced as a standalone item in the UI.
+ */
+function isSyntheticBundle(customization: CustomizationRef): boolean {
+ try {
+ return URI.parse(customization.uri).scheme === SYNCED_CUSTOMIZATION_SCHEME;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Maps a plugin sub-directory name to the {@link PromptsType}
+ * its files represent. Returns `undefined` for unknown directories.
+ */
+function promptsTypeForPluginDir(dir: string): PromptsType | undefined {
+ switch (dir) {
+ case 'rules': return PromptsType.instructions;
+ case 'commands': return PromptsType.prompt;
+ case 'agents': return PromptsType.agent;
+ case 'skills': return PromptsType.skill;
+ default: return undefined;
+ }
+}
+
+/**
+ * Strips conventional prompt file extensions so we can show `foo`
+ * for `foo.prompt.md`, `foo.instructions.md`, etc.
+ */
+function stripPromptFileExtensions(filename: string): string {
+ const ext = extname(filename);
+ if (!ext) {
+ return filename;
+ }
+ const stem = filename.slice(0, -ext.length);
+ const dotInStem = stem.lastIndexOf('.');
+ return dotInStem > 0 ? stem.slice(0, dotInStem) : stem;
+}
diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts
index 009d27099fe893..16ec66b3ac622d 100644
--- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts
+++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts
@@ -14,6 +14,7 @@ import { type ProtectedResourceMetadata } from '../../../../../../platform/agent
import { type AgentInfo, type CustomizationRef, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js';
+import { IFileService } from '../../../../../../platform/files/common/files.js';
import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js';
import { ILogService } from '../../../../../../platform/log/common/log.js';
import { IStorageService } from '../../../../../../platform/storage/common/storage.js';
@@ -26,8 +27,9 @@ import { ICustomizationHarnessService } from '../../../common/customizationHarne
import { ILanguageModelsService } from '../../../common/languageModels.js';
import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js';
import { IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js';
+import { AgentCustomizationItemProvider } from './agentCustomizationItemProvider.js';
import { AgentCustomizationSyncProvider } from './agentCustomizationSyncProvider.js';
-import { LocalAgentHostCustomizationItemProvider, resolveCustomizationRefs } from './agentHostLocalCustomizations.js';
+import { resolveCustomizationRefs } from './agentHostLocalCustomizations.js';
import { authenticateProtectedResources, AgentHostAuthTokenCache, resolveAuthenticationInteractively } from './agentHostAuth.js';
import { AgentHostLanguageModelProvider } from './agentHostLanguageModelProvider.js';
import { AgentHostSessionHandler } from './agentHostSessionHandler.js';
@@ -76,6 +78,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
@IStorageService private readonly _storageService: IStorageService,
@IAgentPluginService private readonly _agentPluginService: IAgentPluginService,
@IPromptsService private readonly _promptsService: IPromptsService,
+ @IFileService private readonly _fileService: IFileService,
@IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService,
) {
super();
@@ -186,7 +189,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
// Customization disable provider + item provider + bundler + observable
const syncProvider = store.add(new AgentCustomizationSyncProvider(sessionType, this._storageService));
- const itemProvider = store.add(new LocalAgentHostCustomizationItemProvider(this._promptsService, sessionType, syncProvider));
+ const itemProvider = store.add(new AgentCustomizationItemProvider(agent, this._loggedConnection!, 'local', this._fileService, this._logService));
const bundler = store.add(this._instantiationService.createInstance(SyncedCustomizationBundler, sessionType));
// Distinguish from the extension-host Copilot CLI harness, which
// registers under the same `Copilot CLI` displayName via the chat
diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts
index fb00b9c855928c..1b6098b3f2db34 100644
--- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts
+++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts
@@ -56,7 +56,6 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu
isUserSelectable: true,
pricing: multiplierNumeric !== undefined ? `${multiplierNumeric}x` : undefined,
multiplierNumeric,
- modelPickerCategory: undefined,
targetChatSessionType: this._sessionType,
capabilities: {
vision: m.supportsVision ?? false,
diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts
index 39461a4ce69df5..a1f2a142f2af80 100644
--- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts
+++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts
@@ -4,21 +4,16 @@
*--------------------------------------------------------------------------------------------*/
import { CancellationToken } from '../../../../../../base/common/cancellation.js';
-import { Emitter, Event } from '../../../../../../base/common/event.js';
-import { Disposable } from '../../../../../../base/common/lifecycle.js';
-import { ResourceMap } from '../../../../../../base/common/map.js';
-import { basename, isEqualOrParent } from '../../../../../../base/common/resources.js';
+import { isEqualOrParent } from '../../../../../../base/common/resources.js';
import { URI } from '../../../../../../base/common/uri.js';
import { type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js';
import { type CustomizationRef } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { AICustomizationPromptsStorage, BUILTIN_STORAGE } from '../../../common/aiCustomizationWorkspaceService.js';
import { PromptsType } from '../../../common/promptSyntax/promptTypes.js';
import { IPromptPath, IPromptsService, matchesSessionType, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js';
-import { type ICustomizationSyncProvider, type ICustomizationItem, type ICustomizationItemProvider } from '../../../common/customizationHarnessService.js';
+import { type ICustomizationSyncProvider } from '../../../common/customizationHarnessService.js';
import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js';
-import { getFriendlyName } from '../../aiCustomization/aiCustomizationItemSource.js';
import type { SyncedCustomizationBundler } from './syncedCustomizationBundler.js';
-import { getSkillFolderName } from '../../../common/promptSyntax/config/promptFileLocations.js';
/**
* Prompt types that participate in auto-sync to an agent host harness.
@@ -39,8 +34,6 @@ export const SYNCABLE_PROMPT_TYPES: readonly PromptsType[] = [
* instructions, and agents available as the local VS Code client.
*/
export const SYNCABLE_STORAGE_SOURCES: readonly PromptsStorage[] = [
- PromptsStorage.local,
- PromptsStorage.user,
PromptsStorage.plugin,
PromptsStorage.extension,
];
@@ -126,80 +119,6 @@ export async function enumerateLocalCustomizationsForHarness(
return result;
}
-/**
- * {@link ICustomizationItemProvider} that surfaces an agent host
- * harness's local customizations as items in the AI Customization view.
- *
- * Each enumerated file is emitted as an {@link ICustomizationItem} with
- * `enabled = !syncProvider.isDisabled(uri)`, so the standard disable
- * affordance in the list widget reflects (and toggles) the
- * per-harness opt-out.
- */
-export class LocalAgentHostCustomizationItemProvider extends Disposable implements ICustomizationItemProvider {
- private readonly _onDidChange = this._register(new Emitter());
- readonly onDidChange: Event = this._onDidChange.event;
-
- constructor(
- private readonly _promptsService: IPromptsService,
- private readonly _sessionType: string,
- private readonly _syncProvider: ICustomizationSyncProvider,
- ) {
- super();
- this._register(this._syncProvider.onDidChange(() => this._onDidChange.fire()));
- this._register(Event.any(
- this._promptsService.onDidChangeCustomAgents,
- this._promptsService.onDidChangeSlashCommands,
- this._promptsService.onDidChangeSkills,
- this._promptsService.onDidChangeInstructions,
- )(() => this._onDidChange.fire()));
- }
-
- async provideChatSessionCustomizations(token: CancellationToken): Promise {
- const [enumerated, skills] = await Promise.all([
- enumerateLocalCustomizationsForHarness(this._promptsService, this._syncProvider, this._sessionType, token),
- this._promptsService.findAgentSkills(token),
- ]);
- if (token.isCancellationRequested) {
- return [];
- }
- // Skill files are conventionally named SKILL.md inside a per-skill
- // folder, so the filename is not a useful display name. Look up the
- // parsed skill metadata (name + description from frontmatter) and
- // fall back to the parent folder name when a skill failed to parse.
- const skillByUri = new ResourceMap<{ name: string; description: string | undefined; userInvocable?: boolean }>();
- for (const skill of skills ?? []) {
- skillByUri.set(skill.uri, { name: skill.name, description: skill.description, userInvocable: skill.userInvocable });
- }
- return enumerated.map(file => {
- let name: string;
- let description: string | undefined;
- let userInvocable: boolean | undefined;
- if (file.type === PromptsType.skill) {
- const parsed = skillByUri.get(file.uri);
- name = parsed?.name ?? getSkillFolderName(file.uri);
- description = parsed?.description;
- userInvocable = parsed?.userInvocable;
- } else {
- name = getFriendlyName(basename(file.uri));
- }
- return {
- uri: file.uri,
- type: file.type,
- name,
- description,
- // Cast through the wider storage union: built-in skills use
- // `BUILTIN_STORAGE`, which is not a `PromptsStorage` enum
- // member but is recognized by the AI Customization view.
- storage: file.storage as PromptsStorage,
- enabled: !file.disabled,
- extensionId: file.extensionId,
- pluginUri: file.pluginUri,
- userInvocable
- };
- });
- }
-}
-
/**
* Resolves the customization refs to include in an `activeClientChanged`
* message.
diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts
index f4cadd4aa571a7..3c5bb258b92d3f 100644
--- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts
+++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts
@@ -43,6 +43,7 @@ import { ChatConfiguration } from '../../../common/constants.js';
import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js';
import { IChatWidgetService } from '../../chat.js';
import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js';
+import { ITitleService } from '../../../../../services/title/browser/titleService.js';
// Telemetry types
type AgentStatusClickAction =
@@ -146,11 +147,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem {
/** Menu for ChatTitleBarMenu items (same as chat controls dropdown) */
private readonly _chatTitleBarMenu;
- /** WindowTitle instance for honoring the user's window.title setting */
- private readonly _windowTitle: WindowTitle;
-
constructor(
action: IAction,
+ private readonly _windowTitle: WindowTitle,
options: IBaseActionViewItemOptions | undefined,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IAgentTitleBarStatusService private readonly agentTitleBarStatusService: IAgentTitleBarStatusService,
@@ -177,9 +176,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem {
// Create menu for ChatTitleBarMenu to show in sparkle section dropdown
this._chatTitleBarMenu = this._register(this.menuService.createMenu(MenuId.ChatTitleBarMenu, this.contextKeyService));
- // Create WindowTitle to honor the user's window.title setting
- this._windowTitle = this._register(this.instantiationService.createInstance(WindowTitle, mainWindow));
-
// Re-render when control mode or session info changes
this._register(this.agentTitleBarStatusService.onDidChangeMode(() => {
this._render();
@@ -1400,7 +1396,8 @@ export class AgentTitleBarStatusRendering extends Disposable implements IWorkben
@IActionViewItemService actionViewItemService: IActionViewItemService,
@IInstantiationService instantiationService: IInstantiationService,
@IConfigurationService configurationService: IConfigurationService,
- @IContextKeyService contextKeyService: IContextKeyService
+ @IContextKeyService contextKeyService: IContextKeyService,
+ @ITitleService titleService: ITitleService,
) {
super();
@@ -1408,7 +1405,7 @@ export class AgentTitleBarStatusRendering extends Disposable implements IWorkben
if (!(action instanceof SubmenuItemAction)) {
return undefined;
}
- return instantiationService.createInstance(AgentTitleBarStatusWidget, action, options);
+ return instantiationService.createInstance(AgentTitleBarStatusWidget, action, titleService.windowTitle, options);
}, undefined));
// Add/remove CSS classes on workbench based on settings.
diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts
index 3204fa581e6773..427681601a49df 100644
--- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts
+++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts
@@ -797,21 +797,44 @@ class ActionsColumnRenderer extends ModelsTableColumnRenderer this.languageModelsService.configureModel(entry.model.identifier)
- }));
+ if (configActions.length > 0 || entry.model.metadata.configurationSchema) {
+ secondaryActions.push(toAction({
+ id: 'configureModel',
+ label: localize('models.configureModel', 'Configure...'),
+ run: () => this.languageModelsService.configureModel(entry.model.identifier)
+ }));
+ }
+
+ templateData.actionBar.setActions(primaryActions, secondaryActions);
+ }
- templateData.actionBar.setActions([], secondaryActions);
+ private createPinAction(modelIdentifier: string): IAction {
+ const isPinned = this.languageModelsService.isModelPinned(modelIdentifier);
+ return toAction({
+ id: isPinned ? `unpin.${modelIdentifier}` : `pin.${modelIdentifier}`,
+ label: isPinned
+ ? localize('models.unpinModel', "Unpin Model")
+ : localize('models.pinModel', "Pin Model"),
+ class: ThemeIcon.asClassName(isPinned ? Codicon.pinned : Codicon.pin),
+ run: () => {
+ if (isPinned) {
+ this.languageModelsService.unpinModel(modelIdentifier);
+ } else {
+ this.languageModelsService.pinModel(modelIdentifier);
+ }
+ this.viewModel.refresh();
+ }
+ });
}
}
@@ -1022,6 +1045,7 @@ export class ChatModelsWidget extends Disposable {
}));
this._register(this.chatEntitlementService.onDidChangeUsageBasedBilling(() => this.createTable()));
this._register(this.languageModelsService.onDidChangeLanguageModelVendors(() => this.updateAddModelsButton()));
+ this._register(this.languageModelsService.onDidChangePinnedModels(() => this.viewModel.refresh()));
this._register(this.contextKeyService.onDidChangeContext(e => {
if (e.affectsSome(new Set(['github.copilot.clientByokEnabled']))) {
this.updateAddModelsButton();
@@ -1190,6 +1214,28 @@ export class ChatModelsWidget extends Disposable {
let configureVendor: ILanguageModelProviderDescriptor | undefined;
if (selectedModelEntries.length) {
+ // Pin/unpin action — single action for all selected models
+ const pinnableEntries = selectedModelEntries.filter(e => e.model.metadata.id !== 'auto');
+ if (pinnableEntries.length > 0) {
+ const allPinned = pinnableEntries.every(e => this.languageModelsService.isModelPinned(e.model.identifier));
+ actions.push(toAction({
+ id: allPinned ? 'unpinModels' : 'pinModels',
+ label: allPinned
+ ? localize('models.unpinModel', "Unpin Model")
+ : localize('models.pinModel', "Pin Model"),
+ class: ThemeIcon.asClassName(allPinned ? Codicon.pinned : Codicon.pin),
+ run: () => {
+ for (const entry of pinnableEntries) {
+ if (allPinned) {
+ this.languageModelsService.unpinModel(entry.model.identifier);
+ } else {
+ this.languageModelsService.pinModel(entry.model.identifier);
+ }
+ }
+ }
+ }));
+ }
+
// Show per-model configuration actions for a single model
if (selectedModelEntries.length === 1) {
const configActions = this.languageModelsService.getModelConfigurationActions(selectedModelEntries[0].model.identifier);
diff --git a/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts
index 9e150d731cc188..68500fd6737370 100644
--- a/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts
+++ b/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts
@@ -12,6 +12,7 @@ import { Emitter, Event } from '../../../../base/common/event.js';
import { IJSONSchema, TypeFromJsonSchema } from '../../../../base/common/jsonSchema.js';
import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';
import { autorun } from '../../../../base/common/observable.js';
+import { equalsIgnoreCase } from '../../../../base/common/strings.js';
import { URI } from '../../../../base/common/uri.js';
import { generateUuid } from '../../../../base/common/uuid.js';
import * as nls from '../../../../nls.js';
@@ -23,7 +24,13 @@ import { IExtensionService, isProposedApiEnabled } from '../../../services/exten
import { ExtensionsRegistry, IExtensionPointUser } from '../../../services/extensions/common/extensionsRegistry.js';
export interface IChatOutputItemRenderer {
- renderOutputPart(mime: string, data: Uint8Array, webview: IWebview, token: CancellationToken): Promise;
+ renderOutputPart(mime: string, data: Uint8Array, webview: IWebview, context: IChatOutputRenderContext, token: CancellationToken): Promise;
+}
+
+export interface IChatOutputRenderContext {
+ readonly codeBlockContext?: {
+ readonly languageIdentifier: string;
+ };
}
interface RegisterOptions {
@@ -38,9 +45,13 @@ export const IChatOutputRendererService = createDecorator;
+
+ renderCodeBlock(languageIdentifier: string, data: Uint8Array, parent: HTMLElement, webviewOptions: RenderOutputPartWebviewOptions, token: CancellationToken): Promise;
}
export interface RenderedOutputPart extends IDisposable {
@@ -51,10 +62,15 @@ export interface RenderedOutputPart extends IDisposable {
}
interface RenderOutputPartWebviewOptions {
+ readonly title?: string;
readonly origin?: string;
readonly webviewState?: string;
}
+interface ContributionEntry {
+ readonly mimes: readonly string[];
+ readonly codeBlockLanguageIdentifiers: readonly string[];
+}
interface RendererEntry {
readonly viewType: string;
@@ -65,9 +81,7 @@ interface RendererEntry {
export class ChatOutputRendererService extends Disposable implements IChatOutputRendererService {
_serviceBrand: undefined;
- private readonly _contributions = new Map*viewType*/ string, {
- readonly mimes: readonly string[];
- }>();
+ private readonly _contributions = new Map*viewType*/ string, ContributionEntry>();
private readonly _renderers = new Map*viewType*/ string, RendererEntry>();
@@ -92,8 +106,12 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput
};
}
+ hasCodeBlockRenderer(languageIdentifier: string): boolean {
+ return Array.from(this._contributions.values()).some(value => value.codeBlockLanguageIdentifiers.some(identifier => equalsIgnoreCase(identifier, languageIdentifier)));
+ }
+
async renderOutputPart(mime: string, data: Uint8Array, parent: HTMLElement, webviewOptions: RenderOutputPartWebviewOptions, token: CancellationToken): Promise {
- const rendererData = await this.getRenderer(mime, token);
+ const rendererData = await this.getRendererForMime(mime, token);
if (token.isCancellationRequested) {
throw new CancellationError();
}
@@ -102,10 +120,28 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput
throw new Error(`No renderer registered found for mime type: ${mime}`);
}
+ return this.doRenderOutputPart(rendererData, mime, data, {}, parent, webviewOptions, token);
+ }
+
+ async renderCodeBlock(languageIdentifier: string, data: Uint8Array, parent: HTMLElement, webviewOptions: RenderOutputPartWebviewOptions, token: CancellationToken): Promise {
+ const rendererData = await this.getRendererForCodeBlock(languageIdentifier, token);
+ if (token.isCancellationRequested) {
+ throw new CancellationError();
+ }
+
+ if (!rendererData) {
+ throw new Error(`No renderer registered found for code block language identifier: ${languageIdentifier}`);
+ }
+
+ return this.doRenderOutputPart(rendererData, 'text/x-vscode-chat-code-block', data, { codeBlockContext: { languageIdentifier } }, parent, webviewOptions, token);
+ }
+
+ private async doRenderOutputPart(rendererData: RendererEntry, mime: string, data: Uint8Array, context: IChatOutputRenderContext, parent: HTMLElement, webviewOptions: RenderOutputPartWebviewOptions, token: CancellationToken): Promise {
+
const store = new DisposableStore();
const webview = store.add(this._webviewService.createWebviewElement({
- title: '',
+ title: webviewOptions.title ?? '',
origin: webviewOptions.origin ?? generateUuid(),
providedViewType: rendererData.viewType,
options: {
@@ -132,7 +168,7 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput
}
webview.mountTo(parent, getWindow(parent));
- await rendererData.renderer.renderOutputPart(mime, data, webview, token);
+ await rendererData.renderer.renderOutputPart(mime, data, webview, context, token);
return {
get webview() { return webview; },
@@ -146,10 +182,18 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput
};
}
- private async getRenderer(mime: string, token: CancellationToken): Promise {
+ private async getRendererForMime(mime: string, token: CancellationToken): Promise {
+ return this.getRenderer(value => value.mimes.some(m => matchesMimeType(m, [mime])), token);
+ }
+
+ private async getRendererForCodeBlock(languageIdentifier: string, token: CancellationToken): Promise {
+ return this.getRenderer(value => value.codeBlockLanguageIdentifiers.some(identifier => equalsIgnoreCase(identifier, languageIdentifier)), token);
+ }
+
+ private async getRenderer(matches: (value: ContributionEntry) => boolean, token: CancellationToken): Promise {
await raceCancellationError(this._extensionService.whenInstalledExtensionsRegistered(), token);
for (const [id, value] of this._contributions) {
- if (value.mimes.some(m => matchesMimeType(m, [mime]))) {
+ if (matches(value)) {
await raceCancellationError(this._extensionService.activateByEvent(`onChatOutputRenderer:${id}`), token);
const rendererData = this._renderers.get(id);
if (rendererData) {
@@ -174,8 +218,16 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput
continue;
}
+ const mimeTypes = contribution.mimeTypes ?? [];
+ const codeBlockLanguageIdentifiers = contribution.codeBlockLanguageIdentifiers ?? [];
+ if (!mimeTypes.length && !codeBlockLanguageIdentifiers.length) {
+ extension.collector.error(`Chat output renderer with view type '${contribution.viewType}' must specify at least one mime type or code block language identifier`);
+ continue;
+ }
+
this._contributions.set(contribution.viewType, {
- mimes: contribution.mimeTypes,
+ mimes: mimeTypes,
+ codeBlockLanguageIdentifiers,
});
}
}
@@ -185,7 +237,7 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput
const chatOutputRendererContributionSchema = {
type: 'object',
additionalProperties: false,
- required: ['viewType', 'mimeTypes'],
+ required: ['viewType'],
properties: {
viewType: {
type: 'string',
@@ -194,6 +246,15 @@ const chatOutputRendererContributionSchema = {
mimeTypes: {
type: 'array',
description: nls.localize('chatOutputRenderer.mimeTypes', 'MIME types that this renderer can handle'),
+ uniqueItems: true,
+ items: {
+ type: 'string'
+ }
+ },
+ codeBlockLanguageIdentifiers: {
+ type: 'array',
+ description: nls.localize('chatOutputRenderer.codeBlockLanguageIdentifiers', 'Code block language identifiers that this renderer can handle'),
+ uniqueItems: true,
items: {
type: 'string'
}
@@ -211,7 +272,7 @@ const chatOutputRenderContributionPoint = ExtensionsRegistry.registerExtensionPo
}
},
jsonSchema: {
- description: nls.localize('vscode.extension.contributes.chatOutputRenderer', 'Contributes a renderer for specific MIME types in chat outputs'),
+ description: nls.localize('vscode.extension.contributes.chatOutputRenderer', 'Contributes a renderer for specific MIME types and code block language identifiers in chat outputs'),
type: 'array',
items: chatOutputRendererContributionSchema,
}
diff --git a/src/vs/workbench/contrib/chat/browser/defaultModelContribution.ts b/src/vs/workbench/contrib/chat/browser/defaultModelContribution.ts
index 8af7a3622b94ec..253914a7495e6c 100644
--- a/src/vs/workbench/contrib/chat/browser/defaultModelContribution.ts
+++ b/src/vs/workbench/contrib/chat/browser/defaultModelContribution.ts
@@ -9,7 +9,6 @@ import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '.
import { ILogService } from '../../../../platform/log/common/log.js';
import { Registry } from '../../../../platform/registry/common/platform.js';
import { ILanguageModelChatMetadata, ILanguageModelsService } from '../common/languageModels.js';
-import { DEFAULT_MODEL_PICKER_CATEGORY } from '../common/widget/input/modelPickerWidget.js';
const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration);
@@ -100,16 +99,7 @@ export abstract class DefaultModelContribution extends Disposable {
return true;
});
- supportedModels.sort((a, b) => {
- const aCategory = a.metadata.modelPickerCategory ?? DEFAULT_MODEL_PICKER_CATEGORY;
- const bCategory = b.metadata.modelPickerCategory ?? DEFAULT_MODEL_PICKER_CATEGORY;
-
- if (aCategory.order !== bCategory.order) {
- return aCategory.order - bCategory.order;
- }
-
- return a.metadata.name.localeCompare(b.metadata.name);
- });
+ supportedModels.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name));
for (const model of supportedModels) {
try {
diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts
index 056d9177a31008..934b268c1cbb1f 100644
--- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts
+++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts
@@ -13,7 +13,9 @@ import { DomScrollableElement } from '../../../../../../base/browser/ui/scrollba
import { wrapTablesWithScrollable } from './chatMarkdownTableScrolling.js';
import { coalesce } from '../../../../../../base/common/arrays.js';
import { findLast } from '../../../../../../base/common/arraysFind.js';
+import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js';
import { Codicon } from '../../../../../../base/common/codicons.js';
+import { isCancellationError } from '../../../../../../base/common/errors.js';
import { MarkdownString } from '../../../../../../base/common/htmlContent.js';
import { Lazy } from '../../../../../../base/common/lazy.js';
import { Disposable, DisposableStore, dispose, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js';
@@ -23,6 +25,7 @@ import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js
import { ThemeIcon } from '../../../../../../base/common/themables.js';
import { isEqual } from '../../../../../../base/common/resources.js';
import { URI } from '../../../../../../base/common/uri.js';
+import { generateUuid } from '../../../../../../base/common/uuid.js';
import { Range } from '../../../../../../editor/common/core/range.js';
import { isLocation, type SymbolTag } from '../../../../../../editor/common/languages.js';
import { ILanguageService } from '../../../../../../editor/common/languages/language.js';
@@ -51,7 +54,8 @@ import { IChatProgressRenderableResponseContent } from '../../../common/model/ch
import { IChatContentInlineReference, IChatMarkdownContent, IChatService, IChatUndoStop } from '../../../common/chatService/chatService.js';
import { isRequestVM, isResponseVM } from '../../../common/model/chatViewModel.js';
import { ChatConfiguration } from '../../../common/constants.js';
-import { IChatCodeBlockInfo } from '../../chat.js';
+import { IChatCodeBlockInfo, IChatWidgetService } from '../../chat.js';
+import { IChatOutputRendererService, type RenderedOutputPart } from '../../chatOutputItemRenderer.js';
import { allowedChatMarkdownHtmlTags } from '../chatContentMarkdownRenderer.js';
import { IMarkdownDiffBlockData, MarkdownDiffBlockPart, parseUnifiedDiff } from './chatDiffBlockPart.js';
import { ChatEditingActionContext } from '../../chatEditing/chatEditingActions.js';
@@ -62,6 +66,7 @@ import { IDisposableReference } from './chatCollections.js';
import { EditorPool } from './chatContentCodePools.js';
import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js';
import { ChatExtensionsContentPart } from './chatExtensionsContentPart.js';
+import { ChatProgressSubPart } from './chatProgressContentPart.js';
import { IncrementalDOMMorpher } from './chatIncrementalRendering/chatIncrementalRendering.js';
import './media/chatMarkdownPart.css';
@@ -102,7 +107,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP
*/
readonly onDidChangeDiff: Event = this._onDidChangeDiff.event;
- private readonly allRefs: IDisposableReference[] = [];
+ private readonly allRefs: IDisposableReference[] = [];
private readonly _codeblocks: IMarkdownPartCodeBlockInfo[] = [];
public get codeblocks(): IChatCodeBlockInfo[] {
@@ -128,6 +133,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP
@IConfigurationService configurationService: IConfigurationService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IAiEditTelemetryService private readonly aiEditTelemetryService: IAiEditTelemetryService,
+ @IChatOutputRendererService private readonly chatOutputRendererService: IChatOutputRendererService,
) {
super();
@@ -218,7 +224,11 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP
fillInIncompleteTokens,
codeBlockRendererSync: (languageId, text, raw) => {
const isCodeBlockComplete = !isResponseVM(context.element) || context.element.isComplete || !raw || codeblockHasClosingBackticks(raw);
- if ((!text || (text.startsWith(' {
+ const codeBlock = this.instantiationService.createInstance(
+ ChatOutputCodeBlockPart,
+ identifier,
+ text,
+ codeBlockIndex,
+ context,
+ isComplete,
+ () => this._onDidChangeHeight.fire()
+ );
+ const ref: IDisposableReference = {
+ object: codeBlock,
+ isStale: () => false,
+ dispose: () => codeBlock.dispose()
+ };
+ this.allRefs.push(ref);
+ return ref;
+ }
+
private fireAggregatedDiff(): void {
let totalAdded = 0;
let totalRemoved = 0;
@@ -516,6 +568,8 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP
this.allRefs.forEach((ref, index) => {
if (ref.object instanceof CodeBlockPart) {
ref.object.layout(width);
+ } else if (ref.object instanceof ChatOutputCodeBlockPart) {
+ ref.object.layout(width);
} else if (ref.object instanceof MarkdownDiffBlockPart) {
ref.object.layout(width);
} else if (ref.object instanceof CollapsedCodeBlock) {
@@ -531,7 +585,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP
onDidRemount(): void {
for (const ref of this.allRefs) {
- if (ref.object instanceof CodeBlockPart) {
+ if (ref.object instanceof CodeBlockPart || ref.object instanceof ChatOutputCodeBlockPart) {
ref.object.onDidRemount();
}
}
@@ -613,6 +667,126 @@ export function codeblockHasClosingBackticks(str: string): boolean {
return !!str.match(/\n```+$/);
}
+class ChatOutputCodeBlockPart extends Disposable {
+
+ readonly element: HTMLElement;
+
+ private readonly _disposeCts = this._register(new CancellationTokenSource());
+ private readonly _renderedOutputPart = this._register(new MutableDisposable());
+
+ constructor(
+ identifier: string,
+ text: string,
+ codeBlockIndex: number,
+ private readonly context: IChatContentPartRenderContext,
+ isComplete: boolean,
+ private readonly onDidChangeHeight: () => void,
+ @IInstantiationService private readonly instantiationService: IInstantiationService,
+ @IChatOutputRendererService private readonly chatOutputRendererService: IChatOutputRendererService,
+ @IChatWidgetService private readonly chatWidgetService: IChatWidgetService,
+ ) {
+ super();
+
+ const title = localize('chat.renderedCodeBlockLabel', "Rendered code block {0}", codeBlockIndex + 1);
+ this.element = $('.interactive-result-code-block.chat-output-code-block.tool-output-part');
+ this.element.tabIndex = -1;
+ this.element.ariaLabel = title;
+
+ const parent = $('.webview-output');
+ parent.style.maxHeight = '80vh';
+ parent.style.minHeight = '38px';
+ this.element.appendChild(parent);
+
+ const progressMessage = $('span');
+ progressMessage.textContent = localize('chat.codeBlockOutputRendering', "Rendering code block...");
+ const progressPart = this._register(this.instantiationService.createInstance(ChatProgressSubPart, progressMessage, ThemeIcon.modify(Codicon.loading, 'spin'), undefined));
+ parent.appendChild(progressPart.domNode);
+ if (!isComplete) {
+ this.onDidChangeHeight();
+ return;
+ }
+
+ this.chatOutputRendererService.renderCodeBlock(identifier, new TextEncoder().encode(text), parent, { origin: generateUuid(), title }, this._disposeCts.token).then(renderedItem => {
+ if (this._disposeCts.token.isCancellationRequested) {
+ renderedItem.dispose();
+ return;
+ }
+
+ this._renderedOutputPart.value = renderedItem;
+ progressPart.domNode.remove();
+ parent.style.minHeight = '';
+ this.onDidChangeHeight();
+
+ this._register(renderedItem.onDidChangeHeight(() => this.onDidChangeHeight()));
+ this._register(renderedItem.webview.onDidWheel(e => {
+ this.chatWidgetService.getWidgetBySessionResource(this.context.element.sessionResource)?.delegateScrollFromMouseWheelEvent({
+ ...e,
+ preventDefault: () => { },
+ stopPropagation: () => { }
+ });
+ }));
+
+ this._register(this.context.onDidChangeVisibility(visible => {
+ if (visible) {
+ renderedItem.reinitialize();
+ }
+ }));
+ }, error => {
+ if (isCancellationError(error)) {
+ return;
+ }
+
+ console.error('Error rendering chat code block:', error);
+ progressPart.domNode.replaceWith(this.renderError(error));
+ parent.style.minHeight = '';
+ this.onDidChangeHeight();
+ });
+ }
+
+ override dispose(): void {
+ this._disposeCts.dispose(true);
+ super.dispose();
+ }
+
+ layout(width: number): void {
+ this.element.style.maxWidth = `${width}px`;
+ }
+
+ onDidRemount(): void {
+ this._renderedOutputPart.value?.reinitialize();
+ }
+
+ focus(): void {
+ const webview = this._renderedOutputPart.value?.webview;
+ if (webview) {
+ webview.focus();
+ } else {
+ this.element.focus();
+ }
+ }
+
+ private renderError(error: Error): HTMLElement {
+ const errorNode = $('.output-error');
+
+ const errorHeaderNode = $('.output-error-header');
+ dom.append(errorNode, errorHeaderNode);
+
+ const iconElement = $('div');
+ iconElement.classList.add(...ThemeIcon.asClassNameArray(Codicon.error));
+ errorHeaderNode.append(iconElement);
+
+ const errorTitleNode = $('.output-error-title');
+ errorTitleNode.textContent = localize('chat.codeBlockOutputError', "Error rendering the code block");
+ errorHeaderNode.append(errorTitleNode);
+
+ const errorMessageNode = $('.output-error-details');
+ errorMessageNode.textContent = error?.message || String(error);
+ errorNode.append(errorMessageNode);
+
+ return errorNode;
+ }
+}
+
export class CollapsedCodeBlock extends Disposable {
readonly element: HTMLElement;
diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts
index 30406a07eba5dd..c897eb39ac5c52 100644
--- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts
+++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts
@@ -9,7 +9,7 @@ import { renderMarkdown } from '../../../../../../base/browser/markdownRenderer.
import { renderIcon } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js';
import { getBaseLayerHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegate2.js';
import { getDefaultHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegateFactory.js';
-import { toAction } from '../../../../../../base/common/actions.js';
+import { IAction, toAction } from '../../../../../../base/common/actions.js';
import { IStringDictionary } from '../../../../../../base/common/collections.js';
import { Codicon } from '../../../../../../base/common/codicons.js';
import { Emitter, Event } from '../../../../../../base/common/event.js';
@@ -181,6 +181,7 @@ function createModelItem(
vendorLabel?: string,
isUBB?: boolean,
ariaDescription?: string,
+ pinAction?: IAction,
): IActionListItem {
const hover = model && openerService ? getModelHoverContent(model, openerService, isUBB) : undefined;
return {
@@ -196,10 +197,29 @@ function createModelItem(
badge: vendorLabel,
hover: hover ? { content: hover.element, disposable: hover.disposable } : undefined,
tooltip: action.tooltip,
+ toolbarActions: pinAction ? [pinAction] : undefined,
submenuActions: action.toolbarActions?.length ? action.toolbarActions : undefined,
};
}
+/**
+ * Creates a pin/unpin toolbar action for a model item in the picker.
+ */
+function createPinAction(
+ modelIdentifier: string,
+ isPinned: boolean,
+ onTogglePin: (modelIdentifier: string, pinned: boolean) => void,
+): IAction {
+ return toAction({
+ id: `pin.${modelIdentifier}`,
+ label: isPinned
+ ? localize('chat.modelPicker.unpin', "Unpin Model")
+ : localize('chat.modelPicker.pin', "Pin Model"),
+ class: ThemeIcon.asClassName(isPinned ? Codicon.pinned : Codicon.pin),
+ run: () => onTogglePin(modelIdentifier, !isPinned),
+ });
+}
+
/**
* Resolves a configuration property from a model's configurationSchema by group.
* Returns the key, current value (with default fallback), and schema metadata.
@@ -361,10 +381,12 @@ export function buildModelPickerItems(
models: ILanguageModelChatMetadataAndIdentifier[],
selectedModelId: string | undefined,
recentModelIds: string[],
+ pinnedModelIds: string[],
controlModels: IStringDictionary,
currentVSCodeVersion: string,
updateStateType: StateType,
onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void,
+ onTogglePin: ((modelIdentifier: string, pinned: boolean) => void) | undefined,
manageSettingsUrl: string | undefined,
useGroupedModelPicker: boolean,
manageModelsAction: IActionWidgetDropdownAction | undefined,
@@ -436,7 +458,47 @@ export function buildModelPickerItems(
items.push(createModelItem(autoAction, autoModel, openerService, undefined, isUBB, autoAriaDesc));
}
- // --- 2. Promoted section (selected + recently used + featured) ---
+ // Precompute group labels needed for inline badges
+ const allGroupKeys = new Set(
+ models.map(m => {
+ const info = getProviderGroupForModel(m, modelToGroup, languageModelsService!);
+ return getProviderGroupKey(info.vendor, info.groupName);
+ })
+ );
+ const showGroupLabel = allGroupKeys.size > 1;
+
+ // Helper to create a pin/unpin toolbar action for a model
+ const makePinAction = (model: ILanguageModelChatMetadataAndIdentifier) =>
+ onTogglePin ? createPinAction(model.identifier, pinnedModelIds.includes(model.identifier), onTogglePin) : undefined;
+
+ // --- 2. Pinned models ---
+ const pinnedSet = new Set(pinnedModelIds);
+ const pinnedModels: ILanguageModelChatMetadataAndIdentifier[] = [];
+ for (const id of pinnedModelIds) {
+ if (placed.has(id)) {
+ continue;
+ }
+ const model = resolveModel(id);
+ if (model && !placed.has(model.identifier)) {
+ markPlaced(model.identifier, model.metadata.id);
+ pinnedModels.push(model);
+ }
+ }
+ if (pinnedModels.length > 0) {
+ items.push({ kind: ActionListItemKind.Separator, label: localize('chat.modelPicker.pinned', "Pinned") });
+ for (const model of pinnedModels) {
+ const groupLabel = showGroupLabel
+ ? getProviderGroupForModel(model, modelToGroup, languageModelsService!).groupName
+ : undefined;
+ const { action: pinnedAction, ariaDescription: pinnedAriaDesc } = createModelAction(model, selectedModelId, onSelect, languageModelsService!, undefined, showGroupLabel, isUBB);
+ items.push(createModelItem(pinnedAction, model, openerService, groupLabel, isUBB, pinnedAriaDesc, makePinAction(model)));
+ }
+ }
+
+ // --- 3. Promoted section (selected + recently used + featured) ---
+ // MRU excludes pinned models and is limited to 3 entries
+ const filteredRecentIds = recentModelIds.filter(id => !pinnedSet.has(id)).slice(0, 3);
+
type PromotedItem =
| { kind: 'available'; model: ILanguageModelChatMetadataAndIdentifier }
| { kind: 'unavailable'; id: string; entry: IModelControlEntry; reason: 'upgrade' | 'update' | 'admin' };
@@ -475,8 +537,8 @@ export function buildModelPickerItems(
tryPlaceModel(selectedModelId);
}
- // Recently used models
- for (const id of recentModelIds) {
+ // Recently used models (filtered to exclude pinned, limited to 3)
+ for (const id of filteredRecentIds) {
tryPlaceModel(id);
}
@@ -510,6 +572,9 @@ export function buildModelPickerItems(
// Promoted models show their provider group name inline only when more
// than one provider group is configured across all models.
if (promotedItems.length > 0) {
+ if (items.length > 0) {
+ items.push({ kind: ActionListItemKind.Separator });
+ }
promotedItems.sort((a, b) => {
const aAvail = a.kind === 'available' ? 0 : 1;
const bAvail = b.kind === 'available' ? 0 : 1;
@@ -521,21 +586,13 @@ export function buildModelPickerItems(
return aName.localeCompare(bName);
});
- const allGroupKeys = new Set(
- models.map(m => {
- const info = getProviderGroupForModel(m, modelToGroup, languageModelsService!);
- return getProviderGroupKey(info.vendor, info.groupName);
- })
- );
- const showPromotedGroupLabel = allGroupKeys.size > 1;
-
for (const item of promotedItems) {
if (item.kind === 'available') {
- const groupLabel = showPromotedGroupLabel
+ const groupLabel = showGroupLabel
? getProviderGroupForModel(item.model, modelToGroup, languageModelsService!).groupName
: undefined;
- const { action: promotedAction, ariaDescription: promotedAriaDesc } = createModelAction(item.model, selectedModelId, onSelect, languageModelsService!, undefined, showPromotedGroupLabel, isUBB);
- items.push(createModelItem(promotedAction, item.model, openerService, groupLabel, isUBB, promotedAriaDesc));
+ const { action: promotedAction, ariaDescription: promotedAriaDesc } = createModelAction(item.model, selectedModelId, onSelect, languageModelsService!, undefined, showGroupLabel, isUBB);
+ items.push(createModelItem(promotedAction, item.model, openerService, groupLabel, isUBB, promotedAriaDesc, makePinAction(item.model)));
} else {
items.push(createUnavailableModelItem(item.id, item.entry, item.reason, manageSettingsUrl, updateStateType, chatEntitlementService));
}
@@ -627,7 +684,7 @@ export function buildModelPickerItems(
items.push(createUnavailableModelItem(model.metadata.id, entry, 'update', manageSettingsUrl, updateStateType, chatEntitlementService, ModelPickerSection.Other));
} else {
const { action: bucketAction, ariaDescription: bucketAriaDesc } = createModelAction(model, selectedModelId, onSelect, languageModelsService!, ModelPickerSection.Other, showGroupHeaders, isUBB);
- items.push(createModelItem(bucketAction, model, openerService, undefined, isUBB, bucketAriaDesc));
+ items.push(createModelItem(bucketAction, model, openerService, undefined, isUBB, bucketAriaDesc, makePinAction(model)));
}
}
}
@@ -949,14 +1006,27 @@ export class ModelPickerWidget extends Disposable {
this._telemetryService.publicLog2('chat.modelPickerInteraction', { interaction });
};
const manageSettingsUrl = this._productService.defaultChatAgent?.manageSettingsUrl;
+ const onTogglePin = (modelIdentifier: string, pinned: boolean) => {
+ if (pinned) {
+ this._languageModelsService.pinModel(modelIdentifier);
+ } else {
+ this._languageModelsService.unpinModel(modelIdentifier);
+ }
+ // Re-show the picker to reflect the updated pin state
+ this._actionWidgetService.hide();
+ this.show(anchorElement);
+ };
+
const items = buildModelPickerItems(
models,
this._selectedModel?.identifier,
this._languageModelsService.getRecentlyUsedModelIds(),
+ this._languageModelsService.getPinnedModelIds(),
controlModelsForTier,
this._productService.version,
this._updateService.state.type,
onSelect,
+ onTogglePin,
manageSettingsUrl,
this._delegate.useGroupedModelPicker(),
isUBB ? manageModelsAction : undefined,
@@ -968,6 +1038,16 @@ export class ModelPickerWidget extends Disposable {
isUBB,
);
+ // Collect all hover disposables so they are properly cleaned up when the
+ // picker is hidden. The ActionListWidget only tracks the disposable for the
+ // currently-shown hover; all other items' hover disposables would leak.
+ const hoverDisposables = new DisposableStore();
+ for (const item of items) {
+ if (item.hover?.disposable) {
+ hoverDisposables.add(item.hover.disposable);
+ }
+ }
+
const listOptions = {
// Always show the filter to allow for the secondary heading to show
showFilter: true,
@@ -998,6 +1078,7 @@ export class ModelPickerWidget extends Disposable {
action.run();
},
onHide: () => {
+ hoverDisposables.dispose();
this._nameButton?.setAttribute('aria-expanded', 'false');
if (dom.isHTMLElement(previouslyFocusedElement)) {
previouslyFocusedElement.focus();
diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts
index 95cd0c2d165d3f..85f19ec298f8a3 100644
--- a/src/vs/workbench/contrib/chat/common/constants.ts
+++ b/src/vs/workbench/contrib/chat/common/constants.ts
@@ -8,7 +8,7 @@ import { IChatSessionsService } from './chatSessionsService.js';
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { ContextKeyExpr, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
import { ChatEntitlementContextKeys } from '../../../services/chat/common/chatEntitlementService.js';
-import { IsSessionsWindowContext } from '../../../common/contextkeys.js';
+import { IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../common/contextkeys.js';
export enum ChatConfiguration {
AIDisabled = 'chat.disableAIFeatures',
@@ -210,6 +210,7 @@ export const OPEN_AGENTS_WINDOW_PRECONDITION = ContextKeyExpr.and(
ChatEntitlementContextKeys.Setup.disabledInWorkspace.negate(),
IsSessionsWindowContext.negate(),
ContextKeyExpr.has(`config.${ChatConfiguration.AgentEnabled}`),
+ IsAuxiliaryWindowContext.negate()
);
export const ChatEditorTitleMaxLength = 30;
diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts
index aa066210569199..8784420ba31fc3 100644
--- a/src/vs/workbench/contrib/chat/common/languageModels.ts
+++ b/src/vs/workbench/contrib/chat/common/languageModels.ts
@@ -203,7 +203,6 @@ export interface ILanguageModelChatMetadata {
readonly isDefaultForLocation: { [K in ChatAgentLocation]?: boolean };
readonly isUserSelectable?: boolean;
readonly statusIcon?: ThemeIcon;
- readonly modelPickerCategory: { label: string; order: number } | undefined;
readonly auth?: {
readonly providerLabel: string;
readonly accountLabel?: string;
@@ -442,6 +441,31 @@ export interface ILanguageModelsService {
*/
clearRecentlyUsedList(): void;
+ /**
+ * Returns the pinned model identifiers, in the order they were pinned.
+ */
+ getPinnedModelIds(): string[];
+
+ /**
+ * Pins a model so it appears in the pinned section of the model picker.
+ */
+ pinModel(modelIdentifier: string): void;
+
+ /**
+ * Unpins a model, removing it from the pinned section.
+ */
+ unpinModel(modelIdentifier: string): void;
+
+ /**
+ * Returns whether the given model is pinned.
+ */
+ isModelPinned(modelIdentifier: string): boolean;
+
+ /**
+ * Fires when the pinned models list changes.
+ */
+ readonly onDidChangePinnedModels: Event;
+
/**
* Returns the models from the control manifest,
* separated into free and paid tiers.
@@ -558,6 +582,13 @@ export const languageModelChatProviderExtensionPoint = ExtensionsRegistry.regist
});
const CHAT_MODEL_RECENTLY_USED_STORAGE_KEY = 'chatModelRecentlyUsed';
+const CHAT_MODEL_PINNED_STORAGE_KEY = 'chatModelPinned';
+
+/**
+ * The identifier for the Auto model which dynamically routes to the best backend.
+ * Auto should never appear in user-curated lists (MRU, pinned).
+ */
+const AUTO_MODEL_IDENTIFIER = 'copilot/auto';
const CHAT_PARTICIPANT_NAME_REGISTRY_STORAGE_KEY = 'chat.participantNameRegistry';
const CHAT_MODELS_CONTROL_STORAGE_KEY = 'chat.modelsControl';
@@ -595,10 +626,14 @@ export class LanguageModelsService implements ILanguageModelsService {
readonly onDidChangeLanguageModels: Event = this._onLanguageModelChange.event;
private _recentlyUsedModelIds: string[] = [];
+ private _pinnedModelIds: string[] = [];
private readonly _onDidChangeModelsControlManifest = this._store.add(new Emitter());
readonly onDidChangeModelsControlManifest = this._onDidChangeModelsControlManifest.event;
+ private readonly _onDidChangePinnedModels = this._store.add(new Emitter());
+ readonly onDidChangePinnedModels = this._onDidChangePinnedModels.event;
+
private _modelsControlManifest: IModelsControlManifest = { free: {}, paid: {} };
private _modelsControlRawResponse: IChatControlResponse['models'] | undefined;
@@ -621,6 +656,7 @@ export class LanguageModelsService implements ILanguageModelsService {
) {
this._hasUserSelectableModels = ChatContextKeys.languageModelsAreUserSelectable.bindTo(_contextKeyService);
this._recentlyUsedModelIds = this._readRecentlyUsedModels();
+ this._pinnedModelIds = this._readPinnedModels();
this._initChatControlData();
this._store.add(this.onDidChangeLanguageModels(() => {
@@ -1655,12 +1691,12 @@ export class LanguageModelsService implements ILanguageModelsService {
getRecentlyUsedModelIds(): string[] {
// Filter to only include models that still exist in the cache
return this._recentlyUsedModelIds
- .filter(id => this._modelCache.has(id) && id !== 'copilot/auto')
+ .filter(id => this._modelCache.has(id) && id !== AUTO_MODEL_IDENTIFIER)
.slice(0, 4);
}
addToRecentlyUsedList(modelIdentifier: string): void {
- if (modelIdentifier === 'copilot/auto') {
+ if (modelIdentifier === AUTO_MODEL_IDENTIFIER) {
return;
}
@@ -1685,6 +1721,45 @@ export class LanguageModelsService implements ILanguageModelsService {
//#endregion
+ //#region Pinned models
+
+ private _readPinnedModels(): string[] {
+ return this._storageService.getObject(CHAT_MODEL_PINNED_STORAGE_KEY, StorageScope.PROFILE, []);
+ }
+
+ private _savePinnedModels(): void {
+ this._storageService.store(CHAT_MODEL_PINNED_STORAGE_KEY, this._pinnedModelIds, StorageScope.PROFILE, StorageTarget.USER);
+ }
+
+ getPinnedModelIds(): string[] {
+ return this._pinnedModelIds.filter(id => id !== AUTO_MODEL_IDENTIFIER && this._modelCache.has(id));
+ }
+
+ pinModel(modelIdentifier: string): void {
+ if (modelIdentifier === AUTO_MODEL_IDENTIFIER || this._pinnedModelIds.includes(modelIdentifier)) {
+ return;
+ }
+ this._pinnedModelIds.push(modelIdentifier);
+ this._savePinnedModels();
+ this._onDidChangePinnedModels.fire();
+ }
+
+ unpinModel(modelIdentifier: string): void {
+ const index = this._pinnedModelIds.indexOf(modelIdentifier);
+ if (index === -1) {
+ return;
+ }
+ this._pinnedModelIds.splice(index, 1);
+ this._savePinnedModels();
+ this._onDidChangePinnedModels.fire();
+ }
+
+ isModelPinned(modelIdentifier: string): boolean {
+ return modelIdentifier !== AUTO_MODEL_IDENTIFIER && this._pinnedModelIds.includes(modelIdentifier);
+ }
+
+ //#endregion
+
//#region Models control manifest
getModelsControlManifest(): IModelsControlManifest {
diff --git a/src/vs/workbench/contrib/chat/common/widget/input/modelPickerWidget.ts b/src/vs/workbench/contrib/chat/common/widget/input/modelPickerWidget.ts
index 5d5f437c1db081..57ff8b2df2fd69 100644
--- a/src/vs/workbench/contrib/chat/common/widget/input/modelPickerWidget.ts
+++ b/src/vs/workbench/contrib/chat/common/widget/input/modelPickerWidget.ts
@@ -3,6 +3,4 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
-import { localize } from '../../../../../../nls.js';
-export const DEFAULT_MODEL_PICKER_CATEGORY = { label: localize('chat.modelPicker.other', "Other Models"), order: Number.MAX_SAFE_INTEGER };
diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/enumerateLocalCustomizationsForHarness.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/enumerateLocalCustomizationsForHarness.test.ts
index 49a7ecb0c56ec7..6720d2bb784ee5 100644
--- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/enumerateLocalCustomizationsForHarness.test.ts
+++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/enumerateLocalCustomizationsForHarness.test.ts
@@ -57,18 +57,18 @@ suite('enumerateLocalCustomizationsForHarness', () => {
}]);
});
- test('combines core storage entries with built-in skills', async () => {
+ test('combines extension storage entries with built-in skills', async () => {
const userAgent = URI.file('/user/agents/foo.agent.md');
const builtinSkill = URI.file('/builtin/merge/SKILL.md');
const promptsService = makePromptsService(new Map([
- [`${PromptsType.agent}/${PromptsStorage.user}`, [makePromptPath(userAgent, PromptsType.agent, PromptsStorage.user)]],
+ [`${PromptsType.agent}/${PromptsStorage.extension}`, [makePromptPath(userAgent, PromptsType.agent, PromptsStorage.extension)]],
[`${PromptsType.skill}/${BUILTIN_STORAGE}`, [makePromptPath(builtinSkill, PromptsType.skill, BUILTIN_STORAGE as unknown as PromptsStorage)]],
]));
const result = await enumerateLocalCustomizationsForHarness(promptsService, new FakeSyncProvider(), SessionType.CopilotCLI, CancellationToken.None);
assert.deepStrictEqual(result.map((e: { uri: URI; type: PromptsType; storage: unknown; disabled: boolean }) => ({ uri: e.uri.toString(), type: e.type, storage: e.storage, disabled: e.disabled })), [
- { uri: userAgent.toString(), type: PromptsType.agent, storage: PromptsStorage.user, disabled: false },
+ { uri: userAgent.toString(), type: PromptsType.agent, storage: PromptsStorage.extension, disabled: false },
{ uri: builtinSkill.toString(), type: PromptsType.skill, storage: BUILTIN_STORAGE, disabled: false },
]);
});
diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts
index ccac5217dc7af4..7040cc05e8640d 100644
--- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts
+++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts
@@ -113,7 +113,7 @@ suite('resolveCustomizationRefs - built-in skills', () => {
const userAgent = URI.file('/user/agents/foo.agent.md');
const builtin = URI.file('/builtin/merge/SKILL.md');
const promptsService = makePromptsService(new Map([
- [`${PromptsType.agent}/${PromptsStorage.user}`, [makePromptPath(userAgent, PromptsType.agent, PromptsStorage.user)]],
+ [`${PromptsType.agent}/${PromptsStorage.extension}`, [makePromptPath(userAgent, PromptsType.agent, PromptsStorage.extension)]],
[`${PromptsType.skill}/${BUILTIN_STORAGE}`, [makePromptPath(builtin, PromptsType.skill, BUILTIN_STORAGE as unknown as PromptsStorage)]],
]));
const bundler = new FakeBundler();
diff --git a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts
index a2258c17d18f1f..88f674aedf0c4b 100644
--- a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts
+++ b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts
@@ -153,6 +153,11 @@ class MockLanguageModelsService implements ILanguageModelsService {
getRecentlyUsedModelIds(): string[] { return []; }
addToRecentlyUsedList(): void { }
clearRecentlyUsedList(): void { }
+ getPinnedModelIds(): string[] { return []; }
+ pinModel(_modelIdentifier: string): void { }
+ unpinModel(_modelIdentifier: string): void { }
+ isModelPinned(_modelIdentifier: string): boolean { return false; }
+ onDidChangePinnedModels = Event.None;
getModelsControlManifest(): IModelsControlManifest { return { free: {}, paid: {} }; }
restrictedChatParticipants = observableValue('restrictedChatParticipants', Object.create(null));
}
@@ -191,7 +196,6 @@ suite('ChatModelsViewModel', () => {
vendor: 'copilot',
maxInputTokens: 8192,
maxOutputTokens: 4096,
- modelPickerCategory: { label: 'Copilot', order: 1 },
isUserSelectable: true,
capabilities: {
toolCalling: true,
@@ -212,7 +216,6 @@ suite('ChatModelsViewModel', () => {
vendor: 'copilot',
maxInputTokens: 8192,
maxOutputTokens: 4096,
- modelPickerCategory: { label: 'Copilot', order: 1 },
isUserSelectable: true,
capabilities: {
toolCalling: true,
@@ -233,7 +236,6 @@ suite('ChatModelsViewModel', () => {
vendor: 'openai',
maxInputTokens: 4096,
maxOutputTokens: 2048,
- modelPickerCategory: { label: 'OpenAI', order: 2 },
isUserSelectable: true,
capabilities: {
toolCalling: true,
@@ -254,7 +256,6 @@ suite('ChatModelsViewModel', () => {
vendor: 'openai',
maxInputTokens: 8192,
maxOutputTokens: 4096,
- modelPickerCategory: { label: 'OpenAI', order: 2 },
isUserSelectable: false,
capabilities: {
toolCalling: false,
@@ -547,7 +548,6 @@ suite('ChatModelsViewModel', () => {
vendor: 'copilot',
maxInputTokens: 8192,
maxOutputTokens: 4096,
- modelPickerCategory: { label: 'Copilot', order: 1 },
isUserSelectable: true,
capabilities: {
toolCalling: true,
@@ -569,7 +569,6 @@ suite('ChatModelsViewModel', () => {
vendor: 'copilot',
maxInputTokens: 8192,
maxOutputTokens: 4096,
- modelPickerCategory: { label: 'Copilot', order: 1 },
isUserSelectable: true,
capabilities: {
toolCalling: true,
@@ -653,7 +652,6 @@ suite('ChatModelsViewModel', () => {
vendor: 'anthropic',
maxInputTokens: 100000,
maxOutputTokens: 4096,
- modelPickerCategory: { label: 'Anthropic', order: 3 },
isUserSelectable: true,
capabilities: {
toolCalling: true,
@@ -682,7 +680,6 @@ suite('ChatModelsViewModel', () => {
vendor: 'azure',
maxInputTokens: 8192,
maxOutputTokens: 4096,
- modelPickerCategory: { label: 'Azure', order: 4 },
isUserSelectable: true,
capabilities: {
toolCalling: true,
diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts
index 93bd33b636fd6f..088780a29a2d6e 100644
--- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts
+++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts
@@ -54,10 +54,10 @@ suite('PromptHeaderAutocompletion', () => {
instaService.set(ILanguageModelToolsService, toolService);
const testModels: ILanguageModelChatMetadata[] = [
- { id: 'mae-4', name: 'MAE 4', vendor: 'olama', version: '1.0', family: 'mae', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata,
- { id: 'mae-4.1', name: 'MAE 4.1', vendor: 'copilot', version: '1.0', family: 'mae', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata,
- { id: 'gpt-4', name: 'GPT 4', vendor: 'openai', version: '1.0', family: 'gpt', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: false, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata,
- { id: 'bg-agent-model', name: 'BG Agent Model', vendor: 'copilot', version: '1.0', family: 'bg', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true }, targetChatSessionType: 'background' } satisfies ILanguageModelChatMetadata,
+ { id: 'mae-4', name: 'MAE 4', vendor: 'olama', version: '1.0', family: 'mae', extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata,
+ { id: 'mae-4.1', name: 'MAE 4.1', vendor: 'copilot', version: '1.0', family: 'mae', extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata,
+ { id: 'gpt-4', name: 'GPT 4', vendor: 'openai', version: '1.0', family: 'gpt', extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: false, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata,
+ { id: 'bg-agent-model', name: 'BG Agent Model', vendor: 'copilot', version: '1.0', family: 'bg', extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true }, targetChatSessionType: 'background' } satisfies ILanguageModelChatMetadata,
];
instaService.stub(ILanguageModelsService, {
diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts
index ccfb6f7afb9b7b..bffe8dec8a3469 100644
--- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts
+++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts
@@ -53,12 +53,12 @@ suite('PromptHoverProvider', () => {
instaService.set(ILanguageModelToolsService, toolService);
const testModels: ILanguageModelChatMetadata[] = [
- { id: 'mae-4', name: 'MAE 4', vendor: 'olama', version: '1.0', family: 'mae', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata,
- { id: 'mae-4.1', name: 'MAE 4.1', vendor: 'copilot', version: '1.0', family: 'mae', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata,
+ { id: 'mae-4', name: 'MAE 4', vendor: 'olama', version: '1.0', family: 'mae', extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata,
+ { id: 'mae-4.1', name: 'MAE 4.1', vendor: 'copilot', version: '1.0', family: 'mae', extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata,
// Claude model equivalents
- { id: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5', vendor: 'copilot', version: '1.0', family: 'claude', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 200000, maxOutputTokens: 8192, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: {} } satisfies ILanguageModelChatMetadata,
- { id: 'claude-opus-4.6', name: 'Claude Opus 4.6', vendor: 'copilot', version: '1.0', family: 'claude', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 200000, maxOutputTokens: 8192, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: {} } satisfies ILanguageModelChatMetadata,
- { id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5', vendor: 'copilot', version: '1.0', family: 'claude', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 200000, maxOutputTokens: 8192, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: {} } satisfies ILanguageModelChatMetadata,
+ { id: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5', vendor: 'copilot', version: '1.0', family: 'claude', extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 200000, maxOutputTokens: 8192, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: {} } satisfies ILanguageModelChatMetadata,
+ { id: 'claude-opus-4.6', name: 'Claude Opus 4.6', vendor: 'copilot', version: '1.0', family: 'claude', extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 200000, maxOutputTokens: 8192, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: {} } satisfies ILanguageModelChatMetadata,
+ { id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5', vendor: 'copilot', version: '1.0', family: 'claude', extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 200000, maxOutputTokens: 8192, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: {} } satisfies ILanguageModelChatMetadata,
];
instaService.stub(ILanguageModelsService, {
diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts
index 81d88c70934b6f..639c09d414e993 100644
--- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts
+++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts
@@ -119,9 +119,9 @@ suite('PromptValidator', () => {
instaService.set(ILanguageModelToolsService, toolService);
const testModels: ILanguageModelChatMetadata[] = [
- { id: 'mae-4', name: 'MAE 4', vendor: 'olama', version: '1.0', family: 'mae', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata,
- { id: 'mae-4.1', name: 'MAE 4.1', vendor: 'copilot', version: '1.0', family: 'mae', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata,
- { id: 'mae-3.5-turbo', name: 'MAE 3.5 Turbo', vendor: 'copilot', version: '1.0', family: 'mae', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata
+ { id: 'mae-4', name: 'MAE 4', vendor: 'olama', version: '1.0', family: 'mae', extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata,
+ { id: 'mae-4.1', name: 'MAE 4.1', vendor: 'copilot', version: '1.0', family: 'mae', extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata,
+ { id: 'mae-3.5-turbo', name: 'MAE 3.5 Turbo', vendor: 'copilot', version: '1.0', family: 'mae', extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata
];
instaService.stub(ILanguageModelsService, {
diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts
index 36d47ed7be88e8..e9b7070cec8eaa 100644
--- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts
+++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts
@@ -22,6 +22,7 @@ import { IChatContentPartRenderContext } from '../../../../browser/widget/chatCo
import { ChatMarkdownContentPart } from '../../../../browser/widget/chatContentParts/chatMarkdownContentPart.js';
import { EditorPool, DiffEditorPool } from '../../../../browser/widget/chatContentParts/chatContentCodePools.js';
import { CodeBlockPart, ICodeBlockData } from '../../../../browser/widget/chatContentParts/codeBlockPart.js';
+import { IChatOutputRendererService, type RenderedOutputPart } from '../../../../browser/chatOutputItemRenderer.js';
import { IChatResponseViewModel } from '../../../../common/model/chatViewModel.js';
import { IChatContentInlineReference } from '../../../../common/chatService/chatService.js';
import { ChatConfiguration } from '../../../../common/constants.js';
@@ -39,6 +40,7 @@ suite('ChatMarkdownContentPart', () => {
/** Data captured from each CodeBlockPart.render() call */
const renderedCodeBlocks: ICodeBlockData[] = [];
+ const renderedCodeBlockOutputs: { identifier: string; text: string }[] = [];
function createMockEditorPool(): EditorPool {
return {
@@ -132,6 +134,7 @@ suite('ChatMarkdownContentPart', () => {
disposables = store.add(new DisposableStore());
instantiationService = workbenchInstantiationService(undefined, disposables);
renderedCodeBlocks.length = 0;
+ renderedCodeBlockOutputs.length = 0;
// Seed configuration values needed by ChatEditorOptions
const configService = instantiationService.get(IConfigurationService) as TestConfigurationService;
@@ -171,6 +174,25 @@ suite('ChatMarkdownContentPart', () => {
handleCodeRejected: () => { },
});
+ instantiationService.stub(IChatOutputRendererService, {
+ _serviceBrand: undefined,
+ registerRenderer: () => ({ dispose: () => { } }),
+ hasCodeBlockRenderer: identifier => identifier.toLowerCase() === 'mermaid',
+ renderOutputPart: async () => { throw new Error('Unexpected output render'); },
+ renderCodeBlock: async (identifier, data) => {
+ renderedCodeBlockOutputs.push({ identifier, text: new TextDecoder().decode(data) });
+ return {
+ webview: {
+ focus: () => { },
+ onDidWheel: Event.None,
+ } as RenderedOutputPart['webview'],
+ onDidChangeHeight: Event.None,
+ reinitialize: () => { },
+ dispose: () => { },
+ };
+ },
+ });
+
// Stub view descriptor service
instantiationService.stub(IViewDescriptorService, {
onDidChangeLocation: Event.None,
@@ -209,6 +231,48 @@ suite('ChatMarkdownContentPart', () => {
assert.strictEqual(renderedCodeBlocks[0].languageId, 'javascript');
});
+ test('renders complete code block with contributed chat output renderer', () => {
+ const part = createMarkdownPart('```mermaid\ngraph TD\n```');
+
+ assert.strictEqual(part.codeblocks.length, 1);
+ assert.strictEqual(part.codeblocks[0].languageId, 'mermaid');
+ assert.strictEqual(renderedCodeBlocks.length, 0);
+ assert.deepStrictEqual(renderedCodeBlockOutputs, [{ identifier: 'mermaid', text: 'graph TD' }]);
+ assert.ok(part.domNode.querySelector('.chat-output-code-block'));
+ });
+
+ test('renders complete code block with contributed chat output renderer case-insensitively', () => {
+ const part = createMarkdownPart('```Mermaid\ngraph TD\n```');
+
+ assert.strictEqual(part.codeblocks.length, 1);
+ assert.strictEqual(part.codeblocks[0].languageId, 'Mermaid');
+ assert.strictEqual(renderedCodeBlocks.length, 0);
+ assert.deepStrictEqual(renderedCodeBlockOutputs, [{ identifier: 'Mermaid', text: 'graph TD' }]);
+ assert.ok(part.domNode.querySelector('.chat-output-code-block'));
+ });
+
+ test('does not render initial incomplete code fence', () => {
+ const ctx = createRenderContext(false);
+ const part = createMarkdownPart('```', ctx);
+
+ assert.strictEqual(part.codeblocks.length, 0);
+ assert.strictEqual(renderedCodeBlocks.length, 0);
+ assert.strictEqual(renderedCodeBlockOutputs.length, 0);
+ assert.strictEqual(part.domNode.querySelector('.interactive-result-code-block'), null);
+ });
+
+ test('shows pending chat output renderer for incomplete code block', () => {
+ const ctx = createRenderContext(false);
+ const part = createMarkdownPart('```mermaid\ngraph TD', ctx);
+
+ assert.strictEqual(renderedCodeBlockOutputs.length, 0);
+ assert.strictEqual(renderedCodeBlocks.length, 0);
+ assert.strictEqual(part.codeblocks.length, 1);
+ assert.strictEqual(part.codeblocks[0].languageId, 'mermaid');
+ assert.ok(part.domNode.querySelector('.chat-output-code-block'));
+ assert.ok(part.domNode.textContent?.includes('Rendering code block'));
+ });
+
test('renders multiple code blocks with correct indices', () => {
const part = createMarkdownPart(
'Some text\n```python\nprint("a")\n```\nMore text\n```typescript\nconst x = 1;\n```'
diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts
index 404d7587381a3a..18f29d64096a75 100644
--- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts
+++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts
@@ -38,7 +38,6 @@ function createModel(id: string, name: string, vendor = 'copilot'): ILanguageMod
maxInputTokens: 128000,
maxOutputTokens: 4096,
isDefaultForLocation: {},
- modelPickerCategory: undefined,
} as ILanguageModelChatMetadata,
};
}
@@ -101,6 +100,7 @@ function callBuild(
opts: {
selectedModelId?: string;
recentModelIds?: string[];
+ pinnedModelIds?: string[];
controlModels?: IStringDictionary;
entitlement?: ChatEntitlement;
currentVSCodeVersion?: string;
@@ -122,10 +122,12 @@ function callBuild(
models,
opts.selectedModelId,
opts.recentModelIds ?? [],
+ opts.pinnedModelIds ?? [],
opts.controlModels ?? {},
opts.currentVSCodeVersion ?? '1.100.0',
opts.updateStateType ?? StateType.Idle,
onSelect,
+ undefined,
opts.manageSettingsUrl,
true,
stubManageModelsAction,
@@ -352,7 +354,7 @@ suite('buildModelPickerItems', () => {
});
// With no selected, no recent, and no featured, both models should be in Other
const seps = items.filter(i => i.kind === ActionListItemKind.Separator);
- // One separator before Other Models section (Manage Models is in the toolbar)
+ // One separator before Other Models section
assert.strictEqual(seps.length, 1);
const actions = getActionItems(items);
assert.strictEqual(actions[0].label, 'Auto');
@@ -601,11 +603,13 @@ suite('buildModelPickerItems', () => {
[auto, modelA],
undefined,
[],
+ [],
{},
'1.100.0',
StateType.Idle,
onSelect,
undefined,
+ undefined,
true,
undefined,
stubChatEntitlementService,
@@ -687,10 +691,12 @@ suite('buildModelPickerItems', () => {
[auto],
undefined,
['missing-model'],
+ [],
{ 'missing-model': { label: 'Missing Model' } as IModelControlEntry },
'1.100.0',
StateType.Idle,
() => { },
+ undefined,
'https://aka.ms/github-copilot-settings',
true,
undefined,
@@ -773,11 +779,13 @@ suite('buildModelPickerItems', () => {
[auto, modelA],
undefined,
[],
+ [],
{},
'1.100.0',
StateType.Idle,
onSelect,
undefined,
+ undefined,
true,
undefined,
anonymousEntitlementService,
@@ -981,6 +989,72 @@ suite('buildModelPickerItems', () => {
assert.ok(claudeItem);
assert.strictEqual(claudeItem.item?.description, undefined);
});
+
+ test('pinned models appear in dedicated pinned section', () => {
+ const auto = createAutoModel();
+ const modelA = createModel('gpt-4o', 'GPT-4o');
+ const modelB = createModel('claude', 'Claude');
+ const modelC = createModel('gemini', 'Gemini');
+ const items = callBuild([auto, modelA, modelB, modelC], {
+ pinnedModelIds: [modelB.identifier, modelA.identifier],
+ });
+ // Pinned section header exists
+ const pinnedSep = items.find(i => i.kind === ActionListItemKind.Separator && i.label === 'Pinned');
+ assert.ok(pinnedSep, 'Pinned separator header should exist');
+ // Pinned models appear in pin order (Claude first, then GPT-4o)
+ const pinnedSepIndex = items.indexOf(pinnedSep!);
+ const afterPinned = items.slice(pinnedSepIndex + 1);
+ const firstPinned = afterPinned.find(i => i.kind === ActionListItemKind.Action);
+ assert.strictEqual(firstPinned?.label, 'Claude');
+ });
+
+ test('pinned models do not appear in MRU/promoted section', () => {
+ const auto = createAutoModel();
+ const modelA = createModel('gpt-4o', 'GPT-4o');
+ const modelB = createModel('claude', 'Claude');
+ const items = callBuild([auto, modelA, modelB], {
+ pinnedModelIds: [modelA.identifier],
+ recentModelIds: [modelA.identifier, modelB.identifier],
+ });
+ const actions = getActionItems(items);
+ // GPT-4o should only appear once (in pinned, not again in promoted)
+ const gptItems = actions.filter(a => a.label === 'GPT-4o');
+ assert.strictEqual(gptItems.length, 1, 'Pinned model should appear exactly once');
+ });
+
+ test('MRU is capped at 3 after filtering pinned models', () => {
+ const auto = createAutoModel();
+ const models = [
+ auto,
+ createModel('m1', 'Model 1'),
+ createModel('m2', 'Model 2'),
+ createModel('m3', 'Model 3'),
+ createModel('m4', 'Model 4'),
+ createModel('m5', 'Model 5'),
+ ];
+ const items = callBuild(models, {
+ recentModelIds: [models[1].identifier, models[2].identifier, models[3].identifier, models[4].identifier, models[5].identifier],
+ pinnedModelIds: [models[1].identifier],
+ });
+ // Model 1 is pinned, MRU should be Model 2, 3, 4 (capped at 3), Model 5 goes to Other
+ const actions = getActionItems(items);
+ const promotedLabels = actions
+ .filter(a => !a.isSectionToggle && a.section !== 'other' && a.item?.id !== 'manageModels' && a.label !== 'Auto' && a.label !== 'Model 1')
+ .map(a => a.label);
+ assert.ok(promotedLabels.length <= 3, 'MRU should be capped at 3');
+ assert.ok(!promotedLabels.includes('Model 1'), 'Pinned model should not be in MRU');
+ });
+
+ test('no pinned section when pinnedModelIds is empty', () => {
+ const auto = createAutoModel();
+ const modelA = createModel('gpt-4o', 'GPT-4o');
+ const items = callBuild([auto, modelA], {
+ pinnedModelIds: [],
+ recentModelIds: [modelA.identifier],
+ });
+ const pinnedSep = items.find(i => i.kind === ActionListItemKind.Separator && i.label === 'Pinned');
+ assert.strictEqual(pinnedSep, undefined, 'No pinned separator when there are no pinned models');
+ });
});
suite('formatTokenCount', () => {
diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionLogic.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionLogic.test.ts
index 60b976a416de5d..639ae66116858b 100644
--- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionLogic.test.ts
+++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionLogic.test.ts
@@ -59,7 +59,6 @@ function createModel(
maxOutputTokens: 4096,
isDefaultForLocation: {},
isUserSelectable: true,
- modelPickerCategory: undefined,
capabilities: { toolCalling: true, agentMode: true },
...overrides,
} as ILanguageModelChatMetadata,
diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts
index 5cc52dd5675a8c..5e6c1a171556c4 100644
--- a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts
+++ b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts
@@ -12,7 +12,6 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes
import { NullLogService } from '../../../../../platform/log/common/log.js';
import { ChatMessageRole, LanguageModelsService, IChatMessage, IChatResponsePart, ILanguageModelChatMetadata } from '../../common/languageModels.js';
import { IExtensionService, nullExtensionDescription } from '../../../../services/extensions/common/extensions.js';
-import { DEFAULT_MODEL_PICKER_CATEGORY } from '../../common/widget/input/modelPickerWidget.js';
import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js';
import { TestStorageService } from '../../../../test/common/workbenchTestServices.js';
import { Emitter, Event } from '../../../../../base/common/event.js';
@@ -70,7 +69,6 @@ suite('LanguageModels', function () {
vendor: 'test-vendor',
family: 'test-family',
version: 'test-version',
- modelPickerCategory: undefined,
id: 'test-id-1',
maxInputTokens: 100,
maxOutputTokens: 100,
@@ -82,7 +80,6 @@ suite('LanguageModels', function () {
vendor: 'test-vendor',
family: 'test2-family',
version: 'test2-version',
- modelPickerCategory: undefined,
id: 'test-id-12',
maxInputTokens: 100,
maxOutputTokens: 100,
@@ -149,7 +146,6 @@ suite('LanguageModels', function () {
id: 'actual-lm',
maxInputTokens: 100,
maxOutputTokens: 100,
- modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY,
isDefaultForLocation: {}
} satisfies ILanguageModelChatMetadata
];
@@ -358,7 +354,6 @@ suite('LanguageModels - Model Change Events', function () {
id: 'model1',
maxInputTokens: 100,
maxOutputTokens: 100,
- modelPickerCategory: undefined,
isDefaultForLocation: {}
} satisfies ILanguageModelChatMetadata,
identifier: 'test-vendor/model1'
@@ -386,7 +381,6 @@ suite('LanguageModels - Model Change Events', function () {
id: 'model1',
maxInputTokens: 100,
maxOutputTokens: 100,
- modelPickerCategory: undefined,
isDefaultForLocation: {}
} satisfies ILanguageModelChatMetadata,
identifier: 'test-vendor/model1'
@@ -430,7 +424,6 @@ suite('LanguageModels - Model Change Events', function () {
id: 'model1',
maxInputTokens: 100,
maxOutputTokens: 100,
- modelPickerCategory: undefined,
isDefaultForLocation: {}
} satisfies ILanguageModelChatMetadata,
identifier: 'test-vendor/model1'
@@ -484,7 +477,6 @@ suite('LanguageModels - Model Change Events', function () {
id: 'model1',
maxInputTokens: 100,
maxOutputTokens: 100,
- modelPickerCategory: undefined,
isDefaultForLocation: {}
} satisfies ILanguageModelChatMetadata,
identifier: 'test-vendor/model1'
@@ -531,7 +523,6 @@ suite('LanguageModels - Model Change Events', function () {
id: 'model1',
maxInputTokens: 100,
maxOutputTokens: 100,
- modelPickerCategory: undefined,
isDefaultForLocation: {}
} satisfies ILanguageModelChatMetadata,
identifier: 'test-vendor/model1'
@@ -571,7 +562,6 @@ suite('LanguageModels - Model Change Events', function () {
id: 'model2',
maxInputTokens: 100,
maxOutputTokens: 100,
- modelPickerCategory: undefined,
isDefaultForLocation: {}
} satisfies ILanguageModelChatMetadata,
identifier: 'test-vendor/model2'
@@ -602,7 +592,6 @@ suite('LanguageModels - Model Change Events', function () {
id: 'model1',
maxInputTokens: 100,
maxOutputTokens: 100,
- modelPickerCategory: undefined,
isDefaultForLocation: {}
} satisfies ILanguageModelChatMetadata,
identifier: 'test-vendor/model1'
@@ -619,7 +608,6 @@ suite('LanguageModels - Model Change Events', function () {
id: 'model2',
maxInputTokens: 200,
maxOutputTokens: 200,
- modelPickerCategory: undefined,
isDefaultForLocation: {}
} satisfies ILanguageModelChatMetadata,
identifier: 'test-vendor/model2'
@@ -813,7 +801,6 @@ suite('LanguageModels - Per-Model Configuration', function () {
id: 'model-a',
maxInputTokens: 100,
maxOutputTokens: 100,
- modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY,
isDefaultForLocation: {},
configurationSchema: {
type: 'object',
@@ -835,7 +822,6 @@ suite('LanguageModels - Per-Model Configuration', function () {
id: 'model-b',
maxInputTokens: 100,
maxOutputTokens: 100,
- modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY,
isDefaultForLocation: {}
} satisfies ILanguageModelChatMetadata,
identifier: 'config-vendor/default/model-b'
@@ -971,7 +957,6 @@ suite('LanguageModels - Provider Group Detail Fallback', function () {
id: 'shared-model',
maxInputTokens: 100,
maxOutputTokens: 100,
- modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY,
isDefaultForLocation: {}
} satisfies ILanguageModelChatMetadata,
identifier: `multi-vendor/${options.group}/shared-model`
@@ -1036,7 +1021,6 @@ suite('LanguageModels - Provider Group Detail Fallback', function () {
id: 'solo-model',
maxInputTokens: 100,
maxOutputTokens: 100,
- modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY,
isDefaultForLocation: {}
} satisfies ILanguageModelChatMetadata,
identifier: `single-vendor/${options.group}/solo-model`
@@ -1103,7 +1087,6 @@ suite('LanguageModels - Provider Group Detail Fallback', function () {
detail: `Detailed (${options.group})`,
maxInputTokens: 100,
maxOutputTokens: 100,
- modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY,
isDefaultForLocation: {}
} satisfies ILanguageModelChatMetadata,
identifier: `detail-vendor/${options.group}/detailed-model`
diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.ts
index a035350133b26f..1e2db7b484464b 100644
--- a/src/vs/workbench/contrib/chat/test/common/languageModels.ts
+++ b/src/vs/workbench/contrib/chat/test/common/languageModels.ts
@@ -26,6 +26,7 @@ export class NullLanguageModelsService implements ILanguageModelsService {
onDidChangeLanguageModels = Event.None;
onDidChangeLanguageModelVendors = Event.None;
onDidChangeModelsControlManifest = Event.None;
+ onDidChangePinnedModels = Event.None;
getVendors(): ILanguageModelProviderDescriptor[] {
return [];
@@ -109,6 +110,11 @@ export class NullLanguageModelsService implements ILanguageModelsService {
addToRecentlyUsedList(): void { }
clearRecentlyUsedList(): void { }
+ getPinnedModelIds(): string[] { return []; }
+ pinModel(_modelIdentifier: string): void { }
+ unpinModel(_modelIdentifier: string): void { }
+ isModelPinned(_modelIdentifier: string): boolean { return false; }
+
getModelsControlManifest(): IModelsControlManifest {
return { free: {}, paid: {} };
}
diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts
index 792edcd2c184ac..2c7e4a8787155b 100644
--- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts
+++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts
@@ -340,7 +340,6 @@ suite('RunSubagentTool', () => {
maxInputTokens: 128000,
maxOutputTokens: 8192,
isDefaultForLocation: {},
- modelPickerCategory: undefined,
multiplierNumeric,
capabilities: { toolCalling: true },
};
@@ -616,7 +615,6 @@ suite('RunSubagentTool', () => {
maxInputTokens: 128000,
maxOutputTokens: 8192,
isDefaultForLocation: {},
- modelPickerCategory: undefined,
multiplierNumeric,
capabilities: { toolCalling: true },
};
diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts b/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts
index cf21e94d059589..787100ba371480 100644
--- a/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts
+++ b/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts
@@ -1630,7 +1630,8 @@ export class PreferredExtensionsPagedModel implements IPagedModel {
const indexInPagedModel = index - this.preferredExtensions.length + this.resolvedGalleryExtensionsFromQuery.length;
const pageIndex = Math.floor(indexInPagedModel / this.pager.pageSize);
- const page = this.pages[pageIndex];
+ // pages array excludes page 0 (pre-resolved via firstPage), so adjust index
+ const page = this.pages[pageIndex - 1];
if (!page.promise) {
page.cts = new CancellationTokenSource();
diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesActions.ts b/src/vs/workbench/contrib/preferences/browser/preferencesActions.ts
index d9c48dbbaa0c74..2787e7118f02c5 100644
--- a/src/vs/workbench/contrib/preferences/browser/preferencesActions.ts
+++ b/src/vs/workbench/contrib/preferences/browser/preferencesActions.ts
@@ -23,8 +23,9 @@ import { IContextKeyService } from '../../../../platform/contextkey/common/conte
import { IFileService } from '../../../../platform/files/common/files.js';
import { IExtensionService } from '../../../services/extensions/common/extensions.js';
import { EnablementState, IWorkbenchExtensionEnablementService, IWorkbenchExtensionManagementService } from '../../../services/extensionManagement/common/extensionManagement.js';
-import { Event } from '../../../../base/common/event.js';
import { timeout } from '../../../../base/common/async.js';
+import { ExtensionIdentifierSet } from '../../../../platform/extensions/common/extensions.js';
+import { IDisposable } from '../../../../base/common/lifecycle.js';
export class ConfigureLanguageBasedSettingsAction extends Action {
@@ -175,9 +176,34 @@ CommandsRegistry.registerCommand({
const installed = await extensionManagementService.getInstalled();
const toEnable = installed.filter(e => e.isBuiltin && extensionEnablementService.canChangeEnablement(e) && !extensionEnablementService.isEnabled(e));
if (toEnable.length) {
- const registered = Event.toPromise(extensionService.onDidChangeExtensions);
- await extensionEnablementService.setEnablement(toEnable, EnablementState.EnabledGlobally);
- await Promise.race([registered, timeout(5000)]);
+ // Wait until every extension we're about to enable has actually been
+ // registered with the extension service (its contributions, including
+ // configuration, are processed at that point). Racing with a single
+ // `onDidChangeExtensions` event is unreliable because the event may
+ // fire before all of the enabled extensions have joined the registry.
+ const pending = new ExtensionIdentifierSet(toEnable.map(e => e.identifier.id));
+ for (const ext of extensionService.extensions) {
+ pending.delete(ext.identifier);
+ }
+ let sub: IDisposable | undefined;
+ const allRegistered = pending.size === 0
+ ? Promise.resolve()
+ : new Promise(resolve => {
+ sub = extensionService.onDidChangeExtensions(({ added }) => {
+ for (const ext of added) {
+ pending.delete(ext.identifier);
+ }
+ if (pending.size === 0) {
+ resolve();
+ }
+ });
+ });
+ try {
+ await extensionEnablementService.setEnablement(toEnable, EnablementState.EnabledGlobally);
+ await Promise.race([allRegistered, timeout(15000)]);
+ } finally {
+ sub?.dispose();
+ }
await extensionService.whenInstalledExtensionsRegistered();
}
diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh
index 4869a391ebc379..c43718e3354368 100644
--- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh
+++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh
@@ -106,6 +106,15 @@ if [ "${VSCODE_PREVENT_SHELL_HISTORY:-}" = "1" ]; then
builtin unset VSCODE_PREVENT_SHELL_HISTORY
fi
+# Agent terminal zsh fixups: disable bang history expansion so ! in double
+# quotes does not hang on dquote>, and enable inline # comments so the
+# agent can annotate commands.
+if [ "${VSCODE_AGENT_ZSH_FIXUPS:-}" = "1" ]; then
+ builtin setopt NO_BANG_HIST
+ builtin setopt INTERACTIVE_COMMENTS
+ builtin unset VSCODE_AGENT_ZSH_FIXUPS
+fi
+
# The property (P) and command (E) codes embed values which require escaping.
# Backslashes are doubled. Non-alphanumeric characters are converted to escaped hex.
__vsc_escape_value() {
diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts
index e97b601337931b..c468eb9c5c76f6 100644
--- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts
+++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts
@@ -148,6 +148,11 @@ export class ToolTerminalCreator {
const shellPath = isString(shellOrProfile) ? shellOrProfile : shellOrProfile.path;
const env: Record = {
+ // Let CLI tools detect that they are running inside an AI agent.
+ // This allows programs to adapt their output (e.g. JSON instead of
+ // ANSI, disable interactive prompts, skip animations).
+ // See https://github.com/microsoft/vscode/issues/311734
+ COPILOT_AGENT: '1',
// Avoid making `git diff` interactive when called from copilot
GIT_PAGER: 'cat',
// Prevent git from opening an editor for merge commits
@@ -171,6 +176,13 @@ export class ToolTerminalCreator {
}
}
+ // Zsh-specific fixups for agent terminals: disable bang history
+ // expansion (prevents ! in double quotes from hanging on dquote>)
+ // and enable inline # comments (lets the agent annotate commands).
+ if (isZsh(shellPath, os)) {
+ env['VSCODE_AGENT_ZSH_FIXUPS'] = '1';
+ }
+
const config: IShellLaunchConfig = {
icon: ThemeIcon.fromId(Codicon.chatSparkle.id),
hideFromUser: true,
diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts
index 5ae01c0fa20733..e7ea1507edee2e 100644
--- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts
+++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts
@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import type { IMarker as IXtermMarker } from '@xterm/xterm';
-import { DeferredPromise, timeout, type CancelablePromise } from '../../../../../../base/common/async.js';
+import { DeferredPromise, RunOnceScheduler, timeout, type CancelablePromise } from '../../../../../../base/common/async.js';
import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js';
import { Codicon } from '../../../../../../base/common/codicons.js';
import { CancellationError } from '../../../../../../base/common/errors.js';
@@ -260,7 +260,11 @@ function createZshModelDescription(isSandboxEnabled: boolean, allowToRunUnsandbo
'- Use jobs, fg, bg for job control',
'- Use [[ ]] for conditional tests instead of [ ]',
'- Prefer $() over backticks for command substitution',
- '- Take advantage of zsh globbing features (**, extended globs)'
+ '- Take advantage of zsh globbing features (**, extended globs). Note: unmatched globs fail by default (zsh: no matches found) — use a glob qualifier like *(N) or quote the glob if it should be literal',
+ '',
+ 'zsh pitfalls — these WILL cause errors or hangs:',
+ '- NEVER use bare == or === as separators (e.g. echo === triggers zsh equals expansion). Quote them: echo \'===\'',
+ '- NEVER use status as a variable name (it is read-only in zsh). Use exit_code or ret instead',
].join('\n');
}
@@ -1483,6 +1487,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
let exitCode: number | undefined;
let altBufferResult: IToolResult | undefined;
let didTimeout = false;
+ let didIdleSilence = false;
let didInputNeeded = false;
let didSensitiveAutoCancelled = false;
// Covers both terminals that start as background (persistentSession) and
@@ -1622,7 +1627,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
resultText += `${outputAnalyzerMessage}\n`;
}
resultText += pollingResult.output;
- resultText += `\n${this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ false)}`;
+ resultText += `\n${this._buildInputNeededSteeringText(chatSessionResource, termId, 'none')}`;
} else if (pollingResult) {
resultText += `\n The command is still running, with output:\n`;
if (outputAnalyzerMessage) {
@@ -1672,7 +1677,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
));
}
});
- const raceCandidates: Promise<{ type: 'completed'; result: ITerminalExecuteStrategyResult } | { type: 'background' } | { type: 'timeout' } | { type: 'inputNeeded' }>[] = [
+ const raceCandidates: Promise<{ type: 'completed'; result: ITerminalExecuteStrategyResult } | { type: 'background' } | { type: 'timeout' } | { type: 'inputNeeded' } | { type: 'idleSilence' }>[] = [
executionPromise.then(result => ({ type: 'completed' as const, result })),
continueInBackgroundPromise.then(() => ({ type: 'background' as const })),
new Promise<{ type: 'inputNeeded' }>(resolve => {
@@ -1686,8 +1691,24 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
if (timeoutRacePromise) {
raceCandidates.push(timeoutRacePromise);
}
- const raceResult = await Promise.race(raceCandidates);
- raceCleanup.dispose();
+ // Idle-silence promotion: if no terminal output arrives for N ms,
+ // hand control back to the model with the terminal ID + output
+ // collected so far. The process keeps running — model can poll,
+ // send input, or kill it. Default 60s; 0 disables.
+ const idleSilenceMs = this._configurationService.getValue(TerminalChatAgentToolsSettingId.IdleSilenceTimeoutMs) ?? 60000;
+ if (idleSilenceMs > 0) {
+ const idleSilenceDeferred = new DeferredPromise<{ type: 'idleSilence' }>();
+ const idleSilenceScheduler = raceCleanup.add(new RunOnceScheduler(() => idleSilenceDeferred.complete({ type: 'idleSilence' as const }), idleSilenceMs));
+ raceCleanup.add(toolTerminal.instance.onData(() => idleSilenceScheduler.schedule()));
+ idleSilenceScheduler.schedule();
+ raceCandidates.push(idleSilenceDeferred.p);
+ }
+ let raceResult: { type: 'completed'; result: ITerminalExecuteStrategyResult } | { type: 'background' } | { type: 'timeout' } | { type: 'inputNeeded' } | { type: 'idleSilence' };
+ try {
+ raceResult = await Promise.race(raceCandidates);
+ } finally {
+ raceCleanup.dispose();
+ }
if (raceResult.type === 'inputNeeded') {
// Output monitor detected the terminal is waiting for input.
@@ -1722,6 +1743,18 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
const timeoutOutput = execution.getOutput();
outputLineCount = timeoutOutput ? count(timeoutOutput.trim(), '\n') + 1 : 0;
terminalResult = timeoutOutput ?? '';
+ } else if (raceResult.type === 'idleSilence') {
+ // No output for N ms - promote to background and hand back to model. Process keeps running.
+ this._logService.debug(`RunInTerminalTool: Idle silence reached (${idleSilenceMs}ms), promoting to background`);
+ error = 'idleSilence';
+ didIdleSilence = true;
+ isBackgroundExecution = true;
+ toolTerminal.isBackground = true;
+ this._sessionTerminalAssociations.delete(chatSessionResource);
+ await this._associateProcessIdWithSession(toolTerminal.instance, chatSessionResource, termId, toolTerminal.shellIntegrationQuality, true);
+ const idleSilenceOutput = execution.getOutput();
+ outputLineCount = idleSilenceOutput ? count(idleSilenceOutput.trim(), '\n') + 1 : 0;
+ terminalResult = idleSilenceOutput ?? '';
} else {
const executeResult = raceResult.result;
// Reset user input state after command execution completes
@@ -1975,12 +2008,17 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
if (didSensitiveAutoCancelled) {
resultText.push(`Note: The command in terminal ID ${termId} was prompting for a password, passphrase, or other secret. The user is unavailable (auto-approve / autopilot mode is on, so no human can focus the terminal to type a secret) and the command has been cancelled. Stop, do NOT retry the command, do NOT call ${TerminalToolId.SendToTerminal}, and do NOT call vscode_askQuestions for the secret. Tell the user to run the command interactively when they are available.\n\n`);
} else if (didInputNeeded) {
- resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input.\n${this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ false)}\n\n`);
+ resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input.\n${this._buildInputNeededSteeringText(chatSessionResource, termId, 'none')}\n\n`);
} else if (didTimeout && timeoutValue !== undefined && timeoutValue > 0) {
const notificationHint = shouldSendNotifications
? ' You will be automatically notified on your next turn when it completes.'
: '';
- resultText.push(`Note: Command timed out after ${timeoutValue}ms. The command may still be running in terminal ID ${termId}.${notificationHint}\n${this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ true)}\n\n`);
+ resultText.push(`Note: Command timed out after ${timeoutValue}ms. The command may still be running in terminal ID ${termId}.${notificationHint}\n${this._buildInputNeededSteeringText(chatSessionResource, termId, 'timeout')}\n\n`);
+ } else if (didIdleSilence) {
+ const notificationHint = shouldSendNotifications
+ ? ' You will be automatically notified on your next turn when it completes.'
+ : '';
+ resultText.push(`Note: The command produced no new output for an extended period and was moved to background terminal ID ${termId}; the process is still running and has not been killed.${notificationHint}\n${this._buildInputNeededSteeringText(chatSessionResource, termId, 'idleSilence')}\n\n`);
}
const outputAnalyzerMessage = await this._getOutputAnalyzerMessage(exitCode, terminalResult, command, didSandboxWrapCommand);
if (outputAnalyzerMessage) {
@@ -2034,11 +2072,12 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
* tokens) must never be routed through `vscode_askQuestions`
* because answers to that tool are sent through the model — the
* user is told to type those values directly into the terminal.
- * `kill_terminal` is only advertised on the timeout branch — suggesting it
- * in the general case leads the model to terminate valid interactive
- * sessions (e.g. `npm init`) instead of driving them.
+ * `kill_terminal` is only advertised when the command may be hung
+ * (`'timeout'` or `'idleSilence'`) — suggesting it in the general case
+ * leads the model to terminate valid interactive sessions (e.g.
+ * `npm init`) instead of driving them.
*/
- private _buildInputNeededSteeringText(chatSessionResource: URI, termId: string, mentionTimeout: boolean): string {
+ private _buildInputNeededSteeringText(chatSessionResource: URI, termId: string, hungHint: 'none' | 'timeout' | 'idleSilence'): string {
const isAutoApproved = isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService);
const lines: string[] = [];
lines.push(`This note is not a signal to end the turn — pick one of the actions below and continue.`);
@@ -2052,8 +2091,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
lines.push(` 1. If the command may still be producing output or the shell prompt has not returned, call ${TerminalToolId.GetTerminalOutput} with id="${termId}" to continue polling. This is the default and safest action when unsure.`);
lines.push(` 2. Only if the output clearly ends with a real non-secret input prompt (Continue? (y/n), Enter selection, etc. — a normal shell prompt like \`$\` or \`#\` does NOT count), call the vscode_askQuestions tool to ask the user, then send each answer using ${TerminalToolId.SendToTerminal} with id="${termId}" (which returns the next few lines of output). Repeat one prompt at a time. NEVER route secret prompts (passwords, passphrases, tokens, API keys, etc.) through vscode_askQuestions — answers to that tool are sent through the model. For secret prompts, tell the user to type the value directly into the terminal and stop.`);
}
- if (mentionTimeout) {
+ if (hungHint === 'timeout') {
lines.push(` 3. A timeout does not mean the command failed — call ${TerminalToolId.GetTerminalOutput} with id="${termId}" to continue polling. Only call ${TerminalToolId.KillTerminal} if the command is genuinely hung and you need to retry with a different approach.`);
+ } else if (hungHint === 'idleSilence') {
+ lines.push(` 3. Producing no output for an extended period does not mean the command failed — call ${TerminalToolId.GetTerminalOutput} with id="${termId}" to continue polling. Only call ${TerminalToolId.KillTerminal} if the command is genuinely hung and you need to retry with a different approach.`);
}
return lines.join('\n');
}
@@ -2553,7 +2594,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
}
lastInputNeededOutput = currentOutput;
lastInputNeededNotificationTime = now;
- const inputAction = this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ false);
+ const inputAction = this._buildInputNeededSteeringText(chatSessionResource, termId, 'none');
const message = `[Terminal ${termId} notification: command may be waiting for input — assess the output below.]\n${inputAction}\nTerminal output:\n${currentOutput}`;
this._logService.debug(`RunInTerminalTool: Input needed in background terminal ${termId}, notifying chat session`);
diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts
index f64d76b8b6b30c..46d538a42f7bbb 100644
--- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts
+++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts
@@ -25,6 +25,7 @@ export const enum TerminalChatAgentToolsSettingId {
AgentSandboxAdvancedRuntime = 'chat.agent.sandbox.advanced.runtime',
PreventShellHistory = 'chat.tools.terminal.preventShellHistory',
EnforceTimeoutFromModel = 'chat.tools.terminal.enforceTimeoutFromModel',
+ IdleSilenceTimeoutMs = 'chat.tools.terminal.idleSilenceTimeoutMs',
DetachBackgroundProcesses = 'chat.tools.terminal.detachBackgroundProcesses',
BackgroundNotifications = 'chat.tools.terminal.backgroundNotifications',
IdlePollInterval = 'chat.tools.terminal.idlePollInterval',
@@ -720,6 +721,17 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary {
});
});
+ suite('input-needed steering text', () => {
+ function buildSteeringText(hungHint: 'none' | 'timeout' | 'idleSilence'): string {
+ const sessionResource = LocalChatSessionUri.forSession('input-needed-steering-session');
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ return (runInTerminalTool as unknown as { _buildInputNeededSteeringText(s: URI, t: string, h: 'none' | 'timeout' | 'idleSilence'): string })
+ ._buildInputNeededSteeringText(sessionResource, 'test-term-id', hungHint);
+ }
+
+ test('none mode does not mention timeout, idle silence, or kill_terminal', () => {
+ const text = buildSteeringText('none');
+ ok(!text.toLowerCase().includes('timeout'), 'Expected no mention of timeout in the input-needed (none) hint');
+ ok(!text.toLowerCase().includes('no output'), 'Expected no mention of idle silence in the input-needed (none) hint');
+ ok(!text.includes(TerminalToolId.KillTerminal), 'Expected kill_terminal not to be advertised in the input-needed (none) hint');
+ });
+
+ test('timeout mode advertises kill_terminal and mentions timeout', () => {
+ const text = buildSteeringText('timeout');
+ ok(text.toLowerCase().includes('timeout'), 'Expected timeout hint to mention "timeout"');
+ ok(text.includes(TerminalToolId.KillTerminal), 'Expected timeout hint to advertise kill_terminal');
+ });
+
+ test('idleSilence mode advertises kill_terminal without saying "timeout"', () => {
+ const text = buildSteeringText('idleSilence');
+ ok(!text.toLowerCase().includes('timeout'), 'Idle-silence hint must not refer to a timeout');
+ ok(text.toLowerCase().includes('no output'), 'Expected idle-silence hint to describe the no-output condition');
+ ok(text.includes(TerminalToolId.KillTerminal), 'Expected idle-silence hint to advertise kill_terminal');
+ });
+ });
+
suite('unique rules deduplication', () => {
test('should properly deduplicate rules with same sourceText in auto-approve info', async () => {
setAutoApprove({
diff --git a/src/vs/workbench/services/title/browser/titleService.ts b/src/vs/workbench/services/title/browser/titleService.ts
index 533160479846e3..90dbe8c340e898 100644
--- a/src/vs/workbench/services/title/browser/titleService.ts
+++ b/src/vs/workbench/services/title/browser/titleService.ts
@@ -5,6 +5,7 @@
import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { IAuxiliaryTitlebarPart, ITitlebarPart } from '../../../browser/parts/titlebar/titlebarPart.js';
+import { WindowTitle } from '../../../browser/parts/titlebar/windowTitle.js';
import { IEditorGroupsContainer } from '../../editor/common/editorGroupsService.js';
export const ITitleService = createDecorator('titleService');
@@ -13,6 +14,13 @@ export interface ITitleService extends ITitlebarPart {
readonly _serviceBrand: undefined;
+ /**
+ * The shared {@link WindowTitle} instance for the main window. Used by
+ * components that need to render or react to the resolved `window.title`
+ * (template variables, decorations, etc.) without instantiating their own.
+ */
+ readonly windowTitle: WindowTitle;
+
/**
* Get the status bar part that is rooted in the provided container.
*/
diff --git a/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts b/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts
index 839ce0bb41ad7c..16a26a43bed022 100644
--- a/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts
+++ b/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts
@@ -59,6 +59,25 @@ declare module 'vscode' {
readonly onDidDispose: Event;
}
+ /**
+ * Additional context for rendering chat output.
+ */
+ export interface ChatOutputRenderContext {
+ /**
+ * Context when a code block is being rendered.
+ *
+ * This is set when a renderer contributed through `chatOutputRenderers` renders
+ * a completed fenced code block whose identifier matches one of the contributed
+ * `codeBlockLanguageIdentifiers`.
+ */
+ readonly codeBlockContext?: {
+ /**
+ * The language identifier of the code block being rendered.
+ */
+ readonly languageIdentifier: string;
+ };
+ }
+
export interface ChatOutputRenderer {
/**
* Given an output, render it into the provided webview.
@@ -72,7 +91,7 @@ declare module 'vscode' {
*
* @returns A promise that resolves when the webview has been initialized and is ready to be presented to the user.
*/
- renderChatOutput(data: ChatOutputDataItem, webview: ChatOutputWebview, ctx: {}, token: CancellationToken): Thenable;
+ renderChatOutput(data: ChatOutputDataItem, webview: ChatOutputWebview, ctx: ChatOutputRenderContext, token: CancellationToken): Thenable;
}
export namespace chat {
@@ -84,10 +103,11 @@ declare module 'vscode' {
*
* ```json
* "contributes": {
- * "chatOutputRenderer": [
+ * "chatOutputRenderers": [
* {
* "viewType": "myExt.myChatOutputRenderer",
- * "mimeTypes": ["application/your-mime-type"]
+ * "mimeTypes": ["application/your-mime-type"],
+ * "codeBlockLanguageIdentifiers": ["mermaid"]
* }
* ]
* }
diff --git a/src/vscode-dts/vscode.proposed.chatProvider.d.ts b/src/vscode-dts/vscode.proposed.chatProvider.d.ts
index 1e73a4172b74fb..a3c29ae139e5c9 100644
--- a/src/vscode-dts/vscode.proposed.chatProvider.d.ts
+++ b/src/vscode-dts/vscode.proposed.chatProvider.d.ts
@@ -59,15 +59,6 @@ declare module 'vscode' {
*/
readonly isUserSelectable?: boolean;
- /**
- * Optional category to group models by in the model picker.
- * The lower the order, the higher the category appears in the list.
- * Has no effect if `isUserSelectable` is `false`.
- *
- * WONT BE FINALIZED
- */
- readonly category?: { label: string; order: number };
-
readonly statusIcon?: ThemeIcon;
/**