diff --git a/.github/agents/engineering.md b/.github/agents/engineering.md deleted file mode 100644 index 1cfad832f7a47..0000000000000 --- a/.github/agents/engineering.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: Engineering -description: The VS Code Engineering Agent helps with engineering-related tasks in the VS Code repository. -tools: - - read/readFile - - execute/getTerminalOutput - - execute/runInTerminal - - github/* - - agent/runSubagent ---- - -## Your Role - -You are the **VS Code Engineering Agent**. Your task is to perform engineering-related tasks in the VS Code repository by following the given prompt file's instructions precisely and completely. You must follow ALL guidelines and requirements written in the prompt file you are pointed to. - -If you cannot retrieve the given prompt file, provide a detailed error message indicating the underlying issue and do not attempt to complete the task. - -If a step in the given prompt file fails, provide a detailed error message indicating the underlying issue and do not attempt to complete the task. diff --git a/.github/prompts/find-duplicates.prompt.md b/.github/prompts/find-duplicates.prompt.md index 7bda0fd83afcc..4537750b16b1d 100644 --- a/.github/prompts/find-duplicates.prompt.md +++ b/.github/prompts/find-duplicates.prompt.md @@ -1,6 +1,6 @@ --- # NOTE: This prompt is intended for internal use only for now. -agent: Engineering +agent: agent argument-hint: Provide a link or issue number to find duplicates for description: Find duplicates for a VS Code GitHub issue model: Claude Sonnet 4.5 (copilot) @@ -12,5 +12,5 @@ tools: --- ## Your Task -1. Use the GitHub MCP server to retrieve the prompt file https://github.com/microsoft/vscode-engineering/blob/main/.github/prompts/find-duplicates-gh-cli.prompt.md. +1. Get the file contents of the prompt file https://github.com/microsoft/vscode-engineering/blob/main/.github/prompts/find-duplicates-gh-cli.prompt.md. 2. Follow those instructions PRECISELY to identify potential duplicate issues for a given issue number in the VS Code repository. diff --git a/.github/prompts/find-issue.prompt.md b/.github/prompts/find-issue.prompt.md index dfdfdd56b69cb..32edf030e5b00 100644 --- a/.github/prompts/find-issue.prompt.md +++ b/.github/prompts/find-issue.prompt.md @@ -1,6 +1,6 @@ --- # ⚠️: Internal use only. To onboard, follow instructions at https://github.com/microsoft/vscode-engineering/blob/main/docs/gh-mcp-onboarding.md -agent: Engineering +agent: agent model: Claude Sonnet 4.5 (copilot) argument-hint: Describe your issue. Include relevant keywords or phrases. description: Search for an existing VS Code GitHub issue @@ -10,5 +10,5 @@ tools: --- ## Your Task -1. Use the GitHub MCP server to retrieve the prompt file https://github.com/microsoft/vscode-engineering/blob/main/.github/prompts/find-issue.prompt.md. +1. Get the file contents of the prompt file https://github.com/microsoft/vscode-engineering/blob/main/.github/prompts/find-issue.prompt.md. 2. Follow those instructions PRECISELY to find issues related to the issue description provided. Perform your search in the `vscode` repository. diff --git a/.github/prompts/issue-grouping.prompt.md b/.github/prompts/issue-grouping.prompt.md index 8f6bfea76601f..ec91b12f1bb78 100644 --- a/.github/prompts/issue-grouping.prompt.md +++ b/.github/prompts/issue-grouping.prompt.md @@ -1,5 +1,5 @@ --- -agent: Engineering +agent: agent model: Claude Sonnet 4.5 (copilot) argument-hint: Give an assignee and or a label/labels. Issues with that assignee and label will be fetched and grouped. description: Group similar issues. diff --git a/.gitignore b/.gitignore index 3d97a65e02779..1dbe527cb8ade 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,6 @@ node_modules/ .build/ .vscode/extensions/**/out/ extensions/**/dist/ -src/vs/base/browser/ui/codicons/codicon/codicon.ttf /out*/ /extensions/**/out/ build/node_modules diff --git a/build/azure-pipelines/product-build-macos.yml b/build/azure-pipelines/product-build-macos.yml index 57d88a6c3d752..cc563953b0071 100644 --- a/build/azure-pipelines/product-build-macos.yml +++ b/build/azure-pipelines/product-build-macos.yml @@ -43,7 +43,7 @@ name: "$(Date:yyyyMMdd).$(Rev:r) (${{ parameters.VSCODE_QUALITY }})" resources: repositories: - - repository: 1ESPipelines + - repository: 1esPipelines type: git name: 1ESPipelineTemplates/1ESPipelineTemplates ref: refs/tags/release diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index d035a13106e16..aa01ac6973e27 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -163,7 +163,7 @@ resources: source: 'VS Code 7PM Kick-Off' trigger: true repositories: - - repository: 1ESPipelines + - repository: 1esPipelines type: git name: 1ESPipelineTemplates/1ESPipelineTemplates ref: refs/tags/release diff --git a/build/azure-pipelines/product-sanity-tests.yml b/build/azure-pipelines/product-sanity-tests.yml index f9000fcf4576b..09fd3423e7570 100644 --- a/build/azure-pipelines/product-sanity-tests.yml +++ b/build/azure-pipelines/product-sanity-tests.yml @@ -37,7 +37,7 @@ name: "$(Date:yyyyMMdd).$(Rev:r) (${{ parameters.BUILD_QUALITY }} ${{ parameters resources: repositories: - - repository: 1ESPipelines + - repository: 1esPipelines type: git name: 1ESPipelineTemplates/1ESPipelineTemplates ref: refs/tags/release diff --git a/build/gulpfile.editor.ts b/build/gulpfile.editor.ts index 338c678b7de3f..5096f8caa1e80 100644 --- a/build/gulpfile.editor.ts +++ b/build/gulpfile.editor.ts @@ -36,14 +36,6 @@ const BUNDLED_FILE_HEADER = [ ].join('\n'); const extractEditorSrcTask = task.define('extract-editor-src', () => { - // Ensure codicon.ttf is copied from node_modules (needed when node_modules is cached and postinstall doesn't run) - const codiconSource = path.join(root, 'node_modules', '@vscode', 'codicons', 'dist', 'codicon.ttf'); - const codiconDest = path.join(root, 'src', 'vs', 'base', 'browser', 'ui', 'codicons', 'codicon', 'codicon.ttf'); - if (fs.existsSync(codiconSource)) { - fs.mkdirSync(path.dirname(codiconDest), { recursive: true }); - fs.copyFileSync(codiconSource, codiconDest); - } - const apiusages = monacoapi.execute().usageContent; const extrausages = fs.readFileSync(path.join(root, 'build', 'monaco', 'monaco.usage.recipe')).toString(); standalone.extractEditor({ diff --git a/build/npm/postinstall.ts b/build/npm/postinstall.ts index e94aaaf54c065..c4bbbf5296024 100644 --- a/build/npm/postinstall.ts +++ b/build/npm/postinstall.ts @@ -184,21 +184,3 @@ for (const dir of dirs) { child_process.execSync('git config pull.rebase merges'); child_process.execSync('git config blame.ignoreRevsFile .git-blame-ignore-revs'); - -// Copy codicon.ttf from @vscode/codicons package -const codiconSource = path.join(root, 'node_modules', '@vscode', 'codicons', 'dist', 'codicon.ttf'); -const codiconDest = path.join(root, 'src', 'vs', 'base', 'browser', 'ui', 'codicons', 'codicon', 'codicon.ttf'); - -if (!fs.existsSync(codiconSource)) { - console.error(`ERR codicon.ttf not found at ${codiconSource}`); - process.exit(1); -} - -try { - fs.mkdirSync(path.dirname(codiconDest), { recursive: true }); - fs.copyFileSync(codiconSource, codiconDest); - log('.', `Copied codicon.ttf to ${codiconDest}`); -} catch (error) { - console.error(`ERR Failed to copy codicon.ttf from ${codiconSource} to ${codiconDest}:`, error); - process.exit(1); -} diff --git a/extensions/r/package.json b/extensions/r/package.json index 9d655808b86f9..f4edd6a4f5ced 100644 --- a/extensions/r/package.json +++ b/extensions/r/package.json @@ -17,9 +17,9 @@ { "id": "r", "extensions": [ - ".r", - ".rhistory", - ".rprofile", + ".R", + ".Rhistory", + ".Rprofile", ".rt" ], "aliases": [ diff --git a/package-lock.json b/package-lock.json index cf7940c36d8a0..ce2416401769e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.4", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.44", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", @@ -2947,12 +2946,6 @@ "win32" ] }, - "node_modules/@vscode/codicons": { - "version": "0.0.44", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.44.tgz", - "integrity": "sha512-F7qPRumUK3EHjNdopfICLGRf3iNPoZQt+McTHAn4AlOWPB3W2kL4H0S7uqEqbyZ6rCxaeDjpAn3MCUnwTu/VJQ==", - "license": "CC-BY-4.0" - }, "node_modules/@vscode/deviceid": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/@vscode/deviceid/-/deviceid-0.1.4.tgz", diff --git a/package.json b/package.json index 3fc2a6c88cbf7..1678615987802 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,6 @@ "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.4", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.44", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 83542d2fc50c5..78bef5ccc5d5f 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -10,7 +10,6 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.44", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", @@ -72,12 +71,6 @@ "resolved": "https://registry.npmjs.org/@microsoft/dynamicproto-js/-/dynamicproto-js-1.1.9.tgz", "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, - "node_modules/@vscode/codicons": { - "version": "0.0.44", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.44.tgz", - "integrity": "sha512-F7qPRumUK3EHjNdopfICLGRf3iNPoZQt+McTHAn4AlOWPB3W2kL4H0S7uqEqbyZ6rCxaeDjpAn3MCUnwTu/VJQ==", - "license": "CC-BY-4.0" - }, "node_modules/@vscode/iconv-lite-umd": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/@vscode/iconv-lite-umd/-/iconv-lite-umd-0.7.1.tgz", diff --git a/remote/web/package.json b/remote/web/package.json index 13438c634deb2..e91442d01d77f 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -5,7 +5,6 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.44", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", diff --git a/src/vs/base/browser/ui/codicons/codicon/README.md b/src/vs/base/browser/ui/codicons/codicon/README.md deleted file mode 100644 index 8c0ffcb3b8778..0000000000000 --- a/src/vs/base/browser/ui/codicons/codicon/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Codicons - -## Where does the codicon.ttf come from? - -It is added via the `@vscode/codicons` npm package, then copied to this directory during the postinstall script. diff --git a/src/vs/base/browser/ui/codicons/codicon/codicon.ttf b/src/vs/base/browser/ui/codicons/codicon/codicon.ttf new file mode 100644 index 0000000000000..0c9d65f81c2f3 Binary files /dev/null and b/src/vs/base/browser/ui/codicons/codicon/codicon.ttf differ diff --git a/src/vs/base/browser/ui/tree/abstractTree.ts b/src/vs/base/browser/ui/tree/abstractTree.ts index 1b37c2218f97d..ece9f2d016c0a 100644 --- a/src/vs/base/browser/ui/tree/abstractTree.ts +++ b/src/vs/base/browser/ui/tree/abstractTree.ts @@ -29,6 +29,7 @@ import { Emitter, Event, EventBufferer, Relay } from '../../../common/event.js'; import { fuzzyScore, FuzzyScore } from '../../../common/filters.js'; import { KeyCode } from '../../../common/keyCodes.js'; import { Disposable, DisposableStore, dispose, IDisposable, toDisposable } from '../../../common/lifecycle.js'; +import { isMacintosh } from '../../../common/platform.js'; import { clamp } from '../../../common/numbers.js'; import { ScrollEvent } from '../../../common/scrollable.js'; import './media/tree.css'; @@ -3242,6 +3243,17 @@ export abstract class AbstractTree implements IDisposable this.onDidChangeCollapseStateRelay.input = model.onDidChangeCollapseState; this.onDidChangeRenderNodeCountRelay.input = model.onDidChangeRenderNodeCount; this.onDidSpliceModelRelay.input = model.onDidSpliceModel; + + // Announce collapse state changes for screen readers (VoiceOver doesn't reliably + // announce aria-expanded changes on already-focused elements) + if (isMacintosh) { + this.modelDisposables.add(model.onDidChangeCollapseState(e => { + const { node, deep } = e; + if (node.collapsible && !deep && this.isDOMFocused()) { + alert(node.collapsed ? localize('treeNodeCollapsed', "collapsed") : localize('treeNodeExpanded', "expanded")); + } + })); + } } navigate(start?: TRef): ITreeNavigator { diff --git a/src/vs/editor/browser/services/renameSymbolTrackerService.ts b/src/vs/editor/browser/services/renameSymbolTrackerService.ts new file mode 100644 index 0000000000000..fd3dbf5ea575a --- /dev/null +++ b/src/vs/editor/browser/services/renameSymbolTrackerService.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IObservable, observableValue } from '../../../base/common/observable.js'; +import { Position } from '../../common/core/position.js'; +import { Range } from '../../common/core/range.js'; +import { ITextModel } from '../../common/model.js'; +import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; + +export const IRenameSymbolTrackerService = createDecorator('renameSymbolTrackerService'); + +/** + * Represents a tracked word that is being edited by the user. + */ +export interface ITrackedWord { + /** + * The model in which the word is being tracked. + */ + readonly model: ITextModel; + /** + * The original word text when tracking started. + */ + readonly originalWord: string; + /** + * The original position where the word was found. + */ + readonly originalPosition: Position; + /** + * The original range of the word when tracking started. + */ + readonly originalRange: Range; + /** + * The current word text after edits. + */ + readonly currentWord: string; + /** + * The current range of the word after edits. + */ + readonly currentRange: Range; +} + +export interface IRenameSymbolTrackerService { + readonly _serviceBrand: undefined; + + /** + * Observable that emits the currently tracked word, or undefined if no word is being tracked. + */ + readonly trackedWord: IObservable; +} + +export class NullRenameSymbolTrackerService implements IRenameSymbolTrackerService { + declare readonly _serviceBrand: undefined; + + private readonly _trackedWord = observableValue(this, undefined); + public readonly trackedWord: IObservable = this._trackedWord; + constructor() { + this._trackedWord.set(undefined, undefined); + } +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/renameSymbolProcessor.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/renameSymbolProcessor.ts index a3d7548cd4cf6..7946c817bfdb7 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/renameSymbolProcessor.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/renameSymbolProcessor.ts @@ -10,10 +10,10 @@ import { Disposable } from '../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../nls.js'; import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ServicesAccessor } from '../../../../browser/editorExtensions.js'; -import { IBulkEditService } from '../../../../browser/services/bulkEditService.js'; +import { IBulkEditService, ResourceTextEdit } from '../../../../browser/services/bulkEditService.js'; import { TextReplacement } from '../../../../common/core/edits/textEdit.js'; import { Position } from '../../../../common/core/position.js'; -import { Range } from '../../../../common/core/range.js'; +import { Range, type IRange } from '../../../../common/core/range.js'; import { StandardTokenType } from '../../../../common/encodedTokenAttributes.js'; import { Command, type Rejection, type WorkspaceEdit } from '../../../../common/languages.js'; import { ILanguageConfigurationService } from '../../../../common/languages/languageConfigurationRegistry.js'; @@ -26,6 +26,10 @@ import { InlineSuggestionItem } from './inlineSuggestionItem.js'; import { IInlineSuggestDataActionEdit, InlineCompletionContextWithoutUuid } from './provideInlineCompletions.js'; import { InlineSuggestAlternativeAction } from './InlineSuggestAlternativeAction.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { IRenameSymbolTrackerService } from '../../../../browser/services/renameSymbolTrackerService.js'; +import type { URI } from '../../../../../base/common/uri.js'; +import type { ICodeEditor } from '../../../../browser/editorBrowser.js'; +import { ICodeEditorService } from '../../../../browser/services/codeEditorService.js'; enum RenameKind { no = 'no', @@ -44,6 +48,36 @@ namespace RenameKind { } } +export namespace PrepareNesRenameResult { + export type Yes = { + canRename: RenameKind.yes; + oldName: string; + onOldState: boolean; + }; + export type Maybe = { + canRename: RenameKind.maybe; + oldName: string; + onOldState: boolean; + }; + export type No = { + canRename: RenameKind.no; + timedOut: boolean; + reason?: string; + }; +} + +export type PrepareNesRenameResult = PrepareNesRenameResult.Yes | PrepareNesRenameResult.Maybe | PrepareNesRenameResult.No; + +export type TextChange = { + range: { start: { line: number; character: number }; end: { line: number; character: number } }; + newText?: string; +}; + +export type RenameGroup = { + file: URI; + changes: TextChange[]; +}; + export type RenameEdits = { renames: { edits: TextReplacement[]; position: Position; oldName: string; newName: string }; others: { edits: TextReplacement[] }; @@ -269,23 +303,66 @@ export class RenameInferenceEngine { } } +class EditorState { + + public static create(codeEditorService: ICodeEditorService, textModel: ITextModel): EditorState | undefined { + const editor = codeEditorService.getFocusedCodeEditor(); + if (editor === null) { + return undefined; + } + + if (editor.getModel() !== textModel) { + return undefined; + } + + return new EditorState(editor, textModel.getVersionId()); + } + + private constructor( + private readonly editor: ICodeEditor, + private readonly versionId: number, + ) { } + + public equals(other: EditorState | undefined): boolean { + if (other === undefined) { + return false; + } + return this.editor === other.editor && this.versionId === other.versionId; + } +} + class RenameSymbolRunnable { + private readonly _commandService: ICommandService; private readonly _requestUuid: string; + private readonly _textModel: ITextModel; + private readonly _state: EditorState; private readonly _cancellationTokenSource: CancellationTokenSource; private readonly _promise: Promise; private _result: WorkspaceEdit & Rejection | undefined = undefined; - constructor(languageFeaturesService: ILanguageFeaturesService, textModel: ITextModel, position: Position, newName: string, requestUuid: string) { + constructor(languageFeaturesService: ILanguageFeaturesService, commandService: ICommandService, requestUuid: string, textModel: ITextModel, state: EditorState, position: Position, newName: string, lastSymbolRename: IRange | undefined, oldName: string | undefined) { + this._commandService = commandService; + this._textModel = textModel; + this._state = state; this._requestUuid = requestUuid; this._cancellationTokenSource = new CancellationTokenSource(); - this._promise = rawRename(languageFeaturesService.renameProvider, textModel, position, newName, this._cancellationTokenSource.token); + if (lastSymbolRename === undefined || oldName === undefined) { + this._promise = rawRename(languageFeaturesService.renameProvider, textModel, position, newName, this._cancellationTokenSource.token); + return; + } else { + this._promise = this.sendNesRenameRequest(textModel, position, oldName, newName, lastSymbolRename); + } } public get requestUuid(): string { return this._requestUuid; } + public isValid(codeEditorService: ICodeEditorService): boolean { + return this._state.equals(EditorState.create(codeEditorService, this._textModel)); + } + public cancel(): void { this._cancellationTokenSource.cancel(); } @@ -318,6 +395,26 @@ class RenameSymbolRunnable { } return this._result; } + + private async sendNesRenameRequest(textModel: ITextModel, position: Position, oldName: string, newName: string, lastSymbolRename: IRange | undefined): Promise { + try { + const result = await this._commandService.executeCommand('github.copilot.nes.postRename', textModel.uri, position, oldName, newName, lastSymbolRename); + if (result === undefined) { + return { rejectReason: 'Rename failed', edits: [] }; + } + const edits: ResourceTextEdit[] = []; + for (const item of result) { + for (const change of item.changes) { + const range = new Range(change.range.start.line + 1, change.range.start.character + 1, change.range.end.line + 1, change.range.end.character + 1); + const edit = new ResourceTextEdit(item.file, new TextReplacement(range, change.newText ?? newName)); + edits.push(edit); + } + } + return { edits }; + } catch (error) { + return { rejectReason: 'Rename failed', edits: [] }; + } + } } export class RenameSymbolProcessor extends Disposable { @@ -331,10 +428,12 @@ export class RenameSymbolProcessor extends Disposable { @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, @ILanguageConfigurationService private readonly _languageConfigurationService: ILanguageConfigurationService, @IBulkEditService bulkEditService: IBulkEditService, + @IRenameSymbolTrackerService private readonly _renameSymbolTrackerService: IRenameSymbolTrackerService, + @ICodeEditorService private readonly _codeEditorService: ICodeEditorService, ) { super(); this._register(CommandsRegistry.registerCommand(renameSymbolCommandId, async (_: ServicesAccessor, source: TextModelEditSource, renameRunnable: RenameSymbolRunnable | undefined) => { - if (renameRunnable === undefined) { + if (renameRunnable === undefined || !renameRunnable.isValid(this._codeEditorService)) { return; } @@ -361,6 +460,11 @@ export class RenameSymbolProcessor extends Disposable { return suggestItem; } + const state = EditorState.create(this._codeEditorService, textModel); + if (state === undefined) { + return suggestItem; + } + const start = Date.now(); const edit = suggestItem.action.textReplacement; const languageConfiguration = this._languageConfigurationService.getLanguageConfiguration(textModel.getLanguageId()); @@ -373,10 +477,16 @@ export class RenameSymbolProcessor extends Disposable { const { oldName, newName, position, edits: renameEdits } = edits.renames; + const trackedWord = this._renameSymbolTrackerService.trackedWord.get(); + let lastSymbolRename: IRange | undefined = undefined; + if (trackedWord !== undefined && trackedWord.model === textModel && trackedWord.originalWord === oldName && trackedWord.currentWord === newName) { + lastSymbolRename = trackedWord.currentRange; + } + // Check asynchronously if a rename is possible let timedOut = false; - const check = await raceTimeout(this.checkRenamePrecondition(suggestItem, textModel, position, oldName, newName), 100, () => { timedOut = true; }); - const renamePossible = this.isRenamePossible(suggestItem, check); + const check = await raceTimeout(this.checkRenamePrecondition(suggestItem, textModel, position, oldName, newName, lastSymbolRename), 100, () => { timedOut = true; }); + const renamePossible = this.isRenamePossible(suggestItem, check, state, textModel); suggestItem.setRenameProcessingInfo({ createdRename: renamePossible, @@ -392,7 +502,7 @@ export class RenameSymbolProcessor extends Disposable { // Prepare the rename edits if (this._renameRunnable === undefined) { - this._renameRunnable = new RenameSymbolRunnable(this._languageFeaturesService, textModel, position, newName, suggestItem.requestUuid); + this._renameRunnable = new RenameSymbolRunnable(this._languageFeaturesService, this._commandService, suggestItem.requestUuid, textModel, state, position, newName, lastSymbolRename, lastSymbolRename !== undefined ? oldName : undefined); } // Create alternative action @@ -426,21 +536,39 @@ export class RenameSymbolProcessor extends Disposable { return InlineSuggestionItem.create(suggestItem.withAction(renameAction), textModel, false); } - private async checkRenamePrecondition(suggestItem: InlineSuggestionItem, textModel: ITextModel, position: Position, oldName: string, newName: string): Promise { + private async checkRenamePrecondition(suggestItem: InlineSuggestionItem, textModel: ITextModel, position: Position, oldName: string, newName: string, lastSymbolRename: IRange | undefined): Promise { + const no: PrepareNesRenameResult.No = { canRename: RenameKind.no, timedOut: false }; try { - const result = await this._commandService.executeCommand('github.copilot.nes.prepareRename', textModel.uri, position, oldName, newName, suggestItem.requestUuid); + const result = await this._commandService.executeCommand('github.copilot.nes.prepareRename', textModel.uri, position, oldName, newName, suggestItem.requestUuid, lastSymbolRename); if (result === undefined) { - return RenameKind.no; + return no; + } else if (typeof result === 'string') { + const canRename = RenameKind.fromString(result); + if (canRename === RenameKind.yes || canRename === RenameKind.maybe) { + return { + canRename, + oldName, + onOldState: false, + }; + } else { + return { + canRename, + timedOut: false, + }; + } } else { - return RenameKind.fromString(result); + return result; } } catch (error) { - return RenameKind.no; + return no; } } - private isRenamePossible(suggestItem: InlineSuggestionItem, check: RenameKind | undefined): boolean { - if (check === undefined || check === RenameKind.no) { + private isRenamePossible(suggestItem: InlineSuggestionItem, check: PrepareNesRenameResult | undefined, state: EditorState, textModel: ITextModel): boolean { + if (check === undefined || check.canRename === RenameKind.no) { + return false; + } + if (!state.equals(EditorState.create(this._codeEditorService, textModel))) { return false; } if (this._renameRunnable === undefined) { diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts index cb1adfefdbe38..5b61a15bfb8d7 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts @@ -28,6 +28,7 @@ import { InlineSuggestionsView } from '../../browser/view/inlineSuggestionsView. import { IBulkEditService } from '../../../../browser/services/bulkEditService.js'; import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; +import { IRenameSymbolTrackerService, NullRenameSymbolTrackerService } from '../../../../browser/services/renameSymbolTrackerService.js'; export class MockInlineCompletionsProvider implements InlineCompletionsProvider { private returnValue: InlineCompletion[] = []; @@ -272,6 +273,7 @@ export async function withAsyncTestCodeEditorAndInlineCompletionsModel( refresh: async () => { return null; }, signIn: async () => { return null; }, }); + options.serviceCollection.set(IRenameSymbolTrackerService, new NullRenameSymbolTrackerService()); const d = languageFeaturesService.inlineCompletionsProvider.register({ pattern: '**' }, options.provider); disposableStore.add(d); diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index 196514a540e7a..33f4789da647a 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -21,6 +21,7 @@ import { IDisposable, IReference, ImmortalReference, toDisposable, DisposableSto import { OS, isLinux, isMacintosh } from '../../../base/common/platform.js'; import Severity from '../../../base/common/severity.js'; import { URI } from '../../../base/common/uri.js'; +import { IRenameSymbolTrackerService, NullRenameSymbolTrackerService } from '../../browser/services/renameSymbolTrackerService.js'; import { IBulkEditOptions, IBulkEditResult, IBulkEditService, ResourceEdit, ResourceTextEdit } from '../../browser/services/bulkEditService.js'; import { isDiffEditorConfigurationKey, isEditorConfigurationKey } from '../../common/config/editorConfigurationSchema.js'; import { EditOperation, ISingleEditOperation } from '../../common/core/editOperation.js'; @@ -1178,6 +1179,7 @@ registerSingleton(ITreeSitterLibraryService, StandaloneTreeSitterLibraryService, registerSingleton(ILoggerService, NullLoggerService, InstantiationType.Eager); registerSingleton(IDataChannelService, NullDataChannelService, InstantiationType.Eager); registerSingleton(IDefaultAccountService, StandaloneDefaultAccountService, InstantiationType.Eager); +registerSingleton(IRenameSymbolTrackerService, NullRenameSymbolTrackerService, InstantiationType.Eager); /** * We don't want to eagerly instantiate services because embedders get a one time chance diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 6132cfefb884d..5ce46fadff97d 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -3,13 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../base/browser/dom.js'; +import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.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'; +import { IAction } from '../../../base/common/actions.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { Codicon } from '../../../base/common/codicons.js'; import { ResolvedKeybinding } from '../../../base/common/keybindings.js'; -import { Disposable, MutableDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; import { OS } from '../../../base/common/platform.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import './actionWidget.css'; @@ -59,6 +61,10 @@ export interface IActionListItem { canPreview?: boolean | undefined; readonly hideIcon?: boolean; readonly tooltip?: string; + /** + * Optional toolbar actions shown when the item is focused or hovered. + */ + readonly toolbarActions?: IAction[]; } interface IActionMenuTemplateData { @@ -67,6 +73,8 @@ interface IActionMenuTemplateData { readonly text: HTMLElement; readonly description?: HTMLElement; readonly keybinding: KeybindingLabel; + readonly toolbar: HTMLElement; + readonly elementDisposables: DisposableStore; } export const enum ActionListItemKind { @@ -155,10 +163,19 @@ class ActionItemRenderer implements IListRenderer, IAction const keybinding = new KeybindingLabel(container, OS); - return { container, icon, text, description, keybinding }; + const toolbar = document.createElement('div'); + toolbar.className = 'action-list-item-toolbar'; + container.append(toolbar); + + const elementDisposables = new DisposableStore(); + + return { container, icon, text, description, keybinding, toolbar, elementDisposables }; } renderElement(element: IActionListItem, _index: number, data: IActionMenuTemplateData): void { + // Clear previous element disposables + data.elementDisposables.clear(); + if (element.group?.icon) { data.icon.className = ThemeIcon.asClassName(element.group.icon); if (element.group.icon.color) { @@ -209,10 +226,20 @@ class ActionItemRenderer implements IListRenderer, IAction } else { data.container.title = ''; } + + // Clear and render toolbar actions + dom.clearNode(data.toolbar); + data.container.classList.toggle('has-toolbar', !!element.toolbarActions?.length); + if (element.toolbarActions?.length) { + const actionBar = new ActionBar(data.toolbar); + data.elementDisposables.add(actionBar); + actionBar.push(element.toolbarActions, { icon: true, label: false }); + } } disposeTemplate(templateData: IActionMenuTemplateData): void { templateData.keybinding.dispose(); + templateData.elementDisposables.dispose(); } } @@ -467,6 +494,14 @@ export class ActionList extends Disposable { const element = e.element; if (element && element.item && this.focusCondition(element)) { + // Check if the hover target is inside a toolbar - if so, skip the splice + // to avoid re-rendering which would destroy the toolbar mid-hover + const isHoveringToolbar = dom.isHTMLElement(e.browserEvent.target) && e.browserEvent.target.closest('.action-list-item-toolbar') !== null; + if (isHoveringToolbar) { + this._list.setFocus([]); + return; + } + if (this._delegate.onHover && !element.disabled && element.kind === ActionListItemKind.Action) { const result = await this._delegate.onHover(element.item, this.cts.token); element.canPreview = result ? result.canPreview : undefined; diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index 91f0ebd761caa..6f49c8dc78686 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -198,6 +198,16 @@ margin-left: 0.5em; } +/* Item toolbar - shows on hover/focus */ +.action-widget .monaco-list-row.action .action-list-item-toolbar { + display: none; +} + +.action-widget .monaco-list-row.focused.action.has-toolbar .action-list-item-toolbar, +.action-widget .monaco-list-row:hover.action.has-toolbar .action-list-item-toolbar { + display: flex; +} + .action-widget-delegate-label { display: flex; align-items: center; diff --git a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts index 2021c617ce47e..9e884d85fa6d2 100644 --- a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts +++ b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts @@ -21,6 +21,10 @@ export interface IActionWidgetDropdownAction extends IAction { * Optional flyout hover configuration shown when focusing/hovering over the action. */ hover?: IActionListItemHover; + /** + * Optional toolbar actions shown when the item is focused or hovered. + */ + toolbarActions?: IAction[]; } // TODO @lramos15 - Should we just make IActionProvider templated? @@ -108,6 +112,7 @@ export class ActionWidgetDropdown extends BaseDropdown { tooltip: action.tooltip, description: action.description, hover: action.hover, + toolbarActions: action.toolbarActions, kind: ActionListItemKind.Action, canPreview: false, group: { title: '', icon: action.icon ?? ThemeIcon.fromId(action.checked ? Codicon.check.id : Codicon.blank.id) }, diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 7297c8b72676d..e43d19b50f651 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -297,6 +297,7 @@ export class MenuId { static readonly AgentsTitleBarControlMenu = new MenuId('AgentsTitleBarControlMenu'); static readonly ChatViewSessionTitleNavigationToolbar = new MenuId('ChatViewSessionTitleNavigationToolbar'); static readonly ChatViewSessionTitleToolbar = new MenuId('ChatViewSessionTitleToolbar'); + static readonly ChatContextUsageActions = new MenuId('ChatContextUsageActions'); /** * Create or reuse a `MenuId` with the given identifier diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index cf9ff526955b6..124aae0d4a6b6 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -281,6 +281,10 @@ const _allApiProposals = { languageModelToolResultAudience: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.languageModelToolResultAudience.d.ts', }, + languageModelToolSupportsModel: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.languageModelToolSupportsModel.d.ts', + version: 1 + }, languageStatusText: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.languageStatusText.d.ts', }, diff --git a/src/vs/platform/update/common/update.ts b/src/vs/platform/update/common/update.ts index 38ad531a08cb1..e794ce33bc81d 100644 --- a/src/vs/platform/update/common/update.ts +++ b/src/vs/platform/update/common/update.ts @@ -25,10 +25,10 @@ export interface IUpdate { * Checking for Updates → Available for Download * ↓ * ← Overwriting - * Downloading ↑ - * → Ready - * ↓ ↑ ↓ - * Downloaded → Updating Overwriting → Downloading + * Downloading ↑ + * → Ready + * ↓ ↑ + * Downloaded → Updating * * Available: There is an update available for download (linux). * Ready: Code will be updated as soon as it restarts (win32, darwin). diff --git a/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts b/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts index 1976af774dc0f..5059c608dae22 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts @@ -4,12 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../base/common/cancellation.js'; -import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore } from '../../../base/common/lifecycle.js'; import { revive } from '../../../base/common/marshalling.js'; -import { CountTokensCallback, ILanguageModelToolsService, IToolInvocation, IToolProgressStep, IToolResult, ToolProgress, toolResultHasBuffers } from '../../contrib/chat/common/tools/languageModelToolsService.js'; -import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; +import { isUriComponents, URI, UriComponents } from '../../../base/common/uri.js'; +import { ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js'; +import { ILogService } from '../../../platform/log/common/log.js'; +import { toToolSetKey } from '../../contrib/chat/common/tools/languageModelToolsContribution.js'; +import { CountTokensCallback, ILanguageModelToolsService, IToolData, IToolInvocation, IToolProgressStep, IToolResult, ToolDataSource, ToolProgress, toolResultHasBuffers, ToolSet } from '../../contrib/chat/common/tools/languageModelToolsService.js'; +import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { Dto, SerializableObjectWithBuffers } from '../../services/extensions/common/proxyIdentifier.js'; -import { ExtHostContext, ExtHostLanguageModelToolsShape, IToolDataDto, MainContext, MainThreadLanguageModelToolsShape } from '../common/extHost.protocol.js'; +import { ExtHostContext, ExtHostLanguageModelToolsShape, IToolDataDto, IToolDefinitionDto, MainContext, MainThreadLanguageModelToolsShape } from '../common/extHost.protocol.js'; @extHostNamedCustomer(MainContext.MainThreadLanguageModelTools) export class MainThreadLanguageModelTools extends Disposable implements MainThreadLanguageModelToolsShape { @@ -24,6 +29,7 @@ export class MainThreadLanguageModelTools extends Disposable implements MainThre constructor( extHostContext: IExtHostContext, @ILanguageModelToolsService private readonly _languageModelToolsService: ILanguageModelToolsService, + @ILogService private readonly _logService: ILogService, ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostLanguageModelTools); @@ -32,7 +38,7 @@ export class MainThreadLanguageModelTools extends Disposable implements MainThre } private getToolDtos(): IToolDataDto[] { - return Array.from(this._languageModelToolsService.getTools()) + return Array.from(this._languageModelToolsService.getAllToolsIncludingDisabled()) .map(tool => ({ id: tool.id, displayName: tool.displayName, @@ -98,6 +104,71 @@ export class MainThreadLanguageModelTools extends Disposable implements MainThre this._tools.set(id, disposable); } + $registerToolWithDefinition(extensionId: ExtensionIdentifier, definition: IToolDefinitionDto, hasHandleToolStream: boolean): void { + let icon: IToolData['icon'] | undefined; + if (definition.icon) { + if (ThemeIcon.isThemeIcon(definition.icon)) { + icon = definition.icon; + } else if (typeof definition.icon === 'object' && definition.icon !== null && isUriComponents(definition.icon)) { + icon = { dark: URI.revive(definition.icon as UriComponents) }; + } else { + const iconObj = definition.icon as { light?: UriComponents; dark: UriComponents }; + icon = { dark: URI.revive(iconObj.dark), light: iconObj.light ? URI.revive(iconObj.light) : undefined }; + } + } + + // Convert source from DTO + const source = revive(definition.source); + + // Create the tool data + const toolData: IToolData = { + id: definition.id, + displayName: definition.displayName, + toolReferenceName: definition.toolReferenceName, + legacyToolReferenceFullNames: definition.legacyToolReferenceFullNames, + tags: definition.tags, + userDescription: definition.userDescription, + modelDescription: definition.modelDescription, + inputSchema: definition.inputSchema, + source, + icon, + models: definition.models, + canBeReferencedInPrompt: !!definition.userDescription && !definition.toolSet, + }; + + // Register both tool data and implementation + const id = definition.id; + const store = new DisposableStore(); + store.add(this._languageModelToolsService.registerTool( + toolData, + { + invoke: async (dto, countTokens, progress, token) => { + try { + this._runningToolCalls.set(dto.callId, { countTokens, progress }); + const resultSerialized = await this._proxy.$invokeTool(dto, token); + const resultDto: Dto = resultSerialized instanceof SerializableObjectWithBuffers ? resultSerialized.value : resultSerialized; + return revive(resultDto); + } finally { + this._runningToolCalls.delete(dto.callId); + } + }, + handleToolStream: hasHandleToolStream ? (context, token) => this._proxy.$handleToolStream(id, context, token) : undefined, + prepareToolInvocation: (context, token) => this._proxy.$prepareToolInvocation(id, context, token), + } + )); + + if (definition.toolSet) { + const ts = this._languageModelToolsService.getToolSet(toToolSetKey(extensionId, definition.toolSet)) || this._languageModelToolsService.getToolSet(definition.toolSet); + if (!ts || !(ts instanceof ToolSet)) { + this._logService.warn(`ToolSet ${definition.toolSet} not found for tool ${definition.id} from extension ${extensionId.value}`); + } else { + store.add(ts.addTool(toolData)); + } + } + + this._tools.set(id, store); + } + $unregisterTool(name: string): void { this._tools.deleteAndDispose(name); } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 5a58eb5125102..9914c0652b021 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1615,8 +1615,14 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I registerTool(name: string, tool: vscode.LanguageModelTool) { return extHostLanguageModelTools.registerTool(extension, name, tool); }, - invokeTool(name: string, parameters: vscode.LanguageModelToolInvocationOptions, token?: vscode.CancellationToken) { - return extHostLanguageModelTools.invokeTool(extension, name, parameters, token); + registerToolDefinition(definition: vscode.LanguageModelToolDefinition, tool: vscode.LanguageModelTool) { + return extHostLanguageModelTools.registerToolDefinition(extension, definition, tool); + }, + invokeTool(nameOrInfo: string | vscode.LanguageModelToolInformation, parameters: vscode.LanguageModelToolInvocationOptions, token?: vscode.CancellationToken) { + if (typeof nameOrInfo !== 'string') { + checkProposedApiEnabled(extension, 'chatParticipantAdditions'); + } + return extHostLanguageModelTools.invokeTool(extension, nameOrInfo, parameters, token); }, get tools() { return extHostLanguageModelTools.getTools(extension); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 78afb689625d2..519b556048d79 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1495,12 +1495,26 @@ export interface IToolDataDto { inputSchema?: IJSONSchema; } +export interface ILanguageModelChatSelectorDto { + vendor?: string; + family?: string; + version?: string; + id?: string; +} + +export interface IToolDefinitionDto extends IToolDataDto { + icon?: IconPathDto; + models?: ILanguageModelChatSelectorDto[]; + toolSet?: string; +} + export interface MainThreadLanguageModelToolsShape extends IDisposable { $getTools(): Promise[]>; $acceptToolProgress(callId: string, progress: IToolProgressStep): void; $invokeTool(dto: Dto, token?: CancellationToken): Promise | SerializableObjectWithBuffers>>; $countTokensForInvocation(callId: string, input: string, token: CancellationToken): Promise; $registerTool(id: string, hasHandleToolStream: boolean): void; + $registerToolWithDefinition(extensionId: ExtensionIdentifier, definition: IToolDefinitionDto, hasHandleToolStream: boolean): void; $unregisterTool(name: string): void; } diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 5d2462859670f..096d1cf3632fa 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -593,12 +593,13 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS const { request, location, history } = await this._createRequest(requestDto, context, detector.extension); const model = await this.getModelForRequest(request, detector.extension); + const tools = await this.getToolsForRequest(detector.extension, request.userSelectedTools, model.id, token); const extRequest = typeConvert.ChatAgentRequest.to( request, location, model, this.getDiagnosticsWhenEnabled(detector.extension), - this.getToolsForRequest(detector.extension, request.userSelectedTools), + tools, detector.extension, this._logService); @@ -657,7 +658,8 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS } request.extRequest.tools.clear(); - for (const [k, v] of this.getToolsForRequest(request.extension, tools)) { + const toolsMap = await this.getToolsForRequest(request.extension, tools, request.extRequest.model.id, CancellationToken.None); + for (const [k, v] of toolsMap) { request.extRequest.tools.set(k, v); } this._onDidChangeChatRequestTools.fire(request.extRequest); @@ -685,12 +687,13 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS stream = new ChatAgentResponseStream(agent.extension, request, this._proxy, this._commands.converter, sessionDisposables); const model = await this.getModelForRequest(request, agent.extension); + const tools = await this.getToolsForRequest(agent.extension, request.userSelectedTools, model.id, token); const extRequest = typeConvert.ChatAgentRequest.to( request, location, model, this.getDiagnosticsWhenEnabled(agent.extension), - this.getToolsForRequest(agent.extension, request.userSelectedTools), + tools, agent.extension, this._logService ); @@ -739,7 +742,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS checkProposedApiEnabled(agent.extension, 'chatParticipantPrivate'); } - return { errorDetails, timings: stream?.timings, metadata: result?.metadata, nextQuestion: result?.nextQuestion, details: result?.details } satisfies IChatAgentResult; + return { errorDetails, timings: stream?.timings, metadata: result?.metadata, nextQuestion: result?.nextQuestion, details: result?.details, usage: result?.usage } satisfies IChatAgentResult; }), token); } catch (e) { this._logService.error(e, agent.extension); @@ -767,14 +770,14 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return this._diagnostics.getDiagnostics(); } - private getToolsForRequest(extension: IExtensionDescription, tools: UserSelectedTools | undefined): Map { + private async getToolsForRequest(extension: IExtensionDescription, tools: UserSelectedTools | undefined, modelId: string, token: CancellationToken): Promise> { if (!tools) { return new Map(); } - const result = new Map(); + const result = new Map(); for (const tool of this._tools.getTools(extension)) { if (typeof tools[tool.name] === 'boolean') { - result.set(tool.name, tools[tool.name]); + result.set(tool, tools[tool.name]); } } return result; diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index 9970d36434945..84421c89cd32c 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -17,7 +17,7 @@ import { InternalFetchWebPageToolId } from '../../contrib/chat/common/tools/buil import { SearchExtensionsToolId } from '../../contrib/extensions/common/searchExtensionsTool.js'; import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; import { Dto, SerializableObjectWithBuffers } from '../../services/extensions/common/proxyIdentifier.js'; -import { ExtHostLanguageModelToolsShape, IMainContext, IToolDataDto, MainContext, MainThreadLanguageModelToolsShape } from './extHost.protocol.js'; +import { ExtHostLanguageModelToolsShape, IMainContext, IToolDataDto, IToolDefinitionDto, MainContext, MainThreadLanguageModelToolsShape } from './extHost.protocol.js'; import { ExtHostLanguageModels } from './extHostLanguageModels.js'; import * as typeConvert from './extHostTypeConverters.js'; import { URI } from '../../../base/common/uri.js'; @@ -100,7 +100,8 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape return await fn(input, token); } - async invokeTool(extension: IExtensionDescription, toolId: string, options: vscode.LanguageModelToolInvocationOptions, token?: CancellationToken): Promise { + async invokeTool(extension: IExtensionDescription, toolIdOrInfo: string | vscode.LanguageModelToolInformation, options: vscode.LanguageModelToolInvocationOptions, token?: CancellationToken): Promise { + const toolId = typeof toolIdOrInfo === 'string' ? toolIdOrInfo : toolIdOrInfo.name; const callId = generateUuid(); if (options.tokenizationOptions) { this._tokenCountFuncs.set(callId, options.tokenizationOptions.countTokens); @@ -324,4 +325,36 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape this._proxy.$unregisterTool(id); }); } + + registerToolDefinition(extension: IExtensionDescription, definition: vscode.LanguageModelToolDefinition, tool: vscode.LanguageModelTool): IDisposable { + checkProposedApiEnabled(extension, 'languageModelToolSupportsModel'); + + const id = definition.name; + + // Convert the definition to a DTO + const dto: IToolDefinitionDto = { + id, + displayName: definition.displayName, + toolReferenceName: definition.toolReferenceName, + userDescription: definition.userDescription, + modelDescription: definition.description, + inputSchema: definition.inputSchema as object, + source: { + type: 'extension', + label: extension.displayName ?? extension.name, + extensionId: extension.identifier, + }, + icon: typeConvert.IconPath.from(definition.icon), + models: definition.models, + toolSet: definition.toolSet, + }; + + this._registeredTools.set(id, { extension, tool }); + this._proxy.$registerToolWithDefinition(extension.identifier, dto, typeof tool.handleToolStream === 'function'); + + return toDisposable(() => { + this._registeredTools.delete(id); + this._proxy.$unregisterTool(id); + }); + } } diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index b09ba368f8f61..10cb9ffb1af4d 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -3175,7 +3175,7 @@ export namespace ChatResponsePart { } export namespace ChatAgentRequest { - export function to(request: IChatAgentRequest, location2: vscode.ChatRequestEditorData | vscode.ChatRequestNotebookData | undefined, model: vscode.LanguageModelChat, diagnostics: readonly [vscode.Uri, readonly vscode.Diagnostic[]][], tools: Map, extension: IRelaxedExtensionDescription, logService: ILogService): vscode.ChatRequest { + export function to(request: IChatAgentRequest, location2: vscode.ChatRequestEditorData | vscode.ChatRequestNotebookData | undefined, model: vscode.LanguageModelChat, diagnostics: readonly [vscode.Uri, readonly vscode.Diagnostic[]][], tools: Map, extension: IRelaxedExtensionDescription, logService: ILogService): vscode.ChatRequest { const toolReferences: IChatRequestVariableEntry[] = []; const variableReferences: IChatRequestVariableEntry[] = []; @@ -3412,6 +3412,7 @@ export namespace ChatAgentResult { metadata: reviveMetadata(result.metadata), nextQuestion: result.nextQuestion, details: result.details, + usage: result.usage, }; } export function from(result: vscode.ChatResult): Dto { @@ -3419,7 +3420,8 @@ export namespace ChatAgentResult { errorDetails: result.errorDetails, metadata: result.metadata, nextQuestion: result.nextQuestion, - details: result.details + details: result.details, + usage: result.usage, }; } diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index 4b732fec8505c..2b195efeb99d2 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -349,14 +349,34 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi } }; + // Restore maximized auxiliary bar when no editors, sidebar hidden, and panel hidden + const restoreMaximizedAuxiliaryBar = () => { + if ( + this.mainPartEditorService.visibleEditors.length === 0 && + !this.isVisible(Parts.SIDEBAR_PART) && + !this.isVisible(Parts.PANEL_PART) && + this.auxiliaryBarOpensMaximized() + ) { + this.setAuxiliaryBarMaximized(true); + } + }; + // Wait to register these listeners after the editor group service // is ready to avoid conflicts on startup this.editorGroupService.whenRestored.then(() => { // Restore main editor part on any editor change in main part - this._register(this.mainPartEditorService.onDidVisibleEditorsChange(showEditorIfHidden)); + this._register(this.mainPartEditorService.onDidVisibleEditorsChange(() => { + showEditorIfHidden(); + restoreMaximizedAuxiliaryBar(); + })); this._register(this.editorGroupService.mainPart.onDidActivateGroup(showEditorIfHidden)); + // Restore maximized auxiliary bar when sidebar or panel visibility changes + this._register(this.onDidChangePartVisibility(() => { + restoreMaximizedAuxiliaryBar(); + })); + // Revalidate center layout when active editor changes: diff editor quits centered mode. this._register(this.mainPartEditorService.onDidActiveEditorChange(() => this.centerMainEditorLayout(this.stateModel.getRuntimeValue(LayoutStateKeys.MAIN_EDITOR_CENTERED)))); }); @@ -2159,6 +2179,10 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi return panelOpensMaximized === PartOpensMaximizedOptions.ALWAYS || (panelOpensMaximized === PartOpensMaximizedOptions.REMEMBER_LAST && panelLastIsMaximized); } + private auxiliaryBarOpensMaximized(): boolean { + return this.configurationService.getValue(WorkbenchLayoutSettings.AUXILIARYBAR_DEFAULT_VISIBILITY) === 'maximized'; + } + private setAuxiliaryBarHidden(hidden: boolean, skipLayout?: boolean): void { if (hidden && this.setAuxiliaryBarMaximized(false) && !this.isVisible(Parts.AUXILIARYBAR_PART)) { return; // return: leaving maximised auxiliary bar made this part hidden diff --git a/src/vs/workbench/browser/workbench.ts b/src/vs/workbench/browser/workbench.ts index afe48b84b2e26..10e2c3edace79 100644 --- a/src/vs/workbench/browser/workbench.ts +++ b/src/vs/workbench/browser/workbench.ts @@ -88,6 +88,11 @@ export class Workbench extends Layout { private registerErrorHandler(logService: ILogService): void { + // Increase stack trace limit for better errors stacks + if (!isFirefox) { + Error.stackTraceLimit = 100; + } + // Listen on unhandled rejection events // Note: intentionally not registered as disposable to handle // errors that can occur during shutdown phase. diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 3d644c832296b..8a7a6234a2bfd 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -24,7 +24,7 @@ import { } from '../../../../platform/browserView/common/browserView.js'; import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { isLocalhost } from '../../../../platform/tunnel/common/tunnel.js'; +import { isLocalhostAuthority } from '../../../../platform/url/common/trustedDomains.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; @@ -309,7 +309,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { private logNavigationTelemetry(navigationType: IntegratedBrowserNavigationEvent['navigationType'], url: string): void { let localhost: boolean; try { - localhost = isLocalhost(new URL(url).hostname); + localhost = isLocalhostAuthority(new URL(url).host); } catch { localhost = false; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 851a16d61b045..df5498d137bc9 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -41,6 +41,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { IChatRequestVariableEntry } from '../../chat/common/attachments/chatVariableEntries.js'; import { IBrowserTargetLocator, getDisplayNameFromOuterHTML } from '../../../../platform/browserElements/common/browserElements.js'; +import { logBrowserOpen } from './browserViewTelemetry.js'; export const CONTEXT_BROWSER_CAN_GO_BACK = new RawContextKey('browserCanGoBack', false, localize('browser.canGoBack', "Whether the browser can go back")); export const CONTEXT_BROWSER_CAN_GO_FORWARD = new RawContextKey('browserCanGoForward', false, localize('browser.canGoForward', "Whether the browser can go forward")); @@ -337,22 +338,7 @@ export class BrowserEditor extends EditorPane { })); this._inputDisposables.add(this._model.onDidRequestNewPage(({ url, name, background }) => { - type IntegratedBrowserNewPageRequestEvent = { - background: boolean; - }; - - type IntegratedBrowserNewPageRequestClassification = { - background: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether page was requested to open in background' }; - owner: 'kycutler'; - comment: 'Tracks new page requests from integrated browser'; - }; - - this.telemetryService.publicLog2( - 'integratedBrowser.newPageRequest', - { - background - } - ); + logBrowserOpen(this.telemetryService, background ? 'browserLinkBackground' : 'browserLinkForeground'); // Open a new browser tab for the requested URL const browserUri = BrowserViewUri.forUrl(url, name ? `${input.id}-${name}` : undefined); @@ -544,6 +530,15 @@ export class BrowserEditor extends EditorPane { this._elementSelectionCts = cts; this._elementSelectionActiveContext.set(true); + type IntegratedBrowserAddElementToChatStartEvent = {}; + + type IntegratedBrowserAddElementToChatStartClassification = { + owner: 'jruales'; + comment: 'The user initiated an Add Element to Chat action in Integrated Browser.'; + }; + + this.telemetryService.publicLog2('integratedBrowser.addElementToChat.start', {}); + try { // Get the resource URI for this editor const resourceUri = this.input?.resource; @@ -588,7 +583,8 @@ export class BrowserEditor extends EditorPane { }); // Attach screenshot if enabled - if (this.configurationService.getValue('chat.sendElementsToChat.attachImages') && this._model) { + const attachImages = this.configurationService.getValue('chat.sendElementsToChat.attachImages'); + if (attachImages && this._model) { const screenshotBuffer = await this._model.captureScreenshot({ quality: 90, rect: bounds @@ -607,6 +603,23 @@ export class BrowserEditor extends EditorPane { const widget = await this.chatWidgetService.revealWidget() ?? this.chatWidgetService.lastFocusedWidget; widget?.attachmentModel?.addContext(...toAttach); + type IntegratedBrowserAddElementToChatAddedEvent = { + attachCss: boolean; + attachImages: boolean; + }; + + type IntegratedBrowserAddElementToChatAddedClassification = { + attachCss: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether chat.sendElementsToChat.attachCSS was enabled.' }; + attachImages: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether chat.sendElementsToChat.attachImages was enabled.' }; + owner: 'jruales'; + comment: 'An element was successfully added to chat from Integrated Browser.'; + }; + + this.telemetryService.publicLog2('integratedBrowser.addElementToChat.added', { + attachCss, + attachImages + }); + } catch (error) { if (!cts.token.isCancellationRequested) { this.logService.error('BrowserEditor.addElementToChat: Failed to select element', error); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts index bc23c8a501dff..ed50b7d847999 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts @@ -19,6 +19,8 @@ import { IBrowserViewWorkbenchService, IBrowserViewModel } from '../common/brows import { hasKey } from '../../../../base/common/types.js'; import { ILifecycleService, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js'; import { BrowserEditor } from './browserEditor.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { logBrowserOpen } from './browserViewTelemetry.js'; const LOADING_SPINNER_SVG = (color: string | undefined) => ` @@ -58,7 +60,8 @@ export class BrowserEditorInput extends EditorInput { @IThemeService private readonly themeService: IThemeService, @IBrowserViewWorkbenchService private readonly browserViewWorkbenchService: IBrowserViewWorkbenchService, @ILifecycleService private readonly lifecycleService: ILifecycleService, - @IInstantiationService private readonly instantiationService: IInstantiationService + @IInstantiationService private readonly instantiationService: IInstantiationService, + @ITelemetryService private readonly telemetryService: ITelemetryService ) { super(); this._id = options.id; @@ -212,6 +215,8 @@ export class BrowserEditorInput extends EditorInput { * This is used during Copy into New Window. */ override copy(): EditorInput { + logBrowserOpen(this.telemetryService, 'copyToNewWindow'); + const currentUrl = this._model?.url ?? this._initialData.url; return this.instantiationService.createInstance(BrowserEditorInput, { id: generateUuid(), diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index 76569d9e25aa7..ad01938f2ec1a 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -21,6 +21,14 @@ import { Schemas } from '../../../../base/common/network.js'; import { IBrowserViewWorkbenchService } from '../common/browserView.js'; import { BrowserViewWorkbenchService } from './browserViewWorkbenchService.js'; import { BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; +import { IOpenerService, IOpener, OpenInternalOptions, OpenExternalOptions } from '../../../../platform/opener/common/opener.js'; +import { isLocalhostAuthority } from '../../../../platform/url/common/trustedDomains.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { logBrowserOpen } from './browserViewTelemetry.js'; // Register actions import './browserViewActions.js'; @@ -90,11 +98,64 @@ class BrowserEditorResolverContribution implements IWorkbenchContribution { registerWorkbenchContribution2(BrowserEditorResolverContribution.ID, BrowserEditorResolverContribution, WorkbenchPhase.BlockStartup); +/** + * Opens localhost URLs in the Integrated Browser when the setting is enabled. + */ +class LocalhostLinkOpenerContribution extends Disposable implements IWorkbenchContribution, IOpener { + static readonly ID = 'workbench.contrib.localhostLinkOpener'; + + constructor( + @IOpenerService openerService: IOpenerService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IEditorService private readonly editorService: IEditorService, + @ITelemetryService private readonly telemetryService: ITelemetryService + ) { + super(); + + this._register(openerService.registerOpener(this)); + } + + async open(resource: URI | string, _options?: OpenInternalOptions | OpenExternalOptions): Promise { + if (!this.configurationService.getValue('workbench.browser.openLocalhostLinks')) { + return false; + } + + const url = typeof resource === 'string' ? resource : resource.toString(true); + try { + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return false; + } + if (!isLocalhostAuthority(parsed.host)) { + return false; + } + } catch { + return false; + } + + logBrowserOpen(this.telemetryService, 'localhostLinkOpener'); + + const browserUri = BrowserViewUri.forUrl(url); + await this.editorService.openEditor({ resource: browserUri, options: { pinned: true } }); + return true; + } +} + +registerWorkbenchContribution2(LocalhostLinkOpenerContribution.ID, LocalhostLinkOpenerContribution, WorkbenchPhase.BlockStartup); + registerSingleton(IBrowserViewWorkbenchService, BrowserViewWorkbenchService, InstantiationType.Delayed); Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ ...workbenchConfigurationNodeBase, properties: { + 'workbench.browser.openLocalhostLinks': { + type: 'boolean', + default: false, + markdownDescription: localize( + { comment: ['This is the description for a setting.'], key: 'browser.openLocalhostLinks' }, + 'When enabled, localhost links from the terminal, chat, and other sources will open in the Integrated Browser instead of the system browser.' + ) + }, 'workbench.browser.dataStorage': { type: 'string', enum: [ diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index e4ee6bdb3f010..d662ae7f3532f 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -18,6 +18,8 @@ import { BrowserViewStorageScope } from '../../../../platform/browserView/common import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IPreferencesService } from '../../../services/preferences/common/preferences.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { logBrowserOpen } from './browserViewTelemetry.js'; // Context key expression to check if browser editor is active const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditor.ID); @@ -36,8 +38,11 @@ class OpenIntegratedBrowserAction extends Action2 { async run(accessor: ServicesAccessor, url?: string): Promise { const editorService = accessor.get(IEditorService); + const telemetryService = accessor.get(ITelemetryService); const resource = BrowserViewUri.forUrl(url); + logBrowserOpen(telemetryService, url ? 'commandWithUrl' : 'commandWithoutUrl'); + await editorService.openEditor({ resource }); } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewTelemetry.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewTelemetry.ts new file mode 100644 index 0000000000000..864a0f06acac4 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewTelemetry.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; + +/** + * Source of an Integrated Browser open event. + * + * - `'commandWithoutUrl'`: opened via the "Open Integrated Browser" command without a URL argument. + * This typically means the user ran the command manually from the Command Palette. + * - `'commandWithUrl'`: opened via the "Open Integrated Browser" command with a URL argument. + * This typically means another extension or component invoked the command programmatically. + * - `'localhostLinkOpener'`: opened via the localhost link opener when the + * `workbench.browser.openLocalhostLinks` setting is enabled. This happens when clicking + * localhost links from the terminal, chat, or other sources. + * - `'browserLinkForeground'`: opened when clicking a link inside the Integrated Browser that + * opens in a new focused editor (e.g., links with target="_blank"). + * - `'browserLinkBackground'`: opened when clicking a link inside the Integrated Browser that + * opens in a new background editor (e.g., Ctrl/Cmd+click). + * - `'copyToNewWindow'`: opened when the user copies a browser editor to a new window + * via "Copy into New Window". + */ +export type IntegratedBrowserOpenSource = 'commandWithoutUrl' | 'commandWithUrl' | 'localhostLinkOpener' | 'browserLinkForeground' | 'browserLinkBackground' | 'copyToNewWindow'; + +type IntegratedBrowserOpenEvent = { + source: IntegratedBrowserOpenSource; +}; + +type IntegratedBrowserOpenClassification = { + source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How the Integrated Browser was opened' }; + owner: 'jruales'; + comment: 'Tracks how users open the Integrated Browser'; +}; + +export function logBrowserOpen(telemetryService: ITelemetryService, source: IntegratedBrowserOpenSource): void { + telemetryService.publicLog2( + 'integratedBrowser.open', + { source } + ); +} diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 316ce529b610f..d1725928b6201 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -1176,7 +1176,7 @@ registerAction2(class EditToolApproval extends Action2 { async run(accessor: ServicesAccessor, scope?: 'workspace' | 'profile' | 'session'): Promise { const confirmationService = accessor.get(ILanguageModelToolsConfirmationService); const toolsService = accessor.get(ILanguageModelToolsService); - confirmationService.manageConfirmationPreferences([...toolsService.getTools()], scope ? { defaultScope: scope } : undefined); + confirmationService.manageConfirmationPreferences([...toolsService.getAllToolsIncludingDisabled()], scope ? { defaultScope: scope } : undefined); } }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts index 6154a9e95cefa..28eb5e8705163 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts @@ -24,7 +24,7 @@ import { NotebookEditorInput } from '../../../notebook/common/notebookEditorInpu import { IChatContextPickService, IChatContextValueItem, IChatContextPickerItem, IChatContextPickerPickItem, IChatContextPicker } from '../attachments/chatContextPickService.js'; import { IChatEditingService } from '../../common/editing/chatEditingService.js'; import { IChatRequestToolEntry, IChatRequestToolSetEntry, IChatRequestVariableEntry, IImageVariableEntry, OmittedState, toToolSetVariableEntry, toToolVariableEntry } from '../../common/attachments/chatVariableEntries.js'; -import { ToolDataSource, ToolSet } from '../../common/tools/languageModelToolsService.js'; +import { isToolSet, ToolDataSource } from '../../common/tools/languageModelToolsService.js'; import { IChatWidget } from '../chat.js'; import { imageToHash, isImage } from '../widget/input/editor/chatPasteProviders.js'; import { convertBufferToScreenshotVariable } from '../attachments/chatScreenshotContext.js'; @@ -79,7 +79,7 @@ class ToolsContextPickerPick implements IChatContextPickerItem { for (const [entry, enabled] of widget.input.selectedToolsModel.entriesMap.get()) { if (enabled) { - if (entry instanceof ToolSet) { + if (isToolSet(entry)) { items.push({ toolInfo: ToolDataSource.classify(entry.source), label: entry.referenceName, @@ -242,7 +242,7 @@ class ClipboardImageContextValuePick implements IChatContextValueItem { if (!widget.attachmentCapabilities.supportsImageAttachments) { return false; } - if (!widget.input.selectedLanguageModel?.metadata.capabilities?.vision) { + if (!widget.input.selectedLanguageModel.get()?.metadata.capabilities?.vision) { return false; } const imageData = await this._clipboardService.readImage(); @@ -341,7 +341,7 @@ class ScreenshotContextValuePick implements IChatContextValueItem { ) { } async isEnabled(widget: IChatWidget) { - return !!widget.attachmentCapabilities.supportsImageAttachments && !!widget.input.selectedLanguageModel?.metadata.capabilities?.vision; + return !!widget.attachmentCapabilities.supportsImageAttachments && !!widget.input.selectedLanguageModel.get()?.metadata.capabilities?.vision; } async asAttachment(): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatDeveloperActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatDeveloperActions.ts index 5785c66df3c83..dfa15fb1b86c8 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatDeveloperActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatDeveloperActions.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../../base/common/codicons.js'; +import { isUriComponents, URI } from '../../../../../base/common/uri.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; import { localize2 } from '../../../../../nls.js'; import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; @@ -13,6 +14,19 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatService } from '../../common/chatService/chatService.js'; import { IChatWidgetService } from '../chat.js'; +function uriReplacer(_key: string, value: unknown): unknown { + if (URI.isUri(value)) { + return value.toString(); + } + + if (isUriComponents(value)) { + // This shouldn't be necessary but it seems that some URIs in ChatModels aren't properly revived + return URI.from(value).toString(); + } + + return value; +} + export function registerChatDeveloperActions() { registerAction2(LogChatInputHistoryAction); registerAction2(LogChatIndexAction); @@ -94,13 +108,13 @@ class InspectChatModelAction extends Action2 { const latestRequest = requests[requests.length - 1]; if (latestRequest.response) { output += '## Latest Response\n\n'; - output += '```json\n' + JSON.stringify(latestRequest.response, null, 2) + '\n```\n\n'; + output += '```json\n' + JSON.stringify(latestRequest.response, uriReplacer, 2) + '\n```\n\n'; } } // Show full model data output += '## Full Chat Model\n\n'; - output += '```json\n' + JSON.stringify(modelData, null, 2) + '\n```\n'; + output += '```json\n' + JSON.stringify(modelData, uriReplacer, 2) + '\n```\n'; await editorService.openEditor({ resource: undefined, diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index b21932f41f354..abf92d9dab95e 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -560,7 +560,10 @@ export class OpenWorkspacePickerAction extends Action2 { { id: MenuId.ChatInput, order: 0.1, - when: ChatContextKeys.inAgentSessionsWelcome, + when: ContextKeyExpr.and( + ChatContextKeys.inAgentSessionsWelcome, + ChatContextKeys.chatSessionType.isEqualTo('local') + ), group: 'navigation', }, ] diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts index f37633f0f9410..b113a60e55c23 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts @@ -24,7 +24,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ConfirmedReason, IChatToolInvocation, ToolConfirmKind } from '../../common/chatService/chatService.js'; import { isResponseVM } from '../../common/model/chatViewModel.js'; -import { ChatModeKind } from '../../common/constants.js'; +import { ChatConfiguration, ChatModeKind } from '../../common/constants.js'; import { IChatWidget, IChatWidgetService } from '../chat.js'; import { ToolsScope } from '../widget/input/chatSelectedTools.js'; import { CHAT_CATEGORY } from './chatActions.js'; @@ -125,7 +125,11 @@ class ConfigureToolsAction extends Action2 { category: CHAT_CATEGORY, precondition: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), menu: [{ - when: ContextKeyExpr.and(ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), ChatContextKeys.lockedToCodingAgent.negate()), + when: ContextKeyExpr.and( + ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + ChatContextKeys.lockedToCodingAgent.negate(), + ContextKeyExpr.notEquals(`config.${ChatConfiguration.AlternativeToolAction}`, true) + ), id: MenuId.ChatInput, group: 'navigation', order: 100, @@ -141,20 +145,15 @@ class ConfigureToolsAction extends Action2 { let widget = chatWidgetService.lastFocusedWidget; if (!widget) { - type ChatActionContext = { widget: IChatWidget }; - function isChatActionContext(obj: unknown): obj is ChatActionContext { - return !!obj && typeof obj === 'object' && !!(obj as ChatActionContext).widget; - } - const context = args[0]; - if (isChatActionContext(context)) { - widget = context.widget; - } + widget = this.extractWidget(args); } if (!widget) { return; } + const source = this.extractSource(args) ?? 'chatInput'; + let placeholder; let description; const { entriesScope, entriesMap } = widget.input.selectedToolsModel; @@ -188,7 +187,7 @@ class ConfigureToolsAction extends Action2 { }); try { - const result = await instaService.invokeFunction(showToolsPicker, placeholder, 'chatInput', description, () => entriesMap.get(), cts.token); + const result = await instaService.invokeFunction(showToolsPicker, placeholder, source, description, () => entriesMap.get(), widget.input.selectedLanguageModel.get()?.metadata, cts.token); if (result) { widget.input.selectedToolsModel.set(result, false); } @@ -203,6 +202,36 @@ class ConfigureToolsAction extends Action2 { enabled: Iterable.reduce(tools, (prev, [_, enabled]) => enabled ? prev + 1 : prev, 0), }); } + + private extractWidget(args: unknown[]): IChatWidget | undefined { + type ChatActionContext = { widget: IChatWidget }; + function isChatActionContext(obj: unknown): obj is ChatActionContext { + return !!obj && typeof obj === 'object' && !!(obj as ChatActionContext).widget; + } + + for (const arg of args) { + if (isChatActionContext(arg)) { + return arg.widget; + } + } + + return undefined; + } + + private extractSource(args: unknown[]): string | undefined { + type ChatActionSource = { source: string }; + function isChatActionSource(obj: unknown): obj is ChatActionSource { + return !!obj && typeof obj === 'object' && !!(obj as ChatActionSource).source; + } + + for (const arg of args) { + if (isChatActionSource(arg)) { + return arg.source; + } + } + + return undefined; + } } class ConfigureToolsActionRendering implements IWorkbenchContribution { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts index 2c17502c21b68..3363a0172890f 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts @@ -25,7 +25,8 @@ import { IMcpRegistry } from '../../../mcp/common/mcpRegistryTypes.js'; import { IMcpServer, IMcpService, IMcpWorkbenchService, McpConnectionState, McpServerCacheState, McpServerEditorTab } from '../../../mcp/common/mcpTypes.js'; import { startServerAndWaitForLiveTools } from '../../../mcp/common/mcpTypesUtils.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { ILanguageModelToolsService, IToolData, ToolDataSource, ToolSet } from '../../common/tools/languageModelToolsService.js'; +import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; +import { ILanguageModelToolsService, IToolData, IToolSet, ToolDataSource, ToolSet } from '../../common/tools/languageModelToolsService.js'; import { ConfigureToolSets } from '../tools/toolSetsContribution.js'; const enum BucketOrdinal { User, BuiltIn, Mcp, Extension } @@ -55,7 +56,7 @@ interface IToolTreeItem extends IQuickTreeItem { interface IBucketTreeItem extends IToolTreeItem { readonly itemType: 'bucket'; readonly ordinal: BucketOrdinal; - toolset?: ToolSet; // For MCP servers where the bucket represents the ToolSet - mutable + toolset?: IToolSet; // For MCP servers where the bucket represents the ToolSet - mutable readonly status?: string; readonly children: AnyTreeItem[]; checked: boolean | 'mixed' | undefined; @@ -68,7 +69,7 @@ interface IBucketTreeItem extends IToolTreeItem { */ interface IToolSetTreeItem extends IToolTreeItem { readonly itemType: 'toolset'; - readonly toolset: ToolSet; + readonly toolset: IToolSet; children: AnyTreeItem[] | undefined; checked: boolean | 'mixed'; } @@ -148,7 +149,7 @@ function createToolTreeItemFromData(tool: IToolData, checked: boolean): IToolTre }; } -function createToolSetTreeItem(toolset: ToolSet, checked: boolean, editorService: IEditorService): IToolSetTreeItem { +function createToolSetTreeItem(toolset: IToolSet, checked: boolean, editorService: IEditorService): IToolSetTreeItem { const iconProps = mapIconToTreeItem(toolset.icon); const buttons = []; if (toolset.source.type === 'user') { @@ -185,6 +186,8 @@ function createToolSetTreeItem(toolset: ToolSet, checked: boolean, editorService * @param placeHolder - Placeholder text shown in the picker * @param description - Optional description text shown in the picker * @param toolsEntries - Optional initial selection state for tools and toolsets + * @param modelId - Optional model ID to filter tools by supported models + * @param onUpdate - Optional callback fired when the selection changes * @param token - Optional cancellation token to close the picker when cancelled * @returns Promise resolving to the final selection map, or undefined if cancelled */ @@ -193,9 +196,10 @@ export async function showToolsPicker( placeHolder: string, source: string, description?: string, - getToolsEntries?: () => ReadonlyMap, + getToolsEntries?: () => ReadonlyMap, + model?: ILanguageModelChatMetadata | undefined, token?: CancellationToken -): Promise | undefined> { +): Promise | undefined> { const quickPickService = accessor.get(IQuickInputService); const mcpService = accessor.get(IMcpService); @@ -215,23 +219,23 @@ export async function showToolsPicker( } } - function computeItems(previousToolsEntries?: ReadonlyMap) { + function computeItems(previousToolsEntries?: ReadonlyMap) { // Create default entries if none provided - let toolsEntries = getToolsEntries ? new Map(getToolsEntries()) : undefined; + let toolsEntries = getToolsEntries ? new Map([...getToolsEntries()].map(([k, enabled]) => [k.id, enabled])) : undefined; if (!toolsEntries) { const defaultEntries = new Map(); - for (const tool of toolsService.getTools()) { + for (const tool of toolsService.getTools(model)) { if (tool.canBeReferencedInPrompt) { defaultEntries.set(tool, false); } } - for (const toolSet of toolsService.toolSets.get()) { + for (const toolSet of toolsService.getToolSetsForModel(model)) { defaultEntries.set(toolSet, false); } toolsEntries = defaultEntries; } previousToolsEntries?.forEach((value, key) => { - toolsEntries.set(key, value); + toolsEntries.set(key.id, value); }); // Build tree structure @@ -383,15 +387,15 @@ export async function showToolsPicker( return bucket; }; - for (const toolSet of toolsService.toolSets.get()) { - if (!toolsEntries.has(toolSet)) { + for (const toolSet of toolsService.getToolSetsForModel(model)) { + if (!toolsEntries.has(toolSet.id)) { continue; } const bucket = getBucket(toolSet.source); if (!bucket) { continue; } - const toolSetChecked = toolsEntries.get(toolSet) === true; + const toolSetChecked = toolsEntries.get(toolSet.id) === true; if (toolSet.source.type === 'mcp') { // bucket represents the toolset bucket.toolset = toolSet; @@ -404,7 +408,7 @@ export async function showToolsPicker( bucket.children.push(treeItem); const children = []; for (const tool of toolSet.getTools()) { - const toolChecked = toolSetChecked || toolsEntries.get(tool) === true; + const toolChecked = toolSetChecked || toolsEntries.get(tool.id) === true; const toolTreeItem = createToolTreeItemFromData(tool, toolChecked); children.push(toolTreeItem); } @@ -413,15 +417,16 @@ export async function showToolsPicker( } } } - for (const tool of toolsService.getTools()) { - if (!tool.canBeReferencedInPrompt || !toolsEntries.has(tool)) { + // getting potentially disabled tools is fine here because we filter `toolsEntries.has` + for (const tool of toolsService.getAllToolsIncludingDisabled()) { + if (!tool.canBeReferencedInPrompt || !toolsEntries.has(tool.id)) { continue; } const bucket = getBucket(tool.source); if (!bucket) { continue; } - const toolChecked = bucket.checked === true || toolsEntries.get(tool) === true; + const toolChecked = bucket.checked === true || toolsEntries.get(tool.id) === true; const toolTreeItem = createToolTreeItemFromData(tool, toolChecked); bucket.children.push(toolTreeItem); } @@ -508,7 +513,7 @@ export async function showToolsPicker( const collectResults = () => { - const result = new Map(); + const result = new Map(); const traverse = (items: readonly AnyTreeItem[]) => { for (const item of items) { if (isBucketTreeItem(item)) { @@ -611,7 +616,7 @@ export async function showToolsPicker( return didAccept ? collectResults() : undefined; } -function serializeToolsState(state: ReadonlyMap): string { +function serializeToolsState(state: ReadonlyMap): string { const entries: [string, boolean][] = []; state.forEach((value, key) => { entries.push([key.id, value]); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts index 173f5bc852b3e..be01ac3973c42 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts @@ -13,6 +13,10 @@ import { IEditorOptions } from '../../../../../platform/editor/common/editor.js' import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { Schemas } from '../../../../../base/common/network.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { localize } from '../../../../../nls.js'; +import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; //#region Session Opener Registry @@ -50,12 +54,17 @@ export const sessionOpenerRegistry = new SessionOpenerRegistry(); export async function openSession(accessor: ServicesAccessor, session: IAgentSession, openOptions?: ISessionOpenOptions): Promise { const instantiationService = accessor.get(IInstantiationService); + const logService = accessor.get(ILogService); // First, give registered participants a chance to handle the session for (const participant of sessionOpenerRegistry.getParticipants()) { - const handled = await instantiationService.invokeFunction(accessor => participant.handleOpenSession(accessor, session, openOptions)); - if (handled) { - return undefined; // Participant handled the session, skip default opening + try { + const handled = await instantiationService.invokeFunction(accessor => participant.handleOpenSession(accessor, session, openOptions)); + if (handled) { + return undefined; // Participant handled the session, skip default opening + } + } catch (error) { + logService.error(error); // log error but continue to support opening from default logic } } @@ -66,36 +75,42 @@ export async function openSession(accessor: ServicesAccessor, session: IAgentSes async function openSessionDefault(accessor: ServicesAccessor, session: IAgentSession, openOptions?: ISessionOpenOptions): Promise { const chatSessionsService = accessor.get(IChatSessionsService); const chatWidgetService = accessor.get(IChatWidgetService); + const notificationService = accessor.get(INotificationService); - session.setRead(true); // mark as read when opened + try { + session.setRead(true); // mark as read when opened - let sessionOptions: IChatEditorOptions; - if (isLocalAgentSessionItem(session)) { - sessionOptions = {}; - } else { - sessionOptions = { title: { preferred: session.label } }; - } + let sessionOptions: IChatEditorOptions; + if (isLocalAgentSessionItem(session)) { + sessionOptions = {}; + } else { + sessionOptions = { title: { preferred: session.label } }; + } - let options: IChatEditorOptions = { - ...sessionOptions, - ...openOptions?.editorOptions, - revealIfOpened: true, // always try to reveal if already opened - }; + let options: IChatEditorOptions = { + ...sessionOptions, + ...openOptions?.editorOptions, + revealIfOpened: true, // always try to reveal if already opened + }; - await chatSessionsService.activateChatSessionItemProvider(session.providerType); // ensure provider is activated before trying to open + await chatSessionsService.activateChatSessionItemProvider(session.providerType); // ensure provider is activated before trying to open - let target: typeof SIDE_GROUP | typeof ACTIVE_GROUP | typeof ChatViewPaneTarget | undefined; - if (openOptions?.sideBySide) { - target = ACTIVE_GROUP; - } else { - target = ChatViewPaneTarget; - } + let target: typeof SIDE_GROUP | typeof ACTIVE_GROUP | typeof ChatViewPaneTarget | undefined; + if (openOptions?.sideBySide) { + target = ACTIVE_GROUP; + } else { + target = ChatViewPaneTarget; + } - const isLocalChatSession = session.resource.scheme === Schemas.vscodeChatEditor || session.resource.scheme === Schemas.vscodeLocalChatSession; - if (!isLocalChatSession && !(await chatSessionsService.canResolveChatSession(session.resource))) { - target = openOptions?.sideBySide ? SIDE_GROUP : ACTIVE_GROUP; // force to open in editor if session cannot be resolved in panel - options = { ...options, revealIfOpened: true }; - } + const isLocalChatSession = session.resource.scheme === Schemas.vscodeChatEditor || session.resource.scheme === Schemas.vscodeLocalChatSession; + if (!isLocalChatSession && !(await chatSessionsService.canResolveChatSession(session.resource))) { + target = openOptions?.sideBySide ? SIDE_GROUP : ACTIVE_GROUP; // force to open in editor if session cannot be resolved in panel + options = { ...options, revealIfOpened: true }; + } - return chatWidgetService.openSession(session.resource, target, options); + return await chatWidgetService.openSession(session.resource, target, options); + } catch (error) { + notificationService.error(localize('chat.openSessionFailed', "Failed to open chat session: {0}", toErrorMessage(error))); + return undefined; + } } diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts index 7a212fe3b53d2..c93d58d4be393 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts @@ -59,7 +59,7 @@ import { ITerminalService } from '../../../terminal/browser/terminal.js'; import { IChatContentReference } from '../../common/chatService/chatService.js'; import { IChatRequestPasteVariableEntry, IChatRequestVariableEntry, IElementVariableEntry, INotebookOutputVariableEntry, IPromptFileVariableEntry, IPromptTextVariableEntry, ISCMHistoryItemVariableEntry, OmittedState, PromptFileVariableKind, ChatRequestToolReferenceEntry, ISCMHistoryItemChangeVariableEntry, ISCMHistoryItemChangeRangeVariableEntry, ITerminalVariableEntry, isStringVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../common/languageModels.js'; -import { ILanguageModelToolsService, ToolSet } from '../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, isToolSet } from '../../common/tools/languageModelToolsService.js'; import { getCleanPromptName } from '../../common/promptSyntax/config/promptFileLocations.js'; import { IChatContextService } from '../contextContrib/chatContextService.js'; @@ -748,17 +748,17 @@ export class ToolSetOrToolItemAttachmentWidget extends AbstractChatAttachmentWid @ILanguageModelToolsService toolsService: ILanguageModelToolsService, @ICommandService commandService: ICommandService, @IOpenerService openerService: IOpenerService, - @IHoverService hoverService: IHoverService + @IHoverService hoverService: IHoverService, ) { super(attachment, options, container, contextResourceLabels, currentLanguageModel, commandService, openerService); - const toolOrToolSet = Iterable.find(toolsService.getTools(), tool => tool.id === attachment.id) ?? Iterable.find(toolsService.toolSets.get(), toolSet => toolSet.id === attachment.id); + const toolOrToolSet = Iterable.find(toolsService.getTools(currentLanguageModel?.metadata), tool => tool.id === attachment.id) ?? Iterable.find(toolsService.getToolSetsForModel(currentLanguageModel?.metadata), toolSet => toolSet.id === attachment.id); let name = attachment.name; const icon = attachment.icon ?? Codicon.tools; - if (toolOrToolSet instanceof ToolSet) { + if (isToolSet(toolOrToolSet)) { name = toolOrToolSet.referenceName; } else if (toolOrToolSet) { name = toolOrToolSet.toolReferenceName ?? name; @@ -771,7 +771,7 @@ export class ToolSetOrToolItemAttachmentWidget extends AbstractChatAttachmentWid let hoverContent: string | undefined; - if (toolOrToolSet instanceof ToolSet) { + if (isToolSet(toolOrToolSet)) { hoverContent = localize('toolset', "{0} - {1}", toolOrToolSet.description ?? toolOrToolSet.referenceName, toolOrToolSet.source.label); } else if (toolOrToolSet) { hoverContent = localize('tool', "{0} - {1}", toolOrToolSet.userDescription ?? toolOrToolSet.modelDescription, toolOrToolSet.source.label); diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 7046653e7bbdb..ab9c026c40e90 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -255,6 +255,16 @@ configurationRegistry.registerConfiguration({ markdownDescription: nls.localize('chat.editing.confirmEditRequestRetry', "Whether to show a confirmation before retrying a request and its associated edits."), default: true, }, + 'chat.editing.explainChanges.enabled': { + type: 'boolean', + scope: ConfigurationScope.APPLICATION, + markdownDescription: nls.localize('chat.editing.explainChanges.enabled', "Controls whether the Explain button in the Chat panel and the Explain Changes context menu in the SCM view are shown. This is an experimental feature."), + default: false, + tags: ['experimental'], + experiment: { + mode: 'auto' + } + }, 'chat.experimental.detectParticipant.enabled': { type: 'boolean', deprecationMessage: nls.localize('chat.experimental.detectParticipant.enabled.deprecated', "This setting is deprecated. Please use `chat.detectParticipant.enabled` instead."), @@ -581,6 +591,15 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, + [ChatConfiguration.AlternativeToolAction]: { + type: 'boolean', + description: nls.localize('chat.alternativeToolAction', "When enabled, shows the Configure Tools action in the mode picker dropdown on hover instead of in the chat input."), + default: false, + tags: ['experimental'], + experiment: { + mode: 'auto' + } + }, [ChatConfiguration.EnableMath]: { type: 'boolean', description: nls.localize('chat.mathEnabled.description', "Enable math rendering in chat responses using KaTeX."), @@ -796,6 +815,24 @@ configurationRegistry.registerConfiguration({ disallowConfigurationDefault: true, tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'] }, + [PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS]: { + type: 'boolean', + title: nls.localize('chat.includeApplyingInstructions.title', "Include Applying Instructions",), + markdownDescription: nls.localize('chat.includeApplyingInstructions.description', "Controls whether instructions with a matching 'applyTo' attribute are automatically included in chat requests.",), + default: true, + restricted: true, + disallowConfigurationDefault: true, + tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'] + }, + [PromptsConfig.INCLUDE_REFERENCED_INSTRUCTIONS]: { + type: 'boolean', + title: nls.localize('chat.includeReferencedInstructions.title', "Include Referenced Instructions",), + markdownDescription: nls.localize('chat.includeReferencedInstructions.description', "Controls whether referenced instructions are automatically included in chat requests.",), + default: false, + restricted: true, + disallowConfigurationDefault: true, + tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'] + }, [PromptsConfig.SKILLS_LOCATION_KEY]: { type: 'object', title: nls.localize('chat.agentSkillsLocations.title', "Agent Skills Locations",), @@ -895,6 +932,11 @@ configurationRegistry.registerConfiguration({ markdownDescription: nls.localize('chat.agent.thinking.terminalTools', "When enabled, terminal tool calls are displayed inside the thinking dropdown with a simplified view."), tags: ['experimental'], }, + [ChatConfiguration.AutoExpandToolFailures]: { + type: 'boolean', + default: true, + markdownDescription: nls.localize('chat.tools.autoExpandFailures', "When enabled, tool failures are automatically expanded in the chat UI to show error details."), + }, 'chat.disableAIFeatures': { type: 'boolean', description: nls.localize('chat.disableAIFeatures', "Disable and hide built-in AI features provided by GitHub Copilot, including chat and inline suggestions."), @@ -1151,7 +1193,7 @@ class ToolReferenceNamesContribution extends Disposable implements IWorkbenchCon private _updateToolReferenceNames(): void { const tools = - Array.from(this._languageModelToolsService.getTools()) + Array.from(this._languageModelToolsService.getAllToolsIncludingDisabled()) .filter((tool): tool is typeof tool & { toolReferenceName: string } => typeof tool.toolReferenceName === 'string') .sort((a, b) => a.toolReferenceName.localeCompare(b.toolReferenceName)); toolReferenceNameEnumValues.length = 0; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts index 5d245b48a1c69..ab10c9fec5f1f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts @@ -255,6 +255,34 @@ export class ChatEditingDiscardAllAction extends EditingSessionAction { } registerAction2(ChatEditingDiscardAllAction); +export class ToggleExplanationWidgetAction extends EditingSessionAction { + + static readonly ID = 'chatEditing.toggleExplanationWidget'; + + constructor() { + super({ + id: ToggleExplanationWidgetAction.ID, + title: localize('explainButton', 'Explain'), + tooltip: localize('toggleExplanationTooltip', 'Toggle Change Explanations'), + precondition: hasUndecidedChatEditingResourceContextKey, + menu: [ + { + id: MenuId.ChatEditingWidgetToolbar, + group: 'navigation', + order: 2, + when: ContextKeyExpr.and(hasUndecidedChatEditingResourceContextKey, ContextKeyExpr.has(`config.${ChatConfiguration.ExplainChangesEnabled}`)) + } + ], + }); + } + + override async runEditingSessionAction(accessor: ServicesAccessor, editingSession: IChatEditingSession, chatWidget: IChatWidget, ...args: unknown[]) { + const current = editingSession.explanationWidgetVisible.get(); + editingSession.explanationWidgetVisible.set(!current, undefined); + } +} +registerAction2(ToggleExplanationWidgetAction); + export async function discardAllEditsWithConfirmation(accessor: ServicesAccessor, currentEditingSession: IChatEditingSession): Promise { const dialogService = accessor.get(IDialogService); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCodeEditorIntegration.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCodeEditorIntegration.ts index 72074bcfcc6e5..247db169c8376 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCodeEditorIntegration.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCodeEditorIntegration.ts @@ -36,12 +36,16 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { EditorsOrder, IEditorIdentifier, isDiffEditorInput } from '../../../../common/editor.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { minimapGutterAddedBackground, minimapGutterDeletedBackground, minimapGutterModifiedBackground, overviewRulerAddedForeground, overviewRulerDeletedForeground, overviewRulerModifiedForeground } from '../../../scm/common/quickDiff.js'; -import { IModifiedFileEntry, IModifiedFileEntryChangeHunk, IModifiedFileEntryEditorIntegration, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; +import { IChatEditingService, IModifiedFileEntry, IModifiedFileEntryChangeHunk, IModifiedFileEntryEditorIntegration, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { isTextDiffEditorForEntry } from './chatEditing.js'; import { ActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { ctxCursorInChangeRange } from './chatEditingEditorContextKeys.js'; import { LinkedList } from '../../../../../base/common/linkedList.js'; +import { ChatEditingExplanationWidgetManager } from './chatEditingExplanationWidget.js'; +import { ILanguageModelsService } from '../../common/languageModels.js'; +import { IChatWidgetService } from '../chat.js'; +import { IViewsService } from '../../../../services/views/common/viewsService.js'; export interface IDocumentDiff2 extends IDocumentDiff { @@ -90,6 +94,8 @@ export class ChatEditingCodeEditorIntegration implements IModifiedFileEntryEdito private readonly _accessibleDiffViewVisible = observableValue(this, false); + private _explanationWidgetManager: ChatEditingExplanationWidgetManager | undefined = undefined; + constructor( private readonly _entry: IModifiedFileEntry, private readonly _editor: ICodeEditor, @@ -99,6 +105,10 @@ export class ChatEditingCodeEditorIntegration implements IModifiedFileEntryEdito @IAccessibilitySignalService private readonly _accessibilitySignalsService: IAccessibilitySignalService, @IContextKeyService contextKeyService: IContextKeyService, @IInstantiationService instantiationService: IInstantiationService, + @IChatEditingService private readonly _chatEditingService: IChatEditingService, + @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @IViewsService private readonly _viewsService: IViewsService, ) { this._diffLineDecorations = _editor.createDecorationsCollection(); const codeEditorObs = observableCodeEditor(_editor); @@ -106,6 +116,21 @@ export class ChatEditingCodeEditorIntegration implements IModifiedFileEntryEdito this._diffLineDecorations = this._editor.createDecorationsCollection(); // tracks the line range w/o visuals (used for navigate) this._diffVisualDecorations = this._editor.createDecorationsCollection(); // tracks the real diff with character level inserts + this._store.add(autorun(r => { + const visible = this._entry.explanationWidgetVisible?.read(r) ?? false; + if (visible) { + if (!this._explanationWidgetManager) { + this._explanationWidgetManager = this._store.add(new ChatEditingExplanationWidgetManager(this._editor, this._languageModelsService, this._chatWidgetService, this._viewsService)); + // Pass the current diff info when creating lazily (untracked - diff changes handled separately) + const diff = documentDiffInfo.read(undefined); + this._explanationWidgetManager.update(diff, true); + } + this._explanationWidgetManager.show(); + } else { + this._explanationWidgetManager?.hide(); + } + })); + const enabledObs = derived(r => { if (!isEqual(codeEditorObs.model.read(r)?.uri, documentDiffInfo.read(r).modifiedModel.uri)) { return false; @@ -466,6 +491,17 @@ export class ChatEditingCodeEditorIntegration implements IModifiedFileEntryEdito extraWidget.remove(); } + // Update explanation widgets for chat changes + // Get visibility from session that owns this entry + let explanationVisible = false; + for (const session of this._chatEditingService.editingSessionsObs.get()) { + if (session.entries.get().some((e: IModifiedFileEntry) => e.entryId === this._entry.entryId)) { + explanationVisible = session.explanationWidgetVisible.get(); + break; + } + } + this._explanationWidgetManager?.update(diff, explanationVisible); + const positionObs = observableFromEvent(this._editor.onDidChangeCursorPosition, _ => this._editor.getPosition()); const activeWidgetIdx = derived(r => { diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingExplanationWidget.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingExplanationWidget.ts new file mode 100644 index 0000000000000..e55d821dcc0ee --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingExplanationWidget.ts @@ -0,0 +1,687 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/chatEditingExplanationWidget.css'; + +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Event } from '../../../../../base/common/event.js'; +import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition } from '../../../../../editor/browser/editorBrowser.js'; +import { EditorOption } from '../../../../../editor/common/config/editorOptions.js'; +import { DetailedLineRangeMapping, LineRangeMapping } from '../../../../../editor/common/diff/rangeMapping.js'; +import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { $, addDisposableListener, clearNode, getTotalWidth } from '../../../../../base/browser/dom.js'; +import { ChatMessageRole, ILanguageModelsService } from '../../common/languageModels.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { ChatViewId, IChatWidgetService } from '../chat.js'; +import { IViewsService } from '../../../../services/views/common/viewsService.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import * as nls from '../../../../../nls.js'; + +/** + * Simple diff info interface for explanation widgets + * Does not require chat editing session methods like keep/undo + */ +export interface IExplanationDiffInfo { + readonly changes: readonly (LineRangeMapping | DetailedLineRangeMapping)[]; + readonly identical: boolean; + readonly originalModel: ITextModel; + readonly modifiedModel: ITextModel; +} + +/** + * Explanation data for a single change hunk + */ +interface IChangeExplanation { + readonly startLineNumber: number; + readonly endLineNumber: number; + explanation: string; + read: boolean; + loading: boolean; + readonly originalText: string; + readonly modifiedText: string; +} + +/** + * Gets the text content for a change + */ +function getChangeTexts(change: LineRangeMapping | DetailedLineRangeMapping, diffInfo: IExplanationDiffInfo): { originalText: string; modifiedText: string } { + const originalLines: string[] = []; + const modifiedLines: string[] = []; + + // Get original text + for (let i = change.original.startLineNumber; i < change.original.endLineNumberExclusive; i++) { + const line = diffInfo.originalModel.getLineContent(i); + originalLines.push(line); + } + + // Get modified text + for (let i = change.modified.startLineNumber; i < change.modified.endLineNumberExclusive; i++) { + const line = diffInfo.modifiedModel.getLineContent(i); + modifiedLines.push(line); + } + + return { + originalText: originalLines.join('\n'), + modifiedText: modifiedLines.join('\n') + }; +} + +/** + * Groups nearby changes within a threshold number of lines + */ +function groupNearbyChanges(changes: readonly T[], lineThreshold: number = 5): T[][] { + if (changes.length === 0) { + return []; + } + + const groups: T[][] = []; + let currentGroup: T[] = [changes[0]]; + + for (let i = 1; i < changes.length; i++) { + const prevChange = currentGroup[currentGroup.length - 1]; + const currentChange = changes[i]; + + const gap = currentChange.modified.startLineNumber - prevChange.modified.endLineNumberExclusive; + + if (gap <= lineThreshold) { + currentGroup.push(currentChange); + } else { + groups.push(currentGroup); + currentGroup = [currentChange]; + } + } + + if (currentGroup.length > 0) { + groups.push(currentGroup); + } + + return groups; +} + +/** + * Widget that displays explanatory comments for chat-made changes + * Positioned on the right side of the editor like a speech bubble + */ +export class ChatEditingExplanationWidget extends Disposable implements IOverlayWidget { + + private static _idPool = 0; + private readonly _id: string = `chat-explanation-widget-${ChatEditingExplanationWidget._idPool++}`; + + private readonly _domNode: HTMLElement; + private readonly _headerNode: HTMLElement; + private readonly _readIndicator: HTMLElement; + private readonly _titleNode: HTMLElement; + private readonly _dismissButton: HTMLElement; + private readonly _toggleButton: HTMLElement; + private readonly _bodyNode: HTMLElement; + private readonly _explanationItems: Map = new Map(); + + private _position: IOverlayWidgetPosition | null = null; + private _explanations: IChangeExplanation[] = []; + private _isExpanded: boolean = true; + private _isAllRead: boolean = false; + private _disposed: boolean = false; + private _startLineNumber: number = 1; + private readonly _cancellationTokenSource = new CancellationTokenSource(); + private readonly _uri: URI; + + private readonly _eventStore = this._register(new DisposableStore()); + + constructor( + private readonly _editor: ICodeEditor, + private _changes: readonly (LineRangeMapping | DetailedLineRangeMapping)[], + diffInfo: IExplanationDiffInfo, + private readonly _languageModelsService: ILanguageModelsService, + private readonly _chatWidgetService: IChatWidgetService, + private readonly _viewsService: IViewsService, + ) { + super(); + + this._uri = diffInfo.modifiedModel.uri; + + // Build explanations from changes with loading state + this._explanations = this._changes.map(change => { + const { originalText, modifiedText } = getChangeTexts(change, diffInfo); + return { + startLineNumber: change.modified.startLineNumber, + endLineNumber: change.modified.endLineNumberExclusive - 1, + explanation: nls.localize('generatingExplanation', "Generating explanation..."), + read: false, + loading: true, + originalText, + modifiedText, + }; + }); + + // Create DOM structure + this._domNode = $('div.chat-explanation-widget'); + + // Header + this._headerNode = $('div.chat-explanation-header'); + + // Read indicator (checkbox-like) + this._readIndicator = $('div.chat-explanation-read-indicator'); + this._updateReadIndicator(); + this._headerNode.appendChild(this._readIndicator); + + // Title showing change count + this._titleNode = $('span.chat-explanation-title'); + this._updateTitle(); + this._headerNode.appendChild(this._titleNode); + + // Spacer + this._headerNode.appendChild($('span.chat-explanation-spacer')); + + // Toggle expand/collapse button + this._toggleButton = $('div.chat-explanation-toggle'); + this._updateToggleButton(); + this._headerNode.appendChild(this._toggleButton); + + // Dismiss button + this._dismissButton = $('div.chat-explanation-dismiss'); + this._dismissButton.appendChild(renderIcon(Codicon.close)); + this._dismissButton.title = 'Dismiss'; + this._headerNode.appendChild(this._dismissButton); + + this._domNode.appendChild(this._headerNode); + + // Body (collapsible) + this._bodyNode = $('div.chat-explanation-body'); + // Body starts expanded by default + this._buildExplanationItems(); + this._domNode.appendChild(this._bodyNode); + + // Arrow pointer + const arrow = $('div.chat-explanation-arrow'); + this._domNode.appendChild(arrow); + + // Event handlers + this._setupEventHandlers(); + + // Add visible class for initial display + this._domNode.classList.add('visible'); + + // Add to editor + this._editor.addOverlayWidget(this); + } + + private _setupEventHandlers(): void { + // Read indicator click - toggle all read/unread + this._eventStore.add(addDisposableListener(this._readIndicator, 'click', (e) => { + e.stopPropagation(); + this._isAllRead = !this._isAllRead; + for (const exp of this._explanations) { + exp.read = this._isAllRead; + } + this._updateReadIndicator(); + this._updateExplanationItemsReadState(); + })); + + // Toggle button click - expand/collapse + this._eventStore.add(addDisposableListener(this._toggleButton, 'click', (e) => { + e.stopPropagation(); + this._toggleExpanded(); + })); + + // Header click - also toggles expand/collapse + this._eventStore.add(addDisposableListener(this._headerNode, 'click', () => { + this._toggleExpanded(); + })); + + // Dismiss button click + this._eventStore.add(addDisposableListener(this._dismissButton, 'click', (e) => { + e.stopPropagation(); + this._dismiss(); + })); + } + + private _toggleExpanded(): void { + this._isExpanded = !this._isExpanded; + this._bodyNode.classList.toggle('collapsed', !this._isExpanded); + this._updateToggleButton(); + this._editor.layoutOverlayWidget(this); + } + + private _dismiss(): void { + this._domNode.classList.add('fadeOut'); + + const dispose = () => { + this.dispose(); + }; + + // Listen for animation end + const handle = setTimeout(dispose, 150); + this._domNode.addEventListener('animationend', () => { + clearTimeout(handle); + dispose(); + }, { once: true }); + } + + private _updateReadIndicator(): void { + clearNode(this._readIndicator); + const allRead = this._explanations.every(e => e.read); + const someRead = this._explanations.some(e => e.read); + this._isAllRead = allRead; + + if (allRead) { + this._readIndicator.appendChild(renderIcon(Codicon.circle)); + this._readIndicator.classList.add('read'); + this._readIndicator.classList.remove('partial', 'unread'); + this._readIndicator.title = 'Mark as unread'; + } else if (someRead) { + this._readIndicator.appendChild(renderIcon(Codicon.circleFilled)); + this._readIndicator.classList.remove('read', 'unread'); + this._readIndicator.classList.add('partial'); + this._readIndicator.title = 'Mark all as read'; + } else { + this._readIndicator.appendChild(renderIcon(Codicon.circleFilled)); + this._readIndicator.classList.remove('read', 'partial'); + this._readIndicator.classList.add('unread'); + this._readIndicator.title = 'Mark as read'; + } + } + + private _updateTitle(): void { + const count = this._explanations.length; + if (count === 1) { + this._titleNode.textContent = '1 change'; + } else { + this._titleNode.textContent = `${count} changes`; + } + } + + private _updateToggleButton(): void { + clearNode(this._toggleButton); + if (this._isExpanded) { + this._toggleButton.appendChild(renderIcon(Codicon.chevronUp)); + this._toggleButton.title = 'Collapse'; + } else { + this._toggleButton.appendChild(renderIcon(Codicon.chevronDown)); + this._toggleButton.title = 'Expand'; + } + } + + private _buildExplanationItems(): void { + clearNode(this._bodyNode); + this._explanationItems.clear(); + + for (let i = 0; i < this._explanations.length; i++) { + const exp = this._explanations[i]; + const item = $('div.chat-explanation-item'); + + // Line indicator + const lineInfo = $('span.chat-explanation-line-info'); + if (exp.startLineNumber === exp.endLineNumber) { + lineInfo.textContent = `Line ${exp.startLineNumber}`; + } else { + lineInfo.textContent = `Lines ${exp.startLineNumber}-${exp.endLineNumber}`; + } + item.appendChild(lineInfo); + + // Explanation text with loading indicator + const text = $('span.chat-explanation-text'); + if (exp.loading) { + const loadingIcon = renderIcon(ThemeIcon.modify(Codicon.loading, 'spin')); + loadingIcon.classList.add('chat-explanation-loading'); + text.appendChild(loadingIcon); + const loadingText = document.createTextNode(' ' + exp.explanation); + text.appendChild(loadingText); + } else { + text.textContent = exp.explanation; + } + item.appendChild(text); + + // Item read indicator + const itemReadIndicator = $('div.chat-explanation-item-read'); + this._updateItemReadIndicator(itemReadIndicator, exp.read); + item.appendChild(itemReadIndicator); + + // Reply button to add context to chat + const replyButton = $('div.chat-explanation-reply-button'); + replyButton.appendChild(renderIcon(Codicon.arrowRight)); + replyButton.title = 'Follow up on this change'; + item.appendChild(replyButton); + + // Reply button click handler + this._eventStore.add(addDisposableListener(replyButton, 'click', async (e) => { + e.stopPropagation(); + const chatWidget = this._chatWidgetService.lastFocusedWidget; + if (chatWidget) { + const range = new Range(exp.startLineNumber, 1, exp.endLineNumber, 1); + chatWidget.attachmentModel.addContext( + chatWidget.attachmentModel.asFileVariableEntry(this._uri, range) + ); + } + await this._viewsService.openView(ChatViewId, true); + })); + + // Click on item to mark as read + this._eventStore.add(addDisposableListener(item, 'click', (e) => { + e.stopPropagation(); + exp.read = !exp.read; + this._updateItemReadIndicator(itemReadIndicator, exp.read); + this._updateReadIndicator(); + })); + + this._explanationItems.set(i, { item, readIndicator: itemReadIndicator, textElement: text }); + this._bodyNode.appendChild(item); + + // Generate explanation via LLM + this._generateExplanation(i); + } + } + + private async _generateExplanation(index: number): Promise { + const exp = this._explanations[index]; + if (!exp.loading || this._disposed) { + return; + } + + try { + // Select a fast model + let models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-fast' }); + if (!models.length) { + models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', family: 'gpt-4o-mini' }); + } + if (!models.length) { + exp.explanation = nls.localize('noModelAvailable', "Unable to generate explanation - no model available."); + exp.loading = false; + this._updateExplanationText(index); + return; + } + + const prompt = `Explain this code change in one brief sentence (max 15 words). Be specific about what changed and why. + +BEFORE: +${exp.originalText || '(empty)'} + +AFTER: +${exp.modifiedText || '(empty)'} + +Explanation:`; + + const response = await this._languageModelsService.sendChatRequest( + models[0], + new ExtensionIdentifier('core'), + [{ role: ChatMessageRole.User, content: [{ type: 'text', value: prompt }] }], + {}, + this._cancellationTokenSource.token + ); + + let explanation = ''; + for await (const part of response.stream) { + if (this._disposed) { + return; + } + if (Array.isArray(part)) { + for (const p of part) { + if (p.type === 'text') { + explanation += p.value; + } + } + } else if (part.type === 'text') { + explanation += part.value; + } + } + + await response.result; + + if (!this._disposed) { + exp.explanation = explanation.trim() || nls.localize('codeWasModified', "Code was modified."); + exp.loading = false; + this._updateExplanationText(index); + } + } catch (error) { + if (!this._disposed) { + exp.explanation = nls.localize('failedToGenerateExplanation', "Failed to generate explanation."); + exp.loading = false; + this._updateExplanationText(index); + } + } + } + + private _updateExplanationText(index: number): void { + const itemData = this._explanationItems.get(index); + const exp = this._explanations[index]; + if (itemData && exp) { + clearNode(itemData.textElement); + itemData.textElement.textContent = exp.explanation; + } + } + + private _updateItemReadIndicator(element: HTMLElement, read: boolean): void { + clearNode(element); + if (read) { + element.appendChild(renderIcon(Codicon.circle)); + element.classList.add('read'); + element.classList.remove('unread'); + } else { + element.appendChild(renderIcon(Codicon.circleFilled)); + element.classList.remove('read'); + element.classList.add('unread'); + } + } + + private _updateExplanationItemsReadState(): void { + this._explanationItems.forEach(({ readIndicator }, index) => { + const exp = this._explanations[index]; + this._updateItemReadIndicator(readIndicator, exp.read); + }); + } + + /** + * Updates the widget position and layout + */ + layout(startLineNumber: number): void { + if (this._disposed) { + return; + } + + this._startLineNumber = startLineNumber; + + const lineHeight = this._editor.getOption(EditorOption.lineHeight); + const { contentLeft, contentWidth, verticalScrollbarWidth } = this._editor.getLayoutInfo(); + const scrollTop = this._editor.getScrollTop(); + + // Position at right edge like DiffHunkWidget + const widgetWidth = getTotalWidth(this._domNode) || 280; + + this._position = { + stackOrdinal: 2, + preference: { + top: this._editor.getTopForLineNumber(startLineNumber) - scrollTop - lineHeight, + left: contentLeft + contentWidth - (2 * verticalScrollbarWidth + widgetWidth) + } + }; + + this._editor.layoutOverlayWidget(this); + } + + /** + * Shows or hides the widget + */ + toggle(show: boolean): void { + this._domNode.classList.toggle('visible', show); + if (show && this._explanations.length > 0) { + this.layout(this._explanations[0].startLineNumber); + } + } + + /** + * Relayouts the widget at its current line number + */ + relayout(): void { + if (this._startLineNumber) { + this.layout(this._startLineNumber); + } + } + + // IOverlayWidget implementation + + getId(): string { + return this._id; + } + + getDomNode(): HTMLElement { + return this._domNode; + } + + getPosition(): IOverlayWidgetPosition | null { + return this._position; + } + + override dispose(): void { + if (this._disposed) { + return; + } + this._disposed = true; + this._editor.removeOverlayWidget(this); + super.dispose(); + } +} + +/** + * Manager for explanation widgets in an editor + * Groups changes and creates combined widgets for nearby changes + */ +export class ChatEditingExplanationWidgetManager extends Disposable { + + private readonly _widgets: ChatEditingExplanationWidget[] = []; + private _visible: boolean = false; + private _pendingDiffInfo: IExplanationDiffInfo | undefined; + + private _modelUri: URI | undefined; + + constructor( + private readonly _editor: ICodeEditor, + private readonly _languageModelsService: ILanguageModelsService, + private readonly _chatWidgetService: IChatWidgetService, + private readonly _viewsService: IViewsService, + ) { + super(); + + // Listen for model changes - hide/show widgets based on whether current model matches + this._register(this._editor.onDidChangeModel(() => { + const newUri = this._editor.getModel()?.uri; + if (this._modelUri) { + if (newUri && newUri.toString() === this._modelUri.toString()) { + // Switched back to the file - show widgets + for (const widget of this._widgets) { + widget.toggle(this._visible); + widget.relayout(); + } + } else { + // Switched to a different file - hide widgets + for (const widget of this._widgets) { + widget.toggle(false); + } + } + } + })); + } + + /** + * Updates the diff info. Widgets are only created when show() is called. + * @param visible Whether widgets should be visible (default: false) + */ + update(diffInfo: IExplanationDiffInfo, visible: boolean = false): void { + // Store diff info for later widget creation + this._pendingDiffInfo = diffInfo; + this._modelUri = diffInfo.modifiedModel.uri; + + // If already visible and widgets exist, recreate them with new diff + if (this._visible && this._widgets.length > 0) { + this._createWidgets(diffInfo); + } + + // Handle visibility change + if (visible && !this._visible) { + this.show(); + } else if (!visible && this._visible) { + this.hide(); + } + } + + private _createWidgets(diffInfo: IExplanationDiffInfo): void { + // Clear existing widgets + this._clearWidgets(); + + if (diffInfo.identical || diffInfo.changes.length === 0) { + return; + } + + // Group nearby changes + const groups = groupNearbyChanges(diffInfo.changes, 5); + + // Create a widget for each group + for (const group of groups) { + const widget = new ChatEditingExplanationWidget( + this._editor, + group, + diffInfo, + this._languageModelsService, + this._chatWidgetService, + this._viewsService, + ); + this._widgets.push(widget); + this._register(widget); + + // Layout at the first change in the group + widget.layout(group[0].modified.startLineNumber); + widget.toggle(true); + } + + // Relayout on scroll/layout changes + this._register(Event.any(this._editor.onDidScrollChange, this._editor.onDidLayoutChange)(() => { + for (const widget of this._widgets) { + widget.relayout(); + } + })); + } + + /** + * Shows all widgets, creating them if needed + */ + show(): void { + this._visible = true; + + // Create widgets if we have pending diff info but no widgets yet + if (this._widgets.length === 0 && this._pendingDiffInfo) { + this._createWidgets(this._pendingDiffInfo); + } else { + for (const widget of this._widgets) { + widget.toggle(true); + widget.relayout(); + } + } + } + + /** + * Hides all widgets + */ + hide(): void { + this._visible = false; + for (const widget of this._widgets) { + widget.toggle(false); + } + } + + private _clearWidgets(): void { + for (const widget of this._widgets) { + widget.dispose(); + } + this._widgets.length = 0; + } + + override dispose(): void { + this._clearWidgets(); + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts index 2b02a675551c0..04792fa038c40 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts @@ -48,6 +48,12 @@ export abstract class AbstractChatEditingModifiedFileEntry extends Disposable im readonly entryId = `${AbstractChatEditingModifiedFileEntry.scheme}::${++AbstractChatEditingModifiedFileEntry.lastEntryId}`; + /** + * Observable that controls whether explanation widgets should be visible for this entry. + * Set by the owning session. + */ + explanationWidgetVisible?: IObservable; + protected readonly _onDidDelete = this._register(new Emitter()); readonly onDidDelete = this._onDidDelete.event; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts index bcf29df448d78..0ab0e5851cd78 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts @@ -131,6 +131,8 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio private readonly _state = observableValue(this, ChatEditingSessionState.Initial); private readonly _timeline: IChatEditingCheckpointTimeline; + public readonly explanationWidgetVisible = observableValue(this, false); + /** * Contains the contents of a file when the AI first began doing edits to it. */ @@ -1015,6 +1017,8 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio }); this._store.add(listener); + entry.explanationWidgetVisible = this.explanationWidgetVisible; + const entriesArr = [...this._entriesObs.get(), entry]; this._entriesObs.set(entriesArr, undefined); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditingExplanationWidget.css b/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditingExplanationWidget.css new file mode 100644 index 0000000000000..8d9a06cc3c3a5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditingExplanationWidget.css @@ -0,0 +1,269 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Main widget container - speech bubble style */ +.chat-explanation-widget { + position: absolute; + max-width: 280px; + min-width: 180px; + background-color: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder)); + border-radius: 8px; + box-shadow: 0 2px 8px var(--vscode-widget-shadow); + font-size: 12px; + line-height: 1.4; + opacity: 0; + transition: opacity 0.2s ease-in-out; + z-index: 10; +} + +.chat-explanation-widget.visible { + opacity: 1; +} + +.chat-explanation-widget.fadeOut { + animation: chatExplanationFadeOut 150ms ease-out forwards; +} + +@keyframes chatExplanationFadeOut { + from { opacity: 1; } + to { opacity: 0; } +} + +/* Arrow pointer pointing left toward the code */ +.chat-explanation-arrow { + position: absolute; + left: -8px; + top: 12px; + width: 0; + height: 0; + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; + border-right: 8px solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder)); +} + +.chat-explanation-arrow::after { + content: ''; + position: absolute; + left: 2px; + top: -7px; + width: 0; + height: 0; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + border-right: 7px solid var(--vscode-editorWidget-background); +} + +/* Header */ +.chat-explanation-header { + display: flex; + align-items: center; + padding: 8px 10px; + border-bottom: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border)); + cursor: pointer; + gap: 6px; +} + +.chat-explanation-header:hover { + background-color: var(--vscode-list-hoverBackground); +} + +/* Read indicator - GitHub notification style */ +.chat-explanation-read-indicator { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 4px; + cursor: pointer; + color: var(--vscode-foreground); + opacity: 0.4; + transition: opacity 0.1s; +} + +.chat-explanation-read-indicator:hover { + opacity: 0.8; + background-color: var(--vscode-toolbar-hoverBackground); +} + +.chat-explanation-read-indicator.unread { + color: var(--vscode-textLink-foreground); + opacity: 1; +} + +.chat-explanation-read-indicator.unread .codicon { + color: var(--vscode-textLink-foreground) !important; +} + +.chat-explanation-read-indicator.partial { + color: var(--vscode-textLink-foreground); + opacity: 0.6; +} + +.chat-explanation-read-indicator.partial .codicon { + color: var(--vscode-textLink-foreground) !important; +} + +.chat-explanation-read-indicator.read { + color: var(--vscode-foreground); + opacity: 0.4; +} + +/* Title */ +.chat-explanation-title { + font-weight: 500; + color: var(--vscode-foreground); + white-space: nowrap; +} + +/* Spacer to push buttons to the right */ +.chat-explanation-spacer { + flex: 1; +} + +/* Toggle button */ +.chat-explanation-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 4px; + cursor: pointer; + color: var(--vscode-foreground); + opacity: 0.7; + transition: opacity 0.1s; +} + +.chat-explanation-toggle:hover { + opacity: 1; + background-color: var(--vscode-toolbar-hoverBackground); +} + +/* Dismiss button */ +.chat-explanation-dismiss { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 4px; + cursor: pointer; + color: var(--vscode-foreground); + opacity: 0.7; + transition: opacity 0.1s; +} + +.chat-explanation-dismiss:hover { + opacity: 1; + background-color: var(--vscode-toolbar-hoverBackground); +} + +/* Body - collapsible */ +.chat-explanation-body { + transition: max-height 0.2s ease-in-out, padding 0.2s ease-in-out; +} + +.chat-explanation-body.collapsed { + max-height: 0; + overflow: hidden; + padding: 0; +} + +/* Individual explanation item */ +.chat-explanation-item { + display: flex; + flex-direction: column; + padding: 8px 10px; + border-bottom: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border)); + cursor: pointer; + position: relative; +} + +.chat-explanation-item:last-child { + border-bottom: none; +} + +.chat-explanation-item:hover { + background-color: var(--vscode-list-hoverBackground); +} + +/* Line info */ +.chat-explanation-line-info { + font-size: 10px; + font-weight: 600; + color: var(--vscode-descriptionForeground); + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Explanation text */ +.chat-explanation-text { + color: var(--vscode-foreground); + word-wrap: break-word; + padding-right: 24px; /* Space for read indicator */ +} + +/* Item read indicator - GitHub notification style */ +.chat-explanation-item-read { + position: absolute; + top: 8px; + right: 8px; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 3px; + color: var(--vscode-foreground); + opacity: 0.3; + font-size: 12px; +} + +.chat-explanation-item:hover .chat-explanation-item-read { + opacity: 0.6; +} + +.chat-explanation-item-read.unread { + color: var(--vscode-textLink-foreground); + opacity: 1; +} + +.chat-explanation-item-read.unread .codicon { + color: var(--vscode-textLink-foreground) !important; +} + +.chat-explanation-item-read.read { + color: var(--vscode-foreground); + opacity: 0.3; +} + +/* Reply button */ +.chat-explanation-reply-button { + position: absolute; + bottom: 8px; + right: 8px; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + cursor: pointer; + color: var(--vscode-foreground); + opacity: 0; + transition: opacity 0.1s, background-color 0.1s; +} + +.chat-explanation-item:hover .chat-explanation-reply-button { + opacity: 0.6; +} + +.chat-explanation-reply-button:hover { + opacity: 1 !important; + background-color: var(--vscode-toolbar-hoverBackground); +} diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts index 7169f91b9e2e8..c043f7c9b5ca9 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts @@ -478,14 +478,14 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { } // check that tools other than setup. and internal tools are registered. - for (const tool of languageModelToolsService.getTools()) { + for (const tool of languageModelToolsService.getAllToolsIncludingDisabled()) { if (tool.id.startsWith('copilot_')) { return; // we have tools! } } return Event.toPromise(Event.filter(languageModelToolsService.onDidChangeTools, () => { - for (const tool of languageModelToolsService.getTools()) { + for (const tool of languageModelToolsService.getAllToolsIncludingDisabled()) { if (tool.id.startsWith('copilot_')) { return true; // we have tools! } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts index 60e561cca6067..e350c3b6289be 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts @@ -7,7 +7,7 @@ import { localize } from '../../../../../../nls.js'; import { URI } from '../../../../../../base/common/uri.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; -import { IPromptPath, IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { IExtensionPromptPath, IPromptPath, IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { dirname, extUri, joinPath } from '../../../../../../base/common/resources.js'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; @@ -25,7 +25,9 @@ import { askForPromptSourceFolder } from './askForPromptSourceFolder.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { PromptsConfig } from '../../../common/promptSyntax/config/config.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { PromptFileRewriter } from '../promptFileRewriter.js'; +import { isOrganizationPromptFile } from '../../../common/promptSyntax/utils/promptsServiceUtils.js'; /** * Options for the {@link askToSelectInstructions} function. @@ -122,6 +124,13 @@ function isPromptFileItem(item: IPromptPickerQuickPickItem | IQuickPickSeparator return item.type === 'item' && !!item.promptFileUri; } +/** + * Type guard for extension prompt paths. + */ +function isExtensionPromptPath(prompt: IPromptPath): prompt is IExtensionPromptPath { + return prompt.storage === PromptsStorage.extension && !!prompt.extension; +} + type IPromptQuickPick = IQuickPick; /** @@ -259,6 +268,7 @@ export class PromptFilePickers { @IPromptsService private readonly _promptsService: IPromptsService, @ILabelService private readonly _labelService: ILabelService, @IConfigurationService private readonly _configurationService: IConfigurationService, + @IProductService private readonly _productService: IProductService, ) { } @@ -408,9 +418,8 @@ export class PromptFilePickers { result.push(...sortByLabel(await Promise.all(agentInstructionFiles.map(l => this._createPromptPickItem(l, agentButtons, getVisibility(l), token))))); } - const exts = await this._promptsService.listPromptFilesForStorage(options.type, PromptsStorage.extension, token); + const exts = (await this._promptsService.listPromptFilesForStorage(options.type, PromptsStorage.extension, token)).filter(isExtensionPromptPath); if (exts.length) { - result.push({ type: 'separator', label: localize('separator.extensions', "Extensions") }); const extButtons: IQuickInputButton[] = []; if (options.optionEdit !== false) { extButtons.push(EDIT_BUTTON); @@ -418,7 +427,21 @@ export class PromptFilePickers { if (options.optionCopy !== false) { extButtons.push(COPY_BUTTON); } - result.push(...sortByLabel(await Promise.all(exts.map(e => this._createPromptPickItem(e, extButtons, getVisibility(e), token))))); + + const groupedExts = new Map(); + for (const ext of exts) { + const groupLabel = this._getExtensionGroupLabel(ext); + if (!groupedExts.has(groupLabel)) { + groupedExts.set(groupLabel, []); + } + groupedExts.get(groupLabel)!.push(ext); + } + + const sortedGroupedExts = Array.from(groupedExts.entries()).sort((a, b) => a[0].localeCompare(b[0])); + for (const [groupLabel, groupExts] of sortedGroupedExts) { + result.push({ type: 'separator', label: groupLabel }); + result.push(...sortByLabel(await Promise.all(groupExts.map(e => this._createPromptPickItem(e, extButtons, getVisibility(e), token))))); + } } const users = await this._promptsService.listPromptFilesForStorage(options.type, PromptsStorage.user, token); if (users.length) { @@ -428,6 +451,16 @@ export class PromptFilePickers { return result; } + private _getExtensionGroupLabel(extPath: IExtensionPromptPath): string { + if (isOrganizationPromptFile(extPath.uri, extPath.extension.identifier, this._productService)) { + return localize('separator.organization', "Organization"); + } + + // By default, extension prompt files are grouped under "Extensions" + return localize('separator.extensions', "Extensions"); + + } + private _getNewItems(type: PromptsType): IPromptPickerQuickPickItem[] { switch (type) { case PromptsType.prompt: diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts index 37a96a89d1030..3a8d9fda95152 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts @@ -32,7 +32,7 @@ class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider @IPromptsService private readonly promptsService: IPromptsService, @ILanguageFeaturesService private readonly languageService: ILanguageFeaturesService, @ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService, - @IInstantiationService private readonly instantiationService: IInstantiationService + @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(); @@ -84,12 +84,12 @@ class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider } private async updateTools(model: ITextModel, range: Range, selectedTools: readonly string[], target: string | undefined): Promise { - const selectedToolsNow = () => this.languageModelToolsService.toToolAndToolSetEnablementMap(selectedTools, target); + const selectedToolsNow = () => this.languageModelToolsService.toToolAndToolSetEnablementMap(selectedTools, target, undefined); const newSelectedAfter = await this.instantiationService.invokeFunction(showToolsPicker, localize('placeholder', "Select tools"), 'codeLens', undefined, selectedToolsNow); if (!newSelectedAfter) { return; } - await this.instantiationService.createInstance(PromptFileRewriter).rewriteTools(model, newSelectedAfter, range); + this.instantiationService.createInstance(PromptFileRewriter).rewriteTools(model, newSelectedAfter, range); } } diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index dc8e5a809b268..5e392640d39ad 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -16,7 +16,7 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { createMarkdownCommandLink, MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../../base/common/iterator.js'; import { combinedDisposable, Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { derived, IObservable, IReader, observableFromEventOpts, ObservableSet } from '../../../../../base/common/observable.js'; +import { derived, derivedOpts, IObservable, IReader, observableFromEventOpts, ObservableSet, observableSignal, transaction } from '../../../../../base/common/observable.js'; import Severity from '../../../../../base/common/severity.js'; import { StopWatch } from '../../../../../base/common/stopwatch.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; @@ -35,13 +35,14 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../../pla import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { ChatRequestToolReferenceEntry, toToolSetVariableEntry, toToolVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { IVariableReference } from '../../common/chatModes.js'; -import { ChatToolInvocation } from '../../common/model/chatProgressTypes/chatToolInvocation.js'; import { ConfirmedReason, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../common/chatService/chatService.js'; -import { ChatRequestToolReferenceEntry, toToolSetVariableEntry, toToolVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { ChatConfiguration } from '../../common/constants.js'; +import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; +import { ChatToolInvocation } from '../../common/model/chatProgressTypes/chatToolInvocation.js'; import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; -import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, ILanguageModelToolsService, IPreparedToolInvocation, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolResult, IToolResultInputOutputDetails, SpecedToolAliases, stringifyPromptTsxPart, ToolDataSource, ToolSet, VSCodeToolReference } from '../../common/tools/languageModelToolsService.js'; +import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, ILanguageModelToolsService, IPreparedToolInvocation, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolResult, IToolResultInputOutputDetails, SpecedToolAliases, stringifyPromptTsxPart, isToolSet, ToolDataSource, toolMatchesModel, ToolSet, VSCodeToolReference, IToolSet, ToolSetForModel } from '../../common/tools/languageModelToolsService.js'; import { getToolConfirmationAlert } from '../accessibility/chatAccessibilityProvider.js'; import { URI } from '../../../../../base/common/uri.js'; import { chatSessionResourceToId } from '../../common/model/chatUri.js'; @@ -199,7 +200,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return true; } const permittedInternalToolSetIds = [SpecedToolAliases.read, SpecedToolAliases.search, SpecedToolAliases.web]; - if (toolOrToolSet instanceof ToolSet) { + if (isToolSet(toolOrToolSet)) { const permitted = toolOrToolSet.source.type === 'internal' && permittedInternalToolSetIds.includes(toolOrToolSet.referenceName); this._logService.trace(`LanguageModelToolsService#isPermitted: ToolSet ${toolOrToolSet.id} (${toolOrToolSet.referenceName}) permitted=${permitted}`); return permitted; @@ -300,36 +301,52 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo ); } - getTools(includeDisabled?: boolean): Iterable { + getTools(model: ILanguageModelChatMetadata | undefined): Iterable { const toolDatas = Iterable.map(this._tools.values(), i => i.data); const extensionToolsEnabled = this._configurationService.getValue(ChatConfiguration.ExtensionToolsEnabled); return Iterable.filter( toolDatas, toolData => { - const satisfiesWhenClause = includeDisabled || !toolData.when || this._contextKeyService.contextMatchesRules(toolData.when); + const satisfiesWhenClause = !toolData.when || this._contextKeyService.contextMatchesRules(toolData.when); const satisfiesExternalToolCheck = toolData.source.type !== 'extension' || !!extensionToolsEnabled; - const satisfiesPermittedCheck = includeDisabled || this.isPermitted(toolData); - return satisfiesWhenClause && satisfiesExternalToolCheck && satisfiesPermittedCheck; + const satisfiesPermittedCheck = this.isPermitted(toolData); + const satisfiesModelFilter = toolMatchesModel(toolData, model); + return satisfiesWhenClause && satisfiesExternalToolCheck && satisfiesPermittedCheck && satisfiesModelFilter; }); } - readonly toolsObservable = observableFromEventOpts({ equalsFn: arrayEqualsC() }, this.onDidChangeTools, () => Array.from(this.getTools())); + observeTools(model: ILanguageModelChatMetadata | undefined): IObservable { + const meta = derived(reader => { + const signal = observableSignal('observeToolsContext'); + const trigger = () => transaction(tx => signal.trigger(tx)); + reader.store.add(this.onDidChangeTools(trigger)); + return signal; + }); - getTool(id: string): IToolData | undefined { - return this._getToolEntry(id)?.data; + return derivedOpts({ equalsFn: arrayEqualsC() }, reader => { + meta.read(reader).read(reader); + return Array.from(this.getTools(model)); + }); } - private _getToolEntry(id: string): IToolEntry | undefined { - const entry = this._tools.get(id); - if (entry && (!entry.data.when || this._contextKeyService.contextMatchesRules(entry.data.when))) { - return entry; - } else { - return undefined; - } + getAllToolsIncludingDisabled(): Iterable { + const toolDatas = Iterable.map(this._tools.values(), i => i.data); + const extensionToolsEnabled = this._configurationService.getValue(ChatConfiguration.ExtensionToolsEnabled); + return Iterable.filter( + toolDatas, + toolData => { + const satisfiesExternalToolCheck = toolData.source.type !== 'extension' || !!extensionToolsEnabled; + const satisfiesPermittedCheck = this.isPermitted(toolData); + return satisfiesExternalToolCheck && satisfiesPermittedCheck; + }); + } + + getTool(id: string): IToolData | undefined { + return this._tools.get(id)?.data; } - getToolByName(name: string, includeDisabled?: boolean): IToolData | undefined { - for (const tool of this.getTools(!!includeDisabled)) { + getToolByName(name: string): IToolData | undefined { + for (const tool of this.getAllToolsIncludingDisabled()) { if (tool.toolReferenceName === name) { return tool; } @@ -896,7 +913,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo private static readonly githubMCPServerAliases = ['github/github-mcp-server', 'io.github.github/github-mcp-server', 'github-mcp-server']; private static readonly playwrightMCPServerAliases = ['microsoft/playwright-mcp', 'com.microsoft/playwright-mcp']; - private * getToolSetAliases(toolSet: ToolSet, fullReferenceName: string): Iterable { + private *getToolSetAliases(toolSet: ToolSet, fullReferenceName: string): Iterable { if (fullReferenceName !== toolSet.referenceName) { yield toolSet.referenceName; // tool set name without '/*' } @@ -960,19 +977,24 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo * @param fullReferenceNames A list of tool or toolset by their full reference names that are enabled. * @returns A map of tool or toolset instances to their enablement state. */ - toToolAndToolSetEnablementMap(fullReferenceNames: readonly string[], _target: string | undefined): IToolAndToolSetEnablementMap { + toToolAndToolSetEnablementMap(fullReferenceNames: readonly string[], _target: string | undefined, model: ILanguageModelChatMetadata | undefined): IToolAndToolSetEnablementMap { const toolOrToolSetNames = new Set(fullReferenceNames); - const result = new Map(); + const result = new Map(); for (const [tool, fullReferenceName] of this.toolsWithFullReferenceName.get()) { - if (tool instanceof ToolSet) { + if (isToolSet(tool)) { const enabled = toolOrToolSetNames.has(fullReferenceName) || Iterable.some(this.getToolSetAliases(tool, fullReferenceName), name => toolOrToolSetNames.has(name)); - result.set(tool, enabled); + const scoped = model ? new ToolSetForModel(tool, model) : tool; + result.set(scoped, enabled); if (enabled) { - for (const memberTool of tool.getTools()) { + for (const memberTool of scoped.getTools()) { result.set(memberTool, true); } } } else { + if (model && !toolMatchesModel(tool, model)) { + continue; + } + if (!result.has(tool)) { // already set via an enabled toolset const enabled = toolOrToolSetNames.has(fullReferenceName) || Iterable.some(this.getToolAliases(tool, fullReferenceName), name => toolOrToolSetNames.has(name)) @@ -1000,7 +1022,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo const result: string[] = []; const toolsCoveredByEnabledToolSet = new Set(); for (const [tool, fullReferenceName] of this.toolsWithFullReferenceName.get()) { - if (tool instanceof ToolSet) { + if (isToolSet(tool)) { if (map.get(tool)) { result.push(fullReferenceName); for (const memberTool of tool.getTools()) { @@ -1026,7 +1048,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo for (const ref of variableReferences) { const toolOrToolSet = toolsOrToolSetByName.get(ref.name); if (toolOrToolSet) { - if (toolOrToolSet instanceof ToolSet) { + if (isToolSet(toolOrToolSet)) { result.push(toToolSetVariableEntry(toolOrToolSet, ref.range)); } else { result.push(toToolVariableEntry(toolOrToolSet, ref.range)); @@ -1044,6 +1066,14 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return allToolSets.filter(toolSet => this.isPermitted(toolSet, reader)); }); + getToolSetsForModel(model: ILanguageModelChatMetadata | undefined, reader?: IReader): Iterable { + if (!model) { + return this.toolSets.read(reader); + } + + return Iterable.map(this.toolSets.read(reader), ts => new ToolSetForModel(ts, model)); + } + getToolSet(id: string): ToolSet | undefined { for (const toolSet of this._toolSets) { if (toolSet.id === id) { @@ -1092,7 +1122,13 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return result; } - readonly toolsWithFullReferenceName = derived<[IToolData | ToolSet, string][]>(reader => { + private readonly allToolsIncludingDisableObs = observableFromEventOpts( + { equalsFn: arrayEqualsC() }, + this.onDidChangeTools, + () => Array.from(this.getAllToolsIncludingDisabled()), + ); + + private readonly toolsWithFullReferenceName = derived<[IToolData | ToolSet, string][]>(reader => { const result: [IToolData | ToolSet, string][] = []; const coveredByToolSets = new Set(); for (const toolSet of this.toolSets.read(reader)) { @@ -1104,7 +1140,13 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } } } - for (const tool of this.toolsObservable.read(reader)) { + for (const tool of this.allToolsIncludingDisableObs.read(reader)) { + // todo@connor4312/aeschil: this effectively hides model-specific tools + // for prompt referencing. Should we eventually enable this? (If so how?) + if (tool.when && !this._contextKeyService.contextMatchesRules(tool.when)) { + continue; + } + if (tool.canBeReferencedInPrompt && !coveredByToolSets.has(tool) && this.isPermitted(tool, reader)) { result.push([tool, getToolFullReferenceName(tool)]); } @@ -1131,7 +1173,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo }; for (const [tool, _] of this.toolsWithFullReferenceName.get()) { - if (tool instanceof ToolSet) { + if (isToolSet(tool)) { knownToolSetNames.add(tool.referenceName); if (tool.legacyFullNames) { for (const legacyName of tool.legacyFullNames) { @@ -1142,7 +1184,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } for (const [tool, fullReferenceName] of this.toolsWithFullReferenceName.get()) { - if (tool instanceof ToolSet) { + if (isToolSet(tool)) { for (const alias of this.getToolSetAliases(tool, fullReferenceName)) { add(alias, fullReferenceName); } @@ -1173,7 +1215,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo if (fullReferenceName === toolFullReferenceName) { return tool; } - const aliases = tool instanceof ToolSet ? this.getToolSetAliases(tool, toolFullReferenceName) : this.getToolAliases(tool, toolFullReferenceName); + const aliases = isToolSet(tool) ? this.getToolSetAliases(tool, toolFullReferenceName) : this.getToolAliases(tool, toolFullReferenceName); if (Iterable.some(aliases, alias => fullReferenceName === alias)) { return tool; } @@ -1181,15 +1223,15 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return undefined; } - getFullReferenceName(tool: IToolData | ToolSet, toolSet?: ToolSet): string { - if (tool instanceof ToolSet) { + getFullReferenceName(tool: IToolData | IToolSet, toolSet?: IToolSet): string { + if (isToolSet(tool)) { return getToolSetFullReferenceName(tool); } return getToolFullReferenceName(tool, toolSet); } } -function getToolFullReferenceName(tool: IToolData, toolSet?: ToolSet) { +function getToolFullReferenceName(tool: IToolData, toolSet?: IToolSet) { const toolName = tool.toolReferenceName ?? tool.displayName; if (toolSet) { return `${toolSet.referenceName}/${toolName}`; @@ -1199,7 +1241,7 @@ function getToolFullReferenceName(tool: IToolData, toolSet?: ToolSet) { return toolName; } -function getToolSetFullReferenceName(toolSet: ToolSet) { +function getToolSetFullReferenceName(toolSet: IToolSet) { if (toolSet.source.type === 'mcp') { return `${toolSet.referenceName}/*`; } diff --git a/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts b/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts index 6b959e113ab7a..1360fbcba5cb1 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts @@ -24,7 +24,7 @@ import { IExtensionService } from '../../../../services/extensions/common/extens import { ILifecycleService, LifecyclePhase } from '../../../../services/lifecycle/common/lifecycle.js'; import { IUserDataProfileService } from '../../../../services/userDataProfile/common/userDataProfile.js'; import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from '../actions/chatActions.js'; -import { ILanguageModelToolsService, IToolData, ToolDataSource, ToolSet } from '../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, IToolData, IToolSet, ToolDataSource } from '../../common/tools/languageModelToolsService.js'; import { IRawToolSetContribution } from '../../common/tools/languageModelToolsContribution.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { Codicon, getAllCodicons } from '../../../../../base/common/codicons.js'; @@ -146,7 +146,7 @@ export class UserToolSetsContributions extends Disposable implements IWorkbenchC lifecycleService.when(LifecyclePhase.Restored) ]).then(() => this._initToolSets()); - const toolsObs = observableFromEvent(this, _languageModelToolsService.onDidChangeTools, () => Array.from(_languageModelToolsService.getTools())); + const toolsObs = observableFromEvent(this, _languageModelToolsService.onDidChangeTools, () => Array.from(_languageModelToolsService.getAllToolsIncludingDisabled())); const store = this._store.add(new DisposableStore()); this._store.add(autorun(r => { @@ -268,7 +268,7 @@ export class UserToolSetsContributions extends Disposable implements IWorkbenchC for (const [name, value] of data.entries) { const tools: IToolData[] = []; - const toolSets: ToolSet[] = []; + const toolSets: IToolSet[] = []; value.tools.forEach(name => { const tool = this._languageModelToolsService.getToolByName(name); if (tool) { @@ -341,7 +341,7 @@ export class ConfigureToolSets extends Action2 { const fileService = accessor.get(IFileService); const textFileService = accessor.get(ITextFileService); - const picks: ((IQuickPickItem & { toolset?: ToolSet }) | IQuickPickSeparator)[] = []; + const picks: ((IQuickPickItem & { toolset?: IToolSet }) | IQuickPickSeparator)[] = []; picks.push({ label: localize('chat.configureToolSets.add', 'Create new tool sets file...'), diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts index 7e9f47d73a6eb..ff0da48563edc 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts @@ -103,7 +103,7 @@ export abstract class ChatCollapsibleContentPart extends Disposable implements I this.updateAriaLabel(collapseButton.element, typeof referencesLabel === 'string' ? referencesLabel : referencesLabel.value, expanded); // Lazy initialization: render content only when expanded for the first time - if (expanded && !this._contentInitialized) { + if ((expanded || this.shouldInitEarly()) && !this._contentInitialized) { this._contentInitialized = true; this._contentElement = this.initContent(); this._domNode?.appendChild(this._contentElement); @@ -115,6 +115,10 @@ export abstract class ChatCollapsibleContentPart extends Disposable implements I protected abstract initContent(): HTMLElement; + protected shouldInitEarly(): boolean { + return false; + } + abstract hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean; private updateAriaLabel(element: HTMLElement, label: string, expanded?: boolean): void { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts index 2d817fedacee4..26ccd549e18fd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -424,6 +424,10 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } } + protected override shouldInitEarly(): boolean { + return !this.isInitiallyComplete; + } + /** * Creates a ChatToolInvocationPart for the given tool invocation. */ diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index 3c0a8dc71b548..e3884cdef5a17 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -231,6 +231,10 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } } + protected override shouldInitEarly(): boolean { + return this.fixedScrollingMode; + } + // @TODO: @justschen Convert to template for each setting? protected override initContent(): HTMLElement { this.wrapper = $('.chat-used-context-list.chat-thinking-collapsible'); @@ -343,7 +347,9 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.markdownResult.dispose(); this.markdownResult = undefined; } - clearNode(this.textContainer); + if (this.textContainer) { + clearNode(this.textContainer); + } return; } @@ -370,9 +376,11 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen }, target)); this.markdownResult = rendered; if (!target) { - clearNode(this.textContainer); - this.textContainer.appendChild(createThinkingIcon(Codicon.circleFilled)); - this.textContainer.appendChild(rendered.element); + if (this.textContainer) { + clearNode(this.textContainer); + this.textContainer.appendChild(createThinkingIcon(Codicon.circleFilled)); + this.textContainer.appendChild(rendered.element); + } } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts index c45c20b7ee6fc..c2e80d9e2ef13 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts @@ -14,7 +14,9 @@ import { autorun } from '../../../../../../../base/common/observable.js'; import { basename } from '../../../../../../../base/common/resources.js'; import { ILanguageService } from '../../../../../../../editor/common/languages/language.js'; import { IModelService } from '../../../../../../../editor/common/services/model.js'; +import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { ChatConfiguration } from '../../../../common/constants.js'; import { ChatResponseResource } from '../../../../common/model/chatModel.js'; import { IChatToolInvocation, IChatToolInvocationSerialized } from '../../../../common/chatService/chatService.js'; import { IToolResultInputOutputDetails } from '../../../../common/tools/languageModelToolsService.js'; @@ -49,6 +51,7 @@ export class ChatInputOutputMarkdownProgressPart extends BaseChatToolInvocationS @IInstantiationService instantiationService: IInstantiationService, @IModelService modelService: IModelService, @ILanguageService languageService: ILanguageService, + @IConfigurationService configurationService: IConfigurationService, ) { super(toolInvocation); @@ -114,8 +117,11 @@ export class ChatInputOutputMarkdownProgressPart extends BaseChatToolInvocationS }), } : undefined, isError, - // Expand by default when the tool is running, otherwise use the stored expanded state (defaulting to false) - !IChatToolInvocation.isComplete(toolInvocation) || (ChatInputOutputMarkdownProgressPart._expandedByDefault.get(toolInvocation) ?? false), + // Expand by default when the tool is running, when there's an error (if setting enabled), + // otherwise use the stored expanded state (defaulting to false) + !IChatToolInvocation.isComplete(toolInvocation) || + (isError && configurationService.getValue(ChatConfiguration.AutoExpandToolFailures)) || + (ChatInputOutputMarkdownProgressPart._expandedByDefault.get(toolInvocation) ?? false), )); this._register(collapsibleListPart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); this._register(toDisposable(() => ChatInputOutputMarkdownProgressPart._expandedByDefault.set(toolInvocation, collapsibleListPart.expanded))); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index 6b3732ca594b5..097918856d6d5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -385,7 +385,8 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart : commandText; const isComplete = IChatToolInvocation.isComplete(toolInvocation); - const hasError = this._terminalData.terminalCommandState?.exitCode !== undefined && this._terminalData.terminalCommandState.exitCode !== 0; + const autoExpandFailures = this._configurationService.getValue(ChatConfiguration.AutoExpandToolFailures); + const hasError = autoExpandFailures && this._terminalData.terminalCommandState?.exitCode !== undefined && this._terminalData.terminalCommandState.exitCode !== 0; const initialExpanded = !isComplete || hasError; const wrapper = this._register(this._instantiationService.createInstance( @@ -525,8 +526,9 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart if (!showOutputAction) { showOutputAction = this._instantiationService.createInstance(ToggleChatTerminalOutputAction, () => this._toggleOutputFromAction()); this._showOutputAction.value = showOutputAction; + const autoExpandFailures = this._configurationService.getValue(ChatConfiguration.AutoExpandToolFailures); const exitCode = resolvedCommand?.exitCode ?? this._terminalData.terminalCommandState?.exitCode; - if (exitCode) { + if (exitCode !== undefined && exitCode !== 0 && autoExpandFailures) { this._toggleOutput(true); } } @@ -638,7 +640,8 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart this._toggleOutput(false); } // keep outer wrapper expanded on error - if (resolvedCommand?.exitCode !== undefined && resolvedCommand.exitCode !== 0 && this._thinkingCollapsibleWrapper) { + const autoExpandFailures = this._configurationService.getValue(ChatConfiguration.AutoExpandToolFailures); + if (autoExpandFailures && resolvedCommand?.exitCode !== undefined && resolvedCommand.exitCode !== 0 && this._thinkingCollapsibleWrapper) { this.expandCollapsibleWrapper(); } if (resolvedCommand?.endMarker) { @@ -655,7 +658,8 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart this._toggleOutput(false); } // keep outer wrapper expanded on error - if (resolvedImmediately.exitCode !== undefined && resolvedImmediately.exitCode !== 0 && this._thinkingCollapsibleWrapper) { + const autoExpandFailures = this._configurationService.getValue(ChatConfiguration.AutoExpandToolFailures); + if (autoExpandFailures && resolvedImmediately.exitCode !== undefined && resolvedImmediately.exitCode !== 0 && this._thinkingCollapsibleWrapper) { this.expandCollapsibleWrapper(); } return; 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 3b9e4582e94f9..9e35ac0465837 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 @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../../../base/browser/dom.js'; +import { renderAsPlaintext } from '../../../../../../../base/browser/markdownRenderer.js'; import { status } from '../../../../../../../base/browser/ui/aria/aria.js'; import { IMarkdownString, MarkdownString } from '../../../../../../../base/common/htmlContent.js'; +import { stripIcons } from '../../../../../../../base/common/iconLabels.js'; import { autorun } from '../../../../../../../base/common/observable.js'; import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; @@ -110,7 +112,7 @@ export class ChatToolProgressSubPart extends BaseChatToolInvocationSubPart { } private provideScreenReaderStatus(content: IMarkdownString | string): void { - const message = typeof content === 'string' ? content : content.value; + const message = typeof content === 'string' ? content : stripIcons(renderAsPlaintext(content, { useLinkFormatter: true })); status(message); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 40693087c9741..4edca2d8e3c96 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -62,7 +62,7 @@ import { ChatRequestVariableSet, IChatRequestVariableEntry, isPromptFileVariable import { ChatViewModel, IChatResponseViewModel, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; import { CodeBlockModelCollection } from '../../common/widget/codeBlockModelCollection.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; -import { ILanguageModelToolsService, ToolSet } from '../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, isToolSet } from '../../common/tools/languageModelToolsService.js'; import { ComputeAutomaticInstructions } from '../../common/promptSyntax/computeAutomaticInstructions.js'; import { PromptsConfig } from '../../common/promptSyntax/config/config.js'; import { IHandOff, PromptHeader, Target } from '../../common/promptSyntax/promptFileParser.js'; @@ -1559,7 +1559,7 @@ export class ChatWidget extends Disposable implements IChatWidget { if (!isInput) { this.inputPart.setChatMode(this.input.currentModeObs.get().id); - const currentModel = this.input.selectedLanguageModel; + const currentModel = this.input.selectedLanguageModel.get(); if (currentModel) { this.inputPart.switchModel(currentModel.metadata); } @@ -1740,7 +1740,7 @@ export class ChatWidget extends Disposable implements IChatWidget { const toolIds = new Set(); for (const [entry, enabled] of this.input.selectedToolsModel.entriesMap.read(r)) { if (enabled) { - if (entry instanceof ToolSet) { + if (isToolSet(entry)) { toolSetIds.add(entry.id); } else { toolIds.add(entry.id); @@ -2376,7 +2376,7 @@ export class ChatWidget extends Disposable implements IChatWidget { // if not tools to enable are present, we are done if (tools !== undefined && this.input.currentModeKind === ChatModeKind.Agent) { - const enablementMap = this.toolsService.toToolAndToolSetEnablementMap(tools, Target.VSCode); + const enablementMap = this.toolsService.toToolAndToolSetEnablementMap(tools, Target.VSCode, this.input.selectedLanguageModel.get()?.metadata); this.input.selectedToolsModel.set(enablementMap, true); } @@ -2395,7 +2395,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.logService.debug(`ChatWidget#_autoAttachInstructions: prompt files are always enabled`); const enabledTools = this.input.currentModeKind === ChatModeKind.Agent ? this.input.selectedToolsModel.userSelectedTools.get() : undefined; const enabledSubAgents = this.input.currentModeKind === ChatModeKind.Agent ? this.input.currentModeObs.get().agents?.get() : undefined; - const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, enabledTools, enabledSubAgents); + const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, this.input.currentModeObs.get(), enabledTools, enabledSubAgents); await computer.collect(attachedContext, CancellationToken.None); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatContextUsageWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatContextUsageWidget.ts deleted file mode 100644 index 6af972fcf3ac3..0000000000000 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatContextUsageWidget.ts +++ /dev/null @@ -1,391 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import './media/chatContextUsageWidget.css'; -import * as dom from '../../../../../../base/browser/dom.js'; -import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; -import { IChatModel } from '../../../common/model/chatModel.js'; -import { ILanguageModelsService } from '../../../common/languageModels.js'; -import { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; -import { localize } from '../../../../../../nls.js'; -import { RunOnceScheduler } from '../../../../../../base/common/async.js'; -import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; - -const $ = dom.$; - -export class ChatContextUsageWidget extends Disposable { - - public readonly domNode: HTMLElement; - private readonly ringProgress: SVGCircleElement; - - private readonly _modelListener = this._register(new MutableDisposable()); - private _currentModel: IChatModel | undefined; - - private readonly _updateScheduler: RunOnceScheduler; - private readonly _hoverDisplayScheduler: RunOnceScheduler; - - // Stats - private _totalTokenCount = 0; - private _systemTokenCount = 0; - private _promptsTokenCount = 0; - private _filesTokenCount = 0; - private _imagesTokenCount = 0; - private _selectionTokenCount = 0; - private _toolsTokenCount = 0; - private _workspaceTokenCount = 0; - - private _maxTokenCount = 4096; // Default fallback - private _usagePercent = 0; - - private _hoverQuotaBit: HTMLElement | undefined; - private _hoverQuotaValue: HTMLElement | undefined; - private _hoverItemValues: Map = new Map(); - - constructor( - @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, - @IHoverService private readonly hoverService: IHoverService, - @ICommandService private readonly commandService: ICommandService, - ) { - super(); - - this.domNode = $('.chat-context-usage-widget'); - this.domNode.style.display = 'none'; - - // Create SVG Ring - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('class', 'chat-context-usage-ring'); - svg.setAttribute('width', '16'); - svg.setAttribute('height', '16'); - svg.setAttribute('viewBox', '0 0 16 16'); - - const background = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - background.setAttribute('class', 'chat-context-usage-ring-background'); - background.setAttribute('cx', '8'); - background.setAttribute('cy', '8'); - background.setAttribute('r', '7'); - svg.appendChild(background); - - this.ringProgress = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - this.ringProgress.setAttribute('class', 'chat-context-usage-ring-progress'); - this.ringProgress.setAttribute('cx', '8'); - this.ringProgress.setAttribute('cy', '8'); - this.ringProgress.setAttribute('r', '7'); - svg.appendChild(this.ringProgress); - - this.domNode.appendChild(svg); - - this._updateScheduler = this._register(new RunOnceScheduler(() => this._refreshUsage(), 1000)); - this._hoverDisplayScheduler = this._register(new RunOnceScheduler(() => { - this._updateScheduler.schedule(0); - this.hoverService.showInstantHover({ - content: this._getHoverDomNode(), - target: this.domNode, - appearance: { - showPointer: true, - skipFadeInAnimation: true - } - }); - }, 600)); - - this._register(dom.addDisposableListener(this.domNode, 'mouseenter', () => { - this._hoverDisplayScheduler.schedule(); - })); - - this._register(dom.addDisposableListener(this.domNode, 'mouseleave', () => { - this._hoverDisplayScheduler.cancel(); - })); - - this._register(dom.addDisposableListener(this.domNode, dom.EventType.CLICK, () => { - this._hoverDisplayScheduler.cancel(); - this._updateScheduler.schedule(0); - this.hoverService.showInstantHover({ - content: this._getHoverDomNode(), - target: this.domNode, - appearance: { - showPointer: true, - skipFadeInAnimation: true - }, - persistence: { - sticky: true - } - }, true); - })); - } - - setModel(model: IChatModel | undefined) { - if (this._currentModel === model) { - return; - } - - this._currentModel = model; - this._modelListener.clear(); - - if (model && !model.contributedChatSession) { - this._modelListener.value = model.onDidChange(() => { - this._updateScheduler.schedule(); - }); - this._updateScheduler.schedule(0); - } else { - this.domNode.style.display = 'none'; - } - } - - private async _refreshUsage() { - if (!this._currentModel) { - return; - } - - const requests = this._currentModel.getRequests(); - - if (requests.length === 0) { - this.domNode.style.display = 'none'; - return; - } - - this.domNode.style.display = ''; - - let modelId: string | undefined; - - const inputState = this._currentModel.inputModel.state.get(); - if (inputState?.selectedModel) { - modelId = inputState.selectedModel.identifier; - if (inputState.selectedModel.metadata.maxInputTokens) { - this._maxTokenCount = inputState.selectedModel.metadata.maxInputTokens; - } - } - - const countTokens = async (text: string): Promise => { - if (modelId) { - try { - return await this.languageModelsService.computeTokenLength(modelId, text, CancellationToken.None); - } catch (error) { - return text.length / 4; - } - } - return text.length / 4; - }; - - const requestCounts = await Promise.all(requests.map(async (request) => { - let p = 0; - let f = 0; - let i = 0; - let s = 0; - let t = 0; - let w = 0; - - // Prompts: User message - const messageText = typeof request.message === 'string' ? request.message : request.message.text; - p += await countTokens(messageText); - - // Variables (Files, Context) - if (request.variableData && request.variableData.variables) { - for (const variable of request.variableData.variables) { - // Estimate usage - const defaultEstimate = 500; - if (variable.kind === 'file') { - f += defaultEstimate; - } else if (variable.kind === 'image') { - i += defaultEstimate; - } else if (variable.kind === 'implicit' && variable.isSelection) { - s += defaultEstimate; - } else { - w += defaultEstimate; - } - } - } - - // Tools & Response - if (request.response) { - const responseString = request.response.response.toString(); - p += await countTokens(responseString); - - for (const part of request.response.response.value) { - if (part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') { - t += 200; - } - } - } - - return { p, f, i, s, t, w }; - })); - - - const lastRequest = requests[requests.length - 1]; - if (lastRequest.modeInfo?.modeInstructions) { - this._systemTokenCount = await countTokens(lastRequest.modeInfo.modeInstructions.content); - } else { - this._systemTokenCount = 0; - } - - this._promptsTokenCount = 0; - this._filesTokenCount = 0; - this._imagesTokenCount = 0; - this._selectionTokenCount = 0; - this._toolsTokenCount = 0; - this._workspaceTokenCount = 0; - - for (const count of requestCounts) { - this._promptsTokenCount += count.p; - this._filesTokenCount += count.f; - this._imagesTokenCount += count.i; - this._selectionTokenCount += count.s; - this._toolsTokenCount += count.t; - this._workspaceTokenCount += count.w; - } - - this._totalTokenCount = Math.round(this._systemTokenCount + this._promptsTokenCount + this._filesTokenCount + this._imagesTokenCount + this._selectionTokenCount + this._toolsTokenCount + this._workspaceTokenCount); - this._usagePercent = Math.min(100, (this._totalTokenCount / this._maxTokenCount) * 100); - - this._updateRing(); - this._updateHover(); - } - - private _formatTokens(value: number): string { - if (value >= 1000) { - const thousands = value / 1000; - return `${thousands >= 10 ? Math.round(thousands) : thousands.toFixed(1)}k`; - } - return `${value}`; - } - - private _updateRing() { - const r = 7; - const c = 2 * Math.PI * r; - const offset = c - (this._usagePercent / 100) * c; - this.ringProgress.style.strokeDashoffset = String(offset); - - this.domNode.classList.remove('warning', 'error'); - if (this._usagePercent > 95) { - this.domNode.classList.add('error'); - } else if (this._usagePercent > 75) { - this.domNode.classList.add('warning'); - } - } - - private _updateHover() { - if (this._hoverQuotaValue) { - const percentStr = `${this._usagePercent.toFixed(0)}%`; - const usageStr = `${this._formatTokens(this._totalTokenCount)} / ${this._formatTokens(this._maxTokenCount)}`; - this._hoverQuotaValue.textContent = `${usageStr} • ${percentStr}`; - } - - if (this._hoverQuotaBit) { - this._hoverQuotaBit.style.width = `${this._usagePercent}%`; - } - - const updateItem = (key: string, value: number) => { - const item = this._hoverItemValues.get(key); - if (item) { - const percent = this._maxTokenCount > 0 ? (value / this._maxTokenCount) * 100 : 0; - const displayValue = `${percent.toFixed(0)}%`; - item.textContent = displayValue; - } - }; - - updateItem('system', this._systemTokenCount); - updateItem('messages', this._promptsTokenCount); - updateItem('attachedFiles', this._filesTokenCount); - updateItem('images', this._imagesTokenCount); - updateItem('selection', this._selectionTokenCount); - updateItem('systemTools', this._toolsTokenCount); - updateItem('workspace', this._workspaceTokenCount); - } - - private _getHoverDomNode(): HTMLElement { - const container = $('.chat-context-usage-hover'); - - const percentStr = `${this._usagePercent.toFixed(0)}%`; - const usageStr = `${this._formatTokens(this._totalTokenCount)} / ${this._formatTokens(this._maxTokenCount)}`; - - // Quota Indicator (Progress Bar) - const quotaIndicator = dom.append(container, $('.quota-indicator')); - - const quotaLabel = dom.append(quotaIndicator, $('.quota-label')); - dom.append(quotaLabel, $('span.quota-title', undefined, localize('totalUsageLabel', "Total usage"))); - this._hoverQuotaValue = dom.append(quotaLabel, $('span.quota-value', undefined, `${usageStr} • ${percentStr}`)); - - const quotaBar = dom.append(quotaIndicator, $('.quota-bar')); - this._hoverQuotaBit = dom.append(quotaBar, $('.quota-bit')); - this._hoverQuotaBit.style.width = `${this._usagePercent}%`; - - if (this._usagePercent > 75) { - if (this._usagePercent > 95) { - quotaIndicator.classList.add('error'); - } else { - quotaIndicator.classList.add('warning'); - } - - const quotaSubLabel = dom.append(quotaIndicator, $('div.quota-sub-label')); - quotaSubLabel.textContent = this._usagePercent >= 100 - ? localize('contextWindowFull', "Context window full") - : localize('approachingLimit', "Approaching limit"); - } - - // List - const list = dom.append(container, $('.chat-context-usage-hover-list')); - this._hoverItemValues.clear(); - - const addItem = (key: string, label: string, value: number) => { - const item = dom.append(list, $('.chat-context-usage-hover-item')); - dom.append(item, $('span.label', undefined, label)); - - // Calculate percentage for breakdown - const percent = this._maxTokenCount > 0 ? (value / this._maxTokenCount) * 100 : 0; - const displayValue = `${percent.toFixed(0)}%`; - const valueSpan = dom.append(item, $('span.value', undefined, displayValue)); - this._hoverItemValues.set(key, valueSpan); - }; - - const addTitle = (label: string) => { - dom.append(list, $('.chat-context-usage-hover-title', undefined, label)); - }; - - const addSeparator = () => { - dom.append(list, $('.chat-context-usage-hover-separator')); - }; - - // Group 1: System - addTitle(localize('systemGroup', "System")); - addItem('system', localize('system', "System prompt"), Math.round(this._systemTokenCount)); - addItem('systemTools', localize('systemTools', "System tools"), Math.round(this._toolsTokenCount)); - - addSeparator(); - - // Group 2: Messages - addTitle(localize('messagesGroup', "Conversation")); - addItem('messages', localize('messages', "Messages"), Math.round(this._promptsTokenCount)); - - addSeparator(); - - // Group 3: Data / Context - addTitle(localize('dataGroup', "Context")); - addItem('attachedFiles', localize('attachedFiles', "Attached files"), Math.round(this._filesTokenCount)); - addItem('images', localize('images', "Images"), Math.round(this._imagesTokenCount)); - addItem('selection', localize('selection', "Selection"), Math.round(this._selectionTokenCount)); - addItem('workspace', localize('workspace', "Workspace"), Math.round(this._workspaceTokenCount)); - - if (this._usagePercent > 75) { - const warning = dom.append(container, $('.chat-context-usage-warning')); - - const link = dom.append(warning, $('a', { href: '#', class: 'chat-context-usage-action-link' }, localize('startNewSession', "Start a new session"))); - - this._register(dom.addDisposableListener(link, 'click', (e) => { - e.preventDefault(); - this.hoverService.hideHover(); - this.commandService.executeCommand('workbench.action.chat.newChat'); - })); - - const suffix = localize('toIncreaseLimit', " to reset context window."); - dom.append(warning, document.createTextNode(suffix)); - - if (this._usagePercent > 95) { - warning.classList.add('error'); - } - } - - return container; - } -} 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 01bcdcf72a334..39de6a9baabb7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -26,6 +26,7 @@ import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposab import { ResourceSet } from '../../../../../../base/common/map.js'; import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; import { Schemas } from '../../../../../../base/common/network.js'; +import { mixin } from '../../../../../../base/common/objects.js'; import { autorun, derived, derivedOpts, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../../../../base/common/observable.js'; import { isMacintosh } from '../../../../../../base/common/platform.js'; import { isEqual } from '../../../../../../base/common/resources.js'; @@ -69,6 +70,7 @@ import { bindContextKey } from '../../../../../../platform/observable/common/pla import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; import { ISharedWebContentExtractorService } from '../../../../../../platform/webContentExtractor/common/webContentExtractor.js'; +import { IWorkspaceContextService, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; import { ResourceLabels } from '../../../../../browser/labels.js'; import { IWorkbenchAssignmentService } from '../../../../../services/assignment/common/assignmentService.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; @@ -78,50 +80,47 @@ import { AccessibilityCommandId } from '../../../../accessibility/common/accessi import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions, setupSimpleEditorSelectionStyling } from '../../../../codeEditor/browser/simpleEditorOptions.js'; import { InlineChatConfigKeys } from '../../../../inlineChat/common/inlineChat.js'; import { IChatViewTitleActionContext } from '../../../common/actions/chatActions.js'; -import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; -import { IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; -import { IChatModelInputState, IChatRequestModeInfo, IInputModel } from '../../../common/model/chatModel.js'; +import { ChatRequestVariableSet, IChatRequestVariableEntry, isElementVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, isStringImplicitContextValue, isStringVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { ChatMode, IChatMode, IChatModeService } from '../../../common/chatModes.js'; import { IChatFollowup, IChatService, IChatSessionContext } from '../../../common/chatService/chatService.js'; import { agentOptionId, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService, isIChatSessionFileChange2, localChatSessionType } from '../../../common/chatSessionsService.js'; -import { getChatSessionType } from '../../../common/model/chatUri.js'; -import { ChatRequestVariableSet, IChatRequestVariableEntry, isElementVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, isStringImplicitContextValue, isStringVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; -import { IChatResponseViewModel } from '../../../common/model/chatViewModel.js'; -import { ChatHistoryNavigator } from '../../../common/widget/chatWidgetHistoryService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, validateChatMode } from '../../../common/constants.js'; +import { IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../common/languageModels.js'; +import { IChatModelInputState, IChatRequestModeInfo, IInputModel } from '../../../common/model/chatModel.js'; +import { getChatSessionType } from '../../../common/model/chatUri.js'; +import { IChatResponseViewModel } from '../../../common/model/chatViewModel.js'; +import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; +import { ChatHistoryNavigator } from '../../../common/widget/chatWidgetHistoryService.js'; import { ChatSessionPrimaryPickerAction, ChatSubmitAction, IChatExecuteActionContext, OpenDelegationPickerAction, OpenModelPickerAction, OpenModePickerAction, OpenSessionTargetPickerAction, OpenWorkspacePickerAction } from '../../actions/chatExecuteActions.js'; -import { IAgentSessionsService } from '../../agentSessions/agentSessionsService.js'; import { AgentSessionProviders, getAgentSessionProvider } from '../../agentSessions/agentSessions.js'; -import { ImplicitContextAttachmentWidget } from '../../attachments/implicitContextAttachment.js'; -import { IChatWidget, ISessionTypePickerDelegate, IWorkspacePickerDelegate, isIChatResourceViewContext } from '../../chat.js'; +import { IAgentSessionsService } from '../../agentSessions/agentSessionsService.js'; import { ChatAttachmentModel } from '../../attachments/chatAttachmentModel.js'; import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, NotebookCellOutputChatAttachmentWidget, PasteAttachmentWidget, PromptFileAttachmentWidget, PromptTextAttachmentWidget, SCMHistoryItemAttachmentWidget, SCMHistoryItemChangeAttachmentWidget, SCMHistoryItemChangeRangeAttachmentWidget, TerminalCommandAttachmentWidget, ToolSetOrToolItemAttachmentWidget } from '../../attachments/chatAttachmentWidgets.js'; +import { ChatImplicitContext } from '../../attachments/chatImplicitContext.js'; +import { ChatRelatedFiles } from '../../attachments/chatInputRelatedFilesContrib.js'; +import { ImplicitContextAttachmentWidget } from '../../attachments/implicitContextAttachment.js'; +import { IChatWidget, ISessionTypePickerDelegate, isIChatResourceViewContext, IWorkspacePickerDelegate } from '../../chat.js'; +import { ChatEditingShowChangesAction, ViewAllSessionChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js'; +import { resizeImage } from '../../chatImageUtils.js'; +import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../chatSessions/chatSessionPickerActionItem.js'; +import { SearchableOptionPickerActionItem } from '../../chatSessions/searchableOptionPickerActionItem.js'; +import { IChatContextService } from '../../contextContrib/chatContextService.js'; import { IDisposableReference } from '../chatContentParts/chatCollections.js'; import { CollapsibleListPool, IChatCollapsibleListItem } from '../chatContentParts/chatReferencesContentPart.js'; import { ChatTodoListWidget } from '../chatContentParts/chatTodoListWidget.js'; -import { IChatContextService } from '../../contextContrib/chatContextService.js'; import { ChatDragAndDrop } from '../chatDragAndDrop.js'; -import { ChatEditingShowChangesAction, ViewAllSessionChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js'; import { ChatFollowups } from './chatFollowups.js'; import { ChatInputPartWidgetController } from './chatInputPartWidgets.js'; +import { IChatInputPickerOptions } from './chatInputPickerActionItem.js'; import { ChatSelectedTools } from './chatSelectedTools.js'; -import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../chatSessions/chatSessionPickerActionItem.js'; -import { ChatImplicitContext } from '../../attachments/chatImplicitContext.js'; -import { ChatRelatedFiles } from '../../attachments/chatInputRelatedFilesContrib.js'; -import { resizeImage } from '../../chatImageUtils.js'; +import { DelegationSessionPickerActionItem } from './delegationSessionPickerActionItem.js'; import { IModelPickerDelegate, ModelPickerActionItem } from './modelPickerActionItem.js'; import { IModePickerDelegate, ModePickerActionItem } from './modePickerActionItem.js'; import { SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; import { WorkspacePickerActionItem } from './workspacePickerActionItem.js'; -import { DelegationSessionPickerActionItem } from './delegationSessionPickerActionItem.js'; -import { IChatInputPickerOptions } from './chatInputPickerActionItem.js'; -import { SearchableOptionPickerActionItem } from '../../chatSessions/searchableOptionPickerActionItem.js'; -import { mixin } from '../../../../../../base/common/objects.js'; -import { ChatContextUsageWidget } from './chatContextUsageWidget.js'; -import { IWorkspaceContextService, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; const $ = dom.$; @@ -277,7 +276,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private chatInputTodoListWidgetContainer!: HTMLElement; private chatInputWidgetsContainer!: HTMLElement; private readonly _widgetController = this._register(new MutableDisposable()); - private readonly _contextUsageWidget = this._register(new MutableDisposable()); readonly height = observableValue(this, 0); @@ -345,20 +343,18 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge */ private readonly _optionContextKeys: Map> = new Map(); - private _currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined; + private _currentLanguageModel = observableValue('_currentLanguageModel', undefined); get currentLanguageModel() { - return this._currentLanguageModel?.identifier; + return this._currentLanguageModel.get()?.identifier; } - get selectedLanguageModel(): ILanguageModelChatMetadataAndIdentifier | undefined { + get selectedLanguageModel(): IObservable { return this._currentLanguageModel; } private _onDidChangeCurrentChatMode: Emitter = this._register(new Emitter()); readonly onDidChangeCurrentChatMode: Event = this._onDidChangeCurrentChatMode.event; - private _onDidChangeCurrentLanguageModel: Emitter = this._register(new Emitter()); - readonly onDidChangeCurrentLanguageModel: Event = this._onDidChangeCurrentLanguageModel.event; private readonly _currentModeObservable: ISettableObservable; @@ -523,7 +519,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._attachmentModel = this._register(this.instantiationService.createInstance(ChatAttachmentModel)); this._register(this._attachmentModel.onDidChange(() => this._syncInputStateToModel())); - this.selectedToolsModel = this._register(this.instantiationService.createInstance(ChatSelectedTools, this.currentModeObs)); + this.selectedToolsModel = this._register(this.instantiationService.createInstance(ChatSelectedTools, this.currentModeObs, this._currentLanguageModel)); this.dnd = this._register(this.instantiationService.createInstance(ChatDragAndDrop, () => this._widget, this._attachmentModel, styles)); this.inputEditorMaxHeight = this.options.renderStyle === 'compact' ? INPUT_EDITOR_MAX_HEIGHT / 3 : INPUT_EDITOR_MAX_HEIGHT; @@ -601,7 +597,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge ); // We've changed models and the current one is no longer available. Select a new one - const selectedModel = this._currentLanguageModel ? this.getModels().find(m => m.identifier === this._currentLanguageModel?.identifier) : undefined; + const selectedModel = this._currentLanguageModel ? this.getModels().find(m => m.identifier === this._currentLanguageModel.get()?.identifier) : undefined; const selectedModelNotAvailable = this._currentLanguageModel && (!selectedModel?.metadata.isUserSelectable); if (!this.currentLanguageModel || selectedModelNotAvailable) { this.setCurrentLanguageModelToDefault(); @@ -615,9 +611,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } this.setImplicitContextEnablement(); })); - this._register(this._onDidChangeCurrentLanguageModel.event(() => { - if (this._currentLanguageModel?.metadata.name) { - this.accessibilityService.alert(this._currentLanguageModel.metadata.name); + this._register(autorun(reader => { + const lm = this._currentLanguageModel.read(reader); + if (lm?.metadata.name) { + this.accessibilityService.alert(lm.metadata.name); } this._inputEditor?.updateOptions({ ariaLabel: this._getAriaLabel() }); })); @@ -735,7 +732,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge public switchToNextModel(): void { const models = this.getModels(); if (models.length > 0) { - const currentIndex = models.findIndex(model => model.identifier === this._currentLanguageModel?.identifier); + const currentIndex = models.findIndex(model => model.identifier === this._currentLanguageModel.get()?.identifier); const nextIndex = (currentIndex + 1) % models.length; this.setCurrentLanguageModel(models[nextIndex]); } @@ -897,7 +894,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Sync selected model if (state?.selectedModel) { - if (!this._currentLanguageModel || this._currentLanguageModel.identifier !== state.selectedModel.identifier) { + const lm = this._currentLanguageModel.get(); + if (!lm || lm.identifier !== state.selectedModel.identifier) { this.setCurrentLanguageModel(state.selectedModel); } } @@ -947,7 +945,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } public setCurrentLanguageModel(model: ILanguageModelChatMetadataAndIdentifier) { - this._currentLanguageModel = model; + this._currentLanguageModel.set(model, undefined); if (this.cachedWidth) { // For quick chat and editor chat, relayout because the input may need to shrink to accomodate the model name @@ -958,14 +956,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.storageService.store(this.getSelectedModelStorageKey(), model.identifier, StorageScope.APPLICATION, StorageTarget.USER); this.storageService.store(this.getSelectedModelIsDefaultStorageKey(), !!model.metadata.isDefaultForLocation[this.location], StorageScope.APPLICATION, StorageTarget.USER); - this._onDidChangeCurrentLanguageModel.fire(model); - // Sync to model this._syncInputStateToModel(); } private checkModelSupported(): void { - if (this._currentLanguageModel && (!this.modelSupportedForDefaultAgent(this._currentLanguageModel) || !this.modelSupportedForInlineChat(this._currentLanguageModel))) { + const lm = this._currentLanguageModel.get(); + if (lm && (!this.modelSupportedForDefaultAgent(lm) || !this.modelSupportedForInlineChat(lm))) { this.setCurrentLanguageModelToDefault(); } } @@ -1045,7 +1042,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge id: mode.id, kind: mode.kind }, - selectedModel: this._currentLanguageModel, + selectedModel: this._currentLanguageModel.get(), selections: this._inputEditor?.getSelections() || [], contrib: {}, }; @@ -1066,7 +1063,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const mode = this._currentModeObservable.get(); // Include model information if available - const modelName = this._currentLanguageModel?.metadata.name; + const modelName = this._currentLanguageModel.get()?.metadata.name; const modelInfo = modelName ? localize('chatInput.model', ", {0}. ", modelName) : ''; let modeLabel = ''; @@ -1670,7 +1667,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.updateAgentSessionTypeContextKey(); this.refreshChatSessionPickers(); this.tryUpdateWidgetController(); - this._contextUsageWidget.value?.setModel(widget.viewModel?.model); })); let elements; @@ -1736,15 +1732,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.chatInputTodoListWidgetContainer = elements.chatInputTodoListWidgetContainer; this.chatInputWidgetsContainer = elements.chatInputWidgetsContainer; - const isInline = isIChatResourceViewContext(widget.viewContext) && widget.viewContext.isInlineChat; - if (this.location !== ChatAgentLocation.EditorInline && !isInline) { - this._contextUsageWidget.value = this.instantiationService.createInstance(ChatContextUsageWidget); - elements.editorContainer.appendChild(this._contextUsageWidget.value.domNode); - if (this._widget?.viewModel) { - this._contextUsageWidget.value.setModel(this._widget.viewModel.model); - } - } - if (this.options.enableImplicitContext && !this._implicitContext) { this._implicitContext = this._register( this.instantiationService.createInstance(ChatImplicitContext), @@ -1890,6 +1877,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const hoverDelegate = this._register(createInstantHoverDelegate()); const pickerOptions: IChatInputPickerOptions = { getOverflowAnchor: () => this.inputActionsToolbar.getElement(), + actionContext: { widget }, }; this._register(dom.addStandardDisposableListener(toolbarsContainer, dom.EventType.CLICK, e => this.inputEditor.focus())); @@ -1912,8 +1900,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } const itemDelegate: IModelPickerDelegate = { - getCurrentModel: () => this._currentLanguageModel, - onDidChangeModel: this._onDidChangeCurrentLanguageModel.event, + currentModel: this._currentLanguageModel, setModel: (model: ILanguageModelChatMetadataAndIdentifier) => { this._waitForPersistedLanguageModel.clear(); this.setCurrentLanguageModel(model); @@ -1921,7 +1908,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }, getModels: () => this.getModels() }; - return this.modelWidget = this.instantiationService.createInstance(ModelPickerActionItem, action, this._currentLanguageModel, undefined, itemDelegate, pickerOptions); + return this.modelWidget = this.instantiationService.createInstance(ModelPickerActionItem, action, undefined, itemDelegate, pickerOptions); } else if (action.id === OpenModePickerAction.ID && action instanceof MenuItemAction) { const delegate: IModePickerDelegate = { currentMode: this._currentModeObservable, @@ -2143,32 +2130,33 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge let attachmentWidget; const options = { shouldFocusClearButton, supportsDeletion: true }; + const lm = this._currentLanguageModel.get(); if (attachment.kind === 'tool' || attachment.kind === 'toolset') { - attachmentWidget = this.instantiationService.createInstance(ToolSetOrToolItemAttachmentWidget, attachment, this._currentLanguageModel, options, container, this._contextResourceLabels); + attachmentWidget = this.instantiationService.createInstance(ToolSetOrToolItemAttachmentWidget, attachment, lm, options, container, this._contextResourceLabels); } else if (resource && isNotebookOutputVariableEntry(attachment)) { - attachmentWidget = this.instantiationService.createInstance(NotebookCellOutputChatAttachmentWidget, resource, attachment, this._currentLanguageModel, options, container, this._contextResourceLabels); + attachmentWidget = this.instantiationService.createInstance(NotebookCellOutputChatAttachmentWidget, resource, attachment, lm, options, container, this._contextResourceLabels); } else if (isPromptFileVariableEntry(attachment)) { - attachmentWidget = this.instantiationService.createInstance(PromptFileAttachmentWidget, attachment, this._currentLanguageModel, options, container, this._contextResourceLabels); + attachmentWidget = this.instantiationService.createInstance(PromptFileAttachmentWidget, attachment, lm, options, container, this._contextResourceLabels); } else if (isPromptTextVariableEntry(attachment)) { attachmentWidget = this.instantiationService.createInstance(PromptTextAttachmentWidget, attachment, undefined, options, container, this._contextResourceLabels); } else if (resource && (attachment.kind === 'file' || attachment.kind === 'directory')) { - attachmentWidget = this.instantiationService.createInstance(FileAttachmentWidget, resource, range, attachment, undefined, this._currentLanguageModel, options, container, this._contextResourceLabels); + attachmentWidget = this.instantiationService.createInstance(FileAttachmentWidget, resource, range, attachment, undefined, lm, options, container, this._contextResourceLabels); } else if (attachment.kind === 'terminalCommand') { - attachmentWidget = this.instantiationService.createInstance(TerminalCommandAttachmentWidget, attachment, this._currentLanguageModel, options, container, this._contextResourceLabels); + attachmentWidget = this.instantiationService.createInstance(TerminalCommandAttachmentWidget, attachment, lm, options, container, this._contextResourceLabels); } else if (isImageVariableEntry(attachment)) { - attachmentWidget = this.instantiationService.createInstance(ImageAttachmentWidget, resource, attachment, this._currentLanguageModel, options, container, this._contextResourceLabels); + attachmentWidget = this.instantiationService.createInstance(ImageAttachmentWidget, resource, attachment, lm, options, container, this._contextResourceLabels); } else if (isElementVariableEntry(attachment)) { - attachmentWidget = this.instantiationService.createInstance(ElementChatAttachmentWidget, attachment, this._currentLanguageModel, options, container, this._contextResourceLabels); + attachmentWidget = this.instantiationService.createInstance(ElementChatAttachmentWidget, attachment, lm, options, container, this._contextResourceLabels); } else if (isPasteVariableEntry(attachment)) { - attachmentWidget = this.instantiationService.createInstance(PasteAttachmentWidget, attachment, this._currentLanguageModel, options, container, this._contextResourceLabels); + attachmentWidget = this.instantiationService.createInstance(PasteAttachmentWidget, attachment, lm, options, container, this._contextResourceLabels); } else if (isSCMHistoryItemVariableEntry(attachment)) { - attachmentWidget = this.instantiationService.createInstance(SCMHistoryItemAttachmentWidget, attachment, this._currentLanguageModel, options, container, this._contextResourceLabels); + attachmentWidget = this.instantiationService.createInstance(SCMHistoryItemAttachmentWidget, attachment, lm, options, container, this._contextResourceLabels); } else if (isSCMHistoryItemChangeVariableEntry(attachment)) { - attachmentWidget = this.instantiationService.createInstance(SCMHistoryItemChangeAttachmentWidget, attachment, this._currentLanguageModel, options, container, this._contextResourceLabels); + attachmentWidget = this.instantiationService.createInstance(SCMHistoryItemChangeAttachmentWidget, attachment, lm, options, container, this._contextResourceLabels); } else if (isSCMHistoryItemChangeRangeVariableEntry(attachment)) { - attachmentWidget = this.instantiationService.createInstance(SCMHistoryItemChangeRangeAttachmentWidget, attachment, this._currentLanguageModel, options, container, this._contextResourceLabels); + attachmentWidget = this.instantiationService.createInstance(SCMHistoryItemChangeRangeAttachmentWidget, attachment, lm, options, container, this._contextResourceLabels); } else { - attachmentWidget = this.instantiationService.createInstance(DefaultChatAttachmentWidget, resource, range, attachment, undefined, this._currentLanguageModel, options, container, this._contextResourceLabels); + attachmentWidget = this.instantiationService.createInstance(DefaultChatAttachmentWidget, resource, range, attachment, undefined, lm, options, container, this._contextResourceLabels); } if (shouldFocusClearButton) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts index 0fdc94028262e..c8c5ca1723701 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts @@ -10,6 +10,7 @@ import { IActionWidgetService } from '../../../../../../platform/actionWidget/br import { IActionWidgetDropdownOptions } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; +import { IChatExecuteActionContext } from '../../actions/chatExecuteActions.js'; export interface IChatInputPickerOptions { /** @@ -17,6 +18,8 @@ export interface IChatInputPickerOptions { * is not available in the DOM (e.g., when inside an overflow menu). */ readonly getOverflowAnchor?: () => HTMLElement | undefined; + + readonly actionContext?: IChatExecuteActionContext; } /** diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts index 21a4c37ece421..0c15407e6360a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts @@ -11,14 +11,17 @@ import { URI } from '../../../../../../base/common/uri.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ObservableMemento, observableMemento } from '../../../../../../platform/observable/common/observableMemento.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; -import { UserSelectedTools } from '../../../common/participants/chatAgents.js'; import { IChatMode } from '../../../common/chatModes.js'; import { ChatModeKind } from '../../../common/constants.js'; -import { ILanguageModelToolsService, IToolAndToolSetEnablementMap, IToolData, ToolSet } from '../../../common/tools/languageModelToolsService.js'; +import { ILanguageModelChatMetadataAndIdentifier } from '../../../common/languageModels.js'; +import { UserSelectedTools } from '../../../common/participants/chatAgents.js'; import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { ILanguageModelToolsService, IToolAndToolSetEnablementMap, IToolData, IToolSet, isToolSet } from '../../../common/tools/languageModelToolsService.js'; import { PromptFileRewriter } from '../../promptSyntax/promptFileRewriter.js'; +// todo@connor4312/bhavyaus: make tools key off displayName so model-specific tool +// enablement can stick between models with different underlying tool definitions type ToolEnablementStates = { readonly toolSets: ReadonlyMap; readonly tools: ReadonlyMap; @@ -40,7 +43,7 @@ namespace ToolEnablementStates { export function fromMap(map: IToolAndToolSetEnablementMap): ToolEnablementStates { const toolSets: Map = new Map(), tools: Map = new Map(); for (const [entry, enabled] of map.entries()) { - if (entry instanceof ToolSet) { + if (isToolSet(entry)) { toolSets.set(entry.id, enabled); } else { tools.set(entry.id, enabled); @@ -98,9 +101,11 @@ export class ChatSelectedTools extends Disposable { private readonly _globalState: ObservableMemento; private readonly _sessionStates = new ObservableMap(); + private readonly _currentTools: IObservable; constructor( private readonly _mode: IObservable, + private readonly languageModel: IObservable, @ILanguageModelToolsService private readonly _toolsService: ILanguageModelToolsService, @IStorageService _storageService: IStorageService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @@ -115,13 +120,17 @@ export class ChatSelectedTools extends Disposable { }); this._globalState = this._store.add(globalStateMemento(StorageScope.PROFILE, StorageTarget.MACHINE, _storageService)); + this._currentTools = languageModel.map(lm => + _toolsService.observeTools(lm?.metadata)).map((o, r) => o.read(r)); } /** * All tools and tool sets with their enabled state. + * Tools are filtered based on the current model context. */ public readonly entriesMap: IObservable = derived(r => { - const map = new Map(); + const map = new Map(); + const lm = this.languageModel.read(r)?.metadata; // look up the tools in the hierarchy: session > mode > global const currentMode = this._mode.read(r); @@ -130,18 +139,19 @@ export class ChatSelectedTools extends Disposable { const modeTools = currentMode.customTools?.read(r); if (modeTools) { const target = currentMode.target?.read(r); - currentMap = ToolEnablementStates.fromMap(this._toolsService.toToolAndToolSetEnablementMap(modeTools, target)); + currentMap = ToolEnablementStates.fromMap(this._toolsService.toToolAndToolSetEnablementMap(modeTools, target, lm)); } } if (!currentMap) { currentMap = this._globalState.read(r); } - for (const tool of this._toolsService.toolsObservable.read(r)) { + // Use getTools with contextKeyService to filter tools by current model + for (const tool of this._currentTools.read(r)) { if (tool.canBeReferencedInPrompt) { map.set(tool, currentMap.tools.get(tool.id) !== false); // if unknown, it's enabled } } - for (const toolSet of this._toolsService.toolSets.read(r)) { + for (const toolSet of this._toolsService.getToolSetsForModel(lm, r)) { const toolSetEnabled = currentMap.toolSets.get(toolSet.id) !== false; // if unknown, it's enabled map.set(toolSet, toolSetEnabled); for (const tool of toolSet.getTools(r)) { @@ -156,7 +166,7 @@ export class ChatSelectedTools extends Disposable { const result: UserSelectedTools = {}; const map = this.entriesMap.read(r); for (const [item, enabled] of map) { - if (!(item instanceof ToolSet)) { + if (!isToolSet(item)) { result[item.id] = enabled; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts index 4f73ea56374c7..9cacb07ddfd39 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts @@ -54,7 +54,7 @@ import { IChatSlashCommandService } from '../../../../common/participants/chatSl import { IChatRequestVariableEntry } from '../../../../common/attachments/chatVariableEntries.js'; import { IDynamicVariable } from '../../../../common/attachments/chatVariables.js'; import { ChatAgentLocation, ChatModeKind, isSupportedChatFileScheme } from '../../../../common/constants.js'; -import { ToolSet } from '../../../../common/tools/languageModelToolsService.js'; +import { isToolSet } from '../../../../common/tools/languageModelToolsService.js'; import { IPromptsService } from '../../../../common/promptSyntax/service/promptsService.js'; import { ChatSubmitAction, IChatExecuteActionContext } from '../../../actions/chatExecuteActions.js'; import { IChatWidget, IChatWidgetService } from '../../../chat.js'; @@ -1220,7 +1220,7 @@ class ToolCompletions extends Disposable { let documentation: string | undefined; let name: string; - if (item instanceof ToolSet) { + if (isToolSet(item)) { detail = item.description; name = item.referenceName; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/media/chatContextUsageWidget.css b/src/vs/workbench/contrib/chat/browser/widget/input/media/chatContextUsageWidget.css deleted file mode 100644 index 61b00693ca7c8..0000000000000 --- a/src/vs/workbench/contrib/chat/browser/widget/input/media/chatContextUsageWidget.css +++ /dev/null @@ -1,163 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.chat-context-usage-widget { - position: absolute; - top: 10px; - right: 10px; - width: 16px; - height: 16px; - z-index: 10; - cursor: pointer; - opacity: 0.6; -} - -.chat-context-usage-widget:hover { - opacity: 1; -} - -.chat-context-usage-ring { - transform: rotate(-90deg); -} - -.chat-context-usage-ring-background { - fill: none; - stroke: var(--vscode-icon-foreground); - stroke-width: 2; - opacity: 0.3; -} - -.chat-context-usage-ring-progress { - fill: none; - stroke: var(--vscode-icon-foreground); - stroke-width: 2; - stroke-dasharray: 44; /* 2 * PI * r (r=7) */ - stroke-dashoffset: 44; - transition: stroke-dashoffset 0.5s ease; -} - -.chat-context-usage-widget.warning .chat-context-usage-ring-progress { - stroke: var(--vscode-editorWarning-foreground); -} - -.chat-context-usage-widget.error .chat-context-usage-ring-progress { - stroke: var(--vscode-editorError-foreground); -} - -/* Hover Content */ - -.chat-context-usage-hover { - min-width: 200px; - padding: 8px; - display: flex; - flex-direction: column; - gap: 8px; - font-size: 12px; -} - -.chat-context-usage-hover .header { - font-weight: 600; - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 4px; -} - -.chat-context-usage-hover .quota-indicator .quota-label { - display: flex; - justify-content: space-between; - gap: 20px; - margin-bottom: 3px; -} - -.chat-context-usage-hover .quota-indicator .quota-label{ - color: var(--vscode-foreground); -} - -.chat-context-usage-hover .quota-indicator .quota-label .quota-value{ - color: var(--vscode-descriptionForeground); -} - -.chat-context-usage-hover .quota-indicator .quota-bar { - width: 100%; - height: 4px; - background-color: var(--vscode-gauge-background); - border-radius: 4px; - border: 1px solid var(--vscode-gauge-border); - margin: 4px 0; -} - -.chat-context-usage-hover .quota-indicator .quota-bar .quota-bit { - height: 100%; - background-color: var(--vscode-gauge-foreground); - border-radius: 4px; -} - -.chat-context-usage-hover .quota-indicator.warning .quota-bar { - background-color: var(--vscode-gauge-warningBackground); -} - -.chat-context-usage-hover .quota-indicator.warning .quota-bar .quota-bit { - background-color: var(--vscode-gauge-warningForeground); -} - -.chat-context-usage-hover .quota-indicator.error .quota-bar { - background-color: var(--vscode-gauge-errorBackground); -} - -.chat-context-usage-hover .quota-indicator.error .quota-bar .quota-bit { - background-color: var(--vscode-gauge-errorForeground); -} - -.chat-context-usage-hover .quota-indicator .quota-sub-label { - font-size: 12px; - text-align: right; - color: var(--vscode-descriptionForeground); -} - -.chat-context-usage-hover-list { - display: flex; - flex-direction: column; - gap: 2px; -} - -.chat-context-usage-hover-item { - display: flex; - justify-content: space-between; -} - -.chat-context-usage-hover-item .label { - color: var(--vscode-foreground); -} - - -.chat-context-usage-hover-item .value { - color: var(--vscode-descriptionForeground); -} - -.chat-context-usage-action-link { - color: var(--vscode-textLink-foreground); - text-decoration: none; - cursor: pointer; -} - -.chat-context-usage-action-link:hover { - text-decoration: underline; -} - -.chat-context-usage-hover-separator { - height: 1px; - background-color: var(--vscode-menu-separatorBackground); /* Use menu separator color */ - margin: 4px 0; - width: 100%; - opacity: 0.5; -} - -.chat-context-usage-hover-title { - font-weight: 600; - margin-top: 4px; - margin-bottom: 4px; - color: var(--vscode-descriptionForeground); -} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index 307f87eff23ef..c8b2fc02ca7ba 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -25,8 +25,9 @@ import { IKeybindingService } from '../../../../../../platform/keybinding/common import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ChatMode, IChatMode, IChatModeService } from '../../../common/chatModes.js'; +import { isOrganizationPromptFile } from '../../../common/promptSyntax/utils/promptsServiceUtils.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js'; -import { ExtensionAgentSourceType, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { getOpenChatActionIdForMode } from '../../actions/chatActions.js'; import { IToggleChatModeArgs, ToggleAgentModeActionId } from '../../actions/chatExecuteActions.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; @@ -68,6 +69,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { const policyDisabledCategory = { label: localize('managedByOrganization', "Managed by your organization"), order: 999, showHeader: true }; const agentModeDisabledViaPolicy = configurationService.inspect(ChatConfiguration.AgentEnabled).policyValue === false; + const alternativeToolActionEnabled = configurationService.getValue(ChatConfiguration.AlternativeToolAction); const makeAction = (mode: IChatMode, currentMode: IChatMode): IActionWidgetDropdownAction => { const isDisabledViaPolicy = @@ -76,6 +78,30 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { const tooltip = chatAgentService.getDefaultAgent(ChatAgentLocation.Chat, mode.kind)?.description ?? action.tooltip; + const toolbarActions: IAction[] = []; + if (alternativeToolActionEnabled && mode.kind === ChatModeKind.Agent && !isDisabledViaPolicy) { + // Add toolbar actions for Agent modes when alternative tool action is enabled + const label = localize('configureToolsFor', "Configure tools for {0} {1}", mode.label.get(), isModeConsideredBuiltIn(mode, this._productService) ? 'mode' : 'agent'); + toolbarActions.push({ + id: 'configureToolsForMode', + label: label, + tooltip: label, + class: ThemeIcon.asClassName(Codicon.tools), + enabled: true, + run: async () => { + // First switch to the mode if not already selected + if (currentMode.id !== mode.id) { + await commandService.executeCommand( + ToggleAgentModeActionId, + { modeId: mode.id, sessionResource: this.delegate.sessionResource() } satisfies IToggleChatModeArgs + ); + } + // Then open the tools picker + await commandService.executeCommand('workbench.action.chat.configureTools', pickerOptions.actionContext, { source: 'modePicker' }); + } + }); + } + return { ...action, id: getOpenChatActionIdForMode(mode), @@ -86,6 +112,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { checked: !isDisabledViaPolicy && currentMode.id === mode.id, tooltip: '', hover: { content: tooltip }, + toolbarActions, run: async () => { if (isDisabledViaPolicy) { return; // Block interaction if disabled by policy @@ -230,5 +257,19 @@ function isModeConsideredBuiltIn(mode: IChatMode, productService: IProductServic if (mode.isBuiltin) { return true; } - return mode.source?.storage === PromptsStorage.extension && mode.source.extensionId.value === productService.defaultChatAgent?.chatExtensionId && mode.source.type === ExtensionAgentSourceType.contribution; + // Not built-in if not from the built-in chat extension + if (mode.source?.storage !== PromptsStorage.extension) { + return false; + } + const chatExtensionId = productService.defaultChatAgent?.chatExtensionId; + if (!chatExtensionId || mode.source.extensionId.value !== chatExtensionId) { + return false; + } + // Organization-provided agents (under /github/ path) are also not considered built-in + const modeUri = mode.uri?.get(); + if (!modeUri) { + // If somehow there is no URI, but it's from the built-in chat extension, consider it built-in + return true; + } + return !isOrganizationPromptFile(modeUri, mode.source.extensionId, productService); } 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 a161442e6c654..a4f4c32888ce0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts @@ -3,31 +3,30 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IAction } from '../../../../../../base/common/actions.js'; -import { Event } from '../../../../../../base/common/event.js'; -import { ILanguageModelChatMetadataAndIdentifier } from '../../../common/languageModels.js'; -import { localize } from '../../../../../../nls.js'; import * as dom from '../../../../../../base/browser/dom.js'; +import { IActionProvider } from '../../../../../../base/browser/ui/dropdown/dropdown.js'; +import { IManagedHoverContent } from '../../../../../../base/browser/ui/hover/hover.js'; import { renderIcon, renderLabelWithIcons } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { IAction } from '../../../../../../base/common/actions.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { autorun, IObservable } from '../../../../../../base/common/observable.js'; +import { localize } from '../../../../../../nls.js'; import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionWidgetDropdownAction, IActionWidgetDropdownActionProvider, IActionWidgetDropdownOptions } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; -import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; -import { ChatEntitlement, IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; -import { DEFAULT_MODEL_PICKER_CATEGORY } from '../../../common/widget/input/modelPickerWidget.js'; -import { IActionProvider } from '../../../../../../base/browser/ui/dropdown/dropdown.js'; -import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; -import { MANAGE_CHAT_COMMAND_ID } from '../../../common/constants.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { TelemetryTrustedValue } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; -import { IManagedHoverContent } from '../../../../../../base/browser/ui/hover/hover.js'; +import { ChatEntitlement, IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { MANAGE_CHAT_COMMAND_ID } from '../../../common/constants.js'; +import { ILanguageModelChatMetadataAndIdentifier } from '../../../common/languageModels.js'; +import { DEFAULT_MODEL_PICKER_CATEGORY } from '../../../common/widget/input/modelPickerWidget.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; export interface IModelPickerDelegate { - readonly onDidChangeModel: Event; - getCurrentModel(): ILanguageModelChatMetadataAndIdentifier | undefined; + readonly currentModel: IObservable; setModel(model: ILanguageModelChatMetadataAndIdentifier): void; getModels(): ILanguageModelChatMetadataAndIdentifier[]; } @@ -70,7 +69,7 @@ function modelDelegateToWidgetActionsProvider(delegate: IModelPickerDelegate, te id: model.metadata.id, enabled: true, icon: model.metadata.statusIcon, - checked: model.identifier === delegate.getCurrentModel()?.identifier, + checked: model.identifier === delegate.currentModel.get()?.identifier, category: model.metadata.modelPickerCategory || DEFAULT_MODEL_PICKER_CATEGORY, class: undefined, description: model.metadata.multiplier ?? model.metadata.detail, @@ -78,7 +77,7 @@ function modelDelegateToWidgetActionsProvider(delegate: IModelPickerDelegate, te hover: hoverContent ? { content: hoverContent } : undefined, label: model.metadata.name, run: () => { - const previousModel = delegate.getCurrentModel(); + const previousModel = delegate.currentModel.get(); telemetryService.publicLog2('chat.modelChange', { fromModel: previousModel?.metadata.vendor === 'copilot' ? new TelemetryTrustedValue(previousModel.identifier) : 'unknown', toModel: model.metadata.vendor === 'copilot' ? new TelemetryTrustedValue(model.identifier) : 'unknown' @@ -145,10 +144,10 @@ function getModelPickerActionBarActionProvider(commandService: ICommandService, * Action view item for selecting a language model in the chat interface. */ export class ModelPickerActionItem extends ChatInputPickerActionViewItem { + protected currentModel: ILanguageModelChatMetadataAndIdentifier | undefined; constructor( action: IAction, - protected currentModel: ILanguageModelChatMetadataAndIdentifier | undefined, widgetOptions: Omit | undefined, delegate: IModelPickerDelegate, pickerOptions: IChatInputPickerOptions, @@ -163,7 +162,7 @@ export class ModelPickerActionItem extends ChatInputPickerActionViewItem { // Modify the original action with a different label and make it show the current model const actionWithLabel: IAction = { ...action, - label: currentModel?.metadata.name ?? localize('chat.modelPicker.auto', "Auto"), + label: delegate.currentModel.get()?.metadata.name ?? localize('chat.modelPicker.auto', "Auto"), run: () => { } }; @@ -173,9 +172,11 @@ export class ModelPickerActionItem extends ChatInputPickerActionViewItem { }; super(actionWithLabel, widgetOptions ?? modelPickerActionWidgetOptions, pickerOptions, actionWidgetService, keybindingService, contextKeyService); + this.currentModel = delegate.currentModel.get(); // Listen for model changes from the delegate - this._register(delegate.onDidChangeModel(model => { + this._register(autorun(t => { + const model = delegate.currentModel.read(t); this.currentModel = model; this.updateTooltip(); if (this.element) { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts new file mode 100644 index 0000000000000..77741595adbd0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts @@ -0,0 +1,182 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/chatContextUsageDetails.css'; +import * as dom from '../../../../../../base/browser/dom.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../../nls.js'; +import { IMenuService, MenuId } from '../../../../../../platform/actions/common/actions.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { MenuWorkbenchButtonBar } from '../../../../../../platform/actions/browser/buttonbar.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; + +const $ = dom.$; + +export interface IChatContextUsagePromptTokenDetail { + category: string; + label: string; + percentageOfPrompt: number; +} + +export interface IChatContextUsageData { + promptTokens: number; + maxInputTokens: number; + percentage: number; + promptTokenDetails?: readonly IChatContextUsagePromptTokenDetail[]; +} + +/** + * Detailed widget that shows context usage breakdown. + * Displayed when the user clicks on the ChatContextUsageIcon. + */ +export class ChatContextUsageDetails extends Disposable { + + readonly domNode: HTMLElement; + + private readonly quotaItem: HTMLElement; + private readonly percentageLabel: HTMLElement; + private readonly progressFill: HTMLElement; + private readonly tokenDetailsContainer: HTMLElement; + private readonly warningMessage: HTMLElement; + private readonly actionsSection: HTMLElement; + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IMenuService private readonly menuService: IMenuService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + ) { + super(); + + this.domNode = $('.chat-context-usage-details'); + this.domNode.setAttribute('tabindex', '0'); + + // Using same structure as ChatUsageWidget quota items + this.quotaItem = this.domNode.appendChild($('.quota-item')); + + // Header row with label and percentage + const quotaItemHeader = this.quotaItem.appendChild($('.quota-item-header')); + const quotaItemLabel = quotaItemHeader.appendChild($('.quota-item-label')); + quotaItemLabel.textContent = localize('contextWindow', "Context Window"); + this.percentageLabel = quotaItemHeader.appendChild($('.quota-item-value')); + + // Progress bar - using same structure as chat usage widget + const progressBar = this.quotaItem.appendChild($('.quota-bar')); + this.progressFill = progressBar.appendChild($('.quota-bit')); + + // Token details container (for category breakdown) + this.tokenDetailsContainer = this.domNode.appendChild($('.token-details-container')); + + // Warning message (shown when usage is high) + this.warningMessage = this.domNode.appendChild($('.warning-message')); + this.warningMessage.textContent = localize('qualityWarning', "Quality may decline as limit nears."); + this.warningMessage.style.display = 'none'; + + // Actions section with header, separator, and button bar + this.actionsSection = this.domNode.appendChild($('.actions-section')); + this.actionsSection.appendChild($('.separator')); + const actionsHeader = this.actionsSection.appendChild($('.actions-header')); + actionsHeader.textContent = localize('actions', "Actions"); + const buttonBarContainer = this.actionsSection.appendChild($('.button-bar-container')); + this._register(this.instantiationService.createInstance(MenuWorkbenchButtonBar, buttonBarContainer, MenuId.ChatContextUsageActions, { + toolbarOptions: { + primaryGroup: () => true + }, + buttonConfigProvider: () => ({ isSecondary: true }) + })); + + // Listen to menu changes to show/hide actions section + const menu = this._register(this.menuService.createMenu(MenuId.ChatContextUsageActions, this.contextKeyService)); + const updateActionsVisibility = () => { + const actions = menu.getActions(); + const hasActions = actions.length > 0 && actions.some(([, items]) => items.length > 0); + this.actionsSection.style.display = hasActions ? '' : 'none'; + }; + this._register(menu.onDidChange(updateActionsVisibility)); + updateActionsVisibility(); + } + + update(data: IChatContextUsageData): void { + const { percentage, promptTokenDetails } = data; + + // Update percentage label + this.percentageLabel.textContent = `${percentage.toFixed(0)}%`; + + // Update progress bar + this.progressFill.style.width = `${Math.min(100, percentage)}%`; + + // Update color classes based on usage level on the quota item + this.quotaItem.classList.remove('warning', 'error'); + if (percentage >= 90) { + this.quotaItem.classList.add('error'); + } else if (percentage >= 75) { + this.quotaItem.classList.add('warning'); + } + + // Render token details breakdown if available + this.renderTokenDetails(promptTokenDetails, percentage); + + // Show/hide warning message + this.warningMessage.style.display = percentage >= 75 ? '' : 'none'; + } + + private renderTokenDetails(details: readonly IChatContextUsagePromptTokenDetail[] | undefined, contextWindowPercentage: number): void { + // Clear previous content + dom.clearNode(this.tokenDetailsContainer); + + if (!details || details.length === 0) { + this.tokenDetailsContainer.style.display = 'none'; + return; + } + + this.tokenDetailsContainer.style.display = ''; + + // Group details by category + const categoryMap = new Map(); + let totalPercentage = 0; + + for (const detail of details) { + const existing = categoryMap.get(detail.category) || []; + existing.push({ label: detail.label, percentageOfPrompt: detail.percentageOfPrompt }); + categoryMap.set(detail.category, existing); + totalPercentage += detail.percentageOfPrompt; + } + + // Add uncategorized if percentages don't sum to 100% + if (totalPercentage < 100) { + const uncategorizedPercentage = 100 - totalPercentage; + categoryMap.set(localize('uncategorized', "Uncategorized"), [ + { label: localize('other', "Other"), percentageOfPrompt: uncategorizedPercentage } + ]); + } + + // Render each category + for (const [category, items] of categoryMap) { + const categorySection = this.tokenDetailsContainer.appendChild($('.token-category')); + + // Category header + const categoryHeader = categorySection.appendChild($('.token-category-header')); + categoryHeader.textContent = category; + + // Category items + for (const item of items) { + const itemRow = categorySection.appendChild($('.token-detail-item')); + + const itemLabel = itemRow.appendChild($('.token-detail-label')); + itemLabel.textContent = item.label; + + // Calculate percentage relative to context window + // E.g., if context window is at 10% and item uses 10% of prompt, show 1% + const contextRelativePercentage = (item.percentageOfPrompt / 100) * contextWindowPercentage; + + const itemValue = itemRow.appendChild($('.token-detail-value')); + itemValue.textContent = `${contextRelativePercentage.toFixed(1)}%`; + } + } + } + + focus(): void { + this.domNode.focus(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts new file mode 100644 index 0000000000000..6d166534e17be --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts @@ -0,0 +1,272 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/chatContextUsageWidget.css'; +import * as dom from '../../../../../../base/browser/dom.js'; +import { EventType, addDisposableListener } from '../../../../../../base/browser/dom.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { autorun, IObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { localize } from '../../../../../../nls.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; +import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { ChatContextUsageDetails, IChatContextUsageData } from './chatContextUsageDetails.js'; + +const $ = dom.$; + +/** + * A reusable circular progress indicator that displays a pie chart. + * The pie fills clockwise from the top based on the percentage value. + */ +export class CircularProgressIndicator { + + readonly domNode: SVGSVGElement; + + private readonly progressPie: SVGPathElement; + + private static readonly CENTER_X = 18; + private static readonly CENTER_Y = 18; + private static readonly RADIUS = 16; + + constructor() { + this.domNode = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + this.domNode.setAttribute('viewBox', '0 0 36 36'); + this.domNode.classList.add('circular-progress'); + + // Background circle (outline only) + const bgCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + bgCircle.setAttribute('cx', String(CircularProgressIndicator.CENTER_X)); + bgCircle.setAttribute('cy', String(CircularProgressIndicator.CENTER_Y)); + bgCircle.setAttribute('r', String(CircularProgressIndicator.RADIUS)); + bgCircle.classList.add('progress-bg'); + this.domNode.appendChild(bgCircle); + + // Progress pie (filled arc) + this.progressPie = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + this.progressPie.classList.add('progress-pie'); + this.domNode.appendChild(this.progressPie); + } + + /** + * Updates the pie chart to display the given percentage (0-100). + * @param percentage The percentage of the pie to fill (clamped to 0-100) + */ + setProgress(percentage: number): void { + const cx = CircularProgressIndicator.CENTER_X; + const cy = CircularProgressIndicator.CENTER_Y; + const r = CircularProgressIndicator.RADIUS; + + if (percentage >= 100) { + // Full circle - use a circle element's path equivalent + this.progressPie.setAttribute('d', `M ${cx} ${cy - r} A ${r} ${r} 0 1 1 ${cx - 0.001} ${cy - r} Z`); + } else if (percentage <= 0) { + // Empty - no path + this.progressPie.setAttribute('d', ''); + } else { + // Calculate the arc endpoint + const angle = (percentage / 100) * 360; + const radians = (angle - 90) * (Math.PI / 180); // Start from top (-90 degrees) + const x = cx + r * Math.cos(radians); + const y = cy + r * Math.sin(radians); + const largeArcFlag = angle > 180 ? 1 : 0; + + // Create pie slice path: move to center, line to top, arc to endpoint, close + const d = [ + `M ${cx} ${cy}`, // Move to center + `L ${cx} ${cy - r}`, // Line to top + `A ${r} ${r} 0 ${largeArcFlag} 1 ${x} ${y}`, // Arc to endpoint + 'Z' // Close path back to center + ].join(' '); + + this.progressPie.setAttribute('d', d); + } + } +} + +/** + * Widget that displays the context/token usage for the current chat session. + * Shows a circular progress icon that expands on hover/focus to show token counts, + * and on click shows the detailed context usage widget. + */ +export class ChatContextUsageWidget extends Disposable { + + private readonly _onDidChangeVisibility = this._register(new Emitter()); + readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; + + readonly domNode: HTMLElement; + + private readonly tokenLabel: HTMLElement; + private readonly progressIndicator: CircularProgressIndicator; + + private readonly _isVisible = observableValue(this, false); + get isVisible(): IObservable { return this._isVisible; } + + private readonly _lastRequestDisposable = this._register(new MutableDisposable()); + + private currentData: IChatContextUsageData | undefined; + + constructor( + @IHoverService private readonly hoverService: IHoverService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + ) { + super(); + + this.domNode = $('.chat-context-usage-widget.action-label'); + this.domNode.style.display = 'none'; + this.domNode.setAttribute('tabindex', '0'); + this.domNode.setAttribute('role', 'button'); + this.domNode.setAttribute('aria-label', localize('contextUsageLabel', "Context window usage")); + + // Icon container (always visible, contains the pie chart) + const iconContainer = this.domNode.appendChild($('.icon-container')); + this.progressIndicator = new CircularProgressIndicator(); + iconContainer.appendChild(this.progressIndicator.domNode); + + // Token label (shown on hover/focus) + this.tokenLabel = this.domNode.appendChild($('.token-label')); + + // Show details popup on click + this._register(addDisposableListener(this.domNode, EventType.CLICK, () => { + this.showDetails(); + })); + + // Show details on Enter/Space for keyboard accessibility + this._register(addDisposableListener(this.domNode, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.showDetails(); + } + })); + } + + private showDetails(): void { + if (!this.currentData) { + return; + } + + // Add expanded class to keep token label visible while details are shown + this.domNode.classList.add('expanded'); + + const details = this.instantiationService.createInstance(ChatContextUsageDetails); + details.update(this.currentData); + + const hover = this.hoverService.showInstantHover({ + content: details.domNode, + target: { + targetElements: [this.domNode], + dispose: () => { + this.domNode.classList.remove('expanded'); + details.dispose(); + } + }, + persistence: { sticky: true, hideOnHover: false, hideOnKeyDown: false }, + appearance: { showPointer: true } + }, true); + + // Focus the details widget + details.focus(); + + // Handle case where hover couldn't be shown + if (!hover) { + this.domNode.classList.remove('expanded'); + details.dispose(); + } + } + + /** + * Updates the widget with the latest request/response data. + * The model is retrieved from the request's modelId. + * @param lastRequest The last request in the session + */ + update(lastRequest: IChatRequestModel | undefined): void { + this._lastRequestDisposable.clear(); + + if (!lastRequest?.response || !lastRequest.modelId) { + this.hide(); + return; + } + + const response = lastRequest.response; + const modelId = lastRequest.modelId; + + // Subscribe to response changes to update when the response completes. + this._lastRequestDisposable.value = autorun(reader => { + const isComplete = !response.isInProgress.read(reader); + if (isComplete) { + this.updateFromResponse(response, modelId); + } + }); + } + + private updateFromResponse(response: IChatResponseModel, modelId: string): void { + const usage = response.result?.usage; + const modelMetadata = this.languageModelsService.lookupLanguageModel(modelId); + const maxInputTokens = modelMetadata?.maxInputTokens; + + if (!usage || !maxInputTokens || maxInputTokens <= 0) { + this.hide(); + return; + } + + const promptTokens = usage.promptTokens; + const promptTokenDetails = usage.promptTokenDetails; + const percentage = Math.min(100, (promptTokens / maxInputTokens) * 100); + + this.render(percentage, promptTokens, maxInputTokens, promptTokenDetails); + this.show(); + } + + private render(percentage: number, promptTokens: number, maxTokens: number, promptTokenDetails?: readonly { category: string; label: string; percentageOfPrompt: number }[]): void { + // Store current data for use in details popup + this.currentData = { promptTokens, maxInputTokens: maxTokens, percentage, promptTokenDetails }; + + // Update pie chart progress + this.progressIndicator.setProgress(percentage); + + // Update color based on usage level + this.domNode.classList.remove('warning', 'error'); + if (percentage >= 90) { + this.domNode.classList.add('error'); + } else if (percentage >= 75) { + this.domNode.classList.add('warning'); + } + + // Update token label (shown on hover/focus) + this.tokenLabel.textContent = localize( + 'tokenCount', + "{0} / {1} T", + this.formatTokenCount(promptTokens, 1), + this.formatTokenCount(maxTokens, 0) + ); + } + + private formatTokenCount(count: number, decimals: number): string { + if (count >= 1000000) { + return `${(count / 1000000).toFixed(decimals)}M`; + } else if (count >= 1000) { + return `${(count / 1000).toFixed(decimals)}K`; + } + return count.toString(); + } + + private show(): void { + if (this.domNode.style.display === 'none') { + this.domNode.style.display = ''; + this._isVisible.set(true, undefined); + this._onDidChangeVisibility.fire(); + } + } + + private hide(): void { + if (this.domNode.style.display !== 'none') { + this.domNode.style.display = 'none'; + this._isVisible.set(false, undefined); + this._onDidChangeVisibility.fire(); + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts index dac6fd6bf45ab..8d61871b819d8 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts @@ -22,6 +22,7 @@ import { ChatConfiguration } from '../../../common/constants.js'; import { ActionViewItem, IActionViewItemOptions } from '../../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction } from '../../../../../../base/common/actions.js'; import { AgentSessionsPicker } from '../../agentSessions/agentSessionsPicker.js'; +import { ChatContextUsageWidget } from './chatContextUsageWidget.js'; export interface IChatViewTitleDelegate { focusChat(): void; @@ -45,6 +46,7 @@ export class ChatViewTitleControl extends Disposable { private navigationToolbar?: MenuWorkbenchToolBar; private actionsToolbar?: MenuWorkbenchToolBar; + private contextUsageWidget?: ChatContextUsageWidget; private lastKnownHeight = 0; @@ -99,6 +101,7 @@ export class ChatViewTitleControl extends Disposable { private render(parent: HTMLElement): void { const elements = h('div.chat-view-title-container', [ h('div.chat-view-title-navigation-toolbar@navigationToolbar'), + h('div.chat-view-title-context-usage@contextUsage'), h('div.chat-view-title-actions-toolbar@actionsToolbar'), ]); @@ -118,6 +121,13 @@ export class ChatViewTitleControl extends Disposable { menuOptions: { shouldForwardArgs: true } })); + // Context usage widget + this.contextUsageWidget = this._register(this.instantiationService.createInstance(ChatContextUsageWidget)); + elements.contextUsage.appendChild(this.contextUsageWidget.domNode); + this._register(this.contextUsageWidget.onDidChangeVisibility(() => { + this.checkHeight(); + })); + // Actions toolbar on the right this.actionsToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, elements.actionsToolbar, MenuId.ChatViewSessionTitleToolbar, { menuOptions: { shouldForwardArgs: true }, @@ -143,9 +153,17 @@ export class ChatViewTitleControl extends Disposable { if (e.kind === 'setCustomTitle' || e.kind === 'addRequest') { this.doUpdate(); } + if (e.kind === 'completedRequest') { + this.updateContextUsage(); + } }); this.doUpdate(); + this.updateContextUsage(); + } + + private updateContextUsage(): void { + this.contextUsageWidget?.update(this.model?.lastRequest); } private doUpdate(): void { @@ -168,6 +186,14 @@ export class ChatViewTitleControl extends Disposable { } } + private checkHeight(): void { + const currentHeight = this.getHeight(); + if (currentHeight !== this.lastKnownHeight) { + this.lastKnownHeight = currentHeight; + this._onDidChangeHeight.fire(); + } + } + private updateTitle(title: string): void { if (!this.titleContainer) { return; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageDetails.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageDetails.css new file mode 100644 index 0000000000000..d3d8616f643e8 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageDetails.css @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.chat-context-usage-details { + display: flex; + flex-direction: column; + padding: 12px; + min-width: 220px; +} + +.chat-context-usage-details:focus { + outline: none; +} + +/* Using same structure as ChatUsageWidget quota items */ +.chat-context-usage-details .quota-item { + margin-bottom: 8px; +} + +.chat-context-usage-details .quota-item-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; +} + +.chat-context-usage-details .quota-item-label { + color: var(--vscode-foreground); +} + +.chat-context-usage-details .quota-item-value { + color: var(--vscode-descriptionForeground); +} + +/* Progress bar - matching chat usage implementation */ +.chat-context-usage-details .quota-item .quota-bar { + width: 100%; + height: 4px; + background-color: var(--vscode-gauge-background); + border-radius: 4px; + border: 1px solid var(--vscode-gauge-border); + margin: 4px 0; +} + +.chat-context-usage-details .quota-item .quota-bar .quota-bit { + height: 100%; + background-color: var(--vscode-gauge-foreground); + border-radius: 4px; + transition: width 0.3s ease; +} + +.chat-context-usage-details .quota-item.warning .quota-bar { + background-color: var(--vscode-gauge-warningBackground); +} + +.chat-context-usage-details .quota-item.warning .quota-bar .quota-bit { + background-color: var(--vscode-gauge-warningForeground); +} + +.chat-context-usage-details .quota-item.error .quota-bar { + background-color: var(--vscode-gauge-errorBackground); +} + +.chat-context-usage-details .quota-item.error .quota-bar .quota-bit { + background-color: var(--vscode-gauge-errorForeground); +} + +.chat-context-usage-details .warning-message { + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin-bottom: 8px; +} + +/* Token details breakdown */ +.chat-context-usage-details .token-details-container { + margin-top: 8px; +} + +.chat-context-usage-details .token-category { + margin-bottom: 8px; +} + +.chat-context-usage-details .token-category-header { + font-weight: 600; + color: var(--vscode-foreground); + margin-bottom: 4px; +} + +.chat-context-usage-details .token-detail-item { + display: flex; + justify-content: space-between; + align-items: center; + padding-left: 8px; +} + +.chat-context-usage-details .token-detail-label { + color: var(--vscode-foreground); +} + +.chat-context-usage-details .token-detail-value { + color: var(--vscode-descriptionForeground); +} + +.chat-context-usage-details .actions-section .separator { + border-top: 1px solid var(--vscode-editorHoverWidget-border); + margin: 8px 0; +} + +.chat-context-usage-details .actions-section .actions-header { + font-weight: 600; + color: var(--vscode-foreground); + margin-bottom: 8px; +} + +.chat-context-usage-details .actions-section .button-bar-container { + display: flex; + flex-direction: column; + gap: 6px; +} + +.chat-context-usage-details .actions-section .button-bar-container .monaco-button-bar { + flex-direction: column; + gap: 6px; +} + +.chat-context-usage-details .actions-section .button-bar-container .monaco-button { + width: 100%; + justify-content: center; +} diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageWidget.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageWidget.css new file mode 100644 index 0000000000000..ffad8682a1a70 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageWidget.css @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.chat-context-usage-widget { + display: flex; + align-items: center; + justify-content: flex-end; + height: 22px; + flex-shrink: 0; + cursor: pointer; + padding: 3px; + border-radius: 5px; + box-sizing: border-box; + overflow: hidden; + transition: background-color 0.15s ease; +} + +.chat-context-usage-widget .icon-container { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.chat-context-usage-widget .token-label { + max-width: 0; + opacity: 0; + overflow: hidden; + white-space: nowrap; + font-size: 11px; + color: var(--vscode-foreground); + transition: max-width 0.2s ease, opacity 0.2s ease, margin-right 0.2s ease; + margin-right: 0; + order: -1; +} + +/* Expand on hover, focus, or when details are shown (expanded class) */ +.chat-context-usage-widget:hover .token-label, +.chat-context-usage-widget:focus .token-label, +.chat-context-usage-widget:focus-within .token-label, +.chat-context-usage-widget.expanded .token-label { + max-width: 100px; + opacity: 1; + margin-right: 6px; +} + +.chat-context-usage-widget:hover { + background-color: var(--vscode-toolbar-hoverBackground); + outline: 1px dashed var(--vscode-toolbar-hoverOutline); + outline-offset: -1px; +} + +.chat-context-usage-widget:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.chat-context-usage-widget:active { + background-color: var(--vscode-toolbar-activeBackground); +} + +.chat-context-usage-widget .circular-progress { + width: 100%; + height: 100%; + pointer-events: none; +} + +.chat-context-usage-widget .progress-bg { + fill: transparent; + stroke: var(--vscode-icon-foreground); + stroke-width: 2; + opacity: 0.4; +} + +.chat-context-usage-widget .progress-pie { + fill: var(--vscode-icon-foreground); + opacity: 0.8; + transition: d 0.3s ease; + pointer-events: none; +} + +.chat-context-usage-widget.warning .progress-pie { + fill: var(--vscode-editorWarning-foreground); +} + +.chat-context-usage-widget.error .progress-pie { + fill: var(--vscode-editorError-foreground); +} + +.chat-context-usage-widget.warning .token-label { + color: var(--vscode-editorWarning-foreground); +} + +.chat-context-usage-widget.error .token-label { + color: var(--vscode-editorError-foreground); +} diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewTitleControl.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewTitleControl.css index 8e5a8f85ca790..f267e867323c2 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewTitleControl.css +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewTitleControl.css @@ -36,9 +36,14 @@ min-width: 0; } - .chat-view-title-actions-toolbar { + .chat-view-title-context-usage { margin-left: auto; + flex-shrink: 0; + } + + .chat-view-title-actions-toolbar { padding-left: 4px; + flex-shrink: 0; } } diff --git a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts index 33910c91c5982..0426c7fba40ec 100644 --- a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts +++ b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts @@ -16,7 +16,7 @@ import { MarkerSeverity, IMarker } from '../../../../../platform/markers/common/ import { ISCMHistoryItem } from '../../../scm/common/history.js'; import { IChatContentReference } from '../chatService/chatService.js'; import { IChatRequestVariableValue } from './chatVariables.js'; -import { IToolData, ToolSet } from '../tools/languageModelToolsService.js'; +import { IToolData, IToolSet } from '../tools/languageModelToolsService.js'; import { decodeBase64, encodeBase64, VSBuffer } from '../../../../../base/common/buffer.js'; import { Mutable } from '../../../../../base/common/types.js'; @@ -491,7 +491,7 @@ export function toToolVariableEntry(entry: IToolData, range?: IOffsetRange): ICh }; } -export function toToolSetVariableEntry(entry: ToolSet, range?: IOffsetRange): IChatRequestToolSetEntry { +export function toToolSetVariableEntry(entry: IToolSet, range?: IOffsetRange): IChatRequestToolSetEntry { return { kind: 'toolset', id: entry.id, diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 1fe1601342e00..d96818e2bc5d3 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -14,6 +14,7 @@ export enum ChatConfiguration { UnifiedAgentsBar = 'chat.unifiedAgentsBar.enabled', AgentSessionProjectionEnabled = 'chat.agentSessionProjection.enabled', EditModeHidden = 'chat.editMode.hidden', + AlternativeToolAction = 'chat.alternativeToolAction.enabled', Edits2Enabled = 'chat.edits2.enabled', ExtensionToolsEnabled = 'chat.extensionTools.enabled', RepoInfoEnabled = 'chat.repoInfo.enabled', @@ -28,6 +29,7 @@ export enum ChatConfiguration { ThinkingStyle = 'chat.agent.thinkingStyle', ThinkingGenerateTitles = 'chat.agent.thinking.generateTitles', TerminalToolsInThinking = 'chat.agent.thinking.terminalTools', + AutoExpandToolFailures = 'chat.tools.autoExpandFailures', TodosShowWidget = 'chat.tools.todos.showWidget', NotifyWindowOnResponseReceived = 'chat.notifyWindowOnResponseReceived', ChatViewSessionsEnabled = 'chat.viewSessions.enabled', @@ -38,6 +40,7 @@ export enum ChatConfiguration { RestoreLastPanelSession = 'chat.restoreLastPanelSession', ExitAfterDelegation = 'chat.exitAfterDelegation', CommandCenterTriStateToggle = 'chat.commandCenter.triStateToggle', + ExplainChangesEnabled = 'chat.editing.explainChanges.enabled', } /** diff --git a/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts b/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts index 5ebf42b34971e..a9df88dd0e2ff 100644 --- a/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts +++ b/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts @@ -8,7 +8,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../../base/common/errors.js'; import { Event } from '../../../../../base/common/event.js'; import { IDisposable } from '../../../../../base/common/lifecycle.js'; -import { autorunSelfDisposable, IObservable, IReader } from '../../../../../base/common/observable.js'; +import { autorunSelfDisposable, IObservable, IReader, ISettableObservable } from '../../../../../base/common/observable.js'; import { hasKey } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { IDocumentDiff } from '../../../../../editor/common/diff/documentDiffProvider.js'; @@ -122,6 +122,7 @@ export interface IChatEditingSession extends IDisposable { readonly entries: IObservable; /** Requests disabled by undo/redo in the session */ readonly requestDisablement: IObservable; + readonly explanationWidgetVisible: ISettableObservable; show(previousChanges?: boolean): Promise; accept(...uris: URI[]): Promise; @@ -398,6 +399,11 @@ export interface IModifiedFileEntry { autoAcceptController: IObservable<{ total: number; remaining: number; cancel(): void } | undefined>; enableReviewModeUntilSettled(): void; + /** + * Whether explanation widgets should be visible for this entry + */ + readonly explanationWidgetVisible?: IObservable; + /** * Number of changes for this file */ diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index d8f79a27aad48..d69b62ea24cd2 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -171,6 +171,18 @@ export interface IChatAgentResultTimings { totalElapsed: number; } +export interface IChatAgentPromptTokenDetail { + category: string; + label: string; + percentageOfPrompt: number; +} + +export interface IChatAgentResultUsage { + promptTokens: number; + completionTokens: number; + promptTokenDetails?: readonly IChatAgentPromptTokenDetail[]; +} + export interface IChatAgentResult { errorDetails?: IChatResponseErrorDetails; timings?: IChatAgentResultTimings; @@ -178,6 +190,8 @@ export interface IChatAgentResult { readonly metadata?: { readonly [key: string]: unknown }; readonly details?: string; nextQuestion?: IChatQuestion; + /** Token usage information for this request */ + readonly usage?: IChatAgentResultUsage; } export const IChatAgentService = createDecorator('chatAgentService'); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 0e2dd3ca72077..79d7ea53223bf 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -24,8 +24,9 @@ import { PromptsType } from './promptTypes.js'; import { ParsedPromptFile } from './promptFileParser.js'; import { ICustomAgent, IPromptPath, IPromptsService } from './service/promptsService.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; -import { ChatConfiguration } from '../constants.js'; +import { ChatConfiguration, ChatModeKind } from '../constants.js'; import { UserSelectedTools } from '../participants/chatAgents.js'; +import { IChatMode } from '../chatModes.js'; export type InstructionsCollectionEvent = { applyingInstructionsCount: number; @@ -53,6 +54,7 @@ export class ComputeAutomaticInstructions { private _parseResults: ResourceMap = new ResourceMap(); constructor( + private readonly _agent: IChatMode, private readonly _enabledTools: UserSelectedTools | undefined, private readonly _enabledSubagents: (readonly string[]) | undefined, @IPromptsService private readonly _promptsService: IPromptsService, @@ -116,6 +118,11 @@ export class ComputeAutomaticInstructions { /** public for testing */ public async addApplyingInstructions(instructionFiles: readonly IPromptPath[], context: { files: ResourceSet; instructions: ResourceSet }, variables: ChatRequestVariableSet, telemetryEvent: InstructionsCollectionEvent, token: CancellationToken): Promise { + const includeApplyingInstructions = this._configurationService.getValue(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS); + if (!includeApplyingInstructions && this._agent.kind !== ChatModeKind.Edit) { + this._logService.trace(`[InstructionsContextComputer] includeApplyingInstructions is disabled and agent kind is not Edit. No applying instructions will be added.`); + return; + } for (const { uri } of instructionFiles) { const parsedFile = await this._parseInstructionsFile(uri, token); @@ -289,15 +296,14 @@ export class ComputeAutomaticInstructions { const agentsMdFiles = await agentsMdPromise; for (const uri of agentsMdFiles) { - if (uri) { - const folderName = this._labelService.getUriLabel(dirname(uri), { relative: true }); - const description = folderName.trim().length === 0 ? localize('instruction.file.description.agentsmd.root', 'Instructions for the workspace') : localize('instruction.file.description.agentsmd.folder', 'Instructions for folder \'{0}\'', folderName); - entries.push(''); - entries.push(`${description}`); - entries.push(`${getFilePath(uri)}`); - entries.push(''); - hasContent = true; - } + const folderName = this._labelService.getUriLabel(dirname(uri), { relative: true }); + const description = folderName.trim().length === 0 ? localize('instruction.file.description.agentsmd.root', 'Instructions for the workspace') : localize('instruction.file.description.agentsmd.folder', 'Instructions for folder \'{0}\'', folderName); + entries.push(''); + entries.push(`${description}`); + entries.push(`${getFilePath(uri)}`); + entries.push(''); + hasContent = true; + } if (!hasContent) { @@ -376,6 +382,12 @@ export class ComputeAutomaticInstructions { } private async _addReferencedInstructions(attachedContext: ChatRequestVariableSet, telemetryEvent: InstructionsCollectionEvent, token: CancellationToken): Promise { + const includeReferencedInstructions = this._configurationService.getValue(PromptsConfig.INCLUDE_REFERENCED_INSTRUCTIONS); + if (!includeReferencedInstructions && this._agent.kind !== ChatModeKind.Edit) { + this._logService.trace(`[InstructionsContextComputer] includeReferencedInstructions is disabled and agent kind is not Edit. No referenced instructions will be added.`); + return; + } + const seen = new ResourceSet(); const todo: URI[] = []; for (const variable of attachedContext.asArray()) { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts index ccaeb090ea92f..34c11d002a0d2 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts @@ -95,6 +95,16 @@ export namespace PromptsConfig { */ export const USE_AGENT_SKILLS = 'chat.useAgentSkills'; + /** + * Configuration key for including applying instructions. + */ + export const INCLUDE_APPLYING_INSTRUCTIONS = 'chat.includeApplyingInstructions'; + + /** + * Configuration key for including referenced instructions. + */ + export const INCLUDE_REFERENCED_INSTRUCTIONS = 'chat.includeReferencedInstructions'; + /** * Get value of the `reusable prompt locations` configuration setting. * @see {@link PROMPT_LOCATIONS_CONFIG_KEY}, {@link INSTRUCTIONS_LOCATIONS_CONFIG_KEY}, {@link MODE_LOCATIONS_CONFIG_KEY}, {@link SKILLS_LOCATION_KEY}. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts index 2de474fbf093b..2a2826a040768 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts @@ -11,7 +11,7 @@ import { Hover, HoverContext, HoverProvider } from '../../../../../../editor/com import { ITextModel } from '../../../../../../editor/common/model.js'; import { localize } from '../../../../../../nls.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; -import { ILanguageModelToolsService, ToolSet } from '../../tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, isToolSet, IToolSet } from '../../tools/languageModelToolsService.js'; import { IChatModeService, isBuiltinChatMode } from '../../chatModes.js'; import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; @@ -163,7 +163,7 @@ export class PromptHoverProvider implements HoverProvider { private getToolHoverByName(toolName: string, range: Range): Hover | undefined { const tool = this.languageModelToolsService.getToolByFullReferenceName(toolName); if (tool !== undefined) { - if (tool instanceof ToolSet) { + if (isToolSet(tool)) { return this.getToolsetHover(tool, range); } else { return this.createHover(tool.userDescription ?? tool.modelDescription, range); @@ -172,7 +172,7 @@ export class PromptHoverProvider implements HoverProvider { return undefined; } - private getToolsetHover(toolSet: ToolSet, range: Range): Hover | undefined { + private getToolsetHover(toolSet: IToolSet, range: Range): Hover | undefined { const lines: string[] = []; lines.push(localize('toolSetName', 'ToolSet: {0}\n\n', toolSet.referenceName)); if (toolSet.description) { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index 8e630cdaf91a9..19acdca278946 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -123,7 +123,7 @@ export class PromptValidator { if (body.variableReferences.length && !isGitHubTarget) { const headerTools = promptAST.header?.tools; const headerTarget = promptAST.header?.target; - const headerToolsMap = headerTools ? this.languageModelToolsService.toToolAndToolSetEnablementMap(headerTools, headerTarget) : undefined; + const headerToolsMap = headerTools ? this.languageModelToolsService.toToolAndToolSetEnablementMap(headerTools, headerTarget, undefined) : undefined; const available = new Set(this.languageModelToolsService.getFullReferenceNames()); const deprecatedNames = this.languageModelToolsService.getDeprecatedFullReferenceNames(); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index e21b21995f07b..529ab237bb518 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -543,7 +543,28 @@ export class PromptFilesLocator { private async findAgentSkillsInFolder(uri: URI, token: CancellationToken): Promise { try { - return await this.searchFilesInLocation(uri, `*/${SKILL_FILENAME}`, token); + const result: URI[] = []; + const stat = await this.fileService.resolve(uri); + if (stat.isDirectory && stat.children) { + // Recursively traverse subdirectories + for (const child of stat.children) { + try { + if (token.isCancellationRequested) { + return []; + } + if (child.isDirectory) { + const skillFile = joinPath(child.resource, SKILL_FILENAME); + const skillStat = await this.fileService.resolve(skillFile); + if (skillStat.isFile) { + result.push(skillStat.resource); + } + } + } catch (error) { + // Ignore errors for individual files/folders (e.g., permission denied) + } + } + } + return result; } catch (e) { if (!isCancellationError(e)) { this.logService.trace(`[PromptFilesLocator] Error searching for skills in ${uri.toString()}: ${e}`); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptsServiceUtils.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptsServiceUtils.ts new file mode 100644 index 0000000000000..f458870f629cb --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptsServiceUtils.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../../base/common/uri.js'; +import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; + +/** + * Checks if a prompt file is organization-provided. + * Organization-provided prompt files come from the built-in chat extension + * and are located under a `/github/` path. + * + * @param uri The URI of the prompt file + * @param extensionId The extension identifier that provides the prompt file + * @param productService The product service to get the built-in chat extension ID + * @returns `true` if the prompt file is organization-provided, `false` otherwise + */ +export function isOrganizationPromptFile(uri: URI, extensionId: ExtensionIdentifier, productService: IProductService): boolean { + const chatExtensionId = productService.defaultChatAgent?.chatExtensionId; + if (!chatExtensionId) { + return false; + } + const isFromBuiltinChatExtension = ExtensionIdentifier.equals(extensionId, chatExtensionId); + const pathContainsGithub = uri.path.includes('/github/'); + return isFromBuiltinChatExtension && pathContainsGithub; +} diff --git a/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts b/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts index 127dc2f71df83..fa07524a29e1a 100644 --- a/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts +++ b/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts @@ -7,13 +7,13 @@ import { URI } from '../../../../../base/common/uri.js'; import { IPosition, Position } from '../../../../../editor/common/core/position.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; -import { IChatAgentData, IChatAgentService } from '../participants/chatAgents.js'; -import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestSlashPromptPart, ChatRequestTextPart, ChatRequestToolPart, ChatRequestToolSetPart, IParsedChatRequest, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader, chatVariableLeader } from './chatParserTypes.js'; -import { IChatSlashCommandService } from '../participants/chatSlashCommands.js'; import { IChatVariablesService, IDynamicVariable } from '../attachments/chatVariables.js'; import { ChatAgentLocation, ChatModeKind } from '../constants.js'; -import { IToolData, ToolSet } from '../tools/languageModelToolsService.js'; +import { IChatAgentData, IChatAgentService } from '../participants/chatAgents.js'; +import { IChatSlashCommandService } from '../participants/chatSlashCommands.js'; import { IPromptsService } from '../promptSyntax/service/promptsService.js'; +import { IToolData, IToolSet, isToolSet } from '../tools/languageModelToolsService.js'; +import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestSlashPromptPart, ChatRequestTextPart, ChatRequestToolPart, ChatRequestToolSetPart, IParsedChatRequest, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader, chatVariableLeader } from './chatParserTypes.js'; const agentReg = /^@([\w_\-\.]+)(?=(\s|$|\b))/i; // An @-agent const variableReg = /^#([\w_\-]+)(:\d+)?(?=(\s|$|\b))/i; // A #-variable with an optional numeric : arg (@response:2) @@ -39,10 +39,10 @@ export class ChatRequestParser { const parts: IParsedChatRequestPart[] = []; const references = this.variableService.getDynamicVariables(sessionResource); // must access this list before any async calls const toolsByName = new Map(); - const toolSetsByName = new Map(); + const toolSetsByName = new Map(); for (const [entry, enabled] of this.variableService.getSelectedToolAndToolSets(sessionResource)) { if (enabled) { - if (entry instanceof ToolSet) { + if (isToolSet(entry)) { toolSetsByName.set(entry.referenceName, entry); } else { toolsByName.set(entry.toolReferenceName ?? entry.displayName, entry); @@ -160,7 +160,7 @@ export class ChatRequestParser { return new ChatRequestAgentPart(agentRange, agentEditorRange, agent); } - private tryToParseVariable(message: string, offset: number, position: IPosition, parts: ReadonlyArray, toolsByName: ReadonlyMap, toolSetsByName: ReadonlyMap): ChatRequestToolPart | ChatRequestToolSetPart | undefined { + private tryToParseVariable(message: string, offset: number, position: IPosition, parts: ReadonlyArray, toolsByName: ReadonlyMap, toolSetsByName: ReadonlyMap): ChatRequestToolPart | ChatRequestToolSetPart | undefined { const nextVariableMatch = message.match(variableReg); if (!nextVariableMatch) { return; diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index d7d3a3ed1e789..45e8043847b07 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -16,7 +16,7 @@ import { IInstantiationService } from '../../../../../../platform/instantiation/ import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IChatAgentRequest, IChatAgentService } from '../../participants/chatAgents.js'; import { ChatModel, IChatRequestModeInstructions } from '../../model/chatModel.js'; -import { IChatModeService } from '../../chatModes.js'; +import { ChatMode, IChatMode, IChatModeService } from '../../chatModes.js'; import { IChatProgress, IChatService } from '../../chatService/chatService.js'; import { ChatRequestVariableSet } from '../../attachments/chatVariableEntries.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../constants.js'; @@ -30,9 +30,9 @@ import { IToolInvocation, IToolInvocationPreparationContext, IToolResult, + isToolSet, ToolDataSource, ToolProgress, - ToolSet, VSCodeToolReference, } from '../languageModelToolsService.js'; import { ComputeAutomaticInstructions } from '../../promptSyntax/computeAutomaticInstructions.js'; @@ -139,9 +139,10 @@ export class RunSubagentTool extends Disposable implements IToolImpl { let modeModelId = invocation.modelId; let modeTools = invocation.userSelectedTools; let modeInstructions: IChatRequestModeInstructions | undefined; + let mode: IChatMode | undefined; if (args.agentName) { - const mode = this.chatModeService.findModeByName(args.agentName); + mode = this.chatModeService.findModeByName(args.agentName); if (mode) { // Use mode-specific model if available const modeModelQualifiedName = mode.model?.get(); @@ -161,11 +162,11 @@ export class RunSubagentTool extends Disposable implements IToolImpl { const modeCustomTools = mode.customTools?.get(); if (modeCustomTools) { // Convert the mode's custom tools (array of qualified names) to UserSelectedTools format - const enablementMap = this.languageModelToolsService.toToolAndToolSetEnablementMap(modeCustomTools, mode.target?.get()); + const enablementMap = this.languageModelToolsService.toToolAndToolSetEnablementMap(modeCustomTools, mode.target?.get(), undefined); // Convert enablement map to UserSelectedTools (Record) modeTools = {}; for (const [tool, enabled] of enablementMap) { - if (!(tool instanceof ToolSet)) { + if (!isToolSet(tool)) { modeTools[tool.id] = enabled; } } @@ -219,7 +220,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } const variableSet = new ChatRequestVariableSet(); - const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, modeTools, undefined); // agents can not call subagents + const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, mode ?? ChatMode.Agent, modeTools, undefined); // agents can not call subagents await computer.collect(variableSet, token); // Build the agent request diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts index ac3977f1424a7..96fb13cb66942 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts @@ -21,7 +21,7 @@ import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { Extensions, IExtensionFeaturesRegistry, IExtensionFeatureTableRenderer, IRenderedData, IRowData, ITableData } from '../../../../services/extensionManagement/common/extensionFeatures.js'; import { isProposedApiEnabled } from '../../../../services/extensions/common/extensions.js'; import * as extensionsRegistry from '../../../../services/extensions/common/extensionsRegistry.js'; -import { ILanguageModelToolsService, IToolData, ToolDataSource, ToolSet } from './languageModelToolsService.js'; +import { ILanguageModelToolsService, IToolData, IToolSet, ToolDataSource, ToolSet } from './languageModelToolsService.js'; import { toolsParametersSchemaSchemaId } from './languageModelToolsParametersSchema.js'; export interface IRawToolContribution { @@ -196,7 +196,7 @@ function toToolKey(extensionIdentifier: ExtensionIdentifier, toolName: string) { return `${extensionIdentifier.value}/${toolName}`; } -function toToolSetKey(extensionIdentifier: ExtensionIdentifier, toolName: string) { +export function toToolSetKey(extensionIdentifier: ExtensionIdentifier, toolName: string) { return `toolset:${extensionIdentifier.value}/${toolName}`; } @@ -326,10 +326,10 @@ export class LanguageModelToolsExtensionPointHandler implements IWorkbenchContri } const tools: IToolData[] = []; - const toolSets: ToolSet[] = []; + const toolSets: IToolSet[] = []; for (const toolName of toolSet.tools) { - const toolObj = languageModelToolsService.getToolByName(toolName, true); + const toolObj = languageModelToolsService.getToolByName(toolName); if (toolObj) { tools.push(toolObj); continue; diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index f1fc3f1d8efbe..3f0f2602634c6 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -22,13 +22,24 @@ import { ExtensionIdentifier } from '../../../../../platform/extensions/common/e import { ByteSize } from '../../../../../platform/files/common/files.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { IProgress } from '../../../../../platform/progress/common/progress.js'; -import { UserSelectedTools } from '../participants/chatAgents.js'; +import { ChatRequestToolReferenceEntry } from '../attachments/chatVariableEntries.js'; import { IVariableReference } from '../chatModes.js'; import { IChatExtensionsContent, IChatSubagentToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, type IChatTerminalToolInvocationData } from '../chatService/chatService.js'; -import { ChatRequestToolReferenceEntry } from '../attachments/chatVariableEntries.js'; -import { LanguageModelPartAudience } from '../languageModels.js'; +import { ILanguageModelChatMetadata, LanguageModelPartAudience } from '../languageModels.js'; +import { UserSelectedTools } from '../participants/chatAgents.js'; import { PromptElementJSON, stringifyPromptElementJSON } from './promptTsxTypes.js'; +/** + * Selector for matching language models by vendor, family, version, or id. + * Used to filter tools to specific models or model families. + */ +export interface ILanguageModelChatSelector { + readonly vendor?: string; + readonly family?: string; + readonly version?: string; + readonly id?: string; +} + export interface IToolData { readonly id: string; readonly source: ToolDataSource; @@ -52,6 +63,34 @@ export interface IToolData { readonly canRequestPreApproval?: boolean; /** True if this tool might ask for post-approval */ readonly canRequestPostApproval?: boolean; + /** + * Model selectors that this tool is available for. + * If defined, the tool is only available when the selected model matches one of the selectors. + */ + readonly models?: readonly ILanguageModelChatSelector[]; +} + +/** + * Check if a tool matches the given model metadata based on the tool's `models` selectors. + * If the tool has no `models` defined, it matches all models. + * If model is undefined, model-specific filtering is skipped (tool is included). + */ +export function toolMatchesModel(toolData: IToolData, model: ILanguageModelChatMetadata | undefined): boolean { + // If no model selectors are defined, the tool is available for all models + if (!toolData.models || toolData.models.length === 0) { + return true; + } + // If model is undefined, skip model-specific filtering + if (!model) { + return true; + } + // Check if any selector matches the model (OR logic) + return toolData.models.some(selector => + (!selector.id || selector.id === model.id) && + (!selector.vendor || selector.vendor === model.vendor) && + (!selector.family || selector.family === model.family) && + (!selector.version || selector.version === model.version) + ); } export interface IToolProgressStep { @@ -319,13 +358,28 @@ export interface IToolImpl { handleToolStream?(context: IToolInvocationStreamContext, token: CancellationToken): Promise; } -export type IToolAndToolSetEnablementMap = ReadonlyMap; +export interface IToolSet { + readonly id: string; + readonly referenceName: string; + readonly icon: ThemeIcon; + readonly source: ToolDataSource; + readonly description?: string; + readonly legacyFullNames?: string[]; + + getTools(r?: IReader): Iterable; +} -export class ToolSet { +export type IToolAndToolSetEnablementMap = ReadonlyMap; + +export function isToolSet(obj: IToolData | IToolSet | undefined): obj is IToolSet { + return (obj as IToolSet).getTools !== undefined; +} + +export class ToolSet implements IToolSet { protected readonly _tools = new ObservableSet(); - protected readonly _toolSets = new ObservableSet(); + protected readonly _toolSets = new ObservableSet(); /** * A homogenous tool set only contains tools from the same source as the tool set itself @@ -354,7 +408,7 @@ export class ToolSet { }); } - addToolSet(toolSet: ToolSet, tx?: ITransaction): IDisposable { + addToolSet(toolSet: IToolSet, tx?: ITransaction): IDisposable { if (toolSet === this) { return Disposable.None; } @@ -372,6 +426,41 @@ export class ToolSet { } } +export class ToolSetForModel { + public get id() { + return this._toolSet.id; + } + + public get referenceName() { + return this._toolSet.referenceName; + } + + public get icon() { + return this._toolSet.icon; + } + + public get source() { + return this._toolSet.source; + } + + public get description() { + return this._toolSet.description; + } + + public get legacyFullNames() { + return this._toolSet.legacyFullNames; + } + + constructor( + private readonly _toolSet: IToolSet, + private readonly model: ILanguageModelChatMetadata | undefined, + ) { } + + public getTools(r?: IReader): Iterable { + return Iterable.filter(this._toolSet.getTools(r), toolData => toolMatchesModel(toolData, this.model)); + } +} + export interface IBeginToolCallOptions { toolCallId: string; @@ -396,10 +485,43 @@ export interface ILanguageModelToolsService { registerToolData(toolData: IToolData): IDisposable; registerToolImplementation(id: string, tool: IToolImpl): IDisposable; registerTool(toolData: IToolData, tool: IToolImpl): IDisposable; - getTools(): Iterable; - readonly toolsObservable: IObservable; + + /** + * Get all tools currently enabled (matching `when` clauses and model). + * @param model The language model metadata to filter tools by. If undefined, model-specific filtering is skipped. + */ + getTools(model: ILanguageModelChatMetadata | undefined): Iterable; + + /** + * Creats an observable of enabled tools in the context. Note the observable + * should be created and reused, not created per reader, for example: + * + * ``` + * const toolsObs = toolsService.observeTools(model); + * autorun(reader => { + * const tools = toolsObs.read(reader); + * ... + * }); + * ``` + * @param model The language model metadata to filter tools by. If undefined, model-specific filtering is skipped. + */ + observeTools(model: ILanguageModelChatMetadata | undefined): IObservable; + + /** + * Get all registered tools regardless of enablement state. + * Use this for configuration UIs, completions, etc. where all tools should be visible. + */ + getAllToolsIncludingDisabled(): Iterable; + + /** + * Get a tool by its ID. Does not check when clauses. + */ getTool(id: string): IToolData | undefined; - getToolByName(name: string, includeDisabled?: boolean): IToolData | undefined; + + /** + * Get a tool by its reference name. Does not check when clauses. + */ + getToolByName(name: string): IToolData | undefined; /** * Begin a tool call in the streaming phase. @@ -419,22 +541,36 @@ export interface ILanguageModelToolsService { /** Flush any pending tool updates to the extension hosts. */ flushToolUpdates(): void; - readonly toolSets: IObservable>; - getToolSet(id: string): ToolSet | undefined; - getToolSetByName(name: string): ToolSet | undefined; + readonly toolSets: IObservable>; + getToolSetsForModel(model: ILanguageModelChatMetadata | undefined, reader?: IReader): Iterable; + getToolSet(id: string): IToolSet | undefined; + getToolSetByName(name: string): IToolSet | undefined; createToolSet(source: ToolDataSource, id: string, referenceName: string, options?: { icon?: ThemeIcon; description?: string; legacyFullNames?: string[] }): ToolSet & IDisposable; // tool names in prompt and agent files ('full reference names') getFullReferenceNames(): Iterable; - getFullReferenceName(tool: IToolData, toolSet?: ToolSet): string; - getToolByFullReferenceName(fullReferenceName: string): IToolData | ToolSet | undefined; + getFullReferenceName(tool: IToolData, toolSet?: IToolSet): string; + getToolByFullReferenceName(fullReferenceName: string): IToolData | IToolSet | undefined; getDeprecatedFullReferenceNames(): Map>; - toToolAndToolSetEnablementMap(fullReferenceNames: readonly string[], target: string | undefined): IToolAndToolSetEnablementMap; + /** + * Gets the enablement maps based on the given set of references. + * @param fullReferenceNames The full reference names of the tools and tool sets to enable. + * @param target Optional target to filter tools by. + * @param model Optional language model metadata to filter tools by. + * If undefined is passed, all tools will be returned, even if normally disabled. + */ + toToolAndToolSetEnablementMap( + fullReferenceNames: readonly string[], + target: string | undefined, + model: ILanguageModelChatMetadata | undefined, + ): IToolAndToolSetEnablementMap; + toFullReferenceNames(map: IToolAndToolSetEnablementMap): string[]; toToolReferences(variableReferences: readonly IVariableReference[]): ChatRequestToolReferenceEntry[]; } + export function createToolInputUri(toolCallId: string): URI { return URI.from({ scheme: Schemas.inMemory, path: `/lm/tool/${toolCallId}/tool_input.json` }); } diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index 13aeb97db9616..abc09fce8e3c5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -31,6 +31,7 @@ import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { ILanguageModelToolsConfirmationService } from '../../../common/tools/languageModelToolsConfirmationService.js'; import { MockLanguageModelToolsConfirmationService } from '../../common/tools/mockLanguageModelToolsConfirmationService.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; +import { ILanguageModelChatMetadata } from '../../../common/languageModels.js'; // --- Test helpers to reduce repetition and improve readability --- @@ -276,7 +277,7 @@ suite('LanguageModelToolsService', () => { store.add(service.registerToolData(toolData2)); store.add(service.registerToolData(toolData3)); - const tools = Array.from(service.getTools()); + const tools = Array.from(service.getTools(undefined)); assert.strictEqual(tools.length, 2); assert.strictEqual(tools[0].id, 'testTool2'); assert.strictEqual(tools[1].id, 'testTool3'); @@ -314,8 +315,8 @@ suite('LanguageModelToolsService', () => { store.add(service.registerToolData(toolData2)); store.add(service.registerToolData(toolData3)); - assert.strictEqual(service.getToolByName('testTool1'), undefined); - assert.strictEqual(service.getToolByName('testTool1', true)?.id, 'testTool1'); + // getToolByName searches all tools regardless of when clause + assert.strictEqual(service.getToolByName('testTool1')?.id, 'testTool1'); assert.strictEqual(service.getToolByName('testTool2')?.id, 'testTool2'); assert.strictEqual(service.getToolByName('testTool3')?.id, 'testTool3'); }); @@ -575,7 +576,7 @@ suite('LanguageModelToolsService', () => { // Test with enabled tool { const fullReferenceNames = ['tool1RefName']; - const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined); + const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 1, 'Expected 1 tool to be enabled'); assert.strictEqual(result1.get(tool1), true, 'tool1 should be enabled'); @@ -587,7 +588,7 @@ suite('LanguageModelToolsService', () => { // Test with multiple enabled tools { const fullReferenceNames = ['my.extension/extTool1RefName', 'mcpToolSetRefName/*', 'internalToolSetRefName/internalToolSetTool1RefName']; - const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined); + const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 4, 'Expected 4 tools to be enabled'); assert.strictEqual(result1.get(extTool1), true, 'extTool1 should be enabled'); @@ -600,7 +601,7 @@ suite('LanguageModelToolsService', () => { } // Test with all enabled tools, redundant names { - const result1 = service.toToolAndToolSetEnablementMap(allFullReferenceNames, undefined); + const result1 = service.toToolAndToolSetEnablementMap(allFullReferenceNames, undefined, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 12, 'Expected 12 tools to be enabled'); // +4 including the vscode, execute, read, agent toolsets @@ -611,7 +612,7 @@ suite('LanguageModelToolsService', () => { // Test with no enabled tools { const fullReferenceNames: string[] = []; - const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined); + const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 0, 'Expected 0 tools to be enabled'); @@ -621,7 +622,7 @@ suite('LanguageModelToolsService', () => { // Test with unknown tool { const fullReferenceNames: string[] = ['unknownToolRefName']; - const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined); + const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 0, 'Expected 0 tools to be enabled'); @@ -631,7 +632,7 @@ suite('LanguageModelToolsService', () => { // Test with legacy tool names { const fullReferenceNames: string[] = ['extTool1RefName', 'mcpToolSetRefName', 'internalToolSetTool1RefName']; - const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined); + const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 4, 'Expected 4 tools to be enabled'); assert.strictEqual(result1.get(extTool1), true, 'extTool1 should be enabled'); @@ -646,7 +647,7 @@ suite('LanguageModelToolsService', () => { // Test with tool in user tool set { const fullReferenceNames = ['Tool2 Display Name']; - const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined); + const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 2, 'Expected 1 tool and user tool set to be enabled'); assert.strictEqual(result1.get(tool2), true, 'tool2 should be enabled'); @@ -673,7 +674,7 @@ suite('LanguageModelToolsService', () => { // Test enabling the tool set const enabledNames = [toolData1].map(t => service.getFullReferenceName(t)); - const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined); + const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined, undefined); assert.strictEqual(result.get(toolData1), true, 'individual tool should be enabled'); @@ -733,7 +734,7 @@ suite('LanguageModelToolsService', () => { // Test enabling the tool set const enabledNames = [toolSet, toolData1].map(t => service.getFullReferenceName(t)); - const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined); + const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined, undefined); assert.strictEqual(result.get(toolData1), true, 'individual tool should be enabled'); assert.strictEqual(result.get(toolData2), false); @@ -768,7 +769,7 @@ suite('LanguageModelToolsService', () => { // Test with non-existent tool names const enabledNames = [toolData, unregisteredToolData].map(t => service.getFullReferenceName(t)); - const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined); + const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined, undefined); assert.strictEqual(result.get(toolData), true, 'existing tool should be enabled'); // Non-existent tools should not appear in the result map @@ -817,7 +818,7 @@ suite('LanguageModelToolsService', () => { // Test 1: Using legacy tool reference name should enable the tool { - const result = service.toToolAndToolSetEnablementMap(['oldToolName'], undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolName'], undefined, undefined); assert.strictEqual(result.get(toolWithLegacy), true, 'tool should be enabled via legacy name'); const fullReferenceNames = service.toFullReferenceNames(result); @@ -826,7 +827,7 @@ suite('LanguageModelToolsService', () => { // Test 2: Using another legacy tool reference name should also work { - const result = service.toToolAndToolSetEnablementMap(['deprecatedToolName'], undefined); + const result = service.toToolAndToolSetEnablementMap(['deprecatedToolName'], undefined, undefined); assert.strictEqual(result.get(toolWithLegacy), true, 'tool should be enabled via another legacy name'); const fullReferenceNames = service.toFullReferenceNames(result); @@ -835,7 +836,7 @@ suite('LanguageModelToolsService', () => { // Test 3: Using legacy toolset name should enable the entire toolset { - const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined, undefined); assert.strictEqual(result.get(toolSetWithLegacy), true, 'toolset should be enabled via legacy name'); assert.strictEqual(result.get(toolInSet), true, 'tool in set should be enabled when set is enabled via legacy name'); @@ -845,7 +846,7 @@ suite('LanguageModelToolsService', () => { // Test 4: Using deprecated toolset name should also work { - const result = service.toToolAndToolSetEnablementMap(['deprecatedToolSet'], undefined); + const result = service.toToolAndToolSetEnablementMap(['deprecatedToolSet'], undefined, undefined); assert.strictEqual(result.get(toolSetWithLegacy), true, 'toolset should be enabled via another legacy name'); assert.strictEqual(result.get(toolInSet), true, 'tool in set should be enabled when set is enabled via legacy name'); @@ -855,7 +856,7 @@ suite('LanguageModelToolsService', () => { // Test 5: Mix of current and legacy names { - const result = service.toToolAndToolSetEnablementMap(['newToolRef', 'oldToolSet'], undefined); + const result = service.toToolAndToolSetEnablementMap(['newToolRef', 'oldToolSet'], undefined, undefined); assert.strictEqual(result.get(toolWithLegacy), true, 'tool should be enabled via current name'); assert.strictEqual(result.get(toolSetWithLegacy), true, 'toolset should be enabled via legacy name'); assert.strictEqual(result.get(toolInSet), true, 'tool in set should be enabled'); @@ -866,7 +867,7 @@ suite('LanguageModelToolsService', () => { // Test 6: Using legacy names and current names together (redundant but should work) { - const result = service.toToolAndToolSetEnablementMap(['newToolRef', 'oldToolName', 'deprecatedToolName'], undefined); + const result = service.toToolAndToolSetEnablementMap(['newToolRef', 'oldToolName', 'deprecatedToolName'], undefined, undefined); assert.strictEqual(result.get(toolWithLegacy), true, 'tool should be enabled (redundant legacy names should not cause issues)'); const fullReferenceNames = service.toFullReferenceNames(result); @@ -892,7 +893,7 @@ suite('LanguageModelToolsService', () => { // Test 1: Using the full legacy name should enable the tool { - const result = service.toToolAndToolSetEnablementMap(['oldToolSet/oldToolName'], undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolSet/oldToolName'], undefined, undefined); assert.strictEqual(result.get(toolWithOrphanedToolSet), true, 'tool should be enabled via full legacy name'); const fullReferenceNames = service.toFullReferenceNames(result); @@ -901,7 +902,7 @@ suite('LanguageModelToolsService', () => { // Test 2: Using just the orphaned toolset name should also enable the tool { - const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined, undefined); assert.strictEqual(result.get(toolWithOrphanedToolSet), true, 'tool should be enabled via orphaned toolset name'); const fullReferenceNames = service.toFullReferenceNames(result); @@ -921,7 +922,7 @@ suite('LanguageModelToolsService', () => { store.add(service.registerToolData(anotherToolFromOrphanedSet)); { - const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined, undefined); assert.strictEqual(result.get(toolWithOrphanedToolSet), true, 'first tool should be enabled via orphaned toolset name'); assert.strictEqual(result.get(anotherToolFromOrphanedSet), true, 'second tool should also be enabled via orphaned toolset name'); @@ -942,7 +943,7 @@ suite('LanguageModelToolsService', () => { store.add(service.registerToolData(unrelatedTool)); { - const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined, undefined); assert.strictEqual(result.get(toolWithOrphanedToolSet), true, 'tool from oldToolSet should be enabled'); assert.strictEqual(result.get(anotherToolFromOrphanedSet), true, 'another tool from oldToolSet should be enabled'); assert.strictEqual(result.get(unrelatedTool), false, 'tool from different toolset should NOT be enabled'); @@ -970,7 +971,7 @@ suite('LanguageModelToolsService', () => { store.add(newToolSetWithSameName.addTool(toolInRecreatedSet)); { - const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined, undefined); // Now 'oldToolSet' should enable BOTH the recreated toolset AND the tools with legacy names pointing to oldToolSet assert.strictEqual(result.get(newToolSetWithSameName), true, 'recreated toolset should be enabled'); assert.strictEqual(result.get(toolInRecreatedSet), true, 'tool in recreated set should be enabled'); @@ -1060,7 +1061,7 @@ suite('LanguageModelToolsService', () => { { const toolNames = ['custom-agent', 'shell']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); assert.strictEqual(result.get(service.executeToolSet), true, 'execute should be enabled'); assert.strictEqual(result.get(service.agentToolSet), true, 'agent should be enabled'); @@ -1075,7 +1076,7 @@ suite('LanguageModelToolsService', () => { } { const toolNames = ['github/*', 'playwright/*']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); assert.strictEqual(result.get(githubMcpToolSet), true, 'githubMcpToolSet should be enabled'); assert.strictEqual(result.get(playwrightMcpToolSet), true, 'playwrightMcpToolSet should be enabled'); @@ -1091,7 +1092,7 @@ suite('LanguageModelToolsService', () => { { // the speced names should work and not be altered const toolNames = ['github/create_branch', 'playwright/browser_click']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); assert.strictEqual(result.get(githubMcpTool1), true, 'githubMcpTool1 should be enabled'); assert.strictEqual(result.get(playwrightMcpTool1), true, 'playwrightMcpTool1 should be enabled'); @@ -1107,7 +1108,7 @@ suite('LanguageModelToolsService', () => { { // using the old MCP full names should also work const toolNames = ['github/github-mcp-server/*', 'microsoft/playwright-mcp/*']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); assert.strictEqual(result.get(githubMcpToolSet), true, 'githubMcpToolSet should be enabled'); assert.strictEqual(result.get(playwrightMcpToolSet), true, 'playwrightMcpToolSet should be enabled'); @@ -1122,7 +1123,7 @@ suite('LanguageModelToolsService', () => { { // using the old MCP full names should also work const toolNames = ['github/github-mcp-server/create_branch', 'microsoft/playwright-mcp/browser_click']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); assert.strictEqual(result.get(githubMcpTool1), true, 'githubMcpTool1 should be enabled'); assert.strictEqual(result.get(playwrightMcpTool1), true, 'playwrightMcpTool1 should be enabled'); @@ -1138,7 +1139,7 @@ suite('LanguageModelToolsService', () => { { // using the latest MCP full names should also work const toolNames = ['io.github.github/github-mcp-server/*', 'com.microsoft/playwright-mcp/*']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); assert.strictEqual(result.get(githubMcpToolSet), true, 'githubMcpToolSet should be enabled'); assert.strictEqual(result.get(playwrightMcpToolSet), true, 'playwrightMcpToolSet should be enabled'); @@ -1154,7 +1155,7 @@ suite('LanguageModelToolsService', () => { { // using the latest MCP full names should also work const toolNames = ['io.github.github/github-mcp-server/create_branch', 'com.microsoft/playwright-mcp/browser_click']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); assert.strictEqual(result.get(githubMcpTool1), true, 'githubMcpTool1 should be enabled'); assert.strictEqual(result.get(playwrightMcpTool1), true, 'playwrightMcpTool1 should be enabled'); @@ -1170,7 +1171,7 @@ suite('LanguageModelToolsService', () => { { // using the old MCP full names should also work const toolNames = ['github-mcp-server/create_branch']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); assert.strictEqual(result.get(githubMcpTool1), true, 'githubMcpTool1 should be enabled'); const fullReferenceNames = service.toFullReferenceNames(result).sort(); @@ -1776,12 +1777,12 @@ suite('LanguageModelToolsService', () => { store.add(service.registerToolData(disabledTool)); store.add(service.registerToolData(enabledTool)); - const enabledTools = Array.from(service.getTools()); + const enabledTools = Array.from(service.getTools(undefined)); assert.strictEqual(enabledTools.length, 1, 'Should only return enabled tools'); assert.strictEqual(enabledTools[0].id, 'enabledTool'); - const allTools = Array.from(service.getTools(true)); - assert.strictEqual(allTools.length, 2, 'includeDisabled should return all tools'); + const allTools = Array.from(service.getAllToolsIncludingDisabled()); + assert.strictEqual(allTools.length, 2, 'getAllToolsIncludingDisabled should return all tools'); }); test('tool registration duplicate error', () => { @@ -2021,7 +2022,7 @@ suite('LanguageModelToolsService', () => { // Enable the MCP toolset { const enabledNames = [mcpToolSet].map(t => service.getFullReferenceName(t)); - const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined); + const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined, undefined); assert.strictEqual(result.get(mcpToolSet), true, 'MCP toolset should be enabled'); // Ensure the toolset is in the map assert.strictEqual(result.get(mcpTool), true, 'MCP tool should be enabled when its toolset is enabled'); // Ensure the tool is in the map @@ -2032,7 +2033,7 @@ suite('LanguageModelToolsService', () => { // Enable a tool from the MCP toolset { const enabledNames = [mcpTool].map(t => service.getFullReferenceName(t, mcpToolSet)); - const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined); + const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined, undefined); assert.strictEqual(result.get(mcpToolSet), false, 'MCP toolset should be disabled'); // Ensure the toolset is in the map assert.strictEqual(result.get(mcpTool), true, 'MCP tool should be enabled'); // Ensure the tool is in the map @@ -2656,6 +2657,490 @@ suite('LanguageModelToolsService', () => { assert.strictEqual(result.content[0].value, 'commit blocked'); }); + test('beginToolCall creates streaming tool invocation', () => { + const tool = registerToolForTest(service, store, 'streamingTool', { + invoke: async () => ({ content: [{ kind: 'text', value: 'result' }] }), + handleToolStream: async () => ({ invocationMessage: 'Processing...' }), + }); + + const sessionId = 'streaming-session'; + const requestId = 'streaming-request'; + stubGetSession(chatService, sessionId, { requestId }); + + const invocation = service.beginToolCall({ + toolCallId: 'call-123', + toolId: tool.id, + chatRequestId: requestId, + sessionResource: LocalChatSessionUri.forSession(sessionId), + }); + + assert.ok(invocation, 'beginToolCall should return an invocation'); + assert.strictEqual(invocation.toolId, tool.id); + }); + + test('beginToolCall returns undefined for unknown tool', () => { + const invocation = service.beginToolCall({ + toolCallId: 'call-unknown', + toolId: 'nonExistentTool', + }); + + assert.strictEqual(invocation, undefined, 'beginToolCall should return undefined for unknown tools'); + }); + + test('updateToolStream calls handleToolStream on tool implementation', async () => { + let handleToolStreamCalled = false; + let receivedRawInput: unknown; + + const tool = registerToolForTest(service, store, 'streamHandlerTool', { + invoke: async () => ({ content: [{ kind: 'text', value: 'result' }] }), + handleToolStream: async (context) => { + handleToolStreamCalled = true; + receivedRawInput = context.rawInput; + return { invocationMessage: 'Processing...' }; + }, + }); + + const sessionId = 'stream-handler-session'; + const requestId = 'stream-handler-request'; + stubGetSession(chatService, sessionId, { requestId }); + + const invocation = service.beginToolCall({ + toolCallId: 'call-stream', + toolId: tool.id, + chatRequestId: requestId, + sessionResource: LocalChatSessionUri.forSession(sessionId), + }); + + assert.ok(invocation, 'should create invocation'); + + // Update the stream with partial input + const partialInput = { partial: 'data' }; + await service.updateToolStream('call-stream', partialInput, CancellationToken.None); + + assert.strictEqual(handleToolStreamCalled, true, 'handleToolStream should be called'); + assert.deepStrictEqual(receivedRawInput, partialInput, 'should receive the partial input'); + }); + + test('updateToolStream does nothing for unknown tool call', async () => { + // Should not throw + await service.updateToolStream('unknown-call-id', { data: 'test' }, CancellationToken.None); + }); + + test('toToolAndToolSetEnablementMap with model metadata filters tools', () => { + // This test verifies that when a tool's models selector matches the provided model, + // it's included in the enablement map. + + // Tool that requires gpt-4 family (matches provided model) + const gpt4ToolDef: IToolData = { + id: 'gpt4Tool', + toolReferenceName: 'gpt4ToolRef', + modelDescription: 'GPT-4 Tool', + displayName: 'GPT-4 Tool', + source: ToolDataSource.Internal, + canBeReferencedInPrompt: true, + models: [{ family: 'gpt-4' }], + }; + + // Tool with no models selector (available for all models) + const anyModelToolDef: IToolData = { + id: 'anyModelTool', + toolReferenceName: 'anyModelToolRef', + modelDescription: 'Any Model Tool', + displayName: 'Any Model Tool', + source: ToolDataSource.Internal, + canBeReferencedInPrompt: true, + }; + + // Tool that requires claude family (won't match) + const claudeToolDef: IToolData = { + id: 'claudeTool', + toolReferenceName: 'claudeToolRef', + modelDescription: 'Claude Tool', + displayName: 'Claude Tool', + source: ToolDataSource.Internal, + canBeReferencedInPrompt: true, + models: [{ family: 'claude-3' }], + }; + + store.add(service.registerToolData(gpt4ToolDef)); + store.add(service.registerToolData(anyModelToolDef)); + store.add(service.registerToolData(claudeToolDef)); + + // Get the tools from the service + const gpt4Tool = service.getTool('gpt4Tool'); + const anyModelTool = service.getTool('anyModelTool'); + const claudeTool = service.getTool('claudeTool'); + assert.ok(gpt4Tool && anyModelTool && claudeTool, 'tools should be registered'); + + // Provide model metadata for gpt-4 family + const modelMetadata = { id: 'gpt-4-turbo', vendor: 'openai', family: 'gpt-4', version: '1.0' } as ILanguageModelChatMetadata; + const enabledNames = ['gpt4ToolRef', 'anyModelToolRef', 'claudeToolRef']; + const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined, modelMetadata); + + // gpt4Tool should be enabled (model matches) + assert.strictEqual(result.get(gpt4Tool), true, 'gpt4Tool should be enabled'); + // anyModelTool should be enabled (no model restriction) + assert.strictEqual(result.get(anyModelTool), true, 'anyModelTool should be enabled'); + // claudeTool should NOT be in the enablement map (filtered out by model) + assert.strictEqual(result.has(claudeTool), false, 'claudeTool should be filtered out by model'); + }); + + test('observeTools returns tools filtered by context', async () => { + return runWithFakedTimers({}, async () => { + contextKeyService.createKey('featureEnabled', true); + + const enabledTool: IToolData = { + id: 'enabledObsTool', + modelDescription: 'Enabled Tool', + displayName: 'Enabled Tool', + source: ToolDataSource.Internal, + when: ContextKeyEqualsExpr.create('featureEnabled', true), + }; + + const disabledTool: IToolData = { + id: 'disabledObsTool', + modelDescription: 'Disabled Tool', + displayName: 'Disabled Tool', + source: ToolDataSource.Internal, + when: ContextKeyEqualsExpr.create('featureEnabled', false), + }; + + store.add(service.registerToolData(enabledTool)); + store.add(service.registerToolData(disabledTool)); + + const toolsObs = service.observeTools(undefined); + + // Read current value directly + const tools = toolsObs.get(); + + assert.strictEqual(tools.length, 1, 'should only include enabled tool'); + assert.strictEqual(tools[0].id, 'enabledObsTool'); + }); + }); + + test('invokeTool with chatStreamToolCallId correlates with pending streaming call', async () => { + const tool = registerToolForTest(service, store, 'correlatedTool', { + invoke: async () => ({ content: [{ kind: 'text', value: 'correlated result' }] }), + handleToolStream: async () => ({ invocationMessage: 'Processing...' }), + }); + + const sessionId = 'correlated-session'; + const requestId = 'correlated-request'; + const capture: { invocation?: any } = {}; + stubGetSession(chatService, sessionId, { requestId, capture }); + + // Start a streaming tool call + const streamingInvocation = service.beginToolCall({ + toolCallId: 'stream-call-id', + toolId: tool.id, + chatRequestId: requestId, + sessionResource: LocalChatSessionUri.forSession(sessionId), + }); + + assert.ok(streamingInvocation, 'should create streaming invocation'); + + // Now invoke the tool with a different callId but matching chatStreamToolCallId + const dto: IToolInvocation = { + callId: 'different-call-id', + toolId: tool.id, + tokenBudget: 100, + parameters: { test: 1 }, + context: { + sessionId, + sessionResource: LocalChatSessionUri.forSession(sessionId), + }, + chatStreamToolCallId: 'stream-call-id', // This should correlate + }; + + const result = await service.invokeTool(dto, async () => 0, CancellationToken.None); + assert.strictEqual(result.content[0].value, 'correlated result'); + }); + + test('getAllToolsIncludingDisabled returns tools regardless of when clause', () => { + contextKeyService.createKey('featureFlag', false); + + const enabledTool: IToolData = { + id: 'enabledTool', + modelDescription: 'Enabled Tool', + displayName: 'Enabled Tool', + source: ToolDataSource.Internal, + }; + + const disabledTool: IToolData = { + id: 'disabledTool', + modelDescription: 'Disabled Tool', + displayName: 'Disabled Tool', + source: ToolDataSource.Internal, + when: ContextKeyEqualsExpr.create('featureFlag', true), // Will be disabled + }; + + store.add(service.registerToolData(enabledTool)); + store.add(service.registerToolData(disabledTool)); + + // getAllToolsIncludingDisabled should return both tools + const allTools = Array.from(service.getAllToolsIncludingDisabled()); + assert.strictEqual(allTools.length, 2, 'getAllToolsIncludingDisabled should return all tools'); + assert.ok(allTools.some(t => t.id === 'enabledTool'), 'should include enabled tool'); + assert.ok(allTools.some(t => t.id === 'disabledTool'), 'should include disabled tool'); + + // getTools should only return tools matching when clause + const enabledTools = Array.from(service.getTools(undefined)); + assert.strictEqual(enabledTools.length, 1, 'getTools should only return matching tools'); + assert.strictEqual(enabledTools[0].id, 'enabledTool'); + }); + + test('getTools filters by model id using models property', () => { + const gpt4Tool: IToolData = { + id: 'gpt4Tool', + modelDescription: 'GPT-4 Tool', + displayName: 'GPT-4 Tool', + source: ToolDataSource.Internal, + models: [{ id: 'gpt-4-turbo' }], + }; + + const claudeTool: IToolData = { + id: 'claudeTool', + modelDescription: 'Claude Tool', + displayName: 'Claude Tool', + source: ToolDataSource.Internal, + models: [{ id: 'claude-3-opus' }], + }; + + const universalTool: IToolData = { + id: 'universalTool', + modelDescription: 'Universal Tool', + displayName: 'Universal Tool', + source: ToolDataSource.Internal, + // No models - available for all models + }; + + store.add(service.registerToolData(gpt4Tool)); + store.add(service.registerToolData(claudeTool)); + store.add(service.registerToolData(universalTool)); + + // Mock model metadata with id 'gpt-4-turbo' + const modelMetadata = { id: 'gpt-4-turbo', vendor: 'openai', family: 'gpt-4', version: '1.0' } as ILanguageModelChatMetadata; + const tools = Array.from(service.getTools(modelMetadata)); + + assert.strictEqual(tools.length, 2, 'should return 2 tools'); + assert.ok(tools.some(t => t.id === 'gpt4Tool'), 'should include GPT-4 tool'); + assert.ok(tools.some(t => t.id === 'universalTool'), 'should include universal tool'); + assert.ok(!tools.some(t => t.id === 'claudeTool'), 'should NOT include Claude tool'); + }); + + test('getTools filters by model vendor using models property', () => { + const anthropicTool: IToolData = { + id: 'anthropicTool', + modelDescription: 'Anthropic Tool', + displayName: 'Anthropic Tool', + source: ToolDataSource.Internal, + models: [{ vendor: 'anthropic' }], + }; + + const openaiTool: IToolData = { + id: 'openaiTool', + modelDescription: 'OpenAI Tool', + displayName: 'OpenAI Tool', + source: ToolDataSource.Internal, + models: [{ vendor: 'openai' }], + }; + + store.add(service.registerToolData(anthropicTool)); + store.add(service.registerToolData(openaiTool)); + + // Mock model metadata with vendor 'anthropic' + const modelMetadata = { id: 'claude-3', vendor: 'anthropic', family: 'claude-3', version: '1.0' } as ILanguageModelChatMetadata; + const tools = Array.from(service.getTools(modelMetadata)); + + assert.strictEqual(tools.length, 1, 'should return 1 tool'); + assert.strictEqual(tools[0].id, 'anthropicTool', 'should include Anthropic tool'); + }); + + test('getTools filters by model family using models property', () => { + const gpt4FamilyTool: IToolData = { + id: 'gpt4FamilyTool', + modelDescription: 'GPT-4 Family Tool', + displayName: 'GPT-4 Family Tool', + source: ToolDataSource.Internal, + models: [{ family: 'gpt-4' }], + }; + + const gpt35FamilyTool: IToolData = { + id: 'gpt35FamilyTool', + modelDescription: 'GPT-3.5 Family Tool', + displayName: 'GPT-3.5 Family Tool', + source: ToolDataSource.Internal, + models: [{ family: 'gpt-3.5' }], + }; + + store.add(service.registerToolData(gpt4FamilyTool)); + store.add(service.registerToolData(gpt35FamilyTool)); + + // Mock model metadata with family 'gpt-4' + const modelMetadata = { id: 'gpt-4-turbo', vendor: 'openai', family: 'gpt-4', version: '1.0' } as ILanguageModelChatMetadata; + const tools = Array.from(service.getTools(modelMetadata)); + + assert.strictEqual(tools.length, 1, 'should return 1 tool'); + assert.strictEqual(tools[0].id, 'gpt4FamilyTool', 'should include GPT-4 family tool'); + }); + + test('getTools with undefined model skips model filtering', () => { + const gpt4Tool: IToolData = { + id: 'gpt4Tool', + modelDescription: 'GPT-4 Tool', + displayName: 'GPT-4 Tool', + source: ToolDataSource.Internal, + models: [{ id: 'gpt-4-turbo' }], + }; + + const claudeTool: IToolData = { + id: 'claudeTool', + modelDescription: 'Claude Tool', + displayName: 'Claude Tool', + source: ToolDataSource.Internal, + models: [{ id: 'claude-3-opus' }], + }; + + store.add(service.registerToolData(gpt4Tool)); + store.add(service.registerToolData(claudeTool)); + + // When model is undefined, all tools should be returned (model filtering skipped) + const tools = Array.from(service.getTools(undefined)); + + assert.strictEqual(tools.length, 2, 'should return all tools when model is undefined'); + assert.ok(tools.some(t => t.id === 'gpt4Tool'), 'should include GPT-4 tool'); + assert.ok(tools.some(t => t.id === 'claudeTool'), 'should include Claude tool'); + }); + + test('getTool returns tool regardless of when clause', () => { + contextKeyService.createKey('someFlag', false); + + const disabledTool: IToolData = { + id: 'disabledLookupTool', + modelDescription: 'Disabled Lookup Tool', + displayName: 'Disabled Lookup Tool', + source: ToolDataSource.Internal, + when: ContextKeyEqualsExpr.create('someFlag', true), // Disabled + }; + + store.add(service.registerToolData(disabledTool)); + + // getTool should still find the tool by ID + const tool = service.getTool('disabledLookupTool'); + assert.ok(tool, 'getTool should return tool even when disabled'); + assert.strictEqual(tool.id, 'disabledLookupTool'); + }); + + test('getToolByName returns tool regardless of when clause', () => { + contextKeyService.createKey('anotherFlag', false); + + const disabledTool: IToolData = { + id: 'disabledNamedTool', + toolReferenceName: 'disabledNamedToolRef', + modelDescription: 'Disabled Named Tool', + displayName: 'Disabled Named Tool', + source: ToolDataSource.Internal, + when: ContextKeyEqualsExpr.create('anotherFlag', true), // Disabled + }; + + store.add(service.registerToolData(disabledTool)); + + // getToolByName should still find the tool by reference name + const tool = service.getToolByName('disabledNamedToolRef'); + assert.ok(tool, 'getToolByName should return tool even when disabled'); + assert.strictEqual(tool.id, 'disabledNamedTool'); + }); + + test('IToolData models property stores selector information', () => { + const toolWithModels: IToolData = { + id: 'modelSpecificTool', + modelDescription: 'Model Specific Tool', + displayName: 'Model Specific Tool', + source: ToolDataSource.Internal, + models: [ + { vendor: 'openai', family: 'gpt-4' }, + { vendor: 'anthropic', family: 'claude-3' }, + ], + }; + + store.add(service.registerToolData(toolWithModels)); + + const tool = service.getTool('modelSpecificTool'); + assert.ok(tool, 'tool should be registered'); + assert.ok(tool.models, 'tool should have models property'); + assert.strictEqual(tool.models.length, 2, 'tool should have 2 model selectors'); + assert.deepStrictEqual(tool.models[0], { vendor: 'openai', family: 'gpt-4' }); + assert.deepStrictEqual(tool.models[1], { vendor: 'anthropic', family: 'claude-3' }); + }); + + test('tools with extension tools disabled setting are filtered', () => { + // Create a tool from an extension + const extensionTool: IToolData = { + id: 'extensionTool', + modelDescription: 'Extension Tool', + displayName: 'Extension Tool', + source: { type: 'extension', label: 'Test Extension', extensionId: new ExtensionIdentifier('test.extension') }, + }; + + store.add(service.registerToolData(extensionTool)); + + // With extension tools enabled (default in setup) + let tools = Array.from(service.getTools(undefined)); + assert.ok(tools.some(t => t.id === 'extensionTool'), 'extension tool should be included when enabled'); + + // Disable extension tools + configurationService.setUserConfiguration(ChatConfiguration.ExtensionToolsEnabled, false); + + tools = Array.from(service.getTools(undefined)); + assert.ok(!tools.some(t => t.id === 'extensionTool'), 'extension tool should be excluded when disabled'); + + // Re-enable for cleanup + configurationService.setUserConfiguration(ChatConfiguration.ExtensionToolsEnabled, true); + }); + + test('observeTools changes when context key changes', async () => { + return runWithFakedTimers({}, async () => { + const testCtxKey = contextKeyService.createKey('dynamicTestKey', 'value1'); + + const tool1: IToolData = { + id: 'dynamicTool1', + modelDescription: 'Dynamic Tool 1', + displayName: 'Dynamic Tool 1', + source: ToolDataSource.Internal, + when: ContextKeyEqualsExpr.create('dynamicTestKey', 'value1'), + }; + + const tool2: IToolData = { + id: 'dynamicTool2', + modelDescription: 'Dynamic Tool 2', + displayName: 'Dynamic Tool 2', + source: ToolDataSource.Internal, + when: ContextKeyEqualsExpr.create('dynamicTestKey', 'value2'), + }; + + store.add(service.registerToolData(tool1)); + store.add(service.registerToolData(tool2)); + + const toolsObs = service.observeTools(undefined); + + // Initial state: value1 matches tool1 + let tools = toolsObs.get(); + assert.strictEqual(tools.length, 1, 'should have 1 tool initially'); + assert.strictEqual(tools[0].id, 'dynamicTool1', 'should be dynamicTool1'); + + // Change context key to value2 + testCtxKey.set('value2'); + + // Wait for scheduler to trigger + await new Promise(resolve => setTimeout(resolve, 800)); + + // Now tool2 should be available + tools = toolsObs.get(); + assert.strictEqual(tools.length, 1, 'should have 1 tool after change'); + assert.strictEqual(tools[0].id, 'dynamicTool2', 'should be dynamicTool2 after context change'); + }); + }); + test('isPermitted allows tools in permitted toolsets when agent mode is disabled', () => { // Disable agent mode configurationService.setUserConfiguration(ChatConfiguration.AgentEnabled, false); @@ -2682,7 +3167,7 @@ suite('LanguageModelToolsService', () => { store.add(service.registerToolData(standaloneTool)); // Get tools - should include the tool in the read toolset but not the standalone tool - const tools = Array.from(service.getTools()); + const tools = Array.from(service.getTools(undefined)); const toolIds = tools.map(t => t.id); assert.ok(toolIds.includes('readToolInSet'), 'Tool in read toolset should be permitted when agent mode is disabled'); @@ -2715,7 +3200,7 @@ suite('LanguageModelToolsService', () => { store.add(service.registerToolData(standaloneTool)); // Get tools - both should be available when agent mode is enabled - const tools = Array.from(service.getTools()); + const tools = Array.from(service.getTools(undefined)); const toolIds = tools.map(t => t.id); assert.ok(toolIds.includes('readToolEnabled'), 'Tool in read toolset should be permitted when agent mode is enabled'); @@ -2768,7 +3253,7 @@ suite('LanguageModelToolsService', () => { store.add(service.executeToolSet.addTool(executeTool)); // Get tools - execute tool should be available when agent mode is enabled - const tools = Array.from(service.getTools()); + const tools = Array.from(service.getTools(undefined)); const toolIds = tools.map(t => t.id); assert.ok(toolIds.includes('executeToolInSet'), 'Tool in execute toolset should be permitted when agent mode is enabled'); @@ -2790,7 +3275,7 @@ suite('LanguageModelToolsService', () => { store.add(service.executeToolSet.addTool(executeTool)); // Get tools - execute tool should NOT be available when agent mode is disabled - const tools = Array.from(service.getTools()); + const tools = Array.from(service.getTools(undefined)); const toolIds = tools.map(t => t.id); assert.ok(!toolIds.includes('executeToolBlocked'), 'Tool in execute toolset should NOT be permitted when agent mode is disabled'); @@ -2819,7 +3304,7 @@ suite('LanguageModelToolsService', () => { store.add(searchToolSet.addTool(searchTool)); // Get tools - search tool should be available when agent mode is disabled - const tools = Array.from(service.getTools()); + const tools = Array.from(service.getTools(undefined)); const toolIds = tools.map(t => t.id); assert.ok(toolIds.includes('searchToolInSet'), 'Tool in search toolset should be permitted when agent mode is disabled'); @@ -2848,7 +3333,7 @@ suite('LanguageModelToolsService', () => { store.add(webToolSet.addTool(webTool)); // Get tools - web tool should be available when agent mode is disabled - const tools = Array.from(service.getTools()); + const tools = Array.from(service.getTools(undefined)); const toolIds = tools.map(t => t.id); assert.ok(toolIds.includes('webToolInSet'), 'Tool in web toolset should be permitted when agent mode is disabled'); @@ -2869,7 +3354,7 @@ suite('LanguageModelToolsService', () => { store.add(service.registerToolData(fetchTool)); // Get tools - this special tool should be available even when not in a toolset - const tools = Array.from(service.getTools()); + const tools = Array.from(service.getTools(undefined)); const toolIds = tools.map(t => t.id); assert.ok(toolIds.includes('vscode_fetchWebPage_internal'), 'vscode_fetchWebPage_internal should be permitted as special case when agent mode is disabled'); @@ -2891,7 +3376,7 @@ suite('LanguageModelToolsService', () => { store.add(service.registerToolData(extensionTool)); // Get tools - extension tool should NOT be available when agent mode is disabled - const tools = Array.from(service.getTools()); + const tools = Array.from(service.getTools(undefined)); const toolIds = tools.map(t => t.id); assert.ok(!toolIds.includes('extensionToolBlocked'), 'Extension tool not in permitted toolset should NOT be permitted when agent mode is disabled'); @@ -2921,7 +3406,7 @@ suite('LanguageModelToolsService', () => { store.add(mcpToolSet.addTool(mcpTool)); // Get tools - MCP tool should NOT be available when agent mode is disabled - const tools = Array.from(service.getTools()); + const tools = Array.from(service.getTools(undefined)); const toolIds = tools.map(t => t.id); assert.ok(!toolIds.includes('mcpToolBlocked'), 'MCP tool should NOT be permitted when agent mode is disabled'); @@ -2949,7 +3434,7 @@ suite('LanguageModelToolsService', () => { store.add(service.agentToolSet.addTool(agentTool)); // Get tools - agent tool should NOT be available when agent mode is disabled - const tools = Array.from(service.getTools()); + const tools = Array.from(service.getTools(undefined)); const toolIds = tools.map(t => t.id); assert.ok(!toolIds.includes('agentToolBlocked'), 'Tool in agent toolset should NOT be permitted when agent mode is disabled'); @@ -2988,7 +3473,7 @@ suite('LanguageModelToolsService', () => { store.add(customToolSet.addTool(multiSetTool)); // Get tools - tool should be available because it's in the 'read' toolset - const tools = Array.from(service.getTools()); + const tools = Array.from(service.getTools(undefined)); const toolIds = tools.map(t => t.id); assert.ok(toolIds.includes('multiSetTool'), 'Tool should be permitted if it belongs to at least one permitted toolset'); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatSelectedTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatSelectedTools.test.ts index 60df438df90cf..9860fdcf3796b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatSelectedTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatSelectedTools.test.ts @@ -40,7 +40,7 @@ suite('ChatSelectedTools', () => { store.add(instaService); toolsService = instaService.get(ILanguageModelToolsService); - selectedTools = store.add(instaService.createInstance(ChatSelectedTools, constObservable(ChatMode.Agent))); + selectedTools = store.add(instaService.createInstance(ChatSelectedTools, constObservable(ChatMode.Agent), constObservable(undefined))); }); teardown(function () { @@ -95,7 +95,7 @@ suite('ChatSelectedTools', () => { store.add(toolset.addTool(toolData2)); store.add(toolset.addTool(toolData3)); - assert.strictEqual(Iterable.length(toolsService.getTools()), 3); + assert.strictEqual(Iterable.length(toolsService.getTools(undefined)), 3); const size = Iterable.length(toolset.getTools()); assert.strictEqual(size, 3); @@ -159,7 +159,7 @@ suite('ChatSelectedTools', () => { store.add(toolset.addTool(toolData2)); store.add(toolset.addTool(toolData3)); - assert.strictEqual(Iterable.length(toolsService.getTools()), 3); + assert.strictEqual(Iterable.length(toolsService.getTools(undefined)), 3); const size = Iterable.length(toolset.getTools()); assert.strictEqual(size, 3); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts new file mode 100644 index 0000000000000..deded3b1c3f99 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -0,0 +1,1130 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as sinon from 'sinon'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Event } from '../../../../../../base/common/event.js'; +import { Schemas } from '../../../../../../base/common/network.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ILanguageService } from '../../../../../../editor/common/languages/language.js'; +import { IModelService } from '../../../../../../editor/common/services/model.js'; +import { ModelService } from '../../../../../../editor/common/services/modelService.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { FileService } from '../../../../../../platform/files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../../../../platform/files/common/inMemoryFilesystemProvider.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { testWorkspace } from '../../../../../../platform/workspace/test/common/testWorkspace.js'; +import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; +import { TestContextService, TestUserDataProfileService } from '../../../../../test/common/workbenchTestServices.js'; +import { ChatRequestVariableSet, isPromptFileVariableEntry, isPromptTextVariableEntry, toFileVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; +import { ComputeAutomaticInstructions } from '../../../common/promptSyntax/computeAutomaticInstructions.js'; +import { PromptsConfig } from '../../../common/promptSyntax/config/config.js'; +import { INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../common/promptSyntax/config/promptFileLocations.js'; +import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID } from '../../../common/promptSyntax/promptTypes.js'; +import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; +import { PromptsService } from '../../../common/promptSyntax/service/promptsServiceImpl.js'; +import { mockFiles } from './testUtils/mockFilesystem.js'; +import { InMemoryStorageService, IStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { IPathService } from '../../../../../services/path/common/pathService.js'; +import { IFileQuery, ISearchService } from '../../../../../services/search/common/search.js'; +import { IExtensionService } from '../../../../../services/extensions/common/extensions.js'; +import { ChatMode } from '../../../common/chatModes.js'; +import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; +import { basename } from '../../../../../../base/common/resources.js'; +import { match } from '../../../../../../base/common/glob.js'; + +suite('ComputeAutomaticInstructions', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let service: IPromptsService; + let instaService: TestInstantiationService; + let workspaceContextService: TestContextService; + let testConfigService: TestConfigurationService; + let fileService: IFileService; + let toolsService: ILanguageModelToolsService; + + setup(async () => { + instaService = disposables.add(new TestInstantiationService()); + instaService.stub(ILogService, new NullLogService()); + + workspaceContextService = new TestContextService(); + instaService.stub(IWorkspaceContextService, workspaceContextService); + + testConfigService = new TestConfigurationService(); + testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, true); + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, true); + testConfigService.setUserConfiguration(PromptsConfig.USE_NESTED_AGENT_MD, false); + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true); + testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_REFERENCED_INSTRUCTIONS, true); + testConfigService.setUserConfiguration(PromptsConfig.INSTRUCTIONS_LOCATION_KEY, { [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true }); + testConfigService.setUserConfiguration(PromptsConfig.PROMPT_LOCATIONS_KEY, { [PROMPT_DEFAULT_SOURCE_FOLDER]: true }); + testConfigService.setUserConfiguration(PromptsConfig.MODE_LOCATION_KEY, { [LEGACY_MODE_DEFAULT_SOURCE_FOLDER]: true }); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, { '.claude/skills': true }); + + instaService.stub(IConfigurationService, testConfigService); + instaService.stub(IUserDataProfileService, new TestUserDataProfileService()); + instaService.stub(ITelemetryService, NullTelemetryService); + instaService.stub(IStorageService, InMemoryStorageService); + instaService.stub(IExtensionService, { + whenInstalledExtensionsRegistered: () => Promise.resolve(true), + activateByEvent: () => Promise.resolve() + }); + + fileService = disposables.add(instaService.createInstance(FileService)); + instaService.stub(IFileService, fileService); + + const modelService = disposables.add(instaService.createInstance(ModelService)); + instaService.stub(IModelService, modelService); + instaService.stub(ILanguageService, { + guessLanguageIdByFilepathOrFirstLine(uri: URI) { + if (uri.path.endsWith(PROMPT_FILE_EXTENSION)) { + return PROMPT_LANGUAGE_ID; + } + + if (uri.path.endsWith(INSTRUCTION_FILE_EXTENSION)) { + return INSTRUCTIONS_LANGUAGE_ID; + } + + return 'plaintext'; + } + }); + instaService.stub(ILabelService, { + getUriLabel: (uri: URI, options?: { relative?: boolean }) => { + if (options?.relative) { + return basename(uri); + } + return uri.path; + } + }); + + const fileSystemProvider = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider(Schemas.file, fileSystemProvider)); + + const pathService = { + userHome: (): URI | Promise => { + return Promise.resolve(URI.file('/home/user')); + }, + } as IPathService; + instaService.stub(IPathService, pathService); + + instaService.stub(ISearchService, { + schemeHasFileSearchProvider: () => true, + async fileSearch(query: IFileQuery) { + const results: any[] = []; + for (const folderQuery of query.folderQueries) { + const findFilesInLocation = async (location: URI, results: URI[] = []): Promise => { + try { + const resolve = await fileService.resolve(location); + if (resolve.isFile) { + results.push(resolve.resource); + } else if (resolve.isDirectory && resolve.children) { + for (const child of resolve.children) { + await findFilesInLocation(child.resource, results); + } + } + } catch (error) { + // folder doesn't exist + } + return results; + }; + + const allFiles = await findFilesInLocation(folderQuery.folder); + for (const resource of allFiles) { + const pathMatch = query.filePattern === undefined || match(query.filePattern, resource.path); + if (pathMatch) { + results.push({ resource }); + } + } + } + return { results, messages: [] }; + } + }); + + // Mock tools service + toolsService = { + getToolByName: (name: string) => { + if (name === 'readFile') { + return { id: 'vscode_readFile', name: 'readFile' }; + } + if (name === 'runSubagent') { + return { id: 'vscode_runSubagent', name: 'runSubagent' }; + } + return undefined; + }, + getFullReferenceName: (tool: { name: string }) => tool.name, + } as unknown as ILanguageModelToolsService; + instaService.stub(ILanguageModelToolsService, toolsService); + + service = disposables.add(instaService.createInstance(PromptsService)); + instaService.stub(IPromptsService, service); + }); + + teardown(() => { + sinon.restore(); + }); + + suite('collect', () => { + test('should collect all types of instructions', async () => { + const rootFolderName = 'collect-all-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + // Applying instruction + { + path: `${rootFolder}/.github/instructions/typescript.instructions.md`, + contents: [ + '---', + 'description: \'TypeScript instructions\'', + 'applyTo: "**/*.ts"', + '---', + 'TypeScript coding standards', + ] + }, + // copilot-instructions + { + path: `${rootFolder}/.github/copilot-instructions.md`, + contents: [ + 'Be helpful and friendly', + ] + }, + // AGENTS.md + { + path: `${rootFolder}/AGENTS.md`, + contents: [ + 'Agent guidelines', + ] + }, + // Attached file + { + path: `${rootFolder}/src/file.ts`, + contents: [ + 'console.log("test");', + ] + }, + ]); + { + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + const paths = instructionFiles.map(i => isPromptFileVariableEntry(i) ? i.value.path : undefined); + + assert.ok(paths.includes(`${rootFolder}/.github/instructions/typescript.instructions.md`), 'Should include applying instruction'); + assert.ok(paths.includes(`${rootFolder}/.github/copilot-instructions.md`), 'Should include copilot-instructions'); + assert.ok(paths.includes(`${rootFolder}/AGENTS.md`), 'Should include AGENTS.md'); + } + { + testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, false); + testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, true); + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, true); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + const paths = instructionFiles.map(i => isPromptFileVariableEntry(i) ? i.value.path : undefined); + + assert.ok(!paths.includes(`${rootFolder}/.github/instructions/typescript.instructions.md`), 'Should not include applying instruction'); + assert.ok(paths.includes(`${rootFolder}/.github/copilot-instructions.md`), 'Should include copilot-instructions'); + assert.ok(paths.includes(`${rootFolder}/AGENTS.md`), 'Should include AGENTS.md'); + } + { + testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true); + testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, false); + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, true); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + const paths = instructionFiles.map(i => isPromptFileVariableEntry(i) ? i.value.path : undefined); + + assert.ok(paths.includes(`${rootFolder}/.github/instructions/typescript.instructions.md`), 'Should include applying instruction'); + assert.ok(!paths.includes(`${rootFolder}/.github/copilot-instructions.md`), 'Should not include copilot-instructions'); + assert.ok(paths.includes(`${rootFolder}/AGENTS.md`), 'Should include AGENTS.md'); + } + { + testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true); + testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, true); + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, false); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + const paths = instructionFiles.map(i => isPromptFileVariableEntry(i) ? i.value.path : undefined); + + assert.ok(paths.includes(`${rootFolder}/.github/instructions/typescript.instructions.md`), 'Should include applying instruction'); + assert.ok(paths.includes(`${rootFolder}/.github/copilot-instructions.md`), 'Should include copilot-instructions'); + assert.ok(!paths.includes(`${rootFolder}/AGENTS.md`), 'Should not include AGENTS.md'); + } + }); + + test('should not collect when settings are disabled', async () => { + testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, false); + testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, false); + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, false); + + const rootFolderName = 'disabled-settings-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/instructions/typescript.instructions.md`, + contents: [ + '---', + 'applyTo: "**/*.ts"', + '---', + 'TypeScript coding standards', + ] + }, + { + path: `${rootFolder}/.github/copilot-instructions.md`, + contents: ['Be helpful'], + }, + { + path: `${rootFolder}/AGENTS.md`, + contents: ['Guidelines'], + }, + { + path: `${rootFolder}/src/file.ts`, + contents: ['console.log("test");'], + }, + ]); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + assert.strictEqual(instructionFiles.length, 0, 'Should not collect any instructions when settings are disabled'); + }); + + test('should collect for edit mode even when settings disabled', async () => { + testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, false); + + const rootFolderName = 'edit-mode-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/instructions/typescript.instructions.md`, + contents: [ + '---', + 'applyTo: "**/*.ts"', + '---', + 'TypeScript standards', + ] + }, + { + path: `${rootFolder}/src/file.ts`, + contents: ['console.log("test");'], + }, + ]); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Edit, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + assert.ok(instructionFiles.length > 0, 'Should collect instructions in edit mode even when setting is disabled'); + }); + }); + + suite('addApplyingInstructions', () => { + test('should match ** pattern for any file', async () => { + const rootFolderName = 'wildcard-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/instructions/all-files.instructions.md`, + contents: [ + '---', + 'applyTo: "**"', + '---', + 'Apply to all files', + ] + }, + { + path: `${rootFolder}/src/file.ts`, + contents: ['code'], + }, + ]); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + assert.strictEqual(instructionFiles.length, 1, 'Should match ** pattern'); + }); + + test('should match specific file patterns', async () => { + const rootFolderName = 'pattern-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/instructions/typescript.instructions.md`, + contents: [ + '---', + 'applyTo: "**/*.ts"', + '---', + 'TS instructions', + ] + }, + { + path: `${rootFolder}/.github/instructions/javascript.instructions.md`, + contents: [ + '---', + 'applyTo: "**/*.js"', + '---', + 'JS instructions', + ] + }, + { + path: `${rootFolder}/src/file.ts`, + contents: ['code'], + }, + ]); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const paths = variables.asArray() + .filter(v => isPromptFileVariableEntry(v)) + .map(v => isPromptFileVariableEntry(v) ? v.value.path : undefined); + assert.ok(paths.includes(`${rootFolder}/.github/instructions/typescript.instructions.md`), 'Should match TS file'); + assert.ok(!paths.includes(`${rootFolder}/.github/instructions/javascript.instructions.md`), 'Should not match JS pattern'); + }); + + test('should handle multiple patterns separated by comma', async () => { + const rootFolderName = 'multi-pattern-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/instructions/web.instructions.md`, + contents: [ + '---', + 'applyTo: "**/*.ts, **/*.js, **/*.tsx"', + '---', + 'Web instructions', + ] + }, + { + path: `${rootFolder}/src/component.tsx`, + contents: ['code'], + }, + ]); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/component.tsx'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + assert.strictEqual(instructionFiles.length, 1, 'Should match one of the comma-separated patterns'); + }); + + test('should not add duplicate instructions', async () => { + const rootFolderName = 'duplicate-test'; const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/instructions/typescript.instructions.md`, + contents: [ + '---', + 'applyTo: "**/*.ts"', + '---', + 'TS instructions', + ] + }, + { + path: `${rootFolder}/src/file1.ts`, + contents: ['code'], + }, + { + path: `${rootFolder}/src/file2.ts`, + contents: ['code'], + }, + ]); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file1.ts'))); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file2.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + assert.strictEqual(instructionFiles.length, 1, 'Should add instruction only once even with multiple matching files'); + }); + + test('should handle relative glob patterns', async () => { + const rootFolderName = 'relative-pattern-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/instructions/src-files.instructions.md`, + contents: [ + '---', + 'applyTo: "src/**/*.ts"', + '---', + 'Src instructions', + ] + }, + { + path: `${rootFolder}/src/file.ts`, + contents: ['code'], + }, + ]); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + assert.strictEqual(instructionFiles.length, 1, 'Should match relative glob pattern'); + }); + }); + + suite('referenced instructions', () => { + test('should add referenced instruction files', async () => { + const rootFolderName = 'referenced-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/instructions/main.instructions.md`, + contents: [ + '---', + 'description: \'Main instructions\'', + 'applyTo: "**/*.ts"', + '---', + 'Main instructions #file:./referenced.instructions.md', + ] + }, + { + path: `${rootFolder}/.github/instructions/referenced.instructions.md`, + contents: [ + '---', + 'description: \'Referenced instructions\'', + '---', + 'Referenced content', + ] + }, + ]); + + const mainUri = URI.joinPath(rootFolderUri, '.github/instructions/main.instructions.md'); + const referencedUri = URI.joinPath(rootFolderUri, '.github/instructions/referenced.instructions.md'); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const paths = variables.asArray() + .filter(v => isPromptFileVariableEntry(v)) + .map(v => isPromptFileVariableEntry(v) ? v.value.path : undefined); + + assert.ok(paths.includes(mainUri.path), 'Should include main instruction'); + assert.ok(paths.includes(referencedUri.path), 'Should include referenced instruction'); + }); + + test('should not add non-workspace references', async () => { + const rootFolderName = 'non-workspace-ref-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/instructions/main.instructions.md`, + contents: [ + '---', + 'description: \'Main instructions\'', + 'applyTo: "**/*.ts"', + '---', + 'Main instructions #file:/tmp/external.md', + ] + }, + ]); + + const mainUri = URI.joinPath(rootFolderUri, '.github/instructions/main.instructions.md'); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const paths = variables.asArray() + .filter(v => isPromptFileVariableEntry(v)) + .map(v => isPromptFileVariableEntry(v) ? v.value.path : undefined); + + assert.ok(paths.includes(mainUri.path), 'Should include main instruction'); + assert.ok(!paths.includes('/tmp/external.md'), 'Should not include non-workspace reference'); + }); + + test('should handle nested references', async () => { + const rootFolderName = 'nested-ref-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/instructions/level1.instructions.md`, + contents: [ + '---', + 'applyTo: "**/*.ts"', + '---', + 'Level 1 #file:./level2.instructions.md', + ] + }, + { + path: `${rootFolder}/.github/instructions/level2.instructions.md`, + contents: [ + 'Level 2 #file:./level3.instructions.md', + ] + }, + { + path: `${rootFolder}/.github/instructions/level3.instructions.md`, + contents: [ + 'Level 3', + ] + }, + ]); + + const level1Uri = URI.joinPath(rootFolderUri, '.github/instructions/level1.instructions.md'); + const level2Uri = URI.joinPath(rootFolderUri, '.github/instructions/level2.instructions.md'); + const level3Uri = URI.joinPath(rootFolderUri, '.github/instructions/level3.instructions.md'); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const paths = variables.asArray() + .filter(v => isPromptFileVariableEntry(v)) + .map(v => isPromptFileVariableEntry(v) ? v.value.path : undefined); + + assert.ok(paths.includes(level1Uri.path), 'Should include level 1'); + assert.ok(paths.includes(level2Uri.path), 'Should include level 2'); + assert.ok(paths.includes(level3Uri.path), 'Should include level 3'); + }); + }); + + suite('telemetry', () => { + test('should emit telemetry event with counts', async () => { + const rootFolderName = 'telemetry-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/instructions/typescript.instructions.md`, + contents: [ + '---', + 'applyTo: "**/*.ts"', + '---', + 'TS instructions [](./referenced.instructions.md)', + ] + }, + { + path: `${rootFolder}/.github/instructions/referenced.instructions.md`, + contents: ['Referenced content'], + }, + { + path: `${rootFolder}/.github/copilot-instructions.md`, + contents: ['Copilot instructions'], + }, + { + path: `${rootFolder}/AGENTS.md`, + contents: ['Agent instructions'], + }, + { + path: `${rootFolder}/src/file.ts`, + contents: ['code'], + }, + ]); + + const telemetryEvents: { eventName: string; data: unknown }[] = []; + const mockTelemetryService = { + publicLog2: (eventName: string, data: unknown) => { + telemetryEvents.push({ eventName, data }); + } + } as unknown as ITelemetryService; + instaService.stub(ITelemetryService, mockTelemetryService); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const telemetryEvent = telemetryEvents.find(e => e.eventName === 'instructionsCollected'); + assert.ok(telemetryEvent, 'Should emit telemetry event'); + const data = telemetryEvent.data as { + applyingInstructionsCount: number; + referencedInstructionsCount: number; + agentInstructionsCount: number; + totalInstructionsCount: number; + }; + assert.ok(data.applyingInstructionsCount >= 0, 'Should have applying count'); + assert.ok(data.referencedInstructionsCount >= 0, 'Should have referenced count'); + assert.ok(data.agentInstructionsCount >= 0, 'Should have agent count'); + assert.ok(data.totalInstructionsCount >= 0, 'Should have total count'); + }); + }); + + suite('instructions list variable', () => { + function xmlContents(text: string, tag: string): string[] { + const regex = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g'); + const matches = []; + let match; + while ((match = regex.exec(text)) !== null) { + matches.push(match[1].trim()); + } + return matches; + } + + function getFilePath(path: string): string { + return URI.file(path).fsPath; + } + + test('should generate instructions list when readFile tool available', async () => { + const rootFolderName = 'instructions-list-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/instructions/test.instructions.md`, + contents: [ + '---', + 'description: \'Test instructions\'', + 'applyTo: "**/*.ts"', + '---', + 'Test content', + ] + }, + ]); + + const contextComputer = instaService.createInstance( + ComputeAutomaticInstructions, + ChatMode.Agent, + { 'vscode_readFile': true }, // Enable readFile tool + undefined + ); + const variables = new ChatRequestVariableSet(); + + await contextComputer.collect(variables, CancellationToken.None); + + const textVariables = variables.asArray().filter(v => isPromptTextVariableEntry(v)); + assert.equal(textVariables.length, 1, 'There should be one text variable for instructions list'); + + const instructionsList = xmlContents(textVariables[0].value, 'instructions'); + assert.equal(instructionsList.length, 1, 'There should be one instructions list'); + + const instructions = xmlContents(instructionsList[0], 'instruction'); + assert.equal(instructions.length, 1, 'There should be one instruction'); + + assert.equal(xmlContents(instructions[0], 'description')[0], 'Test instructions'); + assert.equal(xmlContents(instructions[0], 'file')[0], getFilePath(`${rootFolder}/.github/instructions/test.instructions.md`)); + assert.equal(xmlContents(instructions[0], 'applyTo')[0], '**/*.ts'); + }); + + test('should include agents list when runSubagent tool available', async () => { + const rootFolderName = 'agents-list-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Enable the config for custom agents + testConfigService.setUserConfiguration('chat.customAgentInSubagent.enabled', true); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/agents/test-agent.agent.md`, + contents: [ + '---', + 'description: \'Test agent\'', + '---', + 'Test agent content', + ] + }, + ]); + + const contextComputer = instaService.createInstance( + ComputeAutomaticInstructions, + ChatMode.Agent, + { 'vscode_runSubagent': true }, // Enable runSubagent tool + ['*'] // Enable all subagents + ); + const variables = new ChatRequestVariableSet(); + + await contextComputer.collect(variables, CancellationToken.None); + + const textVariables = variables.asArray().filter(v => isPromptTextVariableEntry(v)); + assert.equal(textVariables.length, 1, 'There should be one text variable for agents list'); + + const agentsList = xmlContents(textVariables[0].value, 'agents'); + assert.equal(agentsList.length, 1, 'There should be one agents list'); + + const agents = xmlContents(agentsList[0], 'agent'); + assert.equal(agents.length, 1, 'There should be one agent'); + + assert.equal(xmlContents(agents[0], 'description')[0], 'Test agent'); + assert.equal(xmlContents(agents[0], 'name')[0], `test-agent`); + }); + + test('should include skills list when readFile tool available', async () => { + const rootFolderName = 'skills-list-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Enable the config for agent skills + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.claude/skills/javascript/SKILL.md`, + contents: [ + '---', + 'name: \'javascript\'', + 'description: \'JavaScript best practices\'', + '---', + 'JavaScript skill content', + ] + }, + { + path: `${rootFolder}/.claude/skills/typescript/SKILL.md`, + contents: [ + '---', + 'name: \'typescript\'', + '---', + 'TypeScript skill content', + ] + }, + ]); + + const contextComputer = instaService.createInstance( + ComputeAutomaticInstructions, + ChatMode.Agent, + { 'vscode_readFile': true }, // Enable readFile tool + undefined + ); + const variables = new ChatRequestVariableSet(); + + await contextComputer.collect(variables, CancellationToken.None); + + const textVariables = variables.asArray().filter(v => isPromptTextVariableEntry(v)); + assert.equal(textVariables.length, 1, 'There should be one text variable for skills list'); + + const skillsList = xmlContents(textVariables[0].value, 'skills'); + assert.equal(skillsList.length, 1, 'There should be one skills list'); + + const skills = xmlContents(skillsList[0], 'skill'); + assert.equal(skills.length, 2, 'There should be two skills'); + + assert.equal(xmlContents(skills[0], 'description')[0], 'JavaScript best practices'); + assert.equal(xmlContents(skills[0], 'file')[0], getFilePath(`${rootFolder}/.claude/skills/javascript/SKILL.md`)); + assert.equal(xmlContents(skills[0], 'name')[0], 'javascript'); + + assert.equal(xmlContents(skills[1], 'description')[0], undefined); + assert.equal(xmlContents(skills[1], 'file')[0], getFilePath(`${rootFolder}/.claude/skills/typescript/SKILL.md`)); + assert.equal(xmlContents(skills[1], 'name')[0], 'typescript'); + }); + + test('should not include skills list when readFile tool unavailable', async () => { + const rootFolderName = 'no-skills-list-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Enable the config for agent skills + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/skills/javascript/SKILL.md`, + contents: [ + '---', + 'description: \'JavaScript best practices\'', + '---', + 'JavaScript skill content', + ] + }, + ]); + + const contextComputer = instaService.createInstance( + ComputeAutomaticInstructions, + ChatMode.Agent, + undefined, // No tools available + undefined + ); + const variables = new ChatRequestVariableSet(); + + await contextComputer.collect(variables, CancellationToken.None); + + const textVariables = variables.asArray().filter(v => isPromptTextVariableEntry(v)); + assert.equal(textVariables.length, 0, 'There should be no text variables when readFile tool is unavailable'); + }); + + test('should not include skills list when USE_AGENT_SKILLS disabled', async () => { + const rootFolderName = 'skills-disabled-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Disable the config for agent skills + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, false); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/skills/javascript/SKILL.md`, + contents: [ + '---', + 'description: \'JavaScript best practices\'', + '---', + 'JavaScript skill content', + ] + }, + ]); + + const contextComputer = instaService.createInstance( + ComputeAutomaticInstructions, + ChatMode.Agent, + { 'vscode_readFile': true }, // Enable readFile tool + undefined + ); + const variables = new ChatRequestVariableSet(); + + await contextComputer.collect(variables, CancellationToken.None); + + const textVariables = variables.asArray().filter(v => isPromptTextVariableEntry(v)); + assert.equal(textVariables.length, 0, 'There should be no text variables when readFile tool is unavailable'); + }); + + test('should include skills from home folder in skills list', async () => { + const rootFolderName = 'home-skills-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Enable the config for agent skills + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + // Disable workspace skills to isolate home folder skills + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, { + '.github/skills': false, + '.claude/skills': false, + }); + + await mockFiles(fileService, [ + // Home folder skills (using the mock user home /home/user) + { + path: '/home/user/.copilot/skills/personal-skill/SKILL.md', + contents: [ + '---', + 'name: \'personal-skill\'', + 'description: \'A personal skill from home folder\'', + '---', + 'Personal skill content', + ] + }, + { + path: '/home/user/.claude/skills/claude-personal/SKILL.md', + contents: [ + '---', + 'name: \'claude-personal\'', + 'description: \'A Claude personal skill\'', + '---', + 'Claude personal skill content', + ] + }, + ]); + + const contextComputer = instaService.createInstance( + ComputeAutomaticInstructions, + ChatMode.Agent, + { 'vscode_readFile': true }, // Enable readFile tool + undefined + ); + const variables = new ChatRequestVariableSet(); + + await contextComputer.collect(variables, CancellationToken.None); + + const textVariables = variables.asArray().filter(v => isPromptTextVariableEntry(v)); + const skillsList = xmlContents(textVariables[0].value, 'skills'); + assert.equal(skillsList.length, 1, 'There should be one skills list'); + + const skills = xmlContents(skillsList[0], 'skill'); + assert.equal(skills.length, 2, 'There should be two skills'); + + assert.equal(xmlContents(skills[0], 'description')[0], 'A personal skill from home folder'); + assert.equal(xmlContents(skills[0], 'file')[0], getFilePath(`/home/user/.copilot/skills/personal-skill/SKILL.md`)); + assert.equal(xmlContents(skills[0], 'name')[0], 'personal-skill'); + + assert.equal(xmlContents(skills[1], 'description')[0], 'A Claude personal skill'); + assert.equal(xmlContents(skills[1], 'file')[0], getFilePath(`/home/user/.claude/skills/claude-personal/SKILL.md`)); + assert.equal(xmlContents(skills[1], 'name')[0], 'claude-personal'); + }); + }); + + suite('edge cases', () => { + test('should handle empty workspace', async () => { + const rootFolderName = 'empty-workspace'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + + await contextComputer.collect(variables, CancellationToken.None); + + // Should not throw and should handle gracefully + assert.ok(true, 'Should handle empty workspace without errors'); + }); + + test('should handle malformed instruction files', async () => { + const rootFolderName = 'malformed-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/instructions/malformed.instructions.md`, + contents: [ + '---', + 'invalid yaml: [unclosed', + '---', + 'Content', + ] + }, + { + path: `${rootFolder}/src/file.ts`, + contents: ['code'], + }, + ]); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + // Should not throw + await contextComputer.collect(variables, CancellationToken.None); + assert.ok(true, 'Should handle malformed instruction files gracefully'); + }); + + test('should handle cancellation', async () => { + const rootFolderName = 'cancellation-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/src/file.ts`, + contents: ['code'], + }, + ]); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + // Create a cancelled token + const cancelledToken: CancellationToken = { + isCancellationRequested: true, + onCancellationRequested: Event.None + }; + + // Should handle cancellation gracefully + await contextComputer.collect(variables, cancelledToken); + assert.ok(true, 'Should handle cancellation without errors'); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 044a0f1db1a69..bdda77af18609 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -47,6 +47,7 @@ import { InMemoryStorageService, IStorageService } from '../../../../../../../pl import { IPathService } from '../../../../../../services/path/common/pathService.js'; import { IFileMatch, IFileQuery, ISearchService } from '../../../../../../services/search/common/search.js'; import { IExtensionService } from '../../../../../../services/extensions/common/extensions.js'; +import { ChatMode } from '../../../../common/chatModes.js'; suite('PromptsService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -68,6 +69,8 @@ suite('PromptsService', () => { testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, true); testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, true); testConfigService.setUserConfiguration(PromptsConfig.USE_NESTED_AGENT_MD, false); + testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_REFERENCED_INSTRUCTIONS, true); + testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true); testConfigService.setUserConfiguration(PromptsConfig.INSTRUCTIONS_LOCATION_KEY, { [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true }); testConfigService.setUserConfiguration(PromptsConfig.PROMPT_LOCATIONS_KEY, { [PROMPT_DEFAULT_SOURCE_FOLDER]: true }); testConfigService.setUserConfiguration(PromptsConfig.MODE_LOCATION_KEY, { [LEGACY_MODE_DEFAULT_SOURCE_FOLDER]: true }); @@ -461,7 +464,7 @@ suite('PromptsService', () => { ]); const instructionFiles = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); const context = { files: new ResourceSet([ URI.joinPath(rootFolderUri, 'folder1/main.tsx'), @@ -632,7 +635,7 @@ suite('PromptsService', () => { ]); const instructionFiles = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); const context = { files: new ResourceSet([ URI.joinPath(rootFolderUri, 'folder1/main.tsx'), @@ -706,7 +709,7 @@ suite('PromptsService', () => { ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); const context = new ChatRequestVariableSet(); context.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'README.md'))); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptsServiceUtils.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptsServiceUtils.test.ts new file mode 100644 index 0000000000000..58e9f47da29ec --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptsServiceUtils.test.ts @@ -0,0 +1,123 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { ExtensionIdentifier } from '../../../../../../../platform/extensions/common/extensions.js'; +import { IProductService } from '../../../../../../../platform/product/common/productService.js'; +import { isOrganizationPromptFile } from '../../../../common/promptSyntax/utils/promptsServiceUtils.js'; +import { mockService } from './mock.js'; + +suite('promptsServiceUtils', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('isOrganizationPromptFile', () => { + const CHAT_EXTENSION_ID = 'github.copilot-chat'; + + function createProductService(chatExtensionId: string | undefined): IProductService { + return mockService({ + defaultChatAgent: chatExtensionId ? { chatExtensionId } : undefined, + } as Partial); + } + + test('returns false when no chatExtensionId is configured', () => { + const uri = URI.file('/some/path/github/prompt.md'); + const extensionId = new ExtensionIdentifier(CHAT_EXTENSION_ID); + const productService = createProductService(undefined); + + assert.strictEqual( + isOrganizationPromptFile(uri, extensionId, productService), + false, + 'Should return false when chatExtensionId is not configured', + ); + }); + + test('returns false when extension ID does not match', () => { + const uri = URI.file('/some/path/github/prompt.md'); + const extensionId = new ExtensionIdentifier('some.other-extension'); + const productService = createProductService(CHAT_EXTENSION_ID); + + assert.strictEqual( + isOrganizationPromptFile(uri, extensionId, productService), + false, + 'Should return false when extension ID does not match the built-in chat extension', + ); + }); + + test('returns false when path does not contain /github/', () => { + const uri = URI.file('/some/path/to/prompt.md'); + const extensionId = new ExtensionIdentifier(CHAT_EXTENSION_ID); + const productService = createProductService(CHAT_EXTENSION_ID); + + assert.strictEqual( + isOrganizationPromptFile(uri, extensionId, productService), + false, + 'Should return false when path does not contain /github/', + ); + }); + + test('returns true when extension matches and path contains /github/', () => { + const uri = URI.file('/some/path/github/prompts/prompt.md'); + const extensionId = new ExtensionIdentifier(CHAT_EXTENSION_ID); + const productService = createProductService(CHAT_EXTENSION_ID); + + assert.strictEqual( + isOrganizationPromptFile(uri, extensionId, productService), + true, + 'Should return true when extension matches and path contains /github/', + ); + }); + + test('extension ID comparison is case-insensitive', () => { + const uri = URI.file('/some/github/prompt.md'); + const extensionId = new ExtensionIdentifier('GITHUB.COPILOT-CHAT'); + const productService = createProductService('github.copilot-chat'); + + assert.strictEqual( + isOrganizationPromptFile(uri, extensionId, productService), + true, + 'Extension ID comparison should be case-insensitive', + ); + }); + + test('returns false when defaultChatAgent exists but chatExtensionId is empty', () => { + const uri = URI.file('/some/github/prompt.md'); + const extensionId = new ExtensionIdentifier(CHAT_EXTENSION_ID); + const productService = mockService({ + defaultChatAgent: { chatExtensionId: '' }, + } as Partial); + + assert.strictEqual( + isOrganizationPromptFile(uri, extensionId, productService), + false, + 'Should return false when chatExtensionId is empty string', + ); + }); + + test('returns false for similar but incorrect paths', () => { + const extensionId = new ExtensionIdentifier(CHAT_EXTENSION_ID); + const productService = createProductService(CHAT_EXTENSION_ID); + + const invalidPaths = [ + '/some/githubs/prompt.md', // extra 's' + '/some/github-org/prompt.md', // hyphenated + '/some/mygithub/prompt.md', // prefix + '/some/githubstuff/prompt.md', // suffix + '/some/GITHUB/prompt.md', // uppercase (path matching is case-sensitive) + '/some/Github/prompt.md', // mixed case + ]; + + for (const path of invalidPaths) { + const uri = URI.file(path); + assert.strictEqual( + isOrganizationPromptFile(uri, extensionId, productService), + false, + `Should return false for path: ${path}`, + ); + } + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts index 73df30cb679d9..bd8095a8d5324 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts @@ -7,14 +7,15 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { Codicon } from '../../../../../../base/common/codicons.js'; import { Event } from '../../../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../../../base/common/lifecycle.js'; -import { constObservable, IObservable } from '../../../../../../base/common/observable.js'; +import { constObservable, IObservable, IReader } from '../../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { IProgressStep } from '../../../../../../platform/progress/common/progress.js'; +import { ChatRequestToolReferenceEntry } from '../../../common/attachments/chatVariableEntries.js'; import { IVariableReference } from '../../../common/chatModes.js'; import { IChatToolInvocation } from '../../../common/chatService/chatService.js'; -import { ChatRequestToolReferenceEntry } from '../../../common/attachments/chatVariableEntries.js'; -import { CountTokensCallback, IBeginToolCallOptions, ILanguageModelToolsService, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolResult, ToolDataSource, ToolSet } from '../../../common/tools/languageModelToolsService.js'; +import { CountTokensCallback, IBeginToolCallOptions, ILanguageModelToolsService, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolResult, IToolSet, ToolDataSource, ToolSet } from '../../../common/tools/languageModelToolsService.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { ILanguageModelChatMetadata } from '../../../common/languageModels.js'; export class MockLanguageModelToolsService implements ILanguageModelToolsService { _serviceBrand: undefined; @@ -73,13 +74,19 @@ export class MockLanguageModelToolsService implements ILanguageModelToolsService return []; } - toolsObservable: IObservable = constObservable([]); + getAllToolsIncludingDisabled(): Iterable { + return []; + } getTool(id: string): IToolData | undefined { return undefined; } - getToolByName(name: string, includeDisabled?: boolean): IToolData | undefined { + observeTools(): IObservable { + return constObservable([]); + } + + getToolByName(name: string): IToolData | undefined { return undefined; } @@ -102,13 +109,17 @@ export class MockLanguageModelToolsService implements ILanguageModelToolsService // Mock implementation - do nothing } - toolSets: IObservable = constObservable([]); + toolSets: IObservable = constObservable([]); + + getToolSetsForModel(model: ILanguageModelChatMetadata | undefined, reader?: IReader): Iterable { + return []; + } - getToolSetByName(name: string): ToolSet | undefined { + getToolSetByName(name: string): IToolSet | undefined { return undefined; } - getToolSet(id: string): ToolSet | undefined { + getToolSet(id: string): IToolSet | undefined { return undefined; } @@ -128,11 +139,11 @@ export class MockLanguageModelToolsService implements ILanguageModelToolsService throw new Error('Method not implemented.'); } - getToolByFullReferenceName(qualifiedName: string): IToolData | ToolSet | undefined { + getToolByFullReferenceName(qualifiedName: string): IToolData | IToolSet | undefined { throw new Error('Method not implemented.'); } - getFullReferenceName(tool: IToolData, set?: ToolSet): string { + getFullReferenceName(tool: IToolData, set?: IToolSet): string { throw new Error('Method not implemented.'); } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index f31aa61261054..8ffc6fbb64e0e 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -33,6 +33,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; import { ISharedWebContentExtractorService } from '../../../../platform/webContentExtractor/common/webContentExtractor.js'; import { IEditorService, SIDE_GROUP } from '../../../services/editor/common/editorService.js'; @@ -106,8 +107,6 @@ export class InlineChatController implements IEditorContribution { return editor.getContribution(InlineChatController.ID) ?? undefined; } - private static _selectVendorDefaultLanguageModel: boolean = true; - private readonly _store = new DisposableStore(); private readonly _isActiveController = observableValue(this, false); private readonly _renderMode: IObservable<'zone' | 'hover'>; @@ -138,6 +137,7 @@ export class InlineChatController implements IEditorContribution { @IEditorService private readonly _editorService: IEditorService, @IMarkerDecorationsService private readonly _markerDecorationsService: IMarkerDecorationsService, @ILanguageModelsService private readonly _languageModelService: ILanguageModelsService, + @ILogService private readonly _logService: ILogService, ) { const editorObs = observableCodeEditor(_editor); @@ -471,26 +471,12 @@ export class InlineChatController implements IEditorContribution { this._isActiveController.set(true, undefined); const session = this._inlineChatSessionService.createSession(this._editor); - const store = new DisposableStore(); - - // fallback to the default model of the selected vendor unless an explicit selection was made for the session - // or unless the user has chosen to persist their model choice - const persistModelChoice = this._configurationService.getValue(InlineChatConfigKeys.PersistModelChoice); - const model = this._zone.value.widget.chatWidget.input.selectedLanguageModel; - if (!persistModelChoice && InlineChatController._selectVendorDefaultLanguageModel && model && !model.metadata.isDefaultForLocation[session.chatModel.initialLocation]) { - const ids = await this._languageModelService.selectLanguageModels({ vendor: model.metadata.vendor }); - for (const identifier of ids) { - const candidate = this._languageModelService.lookupLanguageModel(identifier); - if (candidate?.isDefaultForLocation[session.chatModel.initialLocation]) { - this._zone.value.widget.chatWidget.input.setCurrentLanguageModel({ metadata: candidate, identifier }); - break; - } - } - } - store.add(this._zone.value.widget.chatWidget.input.onDidChangeCurrentLanguageModel(newModel => { - InlineChatController._selectVendorDefaultLanguageModel = Boolean(newModel.metadata.isDefaultForLocation[session.chatModel.initialLocation]); - })); + // Check for default model setting + const defaultModelSetting = this._configurationService.getValue(InlineChatConfigKeys.DefaultModel); + if (defaultModelSetting && !this._zone.value.widget.chatWidget.input.switchModelByQualifiedName(defaultModelSetting)) { + this._logService.warn(`inlineChat.defaultModel setting value '${defaultModelSetting}' did not match any available model. Falling back to vendor default.`); + } // ADD diagnostics const entries: IChatRequestVariableEntry[] = []; @@ -543,25 +529,20 @@ export class InlineChatController implements IEditorContribution { } } - try { - if (!arg?.resolveOnResponse) { - // DEFAULT: wait for the session to be accepted or rejected - await Event.toPromise(session.editingSession.onDidDispose); - const rejected = session.editingSession.getEntry(uri)?.state.get() === ModifiedFileEntryState.Rejected; - return !rejected; + if (!arg?.resolveOnResponse) { + // DEFAULT: wait for the session to be accepted or rejected + await Event.toPromise(session.editingSession.onDidDispose); + const rejected = session.editingSession.getEntry(uri)?.state.get() === ModifiedFileEntryState.Rejected; + return !rejected; - } else { - // resolveOnResponse: ONLY wait for the file to be modified - const modifiedObs = derived(r => { - const entry = session.editingSession.readEntry(uri, r); - return entry?.state.read(r) === ModifiedFileEntryState.Modified && !entry?.isCurrentlyBeingModifiedBy.read(r); - }); - await waitForState(modifiedObs, state => state === true); - return true; - } - - } finally { - store.dispose(); + } else { + // resolveOnResponse: ONLY wait for the file to be modified + const modifiedObs = derived(r => { + const entry = session.editingSession.readEntry(uri, r); + return entry?.state.read(r) === ModifiedFileEntryState.Modified && !entry?.isCurrentlyBeingModifiedBy.read(r); + }); + await waitForState(modifiedObs, state => state === true); + return true; } } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts index 84e873083dcd6..7c8f5a2933037 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts @@ -7,7 +7,6 @@ import './media/inlineChatSessionOverlay.css'; import * as dom from '../../../../base/browser/dom.js'; import { renderAsPlaintext } from '../../../../base/browser/markdownRenderer.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; -import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { IAction, Separator } from '../../../../base/common/actions.js'; import { ActionBar, ActionsOrientation } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { Codicon } from '../../../../base/common/codicons.js'; @@ -251,6 +250,9 @@ export class InlineChatInputWidget extends Disposable { if (this._anchorAbove) { const widgetHeight = this._domNode.offsetHeight; adjustedTop = top - widgetHeight; + } else { + const lineHeight = editor.getOption(EditorOption.lineHeight); + adjustedTop = top + lineHeight; } this._position.set({ @@ -304,12 +306,12 @@ export class InlineChatSessionOverlayWidget extends Disposable { private readonly _domNode: HTMLElement = document.createElement('div'); private readonly _container: HTMLElement; - private readonly _progressNode: HTMLElement; - private readonly _progressMessage: HTMLElement; + private readonly _statusNode: HTMLElement; + private readonly _icon: HTMLElement; + private readonly _message: HTMLElement; private readonly _toolbarNode: HTMLElement; private readonly _showStore = this._store.add(new DisposableStore()); - private readonly _session = observableValue(this, undefined); private readonly _position = observableValue(this, null); constructor( @@ -323,20 +325,27 @@ export class InlineChatSessionOverlayWidget extends Disposable { this._domNode.appendChild(this._container); this._container.classList.add('inline-chat-session-overlay-widget'); - // Create progress node - this._progressNode = document.createElement('div'); - this._progressNode.classList.add('progress'); - dom.append(this._progressNode, renderIcon(ThemeIcon.modify(Codicon.loading, 'spin'))); - this._progressMessage = dom.append(this._progressNode, dom.$('span.progress-message')); - this._container.appendChild(this._progressNode); + // Create status node with icon and message + this._statusNode = document.createElement('div'); + this._statusNode.classList.add('status'); + this._icon = dom.append(this._statusNode, dom.$('span')); + this._message = dom.append(this._statusNode, dom.$('span.message')); + this._container.appendChild(this._statusNode); // Create toolbar node this._toolbarNode = document.createElement('div'); this._toolbarNode.classList.add('toolbar'); + } - // Set up progress message observable + show(session: IInlineChatSession2): void { + assertType(this._editorObs.editor.hasModel()); + this._showStore.clear(); + + // Derived entry observable for this session + const entry = derived(r => session.editingSession.readEntry(session.uri, r)); + + // Set up status message observable const requestMessage = derived(r => { - const session = this._session.read(r); const chatModel = session?.chatModel; if (!session || !chatModel) { return undefined; @@ -347,6 +356,17 @@ export class InlineChatSessionOverlayWidget extends Disposable { return { message: localize('working', "Working...") }; } + if (response.isComplete) { + const changes = entry.read(r)?.changesCount.read(r) ?? 0; + return { + message: changes === 0 + ? localize('done', "Done") + : changes === 1 + ? localize('done1', "Done, 1 change") + : localize('doneN', "Done, {0} changes", changes) + }; + } + const lastPart = observableFromEventOpts({ equalsFn: () => false }, response.onDidChange, () => response.response.value) .read(r) .filter(part => part.kind === 'progressMessage' || part.kind === 'toolInvocation') @@ -361,30 +381,24 @@ export class InlineChatSessionOverlayWidget extends Disposable { } }); - this._store.add(autorun(r => { + this._showStore.add(autorun(r => { const value = requestMessage.read(r); if (value) { - this._progressMessage.innerText = renderAsPlaintext(value.message); + this._message.innerText = renderAsPlaintext(value.message); } else { - this._progressMessage.innerText = ''; + this._message.innerText = ''; } })); - } - show(session: IInlineChatSession2): void { - assertType(this._editorObs.editor.hasModel()); - this._showStore.clear(); - - this._session.set(session, undefined); - - // Derived entry observable for this session - const entry = derived(r => session.editingSession.readEntry(session.uri, r)); - - // Keep busy class in sync with whether edits are being streamed + // Keep active class in sync with whether edits are being streamed or done this._showStore.add(autorun(r => { const e = entry.read(r); const isBusy = !e || !!e.isCurrentlyBeingModifiedBy.read(r); - this._container.classList.toggle('busy', isBusy); + const isDone = e?.lastModifyingResponse.read(r)?.isComplete; + this._container.classList.toggle('active', isBusy || isDone); + + this._icon.className = ''; + this._icon.classList.add(...ThemeIcon.asClassNameArray(isDone ? Codicon.check : ThemeIcon.modify(Codicon.loading, 'spin'))); })); // Add toolbar @@ -418,19 +432,25 @@ export class InlineChatSessionOverlayWidget extends Disposable { const above = selection.getDirection() === SelectionDirection.RTL; this._showStore.add(autorun(r => { - let newPosition = selection.getPosition(); const e = entry.read(r); const diffInfo = e?.diffInfo?.read(r); - const position = that._position.read(undefined)?.position; - if (diffInfo && position) { + + // Build combined range from selection and all diff changes + let startLine = selection.startLineNumber; + let endLineExclusive = selection.endLineNumber + 1; + + if (diffInfo) { for (const change of diffInfo.changes) { - if (change.modified.contains(position.lineNumber)) { - newPosition = new Position(change.modified.startLineNumber - 1, 1); - break; - } + startLine = Math.min(startLine, change.modified.startLineNumber); + endLineExclusive = Math.max(endLineExclusive, change.modified.endLineNumberExclusive); } } + // Position at start (above) or end (below) of the combined range + const newPosition = above + ? new Position(startLine, 1) + : new Position(endLineExclusive - 1, 1); + this._position.set({ position: newPosition, preference: [above ? ContentWidgetPositionPreference.ABOVE : ContentWidgetPositionPreference.BELOW] @@ -447,7 +467,6 @@ export class InlineChatSessionOverlayWidget extends Disposable { hide(): void { this._position.set(null, undefined); - this._session.set(undefined, undefined); this._showStore.clear(); } } diff --git a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatSessionOverlay.css b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatSessionOverlay.css index 8b0c5a4a12a0a..b9f1aa17dd6b3 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatSessionOverlay.css +++ b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatSessionOverlay.css @@ -18,23 +18,7 @@ overflow: hidden; } -@keyframes inline-chat-session-pulse { - 0% { - box-shadow: 0 2px 8px 0 var(--vscode-widget-shadow); - } - 50% { - box-shadow: 0 2px 8px 4px var(--vscode-widget-shadow); - } - 100% { - box-shadow: 0 2px 8px 0 var(--vscode-widget-shadow); - } -} - -.inline-chat-session-overlay-widget.busy { - animation: inline-chat-session-pulse ease-in 2.3s infinite; -} - -.inline-chat-session-overlay-widget .progress { +.inline-chat-session-overlay-widget .status { align-items: center; display: none; padding: 5px 0 5px 5px; @@ -43,22 +27,22 @@ gap: 6px; } -.inline-chat-session-overlay-widget.busy .progress { +.inline-chat-session-overlay-widget.active .status { display: inline-flex; } -.inline-chat-session-overlay-widget .progress .progress-message { +.inline-chat-session-overlay-widget .status .message { white-space: nowrap; max-width: 13em; overflow: hidden; text-overflow: ellipsis; } -.inline-chat-session-overlay-widget .progress .progress-message:not(:empty) { +.inline-chat-session-overlay-widget .status .message:not(:empty) { padding-right: 2em; } -.inline-chat-session-overlay-widget.busy .progress .codicon { +.inline-chat-session-overlay-widget .status .codicon { color: var(--vscode-foreground); } diff --git a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts index a9ce341d53c2b..51928e5baff90 100644 --- a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts +++ b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts @@ -19,7 +19,7 @@ export const enum InlineChatConfigKeys { /** @deprecated do not read on client */ EnableV2 = 'inlineChat.enableV2', notebookAgent = 'inlineChat.notebookAgent', - PersistModelChoice = 'inlineChat.persistModelChoice', + DefaultModel = 'inlineChat.defaultModel', Affordance = 'inlineChat.affordance', RenderMode = 'inlineChat.renderMode', } @@ -55,13 +55,10 @@ Registry.as(Extensions.Configuration).registerConfigurat mode: 'startup' } }, - [InlineChatConfigKeys.PersistModelChoice]: { - description: localize('persistModelChoice', "Whether to persist the selected language model choice across inline chat sessions. The default is not to persist and to use the vendor's default model for inline chat because that yields the best experience."), - default: false, - type: 'boolean', - experiment: { - mode: 'auto' - } + [InlineChatConfigKeys.DefaultModel]: { + markdownDescription: localize('defaultModel', "The default model to use for inline chat. The value can be the model's qualified name (e.g., `{0}`) or the model name for Copilot models (e.g., `{1}`). If not set, the vendor's default model for inline chat is used.", 'GPT-4o (copilot)', 'GPT-4o'), + default: '', + type: 'string' }, [InlineChatConfigKeys.Affordance]: { description: localize('affordance', "Controls whether an inline chat affordance is shown when text is selected."), diff --git a/src/vs/workbench/contrib/inlineCompletions/browser/renameSymbolTrackerService.ts b/src/vs/workbench/contrib/inlineCompletions/browser/renameSymbolTrackerService.ts new file mode 100644 index 0000000000000..22466135414a3 --- /dev/null +++ b/src/vs/workbench/contrib/inlineCompletions/browser/renameSymbolTrackerService.ts @@ -0,0 +1,315 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, IObservable, observableValue } from '../../../../base/common/observable.js'; +import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; +import { observableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js'; +import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; +import { IRenameSymbolTrackerService, type ITrackedWord } from '../../../../editor/browser/services/renameSymbolTrackerService.js'; +import { Position } from '../../../../editor/common/core/position.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { StandardTokenType } from '../../../../editor/common/encodedTokenAttributes.js'; +import { ITextModel } from '../../../../editor/common/model.js'; +import { IModelContentChangedEvent } from '../../../../editor/common/textModelEvents.js'; +import { TextModelEditSource } from '../../../../editor/common/textModelEditSource.js'; +import { IModelService } from '../../../../editor/common/services/model.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; + +/** + * Checks if a model content change event was caused only by typing or pasting. + * Returns false for AI edits, refactorings, undo/redo, etc. + */ +function isTypingOrPasteEdit(event: IModelContentChangedEvent): boolean { + if (event.isUndoing || event.isRedoing || event.isFlush) { + return false; + } + + for (const source of event.detailedReasons) { + if (!isTypingOrPasteSource(source)) { + return false; + } + } + + return event.detailedReasons.length > 0; +} + +function isTypingOrPasteSource(source: TextModelEditSource): boolean { + const metadata = source.metadata; + if (metadata.source !== 'cursor') { + return false; + } + const kind = (metadata as { kind?: string }).kind; + return kind === 'type' || kind === 'paste' || kind === 'compositionType' || kind === 'compositionEnd'; +} + +type WordState = { + word: string; + range: Range; + position: Position; +}; + +/** + * Tracks symbol edits for a single ITextModel. + * + * Receives cursor position updates from external sources (e.g., focused code editors). + * Only tracks edits done by typing or paste. Resets when: + * - A non-typing/paste edit occurs (AI, refactoring, undo/redo, etc.) + */ +class ModelSymbolRenameTracker extends Disposable { + private readonly _trackedWord = observableValue(this, undefined); + public readonly trackedWord: IObservable = this._trackedWord; + + private _capturedWord: WordState | undefined = undefined; + private _lastWordBeforeEdit: WordState | undefined = undefined; + private _pendingContentChange: boolean = false; + private _lastCursorPosition: Position | undefined = undefined; + + constructor( + private readonly _model: ITextModel + ) { + super(); + + // Listen to content changes - only reset on non-typing/paste edits + this._register(this._model.onDidChangeContent(e => { + if (!isTypingOrPasteEdit(e)) { + // Non-typing/paste edit occurred - reset tracking and start a new + // rename tracking at the current cursor position (if any) + const position = this._lastCursorPosition; + this.reset(); + if (position !== undefined) { + this.updateCursorPosition(position); + } + return; + } + // Valid typing/paste edit - mark that content changed, cursor update will handle tracking + this._pendingContentChange = true; + })); + } + + /** + * Called by the service when the cursor position changes in an editor showing this model. + * Updates tracking based on the word under cursor and whether content has changed. + */ + public updateCursorPosition(position: Position): void { + this._lastCursorPosition = position; + const wordAtPosition = this._model.getWordAtPosition(position); + if (!wordAtPosition) { + // Not on a word - just clear lastWordBeforeEdit + this._lastWordBeforeEdit = undefined; + this._pendingContentChange = false; + return; + } + + // Check if the position is in a comment + if (this._isPositionInComment(position)) { + this._lastWordBeforeEdit = undefined; + this._pendingContentChange = false; + return; + } + + const currentWord: WordState = { + word: wordAtPosition.word, + range: new Range( + position.lineNumber, + wordAtPosition.startColumn, + position.lineNumber, + wordAtPosition.endColumn + ), + position + }; + + const contentChanged = this._pendingContentChange; + this._pendingContentChange = false; + + if (!contentChanged) { + // Just cursor movement - remember this word for later + this._lastWordBeforeEdit = currentWord; + return; + } + + // Content changed - update tracking + if (!this._capturedWord) { + // First edit on a word - use the word from before the edit as original + const originalWord = this._lastWordBeforeEdit ?? currentWord; + this._capturedWord = { ...originalWord }; + this._trackedWord.set({ + model: this._model, + originalWord: originalWord.word, + originalPosition: originalWord.position, + originalRange: originalWord.range, + currentWord: currentWord.word, + currentRange: currentWord.range, + }, undefined); + this._lastWordBeforeEdit = currentWord; + return; + } + + const capturedWord = this._capturedWord; + // Check if we're still on the same word (by position overlap or adjacency) + const isOnSameWord = this._rangesOverlap(capturedWord.range, currentWord.range) || + this._isAdjacent(capturedWord.range, currentWord.range); + + if (isOnSameWord) { + // Word has been edited - update the tracked word + this._trackedWord.set({ + model: this._model, + originalWord: capturedWord.word, + originalPosition: capturedWord.position, + originalRange: capturedWord.range, + currentWord: currentWord.word, + currentRange: currentWord.range, + }, undefined); + } else { + // User started typing in a different word - use the word from before the edit as original + const originalWord = this._lastWordBeforeEdit ?? currentWord; + this._capturedWord = { ...originalWord }; + this._trackedWord.set({ + model: this._model, + originalWord: originalWord.word, + originalPosition: originalWord.position, + originalRange: originalWord.range, + currentWord: currentWord.word, + currentRange: currentWord.range, + }, undefined); + } + // Update lastWordBeforeEdit for the next iteration + this._lastWordBeforeEdit = currentWord; + } + + private reset(): void { + this._trackedWord.set(undefined, undefined); + this._capturedWord = undefined; + this._lastWordBeforeEdit = undefined; + this._pendingContentChange = false; + this._lastCursorPosition = undefined; + } + + private _isPositionInComment(position: Position): boolean { + this._model.tokenization.tokenizeIfCheap(position.lineNumber); + const tokens = this._model.tokenization.getLineTokens(position.lineNumber); + const tokenIndex = tokens.findTokenIndexAtOffset(position.column - 1); + const tokenType = tokens.getStandardTokenType(tokenIndex); + return tokenType === StandardTokenType.Comment; + } + + private _rangesOverlap(a: Range, b: Range): boolean { + if (a.startLineNumber !== b.startLineNumber) { + return false; + } + return !(a.endColumn < b.startColumn || b.endColumn < a.startColumn); + } + + private _isAdjacent(a: Range, b: Range): boolean { + if (a.startLineNumber !== b.startLineNumber) { + return false; + } + return a.endColumn === b.startColumn || b.endColumn === a.startColumn; + } +} + +class RenameSymbolTrackerService extends Disposable implements IRenameSymbolTrackerService { + public _serviceBrand: undefined; + + private readonly _modelTrackers = new Map(); + private readonly _editorFocusTrackingDisposables = new Map(); + + private readonly _focusedModelTracker = observableValue(this, undefined); + + public readonly trackedWord: IObservable = derived(this, reader => { + const tracker = this._focusedModelTracker.read(reader); + return tracker?.trackedWord.read(reader); + }); + + constructor( + @ICodeEditorService private readonly _codeEditorService: ICodeEditorService, + @IModelService private readonly _modelService: IModelService + ) { + super(); + + // Setup tracking for existing editors + for (const editor of this._codeEditorService.listCodeEditors()) { + this._setupEditorTracking(editor); + } + + // Track editor additions + this._register(this._codeEditorService.onCodeEditorAdd(editor => { + this._setupEditorTracking(editor); + })); + + // Clean up editor focus tracking when editors are removed + this._register(this._codeEditorService.onCodeEditorRemove(editor => { + const focusDisposable = this._editorFocusTrackingDisposables.get(editor); + if (focusDisposable) { + focusDisposable.dispose(); + this._editorFocusTrackingDisposables.delete(editor); + } + })); + + // Clean up model trackers when models are removed + this._register(this._modelService.onModelRemoved(model => { + const tracker = this._modelTrackers.get(model); + if (tracker) { + tracker.dispose(); + this._modelTrackers.delete(model); + } + })); + } + + private _setupEditorTracking(editor: ICodeEditor): void { + if (editor.isSimpleWidget) { + return; + } + + // Setup focus and cursor tracking + if (!this._editorFocusTrackingDisposables.has(editor)) { + const obsEditor = observableCodeEditor(editor); + + const focusDisposable = autorun(reader => { + /** @description track focused editor and forward cursor to model tracker */ + const isFocused = obsEditor.isFocused.read(reader); + const model = obsEditor.model.read(reader); + const cursorPosition = obsEditor.cursorPosition.read(reader); + + if (!isFocused || !model) { + return; + } + + // Ensure we have a tracker for this model + let tracker = this._modelTrackers.get(model); + if (!tracker) { + tracker = new ModelSymbolRenameTracker(model); + this._modelTrackers.set(model, tracker); + } + + // Update the focused tracker + if (this._focusedModelTracker.read(undefined) !== tracker) { + this._focusedModelTracker.set(tracker, undefined); + } + + // Forward cursor position to the model tracker + if (cursorPosition) { + tracker.updateCursorPosition(cursorPosition); + } + }); + + this._editorFocusTrackingDisposables.set(editor, focusDisposable); + } + } + + override dispose(): void { + for (const tracker of this._modelTrackers.values()) { + tracker.dispose(); + } + this._modelTrackers.clear(); + for (const disposable of this._editorFocusTrackingDisposables.values()) { + disposable.dispose(); + } + this._editorFocusTrackingDisposables.clear(); + super.dispose(); + } +} + +registerSingleton(IRenameSymbolTrackerService, RenameSymbolTrackerService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts index 5d106875bc5d9..d08f85abbe234 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts @@ -151,7 +151,7 @@ export class TerminalChatWidget extends Disposable { this._inlineChatWidget.onDidChangeHeight, this._instance.onDimensionsChanged, this._inlineChatWidget.chatWidget.onDidChangeContentHeight, - this._inlineChatWidget.chatWidget.input.onDidChangeCurrentLanguageModel, + Event.fromObservableLight(this._inlineChatWidget.chatWidget.input.selectedLanguageModel), Event.debounce(this._xterm.raw.onCursorMove, () => void 0, MicrotaskDelay), )(() => this._relayout())); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/alternativeRecommendation.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/alternativeRecommendation.ts index da3f398000909..1f6c62232b297 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/alternativeRecommendation.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/alternativeRecommendation.ts @@ -27,7 +27,7 @@ const terminalCommands: { commands: RegExp[]; tags: string[] }[] = [ ]; export function getRecommendedToolsOverRunInTerminal(commandLine: string, languageModelToolsService: ILanguageModelToolsService): string | undefined { - const tools = languageModelToolsService.getTools(); + const tools = languageModelToolsService.getTools(undefined); if (!tools || previouslyRecommededInSession) { return; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 45aa0be265b1a..98a317bf40820 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -943,11 +943,17 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } resultText.push(terminalResult); + const isError = exitCode !== undefined && exitCode !== 0; return { toolResultMessage, toolMetadata: { exitCode: exitCode }, + toolResultDetails: isError ? { + input: command, + output: [{ type: 'embed', isText: true, value: terminalResult }], + isError: true + } : undefined, content: [{ kind: 'text', value: resultText.join(''), diff --git a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts index 95c45536910a7..53dbf3f2ed3d4 100644 --- a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts +++ b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts @@ -68,6 +68,7 @@ export class AgentSessionsWelcomePage extends EditorPane { private chatModelRef: IReference | undefined; private sessionsControl: AgentSessionsControl | undefined; private sessionsControlContainer: HTMLElement | undefined; + private sessionsLoadingContainer: HTMLElement | undefined; private readonly sessionsControlDisposables = this._register(new DisposableStore()); private readonly contentDisposables = this._register(new DisposableStore()); private contextService: IContextKeyService; @@ -379,6 +380,7 @@ export class AgentSessionsWelcomePage extends EditorPane { // Clear previous sessions control this.sessionsControlDisposables.clear(); this.sessionsControl = undefined; + this.sessionsLoadingContainer = undefined; const sessions = this.agentSessionsService.model.sessions; @@ -387,9 +389,44 @@ export class AgentSessionsWelcomePage extends EditorPane { } } + private buildLoadingSkeleton(container: HTMLElement): HTMLElement { + const loadingContainer = append(container, $('.agentSessionsWelcome-sessionsLoading', { + 'role': 'status', + 'aria-busy': 'true', + 'aria-label': localize('loadingSessions', "Loading sessions...") + })); + + // Create skeleton items to match MAX_SESSIONS (6 items, arranged in 2 columns) + for (let i = 0; i < MAX_SESSIONS; i++) { + const skeleton = append(loadingContainer, $('.agentSessionsWelcome-sessionSkeleton', { 'aria-hidden': 'true' })); + append(skeleton, $('.agentSessionsWelcome-sessionSkeleton-icon')); + const content = append(skeleton, $('.agentSessionsWelcome-sessionSkeleton-content')); + append(content, $('.agentSessionsWelcome-sessionSkeleton-title')); + append(content, $('.agentSessionsWelcome-sessionSkeleton-description')); + } + + return loadingContainer; + } + + private hideLoadingSkeleton(): void { + // Hide loading skeleton and show the sessions control + if (this.sessionsLoadingContainer) { + this.sessionsLoadingContainer.style.display = 'none'; + } + if (this.sessionsControlContainer) { + this.sessionsControlContainer.style.display = ''; + this.layoutSessionsControl(); + } + } + private buildSessionsGrid(container: HTMLElement, _sessions: IAgentSession[]): void { + // Show loading skeleton initially + this.sessionsLoadingContainer = this.buildLoadingSkeleton(container); + this.sessionsControlContainer = append(container, $('.agentSessionsWelcome-sessionsGrid')); + // Hide the control initially until loading completes + this.sessionsControlContainer.style.display = 'none'; // Create a filter that limits results and excludes archived sessions const onDidChangeEmitter = this.sessionsControlDisposables.add(new Emitter()); @@ -423,6 +460,15 @@ export class AgentSessionsWelcomePage extends EditorPane { options )); + // Listen for loading state changes to toggle skeleton visibility + this.sessionsControlDisposables.add(this.agentSessionsService.model.onDidResolve(() => { + this.hideLoadingSkeleton(); + })); + + if (this.agentSessionsService.model.resolved) { + this.hideLoadingSkeleton(); + } + // Schedule layout at next animation frame to ensure proper rendering this.sessionsControlDisposables.add(scheduleAtNextAnimationFrame(getWindow(this.sessionsControlContainer), () => { this.layoutSessionsControl(); diff --git a/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css b/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css index 2309a4ba97d57..3252394babd9f 100644 --- a/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css +++ b/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css @@ -380,3 +380,63 @@ .agentSessionsWelcome-tosLink:hover { text-decoration: underline; } + +/* Loading skeleton */ +.agentSessionsWelcome-sessionsLoading { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + width: 100%; + margin-bottom: 12px; +} + +.agentSessionsWelcome-sessionSkeleton { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + border-radius: 4px; + height: 36px; + box-sizing: border-box; +} + +.agentSessionsWelcome-sessionSkeleton-icon { + width: 20px; + height: 20px; + border-radius: 4px; + background: var(--vscode-welcomePage-tileBorder, var(--vscode-contrastBorder, rgba(128, 128, 128, 0.2))); + animation: agentSessionsWelcome-shimmer 1.5s ease-in-out infinite; +} + +.agentSessionsWelcome-sessionSkeleton-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; +} + +.agentSessionsWelcome-sessionSkeleton-title { + height: 12px; + border-radius: 2px; + background: var(--vscode-welcomePage-tileBorder, var(--vscode-contrastBorder, rgba(128, 128, 128, 0.2))); + animation: agentSessionsWelcome-shimmer 1.5s ease-in-out infinite; + animation-delay: 0.1s; +} + +.agentSessionsWelcome-sessionSkeleton-description { + height: 10px; + width: 60%; + border-radius: 2px; + background: var(--vscode-welcomePage-tileBorder, var(--vscode-contrastBorder, rgba(128, 128, 128, 0.2))); + animation: agentSessionsWelcome-shimmer 1.5s ease-in-out infinite; + animation-delay: 0.2s; +} + +@keyframes agentSessionsWelcome-shimmer { + 0%, 100% { + opacity: 0.3; + } + 50% { + opacity: 0.6; + } +} diff --git a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts index 304b7bb114766..0b7630309b9ab 100644 --- a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts +++ b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts @@ -508,6 +508,12 @@ const apiMenus: IAPIMenu[] = [ supportsSubmenus: false, proposed: 'contribChatEditorInlineGutterMenu', }, + { + key: 'chat/contextUsage/actions', + id: MenuId.ChatContextUsageActions, + description: localize('menus.chatContextUsageActions', "Actions in the chat context usage details popup."), + proposed: 'chatParticipantAdditions' + }, ]; namespace schema { diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index f802122ef4b84..928085d2f93fc 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -236,6 +236,9 @@ import './contrib/files/browser/files.contribution.js'; import './contrib/bulkEdit/browser/bulkEditService.js'; import './contrib/bulkEdit/browser/preview/bulkEdit.contribution.js'; +// Rename Symbol Tracker for Inline completions. +import './contrib/inlineCompletions/browser/renameSymbolTrackerService.js'; + // Search import './contrib/search/browser/search.contribution.js'; import './contrib/search/browser/searchView.js'; diff --git a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts index 25457624e2cbc..1fcb8dbb07352 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts @@ -474,7 +474,7 @@ declare module 'vscode' { /** * A map of all tools that should (`true`) and should not (`false`) be used in this request. */ - readonly tools: Map; + readonly tools: Map; } export namespace lm { @@ -564,6 +564,47 @@ declare module 'vscode' { export type ChatExtendedRequestHandler = (request: ChatRequest, context: ChatContext, response: ChatResponseStream, token: CancellationToken) => ProviderResult; + /** + * Details about the prompt token usage by category and label. + */ + export interface ChatResultPromptTokenDetail { + /** + * The category this token usage belongs to (e.g., "System", "Context", "Conversation"). + */ + readonly category: string; + + /** + * The label for this specific token usage (e.g., "System prompt", "Attached files"). + */ + readonly label: string; + + /** + * The percentage of the total prompt tokens this represents (0-100). + */ + readonly percentageOfPrompt: number; + } + + /** + * Token usage information for a chat request. + */ + export interface ChatResultUsage { + /** + * The number of prompt tokens used in this request. + */ + readonly promptTokens: number; + + /** + * The number of completion tokens generated in this response. + */ + readonly completionTokens: number; + + /** + * Optional breakdown of prompt token usage by category and label. + * If the percentages do not sum to 100%, the remaining will be shown as "Uncategorized". + */ + readonly promptTokenDetails?: readonly ChatResultPromptTokenDetail[]; + } + export interface ChatResult { nextQuestion?: { prompt: string; @@ -574,6 +615,12 @@ declare module 'vscode' { * An optional detail string that will be rendered at the end of the response in certain UI contexts. */ details?: string; + + /** + * Token usage information for this request, if available. + * This is typically provided by the underlying language model. + */ + readonly usage?: ChatResultUsage; } export namespace chat { diff --git a/src/vscode-dts/vscode.proposed.languageModelToolSupportsModel.d.ts b/src/vscode-dts/vscode.proposed.languageModelToolSupportsModel.d.ts new file mode 100644 index 0000000000000..ab481a3d9d88f --- /dev/null +++ b/src/vscode-dts/vscode.proposed.languageModelToolSupportsModel.d.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// version: 1 + +declare module 'vscode' { + + export interface LanguageModelToolDefinition extends LanguageModelToolInformation { + /** + * Display name for the tool. + */ + displayName: string; + /** + * Name of the tools that can users can reference in the prompt. If not + * provided, the tool will not be able to be referenced. Must not contain whitespace. + */ + toolReferenceName?: string; + /** + * Description for the tool shown to the user. + */ + userDescription?: string; + /** + * Icon for the tool shown to the user. + */ + icon?: IconPath; + /** + * If defined, the tool will only be available for language models that match + * the selector. + */ + models?: LanguageModelChatSelector[]; + /** + * Name of the toolset the tool should be contributed to, as defined in your + * extension's `package.json`. + */ + toolSet?: string; + } + + export namespace lm { + /** + * Registers a language model tool along with its definition. Unlike {@link lm.registerTool}, + * this does not require the tool to be present first in the extension's `package.json` contributions. + * + * Multiple tools may be registered with the the same name using the API. In any given context, + * the most specific tool (based on the {@link LanguageModelToolDefinition.models}) will be used. + * + * @param definition The definition of the tool to register. + * @param tool The implementation of the tool. + * @returns A disposable that unregisters the tool when disposed. + */ + export function registerToolDefinition( + definition: LanguageModelToolDefinition, + tool: LanguageModelTool, + ): Disposable; + + /** + * Invoke a tool by its full information object rather than just name. + * This allows disambiguation when multiple tools have the same name + * (e.g., from different MCP servers or model-specific implementations). + * + * @param tool The tool information object, typically obtained from {@link lm.tools}. + * @param options The options to use when invoking the tool. + * @param token A cancellation token. + * @returns The result of the tool invocation. + */ + export function invokeTool(tool: LanguageModelToolInformation, options: LanguageModelToolInvocationOptions, token?: CancellationToken): Thenable; + } +}