From abc186045cc04c41d2ac8d705bad1323e151ced6 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:16:54 -0800 Subject: [PATCH 01/32] Tree shake extensions --- extensions/esbuild-extension-common.mts | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/esbuild-extension-common.mts b/extensions/esbuild-extension-common.mts index da028ca7e02db..1c458e4bfe172 100644 --- a/extensions/esbuild-extension-common.mts +++ b/extensions/esbuild-extension-common.mts @@ -44,6 +44,7 @@ function resolveOptions(config: RunConfig, outdir: string): BuildOptions { platform: config.platform, bundle: true, minify: true, + treeShaking: true, sourcemap: true, target: ['es2024'], external: ['vscode'], From 2d698cf0544ccd408de942ece55ff916f8d442a8 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:00:24 -0800 Subject: [PATCH 02/32] Disallow dynamic require/import in extensions Follow up on https://github.com/microsoft/vscode/pull/296220 Let's make sure more code doesn't introduce this pattern without some thought --- eslint.config.js | 24 +++++++++++++++++++ .../web/src/serverHost.ts | 2 ++ 2 files changed, 26 insertions(+) diff --git a/eslint.config.js b/eslint.config.js index bc3c698a129f2..c86b226ee6e96 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2112,6 +2112,30 @@ export default tseslint.config( 'comma-dangle': ['warn', 'only-multiline'] } }, + // Extension main sources (excluding tests) + { + files: [ + 'extensions/**/*.ts', + + ], + ignores: [ + 'extensions/**/*.test.ts', + ], + rules: { + // Ban dynamic require() and import() calls in extensions to ensure tree-shaking works + 'no-restricted-syntax': [ + 'warn', + { + 'selector': `CallExpression[callee.name='require'][arguments.0.type!='Literal']`, + 'message': 'Use static imports instead of dynamic require() calls to enable tree-shaking.' + }, + { + 'selector': `ImportExpression[source.type!='Literal']`, + 'message': 'Use static imports instead of dynamic import() calls to enable tree-shaking.' + }, + ], + } + }, // markdown-language-features { files: [ diff --git a/extensions/typescript-language-features/web/src/serverHost.ts b/extensions/typescript-language-features/web/src/serverHost.ts index fdc617868b5c8..975672a50be43 100644 --- a/extensions/typescript-language-features/web/src/serverHost.ts +++ b/extensions/typescript-language-features/web/src/serverHost.ts @@ -104,6 +104,8 @@ function createServerHost( const scriptPath = combinePaths(packageRoot, browser); try { + // This file isn't bundled so we really do want a dynamic import here + // eslint-disable-next-line no-restricted-syntax const { default: module } = await import(/* webpackIgnore: true */ scriptPath); return { module, error: undefined }; } catch (e) { From d36696b4823f021727da4c6d9a7595ecb5303cd0 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:33:01 -0800 Subject: [PATCH 03/32] Esbuild the html and json extensions Switches from webpack to esbuild --- .../html-language-features/.vscodeignore | 5 +- .../client/tsconfig.browser.json | 7 ++ .../esbuild.browser.mts | 112 ++++++++++++++++++ extensions/html-language-features/esbuild.mts | 37 ++++++ .../extension-browser.webpack.config.js | 18 --- .../extension.webpack.config.js | 18 --- .../extension-browser.webpack.config.js | 42 ------- .../server/extension.webpack.config.js | 21 ---- .../server/tsconfig.browser.json | 7 ++ .../json-language-features/.vscodeignore | 5 +- .../client/tsconfig.browser.json | 7 ++ .../esbuild.browser.mts | 36 ++++++ extensions/json-language-features/esbuild.mts | 36 ++++++ .../extension-browser.webpack.config.js | 19 --- .../extension.webpack.config.js | 21 ---- .../extension-browser.webpack.config.js | 20 ---- .../server/extension.webpack.config.js | 20 ---- .../server/tsconfig.browser.json | 7 ++ 18 files changed, 251 insertions(+), 187 deletions(-) create mode 100644 extensions/html-language-features/client/tsconfig.browser.json create mode 100644 extensions/html-language-features/esbuild.browser.mts create mode 100644 extensions/html-language-features/esbuild.mts delete mode 100644 extensions/html-language-features/extension-browser.webpack.config.js delete mode 100644 extensions/html-language-features/extension.webpack.config.js delete mode 100644 extensions/html-language-features/server/extension-browser.webpack.config.js delete mode 100644 extensions/html-language-features/server/extension.webpack.config.js create mode 100644 extensions/html-language-features/server/tsconfig.browser.json create mode 100644 extensions/json-language-features/client/tsconfig.browser.json create mode 100644 extensions/json-language-features/esbuild.browser.mts create mode 100644 extensions/json-language-features/esbuild.mts delete mode 100644 extensions/json-language-features/extension-browser.webpack.config.js delete mode 100644 extensions/json-language-features/extension.webpack.config.js delete mode 100644 extensions/json-language-features/server/extension-browser.webpack.config.js delete mode 100644 extensions/json-language-features/server/extension.webpack.config.js create mode 100644 extensions/json-language-features/server/tsconfig.browser.json diff --git a/extensions/html-language-features/.vscodeignore b/extensions/html-language-features/.vscodeignore index 3e57ff5a65730..90ce71c9388ba 100644 --- a/extensions/html-language-features/.vscodeignore +++ b/extensions/html-language-features/.vscodeignore @@ -15,9 +15,6 @@ server/lib/cgmanifest.json server/package-lock.json server/.npmignore package-lock.json -server/extension.webpack.config.js -extension.webpack.config.js -server/extension-browser.webpack.config.js -extension-browser.webpack.config.js +**/esbuild*.mts CONTRIBUTING.md cgmanifest.json diff --git a/extensions/html-language-features/client/tsconfig.browser.json b/extensions/html-language-features/client/tsconfig.browser.json new file mode 100644 index 0000000000000..d10ec3ba37121 --- /dev/null +++ b/extensions/html-language-features/client/tsconfig.browser.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "./src/node/**", + "./src/test/**" + ] +} diff --git a/extensions/html-language-features/esbuild.browser.mts b/extensions/html-language-features/esbuild.browser.mts new file mode 100644 index 0000000000000..8f41059d45f8a --- /dev/null +++ b/extensions/html-language-features/esbuild.browser.mts @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type * as esbuild from 'esbuild'; +import { run } from '../esbuild-extension-common.mts'; + +const extensionRoot = import.meta.dirname; + +/** + * An esbuild plugin that replaces the `javascriptLibs` module with inlined TypeScript + * library definitions for the browser build. This is the esbuild equivalent of the + * webpack `javaScriptLibraryLoader.js`. + */ +function javaScriptLibsPlugin(): esbuild.Plugin { + return { + name: 'javascript-libs', + setup(build) { + build.onLoad({ filter: /javascriptLibs\.ts$/ }, () => { + const TYPESCRIPT_LIB_SOURCE = path.join(extensionRoot, 'node_modules', 'typescript', 'lib'); + const JQUERY_DTS = path.join(extensionRoot, 'server', 'lib', 'jquery.d.ts'); + + function getFileName(name: string): string { + return name === '' ? 'lib.d.ts' : `lib.${name}.d.ts`; + } + + function readLibFile(name: string): string { + return fs.readFileSync(path.join(TYPESCRIPT_LIB_SOURCE, getFileName(name)), 'utf8'); + } + + const queue: string[] = []; + const inQueue: Record = {}; + + function enqueue(name: string): void { + if (inQueue[name]) { + return; + } + inQueue[name] = true; + queue.push(name); + } + + enqueue('es2020.full'); + + const result: { name: string; content: string }[] = []; + while (queue.length > 0) { + const name = queue.shift()!; + const contents = readLibFile(name); + const lines = contents.split(/\r\n|\r|\n/); + + const outputLines: string[] = []; + for (const line of lines) { + const m = line.match(/\/\/\/\s* Date: Thu, 19 Feb 2026 00:48:16 -0800 Subject: [PATCH 04/32] Update extensions/html-language-features/esbuild.mts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extensions/html-language-features/esbuild.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/html-language-features/esbuild.mts b/extensions/html-language-features/esbuild.mts index 5317052836976..a4e34700d2bbc 100644 --- a/extensions/html-language-features/esbuild.mts +++ b/extensions/html-language-features/esbuild.mts @@ -31,7 +31,7 @@ await Promise.all([ outdir: path.join(extensionRoot, 'server', 'dist', 'node'), additionalOptions: { tsconfig: path.join(extensionRoot, 'server', 'tsconfig.json'), - external: ['typescript'], + external: ['vscode', 'typescript'], }, }, process.argv), ]); From 70008ff4fd40dae9764f6d9aa494cda7ad1db38d Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:52:31 -0800 Subject: [PATCH 05/32] Use import.resolve to get ts location --- extensions/html-language-features/esbuild.browser.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/html-language-features/esbuild.browser.mts b/extensions/html-language-features/esbuild.browser.mts index 8f41059d45f8a..b0383ed459cbc 100644 --- a/extensions/html-language-features/esbuild.browser.mts +++ b/extensions/html-language-features/esbuild.browser.mts @@ -19,7 +19,7 @@ function javaScriptLibsPlugin(): esbuild.Plugin { name: 'javascript-libs', setup(build) { build.onLoad({ filter: /javascriptLibs\.ts$/ }, () => { - const TYPESCRIPT_LIB_SOURCE = path.join(extensionRoot, 'node_modules', 'typescript', 'lib'); + const TYPESCRIPT_LIB_SOURCE = path.dirname(import.meta.resolve('typescript').replace('file://', '')); const JQUERY_DTS = path.join(extensionRoot, 'server', 'lib', 'jquery.d.ts'); function getFileName(name: string): string { From f029e4caff21d437079c22070696ae7f1c9d5553 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 19 Feb 2026 10:55:12 +0000 Subject: [PATCH 06/32] style: adjust margins and border radius for chat tip toolbar action items --- .../widget/chatContentParts/media/chatTipContent.css | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css index 43832fd58ce10..fb688523bde10 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css @@ -33,6 +33,14 @@ margin: 1px 2px; } +.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar .action-item:first-of-type { + margin-left: 1px; +} + +.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar .action-item:last-of-type { + margin-right: 1px; +} + .chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar .action-item .action-label { color: var(--vscode-descriptionForeground); padding: 4px; @@ -57,9 +65,9 @@ box-sizing: border-box; padding: 6px; background-color: var(--vscode-editorWidget-background); - border-radius: 4px 4px 0 0; + border-radius: var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-small) 0 0; border: 1px solid var(--vscode-editorWidget-border, var(--vscode-input-border, transparent)); - border-bottom: 1px solid var(--vscode-editorWidget-border, var(--vscode-input-border, transparent)); + border-bottom: none; font-size: var(--vscode-chat-font-size-body-s); font-family: var(--vscode-chat-font-family, inherit); color: var(--vscode-descriptionForeground); From eb1260a6d5f7fbb88a9e86bb0ecadd229ddb119a Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Thu, 19 Feb 2026 17:48:52 +0100 Subject: [PATCH 07/32] Add queue management options in chat handling (#296317) * feat(chat): add options for alwaysQueue and pauseQueue in chat request handling * feat(inlineChat): replace SubmitToChatAction with QueueInChatAction and remove AttachToChatAction --- src/vs/workbench/contrib/chat/browser/chat.ts | 5 + .../contrib/chat/browser/widget/chatWidget.ts | 6 +- .../chat/common/chatService/chatService.ts | 6 ++ .../common/chatService/chatServiceImpl.ts | 4 +- .../browser/inlineChat.contribution.ts | 3 +- .../inlineChat/browser/inlineChatActions.ts | 97 +------------------ 6 files changed, 24 insertions(+), 97 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index e58d1fe7fba73..c29b6a4843b05 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -322,6 +322,11 @@ export interface IChatAcceptInputOptions { * If Steering, also sets yieldRequested on any active request to signal it should wrap up. */ queue?: ChatRequestQueueKind; + /** + * When true, always queues the request regardless of whether a request is currently in progress. + * The request stays in the pending queue until explicitly processed. + */ + alwaysQueue?: boolean; } export interface IChatWidgetViewModelChangeEvent { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 01123496922e6..74cea0f3871ed 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -2330,6 +2330,9 @@ export class ChatWidget extends Disposable implements IChatWidget { // discard them or need a prompt (as in `confirmPendingRequestsBeforeSend`) // which could be a surprising behavior if the user finishes typing a steering // request just as confirmation is triggered. + if (options.alwaysQueue) { + options.queue ??= ChatRequestQueueKind.Queued; + } if (model.requestNeedsInput.get() && !model.getPendingRequests().length) { this.chatService.cancelCurrentRequestForSession(this.viewModel.sessionResource); options.queue ??= ChatRequestQueueKind.Queued; @@ -2337,7 +2340,7 @@ export class ChatWidget extends Disposable implements IChatWidget { if (requestInProgress) { options.queue ??= ChatRequestQueueKind.Queued; } - if (!requestInProgress && !isEditing && !(await this.confirmPendingRequestsBeforeSend(model, options))) { + if (!options.alwaysQueue && !requestInProgress && !isEditing && !(await this.confirmPendingRequestsBeforeSend(model, options))) { return; } @@ -2408,6 +2411,7 @@ export class ChatWidget extends Disposable implements IChatWidget { modeInfo: this.input.currentModeInfo, agentIdSilent: this._lockedAgent?.id, queue: options?.queue, + pauseQueue: options?.alwaysQueue, }); if (this.viewModel.sessionResource && !options.queue && ChatSendResult.isRejected(result)) { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index a6604f07fba4d..a826cd3b65816 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1329,6 +1329,12 @@ export interface IChatSendRequestOptions { */ queue?: ChatRequestQueueKind; + /** + * When true, the queued request will not be processed immediately even if no request is active. + * The request stays in the queue until `processPendingRequests` is called explicitly. + */ + pauseQueue?: boolean; + } export type IChatModelReference = IReference; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 490703770745a..60b1e20d348cf 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -794,7 +794,9 @@ export class ChatService extends Disposable implements IChatService { if (options?.queue) { const queued = this.queuePendingRequest(model, sessionResource, request, options); - this.processPendingRequests(sessionResource); + if (!options.pauseQueue) { + this.processPendingRequests(sessionResource); + } return queued; } else if (hasPendingRequest) { this.trace('sendRequest', `Session ${sessionResource} already has a pending request`); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts index f99d40fbd0fb5..2e6c7a6474b56 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts @@ -92,8 +92,7 @@ registerAction2(InlineChatActions.StartSessionAction); registerAction2(InlineChatActions.AskInChatAction); registerAction2(InlineChatActions.FocusInlineChat); registerAction2(InlineChatActions.SubmitInlineChatInputAction); -registerAction2(InlineChatActions.SubmitToChatAction); -registerAction2(InlineChatActions.AttachToChatAction); +registerAction2(InlineChatActions.QueueInChatAction); registerAction2(InlineChatActions.HideInlineChatInputAction); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index 39bbc646a0099..92789d87d0f16 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -28,9 +28,6 @@ import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IChatEditingService } from '../../chat/common/editing/chatEditingService.js'; import { IChatWidgetService } from '../../chat/browser/chat.js'; -import { IAgentFeedbackVariableEntry } from '../../chat/common/attachments/chatVariableEntries.js'; -import { generateUuid } from '../../../../base/common/uuid.js'; -import { basename } from '../../../../base/common/resources.js'; import { ChatRequestQueueKind } from '../../chat/common/chatService/chatService.js'; @@ -456,13 +453,13 @@ export class AskInChatAction extends EditorAction2 { } } -export class SubmitToChatAction extends AbstractInlineChatAction { +export class QueueInChatAction extends AbstractInlineChatAction { constructor() { super({ - id: 'inlineChat.submitToChat', - title: localize2('submitToChat', "Send to Chat"), + id: 'inlineChat.queueInChat', + title: localize2('queueInChat', "Queue in Chat"), icon: Codicon.arrowUp, precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, CTX_INLINE_CHAT_INPUT_HAS_TEXT, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT), keybinding: { @@ -475,11 +472,6 @@ export class SubmitToChatAction extends AbstractInlineChatAction { group: '0_main', order: 1, when: CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, - alt: { - id: AttachToChatAction.Id, - title: localize2('attachToChat', "Attach to Chat"), - icon: Codicon.attach - } }] }); } @@ -511,87 +503,6 @@ export class SubmitToChatAction extends AbstractInlineChatAction { if (selection && !selection.isEmpty()) { await widget.attachmentModel.addFile(editor.getModel().uri, selection); } - await widget.acceptInput(value, { queue: ChatRequestQueueKind.Queued }); - } -} - -export class AttachToChatAction extends AbstractInlineChatAction { - - static readonly Id = 'inlineChat.attachToChat'; - - constructor() { - super({ - id: AttachToChatAction.Id, - title: localize2('attachToChat', "Attach to Chat"), - icon: Codicon.attach, - precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, CTX_INLINE_CHAT_INPUT_HAS_TEXT, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT), - keybinding: { - when: ContextKeyExpr.and(CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT), - weight: KeybindingWeight.EditorCore + 10, - primary: KeyMod.CtrlCmd | KeyCode.Enter, - secondary: [KeyMod.Alt | KeyCode.Enter] - }, - }); - } - - override async runInlineChatCommand(accessor: ServicesAccessor, ctrl: InlineChatController, editor: ICodeEditor): Promise { - const chatEditingService = accessor.get(IChatEditingService); - const chatWidgetService = accessor.get(IChatWidgetService); - if (!editor.hasModel()) { - return; - } - - const value = ctrl.inputWidget.value; - const selection = editor.getSelection(); - ctrl.inputWidget.hide(); - if (!value || !selection || selection.isEmpty()) { - return; - } - - const session = chatEditingService.editingSessionsObs.get().find(s => s.getEntry(editor.getModel().uri)); - if (!session) { - return; - } - - const widget = await chatWidgetService.openSession(session.chatSessionResource); - if (!widget) { - return; - } - - const uri = editor.getModel().uri; - const selectedText = editor.getModel().getValueInRange(selection); - const fileName = basename(uri); - const lineRef = selection.startLineNumber === selection.endLineNumber - ? `${selection.startLineNumber}` - : `${selection.startLineNumber}-${selection.endLineNumber}`; - - const feedbackValue = [ - ``, - ``, - selectedText, - ``, - ``, - value, - ``, - `` - ].join('\n'); - - const feedbackId = generateUuid(); - const entry: IAgentFeedbackVariableEntry = { - kind: 'agentFeedback', - id: `inlineChat.feedback.${feedbackId}`, - name: localize('attachToChat.name', "{0}:{1}", fileName, lineRef), - icon: Codicon.comment, - sessionResource: session.chatSessionResource, - feedbackItems: [{ - id: feedbackId, - text: value, - resourceUri: uri, - range: selection, - }], - value: feedbackValue, - }; - - widget.attachmentModel.addContext(entry); + await widget.acceptInput(value, { alwaysQueue: true, queue: ChatRequestQueueKind.Queued }); } } From d008aeb364fe9723cb258797fe884bfcf1221cd5 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 19 Feb 2026 18:09:06 +0100 Subject: [PATCH 08/32] Improvements to model picker (#296313) * enhance ActionList focus behavior and add automatic filtering on keydown * suppress hover during programmatic focus in ActionList * refactor(ActionList): streamline focus handling and remove suppress hover logic * fix(ActionList): suppress hover during programmatic focus changes * fix(ActionWidgetService): move filter input above the list for improved layout * feat(ActionWidgetService): add collapse and expand section actions with keybindings * feat(ActionWidgetService): add toggle section action with keybinding * fix(ActionList): preserve focus on previously focused item after filtering * fix(ActionWidgetService): update toggleSection to return a boolean and handle section toggling in action * feat(ActionList): add filter placement option for customizable filter input position * fix(ModelPicker): enhance model availability checks for curated models and version updates * fix(ModelPickerWidget): update hover content logic to exclude auto models from version display * fix(ActionList): adjust border styles for filter input to enhance visual separation * refactor(LanguageModels): rename curated models to control manifest and update related interfaces * fix(ModelPicker): update hover content and description handling for unavailable models * fix(LanguageModels): update models control manifest structure to use dictionaries instead of arrays * fix(ModelPicker): enhance model selection with version checks and update hover content for outdated models * fix(ModelPicker): enhance hover content for model updates based on VS Code version state * fix(ModelPicker): update model picker to only include featured control manifest models * fix(LanguageModels): add featured property to model control entries and update response handling * fix(LanguageModels): unify model control entry types and update references in the model picker * fix(ModelPicker): enhance model retrieval logic to include metadata-based fallback for recent models * fix(ModelPicker): refactor control models handling to use IStringDictionary for improved access and performance * fix(ChatModelPicker): improve upgrade handling with fallback message when upgrade URL is not available * fix(ChatModelPicker): add version check for model promotion to handle unavailable models * fix(ModelPicker): refactor buildModelPickerItems for improved model handling and add unit tests * fix(ActionList): add showAlways property to IActionListItem for persistent visibility in filtering * fix(ActionList): ensure items tagged with showAlways are always visible during filtering * fix(ActionWidget): add filter input focus tracking and update preconditions for actions * fix(ChatModelPicker): update layout description for model picker to include promoted section and visibility of unavailable models * fix(LanguageModelsService): improve handling of free and paid model entries for robustness * fix(chatModelPicker.test): ensure no disposables are leaked in test suite --- .../actionWidget/browser/actionList.ts | 132 ++++- .../actionWidget/browser/actionWidget.css | 9 +- .../actionWidget/browser/actionWidget.ts | 104 +++- .../browser/widget/input/chatModelPicker.ts | 336 +++++++----- .../contrib/chat/common/languageModels.ts | 98 ++-- .../chatModelsViewModel.test.ts | 6 +- .../widget/input/chatModelPicker.test.ts | 495 ++++++++++++++++++ .../chat/test/common/languageModels.ts | 8 +- 8 files changed, 977 insertions(+), 211 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 10cf26dc73fae..b80f1107c4d99 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -89,6 +89,10 @@ export interface IActionListItem { * Optional badge text to display after the label (e.g., "New"). */ readonly badge?: string; + /** + * When true, this item is always shown when filtering produces no other results. + */ + readonly showAlways?: boolean; } interface IActionMenuTemplateData { @@ -325,10 +329,15 @@ function getKeyboardNavigationLabel(item: IActionListItem): string | undef */ export interface IActionListOptions { /** - * When true, shows a filter input at the bottom of the list. + * When true, shows a filter input. */ readonly showFilter?: boolean; + /** + * Placement of the filter input. Defaults to 'top'. + */ + readonly filterPlacement?: 'top' | 'bottom'; + /** * Section IDs that should be collapsed by default. */ @@ -358,6 +367,7 @@ export class ActionList extends Disposable { private readonly _collapsedSections = new Set(); private _filterText = ''; + private _suppressHover = false; private readonly _filterInput: HTMLInputElement | undefined; private readonly _filterContainer: HTMLElement | undefined; private _lastMinWidth = 0; @@ -499,7 +509,23 @@ export class ActionList extends Disposable { this._applyFilter(); if (this._list.length) { - this.focusNext(); + this._focusCheckedOrFirst(); + } + + // When the list has focus and user types a printable character, + // forward it to the filter input so search begins automatically. + if (this._filterInput) { + this._register(dom.addDisposableListener(this.domNode, 'keydown', (e: KeyboardEvent) => { + if (this._filterInput && !dom.isActiveElement(this._filterInput) + && e.key.length === 1 && e.key !== ' ' && !e.ctrlKey && !e.metaKey && !e.altKey) { + this._filterInput.focus(); + this._filterInput.value = e.key; + this._filterText = e.key; + this._applyFilter(); + e.preventDefault(); + e.stopPropagation(); + } + })); } } @@ -517,6 +543,13 @@ export class ActionList extends Disposable { const isFiltering = filterLower.length > 0; const visible: IActionListItem[] = []; + // Remember the focused item before splice + const focusedIndexes = this._list.getFocus(); + let focusedItem: IActionListItem | undefined; + if (focusedIndexes.length > 0) { + focusedItem = this._list.element(focusedIndexes[0]); + } + for (const item of this._allMenuItems) { if (item.kind === ActionListItemKind.Header) { if (isFiltering) { @@ -537,6 +570,11 @@ export class ActionList extends Disposable { // Action item if (isFiltering) { + // Always show items tagged with showAlways + if (item.showAlways) { + visible.push(item); + continue; + } // When filtering, skip section toggle items and only match content if (item.isSectionToggle) { continue; @@ -582,6 +620,20 @@ export class ActionList extends Disposable { this._filterInput?.focus(); } else { this._list.domFocus(); + // Restore focus to the previously focused item + if (focusedItem) { + const focusedItemId = (focusedItem.item as { id?: string })?.id; + if (focusedItemId) { + for (let i = 0; i < this._list.length; i++) { + const el = this._list.element(i); + if ((el.item as { id?: string })?.id === focusedItemId) { + this._list.setFocus([i]); + this._list.reveal(i); + break; + } + } + } + } } // Reposition the context view so the widget grows in the correct direction if (reposition) { @@ -598,15 +650,38 @@ export class ActionList extends Disposable { return this._filterContainer; } + get filterPlacement(): 'top' | 'bottom' { + return this._options?.filterPlacement ?? 'top'; + } + + get filterInput(): HTMLInputElement | undefined { + return this._filterInput; + } + private focusCondition(element: IActionListItem): boolean { return !element.disabled && element.kind === ActionListItemKind.Action; } focus(): void { - if (this._filterInput) { - this._filterInput.focus(); - } else { - this._list.domFocus(); + this._list.domFocus(); + this._focusCheckedOrFirst(); + } + + private _focusCheckedOrFirst(): void { + this._suppressHover = true; + try { + // Try to focus the checked item first + for (let i = 0; i < this._list.length; i++) { + const element = this._list.element(i); + if (element.kind === ActionListItemKind.Action && (element.item as { checked?: boolean })?.checked) { + this._list.setFocus([i]); + this._list.reveal(i); + return; + } + } + this.focusNext(); + } finally { + this._suppressHover = false; } } @@ -742,6 +817,45 @@ export class ActionList extends Disposable { } } + collapseFocusedSection() { + const section = this._getFocusedSection(); + if (section && !this._collapsedSections.has(section)) { + this._toggleSection(section); + } + } + + expandFocusedSection() { + const section = this._getFocusedSection(); + if (section && this._collapsedSections.has(section)) { + this._toggleSection(section); + } + } + + toggleFocusedSection(): boolean { + const focused = this._list.getFocus(); + if (focused.length === 0) { + return false; + } + const element = this._list.element(focused[0]); + if (element.isSectionToggle && element.section) { + this._toggleSection(element.section); + return true; + } + return false; + } + + private _getFocusedSection(): string | undefined { + const focused = this._list.getFocus(); + if (focused.length === 0) { + return undefined; + } + const element = this._list.element(focused[0]); + if (element.isSectionToggle && element.section) { + return element.section; + } + return element.section; + } + acceptSelected(preview?: boolean) { const focused = this._list.getFocus(); if (focused.length === 0) { @@ -784,8 +898,10 @@ export class ActionList extends Disposable { const element = this._list.element(focusIndex); this._delegate.onFocus?.(element.item); - // Show hover on focus change - this._showHoverForElement(element, focusIndex); + // Show hover on focus change (suppress during programmatic initial focus) + if (!this._suppressHover) { + this._showHoverForElement(element, focusIndex); + } } private _getRowElement(index: number): HTMLElement | null { diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index eb97852b4c631..db07ddba8e2a4 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -240,10 +240,17 @@ /* Filter input */ .action-widget .action-list-filter { - border-top: 1px solid var(--vscode-editorHoverWidget-border); padding: 4px; } +.action-widget .action-list-filter:first-child { + border-bottom: 1px solid var(--vscode-editorHoverWidget-border); +} + +.action-widget .action-list-filter:last-child { + border-top: 1px solid var(--vscode-editorHoverWidget-border); +} + .action-widget .action-list-filter-input { width: 100%; box-sizing: border-box; diff --git a/src/vs/platform/actionWidget/browser/actionWidget.ts b/src/vs/platform/actionWidget/browser/actionWidget.ts index 88c6cf608e5ab..3fe8208fc8f9e 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.ts +++ b/src/vs/platform/actionWidget/browser/actionWidget.ts @@ -12,7 +12,7 @@ import './actionWidget.css'; import { localize, localize2 } from '../../../nls.js'; import { acceptSelectedActionCommand, ActionList, IActionListDelegate, IActionListItem, IActionListOptions, previewSelectedActionCommand } from './actionList.js'; import { Action2, registerAction2 } from '../../actions/common/actions.js'; -import { IContextKeyService, RawContextKey } from '../../contextkey/common/contextkey.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../contextkey/common/contextkey.js'; import { IContextViewService } from '../../contextview/browser/contextView.js'; import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; import { createDecorator, IInstantiationService, ServicesAccessor } from '../../instantiation/common/instantiation.js'; @@ -28,7 +28,8 @@ registerColor( ); const ActionWidgetContextKeys = { - Visible: new RawContextKey('codeActionMenuVisible', false, localize('codeActionMenuVisible', "Whether the action widget list is visible")) + Visible: new RawContextKey('codeActionMenuVisible', false, localize('codeActionMenuVisible', "Whether the action widget list is visible")), + FilterFocused: new RawContextKey('codeActionMenuFilterFocused', false, localize('codeActionMenuFilterFocused', "Whether the action widget filter input is focused")), }; export const IActionWidgetService = createDecorator('actionWidgetService'); @@ -89,6 +90,18 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { this._list?.value?.focusNext(); } + collapseSection() { + this._list?.value?.collapseFocusedSection(); + } + + expandSection() { + this._list?.value?.expandFocusedSection(); + } + + toggleSection(): boolean { + return this._list?.value?.toggleFocusedSection() ?? false; + } + hide(didCancel?: boolean) { this._list.value?.hide(didCancel); this._list.clear(); @@ -105,7 +118,15 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { this._list.value = list; if (this._list.value) { + // Filter input at the top + if (this._list.value.filterContainer && this._list.value.filterPlacement === 'top') { + widget.appendChild(this._list.value.filterContainer); + } widget.appendChild(this._list.value.domNode); + // Filter input at the bottom + if (this._list.value.filterContainer && this._list.value.filterPlacement === 'bottom') { + widget.appendChild(this._list.value.filterContainer); + } } else { throw new Error('List has no value'); } @@ -137,16 +158,20 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { } } - // Filter input (appended after the list, before action bar visually) - if (this._list.value?.filterContainer) { - widget.appendChild(this._list.value.filterContainer); - } - const width = this._list.value?.layout(actionBarWidth); widget.style.width = `${width}px`; this._list.value?.focus(); + // Track filter input focus state + const filterFocusedContext = ActionWidgetContextKeys.FilterFocused.bindTo(this._contextKeyService); + renderDisposables.add({ dispose: () => filterFocusedContext.reset() }); + if (this._list.value?.filterInput) { + const filterInput = this._list.value.filterInput; + renderDisposables.add(dom.addDisposableListener(filterInput, 'focus', () => filterFocusedContext.set(true))); + renderDisposables.add(dom.addDisposableListener(filterInput, 'blur', () => filterFocusedContext.set(false))); + } + const focusTracker = renderDisposables.add(dom.trackFocus(element)); renderDisposables.add(focusTracker.onDidBlur(() => { // Don't hide if focus moved to a hover that belongs to this action widget @@ -245,6 +270,71 @@ registerAction2(class extends Action2 { } }); +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'collapseSectionCodeAction', + title: localize2('collapseSectionCodeAction.title', "Collapse section"), + precondition: ContextKeyExpr.and(ActionWidgetContextKeys.Visible, ActionWidgetContextKeys.FilterFocused.negate()), + keybinding: { + weight, + primary: KeyCode.LeftArrow, + } + }); + } + + run(accessor: ServicesAccessor): void { + const widgetService = accessor.get(IActionWidgetService); + if (widgetService instanceof ActionWidgetService) { + widgetService.collapseSection(); + } + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'expandSectionCodeAction', + title: localize2('expandSectionCodeAction.title', "Expand section"), + precondition: ContextKeyExpr.and(ActionWidgetContextKeys.Visible, ActionWidgetContextKeys.FilterFocused.negate()), + keybinding: { + weight, + primary: KeyCode.RightArrow, + } + }); + } + + run(accessor: ServicesAccessor): void { + const widgetService = accessor.get(IActionWidgetService); + if (widgetService instanceof ActionWidgetService) { + widgetService.expandSection(); + } + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'toggleSectionCodeAction', + title: localize2('toggleSectionCodeAction.title', "Toggle section"), + precondition: ContextKeyExpr.and(ActionWidgetContextKeys.Visible, ActionWidgetContextKeys.FilterFocused.negate()), + keybinding: { + weight, + primary: KeyCode.Space, + } + }); + } + + run(accessor: ServicesAccessor): void { + const widgetService = accessor.get(IActionWidgetService); + if (widgetService instanceof ActionWidgetService) { + if (!widgetService.toggleSection()) { + widgetService.acceptSelected(); + } + } + } +}); + registerAction2(class extends Action2 { constructor() { super({ 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 23af9f66d09d5..222e7e78289a0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -6,6 +6,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; import { renderIcon, renderLabelWithIcons } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { IStringDictionary } from '../../../../../../base/common/collections.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; @@ -21,10 +22,11 @@ import { IProductService } from '../../../../../../platform/product/common/produ import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { TelemetryTrustedValue } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { MANAGE_CHAT_COMMAND_ID } from '../../../common/constants.js'; -import { ICuratedModel, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../common/languageModels.js'; +import { IModelControlEntry, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../common/languageModels.js'; import { IChatEntitlementService, isProUser } from '../../../../../services/chat/common/chatEntitlementService.js'; import * as semver from '../../../../../../base/common/semver/semver.js'; import { IModelPickerDelegate } from './modelPickerActionItem.js'; +import { IUpdateService, StateType } from '../../../../../../platform/update/common/update.js'; function isVersionAtLeast(current: string, required: string): boolean { const currentSemver = semver.coerce(current); @@ -34,6 +36,23 @@ function isVersionAtLeast(current: string, required: string): boolean { return semver.gte(currentSemver, required); } +function getUpdateHoverContent(updateState: StateType): MarkdownString { + const hoverContent = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); + switch (updateState) { + case StateType.AvailableForDownload: + hoverContent.appendMarkdown(localize('chat.modelPicker.downloadUpdateHover', "This model requires a newer version of VS Code. [Download Update](command:update.downloadUpdate) to access it.")); + break; + case StateType.Downloaded: + case StateType.Ready: + hoverContent.appendMarkdown(localize('chat.modelPicker.restartUpdateHover', "This model requires a newer version of VS Code. [Restart to Update](command:update.restartToUpdate) to access it.")); + break; + default: + hoverContent.appendMarkdown(localize('chat.modelPicker.checkUpdateHover', "This model requires a newer version of VS Code. [Check for Updates](command:update.checkForUpdate) to access it.")); + break; + } + return hoverContent; +} + /** * Section identifiers for collapsible groups in the model picker. */ @@ -94,174 +113,155 @@ function createModelAction( * * Layout: * 1. Auto (always first) - * 2. Recently used + curated models (merged, sorted alphabetically, no header) - * 3. Other Models (collapsible toggle, sorted alphabetically) - * - Last item is "Manage Models..." + * 2. Promoted section (selected + recently used + featured models from control manifest) + * - Available models sorted alphabetically, followed by unavailable models + * - Unavailable models show upgrade/update/admin status + * 3. Other Models (collapsible toggle, sorted by vendor then name) + * - Last item is "Manage Models..." (always visible during filtering) */ -function buildModelPickerItems( +export function buildModelPickerItems( models: ILanguageModelChatMetadataAndIdentifier[], selectedModelId: string | undefined, recentModelIds: string[], - curatedModels: ICuratedModel[], + controlModels: IStringDictionary, isProUser: boolean, currentVSCodeVersion: string, + updateStateType: StateType, onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void, commandService: ICommandService, upgradePlanUrl: string | undefined, ): IActionListItem[] { const items: IActionListItem[] = []; - // Collect all available models + // Collect all available models into lookup maps const allModelsMap = new Map(); - for (const model of models) { - allModelsMap.set(model.identifier, model); - } - - // Build a secondary lookup by metadata.id for flexible matching const modelsByMetadataId = new Map(); for (const model of models) { + allModelsMap.set(model.identifier, model); modelsByMetadataId.set(model.metadata.id, model); } - // Track which model IDs have been placed in the promoted group const placed = new Set(); + const markPlaced = (identifierOrId: string, metadataId?: string) => { + placed.add(identifierOrId); + if (metadataId) { + placed.add(metadataId); + } + }; + + const resolveModel = (id: string) => allModelsMap.get(id) ?? modelsByMetadataId.get(id); + + const getUnavailableReason = (entry: IModelControlEntry): 'upgrade' | 'update' | 'admin' => { + if (!isProUser) { + return 'upgrade'; + } + if (entry.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { + return 'update'; + } + return 'admin'; + }; + // --- 1. Auto --- - const autoModel = models.find(m => m.metadata.id === 'auto' && m.metadata.vendor === 'copilot')!; - // Always mark the auto model as placed + const autoModel = models.find(m => m.metadata.id === 'auto' && m.metadata.vendor === 'copilot'); if (autoModel) { - placed.add(autoModel.identifier); - placed.add(autoModel.metadata.id); - const action = createModelAction(autoModel, selectedModelId, onSelect); - items.push(createModelItem(action, autoModel)); + markPlaced(autoModel.identifier, autoModel.metadata.id); + items.push(createModelItem(createModelAction(autoModel, selectedModelId, onSelect), autoModel)); } - // --- 2. Promoted models (recently used + curated, merged & sorted alphabetically) --- - const promotedModels: ILanguageModelChatMetadataAndIdentifier[] = []; - const unavailableCurated: { curated: ICuratedModel; reason: 'upgrade' | 'update' | 'admin' }[] = []; + // --- 2. Promoted section (selected + recently used + featured) --- + type PromotedItem = + | { kind: 'available'; model: ILanguageModelChatMetadataAndIdentifier } + | { kind: 'unavailable'; entry: IModelControlEntry; reason: 'upgrade' | 'update' | 'admin' }; - // Always include the currently selected model in the promoted group - if (selectedModelId && selectedModelId !== autoModel?.identifier) { - const selectedModel = allModelsMap.get(selectedModelId); - if (selectedModel && !placed.has(selectedModel.identifier)) { - promotedModels.push(selectedModel); - placed.add(selectedModel.identifier); - placed.add(selectedModel.metadata.id); - } - } + const promotedItems: PromotedItem[] = []; - // Add recently used - for (const id of recentModelIds) { - const model = allModelsMap.get(id); - if (model && !placed.has(model.identifier)) { - promotedModels.push(model); - placed.add(model.identifier); - placed.add(model.metadata.id); + // Try to place a model by id. Returns true if handled. + const tryPlaceModel = (id: string): boolean => { + if (placed.has(id)) { + return false; } - } - - // Add curated - available ones become promoted, unavailable ones become disabled entries - for (const curated of curatedModels) { - const model = allModelsMap.get(curated.id) ?? modelsByMetadataId.get(curated.id); - if (model && !placed.has(model.identifier) && !placed.has(model.metadata.id)) { - placed.add(model.identifier); - placed.add(model.metadata.id); - if (curated.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, curated.minVSCodeVersion)) { - unavailableCurated.push({ curated, reason: 'update' }); + const model = resolveModel(id); + if (model && !placed.has(model.identifier)) { + markPlaced(model.identifier, model.metadata.id); + const entry = controlModels[model.metadata.id]; + if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { + promotedItems.push({ kind: 'unavailable', entry, reason: 'update' }); } else { - promotedModels.push(model); + promotedItems.push({ kind: 'available', model }); } - } else if (!model) { - // Model is not available - determine reason - if (!isProUser) { - unavailableCurated.push({ curated, reason: 'upgrade' }); - } else if (curated.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, curated.minVSCodeVersion)) { - unavailableCurated.push({ curated, reason: 'update' }); - } else { - unavailableCurated.push({ curated, reason: 'admin' }); + return true; + } + if (!model) { + const entry = controlModels[id]; + if (entry) { + markPlaced(id); + promotedItems.push({ kind: 'unavailable', entry, reason: getUnavailableReason(entry) }); + return true; } } + return false; + }; + + // Selected model + if (selectedModelId && selectedModelId !== autoModel?.identifier) { + tryPlaceModel(selectedModelId); } - // Sort alphabetically for a stable list - promotedModels.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name)); + // Recently used models + for (const id of recentModelIds) { + tryPlaceModel(id); + } - if (promotedModels.length > 0 || unavailableCurated.length > 0) { - items.push({ - kind: ActionListItemKind.Separator, - }); - for (const model of promotedModels) { - const action = createModelAction(model, selectedModelId, onSelect); - items.push(createModelItem(action, model)); + // Featured models from control manifest + for (const entry of Object.values(controlModels)) { + if (!entry.featured || placed.has(entry.id)) { + continue; } - - // Unavailable curated models shown as disabled with action link - for (const { curated, reason } of unavailableCurated) { - let description: string | MarkdownString; - if (reason === 'upgrade' && upgradePlanUrl) { - description = new MarkdownString(localize('chat.modelPicker.upgradeLink', "[Upgrade]({0})", upgradePlanUrl), { isTrusted: true }); - } else if (reason === 'update') { - description = new MarkdownString(localize('chat.modelPicker.updateLink', "[Update VS Code](command:update.checkForUpdate)"), { isTrusted: true }); + const model = resolveModel(entry.id); + if (model && !placed.has(model.identifier)) { + markPlaced(model.identifier, model.metadata.id); + if (entry.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { + promotedItems.push({ kind: 'unavailable', entry, reason: 'update' }); } else { - description = localize('chat.modelPicker.adminEnable', "Contact Admin"); + promotedItems.push({ kind: 'available', model }); } + } else if (!model) { + markPlaced(entry.id); + promotedItems.push({ kind: 'unavailable', entry, reason: getUnavailableReason(entry) }); + } + } - const hoverContent = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); - if (reason === 'upgrade' && upgradePlanUrl) { - hoverContent.appendMarkdown(localize('chat.modelPicker.upgradeHover', "This model requires a paid plan. [Upgrade]({0}) to access it.", upgradePlanUrl)); - } else if (reason === 'update') { - hoverContent.appendMarkdown(localize('chat.modelPicker.updateHover', "This model requires a newer version of VS Code. [Update VS Code](command:update.checkForUpdate) to access it.")); - } else { - hoverContent.appendMarkdown(localize('chat.modelPicker.adminHover', "This model is not available. Contact your administrator to enable it.")); - } + // Render promoted section: available sorted alphabetically, then unavailable + if (promotedItems.length > 0) { + const available = promotedItems.filter((i): i is PromotedItem & { kind: 'available' } => i.kind === 'available'); + const unavailable = promotedItems.filter((i): i is PromotedItem & { kind: 'unavailable' } => i.kind === 'unavailable'); + available.sort((a, b) => a.model.metadata.name.localeCompare(b.model.metadata.name)); - items.push({ - item: { - id: curated.id, - enabled: false, - checked: false, - class: undefined, - tooltip: curated.label, - label: curated.label, - description: typeof description === 'string' ? description : undefined, - run: () => { } - }, - kind: ActionListItemKind.Action, - label: curated.label, - description, - disabled: true, - group: { title: '', icon: Codicon.blank }, - hideIcon: false, - hover: { content: hoverContent }, - }); + items.push({ kind: ActionListItemKind.Separator }); + for (const { model } of available) { + items.push(createModelItem(createModelAction(model, selectedModelId, onSelect), model)); + } + for (const { entry, reason } of unavailable) { + items.push(createUnavailableModelItem(entry, reason, upgradePlanUrl, updateStateType)); } } // --- 3. Other Models (collapsible) --- - const otherModels: ILanguageModelChatMetadataAndIdentifier[] = []; - for (const model of models) { - if (!placed.has(model.identifier) && !placed.has(model.metadata.id)) { - otherModels.push(model); - } - } - // Copilot models first, then by vendor, each sub-group sorted alphabetically - otherModels.sort((a, b) => { - const aCopilot = a.metadata.vendor === 'copilot' ? 0 : 1; - const bCopilot = b.metadata.vendor === 'copilot' ? 0 : 1; - if (aCopilot !== bCopilot) { - return aCopilot - bCopilot; - } - const vendorCmp = a.metadata.vendor.localeCompare(b.metadata.vendor); - if (vendorCmp !== 0) { - return vendorCmp; - } - return a.metadata.name.localeCompare(b.metadata.name); - }); + const otherModels = models + .filter(m => !placed.has(m.identifier) && !placed.has(m.metadata.id)) + .sort((a, b) => { + const aCopilot = a.metadata.vendor === 'copilot' ? 0 : 1; + const bCopilot = b.metadata.vendor === 'copilot' ? 0 : 1; + if (aCopilot !== bCopilot) { + return aCopilot - bCopilot; + } + const vendorCmp = a.metadata.vendor.localeCompare(b.metadata.vendor); + return vendorCmp !== 0 ? vendorCmp : a.metadata.name.localeCompare(b.metadata.name); + }); if (otherModels.length > 0) { - items.push({ - kind: ActionListItemKind.Separator, - }); + items.push({ kind: ActionListItemKind.Separator }); items.push({ item: { id: 'otherModels', @@ -280,11 +280,14 @@ function buildModelPickerItems( isSectionToggle: true, }); for (const model of otherModels) { - const action = createModelAction(model, selectedModelId, onSelect, ModelPickerSection.Other); - items.push(createModelItem(action, model)); + const entry = controlModels[model.metadata.id] ?? controlModels[model.identifier]; + if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { + items.push(createUnavailableModelItem(entry, 'update', upgradePlanUrl, updateStateType, ModelPickerSection.Other)); + } else { + items.push(createModelItem(createModelAction(model, selectedModelId, onSelect, ModelPickerSection.Other), model)); + } } - // "Manage Models..." entry inside Other Models section, styled as a link items.push({ item: { id: 'manageModels', @@ -294,9 +297,7 @@ function buildModelPickerItems( tooltip: localize('chat.manageModels.tooltip', "Manage Language Models"), label: localize('chat.manageModels', "Manage Models..."), icon: Codicon.settingsGear, - run: () => { - commandService.executeCommand(MANAGE_CHAT_COMMAND_ID); - } + run: () => { commandService.executeCommand(MANAGE_CHAT_COMMAND_ID); } }, kind: ActionListItemKind.Action, label: localize('chat.manageModels', "Manage Models..."), @@ -304,12 +305,66 @@ function buildModelPickerItems( hideIcon: false, section: ModelPickerSection.Other, className: 'manage-models-link', + showAlways: true, }); } return items; } +function createUnavailableModelItem( + entry: IModelControlEntry, + reason: 'upgrade' | 'update' | 'admin', + upgradePlanUrl: string | undefined, + updateStateType: StateType, + section?: string, +): IActionListItem { + let description: string | MarkdownString | undefined; + let icon: ThemeIcon = Codicon.blank; + + if (reason === 'upgrade') { + description = upgradePlanUrl + ? new MarkdownString(localize('chat.modelPicker.upgradeLink', "[Upgrade]({0})", upgradePlanUrl), { isTrusted: true }) + : localize('chat.modelPicker.upgrade', "Upgrade"); + } else { + icon = Codicon.warning; + } + + let hoverContent: MarkdownString; + if (reason === 'upgrade') { + hoverContent = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); + hoverContent.appendMarkdown(upgradePlanUrl + ? localize('chat.modelPicker.upgradeHover', "This model requires a paid plan. [Upgrade]({0}) to access it.", upgradePlanUrl) + : localize('chat.modelPicker.upgradeHoverNoLink', "This model requires a paid plan.")); + } else if (reason === 'update') { + hoverContent = getUpdateHoverContent(updateStateType); + } else { + hoverContent = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); + hoverContent.appendMarkdown(localize('chat.modelPicker.adminHover', "This model is not available. Contact your administrator to enable it.")); + } + + return { + item: { + id: entry.id, + enabled: false, + checked: false, + class: undefined, + tooltip: entry.label, + label: entry.label, + description: typeof description === 'string' ? description : undefined, + run: () => { } + }, + kind: ActionListItemKind.Action, + label: entry.label, + description, + disabled: true, + group: { title: '', icon }, + hideIcon: false, + section, + hover: { content: hoverContent }, + }; +} + /** * Returns the ActionList options for the model picker (filter + collapsed sections). */ @@ -360,6 +415,7 @@ export class ModelPickerWidget extends Disposable { @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, @IProductService private readonly _productService: IProductService, @IChatEntitlementService private readonly _entitlementService: IChatEntitlementService, + @IUpdateService private readonly _updateService: IUpdateService, ) { super(); } @@ -424,16 +480,17 @@ export class ModelPickerWidget extends Disposable { }; const isPro = isProUser(this._entitlementService.entitlement); - const curatedModels = this._languageModelsService.getCuratedModels(); - const curatedForTier = isPro ? curatedModels.paid : curatedModels.free; + const manifest = this._languageModelsService.getModelsControlManifest(); + const controlModelsForTier = isPro ? manifest.paid : manifest.free; const items = buildModelPickerItems( this._delegate.getModels(), this._selectedModel?.identifier, this._languageModelsService.getRecentlyUsedModelIds(), - curatedForTier, + controlModelsForTier, isPro, this._productService.version, + this._updateService.state.type, onSelect, this._commandService, this._productService.defaultChatAgent?.upgradePlanUrl, @@ -528,12 +585,15 @@ export class ModelPickerWidget extends Disposable { function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier): MarkdownString { + const isAuto = model.metadata.id === 'auto' && model.metadata.vendor === 'copilot'; const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); markdown.appendMarkdown(`**${model.metadata.name}**`); - if (model.metadata.id !== model.metadata.version) { - markdown.appendMarkdown(`  _${model.metadata.id}@${model.metadata.version}_ `); - } else { - markdown.appendMarkdown(`  _${model.metadata.id}_ `); + if (!isAuto) { + if (model.metadata.id !== model.metadata.version) { + markdown.appendMarkdown(`  _${model.metadata.id}@${model.metadata.version}_ `); + } else { + markdown.appendMarkdown(`  _${model.metadata.id}_ `); + } } markdown.appendText(`\n`); @@ -552,7 +612,7 @@ function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier): M markdown.appendText(`\n`); } - if (model.metadata.maxInputTokens || model.metadata.maxOutputTokens) { + if (!isAuto && (model.metadata.maxInputTokens || model.metadata.maxOutputTokens)) { const totalTokens = (model.metadata.maxInputTokens ?? 0) + (model.metadata.maxOutputTokens ?? 0); markdown.appendMarkdown(`${localize('models.contextSize', 'Context Size')}: `); markdown.appendMarkdown(`${formatTokenCount(totalTokens)}`); diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index c40a1dd2d11e6..0ffd0dd69a0a8 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -379,15 +379,15 @@ export interface ILanguageModelsService { addToRecentlyUsedList(model: ILanguageModelChatMetadataAndIdentifier): void; /** - * Returns the curated models from the models control manifest, + * Returns the models from the control manifest, * separated into free and paid tiers. */ - getCuratedModels(): ICuratedModels; + getModelsControlManifest(): IModelsControlManifest; /** - * Fires when curated models change. + * Fires when models control manifest changes. */ - readonly onDidChangeCuratedModels: Event; + readonly onDidChangeModelsControlManifest: Event; /** * Observable map of restricted chat participant names to allowed extension publisher/IDs. @@ -396,16 +396,16 @@ export interface ILanguageModelsService { readonly restrictedChatParticipants: IObservable<{ [name: string]: string[] }>; } -export interface ICuratedModel { +export interface IModelControlEntry { readonly id: string; readonly label: string; - readonly isNew?: boolean; + readonly featured?: boolean; readonly minVSCodeVersion?: string; } -export interface ICuratedModels { - readonly free: ICuratedModel[]; - readonly paid: ICuratedModel[]; +export interface IModelsControlManifest { + readonly free: IStringDictionary; + readonly paid: IStringDictionary; } const languageModelChatProviderType = { @@ -496,21 +496,14 @@ export const languageModelChatProviderExtensionPoint = ExtensionsRegistry.regist const CHAT_MODEL_PICKER_PREFERENCES_STORAGE_KEY = 'chatModelPickerPreferences'; const CHAT_MODEL_RECENTLY_USED_STORAGE_KEY = 'chatModelRecentlyUsed'; const CHAT_PARTICIPANT_NAME_REGISTRY_STORAGE_KEY = 'chat.participantNameRegistry'; -const CHAT_CURATED_MODELS_STORAGE_KEY = 'chat.curatedModels'; - -interface IRawCuratedModel { - readonly id: string; - readonly label: string; - readonly isNew?: boolean; - readonly minVSCodeVersion?: string; -} +const CHAT_MODELS_CONTROL_STORAGE_KEY = 'chat.modelsControl'; interface IChatControlResponse { readonly version: number; readonly restrictedChatParticipants: { [name: string]: string[] }; - readonly curatedModels?: { - readonly free?: IRawCuratedModel[]; - readonly paid?: IRawCuratedModel[]; + readonly models?: { + readonly free?: Record; + readonly paid?: Record; }; } @@ -540,10 +533,10 @@ export class LanguageModelsService implements ILanguageModelsService { private _recentlyUsedModelIds: string[] = []; - private readonly _onDidChangeCuratedModels = this._store.add(new Emitter()); - readonly onDidChangeCuratedModels = this._onDidChangeCuratedModels.event; + private readonly _onDidChangeModelsControlManifest = this._store.add(new Emitter()); + readonly onDidChangeModelsControlManifest = this._onDidChangeModelsControlManifest.event; - private _curatedModels: ICuratedModels = { free: [], paid: [] }; + private _modelsControlManifest: IModelsControlManifest = { free: {}, paid: {} }; private _chatControlUrl: string | undefined; private _chatControlDisposed = false; @@ -1410,33 +1403,38 @@ export class LanguageModelsService implements ILanguageModelsService { //#endregion - //#region Curated models + //#region Models control manifest - getCuratedModels(): ICuratedModels { - return this._curatedModels; + getModelsControlManifest(): IModelsControlManifest { + return this._modelsControlManifest; } - private _setCuratedModels(free: IRawCuratedModel[], paid: IRawCuratedModel[]): void { - const toPublic = (m: IRawCuratedModel): ICuratedModel => ({ id: m.id, label: m.label, isNew: m.isNew, minVSCodeVersion: m.minVSCodeVersion }); + private _setModelsControlManifest(response: IChatControlResponse['models']): void { + const free: IStringDictionary = {}; + const paid: IStringDictionary = {}; - this._curatedModels = { free: [], paid: [] }; - const newIds = new Set(); - - for (const model of free) { - this._curatedModels.free.push(toPublic(model)); - if (model.isNew) { - newIds.add(model.id); + if (response?.free) { + const freeEntries = Array.isArray(response.free) ? response.free : Object.values(response.free); + for (const entry of freeEntries) { + if (!entry || !isObject(entry) || typeof entry.id !== 'string') { + continue; + } + free[entry.id] = { id: entry.id, label: entry.label, featured: entry.featured }; } } - for (const model of paid) { - this._curatedModels.paid.push(toPublic(model)); - if (model.isNew) { - newIds.add(model.id); + if (response?.paid) { + const paidEntries = Array.isArray(response.paid) ? response.paid : Object.values(response.paid); + for (const entry of paidEntries) { + if (!entry || !isObject(entry) || typeof entry.id !== 'string') { + continue; + } + paid[entry.id] = { id: entry.id, label: entry.label, featured: entry.featured, minVSCodeVersion: entry.minVSCodeVersion }; } } - this._onDidChangeCuratedModels.fire(this._curatedModels); + this._modelsControlManifest = { free, paid }; + this._onDidChangeModelsControlManifest.fire(this._modelsControlManifest); } //#region Chat control data @@ -1455,15 +1453,15 @@ export class LanguageModelsService implements ILanguageModelsService { this._storageService.remove(CHAT_PARTICIPANT_NAME_REGISTRY_STORAGE_KEY, StorageScope.APPLICATION); } - // Restore curated models from storage - const rawCurated = this._storageService.get(CHAT_CURATED_MODELS_STORAGE_KEY, StorageScope.APPLICATION); + // Restore models control manifest from storage + const rawModels = this._storageService.get(CHAT_MODELS_CONTROL_STORAGE_KEY, StorageScope.APPLICATION); try { - const curated = JSON.parse(rawCurated ?? '{}'); - if (isObject(curated) && Array.isArray(curated.free) && Array.isArray(curated.paid)) { - this._setCuratedModels(curated.free, curated.paid); + const models = JSON.parse(rawModels ?? '{}'); + if (isObject(models)) { + this._setModelsControlManifest(models); } } catch (err) { - this._storageService.remove(CHAT_CURATED_MODELS_STORAGE_KEY, StorageScope.APPLICATION); + this._storageService.remove(CHAT_MODELS_CONTROL_STORAGE_KEY, StorageScope.APPLICATION); } this._refreshChatControlData(); @@ -1498,10 +1496,10 @@ export class LanguageModelsService implements ILanguageModelsService { this._restrictedChatParticipants.set(registry, undefined); this._storageService.store(CHAT_PARTICIPANT_NAME_REGISTRY_STORAGE_KEY, JSON.stringify(registry), StorageScope.APPLICATION, StorageTarget.MACHINE); - // Update curated models - if (result.curatedModels) { - this._setCuratedModels(result.curatedModels?.free ?? [], result.curatedModels?.paid ?? []); - this._storageService.store(CHAT_CURATED_MODELS_STORAGE_KEY, JSON.stringify(result.curatedModels), StorageScope.APPLICATION, StorageTarget.MACHINE); + // Update models control manifest + if (result.models) { + this._setModelsControlManifest(result.models); + this._storageService.store(CHAT_MODELS_CONTROL_STORAGE_KEY, JSON.stringify(result.models), StorageScope.APPLICATION, StorageTarget.MACHINE); } } 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 a050ec83e5eb0..0e0bbe18afa41 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 @@ -8,7 +8,7 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { ICuratedModels, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatSelector, ILanguageModelsGroup, ILanguageModelsService, IUserFriendlyLanguageModel, ILanguageModelProviderDescriptor } from '../../../common/languageModels.js'; +import { IModelsControlManifest, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatSelector, ILanguageModelsGroup, ILanguageModelsService, IUserFriendlyLanguageModel, ILanguageModelProviderDescriptor } from '../../../common/languageModels.js'; import { ChatModelGroup, ChatModelsViewModel, ILanguageModelEntry, ILanguageModelProviderEntry, isLanguageModelProviderEntry, isLanguageModelGroupEntry, ILanguageModelGroupEntry } from '../../../browser/chatManagement/chatModelsViewModel.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IStringDictionary } from '../../../../../../base/common/collections.js'; @@ -29,7 +29,7 @@ class MockLanguageModelsService implements ILanguageModelsService { private readonly _onDidChangeLanguageModelVendors = new Emitter(); readonly onDidChangeLanguageModelVendors = this._onDidChangeLanguageModelVendors.event; - onDidChangeCuratedModels = Event.None; + onDidChangeModelsControlManifest = Event.None; addVendor(vendor: IUserFriendlyLanguageModel): void { this.vendors.push(vendor); @@ -140,7 +140,7 @@ class MockLanguageModelsService implements ILanguageModelsService { getRecentlyUsedModelIds(): string[] { return []; } addToRecentlyUsedList(): void { } - getCuratedModels(): ICuratedModels { return { free: [], paid: [] }; } + getModelsControlManifest(): IModelsControlManifest { return { free: {}, paid: {} }; } restrictedChatParticipants = observableValue('restrictedChatParticipants', Object.create(null)); } 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 new file mode 100644 index 0000000000000..1d3206b200dfc --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts @@ -0,0 +1,495 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { IStringDictionary } from '../../../../../../../base/common/collections.js'; +import { ActionListItemKind, IActionListItem } from '../../../../../../../platform/actionWidget/browser/actionList.js'; +import { IActionWidgetDropdownAction } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { ICommandService } from '../../../../../../../platform/commands/common/commands.js'; +import { StateType } from '../../../../../../../platform/update/common/update.js'; +import { buildModelPickerItems } from '../../../../browser/widget/input/chatModelPicker.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, IModelControlEntry } from '../../../../common/languageModels.js'; + +function createModel(id: string, name: string, vendor = 'copilot'): ILanguageModelChatMetadataAndIdentifier { + return { + identifier: `${vendor}-${id}`, + metadata: { + id, + name, + vendor, + version: id, + family: vendor, + maxInputTokens: 128000, + maxOutputTokens: 4096, + isDefaultForLocation: {}, + modelPickerCategory: undefined, + } as ILanguageModelChatMetadata, + }; +} + +function createAutoModel(): ILanguageModelChatMetadataAndIdentifier { + return createModel('auto', 'Auto', 'copilot'); +} + +const stubCommandService: ICommandService = { + _serviceBrand: undefined, + onWillExecuteCommand: () => ({ dispose() { } }), + onDidExecuteCommand: () => ({ dispose() { } }), + executeCommand: () => Promise.resolve(undefined), +}; + +function getActionItems(items: IActionListItem[]): IActionListItem[] { + return items.filter(i => i.kind === ActionListItemKind.Action); +} + +function getActionLabels(items: IActionListItem[]): string[] { + return getActionItems(items).map(i => i.label!); +} + +function getSeparatorCount(items: IActionListItem[]): number { + return items.filter(i => i.kind === ActionListItemKind.Separator).length; +} + +function callBuild( + models: ILanguageModelChatMetadataAndIdentifier[], + opts: { + selectedModelId?: string; + recentModelIds?: string[]; + controlModels?: IStringDictionary; + isProUser?: boolean; + currentVSCodeVersion?: string; + updateStateType?: StateType; + upgradePlanUrl?: string; + } = {}, +): IActionListItem[] { + const onSelect = () => { }; + return buildModelPickerItems( + models, + opts.selectedModelId, + opts.recentModelIds ?? [], + opts.controlModels ?? {}, + opts.isProUser ?? true, + opts.currentVSCodeVersion ?? '1.100.0', + opts.updateStateType ?? StateType.Idle, + onSelect, + stubCommandService, + opts.upgradePlanUrl, + ); +} + +suite('buildModelPickerItems', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('auto model always appears first', () => { + const auto = createAutoModel(); + const modelA = createModel('gpt-4o', 'GPT-4o'); + const items = callBuild([modelA, auto]); + const actions = getActionItems(items); + assert.strictEqual(actions[0].label, 'Auto'); + }); + + test('empty models list produces no items', () => { + const items = callBuild([]); + assert.strictEqual(items.length, 0); + }); + + test('only auto model produces single item with no separators', () => { + const items = callBuild([createAutoModel()]); + assert.strictEqual(getActionItems(items).length, 1); + assert.strictEqual(getSeparatorCount(items), 0); + }); + + test('selected model appears in promoted section', () => { + const auto = createAutoModel(); + const modelA = createModel('gpt-4o', 'GPT-4o'); + const modelB = createModel('claude', 'Claude'); + const items = callBuild([auto, modelA, modelB], { + selectedModelId: modelA.identifier, + }); + const actions = getActionItems(items); + // Auto first, then selected model in promoted section, then remaining in other + assert.strictEqual(actions[0].label, 'Auto'); + assert.strictEqual(actions[1].label, 'GPT-4o'); + assert.ok(actions[1].item?.checked); + }); + + test('selected model with failing minVSCodeVersion shows as unavailable with reason update', () => { + const auto = createAutoModel(); + const modelA = createModel('gpt-4o', 'GPT-4o'); + const items = callBuild([auto, modelA], { + selectedModelId: modelA.identifier, + controlModels: { + 'gpt-4o': { id: 'gpt-4o', label: 'GPT-4o', minVSCodeVersion: '2.0.0' }, + }, + currentVSCodeVersion: '1.90.0', + }); + const actions = getActionItems(items); + // The promoted section should contain the unavailable model + const promotedItem = actions.find(a => a.label === 'GPT-4o'); + assert.ok(promotedItem); + assert.strictEqual(promotedItem.disabled, true); + assert.strictEqual(promotedItem.item?.enabled, false); + }); + + test('recently used models appear in promoted 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], { + recentModelIds: [modelB.identifier], + }); + const actions = getActionItems(items); + // Auto, then Claude (recent) in promoted, then others + assert.strictEqual(actions[0].label, 'Auto'); + assert.strictEqual(actions[1].label, 'Claude'); + }); + + test('recently used model not in models list but in controlModels shows as unavailable (upgrade for free user)', () => { + const auto = createAutoModel(); + const items = callBuild([auto], { + recentModelIds: ['missing-model'], + controlModels: { + 'missing-model': { id: 'missing-model', label: 'Missing Model' }, + }, + isProUser: false, + }); + const actions = getActionItems(items); + const unavailable = actions.find(a => a.label === 'Missing Model'); + assert.ok(unavailable); + assert.strictEqual(unavailable.disabled, true); + }); + + test('recently used model not in models list shows as unavailable (update for version mismatch)', () => { + const auto = createAutoModel(); + const items = callBuild([auto], { + recentModelIds: ['missing-model'], + controlModels: { + 'missing-model': { id: 'missing-model', label: 'Missing Model', minVSCodeVersion: '2.0.0' }, + }, + isProUser: true, + currentVSCodeVersion: '1.90.0', + }); + const actions = getActionItems(items); + const unavailable = actions.find(a => a.label === 'Missing Model'); + assert.ok(unavailable); + assert.strictEqual(unavailable.disabled, true); + }); + + test('recently used model not in models list shows as unavailable (admin for pro user without version issue)', () => { + const auto = createAutoModel(); + const items = callBuild([auto], { + recentModelIds: ['missing-model'], + controlModels: { + 'missing-model': { id: 'missing-model', label: 'Missing Model' }, + }, + isProUser: true, + }); + const actions = getActionItems(items); + const unavailable = actions.find(a => a.label === 'Missing Model'); + assert.ok(unavailable); + assert.strictEqual(unavailable.disabled, true); + }); + + test('featured control models appear in promoted section', () => { + const auto = createAutoModel(); + const modelA = createModel('gpt-4o', 'GPT-4o'); + const modelB = createModel('claude', 'Claude'); + const items = callBuild([auto, modelA, modelB], { + controlModels: { + 'gpt-4o': { id: 'gpt-4o', label: 'GPT-4o', featured: true }, + }, + }); + const actions = getActionItems(items); + assert.strictEqual(actions[0].label, 'Auto'); + // GPT-4o should be in promoted due to featured + assert.strictEqual(actions[1].label, 'GPT-4o'); + }); + + test('featured model not in models list shows as unavailable for free users (upgrade)', () => { + const auto = createAutoModel(); + const items = callBuild([auto], { + controlModels: { + 'premium-model': { id: 'premium-model', label: 'Premium Model', featured: true }, + }, + isProUser: false, + }); + const actions = getActionItems(items); + const unavailable = actions.find(a => a.label === 'Premium Model'); + assert.ok(unavailable); + assert.strictEqual(unavailable.disabled, true); + }); + + test('featured model not in models list shows as unavailable for pro users (admin)', () => { + const auto = createAutoModel(); + const items = callBuild([auto], { + controlModels: { + 'premium-model': { id: 'premium-model', label: 'Premium Model', featured: true }, + }, + isProUser: true, + }); + const actions = getActionItems(items); + const unavailable = actions.find(a => a.label === 'Premium Model'); + assert.ok(unavailable); + assert.strictEqual(unavailable.disabled, true); + }); + + test('featured model with minVSCodeVersion shows as unavailable (update) when version too low', () => { + const auto = createAutoModel(); + const modelA = createModel('gpt-4o', 'GPT-4o'); + const items = callBuild([auto, modelA], { + controlModels: { + 'gpt-4o': { id: 'gpt-4o', label: 'GPT-4o', featured: true, minVSCodeVersion: '2.0.0' }, + }, + currentVSCodeVersion: '1.90.0', + }); + const actions = getActionItems(items); + const unavailable = actions.find(a => a.label === 'GPT-4o'); + assert.ok(unavailable); + assert.strictEqual(unavailable.disabled, true); + }); + + test('non-featured control models do NOT appear in promoted section', () => { + const auto = createAutoModel(); + const modelA = createModel('gpt-4o', 'GPT-4o'); + const modelB = createModel('claude', 'Claude'); + const items = callBuild([auto, modelA, modelB], { + controlModels: { + 'gpt-4o': { id: 'gpt-4o', label: 'GPT-4o', featured: false }, + }, + }); + // 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 + assert.strictEqual(seps.length, 1); + const actions = getActionItems(items); + assert.strictEqual(actions[0].label, 'Auto'); + // Next should be "Other Models" toggle + assert.strictEqual(actions[1].isSectionToggle, true); + }); + + test('available promoted models are sorted alphabetically', () => { + 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], { + recentModelIds: [modelA.identifier, modelB.identifier, modelC.identifier], + }); + const actions = getActionItems(items); + // Skip Auto, promoted models should be sorted: Claude, Gemini, GPT-4o + assert.strictEqual(actions[1].label, 'Claude'); + assert.strictEqual(actions[2].label, 'Gemini'); + assert.strictEqual(actions[3].label, 'GPT-4o'); + }); + + test('unavailable promoted models appear after available ones', () => { + const auto = createAutoModel(); + const modelA = createModel('gpt-4o', 'GPT-4o'); + const items = callBuild([auto, modelA], { + recentModelIds: [modelA.identifier, 'missing-model'], + controlModels: { + 'missing-model': { id: 'missing-model', label: 'Missing Model' }, + }, + isProUser: false, + }); + const actions = getActionItems(items); + // Auto, then GPT-4o (available), then Missing Model (unavailable) + assert.strictEqual(actions[0].label, 'Auto'); + assert.strictEqual(actions[1].label, 'GPT-4o'); + assert.ok(!actions[1].disabled); + assert.strictEqual(actions[2].label, 'Missing Model'); + assert.strictEqual(actions[2].disabled, true); + }); + + test('models not in promoted section appear in Other Models section', () => { + const auto = createAutoModel(); + const modelA = createModel('gpt-4o', 'GPT-4o'); + const modelB = createModel('claude', 'Claude'); + const items = callBuild([auto, modelA, modelB]); + const actions = getActionItems(items); + // Auto, then "Other Models" toggle, then models, then "Manage Models..." + assert.strictEqual(actions[0].label, 'Auto'); + assert.strictEqual(actions[1].isSectionToggle, true); + assert.ok(actions[1].label!.includes('Other Models')); + }); + + test('Other Models section includes section toggle', () => { + const auto = createAutoModel(); + const modelA = createModel('gpt-4o', 'GPT-4o'); + const items = callBuild([auto, modelA]); + const toggles = getActionItems(items).filter(i => i.isSectionToggle); + assert.strictEqual(toggles.length, 1); + assert.ok(toggles[0].label!.includes('Other Models')); + }); + + test('Other Models section includes Manage Models entry', () => { + const auto = createAutoModel(); + const modelA = createModel('gpt-4o', 'GPT-4o'); + const items = callBuild([auto, modelA]); + const manageItem = getActionItems(items).find(i => i.item?.id === 'manageModels'); + assert.ok(manageItem); + assert.ok(manageItem.label!.includes('Manage Models')); + }); + + test('Other Models with minVSCodeVersion that fails shows as disabled', () => { + const auto = createAutoModel(); + const modelA = createModel('gpt-4o', 'GPT-4o'); + const items = callBuild([auto, modelA], { + controlModels: { + 'gpt-4o': { id: 'gpt-4o', label: 'GPT-4o', minVSCodeVersion: '2.0.0' }, + }, + currentVSCodeVersion: '1.90.0', + }); + const actions = getActionItems(items); + const gptItem = actions.find(a => a.label === 'GPT-4o'); + assert.ok(gptItem); + assert.strictEqual(gptItem.disabled, true); + }); + + test('no duplicate models across sections', () => { + 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], { + selectedModelId: modelA.identifier, + recentModelIds: [modelA.identifier, modelB.identifier], + controlModels: { + 'gpt-4o': { id: 'gpt-4o', label: 'GPT-4o', featured: true }, + 'claude': { id: 'claude', label: 'Claude', featured: true }, + }, + }); + const labels = getActionLabels(items).filter(l => l !== 'Other Models' && !l.includes('Manage Models')); + const uniqueLabels = new Set(labels); + assert.strictEqual(labels.length, uniqueLabels.size, `Duplicate labels found: ${labels.join(', ')}`); + }); + + test('auto model is excluded from promoted and other sections', () => { + const auto = createAutoModel(); + const modelA = createModel('gpt-4o', 'GPT-4o'); + const items = callBuild([auto, modelA], { + selectedModelId: auto.identifier, + recentModelIds: [auto.identifier], + controlModels: { + 'auto': { id: 'auto', label: 'Auto', featured: true }, + }, + }); + const autoItems = getActionItems(items).filter(a => a.label === 'Auto'); + // Auto should appear exactly once (the first item) + assert.strictEqual(autoItems.length, 1); + }); + + test('models with no control manifest entries work fine', () => { + const auto = createAutoModel(); + const modelA = createModel('gpt-4o', 'GPT-4o'); + const modelB = createModel('claude', 'Claude'); + const items = callBuild([auto, modelA, modelB], { + controlModels: {}, + }); + const actions = getActionItems(items); + assert.ok(actions.length >= 3); // Auto + 2 models (in other) + toggle + manage + assert.strictEqual(actions[0].label, 'Auto'); + }); + + test('Other Models sorted by vendor then name', () => { + const auto = createAutoModel(); + const modelA = createModel('zebra', 'Zebra', 'copilot'); + const modelB = createModel('alpha', 'Alpha', 'other-vendor'); + const modelC = createModel('beta', 'Beta', 'copilot'); + const items = callBuild([auto, modelA, modelB, modelC]); + const actions = getActionItems(items); + // Skip Auto and "Other Models" toggle + const otherModelLabels = actions.slice(2).map(a => a.label!).filter(l => !l.includes('Manage Models')); + // copilot models first (sorted by name), then other-vendor + assert.deepStrictEqual(otherModelLabels, ['Beta', 'Zebra', 'Alpha']); + }); + + test('onSelect callback is wired into action items', () => { + const auto = createAutoModel(); + const modelA = createModel('gpt-4o', 'GPT-4o'); + let selectedModel: ILanguageModelChatMetadataAndIdentifier | undefined; + const onSelect = (m: ILanguageModelChatMetadataAndIdentifier) => { selectedModel = m; }; + const items = buildModelPickerItems( + [auto, modelA], + undefined, + [], + {}, + true, + '1.100.0', + StateType.Idle, + onSelect, + stubCommandService, + undefined, + ); + const gptItem = getActionItems(items).find(a => a.label === 'GPT-4o'); + assert.ok(gptItem?.item); + gptItem.item.run(); + assert.strictEqual(selectedModel?.identifier, modelA.identifier); + }); + + test('selected model is checked, others are not', () => { + const auto = createAutoModel(); + const modelA = createModel('gpt-4o', 'GPT-4o'); + const modelB = createModel('claude', 'Claude'); + const items = callBuild([auto, modelA, modelB], { + selectedModelId: modelA.identifier, + }); + const actions = getActionItems(items); + const autoItem = actions.find(a => a.label === 'Auto'); + const gptItem = actions.find(a => a.label === 'GPT-4o'); + const claudeItem = actions.find(a => a.label === 'Claude'); + assert.ok(!autoItem?.item?.checked); + assert.ok(gptItem?.item?.checked); + assert.ok(!claudeItem?.item?.checked); + }); + + test('selected auto model is checked', () => { + const auto = createAutoModel(); + const modelA = createModel('gpt-4o', 'GPT-4o'); + const items = callBuild([auto, modelA], { + selectedModelId: auto.identifier, + }); + const actions = getActionItems(items); + assert.ok(actions[0].item?.checked); + }); + + test('recently used model resolved by metadata id', () => { + const auto = createAutoModel(); + const modelA = createModel('gpt-4o', 'GPT-4o'); + const modelB = createModel('claude', 'Claude'); + // Use metadata id rather than identifier + const items = callBuild([auto, modelA, modelB], { + recentModelIds: ['claude'], + }); + const actions = getActionItems(items); + // Claude should be in promoted section (right after Auto) + assert.strictEqual(actions[0].label, 'Auto'); + assert.strictEqual(actions[1].label, 'Claude'); + }); + + test('multiple featured and recent models all promoted correctly', () => { + const auto = createAutoModel(); + const modelA = createModel('alpha', 'Alpha'); + const modelB = createModel('beta', 'Beta'); + const modelC = createModel('gamma', 'Gamma'); + const modelD = createModel('delta', 'Delta'); + const items = callBuild([auto, modelA, modelB, modelC, modelD], { + recentModelIds: [modelC.identifier], + controlModels: { + 'alpha': { id: 'alpha', label: 'Alpha', featured: true }, + }, + }); + const actions = getActionItems(items); + assert.strictEqual(actions[0].label, 'Auto'); + // Promoted: Alpha (featured) and Gamma (recent) sorted alphabetically + assert.strictEqual(actions[1].label, 'Alpha'); + assert.strictEqual(actions[2].label, 'Gamma'); + // Then Other Models toggle + assert.ok(actions[3].isSectionToggle); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.ts index ee74cce64c1d2..bc7705b37a351 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.ts @@ -9,7 +9,7 @@ import { Event } from '../../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; -import { IChatMessage, ICuratedModels, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatResponse, ILanguageModelChatSelector, ILanguageModelProviderDescriptor, ILanguageModelsGroup, ILanguageModelsService, IUserFriendlyLanguageModel } from '../../common/languageModels.js'; +import { IChatMessage, IModelsControlManifest, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatResponse, ILanguageModelChatSelector, ILanguageModelProviderDescriptor, ILanguageModelsGroup, ILanguageModelsService, IUserFriendlyLanguageModel } from '../../common/languageModels.js'; import { ILanguageModelsProviderGroup } from '../../common/languageModelsConfiguration.js'; export class NullLanguageModelsService implements ILanguageModelsService { @@ -24,7 +24,7 @@ export class NullLanguageModelsService implements ILanguageModelsService { onDidChangeLanguageModels = Event.None; onDidChangeLanguageModelVendors = Event.None; - onDidChangeCuratedModels = Event.None; + onDidChangeModelsControlManifest = Event.None; updateModelPickerPreference(modelIdentifier: string, showInModelPicker: boolean): void { return; @@ -94,8 +94,8 @@ export class NullLanguageModelsService implements ILanguageModelsService { addToRecentlyUsedList(): void { } - getCuratedModels(): ICuratedModels { - return { free: [], paid: [] }; + getModelsControlManifest(): IModelsControlManifest { + return { free: {}, paid: {} }; } restrictedChatParticipants = observableValue('restrictedChatParticipants', Object.create(null)); From aa0b23d9fd24c5895e918e084be4c49e9a356aab Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Thu, 19 Feb 2026 18:09:21 +0100 Subject: [PATCH 09/32] sessions - remove "In progress" grouping for now (#296306) * refactor - remove in-progress session handling * fix compile * feat - add sorting for in-progress agent sessions --- .../agentSessions/agentSessionsControl.ts | 8 +- .../agentSessions/agentSessionsModel.ts | 1 - .../agentSessions/agentSessionsViewer.ts | 5 -- .../agentSessionsDataSource.test.ts | 78 +++++++++---------- 4 files changed, 38 insertions(+), 54 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 1ae1bf5989fb6..b1e5fdc159459 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -9,7 +9,7 @@ import { IContextMenuService } from '../../../../../platform/contextview/browser import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IOpenEvent, WorkbenchCompressibleAsyncDataTree } from '../../../../../platform/list/browser/listService.js'; import { $, append, EventHelper } from '../../../../../base/browser/dom.js'; -import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection, isSessionInProgressStatus } from './agentSessionsModel.js'; +import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection } from './agentSessionsModel.js'; import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionsSorter, IAgentSessionsFilter, IAgentSessionsSorterOptions } from './agentSessionsViewer.js'; import { FuzzyScore } from '../../../../../base/common/filters.js'; import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; @@ -244,10 +244,8 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo const startOfToday = new Date().setHours(0, 0, 0, 0); return this.agentSessionsService.model.sessions.some(session => - !session.isArchived() && ( - isSessionInProgressStatus(session.status) || - getAgentSessionTime(session.timing) >= startOfToday - ) + !session.isArchived() && + getAgentSessionTime(session.timing) >= startOfToday ); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index ea409d2eef2dd..9a9a3d9f5dda5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -153,7 +153,6 @@ interface IAgentSessionState { export const enum AgentSessionSection { // Default Grouping (by date) - InProgress = 'inProgress', Today = 'today', Yesterday = 'yesterday', Week = 'week', diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 562d91aeabcef..12ac84ccbe711 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -719,7 +719,6 @@ export class AgentSessionsDataSource implements IAsyncDataSource= startOfToday) { @@ -764,7 +760,6 @@ export function groupAgentSessionsByDate(sessions: IAgentSession[]): Map([ - [AgentSessionSection.InProgress, { section: AgentSessionSection.InProgress, label: AgentSessionSectionLabels[AgentSessionSection.InProgress], sessions: inProgressSessions }], [AgentSessionSection.Today, { section: AgentSessionSection.Today, label: AgentSessionSectionLabels[AgentSessionSection.Today], sessions: todaySessions }], [AgentSessionSection.Yesterday, { section: AgentSessionSection.Yesterday, label: AgentSessionSectionLabels[AgentSessionSection.Yesterday], sessions: yesterdaySessions }], [AgentSessionSection.Week, { section: AgentSessionSection.Week, label: AgentSessionSectionLabels[AgentSessionSection.Week], sessions: weekSessions }], diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index ea79ad2f353c8..7936c84a83e5f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -8,7 +8,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { AgentSessionsDataSource, AgentSessionListItem, IAgentSessionsFilter, sessionDateFromNow } from '../../../browser/agentSessions/agentSessionsViewer.js'; import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, isAgentSessionSection } from '../../../browser/agentSessions/agentSessionsModel.js'; -import { ChatSessionStatus, isSessionInProgressStatus } from '../../../common/chatSessionsService.js'; +import { ChatSessionStatus } from '../../../common/chatSessionsService.js'; import { ITreeSorter } from '../../../../../../base/browser/ui/tree/tree.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Event } from '../../../../../../base/common/event.js'; @@ -192,12 +192,12 @@ suite('AgentSessionsDataSource', () => { assert.strictEqual(getSectionsFromResult(result).length, 0); }); - test('groups active sessions first with header', () => { + test('in-progress sessions are placed in their date-based section', () => { const now = Date.now(); const sessions = [ createMockSession({ id: '1', status: ChatSessionStatus.Completed, startTime: now, endTime: now }), createMockSession({ id: '2', status: ChatSessionStatus.InProgress, startTime: now - ONE_DAY }), - createMockSession({ id: '3', status: ChatSessionStatus.NeedsInput, startTime: now - 2 * ONE_DAY }), + createMockSession({ id: '3', status: ChatSessionStatus.NeedsInput, startTime: now }), ]; const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); @@ -206,21 +206,19 @@ suite('AgentSessionsDataSource', () => { const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); + const sections = getSectionsFromResult(result); - // First item should be the In Progress section header - const firstItem = result[0]; - assert.ok(isAgentSessionSection(firstItem), 'First item should be a section header'); - assert.strictEqual((firstItem as IAgentSessionSection).section, AgentSessionSection.InProgress); - // Verify the sessions in the section have active status - const activeSessions = (firstItem as IAgentSessionSection).sessions; - assert.ok(activeSessions.every(s => isSessionInProgressStatus(s.status) || s.status === ChatSessionStatus.NeedsInput)); + // No InProgress section - sessions go into date-based sections + const todaySection = sections.find(s => s.section === AgentSessionSection.Today); + assert.ok(todaySection); + assert.strictEqual(todaySection.sessions.length, 2); // completed + needs-input }); - test('adds Today header when there are active sessions', () => { + test('in-progress sessions appear in Today section alongside completed', () => { const now = Date.now(); const sessions = [ createMockSession({ id: '1', status: ChatSessionStatus.Completed, startTime: now, endTime: now }), - createMockSession({ id: '2', status: ChatSessionStatus.InProgress, startTime: now - ONE_DAY }), + createMockSession({ id: '2', status: ChatSessionStatus.InProgress, startTime: now }), ]; const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); @@ -231,10 +229,10 @@ suite('AgentSessionsDataSource', () => { const result = Array.from(dataSource.getChildren(mockModel)); const sections = getSectionsFromResult(result); - // Now all sections have headers, so we expect In Progress and Today sections - assert.strictEqual(sections.length, 2); - assert.strictEqual(sections[0].section, AgentSessionSection.InProgress); - assert.strictEqual(sections[1].section, AgentSessionSection.Today); + // Only a Today section, no InProgress section + assert.strictEqual(sections.length, 1); + assert.strictEqual(sections[0].section, AgentSessionSection.Today); + assert.strictEqual(sections[0].sessions.length, 2); }); test('adds Today header when there are no active sessions', () => { @@ -312,7 +310,7 @@ suite('AgentSessionsDataSource', () => { assert.ok(olderIndex < archivedIndex, 'Older section should come before Archived section'); }); - test('archived in-progress sessions appear in Archived section not In Progress', () => { + test('archived in-progress sessions appear in Archived section', () => { const now = Date.now(); const sessions = [ createMockSession({ id: 'archived-active', status: ChatSessionStatus.InProgress, isArchived: true, startTime: now }), @@ -327,23 +325,23 @@ suite('AgentSessionsDataSource', () => { const result = Array.from(dataSource.getChildren(mockModel)); const sections = getSectionsFromResult(result); - // Verify there is both an In Progress and Archived section - const inProgressSection = sections.find(s => s.section === AgentSessionSection.InProgress); + // Verify there is both a Today and Archived section (no InProgress section) + const todaySection = sections.find(s => s.section === AgentSessionSection.Today); const archivedSection = sections.find(s => s.section === AgentSessionSection.Archived); - assert.ok(inProgressSection, 'In Progress section should exist'); + assert.ok(todaySection, 'Today section should exist'); assert.ok(archivedSection, 'Archived section should exist'); - // The archived session should NOT appear in In Progress - assert.strictEqual(inProgressSection.sessions.length, 1); - assert.strictEqual(inProgressSection.sessions[0].label, 'Session active'); + // The active session should be in Today + assert.strictEqual(todaySection.sessions.length, 1); + assert.strictEqual(todaySection.sessions[0].label, 'Session active'); - // The archived session should appear in Archived even though it's in progress + // The archived session should appear in Archived assert.strictEqual(archivedSection.sessions.length, 1); assert.strictEqual(archivedSection.sessions[0].label, 'Session archived-active'); }); - test('correct order: active, today, week, older, archived', () => { + test('correct order: today, week, older, archived', () => { const now = Date.now(); const sessions = [ createMockSession({ id: 'archived', status: ChatSessionStatus.Completed, isArchived: true, startTime: now, endTime: now }), @@ -360,31 +358,25 @@ suite('AgentSessionsDataSource', () => { const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); - // All sections now have headers - // In Progress section + // Today section (includes in-progress session) assert.ok(isAgentSessionSection(result[0])); - assert.strictEqual((result[0] as IAgentSessionSection).section, AgentSessionSection.InProgress); - assert.strictEqual((result[0] as IAgentSessionSection).sessions[0].label, 'Session active'); - - // Today section - assert.ok(isAgentSessionSection(result[1])); - assert.strictEqual((result[1] as IAgentSessionSection).section, AgentSessionSection.Today); - assert.strictEqual((result[1] as IAgentSessionSection).sessions[0].label, 'Session today'); + assert.strictEqual((result[0] as IAgentSessionSection).section, AgentSessionSection.Today); + assert.strictEqual((result[0] as IAgentSessionSection).sessions.length, 2); // Week section - assert.ok(isAgentSessionSection(result[2])); - assert.strictEqual((result[2] as IAgentSessionSection).section, AgentSessionSection.Week); - assert.strictEqual((result[2] as IAgentSessionSection).sessions[0].label, 'Session week'); + assert.ok(isAgentSessionSection(result[1])); + assert.strictEqual((result[1] as IAgentSessionSection).section, AgentSessionSection.Week); + assert.strictEqual((result[1] as IAgentSessionSection).sessions[0].label, 'Session week'); // Older section - assert.ok(isAgentSessionSection(result[3])); - assert.strictEqual((result[3] as IAgentSessionSection).section, AgentSessionSection.Older); - assert.strictEqual((result[3] as IAgentSessionSection).sessions[0].label, 'Session old'); + assert.ok(isAgentSessionSection(result[2])); + assert.strictEqual((result[2] as IAgentSessionSection).section, AgentSessionSection.Older); + assert.strictEqual((result[2] as IAgentSessionSection).sessions[0].label, 'Session old'); // Archived section - assert.ok(isAgentSessionSection(result[4])); - assert.strictEqual((result[4] as IAgentSessionSection).section, AgentSessionSection.Archived); - assert.strictEqual((result[4] as IAgentSessionSection).sessions[0].label, 'Session archived'); + assert.ok(isAgentSessionSection(result[3])); + assert.strictEqual((result[3] as IAgentSessionSection).section, AgentSessionSection.Archived); + assert.strictEqual((result[3] as IAgentSessionSection).sessions[0].label, 'Session archived'); }); test('empty sessions returns empty result', () => { From 390e82d05e187d08c3ead504179eddc6264a68ac Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Thu, 19 Feb 2026 18:13:05 +0100 Subject: [PATCH 10/32] sessions - use protocol for opening vscode (#296320) --- .../contrib/chat/browser/chat.contribution.ts | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index a00c358a7b9cf..8fa8db33b1c52 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -8,7 +8,10 @@ import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { IHostService } from '../../../../workbench/services/host/browser/host.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IViewContainersRegistry, IViewsRegistry, ViewContainerLocation, Extensions as ViewExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; @@ -50,8 +53,9 @@ export class OpenSessionWorktreeInVSCodeAction extends Action2 { }); } - override async run(accessor: ServicesAccessor,): Promise { - const hostService = accessor.get(IHostService); + override async run(accessor: ServicesAccessor): Promise { + const openerService = accessor.get(IOpenerService); + const productService = accessor.get(IProductService); const sessionsManagementService = accessor.get(ISessionsManagementService); const activeSession = sessionsManagementService.activeSession.get(); @@ -65,7 +69,18 @@ export class OpenSessionWorktreeInVSCodeAction extends Action2 { return; } - await hostService.openWindow([{ folderUri }], { forceNewWindow: true }); + const scheme = productService.quality === 'stable' + ? 'vscode' + : productService.quality === 'exploration' + ? 'vscode-exploration' + : 'vscode-insiders'; + + await openerService.open(URI.from({ + scheme, + authority: Schemas.file, + path: folderUri.path, + query: 'windowId=_blank', + }), { openExternal: true }); } } registerAction2(OpenSessionWorktreeInVSCodeAction); From 6ee7d55a03b68bf8a42b8ade8fa57fc0485f2a0f Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Thu, 19 Feb 2026 18:14:27 +0100 Subject: [PATCH 11/32] sessions - hide group counts (#296321) --- .../contrib/chat/browser/agentSessions/agentSessionsViewer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 12ac84ccbe711..3743ead31ab9c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -694,7 +694,7 @@ export class AgentSessionsDataSource implements IAsyncDataSource 0) { result.push({ section: AgentSessionSection.More, - label: localize('agentSessions.moreSectionWithCount', "More ({0})", othersSessions.length), + label: AgentSessionSectionLabels[AgentSessionSection.More], sessions: othersSessions }); } @@ -764,7 +764,7 @@ export function groupAgentSessionsByDate(sessions: IAgentSession[]): Map Date: Thu, 19 Feb 2026 09:20:12 -0800 Subject: [PATCH 12/32] Update eslint.config.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- eslint.config.js | 1 - 1 file changed, 1 deletion(-) diff --git a/eslint.config.js b/eslint.config.js index c86b226ee6e96..93a8a1b7b4396 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2116,7 +2116,6 @@ export default tseslint.config( { files: [ 'extensions/**/*.ts', - ], ignores: [ 'extensions/**/*.test.ts', From b1f7bdb7a95da29f3a7a3994fca7bc274b3077f9 Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Thu, 19 Feb 2026 18:22:14 +0100 Subject: [PATCH 13/32] sessions window polish --- .vscode/sessions.json | 24 ++++++------ .../agentFeedbackEditorInputContribution.ts | 39 ++++++++++++++++++- .../contrib/chat/browser/runScriptAction.ts | 14 ++++--- .../browser/sessionsManagementService.ts | 3 +- 4 files changed, 58 insertions(+), 22 deletions(-) diff --git a/.vscode/sessions.json b/.vscode/sessions.json index 539b14b5dd49e..dceaccd279eb9 100644 --- a/.vscode/sessions.json +++ b/.vscode/sessions.json @@ -1,28 +1,28 @@ { "scripts": [ { - "name": "run Windows", - "command": "code.bat" + "name": "Run (Windows)", + "command": ".\\scripts\\code.bat" }, { - "name": "run macOS", - "command": "code.sh" + "name": "Run (macOS)", + "command": "./scripts/code.sh" }, { - "name": "run Linux", - "command": "code.sh" + "name": "Run (Linux)", + "command": "./scripts/code.sh" }, { - "name": "run tests Windows", - "command": "test.bat" + "name": "Tests (Windows)", + "command": ".\\scripts\\test.bat" }, { - "name": "run tests macOS", - "command": "test.sh" + "name": "Tests (macOS)", + "command": "./scripts/test.sh" }, { - "name": "run tests Linux", - "command": "test.sh" + "name": "Tests (Linux)", + "command": "./scripts/test.sh" } ] } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts index 7e17e30caf3d3..bfc4bd97f7e6c 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts @@ -103,7 +103,10 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements this._updatePosition(); } })); - this._store.add(this._editor.onMouseDown(() => { + this._store.add(this._editor.onMouseDown((e) => { + if (this._isWidgetTarget(e.event.target)) { + return; + } this._mouseDown = true; this._hide(); })); @@ -111,10 +114,28 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements this._mouseDown = false; this._onSelectionChanged(); })); - this._store.add(this._editor.onDidBlurEditorWidget(() => this._hide())); + this._store.add(this._editor.onDidBlurEditorWidget(() => { + if (!this._visible) { + return; + } + // Defer so focus has settled to the new target + getWindow(this._editor.getDomNode()!).setTimeout(() => { + if (!this._visible) { + return; + } + if (this._isWidgetTarget(getWindow(this._editor.getDomNode()!).document.activeElement)) { + return; + } + this._hide(); + }, 0); + })); this._store.add(this._editor.onDidFocusEditorWidget(() => this._onSelectionChanged())); } + private _isWidgetTarget(target: EventTarget | Element | null): boolean { + return !!this._widget && !!target && this._widget.getDomNode().contains(target as Node); + } + private _ensureWidget(): AgentFeedbackInputWidget { if (!this._widget) { this._widget = new AgentFeedbackInputWidget(this._editor); @@ -240,6 +261,20 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements this._widgetListeners.add(addStandardDisposableListener(widget.inputElement, 'keypress', e => { e.stopPropagation(); })); + + // Hide when input loses focus to something outside both editor and widget + this._widgetListeners.add(addStandardDisposableListener(widget.inputElement, 'blur', () => { + const win = getWindow(widget.inputElement); + win.setTimeout(() => { + if (!this._visible) { + return; + } + if (this._editor.hasWidgetFocus()) { + return; + } + this._hide(); + }, 0); + })); } private _submit(widget: AgentFeedbackInputWidget): void { diff --git a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts index 914bfe0396189..dfa11fd710d40 100644 --- a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts +++ b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts @@ -75,15 +75,16 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr const { scripts, cwd, session } = activeSession; const configureScriptPrecondition = session.worktree ? ContextKeyExpr.true() : ContextKeyExpr.false(); + const addRunActionDisabledTooltip = session.worktree ? undefined : localize('configureScriptTooltipDisabled', "Actions can not be added in empty sessions"); if (scripts.length === 0) { - // No scripts configured - show a "Run Script" button that opens the configure quick pick + // No scripts configured - show a "Run Action" button that opens the configure quick pick reader.store.add(registerAction2(class extends Action2 { constructor() { super({ id: RUN_SCRIPT_ACTION_ID, - title: localize('runScriptNoAction', "Run Script"), - tooltip: localize('runScriptTooltipNoAction', "Configure run action"), + title: localize('runScriptNoAction', "Run Action..."), + tooltip: localize('runScriptTooltipNoAction', "Configure action"), icon: Codicon.play, category: localize2('agentSessions', 'Agent Sessions'), precondition: configureScriptPrecondition, @@ -111,7 +112,7 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr super({ id: actionId, title: script.name, - tooltip: localize('runScriptTooltip', "Run '{0}' in terminal", script.name), + tooltip: localize('runActionTooltip', "Run '{0}' in terminal", script.name), icon: Codicon.play, category: localize2('agentSessions', 'Agent Sessions'), menu: [{ @@ -134,9 +135,10 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr constructor() { super({ id: CONFIGURE_DEFAULT_RUN_ACTION_ID, - title: localize2('configureDefaultRunAction', "Add Run Script..."), + title: localize2('configureDefaultRunAction', "Add Action..."), + tooltip: addRunActionDisabledTooltip, category: localize2('agentSessions', 'Agent Sessions'), - icon: Codicon.add, + icon: Codicon.play, precondition: configureScriptPrecondition, menu: [{ id: RunScriptDropdownMenuId, diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index 86a963db393c8..8ac2ffd7a0c6e 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -25,7 +25,6 @@ import { IWorkspaceContextService } from '../../../../platform/workspace/common/ import { IWorkspaceEditingService } from '../../../../workbench/services/workspaces/common/workspaceEditing.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { localize } from '../../../../nls.js'; export const IsNewChatSessionContext = new RawContextKey('isNewChatSession', true); @@ -243,7 +242,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa const chatsSession = await this.chatSessionsService.getOrCreateChatSession(pendingSessionResource, CancellationToken.None); const chatSessionItem: IChatSessionItem = { resource: chatsSession.sessionResource, - label: localize('sessionsManagement.newPendingAgentSessionLabel', 'Pending Session'), + label: '', timing: { created: Date.now(), lastRequestStarted: undefined, From bf25539d570abe8773663357f348674d1d6bd7f3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:16:57 -0600 Subject: [PATCH 14/32] Bump minimatch from 5.1.6 to 10.2.1 in /extensions/npm (#296143) Bumps [minimatch](https://github.com/isaacs/minimatch) from 5.1.6 to 10.2.1. - [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md) - [Commits](https://github.com/isaacs/minimatch/compare/v5.1.6...v10.2.1) --- updated-dependencies: - dependency-name: minimatch dependency-version: 10.2.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- extensions/npm/package-lock.json | 37 +++++++++++++++++++++----------- extensions/npm/package.json | 2 +- extensions/npm/src/tasks.ts | 2 +- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/extensions/npm/package-lock.json b/extensions/npm/package-lock.json index 694e98b5e1213..c266824737e15 100644 --- a/extensions/npm/package-lock.json +++ b/extensions/npm/package-lock.json @@ -12,7 +12,7 @@ "find-up": "^5.0.0", "find-yarn-workspace-root": "^2.0.0", "jsonc-parser": "^3.2.0", - "minimatch": "^5.1.6", + "minimatch": "^10.2.1", "request-light": "^0.7.0", "vscode-uri": "^3.0.8", "which": "^4.0.0", @@ -58,17 +58,24 @@ } }, "node_modules/balanced-match": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c= sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==" + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", + "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/braces": { @@ -209,14 +216,18 @@ } }, "node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.1.tgz", + "integrity": "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=10" + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/p-limit": { diff --git a/extensions/npm/package.json b/extensions/npm/package.json index bba6a23b8ac99..434604f2467ea 100644 --- a/extensions/npm/package.json +++ b/extensions/npm/package.json @@ -30,7 +30,7 @@ "find-up": "^5.0.0", "find-yarn-workspace-root": "^2.0.0", "jsonc-parser": "^3.2.0", - "minimatch": "^5.1.6", + "minimatch": "^10.2.1", "request-light": "^0.7.0", "which": "^4.0.0", "which-pm": "^2.1.1", diff --git a/extensions/npm/src/tasks.ts b/extensions/npm/src/tasks.ts index ba833705cb4d7..7cc9120df1176 100644 --- a/extensions/npm/src/tasks.ts +++ b/extensions/npm/src/tasks.ts @@ -10,7 +10,7 @@ import { } from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; -import minimatch from 'minimatch'; +import { minimatch } from 'minimatch'; import { Utils } from 'vscode-uri'; import { findPreferredPM } from './preferred-pm'; import { readScripts } from './readScripts'; From 92e2edc708e9e07c0e0fb165e3acdda329cc5537 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 19 Feb 2026 12:25:38 -0600 Subject: [PATCH 15/32] implement `vscode_askQuestions` tool in core (#296135) --- .../chatToolProgressPart.ts | 2 +- .../chat/browser/widget/chatListRenderer.ts | 9 +- .../chatQuestionCarouselData.ts | 36 ++ .../tools/builtinTools/askQuestionsTool.ts | 460 ++++++++++++++++++ .../tools/builtinTools/runSubagentTool.ts | 2 + .../chat/common/tools/builtinTools/tools.ts | 5 + .../localAgentSessionsController.test.ts | 4 + .../common/chatService/mockChatService.ts | 3 + .../builtinTools/askQuestionsTool.test.ts | 146 ++++++ 9 files changed, 665 insertions(+), 2 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts create mode 100644 src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts create mode 100644 src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts index 4742a98d3d952..15be78e1fe829 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts @@ -105,7 +105,7 @@ export class ChatToolProgressSubPart extends BaseChatToolInvocationSubPart { this.provideScreenReaderStatus(content); } - const isAskQuestionsTool = this.toolInvocation.toolId === 'copilot_askQuestions'; + const isAskQuestionsTool = this.toolInvocation.toolId === 'copilot_askQuestions' || this.toolInvocation.toolId === 'vscode_askQuestions'; return this.instantiationService.createInstance(ChatProgressContentPart, progressMessage, this.renderer, this.context, undefined, true, this.getIcon(), this.toolInvocation, isAskQuestionsTool ? undefined : false); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 6532c59240f20..10c4531e37e2c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -59,6 +59,7 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatTextEditGroup } from '../../common/model/chatModel.js'; import { chatSubcommandLeader } from '../../common/requestParser/chatParserTypes.js'; import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatErrorLevel, ChatRequestQueueKind, IChatConfirmation, IChatContentReference, IChatDisabledClaudeHooksPart, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatPullRequestContent, IChatQuestionCarousel, IChatService, IChatTask, IChatTaskSerialized, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, isChatFollowup } from '../../common/chatService/chatService.js'; +import { ChatQuestionCarouselData } from '../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; import { localChatSessionType } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; @@ -1476,7 +1477,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer | undefined }>(); + + constructor( + public questions: IChatQuestion[], + public allowSkip: boolean, + public resolveId?: string, + public data?: Record, + public isUsed?: boolean, + ) { } + + toJSON(): IChatQuestionCarousel { + return { + kind: this.kind, + questions: this.questions, + allowSkip: this.allowSkip, + resolveId: this.resolveId, + data: this.data, + isUsed: this.isUsed, + }; + } +} diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts new file mode 100644 index 0000000000000..ffa93a41355c7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts @@ -0,0 +1,460 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationError } from '../../../../../../base/common/errors.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { IJSONSchema, IJSONSchemaMap } from '../../../../../../base/common/jsonSchema.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { localize } from '../../../../../../nls.js'; +import { IChatQuestion, IChatService } from '../../chatService/chatService.js'; +import { ChatQuestionCarouselData } from '../../model/chatProgressTypes/chatQuestionCarouselData.js'; +import { IChatRequestModel } from '../../model/chatModel.js'; +import { StopWatch } from '../../../../../../base/common/stopwatch.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress } from '../languageModelToolsService.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { raceCancellation } from '../../../../../../base/common/async.js'; +import { URI } from '../../../../../../base/common/uri.js'; + +// Use a distinct id to avoid clashing with extension-provided tools +export const AskQuestionsToolId = 'vscode_askQuestions'; + +export interface IQuestionOption { + readonly label: string; + readonly description?: string; + readonly recommended?: boolean; +} + +export interface IQuestion { + readonly header: string; + readonly question: string; + readonly multiSelect?: boolean; + readonly options?: IQuestionOption[]; + readonly allowFreeformInput?: boolean; +} + +export interface IAskQuestionsParams { + readonly questions: IQuestion[]; +} + +export interface IQuestionAnswer { + readonly selected: string[]; + readonly freeText: string | null; + readonly skipped: boolean; +} + +export interface IAnswerResult { + readonly answers: Record; +} + +export function createAskQuestionsToolData(): IToolData { + const questionSchema: IJSONSchema & { properties: IJSONSchemaMap } = { + type: 'object', + properties: { + header: { + type: 'string', + description: 'Short identifier for the question. Must be unique so answers can be mapped back to the question.' + }, + question: { + type: 'string', + description: 'The question text to display to the user.' + }, + multiSelect: { + type: 'boolean', + description: 'Allow selecting multiple options when options are provided.' + }, + allowFreeformInput: { + type: 'boolean', + description: 'Allow freeform text answers in addition to option selection.' + }, + options: { + type: 'array', + description: 'Optional list of selectable answers. If omitted, the question is free text.', + items: { + type: 'object', + properties: { + label: { + type: 'string', + description: 'Display label and value for the option.' + }, + description: { + type: 'string', + description: 'Optional secondary text shown with the option.' + }, + recommended: { + type: 'boolean', + description: 'Mark this option as the recommended default.' + } + }, + required: ['label'] + } + } + }, + required: ['header', 'question'] + }; + + const inputSchema: IJSONSchema & { properties: IJSONSchemaMap } = { + type: 'object', + properties: { + questions: { + type: 'array', + description: 'List of questions to ask the user. Order is preserved.', + items: questionSchema, + minItems: 1 + } + }, + required: ['questions'] + }; + + return { + id: AskQuestionsToolId, + toolReferenceName: 'askQuestions', + canBeReferencedInPrompt: true, + icon: ThemeIcon.fromId(Codicon.question.id), + displayName: localize('tool.askQuestions.displayName', 'Ask Clarifying Questions'), + userDescription: localize('tool.askQuestions.userDescription', 'Ask structured clarifying questions using single select, multi-select, or freeform inputs to collect task requirements before proceeding.'), + modelDescription: 'Use this tool to ask the user a small number of clarifying questions before proceeding. Provide the questions array with concise headers and prompts. Use options for fixed choices, set multiSelect when multiple selections are allowed, and set allowFreeformInput to let users supply their own answer.', + source: ToolDataSource.Internal, + inputSchema + }; +} + +export const AskQuestionsToolData: IToolData = createAskQuestionsToolData(); + +export class AskQuestionsTool extends Disposable implements IToolImpl { + + constructor( + @IChatService private readonly chatService: IChatService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @ILogService private readonly logService: ILogService, + ) { + super(); + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, progress: ToolProgress, token: CancellationToken): Promise { + const stopWatch = StopWatch.create(true); + const parameters = invocation.parameters as IAskQuestionsParams; + const { questions } = parameters; + this.logService.trace(`[AskQuestionsTool] Invoking with ${questions?.length ?? 0} question(s)`); + + if (!questions || questions.length === 0) { + throw new Error(localize('askQuestionsTool.noQuestions', 'No questions provided. The questions array must contain at least one question.')); + } + + const chatSessionResource = invocation.context?.sessionResource; + const chatRequestId = invocation.chatRequestId; + const { request, sessionResource } = this.getRequest(chatSessionResource, chatRequestId); + + if (!sessionResource || !request) { + this.logService.warn('[AskQuestionsTool] Missing chat context; marking all questions as skipped.'); + return this.createSkippedResult(questions); + } + + const carousel = this.toQuestionCarousel(questions); + this.chatService.appendProgress(request, carousel); + + const answerResult = await raceCancellation(carousel.completion.p, token); + if (token.isCancellationRequested) { + throw new CancellationError(); + } + + progress.report({ message: localize('askQuestionsTool.progress', 'Analyzing your answers...') }); + + const converted = this.convertCarouselAnswers(questions, answerResult?.answers); + const { answeredCount, skippedCount, freeTextCount, recommendedAvailableCount, recommendedSelectedCount } = this.collectMetrics(questions, converted); + + this.sendTelemetry(invocation.chatRequestId, questions.length, answeredCount, skippedCount, freeTextCount, recommendedAvailableCount, recommendedSelectedCount, stopWatch.elapsed()); + + const toolResultJson = JSON.stringify(converted); + this.logService.trace(`[AskQuestionsTool] Returning tool result with metrics: questions=${questions.length}, answered=${answeredCount}, skipped=${skippedCount}, freeText=${freeTextCount}, recommendedAvailable=${recommendedAvailableCount}, recommendedSelected=${recommendedSelectedCount}`); + return { + content: [{ kind: 'text', value: toolResultJson }] + }; + } + + async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + const parameters = context.parameters as IAskQuestionsParams; + const { questions } = parameters; + + if (!questions || questions.length === 0) { + throw new Error(localize('askQuestionsTool.noQuestions', 'No questions provided. The questions array must contain at least one question.')); + } + + for (const question of questions) { + if (question.options && question.options.length === 1) { + throw new Error(localize('askQuestionsTool.invalidOptions', 'Question "{0}" must have at least two options, or none for free text input.', question.header)); + } + } + + const questionCount = questions.length; + const headers = questions.map(q => q.header).join(', '); + const message = questionCount === 1 + ? localize('askQuestionsTool.invocation.single', 'Asking a question ({0})', headers) + : localize('askQuestionsTool.invocation.multiple', 'Asking {0} questions ({1})', questionCount, headers); + const pastMessage = questionCount === 1 + ? localize('askQuestionsTool.invocation.single.past', 'Asked a question ({0})', headers) + : localize('askQuestionsTool.invocation.multiple.past', 'Asked {0} questions ({1})', questionCount, headers); + + return { + invocationMessage: new MarkdownString(message), + pastTenseMessage: new MarkdownString(pastMessage) + }; + } + + private getRequest(chatSessionResource: URI | undefined, chatRequestId: string | undefined): { request: IChatRequestModel | undefined; sessionResource: URI | undefined } { + if (!chatSessionResource) { + return { request: undefined, sessionResource: undefined }; + } + + const model = this.chatService.getSession(chatSessionResource); + let request: IChatRequestModel | undefined; + if (model) { + // Prefer an exact match on chatRequestId when possible + if (chatRequestId) { + request = model.getRequests().find(r => r.id === chatRequestId); + } + // Fall back to the most recent request in the session if we can't find a match + if (!request) { + request = model.getRequests().at(-1); + } + } + + if (!request) { + return { request: undefined, sessionResource: chatSessionResource }; + } + + return { request, sessionResource: chatSessionResource }; + } + + private toQuestionCarousel(questions: IQuestion[]): ChatQuestionCarouselData { + const mappedQuestions = questions.map(question => this.toChatQuestion(question)); + return new ChatQuestionCarouselData(mappedQuestions, true, generateUuid()); + } + + private toChatQuestion(question: IQuestion): IChatQuestion { + let type: IChatQuestion['type']; + if (!question.options || question.options.length === 0) { + type = 'text'; + } else if (question.multiSelect) { + type = 'multiSelect'; + } else { + type = 'singleSelect'; + } + + let defaultValue: string | string[] | undefined; + if (question.options) { + const recommendedOptions = question.options.filter(opt => opt.recommended); + if (recommendedOptions.length > 0) { + defaultValue = question.multiSelect ? recommendedOptions.map(opt => opt.label) : recommendedOptions[0].label; + } + } + + return { + id: question.header, + type, + title: question.header, + message: question.question, + options: question.options?.map(opt => ({ + id: opt.label, + label: opt.description ? `${opt.label} - ${opt.description}` : opt.label, + value: opt.label + })), + defaultValue, + allowFreeformInput: question.allowFreeformInput ?? false + }; + } + + protected convertCarouselAnswers(questions: IQuestion[], carouselAnswers: Record | undefined): IAnswerResult { + const result: IAnswerResult = { answers: {} }; + + if (carouselAnswers) { + this.logService.trace(`[AskQuestionsTool] Carousel answer keys: ${Object.keys(carouselAnswers).join(', ')}`); + this.logService.trace(`[AskQuestionsTool] Question headers: ${questions.map(q => q.header).join(', ')}`); + } + + for (const question of questions) { + if (!carouselAnswers) { + result.answers[question.header] = { + selected: [], + freeText: null, + skipped: true + }; + continue; + } + + const answer = carouselAnswers[question.header]; + this.logService.trace(`[AskQuestionsTool] Processing question "${question.header}", raw answer: ${JSON.stringify(answer)}, type: ${typeof answer}`); + + if (answer === undefined) { + result.answers[question.header] = { + selected: [], + freeText: null, + skipped: true + }; + } else if (typeof answer === 'string') { + if (question.options?.some(opt => opt.label === answer)) { + result.answers[question.header] = { + selected: [answer], + freeText: null, + skipped: false + }; + } else { + result.answers[question.header] = { + selected: [], + freeText: answer, + skipped: false + }; + } + } else if (Array.isArray(answer)) { + result.answers[question.header] = { + selected: answer.map(a => String(a)), + freeText: null, + skipped: false + }; + } else if (typeof answer === 'object' && answer !== null) { + const answerObj = answer as Record; + const freeformValue = typeof answerObj.freeformValue === 'string' && answerObj.freeformValue ? answerObj.freeformValue : null; + const selectedValues = Array.isArray(answerObj.selectedValues) ? answerObj.selectedValues.map(v => String(v)) : undefined; + const selectedValue = answerObj.selectedValue; + const label = typeof answerObj.label === 'string' ? answerObj.label : undefined; + + if (selectedValues) { + result.answers[question.header] = { + selected: selectedValues, + freeText: freeformValue, + skipped: false + }; + } else if (typeof selectedValue === 'string') { + if (question.options?.some(opt => opt.label === selectedValue)) { + result.answers[question.header] = { + selected: [selectedValue], + freeText: freeformValue, + skipped: false + }; + } else { + result.answers[question.header] = { + selected: [], + freeText: freeformValue ?? selectedValue, + skipped: false + }; + } + } else if (Array.isArray(selectedValue)) { + result.answers[question.header] = { + selected: selectedValue.map(v => String(v)), + freeText: freeformValue, + skipped: false + }; + } else if (selectedValue === undefined || selectedValue === null) { + if (freeformValue) { + result.answers[question.header] = { + selected: [], + freeText: freeformValue, + skipped: false + }; + } else { + result.answers[question.header] = { + selected: [], + freeText: null, + skipped: true + }; + } + } else if (freeformValue) { + result.answers[question.header] = { + selected: [], + freeText: freeformValue, + skipped: false + }; + } else if (label) { + result.answers[question.header] = { + selected: [label], + freeText: null, + skipped: false + }; + } else { + this.logService.warn(`[AskQuestionsTool] Unknown answer object format for "${question.header}": ${JSON.stringify(answer)}`); + result.answers[question.header] = { + selected: [], + freeText: null, + skipped: true + }; + } + } else { + this.logService.warn(`[AskQuestionsTool] Unknown answer format for "${question.header}": ${typeof answer}`); + result.answers[question.header] = { + selected: [], + freeText: null, + skipped: true + }; + } + } + + return result; + } + + private collectMetrics(questions: IQuestion[], result: IAnswerResult): { answeredCount: number; skippedCount: number; freeTextCount: number; recommendedAvailableCount: number; recommendedSelectedCount: number } { + const answers = Object.values(result.answers); + const answeredCount = answers.filter(a => !a.skipped).length; + const skippedCount = answers.filter(a => a.skipped).length; + const freeTextCount = answers.filter(a => a.freeText !== null).length; + const recommendedAvailableCount = questions.filter(q => q.options?.some(opt => opt.recommended)).length; + const recommendedSelectedCount = questions.filter(q => { + const answer = result.answers[q.header]; + const recommendedOption = q.options?.find(opt => opt.recommended); + return answer && !answer.skipped && recommendedOption && answer.selected.includes(recommendedOption.label); + }).length; + return { answeredCount, skippedCount, freeTextCount, recommendedAvailableCount, recommendedSelectedCount }; + } + + private createSkippedResult(questions: IQuestion[]): IToolResult { + const skippedAnswers: Record = {}; + for (const question of questions) { + skippedAnswers[question.header] = { selected: [], freeText: null, skipped: true }; + } + return { + content: [{ kind: 'text', value: JSON.stringify({ answers: skippedAnswers }) }] + }; + } + + private sendTelemetry(requestId: string | undefined, questionCount: number, answeredCount: number, skippedCount: number, freeTextCount: number, recommendedAvailableCount: number, recommendedSelectedCount: number, duration: number): void { + this.telemetryService.publicLog2('askQuestionsToolInvoked', { + requestId, + questionCount, + answeredCount, + skippedCount, + freeTextCount, + recommendedAvailableCount, + recommendedSelectedCount, + duration, + }); + } +} + +type AskQuestionsToolInvokedEvent = { + requestId: string | undefined; + questionCount: number; + answeredCount: number; + skippedCount: number; + freeTextCount: number; + recommendedAvailableCount: number; + recommendedSelectedCount: number; + duration: number; +}; + +type AskQuestionsToolInvokedClassification = { + requestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The id of the current request turn.' }; + questionCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The total number of questions asked' }; + answeredCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of questions that were answered' }; + skippedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of questions that were skipped' }; + freeTextCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of questions answered with free text input' }; + recommendedAvailableCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of questions that had a recommended option' }; + recommendedSelectedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of questions where the user selected the recommended option' }; + duration: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'The total time in milliseconds to complete all questions' }; + owner: 'digitarald'; + comment: 'Tracks usage of the AskQuestions tool for agent clarifications'; +}; diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index 4e9eacf9ae365..7c3692d02c408 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -39,6 +39,7 @@ import { VSCodeToolReference, } from '../languageModelToolsService.js'; import { ManageTodoListToolToolId } from './manageTodoListTool.js'; +import { AskQuestionsToolId } from './askQuestionsTool.js'; import { createToolSimpleTextResult } from './toolHelpers.js'; const BaseModelDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. This tool is good at researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries, use this agent to perform the search for you. @@ -241,6 +242,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { modeTools[RunSubagentTool.Id] = false; modeTools[ManageTodoListToolToolId] = false; modeTools['copilot_askQuestions'] = false; + modeTools[AskQuestionsToolId] = false; } const variableSet = new ChatRequestVariableSet(); diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts index d07c4263a190c..56a1b956acbe7 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts @@ -7,6 +7,7 @@ import { Disposable, IDisposable } from '../../../../../../base/common/lifecycle import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution } from '../../../../../common/contributions.js'; import { ILanguageModelToolsService } from '../languageModelToolsService.js'; +import { AskQuestionsTool, AskQuestionsToolData } from './askQuestionsTool.js'; import { ConfirmationTool, ConfirmationToolData, ConfirmationToolWithOptionsData } from './confirmationTool.js'; import { EditTool, EditToolData } from './editFileTool.js'; import { createManageTodoListToolData, ManageTodoListTool } from './manageTodoListTool.js'; @@ -25,6 +26,10 @@ export class BuiltinToolsContribution extends Disposable implements IWorkbenchCo const editTool = instantiationService.createInstance(EditTool); this._register(toolsService.registerTool(EditToolData, editTool)); + const askQuestionsTool = this._register(instantiationService.createInstance(AskQuestionsTool)); + this._register(toolsService.registerTool(AskQuestionsToolData, askQuestionsTool)); + this._register(toolsService.agentToolSet.addTool(AskQuestionsToolData)); + const todoToolData = createManageTodoListToolData(); const manageTodoListTool = this._register(instantiationService.createInstance(ManageTodoListTool)); this._register(toolsService.registerTool(todoToolData, manageTodoListTool)); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts index 3cbc1cccf0304..2cd794010cb0f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts @@ -106,6 +106,10 @@ class MockChatService implements IChatService { return this.sessions.get(sessionResource.toString()); } + getLatestRequest(): IChatRequestModel | undefined { + return undefined; + } + getOrRestoreSession(_sessionResource: URI): Promise { throw new Error('Method not implemented.'); } diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts index 68c2d724b002c..5d662e05199bb 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts @@ -47,6 +47,9 @@ export class MockChatService implements IChatService { // eslint-disable-next-line local/code-no-dangerous-type-assertions return this.sessions.get(sessionResource) ?? {} as IChatModel; } + getLatestRequest(): IChatRequestModel | undefined { + return undefined; + } async getOrRestoreSession(sessionResource: URI): Promise { throw new Error('Method not implemented.'); } diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts new file mode 100644 index 0000000000000..cf380b921ba3d --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { NullTelemetryService } from '../../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { NullLogService } from '../../../../../../../platform/log/common/log.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { AskQuestionsTool, IAnswerResult, IQuestion, IQuestionAnswer } from '../../../../common/tools/builtinTools/askQuestionsTool.js'; +import { IChatService } from '../../../../common/chatService/chatService.js'; + +class TestableAskQuestionsTool extends AskQuestionsTool { + public testConvertCarouselAnswers(questions: IQuestion[], carouselAnswers: Record | undefined): IAnswerResult { + return this.convertCarouselAnswers(questions, carouselAnswers); + } +} + +suite('AskQuestionsTool - convertCarouselAnswers', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + let tool: TestableAskQuestionsTool; + + setup(() => { + tool = store.add(new TestableAskQuestionsTool( + null! as IChatService, + NullTelemetryService, + new NullLogService() + )); + }); + + teardown(() => { + tool?.dispose(); + }); + + test('marks all questions as skipped when answers are undefined', () => { + const questions: IQuestion[] = [ + { header: 'Q1', question: 'First question?' }, + { header: 'Q2', question: 'Second question?' } + ]; + + const result = tool.testConvertCarouselAnswers(questions, undefined); + + const expected: Record = { + Q1: { selected: [], freeText: null, skipped: true }, + Q2: { selected: [], freeText: null, skipped: true } + }; + assert.deepStrictEqual(result.answers, expected); + }); + + test('handles string answers as option selection or free text', () => { + const questions: IQuestion[] = [ + { header: 'Color', question: 'Pick a color', options: [{ label: 'Red' }, { label: 'Blue' }] }, + { header: 'Comment', question: 'Any comment?' } + ]; + + const result = tool.testConvertCarouselAnswers(questions, { Color: 'Blue', Comment: 'Nice' }); + + assert.deepStrictEqual(result.answers['Color'], { selected: ['Blue'], freeText: null, skipped: false }); + assert.deepStrictEqual(result.answers['Comment'], { selected: [], freeText: 'Nice', skipped: false }); + }); + + test('handles array answers for multi-select', () => { + const questions: IQuestion[] = [ + { header: 'Features', question: 'Pick features', multiSelect: true, options: [{ label: 'A' }, { label: 'B' }] } + ]; + + const result = tool.testConvertCarouselAnswers(questions, { Features: ['A', 'B'] }); + + assert.deepStrictEqual(result.answers['Features'], { selected: ['A', 'B'], freeText: null, skipped: false }); + }); + + test('handles selectedValue object answers', () => { + const questions: IQuestion[] = [ + { header: 'Range', question: 'Use range?', options: [{ label: 'Yes' }, { label: 'No' }] }, + { header: 'Feedback', question: 'Feedback?' } + ]; + + const result = tool.testConvertCarouselAnswers(questions, { + Range: { selectedValue: 'Yes' }, + Feedback: { selectedValue: 'Great!' } + }); + + assert.deepStrictEqual(result.answers['Range'], { selected: ['Yes'], freeText: null, skipped: false }); + assert.deepStrictEqual(result.answers['Feedback'], { selected: [], freeText: 'Great!', skipped: false }); + }); + + test('handles selectedValues object answers', () => { + const questions: IQuestion[] = [ + { header: 'Options', question: 'Pick options', multiSelect: true, options: [{ label: 'X' }, { label: 'Y' }] } + ]; + + const result = tool.testConvertCarouselAnswers(questions, { Options: { selectedValues: ['X'] } }); + + assert.deepStrictEqual(result.answers['Options'], { selected: ['X'], freeText: null, skipped: false }); + }); + + test('handles freeformValue with no selection', () => { + const questions: IQuestion[] = [ + { header: 'Choice', question: 'Pick or write', options: [{ label: 'A' }, { label: 'B' }], allowFreeformInput: true } + ]; + + const result = tool.testConvertCarouselAnswers(questions, { Choice: { freeformValue: 'Custom' } }); + + assert.deepStrictEqual(result.answers['Choice'], { selected: [], freeText: 'Custom', skipped: false }); + }); + + test('marks unknown formats as skipped', () => { + const questions: IQuestion[] = [ + { header: 'Odd', question: 'Unknown' } + ]; + + const result = tool.testConvertCarouselAnswers(questions, { Odd: 42 as unknown as object }); + + assert.deepStrictEqual(result.answers['Odd'], { selected: [], freeText: null, skipped: true }); + }); + + test('handles mixed answers and missing keys', () => { + const questions: IQuestion[] = [ + { header: 'Q1', question: 'String answer' }, + { header: 'Q2', question: 'Object answer', options: [{ label: 'A' }] }, + { header: 'Q3', question: 'Array answer', multiSelect: true }, + { header: 'Q4', question: 'Missing answer' } + ]; + + const result = tool.testConvertCarouselAnswers(questions, { + Q1: 'text', + Q2: { selectedValue: 'A' }, + Q3: ['x', 'y'] + }); + + assert.strictEqual(result.answers['Q1'].freeText, 'text'); + assert.deepStrictEqual(result.answers['Q2'].selected, ['A']); + assert.deepStrictEqual(result.answers['Q3'].selected, ['x', 'y']); + assert.strictEqual(result.answers['Q4'].skipped, true); + }); + + test('is case-sensitive when matching options', () => { + const questions: IQuestion[] = [ + { header: 'Case', question: 'Pick', options: [{ label: 'Yes' }, { label: 'No' }] } + ]; + + const result = tool.testConvertCarouselAnswers(questions, { Case: 'yes' }); + + assert.deepStrictEqual(result.answers['Case'], { selected: [], freeText: 'yes', skipped: false }); + }); +}); From 345315625dc064dd3cbe63fd4f037b4628018794 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 19 Feb 2026 12:26:57 -0600 Subject: [PATCH 16/32] update yolo mode description to include autoReply link (#296097) --- build/lib/policies/policyData.jsonc | 4 ++-- .../contrib/chat/browser/chat.contribution.ts | 4 ++-- .../chat/browser/tools/languageModelToolsService.ts | 5 +++-- .../contrib/preferences/browser/settingsEditor2.ts | 12 ++++++++---- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 7489336e8144d..9c1e1e0e87a8f 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -87,8 +87,8 @@ "minimumVersion": "1.99", "localization": { "description": { - "key": "autoApprove2.description", - "value": "Global auto approve also known as \"YOLO mode\" disables manual approval completely for all tools in all workspaces, allowing the agent to act fully autonomously. This is extremely dangerous and is *never* recommended, even containerized environments like Codespaces and Dev Containers have user keys forwarded into the container that could be compromised.\n\nThis feature disables critical security protections and makes it much easier for an attacker to compromise the machine." + "key": "autoApprove3.description", + "value": "Global auto approve also known as \"YOLO mode\" disables manual approval completely for all tools in all workspaces, allowing the agent to act fully autonomously. This is extremely dangerous and is *never* recommended, even containerized environments like Codespaces and Dev Containers have user keys forwarded into the container that could be compromised.\n\nThis feature disables critical security protections and makes it much easier for an attacker to compromise the machine.\n\nNote: This setting only controls tool approval and does not prevent the agent from asking questions. To automatically answer agent questions, use the `#chat.autoReply#` setting." } }, "type": "boolean", diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 88aeddb80f474..564767f17b073 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -344,8 +344,8 @@ configurationRegistry.registerConfiguration({ value: (policyData) => policyData.chat_preview_features_enabled === false ? false : undefined, localization: { description: { - key: 'autoApprove2.description', - value: nls.localize('autoApprove2.description', 'Global auto approve also known as "YOLO mode" disables manual approval completely for all tools in all workspaces, allowing the agent to act fully autonomously. This is extremely dangerous and is *never* recommended, even containerized environments like Codespaces and Dev Containers have user keys forwarded into the container that could be compromised.\n\nThis feature disables critical security protections and makes it much easier for an attacker to compromise the machine.') + key: 'autoApprove3.description', + value: nls.localize('autoApprove3.description', 'Global auto approve also known as "YOLO mode" disables manual approval completely for all tools in all workspaces, allowing the agent to act fully autonomously. This is extremely dangerous and is *never* recommended, even containerized environments like Codespaces and Dev Containers have user keys forwarded into the container that could be compromised.\n\nThis feature disables critical security protections and makes it much easier for an attacker to compromise the machine.\n\nNote: This setting only controls tool approval and does not prevent the agent from asking questions. To automatically answer agent questions, use the `#chat.autoReply#` setting.') } }, } diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 7b590f3692216..1f676140061fc 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -73,15 +73,16 @@ const toolIdThatCannotBeAutoApproved = 'vscode_get_confirmation_with_options'; export const globalAutoApproveDescription = localize2( { - key: 'autoApprove2.markdown', + key: 'autoApprove3.markdown', comment: [ '{Locked=\'](https://github.com/features/codespaces)\'}', '{Locked=\'](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)\'}', '{Locked=\'](https://code.visualstudio.com/docs/copilot/security)\'}', '{Locked=\'**\'}', + '{Locked=\'`#chat.autoReply#`\'}', ] }, - 'Global auto approve also known as "YOLO mode" disables manual approval completely for _all tools in all workspaces_, allowing the agent to act fully autonomously. This is extremely dangerous and is *never* recommended, even containerized environments like [Codespaces](https://github.com/features/codespaces) and [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) have user keys forwarded into the container that could be compromised.\n\n**This feature disables [critical security protections](https://code.visualstudio.com/docs/copilot/security) and makes it much easier for an attacker to compromise the machine.**' + 'Global auto approve also known as "YOLO mode" disables manual approval completely for _all tools in all workspaces_, allowing the agent to act fully autonomously. This is extremely dangerous and is *never* recommended, even containerized environments like [Codespaces](https://github.com/features/codespaces) and [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) have user keys forwarded into the container that could be compromised.\n\n**This feature disables [critical security protections](https://code.visualstudio.com/docs/copilot/security) and makes it much easier for an attacker to compromise the machine.**\n\nNote: This setting only controls tool approval and does not prevent the agent from asking questions. To automatically answer agent questions, use `#chat.autoReply#`.' ); export class LanguageModelToolsService extends Disposable implements ILanguageModelToolsService { diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts index 4c7e17c9cc3dc..19d8730711092 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts @@ -914,11 +914,15 @@ export class SettingsEditor2 extends EditorPane { } if (!recursed && (!targetElement || revealFailed)) { - // We'll call this event handler again after clearing the search query, - // so that more settings show up in the list. - const p = this.triggerSearch('', true); + // Search for the target setting by ID so it becomes visible, + // even if it's an advanced setting that would be hidden with an empty query. + const idQuery = `@id:${evt.targetKey}`; + // Set the widget value first, then cancel the debounced search it triggers, + // so that only the direct triggerSearch call below runs. + this.searchWidget.setValue(idQuery); + this.searchInputDelayer.cancel(); + const p = this.triggerSearch(idQuery, true); p.then(() => { - this.searchWidget.setValue(''); this.onDidClickSetting(evt, true); }); } From 724656efa2c26ab6e7eb2023426dcf2658dc3203 Mon Sep 17 00:00:00 2001 From: David Dossett <25163139+daviddossett@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:37:54 -0800 Subject: [PATCH 17/32] Hide 'Used references' button in chat in agent mode (#296144) * Hide 'Used references' button in chat * Use no-op part for hidden chat references * rm unused import * Hide 'Used references' button only in built-in Agent mode Only hide the 'Used n references' collapsible list for the built-in Agent mode (modeId === 'agent'). Custom Agent-kind modes like Plan continue to show the references button. Uses request modeInfo.modeId from the response model instead of the current UI mode kind, so the check is scoped to the mode that was active when the request was made. --- .../workbench/contrib/chat/browser/widget/chatListRenderer.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 10c4531e37e2c..fd984cb2740fc 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -1707,6 +1707,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer other.kind === content.kind); + } return this.renderContentReferencesListData(content, undefined, context, templateData); } else if (content.kind === 'codeCitations') { return this.renderCodeCitations(content, context, templateData); From 8eb7cee49c0e6b8c5e35fe68f964b2f1480e7a07 Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Thu, 19 Feb 2026 20:06:45 +0100 Subject: [PATCH 18/32] Add 'Allow all files in this repository' option for external file access (#296225) When an agent reads files outside the workspace, the confirmation dialog now offers an option to allow all files in the containing git repository for the current session, in addition to the existing per-folder option. This reduces repeated confirmation prompts when navigating across different subdirectories within the same repository. The git root is discovered by walking up from the file path and checking for a .git folder using IFileService. Results are cached so the option shows the resolved repo path on subsequent prompts, and is hidden entirely if the path is not inside a git repository. --- .../chatExternalPathConfirmation.ts | 55 ++++++++++++++++++- .../electron-browser/builtInTools/tools.ts | 23 ++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts index b6af28672fed9..9babb75b5bdb0 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts @@ -28,9 +28,12 @@ export class ChatExternalPathConfirmationContribution implements ILanguageModelT readonly canUseDefaultApprovals = false; private readonly _sessionFolderAllowlist = new ResourceMap(); + /** Cache of path URI -> resolved git root URI (or null if not in a repo) */ + private readonly _gitRootCache = new ResourceMap(); constructor( private readonly _getPathInfo: (ref: ILanguageModelToolConfirmationRef) => IExternalPathInfo | undefined, + private readonly _findGitRoot?: (pathUri: URI) => Promise, ) { } getPreConfirmAction(ref: ILanguageModelToolConfirmationRef): ConfirmedReason | undefined { @@ -80,7 +83,7 @@ export class ChatExternalPathConfirmationContribution implements ILanguageModelT const folderUri = pathInfo.isDirectory ? pathUri : dirname(pathUri); const sessionResource = ref.chatSessionResource; - return [ + const actions: ILanguageModelToolConfirmationActions[] = [ { label: localize('allowFolderSession', 'Allow this folder in this session'), detail: localize('allowFolderSessionDetail', 'Allow reading files from this folder without further confirmation in this chat session'), @@ -95,5 +98,55 @@ export class ChatExternalPathConfirmationContribution implements ILanguageModelT } } ]; + + // If a git root finder is available, offer to allow the entire repository + if (this._findGitRoot) { + const findGitRoot = this._findGitRoot; + const gitRootCache = this._gitRootCache; + const allowlist = this._sessionFolderAllowlist; + + // Check if we already know the git root for this path (or that there is none) + const cached = gitRootCache.get(pathUri); + if (cached === null) { + // Previously resolved: not in a git repository, don't show the option + } else if (cached) { + // Previously resolved: show with the known repo path + actions.push({ + label: localize('allowRepoSession', 'Allow all files in this repository for this session'), + detail: localize('allowRepoSessionDetail', 'Allow reading files from {0}', cached.fsPath), + select: async () => { + let folders = allowlist.get(sessionResource); + if (!folders) { + folders = new ResourceSet(); + allowlist.set(sessionResource, folders); + } + folders.add(cached); + return true; + } + }); + } else { + // Not yet resolved: show the option and resolve on selection + actions.push({ + label: localize('allowRepoSession', 'Allow all files in this repository for this session'), + detail: localize('allowRepoSessionDetailLookup', 'Looks up the containing git repository for this path'), + select: async () => { + const gitRootUri = await findGitRoot(pathUri); + gitRootCache.set(pathUri, gitRootUri ?? null); + if (!gitRootUri) { + return false; + } + let folders = allowlist.get(sessionResource); + if (!folders) { + folders = new ResourceSet(); + allowlist.set(sessionResource, folders); + } + folders.add(gitRootUri); + return true; + } + }); + } + } + + return actions; } } diff --git a/src/vs/workbench/contrib/chat/electron-browser/builtInTools/tools.ts b/src/vs/workbench/contrib/chat/electron-browser/builtInTools/tools.ts index b87ac4675e9a7..3af7e138aeb54 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/builtInTools/tools.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/builtInTools/tools.ts @@ -4,6 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { dirname, extUriBiasedIgnorePathCase } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { ChatExternalPathConfirmationContribution } from '../../common/tools/builtinTools/chatExternalPathConfirmation.js'; @@ -21,6 +24,7 @@ export class NativeBuiltinToolsContribution extends Disposable implements IWorkb @ILanguageModelToolsService toolsService: ILanguageModelToolsService, @IInstantiationService instantiationService: IInstantiationService, @ILanguageModelToolsConfirmationService confirmationService: ILanguageModelToolsConfirmationService, + @IFileService fileService: IFileService, ) { super(); @@ -48,6 +52,25 @@ export class NativeBuiltinToolsContribution extends Disposable implements IWorkb return { path: params.path, isDirectory: true }; } return undefined; + }, + async (pathUri: URI) => { + // Walk up from the path looking for a .git folder to find the repository root + let dir = dirname(pathUri); + for (let i = 0; i < 100; i++) { + try { + if (await fileService.exists(URI.joinPath(dir, '.git'))) { + return dir; + } + } catch { + // ignore permission errors etc. + } + const parent = dirname(dir); + if (extUriBiasedIgnorePathCase.isEqual(parent, dir)) { + return undefined; + } + dir = parent; + } + return undefined; } ); From 1f291263093fa3788f12698eb2de42c34db804f6 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 19 Feb 2026 13:24:32 -0600 Subject: [PATCH 19/32] check policy before showing YOLO tip, only show if never enabled before (#296333) --- .../contrib/chat/browser/chatTipService.ts | 34 +++++++- .../chat/test/browser/chatTipService.test.ts | 78 ++++++++++++++++++- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 2e6c540a88fcb..a158dcd8f453a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -9,7 +9,7 @@ import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../ import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; -import { ChatAgentLocation, ChatModeKind } from '../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../common/constants.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; @@ -560,8 +560,10 @@ export class ChatTipService extends Disposable implements IChatTipService { private static readonly _DISMISSED_TIP_KEY = 'chat.tip.dismissed'; private static readonly _LAST_TIP_ID_KEY = 'chat.tip.lastTipId'; + private static readonly _YOLO_EVER_ENABLED_KEY = 'chat.tip.yoloModeEverEnabled'; private readonly _tracker: TipEligibilityTracker; private readonly _createSlashCommandsUsageTracker: CreateSlashCommandsUsageTracker; + private _yoloModeEverEnabled: boolean; constructor( @IProductService private readonly _productService: IProductService, @@ -580,6 +582,25 @@ export class ChatTipService extends Disposable implements IChatTipService { this.hideTip(); } })); + + // Track whether yolo mode was ever enabled + this._yoloModeEverEnabled = this._storageService.getBoolean(ChatTipService._YOLO_EVER_ENABLED_KEY, StorageScope.APPLICATION, false); + if (!this._yoloModeEverEnabled && this._configurationService.getValue(ChatConfiguration.GlobalAutoApprove)) { + this._yoloModeEverEnabled = true; + this._storageService.store(ChatTipService._YOLO_EVER_ENABLED_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + if (!this._yoloModeEverEnabled) { + const configListener = this._register(new MutableDisposable()); + configListener.value = this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatConfiguration.GlobalAutoApprove)) { + if (this._configurationService.getValue(ChatConfiguration.GlobalAutoApprove)) { + this._yoloModeEverEnabled = true; + this._storageService.store(ChatTipService._YOLO_EVER_ENABLED_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + configListener.clear(); + } + } + }); + } } resetSession(): void { @@ -807,6 +828,17 @@ export class ChatTipService extends Disposable implements IChatTipService { if (this._tracker.isExcluded(tip)) { return false; } + if (tip.id === 'tip.yoloMode') { + if (this._yoloModeEverEnabled) { + this._logService.debug('#ChatTips: tip excluded because yolo mode was previously enabled', tip.id); + return false; + } + const inspected = this._configurationService.inspect(ChatConfiguration.GlobalAutoApprove); + if (inspected.policyValue === false) { + this._logService.debug('#ChatTips: tip excluded because policy restricts auto-approve', tip.id); + return false; + } + } this._logService.debug('#ChatTips: tip is eligible', tip.id); return true; } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index d950b9bfbd1bc..cc0b842d749c3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -19,7 +19,7 @@ import { ChatTipService, ITipDefinition, TipEligibilityTracker } from '../../bro import { AgentFileType, IPromptPath, IPromptsService, IResolvedAgentFile, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { URI } from '../../../../../base/common/uri.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { MockLanguageModelToolsService } from '../common/tools/mockLanguageModelToolsService.js'; @@ -798,6 +798,82 @@ suite('ChatTipService', () => { } }); + test('does not show tip.yoloMode after auto-approve has ever been enabled', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); + + // Enable auto-approve so the service records yoloModeEverEnabled + configurationService.setUserConfiguration(ChatConfiguration.GlobalAutoApprove, true); + (configurationService as TestConfigurationService).onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: (key: string) => key === ChatConfiguration.GlobalAutoApprove, + affectedKeys: new Set([ChatConfiguration.GlobalAutoApprove]), + change: { keys: [], overrides: [] }, + source: ConfigurationTarget.USER, + }); + + // Turn auto-approve back off + configurationService.setUserConfiguration(ChatConfiguration.GlobalAutoApprove, false); + + // The yoloMode tip should never appear since it was ever enabled + for (let i = 0; i < 100; i++) { + const tip = service.getWelcomeTip(contextKeyService); + if (!tip) { + break; + } + assert.notStrictEqual(tip.id, 'tip.yoloMode', 'tip.yoloMode should not be shown after auto-approve was ever enabled'); + service.dismissTip(); + } + + // Verify the flag was persisted + assert.strictEqual( + storageService.getBoolean('chat.tip.yoloModeEverEnabled', StorageScope.APPLICATION, false), + true, + 'yoloModeEverEnabled should be persisted in application storage', + ); + }); + + test('does not show tip.yoloMode when yoloModeEverEnabled is already persisted in storage', () => { + // Simulate a previous session having set the flag + storageService.store('chat.tip.yoloModeEverEnabled', true, StorageScope.APPLICATION, StorageTarget.MACHINE); + + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); + + for (let i = 0; i < 100; i++) { + const tip = service.getWelcomeTip(contextKeyService); + if (!tip) { + break; + } + assert.notStrictEqual(tip.id, 'tip.yoloMode', 'tip.yoloMode should not be shown when yoloModeEverEnabled is already in storage'); + service.dismissTip(); + } + }); + + test('does not show tip.yoloMode when policy restricts auto-approve', () => { + const policyConfigService = new TestConfigurationService(); + const originalInspect = policyConfigService.inspect.bind(policyConfigService); + policyConfigService.inspect = (key: string, overrides?: any) => { + if (key === ChatConfiguration.GlobalAutoApprove) { + return { ...originalInspect(key, overrides), policyValue: false } as unknown as T; + } + return originalInspect(key, overrides); + }; + configurationService = policyConfigService; + instantiationService.stub(IConfigurationService, configurationService); + + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); + + for (let i = 0; i < 100; i++) { + const tip = service.getWelcomeTip(contextKeyService); + if (!tip) { + break; + } + assert.notStrictEqual(tip.id, 'tip.yoloMode', 'tip.yoloMode should not be shown when policy restricts auto-approve'); + service.dismissTip(); + } + }); + test('re-checks agent file exclusion when onDidChangeCustomAgents fires', async () => { const agentChangeEmitter = testDisposables.add(new Emitter()); let agentFiles: IPromptPath[] = []; From 1dcb39c73a3ac1722c2a681f8ddb0d4a9de57542 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 19 Feb 2026 13:37:57 -0600 Subject: [PATCH 20/32] center align question carousel X button (#296351) fixes #292914 --- .../widget/chatContentParts/media/chatQuestionCarousel.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 57a3a06ca786b..8652ea889d7a2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -42,6 +42,7 @@ .chat-question-header-row { display: flex; justify-content: space-between; + align-items: center; gap: 8px; min-width: 0; padding-bottom: 5px; From aad7cbe1fbaa1efe7613814f9b582d609438f868 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:51:14 +0100 Subject: [PATCH 21/32] GitExtensionService - initial scaffolding of the service (#296341) * Initial scaffolding * Update src/vs/workbench/api/common/extHostGitExtensionService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Pull request feedcback --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/vs/sessions/sessions.common.main.ts | 3 + .../api/browser/extensionHost.contribution.ts | 1 + .../browser/mainThreadGitExtensionService.ts | 35 ++++++++ .../workbench/api/common/extHost.api.impl.ts | 3 + .../api/common/extHost.common.services.ts | 2 + .../workbench/api/common/extHost.protocol.ts | 9 ++ .../api/common/extHostGitExtensionService.ts | 84 +++++++++++++++++++ .../contrib/git/browser/git.contributions.ts | 10 +++ .../contrib/git/browser/gitService.ts | 31 +++++++ .../contrib/git/common/gitService.ts | 31 +++++++ src/vs/workbench/workbench.common.main.ts | 3 + 11 files changed, 212 insertions(+) create mode 100644 src/vs/workbench/api/browser/mainThreadGitExtensionService.ts create mode 100644 src/vs/workbench/api/common/extHostGitExtensionService.ts create mode 100644 src/vs/workbench/contrib/git/browser/git.contributions.ts create mode 100644 src/vs/workbench/contrib/git/browser/gitService.ts create mode 100644 src/vs/workbench/contrib/git/common/gitService.ts diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index 2731989f776e5..5e38962868d0b 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -248,6 +248,9 @@ import '../workbench/contrib/searchEditor/browser/searchEditor.contribution.js'; // Sash import '../workbench/contrib/sash/browser/sash.contribution.js'; +// Git +import '../workbench/contrib/git/browser/git.contributions.js'; + // SCM import '../workbench/contrib/scm/browser/scm.contribution.js'; diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index ac03a72494340..7e8e6220540b5 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -98,6 +98,7 @@ import './mainThreadChatOutputRenderer.js'; import './mainThreadChatSessions.js'; import './mainThreadDataChannels.js'; import './mainThreadMeteredConnection.js'; +import './mainThreadGitExtensionService.js'; export class ExtensionPoints implements IWorkbenchContribution { diff --git a/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts b/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts new file mode 100644 index 0000000000000..30a68e7b3c5e8 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; +import { IGitExtensionService, IGitService } from '../../contrib/git/common/gitService.js'; +import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; +import { ExtHostContext, ExtHostGitExtensionShape, MainContext, MainThreadGitExtensionShape } from '../common/extHost.protocol.js'; + +@extHostNamedCustomer(MainContext.MainThreadGitExtension) +export class MainThreadGitExtensionService extends Disposable implements MainThreadGitExtensionShape, IGitExtensionService { + private readonly _proxy: ExtHostGitExtensionShape; + + constructor( + extHostContext: IExtHostContext, + @IGitService private readonly gitService: IGitService, + ) { + super(); + + this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostGitExtension); + gitService.setDelegate(this); + } + + async openRepository(uri: URI): Promise { + const result = await this._proxy.$openRepository(uri); + return result ? URI.revive(result) : undefined; + } + + override dispose(): void { + this.gitService.clearDelegate(); + super.dispose(); + } +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 8cdf04941f63c..f26f121a87d75 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -118,6 +118,7 @@ import { IExtHostPower } from './extHostPower.js'; import { IExtHostWorkspace } from './extHostWorkspace.js'; import { ExtHostChatContext } from './extHostChatContext.js'; import { IExtHostMeteredConnection } from './extHostMeteredConnection.js'; +import { IExtHostGitExtensionService } from './extHostGitExtensionService.js'; export interface IExtensionRegistries { mine: ExtensionDescriptionRegistry; @@ -161,6 +162,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostMcp = accessor.get(IExtHostMpcService); const extHostDataChannels = accessor.get(IExtHostDataChannels); const extHostMeteredConnection = accessor.get(IExtHostMeteredConnection); + const extHostGitExtensionService = accessor.get(IExtHostGitExtensionService); // register addressable instances rpcProtocol.set(ExtHostContext.ExtHostFileSystemInfo, extHostFileSystemInfo); @@ -183,6 +185,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I rpcProtocol.set(ExtHostContext.ExtHostChatProvider, extHostLanguageModels); rpcProtocol.set(ExtHostContext.ExtHostDataChannels, extHostDataChannels); rpcProtocol.set(ExtHostContext.ExtHostMeteredConnection, extHostMeteredConnection); + rpcProtocol.set(ExtHostContext.ExtHostGitExtension, extHostGitExtensionService); // automatically create and register addressable instances const extHostDecorations = rpcProtocol.set(ExtHostContext.ExtHostDecorations, accessor.get(IExtHostDecorations)); diff --git a/src/vs/workbench/api/common/extHost.common.services.ts b/src/vs/workbench/api/common/extHost.common.services.ts index 91a91a8ce4837..04f6862725471 100644 --- a/src/vs/workbench/api/common/extHost.common.services.ts +++ b/src/vs/workbench/api/common/extHost.common.services.ts @@ -36,6 +36,7 @@ import { ExtHostUrls, IExtHostUrlsService } from './extHostUrls.js'; import { ExtHostProgress, IExtHostProgress } from './extHostProgress.js'; import { ExtHostDataChannels, IExtHostDataChannels } from './extHostDataChannels.js'; import { ExtHostMeteredConnection, IExtHostMeteredConnection } from './extHostMeteredConnection.js'; +import { ExtHostGitExtensionService, IExtHostGitExtensionService } from './extHostGitExtensionService.js'; registerSingleton(IExtHostLocalizationService, ExtHostLocalizationService, InstantiationType.Delayed); registerSingleton(ILoggerService, ExtHostLoggerService, InstantiationType.Delayed); @@ -68,3 +69,4 @@ registerSingleton(IExtHostVariableResolverProvider, ExtHostVariableResolverProvi registerSingleton(IExtHostMpcService, ExtHostMcpService, InstantiationType.Eager); registerSingleton(IExtHostDataChannels, ExtHostDataChannels, InstantiationType.Eager); registerSingleton(IExtHostMeteredConnection, ExtHostMeteredConnection, InstantiationType.Eager); +registerSingleton(IExtHostGitExtensionService, ExtHostGitExtensionService, InstantiationType.Delayed); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 14685130cb2db..d10a973d8bc00 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -118,6 +118,9 @@ export interface IMainContext extends IRPCProtocol { // --- main thread +export interface MainThreadGitExtensionShape extends IDisposable { +} + export interface MainThreadClipboardShape extends IDisposable { $readText(): Promise; $writeText(value: string): Promise; @@ -3453,6 +3456,10 @@ export interface ExtHostChatSessionsShape { $provideHandleOptionsChange(providerHandle: number, sessionResource: UriComponents, updates: ReadonlyArray, token: CancellationToken): Promise; } +export interface ExtHostGitExtensionShape { + $openRepository(root: UriComponents): Promise; +} + // --- proxy identifiers export const MainContext = { @@ -3463,6 +3470,7 @@ export const MainContext = { MainThreadChatAgents2: createProxyIdentifier('MainThreadChatAgents2'), MainThreadCodeMapper: createProxyIdentifier('MainThreadCodeMapper'), MainThreadLanguageModelTools: createProxyIdentifier('MainThreadChatSkills'), + MainThreadGitExtension: createProxyIdentifier('MainThreadGitExtension'), MainThreadClipboard: createProxyIdentifier('MainThreadClipboard'), MainThreadCommands: createProxyIdentifier('MainThreadCommands'), MainThreadComments: createProxyIdentifier('MainThreadComments'), @@ -3613,4 +3621,5 @@ export const ExtHostContext = { ExtHostMcp: createProxyIdentifier('ExtHostMcp'), ExtHostDataChannels: createProxyIdentifier('ExtHostDataChannels'), ExtHostChatSessions: createProxyIdentifier('ExtHostChatSessions'), + ExtHostGitExtension: createProxyIdentifier('ExtHostGitExtension'), }; diff --git a/src/vs/workbench/api/common/extHostGitExtensionService.ts b/src/vs/workbench/api/common/extHostGitExtensionService.ts new file mode 100644 index 0000000000000..47276d420b8d6 --- /dev/null +++ b/src/vs/workbench/api/common/extHostGitExtensionService.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { URI, UriComponents } from '../../../base/common/uri.js'; +import { ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js'; +import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; +import { IExtHostExtensionService } from './extHostExtensionService.js'; +import { IExtHostRpcService } from './extHostRpcService.js'; +import { ExtHostGitExtensionShape } from './extHost.protocol.js'; + +const GIT_EXTENSION_ID = 'vscode.git'; + +interface GitExtensionAPI { + openRepository(root: vscode.Uri): Promise<{ readonly rootUri: vscode.Uri } | null>; +} + +interface GitExtension { + getAPI(version: 1): GitExtensionAPI; +} + +export interface IExtHostGitExtensionService extends ExtHostGitExtensionShape { + readonly _serviceBrand: undefined; +} + +export const IExtHostGitExtensionService = createDecorator('IExtHostGitExtensionService'); + +export class ExtHostGitExtensionService extends Disposable implements IExtHostGitExtensionService { + declare readonly _serviceBrand: undefined; + + private _gitApi: GitExtensionAPI | undefined; + private readonly _disposables = this._register(new DisposableStore()); + + constructor( + @IExtHostRpcService _extHostRpc: IExtHostRpcService, + @IExtHostExtensionService private readonly _extHostExtensionService: IExtHostExtensionService, + ) { + super(); + } + + // --- Called by the main thread via RPC (ExtHostGitShape) --- + + async $openRepository(uri: UriComponents): Promise { + const api = await this._ensureGitApi(); + if (!api) { + return undefined; + } + + const repository = await api.openRepository(URI.revive(uri)); + return repository?.rootUri; + } + + // --- Private helpers --- + + private async _ensureGitApi(): Promise { + if (this._gitApi) { + return this._gitApi; + } + + try { + await this._extHostExtensionService.activateByIdWithErrors( + new ExtensionIdentifier(GIT_EXTENSION_ID), + { startup: false, extensionId: new ExtensionIdentifier(GIT_EXTENSION_ID), activationEvent: 'api' } + ); + + const exports = this._extHostExtensionService.getExtensionExports(new ExtensionIdentifier(GIT_EXTENSION_ID)); + if (!!exports && typeof (exports as GitExtension).getAPI === 'function') { + this._gitApi = (exports as GitExtension).getAPI(1); + } + } catch { + // Git extension not available + } + + return this._gitApi; + } + + override dispose(): void { + this._disposables.dispose(); + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/git/browser/git.contributions.ts b/src/vs/workbench/contrib/git/browser/git.contributions.ts new file mode 100644 index 0000000000000..bad65eaec7c66 --- /dev/null +++ b/src/vs/workbench/contrib/git/browser/git.contributions.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IGitService } from '../common/gitService.js'; +import { GitService } from './gitService.js'; + +registerSingleton(IGitService, GitService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/git/browser/gitService.ts b/src/vs/workbench/contrib/git/browser/gitService.ts new file mode 100644 index 0000000000000..c298ec69e2a76 --- /dev/null +++ b/src/vs/workbench/contrib/git/browser/gitService.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IGitService, IGitExtensionService } from '../common/gitService.js'; + +export class GitService extends Disposable implements IGitService { + declare readonly _serviceBrand: undefined; + + private _delegate: IGitExtensionService | undefined; + + setDelegate(delegate: IGitExtensionService): void { + this._delegate = delegate; + } + + clearDelegate(): void { + this._delegate = undefined; + } + + async openRepository(root: URI): Promise { + if (!this._delegate) { + return undefined; + } + + const result = await this._delegate.openRepository(root); + return result ? URI.revive(result) : undefined; + } +} diff --git a/src/vs/workbench/contrib/git/common/gitService.ts b/src/vs/workbench/contrib/git/common/gitService.ts new file mode 100644 index 0000000000000..3092386866947 --- /dev/null +++ b/src/vs/workbench/contrib/git/common/gitService.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI, UriComponents } from '../../../../base/common/uri.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +/** + * Delegate interface that bridges to the git extension running + * in the extension host. Set by MainThreadGit when an extension + * host connects. + */ +export interface IGitExtensionService { + openRepository(uri: UriComponents): Promise; +} + +export const IGitService = createDecorator('gitService'); + +export interface IGitService { + readonly _serviceBrand: undefined; + + setDelegate(delegate: IGitExtensionService): void; + clearDelegate(): void; + + /** + * Open a git repository at the given URI. + * @returns The repository root URI or `undefined` if the repository could not be opened. + */ + openRepository(uri: URI): Promise; +} diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 8b07bafcbcec3..be64fd940aaae 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -250,6 +250,9 @@ import './contrib/searchEditor/browser/searchEditor.contribution.js'; // Sash import './contrib/sash/browser/sash.contribution.js'; +// Git +import './contrib/git/browser/git.contributions.js'; + // SCM import './contrib/scm/browser/scm.contribution.js'; From 75c92f0d57d3e9397ce109afbac34d6b6a9f1afc Mon Sep 17 00:00:00 2001 From: Murat Aslan <78961478+murataslan1@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:53:01 +0300 Subject: [PATCH 22/32] testing: show running badge on Activity Bar while tests are running (#292257) * feat(testing): show running badge on Activity Bar while tests are running Shows a spinning loading indicator badge on the Testing icon in the Activity Bar when tests are actively running. This provides visual feedback at a glance to know when test runs have started and completed. The badge priority is: 1. Running tests (spinning icon) - highest priority 2. Count badge (failed/passed/skipped) 3. Continuous testing indicator Fixes #201982 Co-authored-by: Cursor * fix: prevent stale badge cache after badgeDisposable is cleared Include badgeDisposable.value in the early-return cache check so that badges are properly re-applied after results are cleared (e.g. when badgeDisposable.clear() is called in the no-results branch) and tests start again. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Murat Aslan Co-authored-by: Cursor Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../testing/browser/testingExplorerView.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts index ef75da688b1cf..9257aa6588f0e 100644 --- a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts @@ -625,7 +625,7 @@ class ResultSummaryView extends Disposable { count.textContent = `${counts.passed}/${counts.totalWillBeRun}`; this.countHover.update(getTestProgressText(counts)); - this.renderActivityBadge(counts); + this.renderActivityBadge(counts, live.length > 0); if (!this.elementsWereAttached) { dom.clearNode(this.container); @@ -634,15 +634,21 @@ class ResultSummaryView extends Disposable { } } - private renderActivityBadge(countSummary: CountSummary) { - if (countSummary && this.badgeType !== TestingCountBadge.Off && countSummary[this.badgeType] !== 0) { - if (this.lastBadge instanceof NumberBadge && this.lastBadge.number === countSummary[this.badgeType]) { + private renderActivityBadge(countSummary: CountSummary, isRunning: boolean) { + if (isRunning) { + if (this.badgeDisposable.value && this.lastBadge instanceof IconBadge && this.lastBadge.icon === spinningLoading) { + return; + } + + this.lastBadge = new IconBadge(spinningLoading, () => localize('testingRunningBadge', 'Tests are running')); + } else if (countSummary && this.badgeType !== TestingCountBadge.Off && countSummary[this.badgeType] !== 0) { + if (this.badgeDisposable.value && this.lastBadge instanceof NumberBadge && this.lastBadge.number === countSummary[this.badgeType]) { return; } this.lastBadge = new NumberBadge(countSummary[this.badgeType], num => this.getLocalizedBadgeString(this.badgeType, num)); } else if (this.crService.isEnabled()) { - if (this.lastBadge instanceof IconBadge && this.lastBadge.icon === icons.testingContinuousIsOn) { + if (this.badgeDisposable.value && this.lastBadge instanceof IconBadge && this.lastBadge.icon === icons.testingContinuousIsOn) { return; } From dda13e8d8bedfc4cbe5106a9f5a59490e1609e31 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 19 Feb 2026 13:55:33 -0600 Subject: [PATCH 23/32] play OS notification to indicate question has come in, reusing setting (#296344) fix #293038 --- .../contrib/chat/browser/chat.contribution.ts | 2 +- .../chat/browser/chatWindowNotifier.ts | 29 +++++++++- .../chat/browser/widget/chatListRenderer.ts | 55 +------------------ 3 files changed, 29 insertions(+), 57 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 564767f17b073..e882231c7e131 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -321,7 +321,7 @@ configurationRegistry.registerConfiguration({ }, 'chat.notifyWindowOnConfirmation': { type: 'boolean', - description: nls.localize('chat.notifyWindowOnConfirmation', "Controls whether a chat session should present the user with an OS notification when a confirmation is needed while the window is not in focus. This includes a window badge as well as notification toast."), + description: nls.localize('chat.notifyWindowOnConfirmation', "Controls whether a chat session should present the user with an OS notification when a confirmation or question needs input while the window is not in focus. This includes a window badge as well as notification toast."), default: true, }, [ChatConfiguration.AutoReply]: { diff --git a/src/vs/workbench/contrib/chat/browser/chatWindowNotifier.ts b/src/vs/workbench/contrib/chat/browser/chatWindowNotifier.ts index c7fbb220542d1..2b4857787cfef 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWindowNotifier.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWindowNotifier.ts @@ -96,11 +96,23 @@ export class ChatWindowNotifier extends Disposable implements IWorkbenchContribu const cts = new CancellationTokenSource(); this._activeNotifications.set(sessionResource, toDisposable(() => cts.dispose(true))); + // Determine if the pending input is for a question carousel + const isQuestionCarousel = this._isQuestionCarouselPending(sessionResource); + try { + const actionLabel = isQuestionCarousel + ? localize('openChatAction', "Open Chat") + : localize('allowAction', "Allow"); + const body = info.detail + ? this._sanitizeOSToastText(info.detail) + : isQuestionCarousel + ? localize('questionCarouselDetail', "Questions need your input.") + : localize('notificationDetail', "Approval needed to continue."); + const result = await this._hostService.showToast({ title: this._sanitizeOSToastText(notificationTitle), - body: info.detail ? this._sanitizeOSToastText(info.detail) : localize('notificationDetail', "Approval needed to continue."), - actions: [localize('allowAction', "Allow")], + body, + actions: [actionLabel], }, cts.token); if (result.clicked || typeof result.actionIndex === 'number') { @@ -109,7 +121,7 @@ export class ChatWindowNotifier extends Disposable implements IWorkbenchContribu const widget = await this._chatWidgetService.openSession(sessionResource); widget?.focusInput(); - if (result.actionIndex === 0 /* Allow */) { + if (result.actionIndex === 0 && !isQuestionCarousel) { await this._commandService.executeCommand(AcceptToolConfirmationActionId, { sessionResource } satisfies IToolConfirmationActionContext); } } @@ -118,6 +130,17 @@ export class ChatWindowNotifier extends Disposable implements IWorkbenchContribu } } + private _isQuestionCarouselPending(sessionResource: URI): boolean { + const model = this._chatService.getSession(sessionResource); + const lastResponse = model?.lastRequest?.response; + if (!lastResponse) { + return false; + } + return lastResponse.response.value.some( + part => part.kind === 'questionCarousel' && !part.isUsed + ); + } + private _sanitizeOSToastText(text: string): string { return text.replace(/`/g, '\''); // convert backticks to single quotes } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index fd984cb2740fc..3b22171302e8d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -47,8 +47,6 @@ import { IMarkdownRenderer } from '../../../../../platform/markdown/browser/mark import { isDark } from '../../../../../platform/theme/common/theme.js'; import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; -import { FocusMode } from '../../../../../platform/native/common/native.js'; -import { IHostService } from '../../../../services/host/browser/host.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { IWorkbenchIssueService } from '../../../issue/common/issue.js'; import { CodiconActionViewItem } from '../../../notebook/browser/view/cellParts/cellActionView.js'; @@ -188,7 +186,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer(); - private readonly _questionCarouselToast = this._register(new DisposableStore()); private readonly chatContentMarkdownRenderer: IMarkdownRenderer; private readonly markdownDecorationsRenderer: ChatMarkdownDecorationsRenderer; @@ -256,7 +253,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.notifyWindowOnConfirmation')) { - return; - } - - if (!isResponseVM(context.element)) { - return; - } - - const widget = this.chatWidgetService.getWidgetBySessionResource(context.element.sessionResource); - if (!widget) { - return; - } + // Play accessibility signal regardless of notification setting const signalMessage = questionCount === 1 ? localize('chat.questionCarouselSignalOne', "Chat needs your input (1 question).") : localize('chat.questionCarouselSignalMany', "Chat needs your input ({0} questions).", questionCount); this.accessibilitySignalService.playSignal(AccessibilitySignal.chatUserActionRequired, { allowManyInParallel: true, customAlertMessage: signalMessage }); - const targetWindow = dom.getWindow(widget.domNode); - if (!targetWindow || targetWindow.document.hasFocus()) { - return; - } - - - const sessionTitle = widget.viewModel?.model.title; - const notificationTitle = sessionTitle ? localize('chatTitle', "Chat: {0}", sessionTitle) : localize('chat.untitledChat', "Untitled Chat"); - - (async () => { - try { - await this.hostService.focus(targetWindow, { mode: FocusMode.Notify }); - - // Dispose any previous unhandled notifications to avoid replacement/coalescing. - this._questionCarouselToast.clear(); - - const cts = new CancellationTokenSource(); - this._questionCarouselToast.add(toDisposable(() => cts.dispose(true))); - - const { clicked, actionIndex } = await this.hostService.showToast({ - title: notificationTitle, - body: signalMessage, - actions: [localize('openChat', "Open Chat")], - }, cts.token); - - this._questionCarouselToast.clear(); - - if (clicked || actionIndex === 0) { - await this.hostService.focus(targetWindow, { mode: FocusMode.Force }); - await this.chatWidgetService.reveal(widget); - widget.focusInput(); - } - } catch (error) { - this.logService.trace('ChatListItemRenderer#_notifyOnQuestionCarousel', toErrorMessage(error)); - } - })(); + // OS toast notification is handled by ChatWindowNotifier } private maybeAutoReplyToQuestionCarousel( From 8679e41a8f25896e93252c6cbf69fce40f9e85c5 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:57:46 +0100 Subject: [PATCH 24/32] Sessions - disable worktree detection (#296354) --- .../contrib/configuration/browser/configuration.contribution.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index 4da1e012c757b..57de7b183e99a 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -20,6 +20,7 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'files.autoSave': 'afterDelay', 'git.autofetch': true, + 'git.detectWorktrees': false, 'git.showProgress': false, 'github.copilot.chat.claudeCode.enabled': true, From e4e882fb4ab164bc0aa0d4bfeaf8e9344ecf099b Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 19 Feb 2026 21:35:41 +0100 Subject: [PATCH 25/32] polishing model picker and enable it in chat (#296356) --- .../actionWidget/browser/actionList.ts | 83 +++++++++++++++++-- .../actionWidget/browser/actionWidget.ts | 10 +-- .../contextview/browser/contextView.ts | 2 + .../contrib/chat/browser/newChatViewPane.ts | 1 - .../browser/actions/chatDeveloperActions.ts | 20 +++++ .../browser/widget/input/chatInputPart.ts | 9 +- .../browser/widget/input/chatModelPicker.ts | 40 +++------ .../common/chatService/chatServiceImpl.ts | 6 +- .../contrib/chat/common/languageModels.ts | 22 +++-- .../chatModelsViewModel.test.ts | 1 + .../chat/test/common/languageModels.ts | 1 + 11 files changed, 139 insertions(+), 56 deletions(-) diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index b80f1107c4d99..b3d1ed909f94c 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -3,8 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../base/browser/dom.js'; +import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; import { renderMarkdown } from '../../../base/browser/markdownRenderer.js'; import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js'; +import { getAnchorRect, IAnchor } from '../../../base/browser/ui/contextview/contextview.js'; import { KeybindingLabel } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; import { IListEvent, IListMouseEvent, IListRenderer, IListVirtualDelegate } from '../../../base/browser/ui/list/list.js'; import { IListAccessibilityProvider, List } from '../../../base/browser/ui/list/listWidget.js'; @@ -13,6 +15,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../base/common import { Codicon } from '../../../base/common/codicons.js'; import { IMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js'; import { ResolvedKeybinding } from '../../../base/common/keybindings.js'; +import { AnchorPosition } from '../../../base/common/layout.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; import { OS } from '../../../base/common/platform.js'; import { ThemeIcon } from '../../../base/common/themables.js'; @@ -333,11 +336,6 @@ export interface IActionListOptions { */ readonly showFilter?: boolean; - /** - * Placement of the filter input. Defaults to 'top'. - */ - readonly filterPlacement?: 'top' | 'bottom'; - /** * Section IDs that should be collapsed by default. */ @@ -373,6 +371,18 @@ export class ActionList extends Disposable { private _lastMinWidth = 0; private _cachedMaxWidth: number | undefined; private _hasLaidOut = false; + private _showAbove: boolean | undefined; + + /** + * Returns the resolved anchor position after the first layout. + * Used by the context view delegate to lock the dropdown direction. + */ + get anchorPosition(): AnchorPosition | undefined { + if (this._showAbove === undefined) { + return undefined; + } + return this._showAbove ? AnchorPosition.ABOVE : AnchorPosition.BELOW; + } constructor( user: string, @@ -381,6 +391,7 @@ export class ActionList extends Disposable { private readonly _delegate: IActionListDelegate, accessibilityProvider: Partial>> | undefined, private readonly _options: IActionListOptions | undefined, + private readonly _anchor: HTMLElement | StandardMouseEvent | IAnchor, @IContextViewService private readonly _contextViewService: IContextViewService, @IKeybindingService private readonly _keybindingService: IKeybindingService, @ILayoutService private readonly _layoutService: ILayoutService, @@ -650,8 +661,13 @@ export class ActionList extends Disposable { return this._filterContainer; } + /** + * Returns the resolved filter placement based on the dropdown direction. + * When shown above the anchor, filter is at the bottom (closest to anchor); + * when shown below, filter is at the top. + */ get filterPlacement(): 'top' | 'bottom' { - return this._options?.filterPlacement ?? 'top'; + return this._showAbove ? 'bottom' : 'top'; } get filterInput(): HTMLInputElement | undefined { @@ -692,6 +708,13 @@ export class ActionList extends Disposable { this._contextViewService.hideContextView(); } + private hasDynamicHeight(): boolean { + if (this._options?.showFilter) { + return true; + } + return this._allMenuItems.some(item => item.isSectionToggle); + } + private computeHeight(): number { // Compute height based on currently visible items in the list const visibleCount = this._list.length; @@ -714,9 +737,36 @@ export class ActionList extends Disposable { const filterHeight = this._filterContainer ? 36 : 0; const padding = 10; const targetWindow = dom.getWindow(this.domNode); - const windowHeight = this._layoutService.getContainer(targetWindow).clientHeight; - const widgetTop = this.domNode.getBoundingClientRect().top; - const availableHeight = widgetTop > 0 ? windowHeight - widgetTop - padding : windowHeight * 0.7; + let availableHeight; + + if (this.hasDynamicHeight()) { + const viewportHeight = targetWindow.innerHeight; + const anchorRect = getAnchorRect(this._anchor); + const anchorTopInViewport = anchorRect.top - targetWindow.pageYOffset; + const spaceBelow = viewportHeight - anchorTopInViewport - anchorRect.height - padding; + const spaceAbove = anchorTopInViewport - padding; + + // Lock the direction on first layout based on whether the full + // unconstrained list fits below. Once decided, the dropdown stays + // in the same position even when the visible item count changes. + if (this._showAbove === undefined) { + let fullHeight = filterHeight; + for (const item of this._allMenuItems) { + switch (item.kind) { + case ActionListItemKind.Header: fullHeight += this._headerLineHeight; break; + case ActionListItemKind.Separator: fullHeight += this._separatorLineHeight; break; + default: fullHeight += this._actionLineHeight; break; + } + } + this._showAbove = fullHeight > spaceBelow && spaceAbove > spaceBelow; + } + availableHeight = this._showAbove ? spaceAbove : spaceBelow; + } else { + const windowHeight = this._layoutService.getContainer(targetWindow).clientHeight; + const widgetTop = this.domNode.getBoundingClientRect().top; + availableHeight = widgetTop > 0 ? windowHeight - widgetTop - padding : windowHeight * 0.7; + } + const maxHeight = Math.max(availableHeight, this._actionLineHeight * 3 + filterHeight); const height = Math.min(listHeight + filterHeight, maxHeight); return height - filterHeight; @@ -798,6 +848,21 @@ export class ActionList extends Disposable { this._cachedMaxWidth = this.computeMaxWidth(minWidth); this._list.layout(listHeight, this._cachedMaxWidth); this.domNode.style.height = `${listHeight}px`; + + // Place filter container on the correct side based on dropdown direction. + // When shown above, filter goes below the list (closest to anchor). + // When shown below, filter goes above the list (closest to anchor). + if (this._filterContainer && this._filterContainer.parentElement) { + const parent = this._filterContainer.parentElement; + if (this._showAbove) { + // Move filter after the list + parent.appendChild(this._filterContainer); + } else { + // Move filter before the list + parent.insertBefore(this._filterContainer, this.domNode); + } + } + return this._cachedMaxWidth; } diff --git a/src/vs/platform/actionWidget/browser/actionWidget.ts b/src/vs/platform/actionWidget/browser/actionWidget.ts index 3fe8208fc8f9e..3e43f12b1957c 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.ts +++ b/src/vs/platform/actionWidget/browser/actionWidget.ts @@ -64,7 +64,7 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: HTMLElement | StandardMouseEvent | IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[], accessibilityProvider?: Partial>>, listOptions?: IActionListOptions): void { const visibleContext = ActionWidgetContextKeys.Visible.bindTo(this._contextKeyService); - const list = this._instantiationService.createInstance(ActionList, user, supportsPreview, items, delegate, accessibilityProvider, listOptions); + const list = this._instantiationService.createInstance(ActionList, user, supportsPreview, items, delegate, accessibilityProvider, listOptions, anchor); this._contextViewService.showContextView({ getAnchor: () => anchor, render: (container: HTMLElement) => { @@ -75,6 +75,7 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { visibleContext.reset(); this._onWidgetClosed(didCancel); }, + get anchorPosition() { return list.anchorPosition; }, }, container, false); } @@ -118,15 +119,10 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { this._list.value = list; if (this._list.value) { - // Filter input at the top - if (this._list.value.filterContainer && this._list.value.filterPlacement === 'top') { + if (this._list.value.filterContainer) { widget.appendChild(this._list.value.filterContainer); } widget.appendChild(this._list.value.domNode); - // Filter input at the bottom - if (this._list.value.filterContainer && this._list.value.filterPlacement === 'bottom') { - widget.appendChild(this._list.value.filterContainer); - } } else { throw new Error('List has no value'); } diff --git a/src/vs/platform/contextview/browser/contextView.ts b/src/vs/platform/contextview/browser/contextView.ts index 1b3e8b2a80876..1cb77054743f3 100644 --- a/src/vs/platform/contextview/browser/contextView.ts +++ b/src/vs/platform/contextview/browser/contextView.ts @@ -8,6 +8,7 @@ import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; import { AnchorAlignment, AnchorAxisAlignment, IAnchor, IContextViewProvider } from '../../../base/browser/ui/contextview/contextview.js'; import { IAction } from '../../../base/common/actions.js'; import { Event } from '../../../base/common/event.js'; +import { AnchorPosition } from '../../../base/common/layout.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; import { IMenuActionOptions, MenuId } from '../../actions/common/actions.js'; import { IContextKeyService } from '../../contextkey/common/contextkey.js'; @@ -43,6 +44,7 @@ export interface IContextViewDelegate { focus?(): void; anchorAlignment?: AnchorAlignment; anchorAxisAlignment?: AnchorAxisAlignment; + anchorPosition?: AnchorPosition; // context views with higher layers are rendered over contet views with lower layers layer?: number; // Default: 0 diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index a8c69daa78eb9..fe2b8209e7049 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -467,7 +467,6 @@ class NewChatWidget extends Disposable { currentModel: this._currentLanguageModel, setModel: (model: ILanguageModelChatMetadataAndIdentifier) => { this._currentLanguageModel.set(model, undefined); - this.languageModelsService.addToRecentlyUsedList(model); }, getModels: () => this._getAvailableModels(), canManageModels: () => true, diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatDeveloperActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatDeveloperActions.ts index dfa15fb1b86c8..ab1429fdaddff 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatDeveloperActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatDeveloperActions.ts @@ -12,6 +12,7 @@ import { Action2, registerAction2 } from '../../../../../platform/actions/common import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatService } from '../../common/chatService/chatService.js'; +import { ILanguageModelsService } from '../../common/languageModels.js'; import { IChatWidgetService } from '../chat.js'; function uriReplacer(_key: string, value: unknown): unknown { @@ -31,6 +32,7 @@ export function registerChatDeveloperActions() { registerAction2(LogChatInputHistoryAction); registerAction2(LogChatIndexAction); registerAction2(InspectChatModelAction); + registerAction2(ClearRecentlyUsedLanguageModelsAction); } class LogChatInputHistoryAction extends Action2 { @@ -126,3 +128,21 @@ class InspectChatModelAction extends Action2 { }); } } + +class ClearRecentlyUsedLanguageModelsAction extends Action2 { + static readonly ID = 'workbench.action.chat.clearRecentlyUsedLanguageModels'; + + constructor() { + super({ + id: ClearRecentlyUsedLanguageModelsAction.ID, + title: localize2('workbench.action.chat.clearRecentlyUsedLanguageModels.label', "Clear Recently Used Language Models"), + category: Categories.Developer, + f1: true, + precondition: ChatContextKeys.enabled + }); + } + + override run(accessor: ServicesAccessor): void { + accessor.get(ILanguageModelsService).clearRecentlyUsedList(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index a4afd7702f01f..3c47743a275b8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -120,12 +120,13 @@ import { ChatInputPartWidgetController } from './chatInputPartWidgets.js'; import { IChatInputPickerOptions } from './chatInputPickerActionItem.js'; import { ChatSelectedTools } from './chatSelectedTools.js'; import { DelegationSessionPickerActionItem } from './delegationSessionPickerActionItem.js'; -import { IModelPickerDelegate, ModelPickerActionItem } from './modelPickerActionItem.js'; +import { IModelPickerDelegate } from './modelPickerActionItem.js'; import { IModePickerDelegate, ModePickerActionItem } from './modePickerActionItem.js'; import { SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; import { WorkspacePickerActionItem } from './workspacePickerActionItem.js'; import { ChatContextUsageWidget } from '../../widgetHosts/viewPane/chatContextUsageWidget.js'; import { Target } from '../../../common/promptSyntax/service/promptsService.js'; +import { EnhancedModelPickerActionItem } from './modelPickerActionItem2.js'; const $ = dom.$; @@ -357,7 +358,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private agentSessionTypeKey: IContextKey; private chatSessionHasCustomAgentTarget: IContextKey; private chatSessionHasTargetedModels: IContextKey; - private modelWidget: ModelPickerActionItem | undefined; + private modelWidget: EnhancedModelPickerActionItem | undefined; private modeWidget: ModePickerActionItem | undefined; private sessionTargetWidget: SessionTypePickerActionItem | undefined; private delegationWidget: DelegationSessionPickerActionItem | undefined; @@ -998,8 +999,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge public setCurrentLanguageModel(model: ILanguageModelChatMetadataAndIdentifier) { this._currentLanguageModel.set(model, undefined); - this.languageModelsService.addToRecentlyUsedList(model); - if (this.cachedWidth) { // For quick chat and editor chat, relayout because the input may need to shrink to accomodate the model name this.layout(this.cachedWidth); @@ -2185,7 +2184,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge getModels: () => this.getModels(), canManageModels: () => !this.getCurrentSessionType() }; - return this.modelWidget = this.instantiationService.createInstance(ModelPickerActionItem, action, undefined, itemDelegate, pickerOptions); + return this.modelWidget = this.instantiationService.createInstance(EnhancedModelPickerActionItem, action, itemDelegate, pickerOptions); } else if (action.id === OpenModePickerAction.ID && action instanceof MenuItemAction) { const delegate: IModePickerDelegate = { currentMode: this._currentModeObservable, 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 222e7e78289a0..9d66c0bd04472 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -232,18 +232,21 @@ export function buildModelPickerItems( } } - // Render promoted section: available sorted alphabetically, then unavailable + // Render promoted section: sorted alphabetically by name if (promotedItems.length > 0) { - const available = promotedItems.filter((i): i is PromotedItem & { kind: 'available' } => i.kind === 'available'); - const unavailable = promotedItems.filter((i): i is PromotedItem & { kind: 'unavailable' } => i.kind === 'unavailable'); - available.sort((a, b) => a.model.metadata.name.localeCompare(b.model.metadata.name)); + promotedItems.sort((a, b) => { + const aName = a.kind === 'available' ? a.model.metadata.name : a.entry.label; + const bName = b.kind === 'available' ? b.model.metadata.name : b.entry.label; + return aName.localeCompare(bName); + }); items.push({ kind: ActionListItemKind.Separator }); - for (const { model } of available) { - items.push(createModelItem(createModelAction(model, selectedModelId, onSelect), model)); - } - for (const { entry, reason } of unavailable) { - items.push(createUnavailableModelItem(entry, reason, upgradePlanUrl, updateStateType)); + for (const item of promotedItems) { + if (item.kind === 'available') { + items.push(createModelItem(createModelAction(item.model, selectedModelId, onSelect), item.model)); + } else { + items.push(createUnavailableModelItem(item.entry, item.reason, upgradePlanUrl, updateStateType)); + } } } @@ -324,7 +327,7 @@ function createUnavailableModelItem( if (reason === 'upgrade') { description = upgradePlanUrl - ? new MarkdownString(localize('chat.modelPicker.upgradeLink', "[Upgrade]({0})", upgradePlanUrl), { isTrusted: true }) + ? new MarkdownString(localize('chat.modelPicker.upgradeLink', "[Upgrade your plan]({0})", upgradePlanUrl), { isTrusted: true }) : localize('chat.modelPicker.upgrade', "Upgrade"); } else { icon = Codicon.warning; @@ -619,23 +622,6 @@ function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier): M markdown.appendText(`\n`); } - if (model.metadata.capabilities) { - markdown.appendMarkdown(`${localize('models.capabilities', 'Capabilities')}: `); - if (model.metadata.capabilities?.toolCalling) { - markdown.appendMarkdown(`  _${localize('models.toolCalling', 'Tools')}_ `); - } - if (model.metadata.capabilities?.vision) { - markdown.appendMarkdown(`  _${localize('models.vision', 'Vision')}_ `); - } - if (model.metadata.capabilities?.agentMode) { - markdown.appendMarkdown(`  _${localize('models.agentMode', 'Agent Mode')}_ `); - } - for (const editTool of model.metadata.capabilities.editTools ?? []) { - markdown.appendMarkdown(`  _${editTool}_ `); - } - markdown.appendText(`\n`); - } - return markdown; } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 60b1e20d348cf..eba0df56ba90c 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -48,7 +48,7 @@ import { IChatTransferService } from '../model/chatTransferService.js'; import { LocalChatSessionUri } from '../model/chatUri.js'; import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../constants.js'; -import { ChatMessageRole, IChatMessage } from '../languageModels.js'; +import { ChatMessageRole, IChatMessage, ILanguageModelsService } from '../languageModels.js'; import { ILanguageModelToolsService } from '../tools/languageModelToolsService.js'; import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js'; import { IPromptsService } from '../promptSyntax/service/promptsService.js'; @@ -159,6 +159,7 @@ export class ChatService extends Disposable implements IChatService { @IMcpService private readonly mcpService: IMcpService, @IPromptsService private readonly promptsService: IPromptsService, @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, ) { super(); @@ -1194,6 +1195,9 @@ export class ChatService extends Disposable implements IChatService { this.processNextPendingRequest(model); } }); + if (options?.userSelectedModelId) { + this.languageModelsService.addToRecentlyUsedList(options.userSelectedModelId); + } this._onDidSubmitRequest.fire({ chatSessionResource: model.sessionResource }); return { responseCreatedPromise: responseCreated.p, diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 0ffd0dd69a0a8..e3f759c00bc58 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -376,7 +376,12 @@ export interface ILanguageModelsService { /** * Records that a model was used, updating the recently used list. */ - addToRecentlyUsedList(model: ILanguageModelChatMetadataAndIdentifier): void; + addToRecentlyUsedList(modelIdentifier: string): void; + + /** + * Clears the recently used model list. + */ + clearRecentlyUsedList(): void; /** * Returns the models from the control manifest, @@ -1378,22 +1383,22 @@ 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 !== 'auto') + .filter(id => this._modelCache.has(id) && id !== 'copilot/auto') .slice(0, 5); } - addToRecentlyUsedList(model: ILanguageModelChatMetadataAndIdentifier): void { - if (model.metadata.id === 'auto' && this._vendors.get(model.metadata.vendor)?.isDefault) { + addToRecentlyUsedList(modelIdentifier: string): void { + if (modelIdentifier === 'copilot/auto') { return; } // Remove if already present (to move to front) - const index = this._recentlyUsedModelIds.indexOf(model.identifier); + const index = this._recentlyUsedModelIds.indexOf(modelIdentifier); if (index !== -1) { this._recentlyUsedModelIds.splice(index, 1); } // Add to front - this._recentlyUsedModelIds.unshift(model.identifier); + this._recentlyUsedModelIds.unshift(modelIdentifier); // Cap at a reasonable max to avoid unbounded growth if (this._recentlyUsedModelIds.length > 20) { this._recentlyUsedModelIds.length = 20; @@ -1401,6 +1406,11 @@ export class LanguageModelsService implements ILanguageModelsService { this._saveRecentlyUsedModels(); } + clearRecentlyUsedList(): void { + this._recentlyUsedModelIds = []; + this._saveRecentlyUsedModels(); + } + //#endregion //#region Models control manifest 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 0e0bbe18afa41..46e8316eef22a 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 @@ -140,6 +140,7 @@ class MockLanguageModelsService implements ILanguageModelsService { getRecentlyUsedModelIds(): string[] { return []; } addToRecentlyUsedList(): void { } + clearRecentlyUsedList(): void { } getModelsControlManifest(): IModelsControlManifest { return { free: {}, paid: {} }; } restrictedChatParticipants = observableValue('restrictedChatParticipants', Object.create(null)); } diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.ts index bc7705b37a351..77b874be830d0 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.ts @@ -93,6 +93,7 @@ export class NullLanguageModelsService implements ILanguageModelsService { } addToRecentlyUsedList(): void { } + clearRecentlyUsedList(): void { } getModelsControlManifest(): IModelsControlManifest { return { free: {}, paid: {} }; From e881d79e64be423484c4119e7532930a83b0de37 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 19 Feb 2026 14:37:23 -0600 Subject: [PATCH 26/32] encourage concise questions (#296359) fix #293727 --- .../common/tools/builtinTools/askQuestionsTool.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts index ffa93a41355c7..1655b0dac454e 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts @@ -59,11 +59,13 @@ export function createAskQuestionsToolData(): IToolData { properties: { header: { type: 'string', - description: 'Short identifier for the question. Must be unique so answers can be mapped back to the question.' + description: 'Short identifier for the question. Must be unique so answers can be mapped back to the question.', + maxLength: 50 }, question: { type: 'string', - description: 'The question text to display to the user.' + description: 'The question text to display to the user. Keep it concise, ideally one sentence.', + maxLength: 200 }, multiSelect: { type: 'boolean', @@ -81,11 +83,13 @@ export function createAskQuestionsToolData(): IToolData { properties: { label: { type: 'string', - description: 'Display label and value for the option.' + description: 'Display label and value for the option.', + maxLength: 100 }, description: { type: 'string', - description: 'Optional secondary text shown with the option.' + description: 'Optional secondary text shown with the option.', + maxLength: 200 }, recommended: { type: 'boolean', From ddc18bbe4c2c1b8e90465b4e9daf14828ab61d12 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:55:03 -0800 Subject: [PATCH 27/32] hygiene skill updates (#296347) --- .github/skills/hygiene/SKILL.md | 37 ++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/.github/skills/hygiene/SKILL.md b/.github/skills/hygiene/SKILL.md index 084b76c719c8d..bcf882fc5c050 100644 --- a/.github/skills/hygiene/SKILL.md +++ b/.github/skills/hygiene/SKILL.md @@ -1,25 +1,38 @@ +--- +name: hygiene +description: Use when making code changes to ensure they pass VS Code's hygiene checks. Covers the pre-commit hook, unicode restrictions, string quoting rules, copyright headers, indentation, formatting, ESLint, and stylelint. Run the hygiene check before declaring work complete. +--- + # Hygiene Checks VS Code runs a hygiene check as a git pre-commit hook. Commits will be rejected if hygiene fails. -## What it checks - -The hygiene linter scans all staged `.ts` files for issues including (but not limited to): - -- **Unicode characters**: Non-ASCII characters (em-dashes, curly quotes, emoji, etc.) are rejected. Use ASCII equivalents in comments and code. -- **Double-quoted strings**: Only use `"double quotes"` for externalized (localized) strings. Use `'single quotes'` everywhere else. -- **Copyright headers**: All files must include the Microsoft copyright header. +## Running the hygiene check -## How it runs +**Always run the pre-commit hygiene check before declaring work complete.** This catches issues that would block a commit. -The git pre-commit hook (via husky) runs `npm run precommit`, which executes: +To run the hygiene check on your staged files: ```bash -node --experimental-strip-types build/hygiene.ts +npm run precommit ``` -This scans only **staged files** (from `git diff --cached`). To run it manually: +This executes `node --experimental-strip-types build/hygiene.ts`, which scans only **staged files** (from `git diff --cached`). + +To check specific files directly (without staging them first): ```bash -npm run precommit +node --experimental-strip-types build/hygiene.ts path/to/file.ts ``` + +## What it checks + +The hygiene linter scans staged files for issues including (but not limited to): + +- **Unicode characters**: Non-ASCII characters (em-dashes, curly quotes, emoji, etc.) are rejected. Use ASCII equivalents in comments and code. Suppress with `// allow-any-unicode-next-line` or `// allow-any-unicode-comment-file`. +- **Double-quoted strings**: Only use `"double quotes"` for externalized (localized) strings. Use `'single quotes'` everywhere else. +- **Copyright headers**: All files must include the Microsoft copyright header. +- **Indentation**: Tabs only, no spaces for indentation. +- **Formatting**: TypeScript files must match the formatter output (run `Format Document` to fix). +- **ESLint**: TypeScript files are linted with ESLint. +- **Stylelint**: CSS files are linted with stylelint. From 99409ad43c2ede75f5adc2adfac0017cb68071e6 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Thu, 19 Feb 2026 22:02:50 +0100 Subject: [PATCH 28/32] sessions - fix some issues found by AI (#296364) --- src/vs/sessions/browser/parts/auxiliaryBarPart.ts | 4 ++-- src/vs/sessions/browser/parts/chatBarPart.ts | 4 ++-- src/vs/sessions/browser/workbench.ts | 2 +- .../chat/browser/newChatContextAttachments.ts | 6 ++++-- .../browser/sessionsAuxiliaryBarContribution.ts | 12 ++++++------ .../sessions/electron-browser/parts/titlebarPart.ts | 4 ++++ 6 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts index d4e677a621515..c7dbddc3e33c3 100644 --- a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts +++ b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts @@ -42,7 +42,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { static readonly activeViewSettingsKey = 'workbench.agentsession.auxiliarybar.activepanelid'; static readonly pinnedViewsKey = 'workbench.agentsession.auxiliarybar.pinnedPanels'; - static readonly placeholdeViewContainersKey = 'workbench.agentsession.auxiliarybar.placeholderPanels'; + static readonly placeholderViewContainersKey = 'workbench.agentsession.auxiliarybar.placeholderPanels'; static readonly viewContainersWorkspaceStateKey = 'workbench.agentsession.auxiliarybar.viewContainersWorkspaceState'; /** Visual margin values for the card-like appearance */ @@ -159,7 +159,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { return { partContainerClass: 'auxiliarybar', pinnedViewContainersKey: AuxiliaryBarPart.pinnedViewsKey, - placeholderViewContainersKey: AuxiliaryBarPart.placeholdeViewContainersKey, + placeholderViewContainersKey: AuxiliaryBarPart.placeholderViewContainersKey, viewContainersWorkspaceStateKey: AuxiliaryBarPart.viewContainersWorkspaceStateKey, icon: false, orientation: ActionsOrientation.HORIZONTAL, diff --git a/src/vs/sessions/browser/parts/chatBarPart.ts b/src/vs/sessions/browser/parts/chatBarPart.ts index 3a1b3be4ce6a5..9a74bb7021bd0 100644 --- a/src/vs/sessions/browser/parts/chatBarPart.ts +++ b/src/vs/sessions/browser/parts/chatBarPart.ts @@ -31,7 +31,7 @@ export class ChatBarPart extends AbstractPaneCompositePart { static readonly activeViewSettingsKey = 'workbench.chatbar.activepanelid'; static readonly pinnedViewsKey = 'workbench.chatbar.pinnedPanels'; - static readonly placeholdeViewContainersKey = 'workbench.chatbar.placeholderPanels'; + static readonly placeholderViewContainersKey = 'workbench.chatbar.placeholderPanels'; static readonly viewContainersWorkspaceStateKey = 'workbench.chatbar.viewContainersWorkspaceState'; // Use the side bar dimensions @@ -120,7 +120,7 @@ export class ChatBarPart extends AbstractPaneCompositePart { return { partContainerClass: 'chatbar', pinnedViewContainersKey: ChatBarPart.pinnedViewsKey, - placeholderViewContainersKey: ChatBarPart.placeholdeViewContainersKey, + placeholderViewContainersKey: ChatBarPart.placeholderViewContainersKey, viewContainersWorkspaceStateKey: ChatBarPart.viewContainersWorkspaceStateKey, icon: false, orientation: ActionsOrientation.HORIZONTAL, diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index d9b4e673d1ac7..3ba1a8bb2cc0c 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -560,7 +560,7 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { const notificationsCenter = this._register(instantiationService.createInstance(NotificationsCenter, this.mainContainer, notificationService.model)); const notificationsToasts = this._register(instantiationService.createInstance(NotificationsToasts, this.mainContainer, notificationService.model)); this._register(instantiationService.createInstance(NotificationsAlerts, notificationService.model)); - const notificationsStatus = instantiationService.createInstance(NotificationsStatus, notificationService.model); + const notificationsStatus = this._register(instantiationService.createInstance(NotificationsStatus, notificationService.model)); // Visibility this._register(notificationsCenter.onDidChangeVisibility(() => { diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index 3b502a1a93980..ca42f1b451575 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -5,7 +5,7 @@ import * as dom from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { Emitter } from '../../../../base/common/event.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; @@ -41,6 +41,7 @@ export class NewChatContextAttachments extends Disposable { private readonly _attachedContext: IChatRequestVariableEntry[] = []; private _container: HTMLElement | undefined; + private readonly _renderDisposables = this._register(new DisposableStore()); private readonly _onDidChangeContext = this._register(new Emitter()); readonly onDidChangeContext = this._onDidChangeContext.event; @@ -72,6 +73,7 @@ export class NewChatContextAttachments extends Disposable { return; } + this._renderDisposables.clear(); dom.clearNode(this._container); if (this._attachedContext.length === 0) { @@ -92,7 +94,7 @@ export class NewChatContextAttachments extends Disposable { removeButton.tabIndex = 0; removeButton.role = 'button'; dom.append(removeButton, renderIcon(Codicon.close)); - this._register(dom.addDisposableListener(removeButton, dom.EventType.CLICK, (e) => { + this._renderDisposables.add(dom.addDisposableListener(removeButton, dom.EventType.CLICK, (e) => { e.stopPropagation(); this._removeAttachment(entry.id); })); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts b/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts index d8618d36a1b76..eb36b915ad62d 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { autorun, derivedOpts } from '../../../../base/common/observable.js'; +import { autorun, derivedOpts, IReader } from '../../../../base/common/observable.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { isEqual } from '../../../../base/common/resources.js'; @@ -68,7 +68,7 @@ export class SessionsAuxiliaryBarContribution extends Disposable { return; } - const hasChangesAfterTurn = this.hasSessionChanges(activeSessionResource); + const hasChangesAfterTurn = this.hasSessionChanges(activeSessionResource, reader); if (!pendingTurnState.hadChangesBeforeSend && hasChangesAfterTurn) { this.layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); } @@ -84,19 +84,19 @@ export class SessionsAuxiliaryBarContribution extends Disposable { return; } - const hasChanges = this.hasSessionChanges(sessionResource); + const hasChanges = this.hasSessionChanges(sessionResource, reader); this.syncAuxiliaryBarVisibility(hasChanges); })); } - private hasSessionChanges(sessionResource: URI): boolean { + private hasSessionChanges(sessionResource: URI, reader?: IReader): boolean { const isBackgroundSession = getChatSessionType(sessionResource) === AgentSessionProviders.Background; let editingSessionCount = 0; if (!isBackgroundSession) { - const sessions = this.chatEditingService.editingSessionsObs.read(undefined); + const sessions = this.chatEditingService.editingSessionsObs.read(reader); const editingSession = sessions.find(candidate => isEqual(candidate.chatSessionResource, sessionResource)); - editingSessionCount = editingSession ? editingSession.entries.read(undefined).length : 0; + editingSessionCount = editingSession ? editingSession.entries.read(reader).length : 0; } const session = this.agentSessionsService.getSession(sessionResource); diff --git a/src/vs/sessions/electron-browser/parts/titlebarPart.ts b/src/vs/sessions/electron-browser/parts/titlebarPart.ts index 8c2c0597b633d..a222e9dacfa8c 100644 --- a/src/vs/sessions/electron-browser/parts/titlebarPart.ts +++ b/src/vs/sessions/electron-browser/parts/titlebarPart.ts @@ -51,6 +51,10 @@ export class NativeTitlebarPart extends TitlebarPart { this.cachedWindowControlStyles.bgColor !== this.element.style.backgroundColor || this.cachedWindowControlStyles.fgColor !== this.element.style.color ) { + this.cachedWindowControlStyles = { + bgColor: this.element.style.backgroundColor, + fgColor: this.element.style.color + }; this.nativeHostService.updateWindowControls({ targetWindowId: getWindowId(getWindow(this.element)), backgroundColor: this.element.style.backgroundColor, From 9bf4d9194e0293b7355e29523a1dc577a60abdda Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 19 Feb 2026 15:09:14 -0600 Subject: [PATCH 29/32] enable `askQuestions` to be invoked by subagent (#296361) enable askQuestions to be invoked by subagent --- .../contrib/chat/common/tools/builtinTools/runSubagentTool.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index 7c3692d02c408..4e9eacf9ae365 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -39,7 +39,6 @@ import { VSCodeToolReference, } from '../languageModelToolsService.js'; import { ManageTodoListToolToolId } from './manageTodoListTool.js'; -import { AskQuestionsToolId } from './askQuestionsTool.js'; import { createToolSimpleTextResult } from './toolHelpers.js'; const BaseModelDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. This tool is good at researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries, use this agent to perform the search for you. @@ -242,7 +241,6 @@ export class RunSubagentTool extends Disposable implements IToolImpl { modeTools[RunSubagentTool.Id] = false; modeTools[ManageTodoListToolToolId] = false; modeTools['copilot_askQuestions'] = false; - modeTools[AskQuestionsToolId] = false; } const variableSet = new ChatRequestVariableSet(); From 8fa78fc8954a736d9e75ebaa79ac891e5d2fcd47 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 19 Feb 2026 15:13:24 -0600 Subject: [PATCH 30/32] add tips for thinking style/phrases (#296373) fixes #296365 --- .../contrib/chat/browser/chatTipService.ts | 28 ++++++++ .../chat/test/browser/chatTipService.test.ts | 70 ++++++++++++++++++- 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index a158dcd8f453a..a6635f79a2e91 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -144,6 +144,11 @@ export interface ITipDefinition { /** If true, exclude the tip until the async file check completes. Default: false. */ readonly excludeUntilChecked?: boolean; }; + /** + * Setting keys that, if changed from their default value, make this tip ineligible. + * The tip won't be shown if the user has already customized the setting it describes. + */ + readonly excludeWhenSettingsChanged?: string[]; } /** @@ -268,6 +273,20 @@ const TIP_CATALOG: ITipDefinition[] = [ enabledCommands: ['workbench.action.chat.sendToNewChat'], excludeWhenCommandsExecuted: ['workbench.action.chat.sendToNewChat'], }, + { + id: 'tip.thinkingStyle', + message: localize('tip.thinkingStyle', "Tip: Change how the agent's reasoning is displayed with the [thinking style](command:workbench.action.openSettings?%5B%22chat.agent.thinking.style%22%5D) setting."), + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + enabledCommands: ['workbench.action.openSettings'], + excludeWhenSettingsChanged: ['chat.agent.thinking.style'], + }, + { + id: 'tip.thinkingPhrases', + message: localize('tip.thinkingPhrases', "Tip: Customize the loading messages shown while the agent works with [thinking phrases](command:workbench.action.openSettings?%5B%22chat.agent.thinking.phrases%22%5D)."), + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + enabledCommands: ['workbench.action.openSettings'], + excludeWhenSettingsChanged: ['chat.agent.thinking.phrases'], + }, ]; /** @@ -839,6 +858,15 @@ export class ChatTipService extends Disposable implements IChatTipService { return false; } } + if (tip.excludeWhenSettingsChanged) { + for (const key of tip.excludeWhenSettingsChanged) { + const inspected = this._configurationService.inspect(key); + if (inspected.userValue !== undefined || inspected.userLocalValue !== undefined || inspected.userRemoteValue !== undefined || inspected.workspaceValue !== undefined || inspected.workspaceFolderValue !== undefined) { + this._logService.debug('#ChatTips: tip excluded because setting was changed from default', tip.id, key); + return false; + } + } + } this._logService.debug('#ChatTips: tip is eligible', tip.id); return true; } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index cc0b842d749c3..df645e77bea4d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -15,7 +15,7 @@ import { MockContextKeyService } from '../../../../../platform/keybinding/test/c import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; -import { ChatTipService, ITipDefinition, TipEligibilityTracker } from '../../browser/chatTipService.js'; +import { ChatTipService, IChatTip, ITipDefinition, TipEligibilityTracker } from '../../browser/chatTipService.js'; import { AgentFileType, IPromptPath, IPromptsService, IResolvedAgentFile, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { URI } from '../../../../../base/common/uri.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; @@ -874,6 +874,74 @@ suite('ChatTipService', () => { } }); + function findTipById(service: ChatTipService, tipId: string, ckService: MockContextKeyServiceWithRulesMatching = contextKeyService): IChatTip | undefined { + for (let i = 0; i < 100; i++) { + const tip = service.getWelcomeTip(ckService); + if (!tip) { + return undefined; + } + if (tip.id === tipId) { + return tip; + } + service.dismissTip(); + } + return undefined; + } + + function assertTipNeverShown(service: ChatTipService, tipId: string, ckService: MockContextKeyServiceWithRulesMatching = contextKeyService): void { + for (let i = 0; i < 100; i++) { + const tip = service.getWelcomeTip(ckService); + if (!tip) { + break; + } + assert.notStrictEqual(tip.id, tipId, `${tipId} should not be shown`); + service.dismissTip(); + } + } + + for (const { tipId, settingKey } of [ + { tipId: 'tip.thinkingStyle', settingKey: 'chat.agent.thinking.style' }, + { tipId: 'tip.thinkingPhrases', settingKey: 'chat.agent.thinking.phrases' }, + ]) { + test(`shows ${tipId} with correct setting link when setting is at default`, async () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); + await new Promise(r => queueMicrotask(r)); + + const tip = findTipById(service, tipId); + assert.ok(tip, `Should show ${tipId} when setting is at default`); + assert.ok(tip.content.value.includes(settingKey), `Tip should reference ${settingKey}`); + assert.ok(tip.enabledCommands?.includes('workbench.action.openSettings'), 'Tip should enable the openSettings command'); + }); + + test(`excludes ${tipId} when setting has been changed from default`, async () => { + configurationService.setUserConfiguration(settingKey, 'changed'); + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); + await new Promise(r => queueMicrotask(r)); + + assertTipNeverShown(service, tipId); + }); + } + + test('excludeWhenSettingsChanged checks workspaceValue', () => { + const workspaceConfigService = new TestConfigurationService(); + const originalInspect = workspaceConfigService.inspect.bind(workspaceConfigService); + workspaceConfigService.inspect = (key: string, overrides?: any) => { + if (key === 'chat.agent.thinking.style') { + return { ...originalInspect(key, overrides), userValue: undefined, userLocalValue: undefined, workspaceValue: 'compact' } as unknown as T; + } + return originalInspect(key, overrides); + }; + configurationService = workspaceConfigService; + instantiationService.stub(IConfigurationService, configurationService); + + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); + + assertTipNeverShown(service, 'tip.thinkingStyle'); + }); + test('re-checks agent file exclusion when onDidChangeCustomAgents fires', async () => { const agentChangeEmitter = testDisposables.add(new Emitter()); let agentFiles: IPromptPath[] = []; From a0ff9d162e918a02d27711eedcfe9623faa58909 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:15:41 +0000 Subject: [PATCH 31/32] Add notification keyboard interaction hints to editor accessibility help dialog (#296367) * Initial plan * Add notification focus/primary action info to editor accessibility help dialog Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> --- src/vs/editor/common/standaloneStrings.ts | 1 + .../contrib/accessibility/browser/editorAccessibilityHelp.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/vs/editor/common/standaloneStrings.ts b/src/vs/editor/common/standaloneStrings.ts index 092a0769fc0b8..169eb7819592c 100644 --- a/src/vs/editor/common/standaloneStrings.ts +++ b/src/vs/editor/common/standaloneStrings.ts @@ -35,6 +35,7 @@ export namespace AccessibilityHelpNLS { export const listSignalSounds = nls.localize("listSignalSoundsCommand", "Run the command: List Signal Sounds for an overview of all sounds and their current status."); export const listAlerts = nls.localize("listAnnouncementsCommand", "Run the command: List Signal Announcements for an overview of announcements and their current status."); export const announceCursorPosition = nls.localize("announceCursorPosition", "Run the command: Announce Cursor Position{0} to hear the current line and column.", ''); + export const focusNotifications = nls.localize("focusNotifications", "Focus notification toasts{0} to navigate them with the keyboard. Accept the primary action of a focused notification{1}.", '', ''); export const quickChat = nls.localize("quickChatCommand", "Toggle quick chat{0} to open or close a chat session.", ''); export const startInlineChat = nls.localize("startInlineChatCommand", "Start inline chat{0} to create an in editor chat session.", ''); export const startDebugging = nls.localize('debug.startDebugging', "The Debug: Start Debugging command{0} will start a debug session.", ''); diff --git a/src/vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp.ts b/src/vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp.ts index 75f42ba85795d..a2613df06db24 100644 --- a/src/vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp.ts @@ -91,6 +91,7 @@ class EditorAccessibilityHelpProvider extends Disposable implements IAccessibleV content.push(AccessibilityHelpNLS.listSignalSounds); content.push(AccessibilityHelpNLS.listAlerts); content.push(AccessibilityHelpNLS.announceCursorPosition); + content.push(AccessibilityHelpNLS.focusNotifications); const chatCommandInfo = getChatCommandInfo(this._keybindingService, this._contextKeyService); From 67846c0ad8317f62c056fa1f3c7919f4a8b743d8 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 19 Feb 2026 23:17:13 +0100 Subject: [PATCH 32/32] fix add and managing models actions and show filter only when there are more than 10 models (#296377) * fix add and managing models actions and show filter only when there are more than 10 models * show curated only in local --- .../contrib/chat/browser/newChatViewPane.ts | 1 + .../browser/widget/input/chatInputPart.ts | 3 +- .../browser/widget/input/chatModelPicker.ts | 359 ++++++++++-------- .../widget/input/modelPickerActionItem.ts | 5 + .../widget/input/chatModelPicker.test.ts | 30 +- 5 files changed, 237 insertions(+), 161 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index fe2b8209e7049..eae4d116d1b09 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -470,6 +470,7 @@ class NewChatWidget extends Disposable { }, getModels: () => this._getAvailableModels(), canManageModels: () => true, + showCuratedModels: () => this._localMode === 'workspace', }; const pickerOptions: IChatInputPickerOptions = { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 3c47743a275b8..9be84c2e65b50 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -2182,7 +2182,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.renderAttachedContext(); }, getModels: () => this.getModels(), - canManageModels: () => !this.getCurrentSessionType() + canManageModels: () => !this.getCurrentSessionType(), + showCuratedModels: () => !this.getCurrentSessionType() }; return this.modelWidget = this.instantiationService.createInstance(EnhancedModelPickerActionItem, action, itemDelegate, pickerOptions); } else if (action.id === OpenModePickerAction.ID && action instanceof MenuItemAction) { 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 9d66c0bd04472..71fcfc6db1ac2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -14,7 +14,7 @@ import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { localize } from '../../../../../../nls.js'; -import { ActionListItemKind, IActionListItem, IActionListOptions } from '../../../../../../platform/actionWidget/browser/actionList.js'; +import { ActionListItemKind, IActionListItem } from '../../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionWidgetDropdownAction } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; @@ -23,7 +23,7 @@ import { ITelemetryService } from '../../../../../../platform/telemetry/common/t import { TelemetryTrustedValue } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { MANAGE_CHAT_COMMAND_ID } from '../../../common/constants.js'; import { IModelControlEntry, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../common/languageModels.js'; -import { IChatEntitlementService, isProUser } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { ChatEntitlement, IChatEntitlementService, isProUser } from '../../../../../services/chat/common/chatEntitlementService.js'; import * as semver from '../../../../../../base/common/semver/semver.js'; import { IModelPickerDelegate } from './modelPickerActionItem.js'; import { IUpdateService, StateType } from '../../../../../../platform/update/common/update.js'; @@ -128,169 +128,194 @@ export function buildModelPickerItems( currentVSCodeVersion: string, updateStateType: StateType, onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void, - commandService: ICommandService, upgradePlanUrl: string | undefined, + commandService: ICommandService, + chatEntitlementService: IChatEntitlementService, ): IActionListItem[] { const items: IActionListItem[] = []; + let otherModels: ILanguageModelChatMetadataAndIdentifier[] = []; + if (models.length === 0) { + items.push(createModelItem({ + id: 'auto', + enabled: true, + checked: true, + class: undefined, + tooltip: localize('chat.modelPicker.auto', "Auto"), + label: localize('chat.modelPicker.auto', "Auto"), + run: () => { } + })); + } else { + // Collect all available models into lookup maps + const allModelsMap = new Map(); + const modelsByMetadataId = new Map(); + for (const model of models) { + allModelsMap.set(model.identifier, model); + modelsByMetadataId.set(model.metadata.id, model); + } - // Collect all available models into lookup maps - const allModelsMap = new Map(); - const modelsByMetadataId = new Map(); - for (const model of models) { - allModelsMap.set(model.identifier, model); - modelsByMetadataId.set(model.metadata.id, model); - } + const placed = new Set(); - const placed = new Set(); + const markPlaced = (identifierOrId: string, metadataId?: string) => { + placed.add(identifierOrId); + if (metadataId) { + placed.add(metadataId); + } + }; - const markPlaced = (identifierOrId: string, metadataId?: string) => { - placed.add(identifierOrId); - if (metadataId) { - placed.add(metadataId); - } - }; + const resolveModel = (id: string) => allModelsMap.get(id) ?? modelsByMetadataId.get(id); - const resolveModel = (id: string) => allModelsMap.get(id) ?? modelsByMetadataId.get(id); + const getUnavailableReason = (entry: IModelControlEntry): 'upgrade' | 'update' | 'admin' => { + if (!isProUser) { + return 'upgrade'; + } + if (entry.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { + return 'update'; + } + return 'admin'; + }; - const getUnavailableReason = (entry: IModelControlEntry): 'upgrade' | 'update' | 'admin' => { - if (!isProUser) { - return 'upgrade'; - } - if (entry.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - return 'update'; + // --- 1. Auto --- + const autoModel = models.find(m => m.metadata.id === 'auto' && m.metadata.vendor === 'copilot'); + if (autoModel) { + markPlaced(autoModel.identifier, autoModel.metadata.id); + items.push(createModelItem(createModelAction(autoModel, selectedModelId, onSelect), autoModel)); } - return 'admin'; - }; - // --- 1. Auto --- - const autoModel = models.find(m => m.metadata.id === 'auto' && m.metadata.vendor === 'copilot'); - if (autoModel) { - markPlaced(autoModel.identifier, autoModel.metadata.id); - items.push(createModelItem(createModelAction(autoModel, selectedModelId, onSelect), autoModel)); - } - - // --- 2. Promoted section (selected + recently used + featured) --- - type PromotedItem = - | { kind: 'available'; model: ILanguageModelChatMetadataAndIdentifier } - | { kind: 'unavailable'; entry: IModelControlEntry; reason: 'upgrade' | 'update' | 'admin' }; + // --- 2. Promoted section (selected + recently used + featured) --- + type PromotedItem = + | { kind: 'available'; model: ILanguageModelChatMetadataAndIdentifier } + | { kind: 'unavailable'; entry: IModelControlEntry; reason: 'upgrade' | 'update' | 'admin' }; - const promotedItems: PromotedItem[] = []; + const promotedItems: PromotedItem[] = []; - // Try to place a model by id. Returns true if handled. - const tryPlaceModel = (id: string): boolean => { - if (placed.has(id)) { - return false; - } - const model = resolveModel(id); - if (model && !placed.has(model.identifier)) { - markPlaced(model.identifier, model.metadata.id); - const entry = controlModels[model.metadata.id]; - if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - promotedItems.push({ kind: 'unavailable', entry, reason: 'update' }); - } else { - promotedItems.push({ kind: 'available', model }); + // Try to place a model by id. Returns true if handled. + const tryPlaceModel = (id: string): boolean => { + if (placed.has(id)) { + return false; } - return true; - } - if (!model) { - const entry = controlModels[id]; - if (entry) { - markPlaced(id); - promotedItems.push({ kind: 'unavailable', entry, reason: getUnavailableReason(entry) }); + const model = resolveModel(id); + if (model && !placed.has(model.identifier)) { + markPlaced(model.identifier, model.metadata.id); + const entry = controlModels[model.metadata.id]; + if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { + promotedItems.push({ kind: 'unavailable', entry, reason: 'update' }); + } else { + promotedItems.push({ kind: 'available', model }); + } return true; } - } - return false; - }; - - // Selected model - if (selectedModelId && selectedModelId !== autoModel?.identifier) { - tryPlaceModel(selectedModelId); - } + if (!model) { + const entry = controlModels[id]; + if (entry) { + markPlaced(id); + promotedItems.push({ kind: 'unavailable', entry, reason: getUnavailableReason(entry) }); + return true; + } + } + return false; + }; - // Recently used models - for (const id of recentModelIds) { - tryPlaceModel(id); - } + // Selected model + if (selectedModelId && selectedModelId !== autoModel?.identifier) { + tryPlaceModel(selectedModelId); + } - // Featured models from control manifest - for (const entry of Object.values(controlModels)) { - if (!entry.featured || placed.has(entry.id)) { - continue; + // Recently used models + for (const id of recentModelIds) { + tryPlaceModel(id); } - const model = resolveModel(entry.id); - if (model && !placed.has(model.identifier)) { - markPlaced(model.identifier, model.metadata.id); - if (entry.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - promotedItems.push({ kind: 'unavailable', entry, reason: 'update' }); - } else { - promotedItems.push({ kind: 'available', model }); + + // Featured models from control manifest + for (const entry of Object.values(controlModels)) { + if (!entry.featured || placed.has(entry.id)) { + continue; + } + const model = resolveModel(entry.id); + if (model && !placed.has(model.identifier)) { + markPlaced(model.identifier, model.metadata.id); + if (entry.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { + promotedItems.push({ kind: 'unavailable', entry, reason: 'update' }); + } else { + promotedItems.push({ kind: 'available', model }); + } + } else if (!model) { + markPlaced(entry.id); + promotedItems.push({ kind: 'unavailable', entry, reason: getUnavailableReason(entry) }); } - } else if (!model) { - markPlaced(entry.id); - promotedItems.push({ kind: 'unavailable', entry, reason: getUnavailableReason(entry) }); } - } - // Render promoted section: sorted alphabetically by name - if (promotedItems.length > 0) { - promotedItems.sort((a, b) => { - const aName = a.kind === 'available' ? a.model.metadata.name : a.entry.label; - const bName = b.kind === 'available' ? b.model.metadata.name : b.entry.label; - return aName.localeCompare(bName); - }); + // Render promoted section: sorted alphabetically by name + if (promotedItems.length > 0) { + promotedItems.sort((a, b) => { + const aName = a.kind === 'available' ? a.model.metadata.name : a.entry.label; + const bName = b.kind === 'available' ? b.model.metadata.name : b.entry.label; + return aName.localeCompare(bName); + }); - items.push({ kind: ActionListItemKind.Separator }); - for (const item of promotedItems) { - if (item.kind === 'available') { - items.push(createModelItem(createModelAction(item.model, selectedModelId, onSelect), item.model)); - } else { - items.push(createUnavailableModelItem(item.entry, item.reason, upgradePlanUrl, updateStateType)); + items.push({ kind: ActionListItemKind.Separator }); + for (const item of promotedItems) { + if (item.kind === 'available') { + items.push(createModelItem(createModelAction(item.model, selectedModelId, onSelect), item.model)); + } else { + items.push(createUnavailableModelItem(item.entry, item.reason, upgradePlanUrl, updateStateType)); + } } } - } - // --- 3. Other Models (collapsible) --- - const otherModels = models - .filter(m => !placed.has(m.identifier) && !placed.has(m.metadata.id)) - .sort((a, b) => { - const aCopilot = a.metadata.vendor === 'copilot' ? 0 : 1; - const bCopilot = b.metadata.vendor === 'copilot' ? 0 : 1; - if (aCopilot !== bCopilot) { - return aCopilot - bCopilot; - } - const vendorCmp = a.metadata.vendor.localeCompare(b.metadata.vendor); - return vendorCmp !== 0 ? vendorCmp : a.metadata.name.localeCompare(b.metadata.name); - }); + // --- 3. Other Models (collapsible) --- + otherModels = models + .filter(m => !placed.has(m.identifier) && !placed.has(m.metadata.id)) + .sort((a, b) => { + const aCopilot = a.metadata.vendor === 'copilot' ? 0 : 1; + const bCopilot = b.metadata.vendor === 'copilot' ? 0 : 1; + if (aCopilot !== bCopilot) { + return aCopilot - bCopilot; + } + const vendorCmp = a.metadata.vendor.localeCompare(b.metadata.vendor); + return vendorCmp !== 0 ? vendorCmp : a.metadata.name.localeCompare(b.metadata.name); + }); - if (otherModels.length > 0) { - items.push({ kind: ActionListItemKind.Separator }); - items.push({ - item: { - id: 'otherModels', - enabled: true, - checked: false, - class: undefined, - tooltip: localize('chat.modelPicker.otherModels', "Other Models"), + if (otherModels.length > 0) { + items.push({ kind: ActionListItemKind.Separator }); + items.push({ + item: { + id: 'otherModels', + enabled: true, + checked: false, + class: undefined, + tooltip: localize('chat.modelPicker.otherModels', "Other Models"), + label: localize('chat.modelPicker.otherModels', "Other Models"), + run: () => { /* toggle handled by isSectionToggle */ } + }, + kind: ActionListItemKind.Action, label: localize('chat.modelPicker.otherModels', "Other Models"), - run: () => { /* toggle handled by isSectionToggle */ } - }, - kind: ActionListItemKind.Action, - label: localize('chat.modelPicker.otherModels', "Other Models"), - group: { title: '', icon: Codicon.chevronDown }, - hideIcon: false, - section: ModelPickerSection.Other, - isSectionToggle: true, - }); - for (const model of otherModels) { - const entry = controlModels[model.metadata.id] ?? controlModels[model.identifier]; - if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - items.push(createUnavailableModelItem(entry, 'update', upgradePlanUrl, updateStateType, ModelPickerSection.Other)); - } else { - items.push(createModelItem(createModelAction(model, selectedModelId, onSelect, ModelPickerSection.Other), model)); + group: { title: '', icon: Codicon.chevronDown }, + hideIcon: false, + section: ModelPickerSection.Other, + isSectionToggle: true, + }); + for (const model of otherModels) { + const entry = controlModels[model.metadata.id] ?? controlModels[model.identifier]; + if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { + items.push(createUnavailableModelItem(entry, 'update', upgradePlanUrl, updateStateType, ModelPickerSection.Other)); + } else { + items.push(createModelItem(createModelAction(model, selectedModelId, onSelect, ModelPickerSection.Other), model)); + } } } + } + if ( + chatEntitlementService.entitlement === ChatEntitlement.Free || + chatEntitlementService.entitlement === ChatEntitlement.Pro || + chatEntitlementService.entitlement === ChatEntitlement.ProPlus || + chatEntitlementService.entitlement === ChatEntitlement.Business || + chatEntitlementService.entitlement === ChatEntitlement.Enterprise || + chatEntitlementService.isInternal + ) { + if (!otherModels.length) { + items.push({ kind: ActionListItemKind.Separator }); + } items.push({ item: { id: 'manageModels', @@ -306,7 +331,37 @@ export function buildModelPickerItems( label: localize('chat.manageModels', "Manage Models..."), group: { title: '', icon: Codicon.settingsGear }, hideIcon: false, - section: ModelPickerSection.Other, + section: otherModels.length ? ModelPickerSection.Other : undefined, + className: 'manage-models-link', + showAlways: true, + }); + } + + // Add sign-in / upgrade option if entitlement is anonymous / free / new user + const isNewOrAnonymousUser = !chatEntitlementService.sentiment.installed || + chatEntitlementService.entitlement === ChatEntitlement.Available || + chatEntitlementService.anonymous || + chatEntitlementService.entitlement === ChatEntitlement.Unknown; + if (isNewOrAnonymousUser || chatEntitlementService.entitlement === ChatEntitlement.Free) { + items.push({ kind: ActionListItemKind.Separator }); + items.push({ + item: { + id: 'moreModels', + enabled: true, + checked: false, + class: 'more-models-action', + tooltip: isNewOrAnonymousUser ? localize('chat.moreModels.tooltip', "Add Language Models") : localize('chat.morePremiumModels.tooltip', "Add Premium Models"), + label: isNewOrAnonymousUser ? localize('chat.moreModels', "Add Language Models") : localize('chat.morePremiumModels', "Add Premium Models"), + icon: Codicon.add, + run: () => { + const commandId = isNewOrAnonymousUser ? 'workbench.action.chat.triggerSetup' : 'workbench.action.chat.upgradePlan'; + commandService.executeCommand(commandId); + } + }, + kind: ActionListItemKind.Action, + label: isNewOrAnonymousUser ? localize('chat.moreModels', "Add Language Models") : localize('chat.morePremiumModels', "Add Premium Models"), + group: { title: '', icon: Codicon.add }, + hideIcon: false, className: 'manage-models-link', showAlways: true, }); @@ -368,17 +423,6 @@ function createUnavailableModelItem( }; } -/** - * Returns the ActionList options for the model picker (filter + collapsed sections). - */ -function getModelPickerListOptions(): IActionListOptions { - return { - showFilter: true, - collapsedByDefault: new Set([ModelPickerSection.Other]), - minWidth: 300, - }; -} - export type ModelPickerBadge = 'info' | 'warning'; /** @@ -482,12 +526,16 @@ export class ModelPickerWidget extends Disposable { this._onDidChangeSelection.fire(model); }; + const models = this._delegate.getModels(); + const showCuratedModels = this._delegate.showCuratedModels?.() ?? true; const isPro = isProUser(this._entitlementService.entitlement); - const manifest = this._languageModelsService.getModelsControlManifest(); - const controlModelsForTier = isPro ? manifest.paid : manifest.free; - + let controlModelsForTier: IStringDictionary = {}; + if (showCuratedModels) { + const manifest = this._languageModelsService.getModelsControlManifest(); + controlModelsForTier = isPro ? manifest.paid : manifest.free; + } const items = buildModelPickerItems( - this._delegate.getModels(), + models, this._selectedModel?.identifier, this._languageModelsService.getRecentlyUsedModelIds(), controlModelsForTier, @@ -495,11 +543,16 @@ export class ModelPickerWidget extends Disposable { this._productService.version, this._updateService.state.type, onSelect, - this._commandService, this._productService.defaultChatAgent?.upgradePlanUrl, + this._commandService, + this._entitlementService ); - const listOptions = getModelPickerListOptions(); + const listOptions = { + showFilter: models.length >= 10, + collapsedByDefault: new Set([ModelPickerSection.Other]), + minWidth: 300, + }; const previouslyFocusedElement = dom.getActiveElement(); const delegate = { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts index 525e9ee8236ef..7709101f30273 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts @@ -30,6 +30,11 @@ export interface IModelPickerDelegate { setModel(model: ILanguageModelChatMetadataAndIdentifier): void; getModels(): ILanguageModelChatMetadataAndIdentifier[]; canManageModels(): boolean; + /** + * Whether to show curated models from the control manifest (featured, unavailable, upgrade prompts, etc.). + * Defaults to `true`. + */ + showCuratedModels?(): boolean; } type ChatModelChangeClassification = { 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 1d3206b200dfc..7e8c1835c3d3f 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 @@ -12,6 +12,14 @@ import { ICommandService } from '../../../../../../../platform/commands/common/c import { StateType } from '../../../../../../../platform/update/common/update.js'; import { buildModelPickerItems } from '../../../../browser/widget/input/chatModelPicker.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, IModelControlEntry } from '../../../../common/languageModels.js'; +import { ChatEntitlement, IChatEntitlementService } from '../../../../../../services/chat/common/chatEntitlementService.js'; + +const stubChatEntitlementService: Partial = { + entitlement: ChatEntitlement.Pro, + sentiment: { installed: true } as IChatEntitlementService['sentiment'], + isInternal: false, + anonymous: false, +}; function createModel(id: string, name: string, vendor = 'copilot'): ILanguageModelChatMetadataAndIdentifier { return { @@ -75,8 +83,9 @@ function callBuild( opts.currentVSCodeVersion ?? '1.100.0', opts.updateStateType ?? StateType.Idle, onSelect, - stubCommandService, opts.upgradePlanUrl, + stubCommandService, + stubChatEntitlementService as IChatEntitlementService, ); } @@ -92,15 +101,21 @@ suite('buildModelPickerItems', () => { assert.strictEqual(actions[0].label, 'Auto'); }); - test('empty models list produces no items', () => { + test('empty models list produces auto and manage models entries', () => { const items = callBuild([]); - assert.strictEqual(items.length, 0); + const actions = getActionItems(items); + assert.strictEqual(actions.length, 2); + assert.strictEqual(actions[0].label, 'Auto'); + assert.strictEqual(actions[1].item?.id, 'manageModels'); }); - test('only auto model produces single item with no separators', () => { + test('only auto model produces auto and manage models with separator', () => { const items = callBuild([createAutoModel()]); - assert.strictEqual(getActionItems(items).length, 1); - assert.strictEqual(getSeparatorCount(items), 0); + const actions = getActionItems(items); + assert.strictEqual(actions.length, 2); + assert.strictEqual(actions[0].label, 'Auto'); + assert.strictEqual(actions[1].item?.id, 'manageModels'); + assert.strictEqual(getSeparatorCount(items), 1); }); test('selected model appears in promoted section', () => { @@ -423,8 +438,9 @@ suite('buildModelPickerItems', () => { '1.100.0', StateType.Idle, onSelect, - stubCommandService, undefined, + stubCommandService, + stubChatEntitlementService as IChatEntitlementService, ); const gptItem = getActionItems(items).find(a => a.label === 'GPT-4o'); assert.ok(gptItem?.item);