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. 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/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/eslint.config.js b/eslint.config.js index bc3c698a129f2..93a8a1b7b4396 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2112,6 +2112,29 @@ 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/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'], 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..b0383ed459cbc --- /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.dirname(import.meta.resolve('typescript').replace('file://', '')); + 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*=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'; 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) { 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/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 10cf26dc73fae..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'; @@ -89,6 +92,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,7 +332,7 @@ 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; @@ -358,11 +365,24 @@ 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; 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, @@ -371,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, @@ -499,7 +520,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 +554,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 +581,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 +631,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 +661,43 @@ 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._showAbove ? 'bottom' : '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; } } @@ -617,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; @@ -639,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; @@ -723,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; } @@ -742,6 +882,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 +963,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..3e43f12b1957c 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'); @@ -63,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) => { @@ -74,6 +75,7 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { visibleContext.reset(); this._onWidgetClosed(didCancel); }, + get anchorPosition() { return list.anchorPosition; }, }, container, false); } @@ -89,6 +91,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,6 +119,9 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { this._list.value = list; if (this._list.value) { + if (this._list.value.filterContainer) { + widget.appendChild(this._list.value.filterContainer); + } widget.appendChild(this._list.value.domNode); } else { throw new Error('List has no value'); @@ -137,16 +154,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 +266,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/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/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/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/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); 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/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index a8c69daa78eb9..eae4d116d1b09 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -467,10 +467,10 @@ class NewChatWidget extends Disposable { currentModel: this._currentLanguageModel, setModel: (model: ILanguageModelChatMetadataAndIdentifier) => { this._currentLanguageModel.set(model, undefined); - this.languageModelsService.addToRecentlyUsedList(model); }, getModels: () => this._getAvailableModels(), canManageModels: () => true, + showCuratedModels: () => this._localMode === 'workspace', }; const pickerOptions: IChatInputPickerOptions = { 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/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, 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/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, 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, 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/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); 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/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..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 }); } @@ -719,7 +719,6 @@ export class AgentSessionsDataSource implements IAsyncDataSource= startOfToday) { @@ -764,12 +760,11 @@ 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 }], [AgentSessionSection.Older, { section: AgentSessionSection.Older, label: AgentSessionSectionLabels[AgentSessionSection.Older], sessions: olderSessions }], - [AgentSessionSection.Archived, { section: AgentSessionSection.Archived, label: localize('agentSessions.archivedSectionWithCount', "Archived ({0})", archivedSessions.length), sessions: archivedSessions }], + [AgentSessionSection.Archived, { section: AgentSessionSection.Archived, label: AgentSessionSectionLabels[AgentSessionSection.Archived], sessions: archivedSessions }], ]); } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 88aeddb80f474..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]: { @@ -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/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/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 2e6c540a88fcb..a6635f79a2e91 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'; @@ -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'], + }, ]; /** @@ -560,8 +579,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 +601,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 +847,26 @@ 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; + } + } + 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/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/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/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; 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); 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..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'; @@ -59,6 +57,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'; @@ -187,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; @@ -255,7 +253,6 @@ 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); @@ -2092,6 +2092,9 @@ 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( 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/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index a4afd7702f01f..9be84c2e65b50 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); @@ -2183,9 +2182,10 @@ 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(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 23af9f66d09d5..71fcfc6db1ac2 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'; @@ -13,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'; @@ -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 { IChatEntitlementService, isProUser } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { IModelControlEntry, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../common/languageModels.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'; 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,230 +113,313 @@ 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, + 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 - const allModelsMap = new Map(); - for (const model of models) { - allModelsMap.set(model.identifier, model); - } + const placed = new Set(); - // Build a secondary lookup by metadata.id for flexible matching - const modelsByMetadataId = new Map(); - for (const model of models) { - modelsByMetadataId.set(model.metadata.id, model); - } + const markPlaced = (identifierOrId: string, metadataId?: string) => { + placed.add(identifierOrId); + if (metadataId) { + placed.add(metadataId); + } + }; - // Track which model IDs have been placed in the promoted group - const placed = new Set(); - - // --- 1. Auto --- - const autoModel = models.find(m => m.metadata.id === 'auto' && m.metadata.vendor === 'copilot')!; - // Always mark the auto model as placed - if (autoModel) { - placed.add(autoModel.identifier); - placed.add(autoModel.metadata.id); - const action = createModelAction(autoModel, selectedModelId, onSelect); - items.push(createModelItem(action, autoModel)); - } + const resolveModel = (id: string) => allModelsMap.get(id) ?? modelsByMetadataId.get(id); - // --- 2. Promoted models (recently used + curated, merged & sorted alphabetically) --- - const promotedModels: ILanguageModelChatMetadataAndIdentifier[] = []; - const unavailableCurated: { curated: ICuratedModel; 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 getUnavailableReason = (entry: IModelControlEntry): 'upgrade' | 'update' | 'admin' => { + if (!isProUser) { + return 'upgrade'; + } + if (entry.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { + return 'update'; + } + return 'admin'; + }; - // 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); + // --- 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)); } - } - // 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' }); - } else { - promotedModels.push(model); + // --- 2. Promoted section (selected + recently used + featured) --- + type PromotedItem = + | { kind: 'available'; model: ILanguageModelChatMetadataAndIdentifier } + | { kind: 'unavailable'; entry: IModelControlEntry; reason: 'upgrade' | 'update' | 'admin' }; + + 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; } - } 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' }); + 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; } - } - } + if (!model) { + const entry = controlModels[id]; + if (entry) { + markPlaced(id); + promotedItems.push({ kind: 'unavailable', entry, reason: getUnavailableReason(entry) }); + return true; + } + } + return false; + }; - // Sort alphabetically for a stable list - promotedModels.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name)); + // Selected model + if (selectedModelId && selectedModelId !== autoModel?.identifier) { + tryPlaceModel(selectedModelId); + } - 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)); + // Recently used models + for (const id of recentModelIds) { + tryPlaceModel(id); } - // 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 }); - } else { - description = localize('chat.modelPicker.adminEnable', "Contact Admin"); + // 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) }); } + } - 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: 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)); + } } + } + + // --- 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: curated.id, - enabled: false, + id: 'otherModels', + enabled: true, checked: false, class: undefined, - tooltip: curated.label, - label: curated.label, - description: typeof description === 'string' ? description : undefined, - run: () => { } + tooltip: localize('chat.modelPicker.otherModels', "Other Models"), + label: localize('chat.modelPicker.otherModels', "Other Models"), + run: () => { /* toggle handled by isSectionToggle */ } }, kind: ActionListItemKind.Action, - label: curated.label, - description, - disabled: true, - group: { title: '', icon: Codicon.blank }, + label: localize('chat.modelPicker.otherModels', "Other Models"), + group: { title: '', icon: Codicon.chevronDown }, hideIcon: false, - hover: { content: hoverContent }, + 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)); + } + } } } - // --- 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; + 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 }); } - return a.metadata.name.localeCompare(b.metadata.name); - }); - - if (otherModels.length > 0) { - items.push({ - kind: ActionListItemKind.Separator, - }); items.push({ item: { - id: 'otherModels', + id: 'manageModels', 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 */ } + class: 'manage-models-action', + tooltip: localize('chat.manageModels.tooltip', "Manage Language Models"), + label: localize('chat.manageModels', "Manage Models..."), + icon: Codicon.settingsGear, + run: () => { commandService.executeCommand(MANAGE_CHAT_COMMAND_ID); } }, kind: ActionListItemKind.Action, - label: localize('chat.modelPicker.otherModels', "Other Models"), - group: { title: '', icon: Codicon.chevronDown }, + label: localize('chat.manageModels', "Manage Models..."), + group: { title: '', icon: Codicon.settingsGear }, hideIcon: false, - section: ModelPickerSection.Other, - isSectionToggle: true, + section: otherModels.length ? ModelPickerSection.Other : undefined, + className: 'manage-models-link', + showAlways: true, }); - for (const model of otherModels) { - const action = createModelAction(model, selectedModelId, onSelect, ModelPickerSection.Other); - items.push(createModelItem(action, model)); - } + } - // "Manage Models..." entry inside Other Models section, styled as a link + // 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: 'manageModels', + id: 'moreModels', enabled: true, checked: false, - class: 'manage-models-action', - tooltip: localize('chat.manageModels.tooltip', "Manage Language Models"), - label: localize('chat.manageModels', "Manage Models..."), - icon: Codicon.settingsGear, + 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: () => { - commandService.executeCommand(MANAGE_CHAT_COMMAND_ID); + const commandId = isNewOrAnonymousUser ? 'workbench.action.chat.triggerSetup' : 'workbench.action.chat.upgradePlan'; + commandService.executeCommand(commandId); } }, kind: ActionListItemKind.Action, - label: localize('chat.manageModels', "Manage Models..."), - group: { title: '', icon: Codicon.settingsGear }, + label: isNewOrAnonymousUser ? localize('chat.moreModels', "Add Language Models") : localize('chat.morePremiumModels', "Add Premium Models"), + group: { title: '', icon: Codicon.add }, hideIcon: false, - section: ModelPickerSection.Other, className: 'manage-models-link', + showAlways: true, }); } return items; } -/** - * Returns the ActionList options for the model picker (filter + collapsed sections). - */ -function getModelPickerListOptions(): IActionListOptions { +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 your plan]({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 { - showFilter: true, - collapsedByDefault: new Set([ModelPickerSection.Other]), - minWidth: 300, + 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 }, }; } @@ -360,6 +462,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(); } @@ -423,23 +526,33 @@ 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 curatedModels = this._languageModelsService.getCuratedModels(); - const curatedForTier = isPro ? curatedModels.paid : curatedModels.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(), - curatedForTier, + controlModelsForTier, isPro, 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 = { @@ -528,12 +641,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,30 +668,13 @@ 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)}`); 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/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/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..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(); @@ -794,7 +795,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`); @@ -1192,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 c40a1dd2d11e6..e3f759c00bc58 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -376,18 +376,23 @@ export interface ILanguageModelsService { /** * Records that a model was used, updating the recently used list. */ - addToRecentlyUsedList(model: ILanguageModelChatMetadataAndIdentifier): void; + addToRecentlyUsedList(modelIdentifier: string): void; /** - * Returns the curated models from the models control manifest, + * Clears the recently used model list. + */ + clearRecentlyUsedList(): void; + + /** + * 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 +401,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 +501,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 +538,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; @@ -1385,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; @@ -1408,35 +1406,45 @@ export class LanguageModelsService implements ILanguageModelsService { this._saveRecentlyUsedModels(); } + clearRecentlyUsedList(): void { + this._recentlyUsedModelIds = []; + this._saveRecentlyUsedModels(); + } + //#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 }); - - this._curatedModels = { free: [], paid: [] }; - const newIds = new Set(); + private _setModelsControlManifest(response: IChatControlResponse['models']): void { + const free: IStringDictionary = {}; + const paid: IStringDictionary = {}; - 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 +1463,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 +1506,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/common/model/chatProgressTypes/chatQuestionCarouselData.ts b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts new file mode 100644 index 0000000000000..f0d598be6dbbc --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DeferredPromise } from '../../../../../../base/common/async.js'; +import { IChatQuestion, IChatQuestionCarousel } from '../../chatService/chatService.js'; + +/** + * Runtime representation of a question carousel with a {@link DeferredPromise} + * that is resolved when the user submits answers. {@link toJSON} strips the + * completion so only serialisable data is persisted. + */ +export class ChatQuestionCarouselData implements IChatQuestionCarousel { + public readonly kind = 'questionCarousel' as const; + public readonly completion = new DeferredPromise<{ answers: Record | 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..1655b0dac454e --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts @@ -0,0 +1,464 @@ +/*--------------------------------------------------------------------------------------------- + * 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.', + maxLength: 50 + }, + question: { + type: 'string', + description: 'The question text to display to the user. Keep it concise, ideally one sentence.', + maxLength: 200 + }, + 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.', + maxLength: 100 + }, + description: { + type: 'string', + description: 'Optional secondary text shown with the option.', + maxLength: 200 + }, + 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/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/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/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; } ); 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', () => { 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/browser/chatManagement/chatModelsViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts index a050ec83e5eb0..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 @@ -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,8 @@ class MockLanguageModelsService implements ILanguageModelsService { getRecentlyUsedModelIds(): string[] { return []; } addToRecentlyUsedList(): void { } - getCuratedModels(): ICuratedModels { return { free: [], paid: [] }; } + clearRecentlyUsedList(): void { } + getModelsControlManifest(): IModelsControlManifest { return { free: {}, paid: {} }; } restrictedChatParticipants = observableValue('restrictedChatParticipants', Object.create(null)); } 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..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,11 +15,11 @@ 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'; -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,150 @@ 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(); + } + }); + + 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[] = []; 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..7e8c1835c3d3f --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts @@ -0,0 +1,511 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; +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 { + 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, + opts.upgradePlanUrl, + stubCommandService, + stubChatEntitlementService as IChatEntitlementService, + ); +} + +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 auto and manage models entries', () => { + const items = callBuild([]); + 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 auto and manage models with separator', () => { + const items = callBuild([createAutoModel()]); + 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', () => { + 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, + undefined, + stubCommandService, + stubChatEntitlementService as IChatEntitlementService, + ); + 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/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/languageModels.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.ts index ee74cce64c1d2..77b874be830d0 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; @@ -93,9 +93,10 @@ export class NullLanguageModelsService implements ILanguageModelsService { } addToRecentlyUsedList(): void { } + clearRecentlyUsedList(): 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/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 }); + }); +}); diff --git a/extensions/html-language-features/extension.webpack.config.js b/src/vs/workbench/contrib/git/browser/git.contributions.ts similarity index 50% rename from extensions/html-language-features/extension.webpack.config.js rename to src/vs/workbench/contrib/git/browser/git.contributions.ts index 1bb6632e92b09..bad65eaec7c66 100644 --- a/extensions/html-language-features/extension.webpack.config.js +++ b/src/vs/workbench/contrib/git/browser/git.contributions.ts @@ -2,17 +2,9 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; -import path from 'path'; -export default withDefaults({ - context: path.join(import.meta.dirname, 'client'), - entry: { - extension: './src/node/htmlClientMain.ts', - }, - output: { - filename: 'htmlClientMain.js', - path: path.join(import.meta.dirname, 'client', 'dist', 'node') - } -}); +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/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 }); } } 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); }); } 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; } 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';