diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index 76e870eca8bdb..2aca946774446 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -15,6 +15,7 @@ "textCodeBlock.background": "#242526", "textLink.foreground": "#48A0C7", "textLink.activeForeground": "#53A5CA", + "textPreformat.background": "#262626", "textPreformat.foreground": "#888888", "textSeparator.foreground": "#2a2a2aFF", "button.background": "#3994BCF2", diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index 667dc4e9ea73c..4cd0263a1a087 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -173,55 +173,112 @@ "description": "%typescript.enablePromptUseWorkspaceTsdk%", "scope": "window" }, + "typescript.experimental.useTsgo": { + "type": "boolean", + "default": false, + "markdownDescription": "%typescript.useTsgo%", + "scope": "window", + "tags": [ + "experimental" + ] + }, + "js/ts.referencesCodeLens.enabled": { + "type": "boolean", + "default": false, + "description": "%configuration.referencesCodeLens.enabled%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.referencesCodeLens.enabled": { "type": "boolean", "default": false, - "description": "%javascript.referencesCodeLens.enabled%", + "description": "%configuration.referencesCodeLens.enabled%", + "markdownDeprecationMessage": "%configuration.referencesCodeLens.enabled.unifiedDeprecationMessage%", "scope": "window" }, - "javascript.referencesCodeLens.showOnAllFunctions": { + "typescript.referencesCodeLens.enabled": { "type": "boolean", "default": false, - "description": "%javascript.referencesCodeLens.showOnAllFunctions%", + "description": "%configuration.referencesCodeLens.enabled%", + "markdownDeprecationMessage": "%configuration.referencesCodeLens.enabled.unifiedDeprecationMessage%", "scope": "window" }, - "typescript.referencesCodeLens.enabled": { + "js/ts.referencesCodeLens.showOnAllFunctions": { "type": "boolean", "default": false, - "description": "%typescript.referencesCodeLens.enabled%", + "description": "%configuration.referencesCodeLens.showOnAllFunctions%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, + "javascript.referencesCodeLens.showOnAllFunctions": { + "type": "boolean", + "default": false, + "description": "%configuration.referencesCodeLens.showOnAllFunctions%", + "markdownDeprecationMessage": "%configuration.referencesCodeLens.showOnAllFunctions.unifiedDeprecationMessage%", "scope": "window" }, "typescript.referencesCodeLens.showOnAllFunctions": { "type": "boolean", "default": false, - "description": "%typescript.referencesCodeLens.showOnAllFunctions%", + "description": "%configuration.referencesCodeLens.showOnAllFunctions%", + "markdownDeprecationMessage": "%configuration.referencesCodeLens.showOnAllFunctions.unifiedDeprecationMessage%", "scope": "window" }, + "js/ts.implementationsCodeLens.enabled": { + "type": "boolean", + "default": false, + "description": "%configuration.implementationsCodeLens.enabled%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "typescript.implementationsCodeLens.enabled": { "type": "boolean", "default": false, - "description": "%typescript.implementationsCodeLens.enabled%", + "description": "%configuration.implementationsCodeLens.enabled%", + "markdownDeprecationMessage": "%configuration.implementationsCodeLens.enabled.unifiedDeprecationMessage%", "scope": "window" }, - "typescript.experimental.useTsgo": { + "js/ts.implementationsCodeLens.showOnInterfaceMethods": { "type": "boolean", "default": false, - "markdownDescription": "%typescript.useTsgo%", - "scope": "window", + "description": "%configuration.implementationsCodeLens.showOnInterfaceMethods%", + "scope": "language-overridable", "tags": [ - "experimental" + "JavaScript", + "TypeScript" ] }, "typescript.implementationsCodeLens.showOnInterfaceMethods": { "type": "boolean", "default": false, - "description": "%typescript.implementationsCodeLens.showOnInterfaceMethods%", + "description": "%configuration.implementationsCodeLens.showOnInterfaceMethods%", + "markdownDeprecationMessage": "%configuration.implementationsCodeLens.showOnInterfaceMethods.unifiedDeprecationMessage%", "scope": "window" }, + "js/ts.implementationsCodeLens.showOnAllClassMethods": { + "type": "boolean", + "default": false, + "description": "%configuration.implementationsCodeLens.showOnAllClassMethods%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "typescript.implementationsCodeLens.showOnAllClassMethods": { "type": "boolean", "default": false, - "description": "%typescript.implementationsCodeLens.showOnAllClassMethods%", + "description": "%configuration.implementationsCodeLens.showOnAllClassMethods%", + "markdownDeprecationMessage": "%configuration.implementationsCodeLens.showOnAllClassMethods.unifiedDeprecationMessage%", "scope": "window" }, "typescript.reportStyleChecksAsWarnings": { diff --git a/extensions/typescript-language-features/package.nls.json b/extensions/typescript-language-features/package.nls.json index 43f62e918f333..ed640ef85f0e6 100644 --- a/extensions/typescript-language-features/package.nls.json +++ b/extensions/typescript-language-features/package.nls.json @@ -49,13 +49,16 @@ "javascript.validate.enable": "Enable/disable JavaScript validation.", "javascript.goToProjectConfig.title": "Go to Project Configuration (jsconfig / tsconfig)", "typescript.goToProjectConfig.title": "Go to Project Configuration (tsconfig)", - "javascript.referencesCodeLens.enabled": "Enable/disable references CodeLens in JavaScript files.", - "javascript.referencesCodeLens.showOnAllFunctions": "Enable/disable references CodeLens on all functions in JavaScript files.", - "typescript.referencesCodeLens.enabled": "Enable/disable references CodeLens in TypeScript files.", - "typescript.referencesCodeLens.showOnAllFunctions": "Enable/disable references CodeLens on all functions in TypeScript files.", - "typescript.implementationsCodeLens.enabled": "Enable/disable implementations CodeLens. This CodeLens shows the implementers of an interface.", - "typescript.implementationsCodeLens.showOnInterfaceMethods": "Enable/disable implementations CodeLens on interface methods.", - "typescript.implementationsCodeLens.showOnAllClassMethods": "Enable/disable showing implementations CodeLens above all class methods instead of only on abstract methods.", + "configuration.referencesCodeLens.enabled": "Enable/disable references CodeLens in JavaScript and TypeScript files. This CodeLens shows the number of references for classes and exported functions and allows you to peek or navigate to them.", + "configuration.referencesCodeLens.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.referencesCodeLens.enabled#` instead.", + "configuration.referencesCodeLens.showOnAllFunctions": "Enable/disable the references CodeLens on all functions in JavaScript and TypeScript files.", + "configuration.referencesCodeLens.showOnAllFunctions.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.referencesCodeLens.showOnAllFunctions#` instead.", + "configuration.implementationsCodeLens.enabled": "Enable/disable implementations CodeLens in TypeScript files. This CodeLens shows the implementers of a TypeScript interface.", + "configuration.implementationsCodeLens.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.implementationsCodeLens.enabled#` instead.", + "configuration.implementationsCodeLens.showOnInterfaceMethods": "Enable/disable implementations CodeLens on TypeScript interface methods.", + "configuration.implementationsCodeLens.showOnInterfaceMethods.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.implementationsCodeLens.showOnInterfaceMethods#` instead.", + "configuration.implementationsCodeLens.showOnAllClassMethods": "Enable/disable showing implementations CodeLens above all TypeScript class methods instead of only on abstract methods.", + "configuration.implementationsCodeLens.showOnAllClassMethods.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.implementationsCodeLens.showOnAllClassMethods#` instead.", "typescript.openTsServerLog.title": "Open TS Server log", "typescript.restartTsServer": "Restart TS Server", "typescript.selectTypeScriptVersion.title": "Select TypeScript Version...", diff --git a/extensions/typescript-language-features/src/languageFeatures/codeLens/implementationsCodeLens.ts b/extensions/typescript-language-features/src/languageFeatures/codeLens/implementationsCodeLens.ts index d012a6ab9d980..d32cfa129f867 100644 --- a/extensions/typescript-language-features/src/languageFeatures/codeLens/implementationsCodeLens.ts +++ b/extensions/typescript-language-features/src/languageFeatures/codeLens/implementationsCodeLens.ts @@ -11,10 +11,16 @@ import type * as Proto from '../../tsServer/protocol/protocol'; import * as PConst from '../../tsServer/protocol/protocol.const'; import * as typeConverters from '../../typeConverters'; import { ClientCapability, ITypeScriptServiceClient } from '../../typescriptService'; -import { conditionalRegistration, requireGlobalConfiguration, requireSomeCapability } from '../util/dependentRegistration'; +import { readUnifiedConfig, unifiedConfigSection } from '../../utils/configuration'; +import { conditionalRegistration, requireHasModifiedUnifiedConfig, requireSomeCapability } from '../util/dependentRegistration'; import { ReferencesCodeLens, TypeScriptBaseCodeLensProvider, getSymbolRange } from './baseCodeLensProvider'; import { ExecutionTarget } from '../../tsServer/server'; +const Config = Object.freeze({ + enabled: 'implementationsCodeLens.enabled', + showOnInterfaceMethods: 'implementationsCodeLens.showOnInterfaceMethods', + showOnAllClassMethods: 'implementationsCodeLens.showOnAllClassMethods', +}); export default class TypeScriptImplementationsCodeLensProvider extends TypeScriptBaseCodeLensProvider { public constructor( @@ -25,14 +31,30 @@ export default class TypeScriptImplementationsCodeLensProvider extends TypeScrip super(client, _cachedResponse); this._register( vscode.workspace.onDidChangeConfiguration(evt => { - if (evt.affectsConfiguration(`${language.id}.implementationsCodeLens.showOnInterfaceMethods`) || - evt.affectsConfiguration(`${language.id}.implementationsCodeLens.showOnAllClassMethods`)) { + if ( + evt.affectsConfiguration(`${unifiedConfigSection}.${Config.enabled}`) || + evt.affectsConfiguration(`${language.id}.${Config.enabled}`) || + evt.affectsConfiguration(`${unifiedConfigSection}.${Config.showOnInterfaceMethods}`) || + evt.affectsConfiguration(`${language.id}.${Config.showOnInterfaceMethods}`) || + evt.affectsConfiguration(`${unifiedConfigSection}.${Config.showOnAllClassMethods}`) || + evt.affectsConfiguration(`${language.id}.${Config.showOnAllClassMethods}`) + ) { this.changeEmitter.fire(); } }) ); } + + override async provideCodeLenses(document: vscode.TextDocument, token: vscode.CancellationToken): Promise { + const enabled = readUnifiedConfig(Config.enabled, false, { scope: document, fallbackSection: this.language.id }); + if (!enabled) { + return []; + } + + return super.provideCodeLenses(document, token); + } + public async resolveCodeLens( codeLens: ReferencesCodeLens, token: vscode.CancellationToken, @@ -88,8 +110,6 @@ export default class TypeScriptImplementationsCodeLensProvider extends TypeScrip item: Proto.NavigationTree, parent: Proto.NavigationTree | undefined ): vscode.Range | undefined { - const cfg = vscode.workspace.getConfiguration(this.language.id); - // Always show on interfaces if (item.kind === PConst.Kind.interface) { return getSymbolRange(document, item); @@ -111,7 +131,7 @@ export default class TypeScriptImplementationsCodeLensProvider extends TypeScrip if ( item.kind === PConst.Kind.method && parent?.kind === PConst.Kind.interface && - cfg.get('implementationsCodeLens.showOnInterfaceMethods', false) + readUnifiedConfig('implementationsCodeLens.showOnInterfaceMethods', false, { scope: document, fallbackSection: this.language.id }) ) { return getSymbolRange(document, item); } @@ -121,7 +141,7 @@ export default class TypeScriptImplementationsCodeLensProvider extends TypeScrip if ( item.kind === PConst.Kind.method && parent?.kind === PConst.Kind.class && - cfg.get('implementationsCodeLens.showOnAllClassMethods', false) + readUnifiedConfig('implementationsCodeLens.showOnAllClassMethods', false, { scope: document, fallbackSection: this.language.id }) ) { // But not private ones as these can never be overridden if (/\bprivate\b/.test(item.kindModifiers ?? '')) { @@ -141,7 +161,7 @@ export function register( cachedResponse: CachedResponse, ) { return conditionalRegistration([ - requireGlobalConfiguration(language.id, 'implementationsCodeLens.enabled'), + requireHasModifiedUnifiedConfig(Config.enabled, language.id), requireSomeCapability(client, ClientCapability.Semantic), ], () => { return vscode.languages.registerCodeLensProvider(selector.semantic, diff --git a/extensions/typescript-language-features/src/languageFeatures/codeLens/referencesCodeLens.ts b/extensions/typescript-language-features/src/languageFeatures/codeLens/referencesCodeLens.ts index de7d1f6d900c6..0942f7f8f3ad7 100644 --- a/extensions/typescript-language-features/src/languageFeatures/codeLens/referencesCodeLens.ts +++ b/extensions/typescript-language-features/src/languageFeatures/codeLens/referencesCodeLens.ts @@ -12,9 +12,14 @@ import * as PConst from '../../tsServer/protocol/protocol.const'; import { ExecutionTarget } from '../../tsServer/server'; import * as typeConverters from '../../typeConverters'; import { ClientCapability, ITypeScriptServiceClient } from '../../typescriptService'; -import { conditionalRegistration, requireGlobalConfiguration, requireSomeCapability } from '../util/dependentRegistration'; +import { readUnifiedConfig, unifiedConfigSection } from '../../utils/configuration'; +import { conditionalRegistration, requireHasModifiedUnifiedConfig, requireSomeCapability } from '../util/dependentRegistration'; import { ReferencesCodeLens, TypeScriptBaseCodeLensProvider, getSymbolRange } from './baseCodeLensProvider'; +const Config = Object.freeze({ + enabled: 'referencesCodeLens.enabled', + showOnAllFunctions: 'referencesCodeLens.showOnAllFunctions', +}); export class TypeScriptReferencesCodeLensProvider extends TypeScriptBaseCodeLensProvider { public constructor( @@ -25,13 +30,27 @@ export class TypeScriptReferencesCodeLensProvider extends TypeScriptBaseCodeLens super(client, _cachedResponse); this._register( vscode.workspace.onDidChangeConfiguration(evt => { - if (evt.affectsConfiguration(`${language.id}.referencesCodeLens.showOnAllFunctions`)) { + if ( + evt.affectsConfiguration(`${unifiedConfigSection}.${Config.enabled}`) || + evt.affectsConfiguration(`${language.id}.${Config.enabled}`) || + evt.affectsConfiguration(`${unifiedConfigSection}.${Config.showOnAllFunctions}`) || + evt.affectsConfiguration(`${language.id}.${Config.showOnAllFunctions}`) + ) { this.changeEmitter.fire(); } }) ); } + override async provideCodeLenses(document: vscode.TextDocument, token: vscode.CancellationToken): Promise { + const enabled = readUnifiedConfig(Config.enabled, false, { scope: document, fallbackSection: this.language.id }); + if (!enabled) { + return []; + } + + return super.provideCodeLenses(document, token); + } + public async resolveCodeLens(codeLens: ReferencesCodeLens, token: vscode.CancellationToken): Promise { const args = typeConverters.Position.toFileLocationRequestArgs(codeLens.file, codeLens.range.start); const response = await this.client.execute('references', args, token, { @@ -76,7 +95,7 @@ export class TypeScriptReferencesCodeLensProvider extends TypeScriptBaseCodeLens switch (item.kind) { case PConst.Kind.function: { - const showOnAllFunctions = vscode.workspace.getConfiguration(this.language.id).get('referencesCodeLens.showOnAllFunctions'); + const showOnAllFunctions = readUnifiedConfig(Config.showOnAllFunctions, false, { scope: document, fallbackSection: this.language.id }); if (showOnAllFunctions && item.nameSpan) { return getSymbolRange(document, item); } @@ -137,7 +156,7 @@ export function register( cachedResponse: CachedResponse, ) { return conditionalRegistration([ - requireGlobalConfiguration(language.id, 'referencesCodeLens.enabled'), + requireHasModifiedUnifiedConfig(Config.enabled, language.id), requireSomeCapability(client, ClientCapability.Semantic), ], () => { return vscode.languages.registerCodeLensProvider(selector.semantic, diff --git a/extensions/typescript-language-features/src/languageFeatures/util/dependentRegistration.ts b/extensions/typescript-language-features/src/languageFeatures/util/dependentRegistration.ts index 8371b470997c4..916bfd8f3aecf 100644 --- a/extensions/typescript-language-features/src/languageFeatures/util/dependentRegistration.ts +++ b/extensions/typescript-language-features/src/languageFeatures/util/dependentRegistration.ts @@ -6,6 +6,7 @@ import * as vscode from 'vscode'; import { API } from '../../tsServer/api'; import { ClientCapability, ITypeScriptServiceClient } from '../../typescriptService'; +import { hasModifiedUnifiedConfig } from '../../utils/configuration'; import { Disposable } from '../../utils/dispose'; export class Condition extends Disposable { @@ -102,6 +103,21 @@ export function requireGlobalConfiguration( ); } +/** + * Requires that a configuration value has been modified from its default value in either the global or workspace scope + * + * Does not check the value, only that it has been modified from the default. + */ +export function requireHasModifiedUnifiedConfig( + configValue: string, + fallbackSection: string, +) { + return new Condition( + () => hasModifiedUnifiedConfig(configValue, { fallbackSection }), + vscode.workspace.onDidChangeConfiguration + ); +} + export function requireSomeCapability( client: ITypeScriptServiceClient, ...capabilities: readonly ClientCapability[] diff --git a/extensions/typescript-language-features/src/utils/configuration.ts b/extensions/typescript-language-features/src/utils/configuration.ts new file mode 100644 index 0000000000000..b10a70fd27a9d --- /dev/null +++ b/extensions/typescript-language-features/src/utils/configuration.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +type ConfigurationScope = vscode.ConfigurationScope | null | undefined; + +export const unifiedConfigSection = 'js/ts'; + +/** + * Gets a configuration value, checking the unified `js/ts` setting first, + * then falling back to the language-specific setting. + */ +export function readUnifiedConfig( + subSectionName: string, + defaultValue: T, + options: { + readonly scope?: ConfigurationScope; + readonly fallbackSection: string; + } +): T { + // Check unified setting first + const unifiedConfig = vscode.workspace.getConfiguration(unifiedConfigSection, options.scope); + const unifiedInspect = unifiedConfig.inspect(subSectionName); + if (hasModifiedValue(unifiedInspect)) { + return unifiedConfig.get(subSectionName, defaultValue); + } + + // Fall back to language-specific setting + const languageConfig = vscode.workspace.getConfiguration(options.fallbackSection, options.scope); + return languageConfig.get(subSectionName, defaultValue); +} + +/** + * Checks if an inspected configuration value has any user-defined values set. + */ +function hasModifiedValue(inspect: ReturnType): boolean { + if (!inspect) { + return false; + } + + return ( + typeof inspect.globalValue !== 'undefined' + || typeof inspect.workspaceValue !== 'undefined' + || typeof inspect.workspaceFolderValue !== 'undefined' + || typeof inspect.globalLanguageValue !== 'undefined' + || typeof inspect.workspaceLanguageValue !== 'undefined' + || typeof inspect.workspaceFolderLanguageValue !== 'undefined' + || ((inspect.languageIds?.length ?? 0) > 0) + ); +} + +/** + * Checks if a unified configuration value has been modified from its default value. + */ +export function hasModifiedUnifiedConfig( + subSectionName: string, + options: { + readonly scope?: ConfigurationScope; + readonly fallbackSection: string; + } +): boolean { + // Check unified setting + const unifiedConfig = vscode.workspace.getConfiguration(unifiedConfigSection, options.scope); + if (hasModifiedValue(unifiedConfig.inspect(subSectionName))) { + return true; + } + + // Check language-specific setting + const languageConfig = vscode.workspace.getConfiguration(options.fallbackSection, options.scope); + return hasModifiedValue(languageConfig.inspect(subSectionName)); +} diff --git a/src/vs/editor/browser/view/viewController.ts b/src/vs/editor/browser/view/viewController.ts index cd312c962716d..9f94a1b4f824e 100644 --- a/src/vs/editor/browser/view/viewController.ts +++ b/src/vs/editor/browser/view/viewController.ts @@ -16,6 +16,7 @@ import { EditorOption } from '../../common/config/editorOptions.js'; import * as platform from '../../../base/common/platform.js'; import { StandardTokenType } from '../../common/encodedTokenAttributes.js'; import { ITextModel } from '../../common/model.js'; +import { containsRTL } from '../../../base/common/strings.js'; export interface IMouseDispatchData { position: Position; @@ -181,15 +182,30 @@ export class ViewController { return undefined; } - // Get 1-based boundaries of the string content (excluding quotes). - const start = lineTokens.getStartOffset(index) + 2; - const end = lineTokens.getEndOffset(index); + // Verify the click is after starting or before closing quote. + const tokenStart = lineTokens.getStartOffset(index); + const tokenEnd = lineTokens.getEndOffset(index); + if (column !== tokenStart + 2 && column !== tokenEnd) { + return undefined; + } + + // Verify the token looks like a complete quoted string (quote ... quote). + const lineContent = model.getLineContent(lineNumber); + const firstChar = lineContent.charAt(tokenStart); + if (firstChar !== '"' && firstChar !== '\'' && firstChar !== '`') { + return undefined; + } + if (lineContent.charAt(tokenEnd - 1) !== firstChar) { + return undefined; + } - if (column !== start && column !== end) { + // Skip if string contains RTL characters. + const content = lineContent.substring(tokenStart + 1, tokenEnd - 1); + if (containsRTL(content)) { return undefined; } - return new Selection(lineNumber, start, lineNumber, end); + return new Selection(lineNumber, tokenStart + 2, lineNumber, tokenEnd); } public dispatchMouse(data: IMouseDispatchData): void { diff --git a/src/vs/editor/test/browser/view/viewController.test.ts b/src/vs/editor/test/browser/view/viewController.test.ts index a3b26791ce88c..95d44801cb795 100644 --- a/src/vs/editor/test/browser/view/viewController.test.ts +++ b/src/vs/editor/test/browser/view/viewController.test.ts @@ -12,8 +12,11 @@ import { NavigationCommandRevealType } from '../../../browser/coreCommands.js'; import { ViewController } from '../../../browser/view/viewController.js'; import { ViewUserInputEvents } from '../../../browser/view/viewUserInputEvents.js'; import { Position } from '../../../common/core/position.js'; +import { MetadataConsts, StandardTokenType } from '../../../common/encodedTokenAttributes.js'; +import { EncodedTokenizationResult, ITokenizationSupport, TokenizationRegistry } from '../../../common/languages.js'; import { ILanguageService } from '../../../common/languages/language.js'; import { ILanguageConfigurationService } from '../../../common/languages/languageConfigurationRegistry.js'; +import { NullState } from '../../../common/languages/nullTokenize.js'; import { MonospaceLineBreaksComputerFactory } from '../../../common/viewModel/monospaceLineBreaksComputer.js'; import { ViewModel } from '../../../common/viewModel/viewModelImpl.js'; import { instantiateTextModel } from '../../../test/common/testTextModel.js'; @@ -145,3 +148,230 @@ suite('ViewController - Bracket content selection', () => { testBracketSelection('var x = {};', new Position(1, 10), ''); }); }); + +interface TokenSpan { + startIndex: number; + type: StandardTokenType; +} + +suite('ViewController - String content selection', () => { + let disposables: DisposableStore; + let instantiationService: TestInstantiationService; + let languageConfigurationService: ILanguageConfigurationService; + let languageService: ILanguageService; + let viewModel: ViewModel | undefined; + + setup(() => { + disposables = new DisposableStore(); + instantiationService = createCodeEditorServices(disposables); + languageConfigurationService = instantiationService.get(ILanguageConfigurationService); + languageService = instantiationService.get(ILanguageService); + viewModel = undefined; + }); + + teardown(() => { + viewModel?.dispose(); + viewModel = undefined; + disposables.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createViewControllerWithTokens(text: string, lineTokens: TokenSpan[]): ViewController { + const languageId = 'stringTestMode'; + disposables.add(languageService.registerLanguage({ id: languageId })); + disposables.add(languageConfigurationService.register(languageId, { + brackets: [ + ['{', '}'], + ['[', ']'], + ['(', ')'], + ] + })); + + const encodedLanguageId = languageService.languageIdCodec.encodeLanguageId(languageId); + const makeMetadata = (type: StandardTokenType) => ( + (encodedLanguageId << MetadataConsts.LANGUAGEID_OFFSET) + | (type << MetadataConsts.TOKEN_TYPE_OFFSET) + ) >>> 0; + + const tokenizationSupport: ITokenizationSupport = { + getInitialState: () => NullState, + tokenize: undefined!, + tokenizeEncoded: (_line, _hasEOL, state) => { + const arr = new Uint32Array(lineTokens.length * 2); + for (let i = 0; i < lineTokens.length; i++) { + arr[i * 2] = lineTokens[i].startIndex; + arr[i * 2 + 1] = makeMetadata(lineTokens[i].type); + } + return new EncodedTokenizationResult(arr, [], state); + } + }; + + disposables.add(TokenizationRegistry.register(languageId, tokenizationSupport)); + + const configuration = disposables.add(new TestConfiguration({})); + const monospaceLineBreaksComputerFactory = MonospaceLineBreaksComputerFactory.create(configuration.options); + const model = disposables.add(instantiateTextModel(instantiationService, text, languageId)); + + model.tokenization.forceTokenization(1); + + viewModel = new ViewModel( + 1, + configuration, + model, + monospaceLineBreaksComputerFactory, + monospaceLineBreaksComputerFactory, + null!, + disposables.add(new TestLanguageConfigurationService()), + new TestThemeService(), + { setVisibleLines() { } }, + { batchChanges: (cb: any) => cb() } + ); + + return new ViewController( + configuration, + viewModel, + new ViewUserInputEvents(viewModel.coordinatesConverter), + { + paste: () => { }, + type: () => { }, + compositionType: () => { }, + startComposition: () => { }, + endComposition: () => { }, + cut: () => { } + } + ); + } + + function doubleClickAt(controller: ViewController, position: Position): string { + controller.dispatchMouse({ + position, + mouseColumn: position.column, + startedOnLineNumbers: false, + revealType: NavigationCommandRevealType.Minimal, + mouseDownCount: 2, + inSelectionMode: false, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + leftButton: true, + middleButton: false, + onInjectedText: false + }); + const selections = viewModel!.getSelections(); + return viewModel!.model.getValueInRange(selections[0]); + } + + // -- Happy-path: whole string as a single token including quotes -- + + test('Select string content clicking right after opening double quote', () => { + // 0123456789... + const text = 'var x = "hello";'; + // Token layout: [0..8) Other [8..15) String("hello") [15..16) Other + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, + { startIndex: 15, type: StandardTokenType.Other }, + ]); + // Column right after opening quote: offset 9 → column 10 + assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'hello'); + }); + + test('Select string content clicking at closing double quote', () => { + const text = 'var x = "hello";'; + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, + { startIndex: 15, type: StandardTokenType.Other }, + ]); + // Column at closing quote: offset 14 → column 15 + assert.strictEqual(doubleClickAt(controller, new Position(1, 15)), 'hello'); + }); + + test('Select string content with single quotes', () => { + const text = `var x = 'hello';`; + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, + { startIndex: 15, type: StandardTokenType.Other }, + ]); + assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'hello'); + }); + + test('Select string content with backtick quotes', () => { + const text = 'var x = `hello`;'; + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, + { startIndex: 15, type: StandardTokenType.Other }, + ]); + assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'hello'); + }); + + // -- Click in middle of string should NOT select the whole string -- + + test('Click in middle of string does not select whole string', () => { + // 0123456789012345678901 + const text = 'var x = "hello world";'; + // Token layout: [0..8) Other [8..21) String("hello world") [21..22) Other + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, + { startIndex: 21, type: StandardTokenType.Other }, + ]); + // Click on 'w' in "world" — word select should pick 'world', not 'hello world' + assert.strictEqual(doubleClickAt(controller, new Position(1, 16)), 'world'); + }); + + // -- Bail-out: quotes as separate tokens (theme issue #292784) -- + + test('Separate quote tokens fall back to word select', () => { + // 0 1 2 + // 0123456789012345678901234 + const text = 'var x = "hello world";'; + // Theme tokenizes quotes as separate Other tokens: + // [0..8) Other [8..9) Other(") [9..20) String(hello world) [20..21) Other(") [21..22) Other + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.Other }, // opening " + { startIndex: 9, type: StandardTokenType.String }, // hello world + { startIndex: 20, type: StandardTokenType.Other }, // closing " + { startIndex: 21, type: StandardTokenType.Other }, + ]); + // The String token "hello world" doesn't start with a quote char → should bail out. + // Click right after opening quote (column 10) → word select picks just 'hello'. + assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'hello'); + }); + + // -- Bail-out: RTL content in string (#293384) -- + + test('RTL content in string falls back to word select', () => { + const text = 'var x = "שלום עולם";'; + // Token layout: [0..8) Other [8..19) String("שלום עולם") [19..20) Other + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, + { startIndex: 19, type: StandardTokenType.Other }, + ]); + // Should bail out due to RTL content → word select picks first word + assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'שלום'); + }); + + // -- Bail-out: mismatched quotes (#293203 — string split at braces) -- + + test('String token without matching closing quote falls back to word select', () => { + // 0123456789012345 + const text = 'var x = "a {} b";'; + // Hypothetical tokenizer splits: [0..8) Other [8..11) String("a ) [11..13) Other({}) [13..17) String( b") [17..18) Other + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, // `"a ` — starts with " but doesn't end with " + { startIndex: 11, type: StandardTokenType.Other }, // `{}` + { startIndex: 13, type: StandardTokenType.String }, // ` b"` — ends with " but doesn't start with " + { startIndex: 16, type: StandardTokenType.Other }, + ]); + // First String token starts with " but ends with space → bail out → word select picks 'a' + assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'a'); + }); +}); diff --git a/src/vs/platform/configuration/common/configurationRegistry.ts b/src/vs/platform/configuration/common/configurationRegistry.ts index f12e732db79bd..f035cdec47c2a 100644 --- a/src/vs/platform/configuration/common/configurationRegistry.ts +++ b/src/vs/platform/configuration/common/configurationRegistry.ts @@ -200,6 +200,11 @@ export interface IConfigurationPropertySchema extends IJSONSchema { */ enumItemLabels?: string[]; + /** + * Optional keywords used for search purposes. + */ + keywords?: string[]; + /** * When specified, controls the presentation format of string settings. * Otherwise, the presentation format defaults to `singleline`. diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index fa429fbf424ea..b6a71de582b39 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -45,7 +45,7 @@ const _allApiProposals = { }, chatHooks: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatHooks.d.ts', - version: 3 + version: 6 }, chatOutputRenderer: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts', diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 20eab0ab271c1..ac03a72494340 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -98,7 +98,6 @@ import './mainThreadChatOutputRenderer.js'; import './mainThreadChatSessions.js'; import './mainThreadDataChannels.js'; import './mainThreadMeteredConnection.js'; -import './mainThreadHooks.js'; export class ExtensionPoints implements IWorkbenchContribution { diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index ff9ea448f4ad1..da9a664e813fe 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -337,20 +337,32 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes this._handle = handle; } - private _items: IChatSessionItem[] = []; + private readonly _items = new ResourceMap(); get items(): IChatSessionItem[] { - return this._items; + return Array.from(this._items.values()); } refresh(token: CancellationToken): Promise { return this._proxy.$refreshChatSessionItems(this._handle, token); } - setItems(items: IChatSessionItem[]): void { - this._items = items; + setItems(items: readonly IChatSessionItem[]): void { + this._items.clear(); + for (const item of items) { + this._items.set(item.resource, item); + } this._onDidChangeChatSessionItems.fire(); } + updateItem(item: IChatSessionItem): void { + if (this._items.has(item.resource)) { + this._items.set(item.resource, item); + this._onDidChangeChatSessionItems.fire(); + } else { + console.warn(`Item with resource ${item.resource.toString()} does not exist. Skipping update.`); + } + } + fireOnDidChangeChatSessionItems(): void { this._onDidChangeChatSessionItems.fire(); } @@ -432,6 +444,36 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat this._itemControllerRegistrations.get(handle)?.controller.fireOnDidChangeChatSessionItems(); } + private async _resolveSessionItem(item: Dto): Promise { + const uri = URI.revive(item.resource); + const model = this._chatService.getSession(uri); + if (model) { + item = await this.handleSessionModelOverrides(model, item); + } + + // We can still get stats if there is no model or if fetching from model failed + if (!item.changes || !model) { + const stats = (await this._chatService.getMetadataForSession(uri))?.stats; + const diffs: IAgentSession['changes'] = { + files: stats?.fileCount || 0, + insertions: stats?.added || 0, + deletions: stats?.removed || 0 + }; + if (hasValidDiff(diffs)) { + item.changes = diffs; + } + } + + return { + ...item, + changes: revive(item.changes), + resource: uri, + iconPath: item.iconPath, + tooltip: item.tooltip ? this._reviveTooltip(item.tooltip) : undefined, + archived: item.archived, + }; + } + async $setChatSessionItems(handle: number, items: Dto[]): Promise { const registration = this._itemControllerRegistrations.get(handle); if (!registration) { @@ -439,37 +481,19 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat return; } - const resolvedItems = await Promise.all(items.map(async item => { - const uri = URI.revive(item.resource); - const model = this._chatService.getSession(uri); - if (model) { - item = await this.handleSessionModelOverrides(model, item); - } - - // We can still get stats if there is no model or if fetching from model failed - if (!item.changes || !model) { - const stats = (await this._chatService.getMetadataForSession(uri))?.stats; - const diffs: IAgentSession['changes'] = { - files: stats?.fileCount || 0, - insertions: stats?.added || 0, - deletions: stats?.removed || 0 - }; - if (hasValidDiff(diffs)) { - item.changes = diffs; - } - } + const resolvedItems = await Promise.all(items.map(item => this._resolveSessionItem(item))); + registration.controller.setItems(resolvedItems); + } - return { - ...item, - changes: revive(item.changes), - resource: uri, - iconPath: item.iconPath, - tooltip: item.tooltip ? this._reviveTooltip(item.tooltip) : undefined, - archived: item.archived, - } satisfies IChatSessionItem; - })); + async $updateChatSessionItem(controllerHandle: number, item: Dto): Promise { + const registration = this._itemControllerRegistrations.get(controllerHandle); + if (!registration) { + this._logService.warn(`No chat session controller registered for handle ${controllerHandle}`); + return; + } - registration.controller.setItems(resolvedItems); + const resolvedItem = await this._resolveSessionItem(item); + registration.controller.updateItem(resolvedItem); } $onDidChangeChatSessionOptions(handle: number, sessionResourceComponents: UriComponents, updates: ReadonlyArray<{ optionId: string; value: string }>): void { diff --git a/src/vs/workbench/api/browser/mainThreadHooks.ts b/src/vs/workbench/api/browser/mainThreadHooks.ts deleted file mode 100644 index d76cbc2a46cc0..0000000000000 --- a/src/vs/workbench/api/browser/mainThreadHooks.ts +++ /dev/null @@ -1,43 +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 { URI, UriComponents } from '../../../base/common/uri.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; -import { ExtHostContext, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js'; -import { HookCommandResultKind, IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; -import { IHooksExecutionProxy, IHooksExecutionService } from '../../contrib/chat/common/hooks/hooksExecutionService.js'; -import { HookTypeValue, IHookCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; -import { CancellationToken } from '../../../base/common/cancellation.js'; - -@extHostNamedCustomer(MainContext.MainThreadHooks) -export class MainThreadHooks extends Disposable implements MainThreadHooksShape { - - constructor( - extHostContext: IExtHostContext, - @IHooksExecutionService private readonly _hooksExecutionService: IHooksExecutionService, - ) { - super(); - const extHostProxy = extHostContext.getProxy(ExtHostContext.ExtHostHooks); - - const proxy: IHooksExecutionProxy = { - runHookCommand: async (hookCommand: IHookCommand, input: unknown, token: CancellationToken): Promise => { - const result = await extHostProxy.$runHookCommand(hookCommand, input, token); - return { - kind: result.kind as HookCommandResultKind, - result: result.result - }; - } - }; - - this._hooksExecutionService.setProxy(proxy); - } - - async $executeHook(hookType: string, sessionResource: UriComponents, input: unknown, token: CancellationToken): Promise { - const uri = URI.revive(sessionResource); - return this._hooksExecutionService.executeHook(hookType as HookTypeValue, uri, { input, token }); - } -} diff --git a/src/vs/workbench/api/common/configurationExtensionPoint.ts b/src/vs/workbench/api/common/configurationExtensionPoint.ts index 4a2cf457445ec..46304274ef0bf 100644 --- a/src/vs/workbench/api/common/configurationExtensionPoint.ts +++ b/src/vs/workbench/api/common/configurationExtensionPoint.ts @@ -116,6 +116,13 @@ const configurationEntrySchema: IJSONSchema = { type: 'boolean', description: nls.localize('scope.ignoreSync', 'When enabled, Settings Sync will not sync the user value of this configuration by default.') }, + keywords: { + type: 'array', + items: { + type: 'string' + }, + description: nls.localize('scope.keywords', 'A list of keywords that help users find this setting in the Settings editor. These are not shown to the user.') + }, tags: { type: 'array', items: { diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index ef3f6a731dd65..d41aab9b5d692 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -65,7 +65,6 @@ import { IExtHostConsumerFileSystem } from './extHostFileSystemConsumer.js'; import { ExtHostFileSystemEventService, FileSystemWatcherCreateOptions } from './extHostFileSystemEventService.js'; import { IExtHostFileSystemInfo } from './extHostFileSystemInfo.js'; import { IExtHostInitDataService } from './extHostInitDataService.js'; -import { IExtHostHooks } from './extHostHooks.js'; import { ExtHostInteractive } from './extHostInteractive.js'; import { ExtHostLabelService } from './extHostLabelService.js'; import { ExtHostLanguageFeatures } from './extHostLanguageFeatures.js'; @@ -245,7 +244,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostEmbeddings = rpcProtocol.set(ExtHostContext.ExtHostEmbeddings, new ExtHostEmbeddings(rpcProtocol)); rpcProtocol.set(ExtHostContext.ExtHostMcp, accessor.get(IExtHostMpcService)); - rpcProtocol.set(ExtHostContext.ExtHostHooks, accessor.get(IExtHostHooks)); // Check that no named customers are missing const expected = Object.values>(ExtHostContext); @@ -257,7 +255,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostMessageService = new ExtHostMessageService(rpcProtocol, extHostLogService); const extHostDialogs = new ExtHostDialogs(rpcProtocol); const extHostChatStatus = new ExtHostChatStatus(rpcProtocol); - const extHostHooks = accessor.get(IExtHostHooks); // Register API-ish commands ExtHostApiCommands.register(extHostCommands); @@ -1661,10 +1658,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.registerPromptFileProvider(extension, PromptsType.skill, provider); }, - async executeHook(hookType: vscode.ChatHookType, options: vscode.ChatHookExecutionOptions, token?: vscode.CancellationToken): Promise { - checkProposedApiEnabled(extension, 'chatHooks'); - return extHostHooks.executeHook(hookType, options, token); - }, }; // namespace: lm diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index fff9a8d8845db..422aa1b582774 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -99,9 +99,6 @@ import { IExtHostDocumentSaveDelegate } from './extHostDocumentData.js'; import { TerminalShellExecutionCommandLineConfidence } from './extHostTypes.js'; import * as tasks from './shared/tasks.js'; import { PromptsType } from '../../contrib/chat/common/promptSyntax/promptTypes.js'; -import { IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; -import { IHookCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; export type IconPathDto = | UriComponents @@ -3234,12 +3231,6 @@ export interface IStartMcpOptions { errorOnUserInteraction?: boolean; } -export type IHookCommandDto = Dto; - -export interface ExtHostHooksShape { - $runHookCommand(hookCommand: IHookCommandDto, input: unknown, token: CancellationToken): Promise; -} - export interface ExtHostMcpShape { $substituteVariables(workspaceFolder: UriComponents | undefined, value: McpServerLaunch.Serialized): Promise; $resolveMcpLaunch(collectionId: string, label: string): Promise; @@ -3295,10 +3286,6 @@ export interface MainThreadMcpShape { export interface MainThreadDataChannelsShape extends IDisposable { } -export interface MainThreadHooksShape extends IDisposable { - $executeHook(hookType: string, sessionResource: UriComponents, input: unknown, token: CancellationToken): Promise; -} - export interface ExtHostDataChannelsShape { $onDidReceiveData(channelId: string, data: unknown): void; } @@ -3431,6 +3418,7 @@ export interface MainThreadChatSessionsShape extends IDisposable { $registerChatSessionItemController(handle: number, chatSessionType: string): void; $unregisterChatSessionItemController(handle: number): void; $setChatSessionItems(handle: number, items: Dto[]): Promise; + $updateChatSessionItem(handle: number, item: Dto): Promise; $onDidChangeChatSessionItems(handle: number): void; $onDidCommitChatSessionItem(handle: number, original: UriComponents, modified: UriComponents): void; $registerChatSessionContentProvider(handle: number, chatSessionScheme: string): void; @@ -3535,7 +3523,6 @@ export const MainContext = { MainThreadChatStatus: createProxyIdentifier('MainThreadChatStatus'), MainThreadAiSettingsSearch: createProxyIdentifier('MainThreadAiSettingsSearch'), MainThreadDataChannels: createProxyIdentifier('MainThreadDataChannels'), - MainThreadHooks: createProxyIdentifier('MainThreadHooks'), MainThreadChatSessions: createProxyIdentifier('MainThreadChatSessions'), MainThreadChatOutputRenderer: createProxyIdentifier('MainThreadChatOutputRenderer'), MainThreadChatContext: createProxyIdentifier('MainThreadChatContext'), @@ -3615,7 +3602,6 @@ export const ExtHostContext = { ExtHostMeteredConnection: createProxyIdentifier('ExtHostMeteredConnection'), ExtHostLocalization: createProxyIdentifier('ExtHostLocalization'), ExtHostMcp: createProxyIdentifier('ExtHostMcp'), - ExtHostHooks: createProxyIdentifier('ExtHostHooks'), ExtHostDataChannels: createProxyIdentifier('ExtHostDataChannels'), ExtHostChatSessions: createProxyIdentifier('ExtHostChatSessions'), }; diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 46a7230a09283..febaa3a413d0b 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -19,10 +19,10 @@ import { SymbolKind, SymbolKinds } from '../../../editor/common/languages.js'; import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { ILogService } from '../../../platform/log/common/log.js'; import { IChatRequestVariableEntry, IDiagnosticVariableEntryFilterData, IPromptFileVariableEntry, ISymbolVariableEntry, PromptFileVariableKind } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; -import { ChatSessionStatus, IChatSessionItem, IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; +import { IChatSessionItem, IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/participants/chatAgents.js'; -import { Proxied } from '../../services/extensions/common/proxyIdentifier.js'; +import { Dto, Proxied } from '../../services/extensions/common/proxyIdentifier.js'; import { ChatSessionDto, ExtHostChatSessionsShape, IChatAgentProgressShape, IChatSessionProviderOptions, MainContext, MainThreadChatSessionsShape } from './extHost.protocol.js'; import { ChatAgentResponseStream } from './extHostChatAgents2.js'; import { CommandsConverter, ExtHostCommands } from './extHostCommands.js'; @@ -331,10 +331,10 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio // Helper to push items to main thread const updateItems = async (items: readonly vscode.ChatSessionItem[]) => { collection.replace(items); - const convertedItems: IChatSessionItem[] = []; + const convertedItems: Array> = []; for (const sessionContent of items) { this._sessionItems.set(sessionContent.resource, sessionContent); - convertedItems.push(this.convertChatSessionItem(sessionContent)); + convertedItems.push(typeConvert.ChatSessionItem.from(sessionContent)); } void this._proxy.$setChatSessionItems(handle, convertedItems); }; @@ -389,10 +389,10 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio const onDidChangeChatSessionItemStateEmitter = disposables.add(new Emitter()); const onItemsChanged = () => { - const items: IChatSessionItem[] = []; + const items: Array> = []; for (const [_, item] of collection) { this._sessionItems.set(item.resource, item); - items.push(this.convertChatSessionItem(item)); + items.push(typeConvert.ChatSessionItem.from(item)); } void this._proxy.$setChatSessionItems(controllerHandle, items); }; @@ -416,10 +416,10 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio throw new Error('ChatSessionItemController has been disposed'); } - return new ChatSessionItemImpl(resource, label, () => { - // TODO: Optimize to only update the specific item - onItemsChanged(); + const item = new ChatSessionItemImpl(resource, label, () => { + void this._proxy.$updateChatSessionItem(controllerHandle, typeConvert.ChatSessionItem.from(item)); }); + return item; }, dispose: () => { isDisposed = true; @@ -466,49 +466,6 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }); } - private convertChatSessionStatus(status: vscode.ChatSessionStatus | undefined): ChatSessionStatus | undefined { - if (status === undefined) { - return undefined; - } - - switch (status) { - case 0: // vscode.ChatSessionStatus.Failed - return ChatSessionStatus.Failed; - case 1: // vscode.ChatSessionStatus.Completed - return ChatSessionStatus.Completed; - case 2: // vscode.ChatSessionStatus.InProgress - return ChatSessionStatus.InProgress; - // Need to support NeedsInput status if we ever export it to the extension API - default: - return undefined; - } - } - - private convertChatSessionItem(sessionContent: vscode.ChatSessionItem): IChatSessionItem { - // Support both new (created, lastRequestStarted, lastRequestEnded) and old (startTime, endTime) timing properties - const timing = sessionContent.timing; - const created = timing?.created ?? timing?.startTime ?? 0; - const lastRequestStarted = timing?.lastRequestStarted ?? timing?.startTime; - const lastRequestEnded = timing?.lastRequestEnded ?? timing?.endTime; - - return { - resource: sessionContent.resource, - label: sessionContent.label, - description: sessionContent.description ? typeConvert.MarkdownString.from(sessionContent.description) : undefined, - badge: sessionContent.badge ? typeConvert.MarkdownString.from(sessionContent.badge) : undefined, - status: this.convertChatSessionStatus(sessionContent.status), - archived: sessionContent.archived, - tooltip: typeConvert.MarkdownString.fromStrict(sessionContent.tooltip), - timing: { - created, - lastRequestStarted, - lastRequestEnded, - }, - changes: sessionContent.changes instanceof Array ? sessionContent.changes : undefined, - metadata: sessionContent.metadata, - }; - } - async $provideChatSessionContent(handle: number, sessionResourceComponents: UriComponents, token: CancellationToken): Promise { const provider = this._chatSessionContentProviders.get(handle); if (!provider) { diff --git a/src/vs/workbench/api/common/extHostHooks.ts b/src/vs/workbench/api/common/extHostHooks.ts deleted file mode 100644 index d03d803c47c30..0000000000000 --- a/src/vs/workbench/api/common/extHostHooks.ts +++ /dev/null @@ -1,21 +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 type * as vscode from 'vscode'; -import { CancellationToken } from '../../../base/common/cancellation.js'; -import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; -import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; -import { ExtHostHooksShape } from './extHost.protocol.js'; - -export const IExtHostHooks = createDecorator('IExtHostHooks'); - -export interface IChatHookExecutionOptions { - readonly input?: unknown; - readonly toolInvocationToken: unknown; -} - -export interface IExtHostHooks extends ExtHostHooksShape { - executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise; -} diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 885302dcb9b35..cf4575655c432 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -16,6 +16,7 @@ import { parse, revive } from '../../../base/common/marshalling.js'; import { MarshalledId } from '../../../base/common/marshallingIds.js'; import { Mimes } from '../../../base/common/mime.js'; import { cloneAndChange } from '../../../base/common/objects.js'; +import { OS } from '../../../base/common/platform.js'; import { IPrefixTreeNode, WellDefinedPrefixTree } from '../../../base/common/prefixTree.js'; import { basename } from '../../../base/common/resources.js'; import { ThemeIcon } from '../../../base/common/themables.js'; @@ -44,8 +45,9 @@ import { IChatRequestModeInstructions } from '../../contrib/chat/common/model/ch import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatExtensionsContent, IChatExternalToolInvocationUpdate, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMoveMessage, IChatMultiDiffDataSerialized, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTerminalToolInvocationData, IChatTextEdit, IChatThinkingPart, IChatToolInvocationSerialized, IChatTreeData, IChatUserActionEvent, IChatWarningMessage, IChatWorkspaceEdit } from '../../contrib/chat/common/chatService/chatService.js'; import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImageVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; +import { ChatSessionStatus, IChatSessionItem } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; +import { IChatRequestHooks, IHookCommand, resolveEffectiveCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; import { IToolInvocationContext, IToolResult, IToolResultInputOutputDetails, IToolResultOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../contrib/chat/common/tools/languageModelToolsService.js'; import * as chatProvider from '../../contrib/chat/common/languageModels.js'; import { IChatMessageDataPart, IChatResponseDataPart, IChatResponsePromptTsxPart, IChatResponseTextPart } from '../../contrib/chat/common/languageModels.js'; @@ -3437,6 +3439,7 @@ export namespace ChatAgentRequest { subAgentName: request.subAgentName, parentRequestId: request.parentRequestId, hasHooksEnabled: request.hasHooksEnabled ?? false, + hooks: request.hooks ? ChatRequestHooksConverter.to(request.hooks) : undefined, }; if (!isProposedApiEnabled(extension, 'chatParticipantPrivate')) { @@ -3464,6 +3467,8 @@ export namespace ChatAgentRequest { delete (requestWithAllProps as any).parentRequestId; // eslint-disable-next-line local/code-no-any-casts delete (requestWithAllProps as any).hasHooksEnabled; + // eslint-disable-next-line local/code-no-any-casts + delete (requestWithAllProps as any).hooks; } if (!isProposedApiEnabled(extension, 'chatParticipantAdditions')) { @@ -4082,13 +4087,86 @@ export namespace SourceControlInputBoxValidationType { } } -export namespace ChatHookResult { - export function to(result: IHookResult): vscode.ChatHookResult { +export namespace ChatRequestHooksConverter { + export function to(hooks: IChatRequestHooks): vscode.ChatRequestHooks { + const result: Record = {}; + for (const [hookType, commands] of Object.entries(hooks)) { + if (!commands || commands.length === 0) { + continue; + } + const converted: vscode.ChatHookCommand[] = []; + for (const cmd of commands) { + const resolved = ChatHookCommand.to(cmd); + if (resolved) { + converted.push(resolved); + } + } + if (converted.length > 0) { + result[hookType] = converted; + } + } + return result; + } +} + +export namespace ChatHookCommand { + export function to(hook: IHookCommand): vscode.ChatHookCommand | undefined { + const command = resolveEffectiveCommand(hook, OS); + if (!command) { + return undefined; + } return { - resultKind: result.resultKind, - stopReason: result.stopReason, - warningMessage: result.warningMessage, - output: result.output, + command, + cwd: hook.cwd, + env: hook.env, + timeout: hook.timeout, + }; + } +} + +export namespace ChatSessionItem { + + function convertStatus(status: vscode.ChatSessionStatus | undefined): ChatSessionStatus | undefined { + if (status === undefined) { + return undefined; + } + + switch (status) { + case 0: // vscode.ChatSessionStatus.Failed + return ChatSessionStatus.Failed; + case 1: // vscode.ChatSessionStatus.Completed + return ChatSessionStatus.Completed; + case 2: // vscode.ChatSessionStatus.InProgress + return ChatSessionStatus.InProgress; + case 3: // vscode.ChatSessionStatus.NeedsInput + return ChatSessionStatus.NeedsInput; + default: + return undefined; + } + } + + export function from(sessionContent: vscode.ChatSessionItem): Dto { + // Support both new (created, lastRequestStarted, lastRequestEnded) and old (startTime, endTime) timing properties + const timing = sessionContent.timing; + const created = timing?.created ?? timing?.startTime ?? 0; + const lastRequestStarted = timing?.lastRequestStarted ?? timing?.startTime; + const lastRequestEnded = timing?.lastRequestEnded ?? timing?.endTime; + + return { + resource: sessionContent.resource, + label: sessionContent.label, + description: sessionContent.description ? MarkdownString.from(sessionContent.description) : undefined, + badge: sessionContent.badge ? MarkdownString.from(sessionContent.badge) : undefined, + status: convertStatus(sessionContent.status), + archived: sessionContent.archived, + tooltip: MarkdownString.fromStrict(sessionContent.tooltip), + timing: { + created, + lastRequestStarted, + lastRequestEnded, + }, + changes: sessionContent.changes instanceof Array ? sessionContent.changes : undefined, + metadata: sessionContent.metadata, }; } } diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 9e47fb165dc7e..d7d1c805b8793 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3552,7 +3552,8 @@ export enum ChatLocation { export enum ChatSessionStatus { Failed = 0, Completed = 1, - InProgress = 2 + InProgress = 2, + NeedsInput = 3 } export class ChatSessionChangedFile { diff --git a/src/vs/workbench/api/node/extHost.node.services.ts b/src/vs/workbench/api/node/extHost.node.services.ts index 5f52766f40a0f..55acd8bd9c11a 100644 --- a/src/vs/workbench/api/node/extHost.node.services.ts +++ b/src/vs/workbench/api/node/extHost.node.services.ts @@ -31,8 +31,6 @@ import { IExtHostMpcService } from '../common/extHostMcp.js'; import { NodeExtHostMpcService } from './extHostMcpNode.js'; import { IExtHostAuthentication } from '../common/extHostAuthentication.js'; import { NodeExtHostAuthentication } from './extHostAuthentication.js'; -import { IExtHostHooks } from '../common/extHostHooks.js'; -import { NodeExtHostHooks } from './extHostHooksNode.js'; // ######################################################################### // ### ### @@ -55,4 +53,3 @@ registerSingleton(IExtHostTerminalService, ExtHostTerminalService, Instantiation registerSingleton(IExtHostTunnelService, NodeExtHostTunnelService, InstantiationType.Eager); registerSingleton(IExtHostVariableResolverProvider, NodeExtHostVariableResolverProviderService, InstantiationType.Eager); registerSingleton(IExtHostMpcService, NodeExtHostMpcService, InstantiationType.Eager); -registerSingleton(IExtHostHooks, NodeExtHostHooks, InstantiationType.Eager); diff --git a/src/vs/workbench/api/node/extHostHooksNode.ts b/src/vs/workbench/api/node/extHostHooksNode.ts deleted file mode 100644 index 1b00ae2a27147..0000000000000 --- a/src/vs/workbench/api/node/extHostHooksNode.ts +++ /dev/null @@ -1,196 +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 type * as vscode from 'vscode'; -import { spawn } from 'child_process'; -import { homedir } from 'os'; -import * as nls from '../../../nls.js'; -import { disposableTimeout } from '../../../base/common/async.js'; -import { CancellationToken } from '../../../base/common/cancellation.js'; -import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { OS } from '../../../base/common/platform.js'; -import { URI, isUriComponents } from '../../../base/common/uri.js'; -import { ILogService } from '../../../platform/log/common/log.js'; -import { HookTypeValue, getEffectiveCommandSource, resolveEffectiveCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; -import { isToolInvocationContext, IToolInvocationContext } from '../../contrib/chat/common/tools/languageModelToolsService.js'; -import { IHookCommandDto, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js'; -import { IChatHookExecutionOptions, IExtHostHooks } from '../common/extHostHooks.js'; -import { IExtHostRpcService } from '../common/extHostRpcService.js'; -import { HookCommandResultKind, IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; -import * as typeConverters from '../common/extHostTypeConverters.js'; - -const SIGKILL_DELAY_MS = 5000; - -export class NodeExtHostHooks implements IExtHostHooks { - - private readonly _mainThreadProxy: MainThreadHooksShape; - - constructor( - @IExtHostRpcService extHostRpc: IExtHostRpcService, - @ILogService private readonly _logService: ILogService - ) { - this._mainThreadProxy = extHostRpc.getProxy(MainContext.MainThreadHooks); - } - - async executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise { - if (!options.toolInvocationToken || !isToolInvocationContext(options.toolInvocationToken)) { - this._logService.error('[NodeExtHostHooks] Invalid or missing tool invocation token'); - return []; - } - - const context = options.toolInvocationToken as IToolInvocationContext; - - const results = await this._mainThreadProxy.$executeHook(hookType, context.sessionResource, options.input, token ?? CancellationToken.None); - return results.map(r => typeConverters.ChatHookResult.to(r as IHookResult)); - } - - async $runHookCommand(hookCommand: IHookCommandDto, input: unknown, token: CancellationToken): Promise { - this._logService.debug(`[ExtHostHooks] Running hook command: ${JSON.stringify(hookCommand)}`); - - try { - return await this._executeCommand(hookCommand, input, token); - } catch (err) { - return { - kind: HookCommandResultKind.Error, - result: err instanceof Error ? err.message : String(err) - }; - } - } - - private _executeCommand(hook: IHookCommandDto, input: unknown, token?: CancellationToken): Promise { - const home = homedir(); - const cwdUri = hook.cwd ? URI.revive(hook.cwd) : undefined; - const cwd = cwdUri ? cwdUri.fsPath : home; - - // Resolve the effective command for the current platform - // This applies windows/linux/osx overrides and falls back to command - const effectiveCommand = resolveEffectiveCommand(hook as Parameters[0], OS); - if (!effectiveCommand) { - return Promise.resolve({ - kind: HookCommandResultKind.NonBlockingError, - result: nls.localize('noCommandForPlatform', "No command specified for the current platform") - }); - } - - // Execute the command, preserving legacy behavior for explicit shell types: - // - powershell source: run through PowerShell so PowerShell-specific commands work - // - bash source: run through bash so bash-specific commands work - // - otherwise: use default shell via spawn with shell: true - const commandSource = getEffectiveCommandSource(hook as Parameters[0], OS); - let shellExecutable: string | undefined; - let shellArgs: string[] | undefined; - - if (commandSource === 'powershell') { - shellExecutable = 'powershell.exe'; - shellArgs = ['-Command', effectiveCommand]; - } else if (commandSource === 'bash') { - shellExecutable = 'bash'; - shellArgs = ['-c', effectiveCommand]; - } - - const child = shellExecutable && shellArgs - ? spawn(shellExecutable, shellArgs, { - stdio: 'pipe', - cwd, - env: { ...process.env, ...hook.env }, - }) - : spawn(effectiveCommand, [], { - stdio: 'pipe', - cwd, - env: { ...process.env, ...hook.env }, - shell: true, - }); - - return new Promise((resolve, reject) => { - const stdout: string[] = []; - const stderr: string[] = []; - let exitCode: number | null = null; - let exited = false; - - const disposables = new DisposableStore(); - const sigkillTimeout = disposables.add(new MutableDisposable()); - - const killWithEscalation = () => { - if (exited) { - return; - } - child.kill('SIGTERM'); - sigkillTimeout.value = disposableTimeout(() => { - if (!exited) { - child.kill('SIGKILL'); - } - }, SIGKILL_DELAY_MS); - }; - - const cleanup = () => { - exited = true; - disposables.dispose(); - }; - - // Collect output - child.stdout.on('data', data => stdout.push(data.toString())); - child.stderr.on('data', data => stderr.push(data.toString())); - - // Set up timeout (default 30 seconds) - disposables.add(disposableTimeout(killWithEscalation, (hook.timeoutSec ?? 30) * 1000)); - - // Set up cancellation - if (token) { - disposables.add(token.onCancellationRequested(killWithEscalation)); - } - - // Write input to stdin - if (input !== undefined && input !== null) { - try { - // Use a replacer to convert URI values to filesystem paths. - // URIs arrive as UriComponents objects via the RPC boundary. - child.stdin.write(JSON.stringify(input, (_key, value) => { - if (isUriComponents(value)) { - return URI.revive(value).fsPath; - } - return value; - })); - } catch { - // Ignore stdin write errors - } - } - child.stdin.end(); - - // Capture exit code - child.on('exit', code => { exitCode = code; }); - - // Resolve on close (after streams flush) - child.on('close', () => { - cleanup(); - const code = exitCode ?? 1; - const stdoutStr = stdout.join(''); - const stderrStr = stderr.join(''); - - if (code === 0) { - // Success - try to parse stdout as JSON, otherwise return as string - let result: string | object = stdoutStr; - try { - result = JSON.parse(stdoutStr); - } catch { - // Keep as string if not valid JSON - } - resolve({ kind: HookCommandResultKind.Success, result }); - } else if (code === 2) { - // Blocking error - show stderr to model and stop processing - resolve({ kind: HookCommandResultKind.Error, result: stderrStr }); - } else { - // Non-blocking error - show stderr to user only - resolve({ kind: HookCommandResultKind.NonBlockingError, result: stderrStr }); - } - }); - - child.on('error', err => { - cleanup(); - reject(err); - }); - }); - } -} diff --git a/src/vs/workbench/api/test/node/extHostHooks.test.ts b/src/vs/workbench/api/test/node/extHostHooks.test.ts deleted file mode 100644 index f398cffd3f5ef..0000000000000 --- a/src/vs/workbench/api/test/node/extHostHooks.test.ts +++ /dev/null @@ -1,137 +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 assert from 'assert'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../../platform/log/common/log.js'; -import { NodeExtHostHooks } from '../../node/extHostHooksNode.js'; -import { IHookCommandDto, MainThreadHooksShape } from '../../common/extHost.protocol.js'; -import { HookCommandResultKind } from '../../../contrib/chat/common/hooks/hooksCommandTypes.js'; -import { IHookResult } from '../../../contrib/chat/common/hooks/hooksTypes.js'; -import { IExtHostRpcService } from '../../common/extHostRpcService.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; - -function createHookCommandDto(command: string, options?: Partial>): IHookCommandDto { - return { - type: 'command', - command, - ...options, - }; -} - -function createMockExtHostRpcService(mainThreadProxy: MainThreadHooksShape): IExtHostRpcService { - return { - _serviceBrand: undefined, - getProxy(): T { - return mainThreadProxy as unknown as T; - }, - set(_identifier: unknown, instance: R): R { - return instance; - }, - dispose(): void { }, - assertRegistered(): void { }, - drain(): Promise { return Promise.resolve(); }, - } as IExtHostRpcService; -} - -suite.skip('ExtHostHooks', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - let hooksService: NodeExtHostHooks; - - setup(() => { - const mockMainThreadProxy: MainThreadHooksShape = { - $executeHook: async (): Promise => { - return []; - }, - dispose: () => { } - }; - - const mockRpcService = createMockExtHostRpcService(mockMainThreadProxy); - hooksService = new NodeExtHostHooks(mockRpcService, new NullLogService()); - }); - - test('$runHookCommand runs command and returns success result', async () => { - const hookCommand = createHookCommandDto('echo "hello world"'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Success); - assert.strictEqual((result.result as string).trim(), 'hello world'); - }); - - test('$runHookCommand parses JSON output', async () => { - const hookCommand = createHookCommandDto('echo \'{"key": "value"}\''); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Success); - assert.deepStrictEqual(result.result, { key: 'value' }); - }); - - test('$runHookCommand returns non-blocking error for exit code 1', async () => { - const hookCommand = createHookCommandDto('exit 1'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.NonBlockingError); - }); - - test('$runHookCommand returns blocking error for exit code 2', async () => { - const hookCommand = createHookCommandDto('exit 2'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Error); - }); - - test('$runHookCommand captures stderr on non-blocking error', async () => { - const hookCommand = createHookCommandDto('echo "error message" >&2 && exit 1'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.NonBlockingError); - assert.strictEqual((result.result as string).trim(), 'error message'); - }); - - test('$runHookCommand captures stderr on blocking error', async () => { - const hookCommand = createHookCommandDto('echo "blocking error" >&2 && exit 2'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Error); - assert.strictEqual((result.result as string).trim(), 'blocking error'); - }); - - test('$runHookCommand passes input to stdin as JSON', async () => { - const hookCommand = createHookCommandDto('cat'); - const input = { tool: 'bash', args: { command: 'ls' } }; - const result = await hooksService.$runHookCommand(hookCommand, input, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Success); - assert.deepStrictEqual(result.result, input); - }); - - test('$runHookCommand returns non-blocking error for invalid command', async () => { - const hookCommand = createHookCommandDto('/nonexistent/command/that/does/not/exist'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - // Invalid commands typically return non-zero exit codes (127 for command not found) - // which are treated as non-blocking errors unless it's exit code 2 - assert.strictEqual(result.kind, HookCommandResultKind.NonBlockingError); - }); - - test('$runHookCommand uses custom environment variables', async () => { - const hookCommand = createHookCommandDto('echo $MY_VAR', { env: { MY_VAR: 'custom_value' } }); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Success); - assert.strictEqual((result.result as string).trim(), 'custom_value'); - }); - - test('$runHookCommand uses custom cwd', async () => { - const hookCommand = createHookCommandDto('pwd', { cwd: URI.file('/tmp') }); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Success); - // The result should contain /tmp or /private/tmp (macOS symlink) - assert.ok((result.result as string).includes('tmp')); - }); -}); diff --git a/src/vs/workbench/api/worker/extHost.worker.services.ts b/src/vs/workbench/api/worker/extHost.worker.services.ts index 14ec71b80e9a5..d6055bcf0f6d4 100644 --- a/src/vs/workbench/api/worker/extHost.worker.services.ts +++ b/src/vs/workbench/api/worker/extHost.worker.services.ts @@ -8,12 +8,10 @@ import { InstantiationType, registerSingleton } from '../../../platform/instanti import { ILogService } from '../../../platform/log/common/log.js'; import { ExtHostAuthentication, IExtHostAuthentication } from '../common/extHostAuthentication.js'; import { IExtHostExtensionService } from '../common/extHostExtensionService.js'; -import { IExtHostHooks } from '../common/extHostHooks.js'; import { ExtHostLogService } from '../common/extHostLogService.js'; import { ExtensionStoragePaths, IExtensionStoragePaths } from '../common/extHostStoragePaths.js'; import { ExtHostTelemetry, IExtHostTelemetry } from '../common/extHostTelemetry.js'; import { ExtHostExtensionService } from './extHostExtensionService.js'; -import { WorkerExtHostHooks } from './extHostHooksWorker.js'; // ######################################################################### // ### ### @@ -26,4 +24,3 @@ registerSingleton(IExtHostAuthentication, ExtHostAuthentication, InstantiationTy registerSingleton(IExtHostExtensionService, ExtHostExtensionService, InstantiationType.Eager); registerSingleton(IExtensionStoragePaths, ExtensionStoragePaths, InstantiationType.Eager); registerSingleton(IExtHostTelemetry, new SyncDescriptor(ExtHostTelemetry, [true], true)); -registerSingleton(IExtHostHooks, WorkerExtHostHooks, InstantiationType.Eager); diff --git a/src/vs/workbench/api/worker/extHostHooksWorker.ts b/src/vs/workbench/api/worker/extHostHooksWorker.ts deleted file mode 100644 index 3bd7fcf6edf8e..0000000000000 --- a/src/vs/workbench/api/worker/extHostHooksWorker.ts +++ /dev/null @@ -1,50 +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 type * as vscode from 'vscode'; -import { CancellationToken } from '../../../base/common/cancellation.js'; -import { ILogService } from '../../../platform/log/common/log.js'; -import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; -import { isToolInvocationContext, IToolInvocationContext } from '../../contrib/chat/common/tools/languageModelToolsService.js'; -import { IHookCommandDto, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js'; -import { IChatHookExecutionOptions, IExtHostHooks } from '../common/extHostHooks.js'; -import { IExtHostRpcService } from '../common/extHostRpcService.js'; -import * as typeConverters from '../common/extHostTypeConverters.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; -import { HookCommandResultKind, IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js'; - -export class WorkerExtHostHooks implements IExtHostHooks { - - private readonly _mainThreadProxy: MainThreadHooksShape; - - constructor( - @IExtHostRpcService extHostRpc: IExtHostRpcService, - @ILogService private readonly _logService: ILogService - ) { - this._mainThreadProxy = extHostRpc.getProxy(MainContext.MainThreadHooks); - } - - async executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise { - if (!options.toolInvocationToken || !isToolInvocationContext(options.toolInvocationToken)) { - this._logService.error('[WorkerExtHostHooks] Invalid or missing tool invocation token'); - return []; - } - - const context = options.toolInvocationToken as IToolInvocationContext; - - const results = await this._mainThreadProxy.$executeHook(hookType, context.sessionResource, options.input, token ?? CancellationToken.None); - return results.map(r => typeConverters.ChatHookResult.to(r as IHookResult)); - } - - async $runHookCommand(_hookCommand: IHookCommandDto, _input: unknown, _token: CancellationToken): Promise { - this._logService.debug('[WorkerExtHostHooks] Hook commands are not supported in web worker context'); - - // Web worker cannot run shell commands - return an error - return { - kind: HookCommandResultKind.Error, - result: 'Hook commands are not supported in web worker context' - }; - } -} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 8270198d11069..ff81e1273fa91 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -7,7 +7,9 @@ import { localize } from '../../../../../nls.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { URI } from '../../../../../base/common/uri.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { observableValue } from '../../../../../base/common/observable.js'; import { IChatSessionTiming } from '../../common/chatService/chatService.js'; +import { IChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; import { foreground, listActiveSelectionForeground, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; @@ -17,6 +19,7 @@ export enum AgentSessionProviders { Cloud = 'copilot-cloud-agent', Claude = 'claude-code', Codex = 'openai-codex', + Growth = 'copilot-growth', } export function isBuiltInAgentSessionProvider(provider: string): boolean { @@ -34,24 +37,33 @@ export function getAgentSessionProvider(sessionResource: URI | string): AgentSes case AgentSessionProviders.Cloud: case AgentSessionProviders.Claude: case AgentSessionProviders.Codex: + case AgentSessionProviders.Growth: return type; default: return undefined; } } +/** + * Observable holding the display name for the background agent session provider. + * Updated via experiment treatment to allow A/B testing of the display name. + */ +export const backgroundAgentDisplayName = observableValue('backgroundAgentDisplayName', localize('chat.session.providerLabel.background', "Background")); + export function getAgentSessionProviderName(provider: AgentSessionProviders): string { switch (provider) { case AgentSessionProviders.Local: return localize('chat.session.providerLabel.local', "Local"); case AgentSessionProviders.Background: - return localize('chat.session.providerLabel.background', "Background"); + return backgroundAgentDisplayName.get(); case AgentSessionProviders.Cloud: return localize('chat.session.providerLabel.cloud', "Cloud"); case AgentSessionProviders.Claude: return 'Claude'; case AgentSessionProviders.Codex: return 'Codex'; + case AgentSessionProviders.Growth: + return 'Growth'; } } @@ -67,6 +79,8 @@ export function getAgentSessionProviderIcon(provider: AgentSessionProviders): Th return Codicon.openai; case AgentSessionProviders.Claude: return Codicon.claude; + case AgentSessionProviders.Growth: + return Codicon.lightbulb; } } @@ -78,11 +92,16 @@ export function isFirstPartyAgentSessionProvider(provider: AgentSessionProviders return true; case AgentSessionProviders.Claude: case AgentSessionProviders.Codex: + case AgentSessionProviders.Growth: return false; } } -export function getAgentCanContinueIn(provider: AgentSessionProviders): boolean { +export function getAgentCanContinueIn(provider: AgentSessionProviders, contribution?: IChatSessionsExtensionPoint): boolean { + // Read-only sessions (e.g., Growth) are passive/informational and cannot be delegation targets + if (contribution?.isReadOnly) { + return false; + } switch (provider) { case AgentSessionProviders.Local: case AgentSessionProviders.Background: @@ -90,6 +109,7 @@ export function getAgentCanContinueIn(provider: AgentSessionProviders): boolean return true; case AgentSessionProviders.Claude: case AgentSessionProviders.Codex: + case AgentSessionProviders.Growth: return false; } } @@ -106,6 +126,8 @@ export function getAgentSessionProviderDescription(provider: AgentSessionProvide return localize('chat.session.providerDescription.claude', "Delegate tasks to the Claude Agent SDK using the Claude models included in your GitHub Copilot subscription. The agent iterates via chat and works interactively to implement changes on your main workspace."); case AgentSessionProviders.Codex: return localize('chat.session.providerDescription.codex', "Opens a new Codex session in the editor. Codex sessions can be managed from the chat sessions view."); + case AgentSessionProviders.Growth: + return localize('chat.session.providerDescription.growth', "Educational messages to help you learn Copilot features."); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts index 209ea059bccc8..f83e4d138ac7b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts @@ -11,7 +11,7 @@ import { registerAction2, Action2, MenuId } from '../../../../../platform/action import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; -import { AgentSessionProviders, getAgentSessionProviderName } from './agentSessions.js'; +import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderName } from './agentSessions.js'; import { AgentSessionStatus, IAgentSession } from './agentSessionsModel.js'; import { IAgentSessionsFilter, IAgentSessionsFilterExcludes } from './agentSessionsViewer.js'; @@ -127,17 +127,21 @@ export class AgentSessionsFilter extends Disposable implements Required ({ - id: provider, - label: getAgentSessionProviderName(provider) - })); + const providers: { id: string; label: string }[] = [{ + id: AgentSessionProviders.Local, + label: getAgentSessionProviderName(AgentSessionProviders.Local) + }]; - for (const provider of this.chatSessionsService.getAllChatSessionContributions()) { - if (providers.find(p => p.id === provider.type)) { + for (const contribution of this.chatSessionsService.getAllChatSessionContributions()) { + if (providers.find(p => p.id === contribution.type)) { continue; // already added } - providers.push({ id: provider.type, label: provider.name }); + const knownProvider = getAgentSessionProvider(contribution.type); + providers.push({ + id: contribution.type, + label: knownProvider ? getAgentSessionProviderName(knownProvider) : contribution.displayName + }); } const that = this; diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 10e81458395cc..f51de3f44f1dd 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -52,7 +52,6 @@ import { ILanguageModelsService, LanguageModelsService } from '../common/languag import { ILanguageModelStatsService, LanguageModelStatsService } from '../common/languageModelStats.js'; import { ILanguageModelToolsConfirmationService } from '../common/tools/languageModelToolsConfirmationService.js'; import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; -import { HooksExecutionService, IHooksExecutionService } from '../common/hooks/hooksExecutionService.js'; import { ChatPromptFilesExtensionPointHandler } from '../common/promptSyntax/chatPromptFilesContribution.js'; import { PromptsConfig } from '../common/promptSyntax/config/config.js'; import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, DEFAULT_SKILL_SOURCE_FOLDERS, AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, SKILL_FILENAME, CLAUDE_AGENTS_SOURCE_FOLDER, CLAUDE_RULES_SOURCE_FOLDER, DEFAULT_HOOK_FILE_PATHS } from '../common/promptSyntax/config/promptFileLocations.js'; @@ -90,6 +89,7 @@ import { registerChatToolActions } from './actions/chatToolActions.js'; import { ChatTransferContribution } from './actions/chatTransfer.js'; import { registerChatCustomizationDiagnosticsAction } from './actions/chatCustomizationDiagnosticsAction.js'; import './agentSessions/agentSessions.contribution.js'; +import { backgroundAgentDisplayName } from './agentSessions/agentSessions.js'; import { IAgentSessionsService } from './agentSessions/agentSessionsService.js'; import { IChatAccessibilityService, IChatCodeBlockContextProviderService, IChatWidgetService, IQuickChatService } from './chat.js'; import { ChatAccessibilityService } from './accessibility/chatAccessibilityService.js'; @@ -356,7 +356,7 @@ configurationRegistry.registerConfiguration({ '**/*.lock': false, // yarn.lock, bun.lock, etc. '**/*-lock.{yaml,json}': false, // pnpm-lock.yaml, package-lock.json }, - markdownDescription: nls.localize('chat.tools.autoApprove.edits', "Controls whether edits made by chat are automatically approved. The default is to approve all edits except those made to certain files which have the potential to cause immediate unintended side-effects, such as `**/.vscode/*.json`.\n\nSet to `true` to automatically approve edits to matching files, `false` to always require explicit approval. The last pattern matching a given file will determine whether the edit is automatically approved."), + markdownDescription: nls.localize('chat.tools.autoApprove.edits', "Controls whether edits made by the agent are automatically approved. The default is to approve all edits except those made to certain files which have the potential to cause immediate unintended side-effects, such as `**/.vscode/*.json`.\n\nSet to `true` to automatically approve edits to matching files, `false` to always require explicit approval. The last pattern matching a given file will determine whether the edit is automatically approved."), type: 'object', additionalProperties: { type: 'boolean', @@ -1191,37 +1191,6 @@ class ChatResolverContribution extends Disposable { } } -class ChatHooksProgressContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.chatHooksProgress'; - - constructor( - @IChatService private readonly chatService: IChatService, - @IHooksExecutionService hooksExecutionService: IHooksExecutionService, - ) { - super(); - - this._register(hooksExecutionService.onDidHookProgress(event => { - const model = this.chatService.getSession(event.sessionResource); - if (!model) { - return; - } - - const request = model.getRequests().at(-1); - if (!request) { - return; - } - - this.chatService.appendProgress(request, { - kind: 'hook', - hookType: event.hookType, - stopReason: event.stopReason, - systemMessage: event.systemMessage, - }); - })); - } -} - class ChatAgentSettingContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.chatAgentSetting'; @@ -1232,6 +1201,7 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr ) { super(); this.registerMaxRequestsSetting(); + this.registerBackgroundAgentDisplayName(); } @@ -1262,6 +1232,14 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr }; this._register(Event.runAndSubscribe(Event.debounce(this.entitlementService.onDidChangeEntitlement, () => { }, 1000), () => registerMaxRequestsSetting())); } + + private registerBackgroundAgentDisplayName(): void { + this.experimentService.getTreatment('backgroundAgentDisplayName').then((value) => { + if (value) { + backgroundAgentDisplayName.set(value, undefined); + } + }); + } } @@ -1430,6 +1408,16 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { }, async () => { await instantiationService.invokeFunction(showConfigureHooksQuickPick); })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'debug', + detail: nls.localize('debug', "Show Chat Debug View"), + sortText: 'z3_debug', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('github.copilot.debug.showChatLogView'); + })); this._store.add(slashCommandService.registerSlashCommand({ command: 'agents', detail: nls.localize('agents', "Configure custom agents"), @@ -1528,7 +1516,6 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer(ChatEditorInput.TypeID, ChatEditorInputSerializer); registerWorkbenchContribution2(ChatResolverContribution.ID, ChatResolverContribution, WorkbenchPhase.BlockStartup); -registerWorkbenchContribution2(ChatHooksProgressContribution.ID, ChatHooksProgressContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatLanguageModelsDataContribution.ID, ChatLanguageModelsDataContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatSlashStaticSlashCommandsContribution.ID, ChatSlashStaticSlashCommandsContribution, WorkbenchPhase.Eventually); @@ -1609,7 +1596,6 @@ registerSingleton(ICodeMapperService, CodeMapperService, InstantiationType.Delay registerSingleton(IChatEditingService, ChatEditingService, InstantiationType.Delayed); registerSingleton(IChatMarkdownAnchorService, ChatMarkdownAnchorService, InstantiationType.Delayed); registerSingleton(ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService, InstantiationType.Delayed); -registerSingleton(IHooksExecutionService, HooksExecutionService, InstantiationType.Delayed); registerSingleton(IPromptsService, PromptsService, InstantiationType.Delayed); registerSingleton(IChatContextPickService, ChatContextPickService, InstantiationType.Delayed); registerSingleton(IChatModeService, ChatModeService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts index 388edd3f00c23..b25f186b1e481 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts @@ -25,6 +25,7 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { EditorActivation } from '../../../../../platform/editor/common/editor.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IEditorPane } from '../../../../common/editor.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; @@ -895,3 +896,71 @@ CommandsRegistry.registerCommand('_chat.editSessions.accept', async (accessor: S await editingSession.accept(...uris); } }); + +//#region View as Tree / View as List toggle + +export const CHAT_EDITS_VIEW_MODE_STORAGE_KEY = 'chat.editsViewMode'; +export const ChatEditsViewAsTreeActionId = 'chatEditing.viewAsTree'; +export const ChatEditsViewAsListActionId = 'chatEditing.viewAsList'; + +registerAction2(class ChatEditsViewAsTreeAction extends Action2 { + constructor() { + super({ + id: ChatEditsViewAsTreeActionId, + title: localize2('chatEditing.viewAsTree', "View as Tree"), + icon: Codicon.listFlat, + category: CHAT_CATEGORY, + menu: [ + { + id: MenuId.ChatEditingWidgetToolbar, + group: 'navigation', + order: 5, + when: ContextKeyExpr.and(hasAppliedChatEditsContextKey, ChatContextKeys.chatEditsInTreeView.negate()), + }, + { + id: MenuId.ChatEditingSessionChangesToolbar, + group: 'navigation', + order: 5, + when: ContextKeyExpr.and(ChatContextKeys.hasAgentSessionChanges, ChatContextKeys.chatEditsInTreeView.negate()), + }, + ], + }); + } + + run(accessor: ServicesAccessor): void { + const storageService = accessor.get(IStorageService); + storageService.store(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, 'tree', StorageScope.PROFILE, StorageTarget.USER); + } +}); + +registerAction2(class ChatEditsViewAsListAction extends Action2 { + constructor() { + super({ + id: ChatEditsViewAsListActionId, + title: localize2('chatEditing.viewAsList', "View as List"), + icon: Codicon.listTree, + category: CHAT_CATEGORY, + menu: [ + { + id: MenuId.ChatEditingWidgetToolbar, + group: 'navigation', + order: 5, + when: ContextKeyExpr.and(hasAppliedChatEditsContextKey, ChatContextKeys.chatEditsInTreeView), + }, + { + id: MenuId.ChatEditingSessionChangesToolbar, + group: 'navigation', + order: 5, + when: ContextKeyExpr.and(ChatContextKeys.hasAgentSessionChanges, ChatContextKeys.chatEditsInTreeView), + }, + ], + }); + } + + run(accessor: ServicesAccessor): void { + const storageService = accessor.get(IStorageService); + storageService.store(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, 'list', StorageScope.PROFILE, StorageTarget.USER); + } +}); + +//#endregion diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 9076add61bd85..8a6372af31d1a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -44,7 +44,7 @@ import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatViewId } from '../chat.js'; import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; -import { AgentSessionProviders, getAgentSessionProviderName } from '../agentSessions/agentSessions.js'; +import { AgentSessionProviders, backgroundAgentDisplayName, getAgentSessionProviderName } from '../agentSessions/agentSessions.js'; import { BugIndicatingError, isCancellationError } from '../../../../../base/common/errors.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { LocalChatSessionUri } from '../../common/model/chatUri.js'; @@ -200,6 +200,11 @@ const extensionPoint = ExtensionsRegistry.registerExtensionPoint Array.from(this._contributions.keys()).filter(isAgentSessionProviderType) as AgentSessionProviders[], + () => Array.from(this._contributions.keys()).filter(key => this._contributionDisposables.has(key) && isAgentSessionProviderType(key)) as AgentSessionProviders[], ).recomputeInitiallyAndOnChange(this._store); this._register(autorun(reader => { + backgroundAgentDisplayName.read(reader); const activatedProviders = [...builtinSessionProviders, ...contributedSessionProviders.read(reader)]; for (const provider of Object.values(AgentSessionProviders)) { if (activatedProviders.includes(provider)) { @@ -647,7 +653,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ private _enableContribution(contribution: IChatSessionsExtensionPoint, ext: IRelaxedExtensionDescription): void { const disposableStore = new DisposableStore(); this._contributionDisposables.set(contribution.type, disposableStore); - if (contribution.canDelegate) { + if (contribution.isReadOnly || contribution.canDelegate) { disposableStore.add(this._registerAgent(contribution, ext)); disposableStore.add(this._registerCommands(contribution)); } diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 5d673cca9965c..5c55c8159adff 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -164,13 +164,13 @@ const TIP_CATALOG: ITipDefinition[] = [ }, { id: 'tip.messageQueueing', - message: localize('tip.messageQueueing', "Tip: You can send follow-up messages while the agent is working. They'll be queued and processed in order."), + message: localize('tip.messageQueueing', "Tip: You can send follow-up messages and steering while the agent is working. They'll be queued and processed in order."), when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), excludeWhenCommandsExecuted: ['workbench.action.chat.queueMessage', 'workbench.action.chat.steerWithMessage'], }, { id: 'tip.yoloMode', - message: localize('tip.yoloMode', "Tip: Enable [auto-approve mode](command:workbench.action.openSettings?%5B%22chat.tools.global.autoApprove%22%5D) to let the agent run tools without manual confirmation."), + message: localize('tip.yoloMode', "Tip: Enable [auto approval](command:workbench.action.openSettings?%5B%22chat.tools.global.autoApprove%22%5D) to let the agent run tools without manual confirmation."), when: ContextKeyExpr.and( ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), ContextKeyExpr.notEquals('config.chat.tools.global.autoApprove', true), @@ -179,13 +179,13 @@ const TIP_CATALOG: ITipDefinition[] = [ }, { id: 'tip.mermaid', - message: localize('tip.mermaid', "Tip: Ask the agent to visualize architectures and flows; it can render Mermaid diagrams directly in chat."), + message: localize('tip.mermaid', "Tip: Ask the agent to draw an architectural diagram or flow chart; it can render Mermaid diagrams directly in chat."), when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), excludeWhenToolsInvoked: ['renderMermaidDiagram'], }, { id: 'tip.githubRepo', - message: localize('tip.githubRepo', "Tip: Mention a GitHub repository (e.g. @owner/repo) in your prompt so the agent can query code and issues across that repo."), + message: localize('tip.githubRepo', "Tip: Mention a GitHub repository (e.g. @owner/repo) in your prompt to let the agent search code, browse issues, and explore pull requests from that repo."), when: ContextKeyExpr.and( ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), ContextKeyExpr.notEquals('gitOpenRepositoryCount', '0'), @@ -194,13 +194,13 @@ const TIP_CATALOG: ITipDefinition[] = [ }, { id: 'tip.subagents', - message: localize('tip.subagents', "Tip: Ask the agent to implement a plan in parallel; it can delegate work across subagents for faster results."), + message: localize('tip.subagents', "Tip: For large tasks, ask the agent to work in parallel. It can split the work across subagents to finish faster."), when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), excludeWhenToolsInvoked: ['runSubagent'], }, { id: 'tip.contextUsage', - message: localize('tip.contextUsage', "Tip: [View your context window usage](command:workbench.action.chat.showContextUsage) to see how many tokens are being spent and what's consuming them."), + message: localize('tip.contextUsage', "Tip: [View your context window usage](command:workbench.action.chat.showContextUsage) to see how many tokens are being used and what's consuming them."), when: ContextKeyExpr.and( ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), ChatContextKeys.contextUsageHasBeenOpened.negate(), diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts index d62abbb822cb4..f94a9c168cd51 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts @@ -299,7 +299,7 @@ class CollapsibleListDelegate implements IListVirtualDelegate { +export class CollapsibleListRenderer implements IListRenderer { static TEMPLATE_ID = 'chatCollapsibleListRenderer'; readonly templateId: string = CollapsibleListRenderer.TEMPLATE_ID; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts index c9e3d0c4a962e..9125e269fb32b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts @@ -132,7 +132,7 @@ export class ChatSuggestNextWidget extends Disposable { return false; } const provider = getAgentSessionProvider(c.type); - return provider !== undefined && getAgentCanContinueIn(provider); + return provider !== undefined && getAgentCanContinueIn(provider, c); }); if (showContinueOn && availableContributions.length > 0) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/codeBlockPart.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/codeBlockPart.css index 9a70eec7939b9..7a9b9f736b7f1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/codeBlockPart.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/codeBlockPart.css @@ -21,6 +21,7 @@ line-height: 26px; background-color: var(--vscode-interactive-result-editor-background-color, var(--vscode-editor-background)); border: 1px solid var(--vscode-chat-requestBorder); + border-radius: var(--vscode-cornerRadius-medium); z-index: 100; max-width: 70%; text-overflow: ellipsis; @@ -32,7 +33,6 @@ } .interactive-result-code-block .interactive-result-code-block-toolbar > .monaco-toolbar { - border-radius: 3px; right: 10px; } @@ -50,7 +50,6 @@ .interactive-result-code-block .interactive-result-code-block-toolbar:focus-within, .interactive-result-code-block.focused .interactive-result-code-block-toolbar { opacity: 1; - border-radius: 2px; pointer-events: auto; } @@ -79,7 +78,7 @@ .interactive-result-code-block, .interactive-result-code-block .monaco-editor, .interactive-result-code-block .monaco-editor .overflow-guard { - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-medium); } .interactive-result-code-block .interactive-result-vulns { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatEditsTree.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatEditsTree.ts new file mode 100644 index 0000000000000..8917b4edfa8fe --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatEditsTree.ts @@ -0,0 +1,636 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../base/browser/dom.js'; +import { addDisposableListener } from '../../../../../../base/browser/dom.js'; +import { ITreeRenderer, ITreeNode, IObjectTreeElement, ObjectTreeElementCollapseState } from '../../../../../../base/browser/ui/tree/tree.js'; +import { IIdentityProvider, IListVirtualDelegate } from '../../../../../../base/browser/ui/list/list.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { comparePaths } from '../../../../../../base/common/comparers.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { matchesSomeScheme, Schemas } from '../../../../../../base/common/network.js'; +import { basename } from '../../../../../../base/common/path.js'; +import { basenameOrAuthority, dirname, isEqual, isEqualAuthority, isEqualOrParent } from '../../../../../../base/common/resources.js'; +import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../nls.js'; +import { MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; +import { MenuId } from '../../../../../../platform/actions/common/actions.js'; +import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { FileKind } from '../../../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../../../platform/instantiation/common/serviceCollection.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { IOpenEvent, WorkbenchList, WorkbenchObjectTree } from '../../../../../../platform/list/browser/listService.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; +import { IStorageService, StorageScope } from '../../../../../../platform/storage/common/storage.js'; +import { isDark } from '../../../../../../platform/theme/common/theme.js'; +import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; +import { IResourceLabel, ResourceLabels } from '../../../../../browser/labels.js'; +import { SETTINGS_AUTHORITY } from '../../../../../services/preferences/common/preferences.js'; +import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; +import { ChatResponseReferencePartStatusKind, IChatContentReference } from '../../../common/chatService/chatService.js'; +import { chatEditingWidgetFileStateContextKey, IChatEditingSession } from '../../../common/editing/chatEditingService.js'; +import { CHAT_EDITS_VIEW_MODE_STORAGE_KEY } from '../../chatEditing/chatEditingActions.js'; +import { createFileIconThemableTreeContainerScope } from '../../../../files/browser/views/explorerView.js'; +import { CollapsibleListPool, IChatCollapsibleListItem, ICollapsibleListTemplate } from '../chatContentParts/chatReferencesContentPart.js'; +import { IDisposableReference } from '../chatContentParts/chatCollections.js'; + +const $ = dom.$; + +/** + * Represents a folder node in the tree view. + */ +export interface IChatEditsFolderElement { + readonly kind: 'folder'; + readonly uri: URI; + readonly children: IChatCollapsibleListItem[]; +} + +/** + * Union type for elements in the chat edits tree. + */ +export type IChatEditsTreeElement = IChatCollapsibleListItem | IChatEditsFolderElement; + +/** + * Find the common ancestor directory among a set of URIs. + * Returns undefined if the URIs have no common ancestor (different schemes/authorities). + */ +function findCommonAncestorUri(uris: readonly URI[]): URI | undefined { + if (uris.length === 0) { + return undefined; + } + let common = uris[0]; + for (let i = 1; i < uris.length; i++) { + while (!isEqualOrParent(uris[i], common)) { + const parent = dirname(common); + if (isEqual(parent, common)) { + return undefined; // reached filesystem root + } + common = parent; + } + } + return common; +} + +/** + * Convert a flat list of chat edits items into a tree grouped by directory. + * Files at the common ancestor directory are shown at the root level without a folder row. + */ +export function buildEditsTree(items: readonly IChatCollapsibleListItem[]): IObjectTreeElement[] { + // Group files by their directory + const folderMap = new Map(); + const itemsWithoutUri: IChatCollapsibleListItem[] = []; + + for (const item of items) { + if (item.kind === 'reference' && URI.isUri(item.reference)) { + const folderUri = dirname(item.reference); + const key = folderUri.toString(); + let group = folderMap.get(key); + if (!group) { + group = { uri: folderUri, items: [] }; + folderMap.set(key, group); + } + group.items.push(item); + } else { + itemsWithoutUri.push(item); + } + } + + const result: IObjectTreeElement[] = []; + + // Add items without URIs as top-level items (e.g., warnings) + for (const item of itemsWithoutUri) { + result.push({ element: item }); + } + + if (folderMap.size === 0) { + return result; + } + + // Find common ancestor so we can flatten files at the root level + const folderUris = [...folderMap.values()].map(f => f.uri); + const commonAncestor = findCommonAncestorUri(folderUris); + + // Sort folders by path + const sortedFolders = [...folderMap.values()].sort((a, b) => + comparePaths(a.uri.fsPath, b.uri.fsPath) + ); + + // Emit folders first, then root-level files (matching search tree behavior) + const rootFiles: IObjectTreeElement[] = []; + for (const folder of sortedFolders) { + const isAtCommonAncestor = commonAncestor && isEqual(folder.uri, commonAncestor); + if (isAtCommonAncestor) { + // Files at the common ancestor go at the root level, after all folders + for (const item of folder.items) { + rootFiles.push({ element: item }); + } + } else { + const folderElement: IChatEditsFolderElement = { + kind: 'folder', + uri: folder.uri, + children: folder.items, + }; + result.push({ + element: folderElement, + children: folder.items.map(item => ({ element: item as IChatEditsTreeElement })), + collapsible: true, + collapsed: ObjectTreeElementCollapseState.PreserveOrExpanded, + }); + } + } + + // Root-level files come after folders + result.push(...rootFiles); + + return result; +} + +/** + * Convert a flat list into tree elements without grouping (list mode). + */ +export function buildEditsList(items: readonly IChatCollapsibleListItem[]): IObjectTreeElement[] { + return items.map(item => ({ element: item as IChatEditsTreeElement })); +} + +/** + * Delegate for the chat edits tree that returns element heights and template IDs. + */ +export class ChatEditsTreeDelegate implements IListVirtualDelegate { + getHeight(_element: IChatEditsTreeElement): number { + return 22; + } + + getTemplateId(element: IChatEditsTreeElement): string { + if (element.kind === 'folder') { + return ChatEditsFolderRenderer.TEMPLATE_ID; + } + return ChatEditsFileTreeRenderer.TEMPLATE_ID; + } +} + +/** + * Identity provider for the chat edits tree. + * Provides stable string IDs so the tree can preserve collapse/selection state across updates. + */ +export class ChatEditsTreeIdentityProvider implements IIdentityProvider { + getId(element: IChatEditsTreeElement): string { + if (element.kind === 'folder') { + return `folder:${element.uri.toString()}`; + } + if (element.kind === 'warning') { + return `warning:${element.content.value}`; + } + const ref = element.reference; + if (typeof ref === 'string') { + return `ref:${ref}`; + } else if (URI.isUri(ref)) { + return `file:${ref.toString()}`; + } else { + // eslint-disable-next-line local/code-no-in-operator + return `file:${'uri' in ref ? ref.uri.toString() : String(ref)}`; + } + } +} + +interface IChatEditsFolderTemplate { + readonly label: IResourceLabel; + readonly templateDisposables: DisposableStore; +} + +/** + * Renderer for folder elements in the chat edits tree. + */ +export class ChatEditsFolderRenderer implements ITreeRenderer { + static readonly TEMPLATE_ID = 'chatEditsFolderRenderer'; + readonly templateId = ChatEditsFolderRenderer.TEMPLATE_ID; + + constructor( + private readonly labels: ResourceLabels, + private readonly labelService: ILabelService, + ) { } + + renderTemplate(container: HTMLElement): IChatEditsFolderTemplate { + const templateDisposables = new DisposableStore(); + const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true })); + return { label, templateDisposables }; + } + + renderElement(node: ITreeNode, _index: number, templateData: IChatEditsFolderTemplate): void { + const element = node.element; + if (element.kind !== 'folder') { + return; + } + const relativeLabel = this.labelService.getUriLabel(element.uri, { relative: true }); + templateData.label.setResource( + { resource: element.uri, name: relativeLabel || basename(element.uri.path) }, + { fileKind: FileKind.FOLDER, fileDecorations: undefined } + ); + } + + disposeTemplate(templateData: IChatEditsFolderTemplate): void { + templateData.templateDisposables.dispose(); + } +} + +/** + * Tree renderer for file elements in the chat edits tree. + * Adapted from CollapsibleListRenderer to work with ITreeNode. + */ +export class ChatEditsFileTreeRenderer implements ITreeRenderer { + static readonly TEMPLATE_ID = 'chatEditsFileRenderer'; + readonly templateId = ChatEditsFileTreeRenderer.TEMPLATE_ID; + + constructor( + private readonly labels: ResourceLabels, + private readonly menuId: MenuId | undefined, + @IThemeService private readonly themeService: IThemeService, + @IProductService private readonly productService: IProductService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + ) { } + + renderTemplate(container: HTMLElement): ICollapsibleListTemplate { + const templateDisposables = new DisposableStore(); + const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true })); + + const fileDiffsContainer = $('.working-set-line-counts'); + const addedSpan = dom.$('.working-set-lines-added'); + const removedSpan = dom.$('.working-set-lines-removed'); + fileDiffsContainer.appendChild(addedSpan); + fileDiffsContainer.appendChild(removedSpan); + label.element.appendChild(fileDiffsContainer); + + let toolbar; + let actionBarContainer; + let contextKeyService; + if (this.menuId) { + actionBarContainer = $('.chat-collapsible-list-action-bar'); + contextKeyService = templateDisposables.add(this.contextKeyService.createScoped(actionBarContainer)); + const scopedInstantiationService = templateDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService]))); + toolbar = templateDisposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, this.menuId, { menuOptions: { shouldForwardArgs: true, arg: undefined } })); + label.element.appendChild(actionBarContainer); + } + + return { templateDisposables, label, toolbar, actionBarContainer, contextKeyService, fileDiffsContainer, addedSpan, removedSpan }; + } + + private getReferenceIcon(data: IChatContentReference): URI | ThemeIcon | undefined { + if (ThemeIcon.isThemeIcon(data.iconPath)) { + return data.iconPath; + } else { + return isDark(this.themeService.getColorTheme().type) && data.iconPath?.dark + ? data.iconPath?.dark + : data.iconPath?.light; + } + } + + renderElement(node: ITreeNode, _index: number, templateData: ICollapsibleListTemplate): void { + const data = node.element; + if (data.kind === 'folder') { + return; + } + + if (data.kind === 'warning') { + templateData.label.setResource({ name: data.content.value }, { icon: Codicon.warning }); + return; + } + + const reference = data.reference; + const icon = this.getReferenceIcon(data); + templateData.label.element.style.display = 'flex'; + let arg: URI | undefined; + // eslint-disable-next-line local/code-no-in-operator + if (typeof reference === 'object' && 'variableName' in reference) { + if (reference.value) { + const uri = URI.isUri(reference.value) ? reference.value : reference.value.uri; + templateData.label.setResource( + { + resource: uri, + name: basenameOrAuthority(uri), + description: `#${reference.variableName}`, + // eslint-disable-next-line local/code-no-in-operator + range: 'range' in reference.value ? reference.value.range : undefined, + }, { icon, title: data.options?.status?.description ?? data.title }); + } else if (reference.variableName.startsWith('kernelVariable')) { + const variable = reference.variableName.split(':')[1]; + const asVariableName = `${variable}`; + const label = `Kernel variable`; + templateData.label.setLabel(label, asVariableName, { title: data.options?.status?.description }); + } else { + templateData.label.setLabel('Unknown variable type: ' + reference.variableName); + } + } else if (typeof reference === 'string') { + templateData.label.setLabel(reference, undefined, { iconPath: URI.isUri(icon) ? icon : undefined, title: data.options?.status?.description ?? data.title }); + } else { + // eslint-disable-next-line local/code-no-in-operator + const uri = 'uri' in reference ? reference.uri : reference; + arg = uri; + if (uri.scheme === 'https' && isEqualAuthority(uri.authority, 'github.com') && uri.path.includes('/tree/')) { + templateData.label.setResource({ resource: uri, name: basename(uri.path) }, { icon: Codicon.github, title: data.title }); + } else if (uri.scheme === this.productService.urlProtocol && isEqualAuthority(uri.authority, SETTINGS_AUTHORITY)) { + const settingId = uri.path.substring(1); + templateData.label.setResource({ resource: uri, name: settingId }, { icon: Codicon.settingsGear, title: localize('setting.hover', "Open setting '{0}'", settingId) }); + } else if (matchesSomeScheme(uri, Schemas.mailto, Schemas.http, Schemas.https)) { + templateData.label.setResource({ resource: uri, name: uri.toString(true) }, { icon: icon ?? Codicon.globe, title: data.options?.status?.description ?? data.title ?? uri.toString(true) }); + } else { + templateData.label.setFile(uri, { + fileKind: FileKind.FILE, + fileDecorations: undefined, + // eslint-disable-next-line local/code-no-in-operator + range: 'range' in reference ? reference.range : undefined, + title: data.options?.status?.description ?? data.title, + }); + } + } + + for (const selector of ['.monaco-icon-suffix-container', '.monaco-icon-name-container']) { + // eslint-disable-next-line no-restricted-syntax + const element = templateData.label.element.querySelector(selector); + if (element) { + if (data.options?.status?.kind === ChatResponseReferencePartStatusKind.Omitted || data.options?.status?.kind === ChatResponseReferencePartStatusKind.Partial) { + element.classList.add('warning'); + } else { + element.classList.remove('warning'); + } + } + } + + if (data.state !== undefined) { + if (templateData.actionBarContainer) { + const diffMeta = data?.options?.diffMeta; + if (diffMeta) { + if (!templateData.fileDiffsContainer || !templateData.addedSpan || !templateData.removedSpan) { + return; + } + templateData.addedSpan.textContent = `+${diffMeta.added}`; + templateData.removedSpan.textContent = `-${diffMeta.removed}`; + templateData.fileDiffsContainer.setAttribute('aria-label', localize('chatEditingSession.fileCounts', '{0} lines added, {1} lines removed', diffMeta.added, diffMeta.removed)); + } + // eslint-disable-next-line no-restricted-syntax + templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.add('modified'); + } + if (templateData.toolbar) { + templateData.toolbar.context = arg; + } + if (templateData.contextKeyService) { + chatEditingWidgetFileStateContextKey.bindTo(templateData.contextKeyService).set(data.state); + } + } + } + + disposeTemplate(templateData: ICollapsibleListTemplate): void { + templateData.templateDisposables.dispose(); + } +} + +/** + * Widget that renders the chat edits file list, supporting both flat list and tree views. + * Manages the lifecycle of the underlying tree or list widget, and handles toggling between modes. + */ +export class ChatEditsListWidget extends Disposable { + private readonly _onDidFocus = this._register(new Emitter()); + readonly onDidFocus: Event = this._onDidFocus.event; + + private readonly _onDidOpen = this._register(new Emitter>()); + readonly onDidOpen: Event> = this._onDidOpen.event; + + private _tree: WorkbenchObjectTree | undefined; + private _list: IDisposableReference> | undefined; + + private readonly _listPool: CollapsibleListPool; + private readonly _widgetDisposables = this._register(new DisposableStore()); + private readonly _chatEditsInTreeView: IContextKey; + + private _currentContainer: HTMLElement | undefined; + private _currentSession: IChatEditingSession | null = null; + private _lastEntries: readonly IChatCollapsibleListItem[] = []; + + get currentSession(): IChatEditingSession | null { + return this._currentSession; + } + + get selectedElements(): URI[] { + const edits: URI[] = []; + if (this._tree) { + for (const element of this._tree.getSelection()) { + if (element && element.kind === 'reference' && URI.isUri(element.reference)) { + edits.push(element.reference); + } + } + } else if (this._list) { + for (const element of this._list.object.getSelectedElements()) { + if (element.kind === 'reference' && URI.isUri(element.reference)) { + edits.push(element.reference); + } + } + } + return edits; + } + + constructor( + private readonly onDidChangeVisibility: Event, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IStorageService private readonly storageService: IStorageService, + @IThemeService private readonly themeService: IThemeService, + @ILabelService private readonly labelService: ILabelService, + ) { + super(); + + this._listPool = this._register(this.instantiationService.createInstance( + CollapsibleListPool, + this.onDidChangeVisibility, + MenuId.ChatEditingWidgetModifiedFilesToolbar, + { verticalScrollMode: ScrollbarVisibility.Visible }, + )); + + this._chatEditsInTreeView = ChatContextKeys.chatEditsInTreeView.bindTo(contextKeyService); + this._chatEditsInTreeView.set(this._isTreeMode); + + this._register(this.storageService.onDidChangeValue(StorageScope.PROFILE, CHAT_EDITS_VIEW_MODE_STORAGE_KEY, this._store)(() => { + const isTree = this._isTreeMode; + this._chatEditsInTreeView.set(isTree); + if (this._currentContainer) { + this.create(this._currentContainer, this._currentSession); + this.setEntries(this._lastEntries); + } + })); + } + + private get _isTreeMode(): boolean { + return this.storageService.get(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, StorageScope.PROFILE, 'list') === 'tree'; + } + + /** + * Creates the appropriate widget (tree or list) inside the given container. + * Must be called before {@link setEntries}. + */ + create(container: HTMLElement, chatEditingSession: IChatEditingSession | null): void { + this._currentContainer = container; + this._currentSession = chatEditingSession; + this.clear(); + dom.clearNode(container); + + if (this._isTreeMode) { + this._createTree(container, chatEditingSession); + } else { + this._createList(container, chatEditingSession); + } + } + + /** + * Rebuild the widget (e.g. after a view mode toggle). + */ + rebuild(container: HTMLElement, chatEditingSession: IChatEditingSession | null): void { + this.create(container, chatEditingSession); + } + + /** + * Whether the current view mode has changed since the widget was last created. + */ + get needsRebuild(): boolean { + if (this._isTreeMode) { + return !this._tree; + } + return !this._list; + } + + /** + * Update the displayed entries. + */ + setEntries(entries: readonly IChatCollapsibleListItem[]): void { + this._lastEntries = entries; + if (this._tree) { + const treeElements = this._isTreeMode + ? buildEditsTree(entries) + : buildEditsList(entries); + + // Use the file entry count for height, not the tree-expanded count, + // so height stays consistent when toggling between tree and list modes + const maxItemsShown = 6; + const itemsShown = Math.min(entries.length, maxItemsShown); + const height = itemsShown * 22; + this._tree.layout(height); + this._tree.getHTMLElement().style.height = `${height}px`; + this._tree.setChildren(null, treeElements); + } else if (this._list) { + const maxItemsShown = 6; + const itemsShown = Math.min(entries.length, maxItemsShown); + const height = itemsShown * 22; + const list = this._list.object; + list.layout(height); + list.getHTMLElement().style.height = `${height}px`; + list.splice(0, list.length, entries); + } + } + + /** + * Dispose the current tree or list widget without disposing the outer widget. + */ + clear(): void { + this._widgetDisposables.clear(); + this._tree = undefined; + this._list = undefined; + } + + private _createTree(container: HTMLElement, chatEditingSession: IChatEditingSession | null): void { + const resourceLabels = this._widgetDisposables.add(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeVisibility })); + const treeContainer = dom.$('.chat-used-context-list'); + this._widgetDisposables.add(createFileIconThemableTreeContainerScope(treeContainer, this.themeService)); + + const tree = this._widgetDisposables.add(this.instantiationService.createInstance( + WorkbenchObjectTree, + 'ChatEditsTree', + treeContainer, + new ChatEditsTreeDelegate(), + [ + new ChatEditsFolderRenderer(resourceLabels, this.labelService), + this.instantiationService.createInstance(ChatEditsFileTreeRenderer, resourceLabels, MenuId.ChatEditingWidgetModifiedFilesToolbar), + ], + { + alwaysConsumeMouseWheel: false, + accessibilityProvider: { + getAriaLabel: (element: IChatEditsTreeElement) => { + if (element.kind === 'folder') { + return this.labelService.getUriLabel(element.uri, { relative: true }); + } + if (element.kind === 'warning') { + return element.content.value; + } + const reference = element.reference; + if (typeof reference === 'string') { + return reference; + } else if (URI.isUri(reference)) { + return this.labelService.getUriBasenameLabel(reference); + // eslint-disable-next-line local/code-no-in-operator + } else if ('uri' in reference) { + return this.labelService.getUriBasenameLabel(reference.uri); + } else { + return ''; + } + }, + getWidgetAriaLabel: () => localize('chatEditsTree', "Changed Files"), + }, + identityProvider: new ChatEditsTreeIdentityProvider(), + verticalScrollMode: ScrollbarVisibility.Visible, + hideTwistiesOfChildlessElements: true, + } + )); + + tree.updateOptions({ enableStickyScroll: false }); + + this._tree = tree; + + this._widgetDisposables.add(tree.onDidChangeFocus(() => { + this._onDidFocus.fire(); + })); + + this._widgetDisposables.add(tree.onDidOpen(e => { + this._onDidOpen.fire(e); + })); + + this._widgetDisposables.add(addDisposableListener(tree.getHTMLElement(), 'click', () => { + this._onDidFocus.fire(); + }, true)); + + dom.append(container, tree.getHTMLElement()); + } + + private _createList(container: HTMLElement, chatEditingSession: IChatEditingSession | null): void { + this._list = this._listPool.get(); + const list = this._list.object; + this._widgetDisposables.add(this._list); + + this._widgetDisposables.add(list.onDidFocus(() => { + this._onDidFocus.fire(); + })); + + this._widgetDisposables.add(list.onDidOpen(async (e) => { + if (e.element) { + this._onDidOpen.fire({ + element: e.element as IChatEditsTreeElement, + editorOptions: e.editorOptions, + sideBySide: e.sideBySide, + browserEvent: e.browserEvent, + }); + } + })); + + this._widgetDisposables.add(addDisposableListener(list.getHTMLElement(), 'click', () => { + this._onDidFocus.fire(); + }, true)); + + dom.append(container, list.getHTMLElement()); + } + + override dispose(): void { + this.clear(); + super.dispose(); + } +} 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 24ab3f42c018c..b0fd053396d1c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -31,7 +31,6 @@ 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'; -import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; import { assertType } from '../../../../../../base/common/types.js'; import { URI } from '../../../../../../base/common/uri.js'; import { IEditorConstructionOptions } from '../../../../../../editor/browser/config/editorConfiguration.js'; @@ -63,7 +62,6 @@ import { registerAndCreateHistoryNavigationContext } from '../../../../../../pla import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../../../platform/instantiation/common/serviceCollection.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; -import { WorkbenchList } from '../../../../../../platform/list/browser/listService.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { ObservableMemento, observableMemento } from '../../../../../../platform/observable/common/observableMemento.js'; import { bindContextKey } from '../../../../../../platform/observable/common/platformObservableUtils.js'; @@ -104,17 +102,17 @@ import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmen import { ChatImplicitContexts } from '../../attachments/chatImplicitContext.js'; import { ImplicitContextAttachmentWidget } from '../../attachments/implicitContextAttachment.js'; import { IChatWidget, ISessionTypePickerDelegate, isIChatResourceViewContext, isIChatViewViewContext, IWorkspacePickerDelegate } from '../../chat.js'; -import { ChatEditingShowChangesAction, ViewAllSessionChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js'; +import { ChatEditingShowChangesAction, ChatEditsViewAsListActionId, ChatEditsViewAsTreeActionId, 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 { ChatQuestionCarouselPart, IChatQuestionCarouselOptions } from '../chatContentParts/chatQuestionCarouselPart.js'; import { IChatContentPartRenderContext } from '../chatContentParts/chatContentParts.js'; -import { CollapsibleListPool, IChatCollapsibleListItem } from '../chatContentParts/chatReferencesContentPart.js'; +import { IChatCollapsibleListItem } from '../chatContentParts/chatReferencesContentPart.js'; import { ChatTodoListWidget } from '../chatContentParts/chatTodoListWidget.js'; import { ChatDragAndDrop } from '../chatDragAndDrop.js'; +import { ChatEditsListWidget } from './chatEditsTree.js'; import { ChatFollowups } from './chatFollowups.js'; import { ChatInputPartWidgetController } from './chatInputPartWidgets.js'; import { IChatInputPickerOptions } from './chatInputPickerActionItem.js'; @@ -420,21 +418,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private _workingSetLinesRemovedSpan = new Lazy(() => dom.$('.working-set-lines-removed')); private readonly _chatEditsActionsDisposables: DisposableStore = this._register(new DisposableStore()); - private readonly _chatEditsDisposables: DisposableStore = this._register(new DisposableStore()); private readonly _renderingChatEdits = this._register(new MutableDisposable()); - private _chatEditsListPool: CollapsibleListPool; - private _chatEditList: IDisposableReference> | undefined; + private readonly _chatEditsListWidget = this._register(new MutableDisposable()); get selectedElements(): URI[] { - const edits = []; - const editsList = this._chatEditList?.object; - const selectedElements = editsList?.getSelectedElements() ?? []; - for (const element of selectedElements) { - if (element.kind === 'reference' && URI.isUri(element.reference)) { - edits.push(element.reference); - } - } - return edits; + return this._chatEditsListWidget.value?.selectedElements ?? []; } private _attemptedWorkingSetEntriesCount: number = 0; @@ -590,8 +578,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.inputEditor.updateOptions(newOptions); })); - this._chatEditsListPool = this._register(this.instantiationService.createInstance(CollapsibleListPool, this._onDidChangeVisibility.event, MenuId.ChatEditingWidgetModifiedFilesToolbar, { verticalScrollMode: ScrollbarVisibility.Visible })); - this._hasFileAttachmentContextKey = ChatContextKeys.hasFileAttachments.bindTo(contextKeyService); this.initSelectedModel(); @@ -2583,8 +2569,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge ); } else { dom.clearNode(this.chatEditingSessionWidgetContainer); - this._chatEditsDisposables.clear(); - this._chatEditList = undefined; + this._chatEditsListWidget.value?.clear(); } }); } @@ -2677,7 +2662,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }) : undefined, disableWhileRunning: isSessionMenu, buttonConfigProvider: (action) => { - if (action.id === ChatEditingShowChangesAction.ID || action.id === ViewPreviousEditsAction.Id || action.id === ViewAllSessionChangesAction.ID) { + if (action.id === ChatEditingShowChangesAction.ID || action.id === ViewPreviousEditsAction.Id || action.id === ViewAllSessionChangesAction.ID + || action.id === ChatEditsViewAsTreeActionId || action.id === ChatEditsViewAsListActionId) { return { showIcon: true, showLabel: false, isSecondary: true }; } return undefined; @@ -2728,54 +2714,51 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge workingSetContainer.classList.toggle('collapsed', collapsed); })); - if (!this._chatEditList) { - this._chatEditList = this._chatEditsListPool.get(); - const list = this._chatEditList.object; - this._chatEditsDisposables.add(this._chatEditList); - this._chatEditsDisposables.add(list.onDidFocus(() => { - this._onDidFocus.fire(); - })); - this._chatEditsDisposables.add(list.onDidOpen(async (e) => { - if (e.element?.kind === 'reference' && URI.isUri(e.element.reference)) { - const modifiedFileUri = e.element.reference; - const originalUri = e.element.options?.originalUri; - - if (e.element.options?.isDeletion && originalUri) { - await this.editorService.openEditor({ - resource: originalUri, // instead of modified, because modified will not exist - options: e.editorOptions - }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + if (!this._chatEditsListWidget.value || this._chatEditsListWidget.value.needsRebuild) { + if (!this._chatEditsListWidget.value) { + const widget = this.instantiationService.createInstance(ChatEditsListWidget, this._onDidChangeVisibility.event); + this._chatEditsListWidget.value = widget; + this._register(widget.onDidFocus(() => this._onDidFocus.fire())); + this._register(widget.onDidOpen(async (e) => { + const element = e.element; + if (!element || element.kind === 'folder' || element.kind === 'warning') { return; } + if (element.kind === 'reference' && URI.isUri(element.reference)) { + const modifiedFileUri = element.reference; + const originalUri = element.options?.originalUri; + + if (element.options?.isDeletion && originalUri) { + await this.editorService.openEditor({ + resource: originalUri, + options: e.editorOptions + }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + return; + } - // If there's a originalUri, open as diff editor - if (originalUri) { - await this.editorService.openEditor({ - original: { resource: originalUri }, - modified: { resource: modifiedFileUri }, + if (originalUri) { + await this.editorService.openEditor({ + original: { resource: originalUri }, + modified: { resource: modifiedFileUri }, + options: e.editorOptions + }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + return; + } + + // Use the widget's current session, not a stale closure + const entry = widget.currentSession?.getEntry(modifiedFileUri); + const pane = await this.editorService.openEditor({ + resource: modifiedFileUri, options: e.editorOptions }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); - return; - } - - const entry = chatEditingSession?.getEntry(modifiedFileUri); - - const pane = await this.editorService.openEditor({ - resource: modifiedFileUri, - options: e.editorOptions - }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); - if (pane) { - entry?.getEditorIntegration(pane).reveal(true, e.editorOptions.preserveFocus); + if (pane) { + entry?.getEditorIntegration(pane).reveal(true, e.editorOptions.preserveFocus); + } } - } - })); - this._chatEditsDisposables.add(addDisposableListener(list.getHTMLElement(), 'click', e => { - if (!this.hasFocus()) { - this._onDidFocus.fire(); - } - }, true)); - dom.append(workingSetContainer, list.getHTMLElement()); + })); + } + this._chatEditsListWidget.value.rebuild(workingSetContainer, chatEditingSession); dom.append(innerContainer, workingSetContainer); } @@ -2788,13 +2771,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // entries, while background chat sessions use session file changes. const allEntries = editEntries.concat(sessionFileEntries); - const maxItemsShown = 6; - const itemsShown = Math.min(allEntries.length, maxItemsShown); - const height = itemsShown * 22; - const list = this._chatEditList!.object; - list.layout(height); - list.getHTMLElement().style.height = `${height}px`; - list.splice(0, list.length, allEntries); + this._chatEditsListWidget.value?.setEntries(allEntries); })); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts index 9e5218f2afbf1..37a075a3970bc 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts @@ -54,7 +54,8 @@ export class DelegationSessionPickerActionItem extends SessionTypePickerActionIt return true; // Always show active session type } - return getAgentCanContinueIn(type); + const contribution = this.chatSessionsService.getChatSessionContribution(type); + return getAgentCanContinueIn(type, contribution); } protected override _getSessionCategory(sessionTypeItem: ISessionTypeItem) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index 725f52f809293..c14a5416cda22 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -18,10 +18,11 @@ import { IKeybindingService } from '../../../../../../platform/keybinding/common import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; -import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderDescription, getAgentSessionProviderIcon, getAgentSessionProviderName, isFirstPartyAgentSessionProvider } from '../../agentSessions/agentSessions.js'; +import { AgentSessionProviders, backgroundAgentDisplayName, getAgentSessionProvider, getAgentSessionProviderDescription, getAgentSessionProviderIcon, getAgentSessionProviderName, isFirstPartyAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; import { ISessionTypePickerDelegate } from '../../chat.js'; import { IActionProvider } from '../../../../../../base/browser/ui/dropdown/dropdown.js'; +import { autorun } from '../../../../../../base/common/observable.js'; export interface ISessionTypeItem { type: AgentSessionProviders; @@ -100,10 +101,19 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { super(action, sessionTargetPickerOptions, pickerOptions, actionWidgetService, keybindingService, contextKeyService, telemetryService); - this._updateAgentSessionItems(); this._register(this.chatSessionsService.onDidChangeAvailability(() => { this._updateAgentSessionItems(); })); + + // Re-render when the background agent display name changes via experiment + // Note: autorun runs immediately, so this also handles initial population + this._register(autorun(reader => { + backgroundAgentDisplayName.read(reader); + this._updateAgentSessionItems(); + if (this.element) { + this.renderLabel(this.element); + } + })); } protected _run(sessionTypeItem: ISessionTypeItem): void { @@ -153,6 +163,10 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { const contributions = this.chatSessionsService.getAllChatSessionContributions(); for (const contribution of contributions) { + if (contribution.isReadOnly) { + continue; // Read-only sessions are not interactive and should not appear in session target picker + } + const agentSessionType = getAgentSessionProvider(contribution.type); if (!agentSessionType) { continue; diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 004b2fd6ecaee..4de68fd4633e8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -300,20 +300,39 @@ border-color: var(--vscode-textBlockQuote-border); } +.interactive-item-container .value .rendered-markdown strong { + font-weight: 600; +} + .interactive-item-container .value .rendered-markdown table { width: 100%; text-align: left; margin-bottom: 16px; + border-radius: var(--vscode-cornerRadius-medium); + overflow: hidden; + border-collapse: separate; + border-spacing: 0; + border: 1px solid var(--vscode-chat-requestBorder); } -.interactive-item-container .value .rendered-markdown table, .interactive-item-container .value .rendered-markdown table td, .interactive-item-container .value .rendered-markdown table th { border: 1px solid var(--vscode-chat-requestBorder); - border-collapse: collapse; + border-top: none; + border-left: none; padding: 4px 6px; } +.interactive-item-container .value .rendered-markdown table td:last-child, +.interactive-item-container .value .rendered-markdown table th:last-child { + border-right: none; +} + +.interactive-item-container .value .rendered-markdown table tr:last-child td, +.interactive-item-container .value .rendered-markdown table tr:last-child th { + border-bottom: none; +} + .interactive-item-container .value .rendered-markdown a, .interactive-item-container .value .interactive-session-followups, .interactive-item-container .value .rendered-markdown a code { @@ -609,6 +628,10 @@ padding-inline-start: 28px; } +.interactive-item-container .value .rendered-markdown li { + margin: 4px 0; +} + /* NOTE- We want to dedent codeblocks in lists specifically to give them the full width. No more elegant way to do this, these values have to be updated for changes to the rules above, or to support more deeply nested lists. */ .interactive-item-container .value .rendered-markdown ul .interactive-result-code-block { @@ -2101,6 +2124,12 @@ have to be updated for changes to the rules above, or to support more deeply nes display: none; } +/* Tree view: remove twistie indent for leaf (non-collapsible) file rows */ +.interactive-session .chat-editing-session-list .monaco-tl-twistie:not(.collapsible) { + width: 0; + padding-right: 0; +} + .interactive-session .chat-summary-list .monaco-list .monaco-list-row { border-radius: 4px; } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 6c7ae754e48fa..c11c625bc9c1a 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -65,7 +65,9 @@ import { IAgentSession } from '../../agentSessions/agentSessionsModel.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; interface IChatViewPaneState extends Partial { + /** @deprecated */ sessionId?: string; + sessionResource?: URI; sessionsSidebarWidth?: number; } @@ -128,7 +130,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { lifecycleService.startupKind !== StartupKind.ReloadedWindow && this.configurationService.getValue(ChatConfiguration.RestoreLastPanelSession) === false ) { - this.viewState.sessionId = undefined; // clear persisted session on fresh start + // clear persisted session on fresh start + this.viewState.sessionId = undefined; + this.viewState.sessionResource = undefined; } this.sessionsViewerVisible = false; // will be updated from layout code this.sessionsViewerSidebarWidth = Math.max(ChatViewPane.SESSIONS_SIDEBAR_MIN_WIDTH, this.viewState.sessionsSidebarWidth ?? ChatViewPane.SESSIONS_SIDEBAR_DEFAULT_WIDTH); @@ -267,6 +271,10 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { return this.chatService.transferredSessionResource; } + if (this.viewState.sessionResource) { + return this.viewState.sessionResource; + } + return this.viewState.sessionId ? LocalChatSessionUri.forSession(this.viewState.sessionId) : undefined; } @@ -675,7 +683,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { if (model) { await this.updateWidgetLockState(model.sessionResource); // Update widget lock state based on session type - this.viewState.sessionId = model.sessionId; // remember as model to restore in view state + // remember as model to restore in view state + this.viewState.sessionId = model.sessionId; + this.viewState.sessionResource = model.sessionResource; } this._widget.setModel(model); diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index d2d47e5842f8a..cb507daf273a4 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -119,6 +119,8 @@ export namespace ChatContextKeys { export const hasMultipleAgentSessionsSelected = new RawContextKey('agentSessionHasMultipleSelected', false, { type: 'boolean', description: localize('agentSessionHasMultipleSelected', "True when multiple agent sessions are selected.") }); export const hasAgentSessionChanges = new RawContextKey('agentSessionHasChanges', false, { type: 'boolean', description: localize('agentSessionHasChanges', "True when the current agent session item has changes.") }); + export const chatEditsInTreeView = new RawContextKey('chatEditsInTreeView', false, { type: 'boolean', description: localize('chatEditsInTreeView', "True when the chat edits working set is displayed as a tree.") }); + export const isKatexMathElement = new RawContextKey('chatIsKatexMathElement', false, { type: 'boolean', description: localize('chatIsKatexMathElement', "True when focusing a KaTeX math element.") }); export const contextUsageHasBeenOpened = new RawContextKey('chatContextUsageHasBeenOpened', false, { type: 'boolean', description: localize('chatContextUsageHasBeenOpened', "True when the user has opened the context window usage details.") }); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 838008c642b71..b55b824cd7d65 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -51,7 +51,6 @@ import { ILanguageModelToolsService } from '../tools/languageModelToolsService.j import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js'; import { IPromptsService } from '../promptSyntax/service/promptsService.js'; import { IChatRequestHooks } from '../promptSyntax/hookSchema.js'; -import { IHooksExecutionService } from '../hooks/hooksExecutionService.js'; const serializedChatKey = 'interactive.sessions'; @@ -156,7 +155,6 @@ export class ChatService extends Disposable implements IChatService { @IChatSessionsService private readonly chatSessionService: IChatSessionsService, @IMcpService private readonly mcpService: IMcpService, @IPromptsService private readonly promptsService: IPromptsService, - @IHooksExecutionService private readonly hooksExecutionService: IHooksExecutionService, ) { super(); @@ -911,10 +909,6 @@ export class ChatService extends Disposable implements IChatService { this.logService.warn('[ChatService] Failed to collect hooks:', error); } - if (collectedHooks) { - store.add(this.hooksExecutionService.registerHooks(model.sessionResource, collectedHooks)); - } - const stopWatch = new StopWatch(false); store.add(token.onCancellationRequested(() => { this.trace('sendRequest', `Request for session ${model.sessionResource} was cancelled`); diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 9f280a98fb88e..839510819e7c1 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -85,6 +85,7 @@ export interface IChatSessionsExtensionPoint { readonly capabilities?: IChatAgentAttachmentCapabilities; readonly commands?: IChatSessionCommandContribution[]; readonly canDelegate?: boolean; + readonly isReadOnly?: boolean; /** * When set, the chat session will show a filtered mode picker with custom agents * that have a matching `target` property. This enables contributed chat sessions diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts deleted file mode 100644 index 1b31c0fafd5af..0000000000000 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts +++ /dev/null @@ -1,67 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * External hook types - types that cross the process boundary to/from spawned hook commands. - * - * "External" means these types define the contract between VS Code and the external hook - * command process. - * - * Internal types (in hooksTypes.ts) are used within VS Code. - */ - -import { URI } from '../../../../../base/common/uri.js'; - -//#region Common Hook Types - -/** - * Common properties added to all hook command inputs. - */ -export interface IHookCommandInput { - readonly timestamp: string; - readonly cwd: URI; - readonly sessionId: string; - readonly hookEventName: string; - readonly transcript_path?: URI; -} - -/** - * Common output fields that can be present in any hook command result. - * These fields control execution flow and user feedback. - */ -export interface IHookCommandOutput { - /** - * If set, stops processing entirely after this hook. - * The message is shown to the user but not to the agent. - */ - readonly stopReason?: string; - /** - * Message shown to the user. - */ - readonly systemMessage?: string; -} - -export const enum HookCommandResultKind { - Success = 1, - /** Blocking error - shown to model */ - Error = 2, - /** Non-blocking error - shown to user only */ - NonBlockingError = 3 -} - -/** - * Raw result from spawning a hook command. - * This is the low-level result before semantic processing. - */ -export interface IHookCommandResult { - readonly kind: HookCommandResultKind; - /** - * For success, this is stdout (parsed as JSON if valid, otherwise string). - * For errors, this is stderr. - */ - readonly result: string | object; -} - -//#endregion diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts deleted file mode 100644 index 5e6f02e05b4db..0000000000000 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts +++ /dev/null @@ -1,465 +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 { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { Emitter, Event } from '../../../../../base/common/event.js'; -import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { StopWatch } from '../../../../../base/common/stopwatch.js'; -import { URI, isUriComponents } from '../../../../../base/common/uri.js'; -import { localize } from '../../../../../nls.js'; -import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; -import { Registry } from '../../../../../platform/registry/common/platform.js'; -import { Extensions, IOutputChannelRegistry, IOutputService } from '../../../../services/output/common/output.js'; -import { HookTypeValue, IChatRequestHooks, IHookCommand } from '../promptSyntax/hookSchema.js'; -import { - HookCommandResultKind, - IHookCommandInput, - IHookCommandResult, -} from './hooksCommandTypes.js'; -import { - commonHookOutputValidator, - IHookResult, -} from './hooksTypes.js'; - -export const hooksOutputChannelId = 'hooksExecution'; -const hooksOutputChannelLabel = localize('hooksExecutionChannel', "Hooks"); - -export interface IHooksExecutionOptions { - readonly input?: unknown; - readonly token?: CancellationToken; -} - -export interface IHookExecutedEvent { - readonly hookType: HookTypeValue; - readonly sessionResource: URI; - readonly input: unknown; - readonly results: readonly IHookResult[]; - readonly durationMs: number; -} - -/** - * Event fired when a hook produces progress that should be shown to the user. - */ -export interface IHookProgressEvent { - readonly hookType: HookTypeValue; - readonly sessionResource: URI; - readonly stopReason?: string; - readonly systemMessage?: string; -} - -/** - * Callback interface for hook execution proxies. - * MainThreadHooks implements this to forward calls to the extension host. - */ -export interface IHooksExecutionProxy { - runHookCommand(hookCommand: IHookCommand, input: unknown, token: CancellationToken): Promise; -} - -export const IHooksExecutionService = createDecorator('hooksExecutionService'); - -export interface IHooksExecutionService { - _serviceBrand: undefined; - - /** - * Fires when a hook has finished executing. - */ - readonly onDidExecuteHook: Event; - - /** - * Fires when a hook produces progress (warning or stop) that should be shown to the user. - */ - readonly onDidHookProgress: Event; - - /** - * Called by mainThreadHooks when extension host is ready - */ - setProxy(proxy: IHooksExecutionProxy): void; - - /** - * Register hooks for a session. Returns a disposable that unregisters them. - */ - registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable; - - /** - * Get hooks registered for a session. - */ - getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined; - - /** - * Execute hooks of the given type for the given session - */ - executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise; -} - -/** - * Keys that should be redacted when logging hook input. - */ -const redactedInputKeys = ['toolArgs']; - -export class HooksExecutionService extends Disposable implements IHooksExecutionService { - declare readonly _serviceBrand: undefined; - - private readonly _onDidExecuteHook = this._register(new Emitter()); - readonly onDidExecuteHook: Event = this._onDidExecuteHook.event; - - private readonly _onDidHookProgress = this._register(new Emitter()); - readonly onDidHookProgress: Event = this._onDidHookProgress.event; - - private _proxy: IHooksExecutionProxy | undefined; - private readonly _sessionHooks = new Map(); - /** Stored transcript path per session (keyed by session URI string). */ - private readonly _sessionTranscriptPaths = new Map(); - private _channelRegistered = false; - private _requestCounter = 0; - - constructor( - @ILogService private readonly _logService: ILogService, - @IOutputService private readonly _outputService: IOutputService, - ) { - super(); - } - - setProxy(proxy: IHooksExecutionProxy): void { - this._proxy = proxy; - } - - private _ensureOutputChannel(): void { - if (this._channelRegistered) { - return; - } - Registry.as(Extensions.OutputChannels).registerChannel({ - id: hooksOutputChannelId, - label: hooksOutputChannelLabel, - log: false - }); - this._channelRegistered = true; - } - - private _log(requestId: number, hookType: HookTypeValue, message: string): void { - this._ensureOutputChannel(); - const channel = this._outputService.getChannel(hooksOutputChannelId); - if (channel) { - channel.append(`${new Date().toISOString()} [#${requestId}] [${hookType}] ${message}\n`); - } - } - - private _redactForLogging(input: object): object { - const result: Record = { ...input }; - for (const key of redactedInputKeys) { - if (Object.hasOwn(result, key)) { - result[key] = '...'; - } - } - return result; - } - - /** - * JSON.stringify replacer that converts URI / UriComponents values to their string form. - */ - private readonly _uriReplacer = (_key: string, value: unknown): unknown => { - if (URI.isUri(value)) { - return value.fsPath; - } - if (isUriComponents(value)) { - return URI.revive(value).fsPath; - } - return value; - }; - - private async _runSingleHook( - requestId: number, - hookType: HookTypeValue, - hookCommand: IHookCommand, - sessionResource: URI, - callerInput: unknown, - transcriptPath: URI | undefined, - token: CancellationToken - ): Promise { - // Build the common hook input properties. - // URI values are kept as URI objects through the RPC boundary, and converted - // to filesystem paths on the extension host side during JSON serialization. - const commonInput: IHookCommandInput = { - timestamp: new Date().toISOString(), - cwd: hookCommand.cwd ?? URI.file(''), - sessionId: sessionResource.toString(), - hookEventName: hookType, - ...(transcriptPath ? { transcript_path: transcriptPath } : undefined), - }; - - // Merge common properties with caller-specific input - const fullInput = !!callerInput && typeof callerInput === 'object' - ? { ...commonInput, ...callerInput } - : commonInput; - - const hookCommandJson = JSON.stringify(hookCommand, this._uriReplacer); - this._log(requestId, hookType, `Running: ${hookCommandJson}`); - const inputForLog = this._redactForLogging(fullInput); - this._log(requestId, hookType, `Input: ${JSON.stringify(inputForLog, this._uriReplacer)}`); - - const sw = StopWatch.create(); - try { - const commandResult = await this._proxy!.runHookCommand(hookCommand, fullInput, token); - const result = this._toInternalResult(commandResult); - this._logCommandResult(requestId, hookType, commandResult, Math.round(sw.elapsed())); - return result; - } catch (err) { - const errMessage = err instanceof Error ? err.message : String(err); - this._log(requestId, hookType, `Error in ${Math.round(sw.elapsed())}ms: ${errMessage}`); - // Proxy errors (e.g., process spawn failure) are treated as warnings - return { - resultKind: 'warning', - output: undefined, - warningMessage: errMessage, - }; - } - } - - private _toInternalResult(commandResult: IHookCommandResult): IHookResult { - switch (commandResult.kind) { - case HookCommandResultKind.Error: { - // Exit code 2 - stop processing with message shown to user (not model) - // Equivalent to continue=false with stopReason=stderr - const message = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result); - return { - resultKind: 'error', - stopReason: message, - output: undefined, - }; - } - case HookCommandResultKind.NonBlockingError: { - // Non-blocking error - shown to user only as warning - const errorMessage = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result); - return { - resultKind: 'warning', - output: undefined, - warningMessage: errorMessage, - }; - } - case HookCommandResultKind.Success: { - // For string results, no common fields to extract - if (typeof commandResult.result !== 'object') { - return { - resultKind: 'success', - output: commandResult.result, - }; - } - - // Extract and validate common fields - const validationResult = commonHookOutputValidator.validate(commandResult.result); - const commonFields = validationResult.error ? {} : validationResult.content; - - // Extract only known hook-specific fields for output - const resultObj = commandResult.result as Record; - const hookOutput = this._extractHookSpecificOutput(resultObj); - - // Handle continue field: when false, stopReason is effective - // stopReason takes precedence if both are set - let stopReason = commonFields.stopReason; - if (commonFields.continue === false && !stopReason) { - stopReason = ''; // Empty string signals stop without a specific reason - } - - return { - resultKind: 'success', - stopReason, - warningMessage: commonFields.systemMessage, - output: Object.keys(hookOutput).length > 0 ? hookOutput : undefined, - }; - } - default: { - // Unexpected result kind - treat as warning - return { - resultKind: 'warning', - warningMessage: `Unexpected hook command result kind: ${(commandResult as IHookCommandResult).kind}`, - output: undefined, - }; - } - } - } - - /** - * Extract hook-specific output fields, excluding common fields. - */ - private _extractHookSpecificOutput(result: Record): Record { - const commonFields = new Set(['continue', 'stopReason', 'systemMessage']); - const output: Record = {}; - for (const [key, value] of Object.entries(result)) { - if (value !== undefined && !commonFields.has(key)) { - output[key] = value; - } - } - - return output; - } - - private _logCommandResult(requestId: number, hookType: HookTypeValue, result: IHookCommandResult, elapsed: number): void { - const resultKindStr = result.kind === HookCommandResultKind.Success ? 'Success' - : result.kind === HookCommandResultKind.NonBlockingError ? 'NonBlockingError' - : 'Error'; - const resultStr = typeof result.result === 'string' ? result.result : JSON.stringify(result.result); - const hasOutput = resultStr.length > 0 && resultStr !== '{}' && resultStr !== '[]'; - if (hasOutput) { - this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsed}ms`); - this._log(requestId, hookType, `Output: ${resultStr}`); - } else { - this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsed}ms, no output`); - } - } - - /** - * Extract `transcript_path` from hook input if present. - * The caller (e.g. SessionStart) may include it as a URI in the input object. - */ - private _extractTranscriptPath(input: unknown): URI | undefined { - if (typeof input !== 'object' || input === null) { - return undefined; - } - const transcriptPath = (input as Record)['transcriptPath']; - if (URI.isUri(transcriptPath)) { - return transcriptPath; - } - if (isUriComponents(transcriptPath)) { - return URI.revive(transcriptPath); - } - return undefined; - } - - /** - * Emit a hook progress event to show warnings or stop reasons to the user. - */ - private _emitHookProgress(hookType: HookTypeValue, sessionResource: URI, stopReason?: string, systemMessage?: string): void { - this._onDidHookProgress.fire({ - hookType, - sessionResource, - stopReason, - systemMessage, - }); - } - - /** - * Collect all warning messages from hook results and emit them as a single aggregated progress event. - * Uses numbered list formatting when there are multiple warnings. - */ - private _emitAggregatedWarnings(hookType: HookTypeValue, sessionResource: URI, results: readonly IHookResult[]): void { - const warnings = results - .filter(r => r.warningMessage !== undefined) - .map(r => r.warningMessage!); - - if (warnings.length > 0) { - const message = warnings.length === 1 - ? warnings[0] - : warnings.map((w, i) => `${i + 1}. ${w}`).join('\n'); - this._emitHookProgress(hookType, sessionResource, undefined, message); - } - } - - registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable { - const key = sessionResource.toString(); - this._sessionHooks.set(key, hooks); - return toDisposable(() => { - this._sessionHooks.delete(key); - this._sessionTranscriptPaths.delete(key); - }); - } - - getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined { - return this._sessionHooks.get(sessionResource.toString()); - } - - async executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise { - const sw = StopWatch.create(); - const results: IHookResult[] = []; - - try { - if (!this._proxy) { - return results; - } - - const sessionKey = sessionResource.toString(); - - // Extract and store transcript_path from input when present (e.g. SessionStart) - const inputTranscriptPath = this._extractTranscriptPath(options?.input); - if (inputTranscriptPath) { - this._sessionTranscriptPaths.set(sessionKey, inputTranscriptPath); - } - - const hooks = this.getHooksForSession(sessionResource); - if (!hooks) { - return results; - } - - const hookCommands = hooks[hookType]; - if (!hookCommands || hookCommands.length === 0) { - return results; - } - - const transcriptPath = this._sessionTranscriptPaths.get(sessionKey); - - const requestId = this._requestCounter++; - const token = options?.token ?? CancellationToken.None; - - this._logService.debug(`[HooksExecutionService] Executing ${hookCommands.length} hook(s) for type '${hookType}'`); - this._log(requestId, hookType, `Executing ${hookCommands.length} hook(s)`); - - for (const hookCommand of hookCommands) { - const result = await this._runSingleHook(requestId, hookType, hookCommand, sessionResource, options?.input, transcriptPath, token); - results.push(result); - - // If stopReason is set, stop processing remaining hooks - if (result.stopReason) { - this._log(requestId, hookType, `Stopping: ${result.stopReason}`); - break; - } - } - - // Emit aggregated warnings for any hook results that had warning messages - this._emitAggregatedWarnings(hookType, sessionResource, results); - - // If any hook set stopReason, emit progress so it's visible to the user - const stoppedResult = results.find(r => r.stopReason !== undefined); - if (stoppedResult?.stopReason) { - this._emitHookProgress(hookType, sessionResource, formatHookErrorMessage(stoppedResult.stopReason)); - } - - return results; - } finally { - this._onDidExecuteHook.fire({ - hookType, - sessionResource, - input: options?.input, - results, - durationMs: Math.round(sw.elapsed()), - }); - } - } - -} - -/** - * Error thrown when a hook requests the agent to abort processing. - * The message should be shown to the user. - */ -export class HookAbortError extends Error { - constructor( - public readonly hookType: string, - public readonly stopReason: string - ) { - super(`Hook ${hookType} aborted: ${stopReason}`); - this.name = 'HookAbortError'; - } -} - -/** - * Formats a localized error message for a failed hook. - * @param errorMessage The error message from the hook - * @returns A localized error message string - */ -export function formatHookErrorMessage(errorMessage: string): string { - if (errorMessage) { - return localize('hookFatalErrorWithMessage', 'A hook prevented chat from continuing. Please check the Hooks output channel for more details. Error message: {0}', errorMessage); - } - return localize('hookFatalError', 'A hook prevented chat from continuing. Please check the Hooks output channel for more details.'); -} diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts deleted file mode 100644 index 1c4bceb531406..0000000000000 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts +++ /dev/null @@ -1,56 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Internal hook types - types used within VS Code's hooks execution service. - * - * "Internal" means these types are used by VS Code code only - they never cross the - * process boundary to external hook commands. They use camelCase for field names. - * - * External types (in hooksCommandTypes.ts) define the contract with spawned commands. - */ - -import { vBoolean, vObj, vOptionalProp, vString } from '../../../../../base/common/validation.js'; - -//#region Common Hook Types - -/** - * The kind of result from executing a hook command. - */ -export type HookResultKind = 'success' | 'error' | 'warning'; - -/** - * Semantic hook result with common fields extracted and defaults applied. - * This is what callers receive from executeHook. - */ -export interface IHookResult { - /** - * The kind of result from executing the hook. - */ - readonly resultKind: HookResultKind; - /** - * If set, the agent should stop processing entirely after this hook. - * The message is shown to the user but not to the agent. - */ - readonly stopReason?: string; - /** - * Warning message shown to the user. - * (Mapped from `systemMessage` in command output, or stderr for non-blocking errors.) - */ - readonly warningMessage?: string; - /** - * The hook's output (hook-specific fields only). - * For errors, this is the error message string. - */ - readonly output: unknown; -} - -export const commonHookOutputValidator = vObj({ - continue: vOptionalProp(vBoolean()), - stopReason: vOptionalProp(vString()), - systemMessage: vOptionalProp(vString()), -}); - -//#endregion diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts index 297a0535e13cd..ccf368ed54a06 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts @@ -92,7 +92,7 @@ export interface IHookCommand { /** Resolved working directory URI. */ readonly cwd?: URI; readonly env?: Record; - readonly timeoutSec?: number; + readonly timeout?: number; /** Original JSON field name that provided the windows command. */ readonly windowsSource?: 'windows' | 'powershell'; /** Original JSON field name that provided the linux command. */ @@ -164,10 +164,10 @@ const hookCommandSchema: IJSONSchema = { additionalProperties: { type: 'string' }, description: nls.localize('hook.env', 'Additional environment variables that are merged with the existing environment.') }, - timeoutSec: { + timeout: { type: 'number', default: 30, - description: nls.localize('hook.timeoutSec', 'Maximum execution time in seconds (default: 30).') + description: nls.localize('hook.timeout', 'Maximum execution time in seconds (default: 30).') } } }; @@ -240,7 +240,7 @@ export const hookFileSchema: IJSONSchema = { { type: 'command', command: '${2:./scripts/validate.sh}', - timeoutSec: 15 + timeout: 15 } ] } @@ -278,7 +278,7 @@ export function toHookType(rawHookTypeId: string): HookType | undefined { * - powershell -> windows * This is an internal helper - use resolveHookCommand for the full resolution. */ -function normalizeHookCommand(raw: Record): { command?: string; windows?: string; linux?: string; osx?: string; windowsSource?: 'windows' | 'powershell'; linuxSource?: 'linux' | 'bash'; osxSource?: 'osx' | 'bash'; cwd?: string; env?: Record; timeoutSec?: number } | undefined { +function normalizeHookCommand(raw: Record): { command?: string; windows?: string; linux?: string; osx?: string; windowsSource?: 'windows' | 'powershell'; linuxSource?: 'linux' | 'bash'; osxSource?: 'osx' | 'bash'; cwd?: string; env?: Record; timeout?: number } | undefined { if (raw.type !== 'command') { return undefined; } @@ -313,7 +313,8 @@ function normalizeHookCommand(raw: Record): { command?: string; ...(osxSource && { osxSource }), ...(typeof raw.cwd === 'string' && { cwd: raw.cwd }), ...(typeof raw.env === 'object' && raw.env !== null && { env: raw.env as Record }), - ...(typeof raw.timeoutSec === 'number' && { timeoutSec: raw.timeoutSec }), + ...(typeof raw.timeout !== 'number' && typeof raw.timeoutSec === 'number' && { timeout: raw.timeoutSec }), + ...(typeof raw.timeout === 'number' && { timeout: raw.timeout }), }; } @@ -456,6 +457,6 @@ export function resolveHookCommand(raw: Record, workspaceRootUr ...(normalized.osxSource && { osxSource: normalized.osxSource }), ...(cwdUri && { cwd: cwdUri }), ...(normalized.env && { env: normalized.env }), - ...(normalized.timeoutSec !== undefined && { timeoutSec: normalized.timeoutSec }), + ...(normalized.timeout !== undefined && { timeout: normalized.timeout }), }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index 77685cafa9738..f7fb12e4c80c1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -22,7 +22,7 @@ import { MenuId } from '../../../../../../platform/actions/common/actions.js'; import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; -import { AgentSessionProviders, getAgentSessionProviderIcon, getAgentSessionProviderName } from '../../../browser/agentSessions/agentSessions.js'; +import { AgentSessionProviders, getAgentCanContinueIn, getAgentSessionProviderIcon, getAgentSessionProviderName } from '../../../browser/agentSessions/agentSessions.js'; suite('AgentSessions', () => { @@ -1949,6 +1949,16 @@ suite('AgentSessions', () => { assert.strictEqual(icon.id, Codicon.cloud.id); }); + test('should return correct name for Growth provider', () => { + const name = getAgentSessionProviderName(AgentSessionProviders.Growth); + assert.strictEqual(name, 'Growth'); + }); + + test('should return correct icon for Growth provider', () => { + const icon = getAgentSessionProviderIcon(AgentSessionProviders.Growth); + assert.strictEqual(icon.id, Codicon.lightbulb.id); + }); + test('should handle Local provider type in model', async () => { return runWithFakedTimers({}, async () => { const instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); @@ -2087,6 +2097,25 @@ suite('AgentSessions', () => { }); }); + suite('AgentSessionsViewModel - getAgentCanContinueIn', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('should return false when contribution.isReadOnly is true', () => { + const result = getAgentCanContinueIn(AgentSessionProviders.Cloud, { type: 'test', name: 'test', displayName: 'Test', description: 'test', isReadOnly: true }); + assert.strictEqual(result, false); + }); + + test('should return true for Cloud when contribution is not read-only', () => { + const result = getAgentCanContinueIn(AgentSessionProviders.Cloud, { type: 'test', name: 'test', displayName: 'Test', description: 'test', isReadOnly: false }); + assert.strictEqual(result, true); + }); + + test('should return false for Growth provider', () => { + const result = getAgentCanContinueIn(AgentSessionProviders.Growth); + assert.strictEqual(result, false); + }); + }); + suite('AgentSessionsViewModel - Cancellation and Lifecycle', () => { const disposables = new DisposableStore(); let mockChatSessionsService: MockChatSessionsService; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts index ee806bff9f414..0243dcfdd0d97 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts @@ -47,7 +47,6 @@ import { IChatVariablesService } from '../../../common/attachments/chatVariables import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; -import { IHooksExecutionService } from '../../../common/hooks/hooksExecutionService.js'; import { NullLanguageModelsService } from '../../common/languageModels.js'; import { MockChatVariablesService } from '../../common/mockChatVariables.js'; import { MockPromptsService } from '../../common/promptSyntax/service/mockPromptsService.js'; @@ -90,9 +89,6 @@ suite('ChatEditingService', function () { collection.set(IMcpService, new TestMcpService()); collection.set(IPromptsService, new MockPromptsService()); collection.set(ILanguageModelsService, new SyncDescriptor(NullLanguageModelsService)); - collection.set(IHooksExecutionService, new class extends mock() { - override registerHooks() { return Disposable.None; } - }); collection.set(IMultiDiffSourceResolverService, new class extends mock() { override registerResolver(_resolver: IMultiDiffSourceResolver): IDisposable { return Disposable.None; 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 a5b61a2687935..3b53ed241bae1 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 @@ -8,7 +8,6 @@ import { Barrier } from '../../../../../../base/common/async.js'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { CancellationError, isCancellationError } from '../../../../../../base/common/errors.js'; -import { Event } from '../../../../../../base/common/event.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; @@ -34,10 +33,6 @@ import { ILanguageModelToolsConfirmationService } from '../../../common/tools/la import { MockLanguageModelToolsConfirmationService } from '../../common/tools/mockLanguageModelToolsConfirmationService.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { ILanguageModelChatMetadata } from '../../../common/languageModels.js'; -import { IHookResult } from '../../../common/hooks/hooksTypes.js'; -import { IHooksExecutionService, IHooksExecutionOptions, IHooksExecutionProxy } from '../../../common/hooks/hooksExecutionService.js'; -import { HookTypeValue, IChatRequestHooks } from '../../../common/promptSyntax/hookSchema.js'; -import { IDisposable } from '../../../../../../base/common/lifecycle.js'; // --- Test helpers to reduce repetition and improve readability --- @@ -65,19 +60,6 @@ class TestTelemetryService implements Partial { } } -class MockHooksExecutionService implements IHooksExecutionService { - readonly _serviceBrand: undefined; - readonly onDidExecuteHook = Event.None; - readonly onDidHookProgress = Event.None; - - setProxy(_proxy: IHooksExecutionProxy): void { } - registerHooks(_sessionResource: URI, _hooks: IChatRequestHooks): IDisposable { return { dispose: () => { } }; } - getHooksForSession(_sessionResource: URI): IChatRequestHooks | undefined { return undefined; } - executeHook(_hookType: HookTypeValue, _sessionResource: URI, _options?: IHooksExecutionOptions): Promise { - return Promise.resolve([]); - } -} - function registerToolForTest(service: LanguageModelToolsService, store: any, id: string, impl: IToolImpl, data?: Partial) { const toolData: IToolData = { id, @@ -136,7 +118,6 @@ interface TestToolsServiceOptions { accessibilityService?: IAccessibilityService; accessibilitySignalService?: Partial; telemetryService?: Partial; - hooksExecutionService?: MockHooksExecutionService; commandService?: Partial; /** Called after configurationService is created but before the service is instantiated */ configureServices?: (config: TestConfigurationService) => void; @@ -161,7 +142,6 @@ function createTestToolsService(store: ReturnType { instaService1.stub(IAccessibilityService, testAccessibilityService1); instaService1.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService); instaService1.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService1.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService1 = store.add(instaService1.createInstance(LanguageModelToolsService)); const tool1 = registerToolForTest(testService1, store, 'soundOnlyTool', { @@ -1774,7 +1753,6 @@ suite('LanguageModelToolsService', () => { instaService2.stub(IAccessibilityService, testAccessibilityService2); instaService2.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService); instaService2.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService2.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService2 = store.add(instaService2.createInstance(LanguageModelToolsService)); const tool2 = registerToolForTest(testService2, store, 'autoScreenReaderTool', { @@ -1817,7 +1795,6 @@ suite('LanguageModelToolsService', () => { instaService3.stub(IAccessibilityService, testAccessibilityService3); instaService3.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService); instaService3.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService3.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService3 = store.add(instaService3.createInstance(LanguageModelToolsService)); const tool3 = registerToolForTest(testService3, store, 'offTool', { @@ -2587,7 +2564,6 @@ suite('LanguageModelToolsService', () => { }, store); instaService.stub(IChatService, chatService); instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService = store.add(instaService.createInstance(LanguageModelToolsService)); const tool = registerToolForTest(testService, store, 'gitCommitTool', { @@ -2626,7 +2602,6 @@ suite('LanguageModelToolsService', () => { }, store); instaService.stub(IChatService, chatService); instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService = store.add(instaService.createInstance(LanguageModelToolsService)); // Tool that was previously namespaced under extension but is now internal @@ -2666,7 +2641,6 @@ suite('LanguageModelToolsService', () => { }, store); instaService.stub(IChatService, chatService); instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService = store.add(instaService.createInstance(LanguageModelToolsService)); // Tool that was previously namespaced under extension but is now internal @@ -2709,7 +2683,6 @@ suite('LanguageModelToolsService', () => { }, store); instaService.stub(IChatService, chatService); instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService = store.add(instaService.createInstance(LanguageModelToolsService)); // Tool that was previously namespaced under extension but is now internal @@ -3799,15 +3772,11 @@ suite('LanguageModelToolsService', () => { }); suite('preToolUse hooks', () => { - let mockHooksService: MockHooksExecutionService; let hookService: LanguageModelToolsService; let hookChatService: MockChatService; setup(() => { - mockHooksService = new MockHooksExecutionService(); - const setup = createTestToolsService(store, { - hooksExecutionService: mockHooksService - }); + const setup = createTestToolsService(store); hookService = setup.service; hookChatService = setup.chatService; }); @@ -4045,9 +4014,7 @@ suite('LanguageModelToolsService', () => { } }; - const mockHooks = new MockHooksExecutionService(); const setup = createTestToolsService(store, { - hooksExecutionService: mockHooks, commandService: mockCommandService as ICommandService, }); @@ -4101,9 +4068,7 @@ suite('LanguageModelToolsService', () => { } }; - const mockHooks = new MockHooksExecutionService(); const setup = createTestToolsService(store, { - hooksExecutionService: mockHooks, commandService: mockCommandService as ICommandService, }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatEditsTree.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatEditsTree.test.ts new file mode 100644 index 0000000000000..d9f9a38a76e08 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatEditsTree.test.ts @@ -0,0 +1,275 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ContextKeyService } from '../../../../../../../platform/contextkey/browser/contextKeyService.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../../platform/storage/common/storage.js'; +import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; +import { IChatCollapsibleListItem } from '../../../../browser/widget/chatContentParts/chatReferencesContentPart.js'; +import { buildEditsList, buildEditsTree, ChatEditsListWidget, ChatEditsTreeIdentityProvider, IChatEditsFolderElement } from '../../../../browser/widget/input/chatEditsTree.js'; +import { CHAT_EDITS_VIEW_MODE_STORAGE_KEY } from '../../../../browser/chatEditing/chatEditingActions.js'; +import { ModifiedFileEntryState, IChatEditingSession } from '../../../../common/editing/chatEditingService.js'; +import { Event } from '../../../../../../../base/common/event.js'; + +function makeFileItem(path: string, added = 0, removed = 0): IChatCollapsibleListItem { + return { + reference: URI.file(path), + state: ModifiedFileEntryState.Modified, + kind: 'reference', + options: { + status: undefined, + diffMeta: { added, removed }, + } + }; +} + +suite('ChatEditsTree', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('buildEditsList', () => { + test('wraps items as flat tree elements', () => { + const items = [ + makeFileItem('/src/a.ts'), + makeFileItem('/src/b.ts'), + ]; + const result = buildEditsList(items); + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].children, undefined); + assert.strictEqual(result[1].children, undefined); + }); + + test('returns empty array for empty input', () => { + assert.deepStrictEqual(buildEditsList([]), []); + }); + }); + + suite('buildEditsTree', () => { + test('groups files by directory', () => { + const items = [ + makeFileItem('/project/src/a.ts'), + makeFileItem('/project/src/b.ts'), + makeFileItem('/project/lib/c.ts'), + ]; + const result = buildEditsTree(items); + + // Should have 2 folder elements + assert.strictEqual(result.length, 2); + + const folders = result.map(r => r.element).filter((e): e is IChatEditsFolderElement => e.kind === 'folder'); + assert.strictEqual(folders.length, 2); + + // Each folder should have children + for (const r of result) { + assert.ok(r.children); + assert.ok(r.collapsible); + } + }); + + test('skips folder grouping for single file in single folder', () => { + const items = [makeFileItem('/project/src/a.ts')]; + const result = buildEditsTree(items); + + // Single file should not be wrapped in a folder + assert.strictEqual(result.length, 1); + assert.notStrictEqual(result[0].element.kind, 'folder'); + }); + + test('still groups when there are multiple folders even with single files', () => { + const items = [ + makeFileItem('/project/src/a.ts'), + makeFileItem('/project/lib/b.ts'), + ]; + const result = buildEditsTree(items); + + assert.strictEqual(result.length, 2); + const folders = result.map(r => r.element).filter((e): e is IChatEditsFolderElement => e.kind === 'folder'); + assert.strictEqual(folders.length, 2); + }); + + test('handles items without URIs as top-level elements', () => { + const warning: IChatCollapsibleListItem = { + kind: 'warning', + content: { value: 'Something went wrong' }, + }; + const items: IChatCollapsibleListItem[] = [ + warning, + makeFileItem('/src/a.ts'), + ]; + const result = buildEditsTree(items); + + // Warning at top level + single file at root (common ancestor is /src/) + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].element.kind, 'warning'); + assert.strictEqual(result[1].element.kind, 'reference'); + }); + + test('flattens files at common ancestor and shows subfolders', () => { + const items = [ + makeFileItem('/project/root/hello.py'), + makeFileItem('/project/root/README.md'), + makeFileItem('/project/root/test.py'), + makeFileItem('/project/root/js/test2.js'), + ]; + const result = buildEditsTree(items); + + // Common ancestor is /project/root/ — files there go to root level, + // js/ becomes a folder node + const rootFiles = result.filter(r => r.element.kind === 'reference'); + const folders = result.filter(r => r.element.kind === 'folder'); + assert.strictEqual(rootFiles.length, 3, 'three files at root level'); + assert.strictEqual(folders.length, 1, 'one subfolder'); + assert.strictEqual((folders[0].element as IChatEditsFolderElement).children.length, 1); + + // Folders should come before files (like search) + const firstFolderIndex = result.findIndex(r => r.element.kind === 'folder'); + const firstFileIndex = result.findIndex(r => r.element.kind === 'reference'); + assert.ok(firstFolderIndex < firstFileIndex, 'folders should appear before files'); + }); + + test('all files in same directory produces no folder row', () => { + const items = [ + makeFileItem('/project/src/a.ts'), + makeFileItem('/project/src/b.ts'), + makeFileItem('/project/src/c.ts'), + ]; + const result = buildEditsTree(items); + + // All files in the same directory — common ancestor is /project/src/ + // No folder row needed + assert.strictEqual(result.length, 3); + assert.ok(result.every(r => r.element.kind === 'reference')); + }); + }); + + suite('ChatEditsTreeIdentityProvider', () => { + test('provides stable IDs for folders', () => { + const provider = new ChatEditsTreeIdentityProvider(); + const folder: IChatEditsFolderElement = { + kind: 'folder', + uri: URI.file('/src'), + children: [], + }; + const id = provider.getId(folder); + assert.strictEqual(id, `folder:${URI.file('/src').toString()}`); + }); + + test('provides stable IDs for file references', () => { + const provider = new ChatEditsTreeIdentityProvider(); + const item = makeFileItem('/src/a.ts'); + const id = provider.getId(item); + assert.strictEqual(id, `file:${URI.file('/src/a.ts').toString()}`); + }); + + test('same element produces same ID', () => { + const provider = new ChatEditsTreeIdentityProvider(); + const item1 = makeFileItem('/src/a.ts'); + const item2 = makeFileItem('/src/a.ts'); + assert.strictEqual(provider.getId(item1), provider.getId(item2)); + }); + + test('different elements produce different IDs', () => { + const provider = new ChatEditsTreeIdentityProvider(); + const item1 = makeFileItem('/src/a.ts'); + const item2 = makeFileItem('/src/b.ts'); + assert.notStrictEqual(provider.getId(item1), provider.getId(item2)); + }); + }); + + suite('ChatEditsListWidget lifecycle', () => { + let store: DisposableStore; + let storageService: IStorageService; + let widget: ChatEditsListWidget; + + setup(() => { + store = new DisposableStore(); + const instaService = workbenchInstantiationService({ + contextKeyService: () => store.add(new ContextKeyService(new TestConfigurationService)), + }, store); + store.add(instaService); + + storageService = instaService.get(IStorageService); + widget = store.add(instaService.createInstance(ChatEditsListWidget, Event.None)); + }); + + teardown(() => { + store.dispose(); + }); + + test('storage listener fires after clear', () => { + // Stub create to avoid DOM/widget side effects in tests + let createCallCount = 0; + const origCreate = widget.create.bind(widget); + widget.create = (c, s) => { + createCallCount++; + // Update stored refs without actually building widgets + (widget as unknown as { _currentContainer: HTMLElement | undefined })._currentContainer = c; + (widget as unknown as { _currentSession: IChatEditingSession | null })._currentSession = s; + }; + + const container = document.createElement('div'); + widget.create(container, null); + assert.strictEqual(createCallCount, 1); + + // Simulate session switch + widget.clear(); + + // Toggle view mode — storage listener must still fire after clear() + createCallCount = 0; + storageService.store(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, 'tree', StorageScope.PROFILE, StorageTarget.USER); + assert.strictEqual(createCallCount, 1, 'storage listener should trigger create after clear()'); + + widget.create = origCreate; + }); + + test('currentSession is updated on rebuild', () => { + // Stub create + widget.create = (c, s) => { + (widget as unknown as { _currentContainer: HTMLElement | undefined })._currentContainer = c; + (widget as unknown as { _currentSession: IChatEditingSession | null })._currentSession = s; + }; + + const container = document.createElement('div'); + widget.create(container, null); + assert.strictEqual(widget.currentSession, null); + + const mockSession = {} as IChatEditingSession; + widget.rebuild(container, mockSession); + assert.strictEqual(widget.currentSession, mockSession); + }); + + test('setEntries replays after view mode toggle', () => { + // Stub create and track setEntries calls + widget.create = (c, s) => { + (widget as unknown as { _currentContainer: HTMLElement | undefined })._currentContainer = c; + (widget as unknown as { _currentSession: IChatEditingSession | null })._currentSession = s; + }; + + const container = document.createElement('div'); + widget.create(container, null); + + const entries = [makeFileItem('/src/a.ts'), makeFileItem('/src/b.ts')]; + widget.setEntries(entries); + + const setEntriesCalls: readonly IChatCollapsibleListItem[][] = []; + const origSetEntries = widget.setEntries.bind(widget); + widget.setEntries = (e) => { + (setEntriesCalls as IChatCollapsibleListItem[][]).push([...e]); + origSetEntries(e); + }; + + // Toggle to tree mode — should replay entries + storageService.store(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, 'tree', StorageScope.PROFILE, StorageTarget.USER); + assert.strictEqual(setEntriesCalls.length, 1, 'setEntries should have been replayed'); + assert.strictEqual(setEntriesCalls[0].length, 2, 'should have replayed the 2 entries'); + + widget.setEntries = origSetEntries; + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts b/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts deleted file mode 100644 index 1493f35a29a6c..0000000000000 --- a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts +++ /dev/null @@ -1,402 +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 assert from 'assert'; -import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../../../platform/log/common/log.js'; -import { HookCommandResultKind, IHookCommandResult } from '../../common/hooks/hooksCommandTypes.js'; -import { HooksExecutionService, IHooksExecutionProxy } from '../../common/hooks/hooksExecutionService.js'; -import { HookType, IHookCommand } from '../../common/promptSyntax/hookSchema.js'; -import { IOutputChannel, IOutputService } from '../../../../services/output/common/output.js'; - -function cmd(command: string): IHookCommand { - return { type: 'command', command, cwd: URI.file('/') }; -} - -function createMockOutputService(): IOutputService { - const mockChannel: Partial = { - append: () => { }, - }; - return { - _serviceBrand: undefined, - getChannel: () => mockChannel as IOutputChannel, - } as unknown as IOutputService; -} - -suite('HooksExecutionService', () => { - const store = ensureNoDisposablesAreLeakedInTestSuite(); - - let service: HooksExecutionService; - const sessionUri = URI.file('/test/session'); - - setup(() => { - service = store.add(new HooksExecutionService(new NullLogService(), createMockOutputService())); - }); - - suite('registerHooks', () => { - test('registers hooks for a session', () => { - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - assert.strictEqual(service.getHooksForSession(sessionUri), hooks); - }); - - test('returns disposable that unregisters hooks', () => { - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - const disposable = service.registerHooks(sessionUri, hooks); - - assert.strictEqual(service.getHooksForSession(sessionUri), hooks); - - disposable.dispose(); - - assert.strictEqual(service.getHooksForSession(sessionUri), undefined); - }); - - test('different sessions have independent hooks', () => { - const session1 = URI.file('/test/session1'); - const session2 = URI.file('/test/session2'); - const hooks1 = { [HookType.PreToolUse]: [cmd('echo 1')] }; - const hooks2 = { [HookType.PostToolUse]: [cmd('echo 2')] }; - - store.add(service.registerHooks(session1, hooks1)); - store.add(service.registerHooks(session2, hooks2)); - - assert.strictEqual(service.getHooksForSession(session1), hooks1); - assert.strictEqual(service.getHooksForSession(session2), hooks2); - }); - }); - - suite('getHooksForSession', () => { - test('returns undefined for unregistered session', () => { - assert.strictEqual(service.getHooksForSession(sessionUri), undefined); - }); - }); - - suite('executeHook', () => { - test('returns empty array when no proxy set', async () => { - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - assert.deepStrictEqual(results, []); - }); - - test('returns empty array when no hooks registered for session', async () => { - const proxy = createMockProxy(); - service.setProxy(proxy); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - assert.deepStrictEqual(results, []); - }); - - test('returns empty array when no hooks of requested type', async () => { - const proxy = createMockProxy(); - service.setProxy(proxy); - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PostToolUse, sessionUri); - assert.deepStrictEqual(results, []); - }); - - test('executes hook commands via proxy and returns semantic results', async () => { - const proxy = createMockProxy((cmd) => ({ - kind: HookCommandResultKind.Success, - result: `executed: ${cmd.command}` - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri, { input: 'test-input' }); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'success'); - assert.strictEqual(results[0].stopReason, undefined); - assert.strictEqual(results[0].output, 'executed: echo test'); - }); - - test('executes multiple hook commands in order', async () => { - const executedCommands: string[] = []; - const proxy = createMockProxy((cmd) => { - executedCommands.push(cmd.command ?? ''); - return { kind: HookCommandResultKind.Success, result: 'ok' }; - }); - service.setProxy(proxy); - - const hooks = { - [HookType.PreToolUse]: [cmd('cmd1'), cmd('cmd2'), cmd('cmd3')] - }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 3); - assert.deepStrictEqual(executedCommands, ['cmd1', 'cmd2', 'cmd3']); - }); - - test('wraps proxy errors in warning result', async () => { - const proxy = createMockProxy(() => { - throw new Error('proxy failed'); - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('fail')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - // Proxy errors are now treated as warnings (non-blocking) - assert.strictEqual(results[0].resultKind, 'warning'); - assert.strictEqual(results[0].warningMessage, 'proxy failed'); - assert.strictEqual(results[0].output, undefined); - assert.strictEqual(results[0].stopReason, undefined); - }); - - test('passes cancellation token to proxy', async () => { - let receivedToken: CancellationToken | undefined; - const proxy = createMockProxy((_cmd, _input, token) => { - receivedToken = token; - return { kind: HookCommandResultKind.Success, result: 'ok' }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const cts = store.add(new CancellationTokenSource()); - await service.executeHook(HookType.PreToolUse, sessionUri, { token: cts.token }); - - assert.strictEqual(receivedToken, cts.token); - }); - - test('uses CancellationToken.None when no token provided', async () => { - let receivedToken: CancellationToken | undefined; - const proxy = createMockProxy((_cmd, _input, token) => { - receivedToken = token; - return { kind: HookCommandResultKind.Success, result: 'ok' }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(receivedToken, CancellationToken.None); - }); - - test('extracts common fields from successful result', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - stopReason: 'User requested stop', - systemMessage: 'Warning: hook triggered', - hookSpecificOutput: { - permissionDecision: 'allow' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'success'); - assert.strictEqual(results[0].stopReason, 'User requested stop'); - assert.strictEqual(results[0].warningMessage, 'Warning: hook triggered'); - // Hook-specific fields are in output with wrapper - assert.deepStrictEqual(results[0].output, { hookSpecificOutput: { permissionDecision: 'allow' } }); - }); - - test('handles continue false by setting stopReason', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - continue: false, - systemMessage: 'User requested to stop' - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'success'); - // continue:false without explicit stopReason sets stopReason to empty string - assert.strictEqual(results[0].stopReason, ''); - assert.strictEqual(results[0].warningMessage, 'User requested to stop'); - }); - - test('stopReason takes precedence over continue false', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - continue: false, - stopReason: 'Explicit stop reason' - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'success'); - assert.strictEqual(results[0].stopReason, 'Explicit stop reason'); - }); - - test('uses defaults when no common fields present', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'allow' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].stopReason, undefined); - assert.strictEqual(results[0].warningMessage, undefined); - assert.deepStrictEqual(results[0].output, { hookSpecificOutput: { permissionDecision: 'allow' } }); - }); - - test('handles error results from command (exit code 2) as stop with stopReason', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Error, - result: 'command failed with error' - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - // Exit code 2 produces error with stopReason - assert.strictEqual(results[0].resultKind, 'error'); - assert.strictEqual(results[0].stopReason, 'command failed with error'); - assert.strictEqual(results[0].output, undefined); - }); - - test('handles non-blocking error results from command', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.NonBlockingError, - result: 'non-blocking warning message' - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'warning'); - assert.strictEqual(results[0].output, undefined); - assert.strictEqual(results[0].warningMessage, 'non-blocking warning message'); - assert.strictEqual(results[0].stopReason, undefined); - }); - - test('handles non-blocking error with object result', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.NonBlockingError, - result: { code: 'WARN_001', message: 'Something went wrong' } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'warning'); - assert.strictEqual(results[0].output, undefined); - assert.strictEqual(results[0].warningMessage, '{"code":"WARN_001","message":"Something went wrong"}'); - assert.strictEqual(results[0].stopReason, undefined); - }); - - test('passes through hook-specific output fields for non-preToolUse hooks', async () => { - // Stop hooks return different fields (decision, reason) than preToolUse hooks - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - decision: 'block', - reason: 'Please run the tests' - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.Stop]: [cmd('check-stop')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.Stop, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'success'); - // Hook-specific fields should be in output, not undefined - assert.deepStrictEqual(results[0].output, { - decision: 'block', - reason: 'Please run the tests' - }); - }); - - test('passes input to proxy', async () => { - let receivedInput: unknown; - const proxy = createMockProxy((_cmd, input) => { - receivedInput = input; - return { kind: HookCommandResultKind.Success, result: 'ok' }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const testInput = { foo: 'bar', nested: { value: 123 } }; - await service.executeHook(HookType.PreToolUse, sessionUri, { input: testInput }); - - // Input includes caller properties merged with common hook properties - assert.ok(typeof receivedInput === 'object' && receivedInput !== null); - const input = receivedInput as Record; - assert.strictEqual(input['foo'], 'bar'); - assert.deepStrictEqual(input['nested'], { value: 123 }); - // Common properties are also present - assert.strictEqual(typeof input['timestamp'], 'string'); - assert.strictEqual(input['hookEventName'], HookType.PreToolUse); - }); - }); - - function createMockProxy(handler?: (cmd: IHookCommand, input: unknown, token: CancellationToken) => IHookCommandResult): IHooksExecutionProxy { - return { - runHookCommand: async (hookCommand, input, token) => { - if (handler) { - return handler(hookCommand, input, token); - } - return { kind: HookCommandResultKind.Success, result: 'mock result' }; - } - }; - } -}); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts index 8321a90cf69c9..84aa27f5722a6 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts @@ -325,11 +325,11 @@ suite('HookClaudeCompat', () => { assert.deepStrictEqual(entry.hooks[0].env, { NODE_ENV: 'production' }); }); - test('preserves timeoutSec', () => { + test('preserves timeout', () => { const json = { hooks: { PreToolUse: [ - { type: 'command', command: 'echo "test"', timeoutSec: 60 } + { type: 'command', command: 'echo "test"', timeout: 60 } ] } }; @@ -337,7 +337,7 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); const entry = result.get(HookType.PreToolUse)!; - assert.strictEqual(entry.hooks[0].timeoutSec, 60); + assert.strictEqual(entry.hooks[0].timeout, 60); }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts index 7bf407a052350..63bcf59c0049f 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts @@ -35,14 +35,14 @@ suite('HookSchema', () => { command: './scripts/validate.sh', cwd: 'src', env: { NODE_ENV: 'test' }, - timeoutSec: 60 + timeout: 60 }, workspaceRoot, userHome); assert.deepStrictEqual(result, { type: 'command', command: './scripts/validate.sh', cwd: URI.file('/workspace/src'), env: { NODE_ENV: 'test' }, - timeoutSec: 60 + timeout: 60 }); }); @@ -118,18 +118,18 @@ suite('HookSchema', () => { }); }); - test('powershell with timeoutSec', () => { + test('powershell with timeout', () => { const result = resolveHookCommand({ type: 'command', powershell: 'Get-Process', - timeoutSec: 30 + timeout: 30 }, workspaceRoot, userHome); assert.deepStrictEqual(result, { type: 'command', windows: 'Get-Process', windowsSource: 'powershell', cwd: workspaceRoot, - timeoutSec: 30 + timeout: 30 }); }); @@ -277,11 +277,11 @@ suite('HookSchema', () => { }); }); - test('ignores non-number timeoutSec', () => { + test('ignores non-number timeout', () => { const result = resolveHookCommand({ type: 'command', command: 'echo hello', - timeoutSec: '30' + timeout: '30' }, workspaceRoot, userHome); assert.deepStrictEqual(result, { type: 'command', diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts b/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts index 3721b87313904..99fd9647231d5 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts @@ -242,10 +242,13 @@ export class SettingMatches { // Search the description if we found non-contiguous key matches at best. const hasContiguousKeyMatchTypes = this.matchType >= SettingMatchType.ContiguousWordsInSettingsLabel; if (this.searchDescription && !hasContiguousKeyMatchTypes) { + // Search the description lines and any additional keywords. + const searchableLines = setting.keywords?.length + ? [...setting.description, setting.keywords.join(' ')] + : setting.description; for (const word of queryWords) { - // Search the description lines. - for (let lineIndex = 0; lineIndex < setting.description.length; lineIndex++) { - const descriptionMatches = matchesBaseContiguousSubString(word, setting.description[lineIndex]); + for (let lineIndex = 0; lineIndex < searchableLines.length; lineIndex++) { + const descriptionMatches = matchesBaseContiguousSubString(word, searchableLines[lineIndex]); if (descriptionMatches?.length) { descriptionMatchingWords.set(word, descriptionMatches.map(match => this.toDescriptionRange(setting, match, lineIndex))); } diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts index fc210a06396c2..9a33bfea060cf 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts @@ -148,6 +148,7 @@ export class SettingsEditor2 extends EditorPane { `@${FEATURE_SETTING_TAG}remote`, `@${FEATURE_SETTING_TAG}timeline`, `@${FEATURE_SETTING_TAG}notebook`, + `@${FEATURE_SETTING_TAG}chat`, `@${POLICY_SETTING_TAG}` ]; diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts index 186468f57adb4..9e2704a79b6c0 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts @@ -471,14 +471,31 @@ export class SettingsTreeSettingElement extends SettingsTreeElement { return true; } - const features = tocData.children!.find(child => child.id === 'features'); + // Restrict to core settings + if (this.setting.extensionInfo) { + return false; + } + // Chat settings are now in their own top-level category + if (featureFilters.has('chat')) { + const chatFeatures = tocData.children!.find(child => child.id === 'chat'); + if (chatFeatures?.children) { + const patterns = chatFeatures.children + .flatMap(feature => feature.settings ?? []) + .map(setting => createSettingMatchRegExp(setting)); + if (patterns.some(pattern => pattern.test(this.setting.key))) { + return true; + } + } + } + + const features = tocData.children!.find(child => child.id === 'features'); return Array.from(featureFilters).some(filter => { - if (features && features.children) { + if (features?.children) { const feature = features.children.find(feature => 'features/' + filter === feature.id); - if (feature) { - const patterns = feature.settings?.map(setting => createSettingMatchRegExp(setting)); - return patterns && !this.setting.extensionInfo && patterns.some(pattern => pattern.test(this.setting.key.toLowerCase())); + if (feature?.settings) { + const patterns = feature.settings.map(setting => createSettingMatchRegExp(setting)); + return patterns.some(pattern => pattern.test(this.setting.key)); } else { return false; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts index c87a59ab3b36a..2e42fbe4ba1c2 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts @@ -148,6 +148,12 @@ export class ToolTerminalCreator { const env: Record = { // Avoid making `git diff` interactive when called from copilot GIT_PAGER: 'cat', + // Prevent git from opening an editor for merge commits + GIT_MERGE_AUTOEDIT: 'no', + // Prevent git from opening an editor (e.g. for commit --amend, rebase -i). + // `:` is a POSIX shell built-in no-op (returns 0), works cross-platform + // since git always invokes the editor via `sh -c`. + GIT_EDITOR: ':', }; const preventShellHistory = this._configurationService.getValue(TerminalChatAgentToolsSettingId.PreventShellHistory) === true; diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 868cfac8c7955..bc2ada6a3d973 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -7,7 +7,7 @@ import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { AuthenticationSession, AuthenticationSessionAccount, IAuthenticationExtensionsService, IAuthenticationService } from '../../authentication/common/authentication.js'; -import { asJson, IRequestService } from '../../../../platform/request/common/request.js'; +import { asJson, IRequestService, isClientError, isSuccess } from '../../../../platform/request/common/request.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { IExtensionService } from '../../extensions/common/extensions.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -189,11 +189,12 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount interface IAccountPolicyData { readonly accountId: string; readonly policyData: IPolicyData; - readonly isTokenEntitlementsDataFetched: boolean; - readonly isMcpRegistryDataFetched: boolean; + readonly tokenEntitlementsFetchedAt?: number; + readonly mcpRegistryDataFetchedAt?: number; } interface IDefaultAccountData { + accountId: string; defaultAccount: IDefaultAccount; policyData: IAccountPolicyData | null; } @@ -261,7 +262,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid const { accountId, policyData } = JSON.parse(cached); if (accountId && policyData) { this.logService.debug('[DefaultAccount] Initializing with cached policy data'); - return { accountId, policyData, isTokenEntitlementsDataFetched: false, isMcpRegistryDataFetched: false }; + return { accountId, policyData }; } } catch (error) { this.logService.error('[DefaultAccount] Failed to parse cached policy data', getErrorMessage(error)); @@ -284,7 +285,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid } this.logService.debug('[DefaultAccount] Starting initialization'); - await this.doUpdateDefaultAccount(false); + await this.doUpdateDefaultAccount(); this.logService.debug('[DefaultAccount] Initialization complete'); this._register(this.onDidChangeDefaultAccount(account => { @@ -332,11 +333,8 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid })); this._register(this.hostService.onDidChangeFocus(focused => { - // Refresh default account when window gets focused and policy data is not fully fetched, to ensure we have the latest policy data. - if (focused && this._policyData && (!this._policyData.isMcpRegistryDataFetched || !this._policyData.isTokenEntitlementsDataFetched)) { - this.accountDataPollScheduler.cancel(); - this.logService.debug('[DefaultAccount] Window focused, updating default account'); - this.refresh(); + if (focused) { + this.refetchDefaultAccount(true); } })); } @@ -348,27 +346,31 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid } this.logService.debug('[DefaultAccount] Refreshing default account'); + await this.updateDefaultAccount(); return this.defaultAccount; } - private async refetchDefaultAccount(): Promise { - if (!this.hostService.hasFocus) { + private async refetchDefaultAccount(useExistingEntitlements?: boolean): Promise { + if (this.accountDataPollScheduler.isScheduled()) { + this.accountDataPollScheduler.cancel(); + } + if (!this.hostService.hasFocus || !this._defaultAccount) { this.scheduleAccountDataPoll(); - this.logService.debug('[DefaultAccount] Skipping refetching default account because window is not focused'); + this.logService.debug('[DefaultAccount] Skipping refetching default account. Host is not focused or default account is not set'); return; } this.logService.debug('[DefaultAccount] Refetching default account'); - await this.updateDefaultAccount(true); + await this.updateDefaultAccount(useExistingEntitlements); } - private async updateDefaultAccount(donotUseLastFetchedData: boolean = false): Promise { - await this.updateThrottler.trigger(() => this.doUpdateDefaultAccount(donotUseLastFetchedData)); + private async updateDefaultAccount(useExistingEntitlements?: boolean): Promise { + await this.updateThrottler.trigger(() => this.doUpdateDefaultAccount(useExistingEntitlements)); } - private async doUpdateDefaultAccount(donotUseLastFetchedData: boolean): Promise { + private async doUpdateDefaultAccount(useExistingEntitlements: boolean = false): Promise { try { - const defaultAccount = await this.fetchDefaultAccount(donotUseLastFetchedData); + const defaultAccount = await this.fetchDefaultAccount(useExistingEntitlements); this.setDefaultAccount(defaultAccount); this.scheduleAccountDataPoll(); } catch (error) { @@ -376,7 +378,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid } } - private async fetchDefaultAccount(donotUseLastFetchedData: boolean): Promise { + private async fetchDefaultAccount(useExistingEntitlements: boolean): Promise { const defaultAccountProvider = this.getDefaultAccountAuthenticationProvider(); this.logService.debug('[DefaultAccount] Default account provider ID:', defaultAccountProvider.id); @@ -386,7 +388,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return null; } - return await this.getDefaultAccountForAuthenticationProvider(defaultAccountProvider, donotUseLastFetchedData); + return await this.getDefaultAccountForAuthenticationProvider(defaultAccountProvider, useExistingEntitlements); } private setDefaultAccount(account: IDefaultAccountData | null): void { @@ -449,7 +451,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return result; } - private async getDefaultAccountForAuthenticationProvider(authenticationProvider: IDefaultAccountAuthenticationProvider, donotUseLastFetchedData: boolean): Promise { + private async getDefaultAccountForAuthenticationProvider(authenticationProvider: IDefaultAccountAuthenticationProvider, useExistingEntitlements: boolean): Promise { try { this.logService.debug('[DefaultAccount] Getting Default Account from authenticated sessions for provider:', authenticationProvider.id); const sessions = await this.findMatchingProviderSession(authenticationProvider.id, this.defaultAccountConfig.authenticationProvider.scopes); @@ -459,39 +461,39 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return null; } - return this.getDefaultAccountFromAuthenticatedSessions(authenticationProvider, sessions, donotUseLastFetchedData); + return this.getDefaultAccountFromAuthenticatedSessions(authenticationProvider, sessions, useExistingEntitlements); } catch (error) { this.logService.error('[DefaultAccount] Failed to get default account for provider:', authenticationProvider.id, getErrorMessage(error)); return null; } } - private async getDefaultAccountFromAuthenticatedSessions(authenticationProvider: IDefaultAccountAuthenticationProvider, sessions: AuthenticationSession[], donotUseLastFetchedData: boolean): Promise { + private async getDefaultAccountFromAuthenticatedSessions(authenticationProvider: IDefaultAccountAuthenticationProvider, sessions: AuthenticationSession[], useExistingEntitlements: boolean): Promise { try { const accountId = sessions[0].account.id; + const existingEntitlementsData = this._defaultAccount?.accountId === accountId ? this._defaultAccount?.defaultAccount.entitlementsData : undefined; const accountPolicyData = this._policyData?.accountId === accountId ? this._policyData : undefined; - const [entitlementsData, tokenEntitlementsData] = await Promise.all([ - this.getEntitlements(sessions), - this.getTokenEntitlements(sessions, donotUseLastFetchedData ? undefined : accountPolicyData), + const [entitlementsData, tokenEntitlementsResult] = await Promise.all([ + useExistingEntitlements && existingEntitlementsData ? existingEntitlementsData : this.getEntitlements(sessions), + this.getTokenEntitlements(sessions, accountPolicyData), ]); - let isTokenEntitlementsDataFetched = false; - let isMcpRegistryDataFetched = false; + let tokenEntitlementsFetchedAt: number | undefined; + let mcpRegistryDataFetchedAt: number | undefined; let policyData: Mutable | undefined = accountPolicyData?.policyData ? { ...accountPolicyData.policyData } : undefined; - if (tokenEntitlementsData) { - isTokenEntitlementsDataFetched = true; + if (tokenEntitlementsResult) { + tokenEntitlementsFetchedAt = tokenEntitlementsResult.fetchedAt; + const tokenEntitlementsData = tokenEntitlementsResult.data; policyData = policyData ?? {}; policyData.chat_agent_enabled = tokenEntitlementsData.chat_agent_enabled; policyData.chat_preview_features_enabled = tokenEntitlementsData.chat_preview_features_enabled; policyData.mcp = tokenEntitlementsData.mcp; if (policyData.mcp) { - const mcpRegistryProvider = await this.getMcpRegistryProvider(sessions, donotUseLastFetchedData ? undefined : accountPolicyData); - if (!isUndefined(mcpRegistryProvider)) { - isMcpRegistryDataFetched = true; - policyData.mcpRegistryUrl = mcpRegistryProvider?.url; - policyData.mcpAccess = mcpRegistryProvider?.registry_access; - } + const mcpRegistryResult = await this.getMcpRegistryProvider(sessions, accountPolicyData); + mcpRegistryDataFetchedAt = mcpRegistryResult?.fetchedAt; + policyData.mcpRegistryUrl = mcpRegistryResult?.data?.url; + policyData.mcpAccess = mcpRegistryResult?.data?.registry_access; } else { policyData.mcpRegistryUrl = undefined; policyData.mcpAccess = undefined; @@ -505,7 +507,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid entitlementsData, }; this.logService.debug('[DefaultAccount] Successfully created default account for provider:', authenticationProvider.id); - return { defaultAccount, policyData: policyData ? { accountId, policyData, isTokenEntitlementsDataFetched, isMcpRegistryDataFetched } : null }; + return { defaultAccount, accountId, policyData: policyData ? { accountId, policyData, tokenEntitlementsFetchedAt, mcpRegistryDataFetchedAt } : null }; } catch (error) { this.logService.error('[DefaultAccount] Failed to create default account for provider:', authenticationProvider.id, getErrorMessage(error)); return null; @@ -560,12 +562,13 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return expectedScopes.every(scope => scopes.includes(scope)); } - private async getTokenEntitlements(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise | undefined> { - if (accountPolicyData?.isTokenEntitlementsDataFetched) { + private async getTokenEntitlements(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise<{ data: Partial; fetchedAt: number } | undefined> { + if (accountPolicyData?.tokenEntitlementsFetchedAt && !this.isDataStale(accountPolicyData.tokenEntitlementsFetchedAt)) { this.logService.debug('[DefaultAccount] Using last fetched token entitlements data'); - return accountPolicyData.policyData; + return { data: accountPolicyData.policyData, fetchedAt: accountPolicyData.tokenEntitlementsFetchedAt }; } - return await this.requestTokenEntitlements(sessions); + const data = await this.requestTokenEntitlements(sessions); + return data ? { data, fetchedAt: Date.now() } : undefined; } private async requestTokenEntitlements(sessions: AuthenticationSession[]): Promise | undefined> { @@ -639,12 +642,14 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return undefined; } - private async getMcpRegistryProvider(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise { - if (accountPolicyData?.isMcpRegistryDataFetched) { + private async getMcpRegistryProvider(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise<{ data: IMcpRegistryProvider | null; fetchedAt: number } | undefined> { + if (accountPolicyData?.mcpRegistryDataFetchedAt && !this.isDataStale(accountPolicyData.mcpRegistryDataFetchedAt)) { this.logService.debug('[DefaultAccount] Using last fetched MCP registry data'); - return accountPolicyData.policyData.mcpRegistryUrl && accountPolicyData.policyData.mcpAccess ? { url: accountPolicyData.policyData.mcpRegistryUrl, registry_access: accountPolicyData.policyData.mcpAccess } : null; + const data = accountPolicyData.policyData.mcpRegistryUrl && accountPolicyData.policyData.mcpAccess ? { url: accountPolicyData.policyData.mcpRegistryUrl, registry_access: accountPolicyData.policyData.mcpAccess } : null; + return { data, fetchedAt: accountPolicyData.mcpRegistryDataFetchedAt }; } - return await this.requestMcpRegistryProvider(sessions); + const data = await this.requestMcpRegistryProvider(sessions); + return !isUndefined(data) ? { data, fetchedAt: Date.now() } : undefined; } private async requestMcpRegistryProvider(sessions: AuthenticationSession[]): Promise { @@ -660,11 +665,13 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return undefined; } - if (response.res.statusCode && response.res.statusCode !== 200) { - this.logService.trace(`[DefaultAccount] unexpected status code ${response.res.statusCode} while fetching MCP registry data`); - return response.res.statusCode === 404 /* mcp not configured */ - ? null - : undefined; + if (!isSuccess(response)) { + if (isClientError(response)) { + this.logService.debug(`[DefaultAccount] Received ${response.res.statusCode} for MCP registry data, treating as no registry available.`); + return null; + } + this.logService.debug(`[DefaultAccount] unexpected status code ${response.res.statusCode} while fetching MCP registry data`); + return undefined; } try { @@ -703,7 +710,8 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid }, token); const status = response.res.statusCode; - if (status && status !== 200) { + if (status === 401 || status === 404) { + this.logService.debug(`[DefaultAccount] Received ${status} for URL ${url} with session ${session.id}, likely due to expired/revoked token or insufficient permissions.`, 'Trying next session if available.'); lastResponse = response; continue; // try next session } @@ -711,7 +719,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return response; } catch (error) { if (!token.isCancellationRequested) { - this.logService.error(`[chat entitlement] request: error ${error}`); + this.logService.error(`[DefaultAccount] request: error ${error}`, url); } } } @@ -724,6 +732,10 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return lastResponse; } + private isDataStale(fetchedAt: number): boolean { + return (Date.now() - fetchedAt) >= ACCOUNT_DATA_POLL_INTERVAL_MS; + } + private getEntitlementUrl(): string | undefined { if (this.getDefaultAccountAuthenticationProvider().enterprise) { try { diff --git a/src/vs/workbench/services/preferences/common/preferences.ts b/src/vs/workbench/services/preferences/common/preferences.ts index 4fc67f487988b..106103bdd9f7f 100644 --- a/src/vs/workbench/services/preferences/common/preferences.ts +++ b/src/vs/workbench/services/preferences/common/preferences.ts @@ -64,6 +64,7 @@ export interface ISetting { value: any; valueRange: IRange; description: string[]; + keywords?: string[]; descriptionIsMarkdown?: boolean; descriptionRanges: IRange[]; overrides?: ISetting[]; diff --git a/src/vs/workbench/services/preferences/common/preferencesModels.ts b/src/vs/workbench/services/preferences/common/preferencesModels.ts index 3dc1900f41a66..a6e78cc1fa0b2 100644 --- a/src/vs/workbench/services/preferences/common/preferencesModels.ts +++ b/src/vs/workbench/services/preferences/common/preferencesModels.ts @@ -723,6 +723,7 @@ export class DefaultSettings extends Disposable { value, description: descriptionLines, descriptionIsMarkdown: !!prop.markdownDescription, + keywords: prop.keywords, range: nullRange, keyRange: nullRange, valueRange: nullRange, diff --git a/src/vscode-dts/vscode.proposed.chatHooks.d.ts b/src/vscode-dts/vscode.proposed.chatHooks.d.ts index 39064b88952e9..eec28002b77d1 100644 --- a/src/vscode-dts/vscode.proposed.chatHooks.d.ts +++ b/src/vscode-dts/vscode.proposed.chatHooks.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 3 +// version: 6 declare module 'vscode' { @@ -13,18 +13,33 @@ declare module 'vscode' { export type ChatHookType = 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'PreCompact' | 'SubagentStart' | 'SubagentStop' | 'Stop'; /** - * Options for executing a hook command. + * A resolved hook command ready for execution. + * The command has already been resolved for the current platform. */ - export interface ChatHookExecutionOptions { + export interface ChatHookCommand { /** - * Input data to pass to the hook via stdin (will be JSON-serialized). + * The shell command to execute, already resolved for the current platform. */ - readonly input?: unknown; + readonly command: string; /** - * The tool invocation token from the chat request context, - * used to associate the hook execution with the current chat session. + * Working directory for the command. */ - readonly toolInvocationToken: ChatParticipantToolToken; + readonly cwd?: Uri; + /** + * Additional environment variables for the command. + */ + readonly env?: Record; + /** + * Maximum execution time in seconds. + */ + readonly timeout?: number; + } + + /** + * Collected hooks for a chat request, organized by hook type. + */ + export interface ChatRequestHooks { + readonly [hookType: string]: readonly ChatHookCommand[]; } /** @@ -60,20 +75,15 @@ declare module 'vscode' { readonly output: unknown; } - export namespace chat { + export interface ChatRequest { /** - * Execute all hooks of the specified type for the current chat session. - * Hooks are configured in hooks .json files in the workspace. - * - * @param hookType The type of hook to execute. - * @param options Hook execution options including the input data. - * @param token Optional cancellation token. - * @returns A promise that resolves to an array of hook execution results. + * Resolved hook commands for this request, organized by hook type. + * The commands have already been resolved for the current platform. + * Only present when hooks are enabled. */ - export function executeHook(hookType: ChatHookType, options: ChatHookExecutionOptions, token?: CancellationToken): Thenable; + readonly hooks?: ChatRequestHooks; } - /** * A progress part representing the execution result of a hook. * Hooks are user-configured scripts that run at specific points during chat processing. diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index a2dc921d69f87..df078abc002aa 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -23,7 +23,12 @@ declare module 'vscode' { /** * The chat session is currently in progress. */ - InProgress = 2 + InProgress = 2, + + /** + * The chat session needs user input (e.g. an unresolved confirmation). + */ + NeedsInput = 3 } export namespace chat {