diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index 44d0d3273f359..72120d9b95ea0 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -136,6 +136,7 @@ "editorSuggestWidget.selectedBackground": "#3994BC26", "editorHoverWidget.background": "#202122", "editorHoverWidget.border": "#2A2B2CFF", + "widget.border": "#2A2B2CFF", "peekView.border": "#2A2B2CFF", "peekViewEditor.background": "#191A1B", "peekViewEditor.matchHighlightBackground": "#3994BC33", diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index 36c310315e65e..0f8e2067f0240 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -106,7 +106,7 @@ "commandCenter.foreground": "#202020", "commandCenter.activeForeground": "#202020", "commandCenter.background": "#FAFAFD", - "commandCenter.activeBackground": "#EEEEEE", + "commandCenter.activeBackground": "#DADADA4f", "commandCenter.border": "#D8D8D8", "editor.background": "#FFFFFF", "editor.foreground": "#202020", @@ -185,6 +185,7 @@ "statusBarItem.prominentBackground": "#0069CCDD", "statusBarItem.prominentForeground": "#FFFFFF", "statusBarItem.prominentHoverBackground": "#0069CC", + "toolbar.hoverBackground": "#DADADA4f", "tab.activeBackground": "#FFFFFF", "tab.activeForeground": "#202020", "tab.inactiveBackground": "#FAFAFD", diff --git a/src/vs/editor/contrib/codeAction/browser/codeActionCommands.ts b/src/vs/editor/contrib/codeAction/browser/codeActionCommands.ts index cce59aaa131ce..3019704a6287d 100644 --- a/src/vs/editor/contrib/codeAction/browser/codeActionCommands.ts +++ b/src/vs/editor/contrib/codeAction/browser/codeActionCommands.ts @@ -82,7 +82,7 @@ export class QuickFixAction extends EditorAction2 { }, menu: { id: MenuId.InlineChatEditorAffordance, - group: '0_quickfix', + group: '1_quickfix', order: 0, when: ContextKeyExpr.and(EditorContextKeys.writable, EditorContextKeys.hasCodeActionsProvider) } diff --git a/src/vs/editor/contrib/codeAction/browser/codeActionController.ts b/src/vs/editor/contrib/codeAction/browser/codeActionController.ts index 5124acc51a1fe..6463d89e4c92b 100644 --- a/src/vs/editor/contrib/codeAction/browser/codeActionController.ts +++ b/src/vs/editor/contrib/codeAction/browser/codeActionController.ts @@ -13,7 +13,8 @@ import { onUnexpectedError } from '../../../../base/common/errors.js'; import { HierarchicalKind } from '../../../../base/common/hierarchicalKind.js'; import { Lazy } from '../../../../base/common/lazy.js'; import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { derived, IObservable } from '../../../../base/common/observable.js'; +import { derivedOpts, IObservable, observableValue } from '../../../../base/common/observable.js'; +import { Event } from '../../../../base/common/event.js'; import { localize } from '../../../../nls.js'; import { IActionListDelegate } from '../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; @@ -35,11 +36,12 @@ import { ModelDecorationOptions } from '../../../common/model/textModel.js'; import { ILanguageFeaturesService } from '../../../common/services/languageFeatures.js'; import { MessageController } from '../../message/browser/messageController.js'; import { CodeActionAutoApply, CodeActionFilter, CodeActionItem, CodeActionKind, CodeActionSet, CodeActionTrigger, CodeActionTriggerSource } from '../common/types.js'; -import { ApplyCodeActionReason, applyCodeAction } from './codeAction.js'; +import { ApplyCodeActionReason, applyCodeAction, autoFixCommandId, quickFixCommandId } from './codeAction.js'; import { CodeActionKeybindingResolver } from './codeActionKeybindingResolver.js'; import { toMenuItems } from './codeActionMenu.js'; import { CodeActionModel, CodeActionsState } from './codeActionModel.js'; -import { LightBulbInfo, LightBulbWidget } from './lightBulbWidget.js'; +import { computeLightBulbInfo, LightBulbInfo, LightBulbWidget } from './lightBulbWidget.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; interface IActionShowOptions { readonly includeDisabledActions?: boolean; @@ -68,12 +70,34 @@ export class CodeActionController extends Disposable implements IEditorContribut private _disposed = false; - public readonly lightBulbState: IObservable = derived(this, reader => { + set onlyLightBulbWithEmptySelection(value: boolean) { const widget = this._lightBulbWidget.rawValue; - if (!widget) { - return undefined; + if (widget) { + widget.onlyWithEmptySelection = value; } - return widget.lightBulbInfo.read(reader); + this._onlyLightBulbWithEmptySelection = value; + } + + private _onlyLightBulbWithEmptySelection = false; + + private readonly _lightBulbInfoObs = observableValue(this, undefined); + private readonly _preferredKbLabel = observableValue(this, undefined); + private readonly _quickFixKbLabel = observableValue(this, undefined); + + private _hasLightBulbStateObservers = false; + + public readonly lightBulbState: IObservable = derivedOpts({ + owner: this, + onLastObserverRemoved: () => { + this._hasLightBulbStateObservers = false; + this._model.ignoreLightbulbOff = false; + }, + }, reader => { + if (!this._hasLightBulbStateObservers) { + this._hasLightBulbStateObservers = true; + this._model.ignoreLightbulbOff = true; + } + return this._lightBulbInfoObs.read(reader); }); constructor( @@ -88,6 +112,7 @@ export class CodeActionController extends Disposable implements IEditorContribut @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IEditorProgressService private readonly _progressService: IEditorProgressService, + @IKeybindingService private readonly _keybindingService: IKeybindingService, ) { super(); @@ -95,10 +120,16 @@ export class CodeActionController extends Disposable implements IEditorContribut this._model = this._register(new CodeActionModel(this._editor, languageFeaturesService.codeActionProvider, markerService, contextKeyService, progressService, _configurationService)); this._register(this._model.onDidChangeState(newState => this.update(newState))); + this._register(Event.runAndSubscribe(this._keybindingService.onDidUpdateKeybindings, () => { + this._preferredKbLabel.set(this._keybindingService.lookupKeybinding(autoFixCommandId)?.getLabel() ?? undefined, undefined); + this._quickFixKbLabel.set(this._keybindingService.lookupKeybinding(quickFixCommandId)?.getLabel() ?? undefined, undefined); + })); + this._lightBulbWidget = new Lazy(() => { const widget = this._editor.getContribution(LightBulbWidget.ID); if (widget) { this._register(widget.onClick(e => this.showCodeActionsFromLightbulb(e.actions, e))); + widget.onlyWithEmptySelection = this._onlyLightBulbWithEmptySelection; } return widget; }); @@ -175,6 +206,7 @@ export class CodeActionController extends Disposable implements IEditorContribut private async update(newState: CodeActionsState.State): Promise { if (newState.type !== CodeActionsState.Type.Triggered) { this.hideLightBulbWidget(); + this._lightBulbInfoObs.set(undefined, undefined); return; } @@ -197,6 +229,7 @@ export class CodeActionController extends Disposable implements IEditorContribut } this._lightBulbWidget.value?.update(actions, newState.trigger, newState.position); + this._lightBulbInfoObs.set(computeLightBulbInfo(actions, newState.trigger, this._preferredKbLabel.get(), this._quickFixKbLabel.get()), undefined); if (newState.trigger.type === CodeActionTriggerType.Invoke) { if (newState.trigger.filter?.include) { // Triggered for specific scope diff --git a/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts b/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts index c05cb6744f465..454e9892722ae 100644 --- a/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts +++ b/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts @@ -36,6 +36,8 @@ class CodeActionOracle extends Disposable { private readonly _autoTriggerTimer = this._register(new TimeoutTimer()); + ignoreLightbulbOff = false; + constructor( private readonly _editor: ICodeEditor, private readonly _markerService: IMarkerService, @@ -74,9 +76,9 @@ class CodeActionOracle extends Disposable { return selection; } const enabled = this._editor.getOption(EditorOption.lightbulb).enabled; - if (enabled === ShowLightbulbIconMode.Off) { + if (enabled === ShowLightbulbIconMode.Off && !this.ignoreLightbulbOff) { return undefined; - } else if (enabled === ShowLightbulbIconMode.On) { + } else if (enabled === ShowLightbulbIconMode.Off || enabled === ShowLightbulbIconMode.On) { return selection; } else if (enabled === ShowLightbulbIconMode.OnCode) { const isSelectionEmpty = selection.isEmpty(); @@ -167,6 +169,22 @@ export class CodeActionModel extends Disposable { private _disposed = false; + private _ignoreLightbulbOff = false; + + set ignoreLightbulbOff(value: boolean) { + if (this._ignoreLightbulbOff === value) { + return; + } + this._ignoreLightbulbOff = value; + const oracle = this._codeActionOracle.value; + if (oracle) { + oracle.ignoreLightbulbOff = value; + if (value) { + oracle.trigger({ type: CodeActionTriggerType.Auto, triggerAction: CodeActionTriggerSource.Default }); + } + } + } + constructor( private readonly _editor: ICodeEditor, private readonly _registry: LanguageFeatureRegistry, @@ -221,7 +239,7 @@ export class CodeActionModel extends Disposable { const supportedActions: string[] = this._registry.all(model).flatMap(provider => provider.providedCodeActionKinds ?? []); this._supportedCodeActions.set(supportedActions.join(' ')); - this._codeActionOracle.value = new CodeActionOracle(this._editor, this._markerService, trigger => { + const oracle = new CodeActionOracle(this._editor, this._markerService, trigger => { if (!trigger) { this.setState(CodeActionsState.Empty); return; @@ -363,6 +381,8 @@ export class CodeActionModel extends Disposable { }, 500); } }, undefined); + oracle.ignoreLightbulbOff = this._ignoreLightbulbOff; + this._codeActionOracle.value = oracle; this._codeActionOracle.value.trigger({ type: CodeActionTriggerType.Auto, triggerAction: CodeActionTriggerSource.Default }); } else { this._supportedCodeActions.reset(); diff --git a/src/vs/editor/contrib/codeAction/browser/lightBulbWidget.ts b/src/vs/editor/contrib/codeAction/browser/lightBulbWidget.ts index 3de0481ef730a..67fd18ea9cec6 100644 --- a/src/vs/editor/contrib/codeAction/browser/lightBulbWidget.ts +++ b/src/vs/editor/contrib/codeAction/browser/lightBulbWidget.ts @@ -12,7 +12,7 @@ import { autorun, derived, IObservable, observableValue } from '../../../../base import { ThemeIcon } from '../../../../base/common/themables.js'; import './lightBulbWidget.css'; import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition, IEditorMouseEvent } from '../../../browser/editorBrowser.js'; -import { EditorOption } from '../../../common/config/editorOptions.js'; +import { EditorOption, ShowLightbulbIconMode } from '../../../common/config/editorOptions.js'; import { IPosition } from '../../../common/core/position.js'; import { GlyphMarginLane, IModelDecorationsChangeAccessor, TrackedRangeStickiness } from '../../../common/model.js'; import { ModelDecorationOptions } from '../../../common/model/textModel.js'; @@ -62,9 +62,49 @@ namespace LightBulbState { export type State = typeof Hidden | Showing; } +export function computeLightBulbInfo(actions: CodeActionSet, trigger: CodeActionTrigger, preferredKbLabel: string | undefined, quickFixKbLabel: string | undefined, forGutter: boolean = false): LightBulbInfo | undefined { + if (actions.validActions.length <= 0) { + return undefined; + } + + let icon: ThemeIcon; + let autoRun = false; + if (actions.allAIFixes) { + icon = forGutter ? GUTTER_SPARKLE_FILLED_ICON : Codicon.sparkleFilled; + if (actions.validActions.length === 1) { + autoRun = true; + } + } else if (actions.hasAutoFix) { + if (actions.hasAIFix) { + icon = forGutter ? GUTTER_LIGHTBULB_AIFIX_AUTO_FIX_ICON : Codicon.lightbulbSparkleAutofix; + } else { + icon = forGutter ? GUTTER_LIGHTBULB_AUTO_FIX_ICON : Codicon.lightbulbAutofix; + } + } else if (actions.hasAIFix) { + icon = forGutter ? GUTTER_LIGHTBULB_AIFIX_ICON : Codicon.lightbulbSparkle; + } else { + icon = forGutter ? GUTTER_LIGHTBULB_ICON : Codicon.lightBulb; + } + + let title: string; + if (autoRun) { + title = nls.localize('codeActionAutoRun', "Run: {0}", actions.validActions[0].action.title); + } else if (actions.hasAutoFix && preferredKbLabel) { + title = nls.localize('preferredcodeActionWithKb', "Show Code Actions. Preferred Quick Fix Available ({0})", preferredKbLabel); + } else if (!actions.hasAutoFix && quickFixKbLabel) { + title = nls.localize('codeActionWithKb', "Show Code Actions ({0})", quickFixKbLabel); + } else { + title = nls.localize('codeAction', "Show Code Actions"); + } + + return { actions, trigger, icon, autoRun, title, isGutter: forGutter }; +} + export class LightBulbWidget extends Disposable implements IContentWidget { private _gutterDecorationID: string | undefined; + onlyWithEmptySelection = false; + private static readonly GUTTER_DECORATION = ModelDecorationOptions.register({ description: 'codicon-gutter-lightbulb-decoration', glyphMarginClassName: ThemeIcon.asClassName(Codicon.lightBulb), @@ -117,39 +157,7 @@ export class LightBulbWidget extends Disposable implements IContentWidget { if (state.type !== LightBulbState.Type.Showing) { return undefined; } - - const { actions, trigger } = state; - let icon: ThemeIcon; - let autoRun = false; - if (actions.allAIFixes) { - icon = forGutter ? GUTTER_SPARKLE_FILLED_ICON : Codicon.sparkleFilled; - if (actions.validActions.length === 1) { - autoRun = true; - } - } else if (actions.hasAutoFix) { - if (actions.hasAIFix) { - icon = forGutter ? GUTTER_LIGHTBULB_AIFIX_AUTO_FIX_ICON : Codicon.lightbulbSparkleAutofix; - } else { - icon = forGutter ? GUTTER_LIGHTBULB_AUTO_FIX_ICON : Codicon.lightbulbAutofix; - } - } else if (actions.hasAIFix) { - icon = forGutter ? GUTTER_LIGHTBULB_AIFIX_ICON : Codicon.lightbulbSparkle; - } else { - icon = forGutter ? GUTTER_LIGHTBULB_ICON : Codicon.lightBulb; - } - - let title: string; - if (autoRun) { - title = nls.localize('codeActionAutoRun', "Run: {0}", actions.validActions[0].action.title); - } else if (actions.hasAutoFix && preferredKbLabel) { - title = nls.localize('preferredcodeActionWithKb', "Show Code Actions. Preferred Quick Fix Available ({0})", preferredKbLabel); - } else if (!actions.hasAutoFix && quickFixKbLabel) { - title = nls.localize('codeActionWithKb', "Show Code Actions ({0})", quickFixKbLabel); - } else { - title = nls.localize('codeAction', "Show Code Actions"); - } - - return { actions, trigger, icon, autoRun, title, isGutter: forGutter }; + return computeLightBulbInfo(state.actions, state.trigger, preferredKbLabel, quickFixKbLabel, forGutter); } constructor( @@ -288,6 +296,11 @@ export class LightBulbWidget extends Disposable implements IContentWidget { return this.hide(); } + if (this.onlyWithEmptySelection && !this._editor.getSelection()?.isEmpty()) { + this.gutterHide(); + return this.hide(); + } + const hasTextFocus = this._editor.hasTextFocus(); if (!hasTextFocus) { this.gutterHide(); @@ -295,7 +308,7 @@ export class LightBulbWidget extends Disposable implements IContentWidget { } const options = this._editor.getOptions(); - if (!options.get(EditorOption.lightbulb).enabled) { + if (options.get(EditorOption.lightbulb).enabled === ShowLightbulbIconMode.Off) { this.gutterHide(); return this.hide(); } diff --git a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css index b1cb10b4d6bd5..44c003884676a 100644 --- a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css +++ b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css @@ -10,8 +10,8 @@ height: 100%; top: 0; left: 0; - /* z-index for modal editors: above titlebar (2500) but below dialogs (2575) */ - z-index: 2550; + /* z-index for modal editors: above titlebar (2500) but below quick input (2550) and dialogs (2575) */ + z-index: 2540; display: flex; justify-content: center; align-items: center; diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index e051ccfcc2bb6..47b9c59f51dfa 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -60,6 +60,7 @@ import { IPromptsService } from '../common/promptSyntax/service/promptsService.j import { PromptsService } from '../common/promptSyntax/service/promptsServiceImpl.js'; import { LanguageModelToolsExtensionPointHandler } from '../common/tools/languageModelToolsContribution.js'; import { BuiltinToolsContribution } from '../common/tools/builtinTools/tools.js'; +import { RenameToolContribution } from './tools/renameTool.js'; import { UsagesToolContribution } from './tools/usagesTool.js'; import { IVoiceChatService, VoiceChatService } from '../common/voiceChatService.js'; import { registerChatAccessibilityActions } from './actions/chatAccessibilityActions.js'; @@ -1438,6 +1439,7 @@ registerWorkbenchContribution2(ChatTeardownContribution.ID, ChatTeardownContribu registerWorkbenchContribution2(ChatStatusBarEntry.ID, ChatStatusBarEntry, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(BuiltinToolsContribution.ID, BuiltinToolsContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(UsagesToolContribution.ID, UsagesToolContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(RenameToolContribution.ID, RenameToolContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatAgentSettingContribution.ID, ChatAgentSettingContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatAgentActionsContribution.ID, ChatAgentActionsContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ToolReferenceNamesContribution.ID, ToolReferenceNamesContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts b/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts new file mode 100644 index 0000000000000..dcaa3e1e6d710 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts @@ -0,0 +1,264 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { Position } from '../../../../../editor/common/core/position.js'; +import { TextEdit } from '../../../../../editor/common/languages.js'; +import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js'; +import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; +import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { rename } from '../../../../../editor/contrib/rename/browser/rename.js'; +import { localize } from '../../../../../nls.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { IChatService } from '../../common/chatService/chatService.js'; +import { ChatModel } from '../../common/model/chatModel.js'; +import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress } from '../../common/tools/languageModelToolsService.js'; +import { createToolSimpleTextResult } from '../../common/tools/builtinTools/toolHelpers.js'; +import { errorResult, findLineNumber, findSymbolColumn, ISymbolToolInput, resolveToolUri } from './toolHelpers.js'; + +export const RenameToolId = 'vscode_renameSymbol'; + +interface IRenameToolInput extends ISymbolToolInput { + newName: string; +} + +const BaseModelDescription = `Rename a code symbol across the workspace using the language server's rename functionality. This performs a precise, semantics-aware rename that updates all references. + +Input: +- "symbol": The exact current name of the symbol to rename. +- "newName": The new name for the symbol. +- "uri": A full URI (e.g. "file:///path/to/file.ts") of a file where the symbol appears. Provide either "uri" or "filePath". +- "filePath": A workspace-relative file path (e.g. "src/utils/helpers.ts") of a file where the symbol appears. Provide either "uri" or "filePath". +- "lineContent": A substring of the line of code where the symbol appears. This is used to locate the exact position in the file. Must be the actual text from the file - do NOT fabricate it. + +IMPORTANT: The file and line do NOT need to be the definition of the symbol. Any occurrence works - a usage, an import, a call site, etc. You can pick whichever occurrence is most convenient. + +If the tool returns an error, retry with corrected input - ensure the file path is correct, the line content matches the actual file content, and the symbol name appears in that line.`; + +export class RenameTool extends Disposable implements IToolImpl { + + private readonly _onDidUpdateToolData = this._store.add(new Emitter()); + readonly onDidUpdateToolData = this._onDidUpdateToolData.event; + + constructor( + @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + @ITextModelService private readonly _textModelService: ITextModelService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IChatService private readonly _chatService: IChatService, + @IBulkEditService private readonly _bulkEditService: IBulkEditService, + ) { + super(); + + this._store.add(Event.debounce( + this._languageFeaturesService.renameProvider.onDidChange, + () => { }, + 2000 + )((() => this._onDidUpdateToolData.fire()))); + } + + getToolData(): IToolData { + const languageIds = this._languageFeaturesService.renameProvider.registeredLanguageIds; + + let modelDescription = BaseModelDescription; + if (languageIds.has('*')) { + modelDescription += '\n\nSupported for all languages.'; + } else if (languageIds.size > 0) { + const sorted = [...languageIds].sort(); + modelDescription += `\n\nCurrently supported for: ${sorted.join(', ')}.`; + } else { + modelDescription += '\n\nNo languages currently have rename providers registered.'; + } + + return { + id: RenameToolId, + toolReferenceName: 'rename', + canBeReferencedInPrompt: false, + icon: ThemeIcon.fromId(Codicon.rename.id), + displayName: localize('tool.rename.displayName', 'Rename Symbol'), + userDescription: localize('tool.rename.userDescription', 'Rename a symbol across the workspace'), + modelDescription, + source: ToolDataSource.Internal, + inputSchema: { + type: 'object', + properties: { + symbol: { + type: 'string', + description: 'The exact current name of the symbol to rename.' + }, + newName: { + type: 'string', + description: 'The new name for the symbol.' + }, + uri: { + type: 'string', + description: 'A full URI of a file where the symbol appears (e.g. "file:///path/to/file.ts"). Provide either "uri" or "filePath".' + }, + filePath: { + type: 'string', + description: 'A workspace-relative file path where the symbol appears (e.g. "src/utils/helpers.ts"). Provide either "uri" or "filePath".' + }, + lineContent: { + type: 'string', + description: 'A substring of the line of code where the symbol appears. Used to locate the exact position. Must be actual text from the file.' + } + }, + required: ['symbol', 'newName', 'lineContent'] + } + }; + } + + async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + const input = context.parameters as IRenameToolInput; + return { + invocationMessage: localize('tool.rename.invocationMessage', 'Renaming `{0}` to `{1}`', input.symbol, input.newName), + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise { + const input = invocation.parameters as IRenameToolInput; + + // --- resolve URI --- + const uri = resolveToolUri(input, this._workspaceContextService); + if (!uri) { + return errorResult('Provide either "uri" (a full URI) or "filePath" (a workspace-relative path) to identify the file.'); + } + + // --- open text model --- + const ref = await this._textModelService.createModelReference(uri); + try { + const model = ref.object.textEditorModel; + + if (!this._languageFeaturesService.renameProvider.has(model)) { + return errorResult(`No rename provider available for this file's language. The rename tool may not support this language.`); + } + + // --- find line containing lineContent --- + const lineNumber = findLineNumber(model, input.lineContent); + if (lineNumber === undefined) { + return errorResult(`Could not find line content "${input.lineContent}" in ${uri.toString()}. Provide the exact text from the line where the symbol appears.`); + } + + // --- find symbol in that line --- + const lineText = model.getLineContent(lineNumber); + const column = findSymbolColumn(lineText, input.symbol); + if (column === undefined) { + return errorResult(`Could not find symbol "${input.symbol}" in the matched line. Ensure the symbol name is correct and appears in the provided line content.`); + } + + const position = new Position(lineNumber, column); + + // --- perform rename --- + const renameResult = await rename(this._languageFeaturesService.renameProvider, model, position, input.newName); + + if (renameResult.rejectReason) { + return errorResult(`Rename rejected: ${renameResult.rejectReason}`); + } + + if (renameResult.edits.length === 0) { + return errorResult(`Rename produced no edits.`); + } + + // --- apply edits via chat response stream --- + if (invocation.context) { + const chatModel = this._chatService.getSession(invocation.context.sessionResource) as ChatModel | undefined; + const request = chatModel?.getRequests().at(-1); + + if (chatModel && request) { + // Group text edits by URI + const editsByUri = new ResourceMap(); + for (const edit of renameResult.edits) { + if (ResourceTextEdit.is(edit)) { + let edits = editsByUri.get(edit.resource); + if (!edits) { + edits = []; + editsByUri.set(edit.resource, edits); + } + edits.push(edit.textEdit); + } + } + + // Push edits through the chat response stream + for (const [editUri, edits] of editsByUri) { + chatModel.acceptResponseProgress(request, { + kind: 'textEdit', + uri: editUri, + edits: [], + }); + chatModel.acceptResponseProgress(request, { + kind: 'textEdit', + uri: editUri, + edits, + }); + chatModel.acceptResponseProgress(request, { + kind: 'textEdit', + uri: editUri, + edits: [], + done: true, + }); + } + + return this._successResult(input, editsByUri.size, renameResult.edits.length); + } + } + + // Fallback: apply via bulk edit service when no chat context is available + await this._bulkEditService.apply(renameResult); + const fileCount = new ResourceSet(renameResult.edits.filter(ResourceTextEdit.is).map(e => e.resource)).size; + return this._successResult(input, fileCount, renameResult.edits.length); + + } finally { + ref.dispose(); + } + } + + private _successResult(input: IRenameToolInput, fileCount: number, editCount: number): IToolResult { + const text = editCount === 1 + ? localize('tool.rename.oneEdit', "Renamed `{0}` to `{1}` - 1 edit in {2} file.", input.symbol, input.newName, fileCount) + : localize('tool.rename.edits', "Renamed `{0}` to `{1}` - {2} edits across {3} files.", input.symbol, input.newName, editCount, fileCount); + const result = createToolSimpleTextResult(text); + result.toolResultMessage = new MarkdownString(text); + return result; + } + +} + + + +export class RenameToolContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'chat.renameTool'; + + constructor( + @ILanguageModelToolsService toolsService: ILanguageModelToolsService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const renameTool = this._store.add(instantiationService.createInstance(RenameTool)); + + let registration: IDisposable | undefined; + const registerRenameTool = () => { + registration?.dispose(); + toolsService.flushToolUpdates(); + const toolData = renameTool.getToolData(); + registration = toolsService.registerTool(toolData, renameTool); + }; + registerRenameTool(); + this._store.add(renameTool.onDidUpdateToolData(registerRenameTool)); + this._store.add({ + dispose: () => { + registration?.dispose(); + } + }); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/tools/toolHelpers.ts b/src/vs/workbench/contrib/chat/browser/tools/toolHelpers.ts new file mode 100644 index 0000000000000..28284cad6ae7f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/tools/toolHelpers.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { escapeRegExpCharacters } from '../../../../../base/common/strings.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IToolResult } from '../../common/tools/languageModelToolsService.js'; +import { createToolSimpleTextResult } from '../../common/tools/builtinTools/toolHelpers.js'; + +export interface ISymbolToolInput { + symbol: string; + uri?: string; + filePath?: string; + lineContent: string; +} + +/** + * Resolves a URI from tool input. Accepts either a full URI string or a + * workspace-relative file path. + */ +export function resolveToolUri(input: ISymbolToolInput, workspaceContextService: IWorkspaceContextService): URI | undefined { + if (input.uri) { + return URI.parse(input.uri); + } + if (input.filePath) { + const folders = workspaceContextService.getWorkspace().folders; + if (folders.length === 1) { + return folders[0].toResource(input.filePath); + } + // try each folder, return the first + for (const folder of folders) { + return folder.toResource(input.filePath); + } + } + return undefined; +} + +/** + * Finds the line number in the model that matches the given line content. + * Whitespace is normalized so that extra spaces in the input still match. + * + * @returns The 1-based line number, or `undefined` if not found. + */ +export function findLineNumber(model: ITextModel, lineContent: string): number | undefined { + const parts = lineContent.trim().split(/\s+/); + const pattern = parts.map(escapeRegExpCharacters).join('\\s+'); + const matches = model.findMatches(pattern, false, true, false, null, false, 1); + if (matches.length === 0) { + return undefined; + } + return matches[0].range.startLineNumber; +} + +/** + * Finds the 1-based column of a symbol within a line of text using word + * boundary matching. + * + * @returns The 1-based column, or `undefined` if not found. + */ +export function findSymbolColumn(lineText: string, symbol: string): number | undefined { + const pattern = new RegExp(`\\b${escapeRegExpCharacters(symbol)}\\b`); + const match = pattern.exec(lineText); + if (match) { + return match.index + 1; // 1-based column + } + return undefined; +} + +/** + * Creates an error tool result with the given message as both the content + * and the tool result message. + */ +export function errorResult(message: string): IToolResult { + const result = createToolSimpleTextResult(message); + result.toolResultMessage = new MarkdownString(message); + return result; +} diff --git a/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts b/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts index aa8e6731069ef..8e9617b3c7ef6 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts @@ -11,7 +11,6 @@ import { escapeRegExpCharacters } from '../../../../../base/common/strings.js'; import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../base/common/map.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { URI } from '../../../../../base/common/uri.js'; import { relativePath } from '../../../../../base/common/resources.js'; import { Position } from '../../../../../editor/common/core/position.js'; import { Range } from '../../../../../editor/common/core/range.js'; @@ -27,16 +26,10 @@ import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { ISearchService, QueryType, resultIsMatch } from '../../../../services/search/common/search.js'; import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress, } from '../../common/tools/languageModelToolsService.js'; import { createToolSimpleTextResult } from '../../common/tools/builtinTools/toolHelpers.js'; +import { errorResult, findLineNumber, findSymbolColumn, ISymbolToolInput, resolveToolUri } from './toolHelpers.js'; export const UsagesToolId = 'vscode_listCodeUsages'; -interface IUsagesToolInput { - symbol: string; - uri?: string; - filePath?: string; - lineContent: string; -} - const BaseModelDescription = `Find all usages (references, definitions, and implementations) of a code symbol across the workspace. This tool locates where a symbol is referenced, defined, or implemented. Input: @@ -118,19 +111,19 @@ export class UsagesTool extends Disposable implements IToolImpl { } async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { - const input = context.parameters as IUsagesToolInput; + const input = context.parameters as ISymbolToolInput; return { invocationMessage: localize('tool.usages.invocationMessage', 'Analyzing usages of `{0}`', input.symbol), }; } async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise { - const input = invocation.parameters as IUsagesToolInput; + const input = invocation.parameters as ISymbolToolInput; // --- resolve URI --- - const uri = this._resolveUri(input); + const uri = resolveToolUri(input, this._workspaceContextService); if (!uri) { - return this._errorResult('Provide either "uri" (a full URI) or "filePath" (a workspace-relative path) to identify the file.'); + return errorResult('Provide either "uri" (a full URI) or "filePath" (a workspace-relative path) to identify the file.'); } // --- open text model --- @@ -139,23 +132,20 @@ export class UsagesTool extends Disposable implements IToolImpl { const model = ref.object.textEditorModel; if (!this._languageFeaturesService.referenceProvider.has(model)) { - return this._errorResult(`No reference provider available for this file's language. The usages tool may not support this language.`); + return errorResult(`No reference provider available for this file's language. The usages tool may not support this language.`); } // --- find line containing lineContent --- - const parts = input.lineContent.trim().split(/\s+/); - const lineContent = parts.map(escapeRegExpCharacters).join('\\s+'); - const matches = model.findMatches(lineContent, false, true, false, null, false, 1); - if (matches.length === 0) { - return this._errorResult(`Could not find line content "${input.lineContent}" in ${uri.toString()}. Provide the exact text from the line where the symbol appears.`); + const lineNumber = findLineNumber(model, input.lineContent); + if (lineNumber === undefined) { + return errorResult(`Could not find line content "${input.lineContent}" in ${uri.toString()}. Provide the exact text from the line where the symbol appears.`); } - const lineNumber = matches[0].range.startLineNumber; // --- find symbol in that line --- const lineText = model.getLineContent(lineNumber); - const column = this._findSymbolColumn(lineText, input.symbol); + const column = findSymbolColumn(lineText, input.symbol); if (column === undefined) { - return this._errorResult(`Could not find symbol "${input.symbol}" in the matched line. Ensure the symbol name is correct and appears in the provided line content.`); + return errorResult(`Could not find symbol "${input.symbol}" in the matched line. Ensure the symbol name is correct and appears in the provided line content.`); } const position = new Position(lineNumber, column); @@ -294,33 +284,6 @@ export class UsagesTool extends Disposable implements IToolImpl { return previews; } - private _resolveUri(input: IUsagesToolInput): URI | undefined { - if (input.uri) { - return URI.parse(input.uri); - } - if (input.filePath) { - const folders = this._workspaceContextService.getWorkspace().folders; - if (folders.length === 1) { - return folders[0].toResource(input.filePath); - } - // try each folder, return the first - for (const folder of folders) { - return folder.toResource(input.filePath); - } - } - return undefined; - } - - private _findSymbolColumn(lineText: string, symbol: string): number | undefined { - // use word boundary matching to avoid partial matches - const pattern = new RegExp(`\\b${escapeRegExpCharacters(symbol)}\\b`); - const match = pattern.exec(lineText); - if (match) { - return match.index + 1; // 1-based column - } - return undefined; - } - private _classifyReference(ref: LocationLink, definitions: LocationLink[], implementations: LocationLink[]): string { if (definitions.some(d => this._overlaps(ref, d))) { return 'definition'; @@ -338,11 +301,6 @@ export class UsagesTool extends Disposable implements IToolImpl { return Range.areIntersectingOrTouching(a.range, b.range); } - private _errorResult(message: string): IToolResult { - const result = createToolSimpleTextResult(message); - result.toolResultMessage = new MarkdownString(message); - return result; - } } export class UsagesToolContribution extends Disposable implements IWorkbenchContribution { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts index 45d54dedc1560..376d670c21d87 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts @@ -839,7 +839,11 @@ export class ChatListWidget extends Disposable { this._container.style.removeProperty('--chat-current-response-min-height'); } else { const secondToLastItem = this._viewModel?.getItems().at(-2); - const secondToLastItemHeight = Math.min((isRequestVM(secondToLastItem) || isResponseVM(secondToLastItem)) ? secondToLastItem.currentRenderedHeight ?? 150 : 150, 150); + const maxRequestShownHeight = 200; + const secondToLastItemHeight = Math.min( + (isRequestVM(secondToLastItem) || isResponseVM(secondToLastItem)) ? + secondToLastItem.currentRenderedHeight ?? 150 : 150, + maxRequestShownHeight); const lastItemMinHeight = Math.max(contentHeight - (secondToLastItemHeight + 10), 0); this._container.style.setProperty('--chat-current-response-min-height', lastItemMinHeight + 'px'); if (lastItemMinHeight !== this._previousLastItemMinHeight) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index f6f9062405075..e9dc1e75178ef 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -48,6 +48,7 @@ export interface IModePickerDelegate { const builtinDefaultIcon = (mode: IChatMode) => { switch (mode.name.get().toLowerCase()) { case 'ask': return Codicon.ask; + case 'edit': return Codicon.edit; case 'plan': return Codicon.tasklist; default: return undefined; } @@ -208,13 +209,11 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { const currentMode = delegate.currentMode.get(); const agentMode = modes.builtin.find(mode => mode.id === ChatMode.Agent.id); - const shouldHideEditMode = configurationService.getValue(ChatConfiguration.EditModeHidden) && chatAgentService.hasToolsAgent && currentMode.id !== ChatMode.Edit.id; - const otherBuiltinModes = modes.builtin.filter(mode => { if (mode.id === ChatMode.Agent.id) { return false; } - if (shouldHideEditMode && mode.id === ChatMode.Edit.id) { + if (mode.id === ChatMode.Edit.id) { return false; } if (mode.id === ChatMode.Ask.id) { diff --git a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts index 8ea0d40a484f3..ae0c91cc93444 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts @@ -143,7 +143,9 @@ class ChatLifecycleHandler extends Disposable { private hasNonCloudSessionInProgress(): boolean { return this.agentSessionsService.model.sessions.some(session => - isSessionInProgressStatus(session.status) && session.providerType !== AgentSessionProviders.Cloud + isSessionInProgressStatus(session.status) && + session.providerType !== AgentSessionProviders.Cloud && + !session.isArchived() ); } diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts new file mode 100644 index 0000000000000..42226127e20d6 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts @@ -0,0 +1,327 @@ +/*--------------------------------------------------------------------------------------------- + * 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 } from '../../../../../../base/common/cancellation.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { Range } from '../../../../../../editor/common/core/range.js'; +import { RenameProvider, WorkspaceEdit, Rejection } from '../../../../../../editor/common/languages.js'; +import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { LanguageFeaturesService } from '../../../../../../editor/common/services/languageFeaturesService.js'; +import { ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; +import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; +import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { IBulkEditService, IBulkEditResult } from '../../../../../../editor/browser/services/bulkEditService.js'; +import { RenameTool, RenameToolId } from '../../../browser/tools/renameTool.js'; +import { IChatService } from '../../../common/chatService/chatService.js'; +import { IToolInvocation, IToolResult, IToolResultTextPart, ToolProgress } from '../../../common/tools/languageModelToolsService.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; + +function getTextContent(result: IToolResult): string { + const part = result.content.find((p): p is IToolResultTextPart => p.kind === 'text'); + return part?.value ?? ''; +} + +suite('RenameTool', () => { + + const disposables = new DisposableStore(); + let langFeatures: LanguageFeaturesService; + + const testUri = URI.parse('file:///test/file.ts'); + const testContent = [ + 'import { MyClass } from "./myClass";', + '', + 'function doSomething() {', + '\tconst instance = new MyClass();', + '\tinstance.run();', + '}', + ].join('\n'); + + function makeEdit(resource: URI, range: Range, text: string) { + return { resource, versionId: undefined, textEdit: { range, text } }; + } + + function createMockTextModelService(model: unknown): ITextModelService { + return { + _serviceBrand: undefined, + createModelReference: async () => ({ + object: { textEditorModel: model }, + dispose: () => { }, + }), + registerTextModelContentProvider: () => ({ dispose: () => { } }), + canHandleResource: () => false, + } as unknown as ITextModelService; + } + + function createMockWorkspaceService(): IWorkspaceContextService { + const folderUri = URI.parse('file:///test'); + const folder = { + uri: folderUri, + toResource: (relativePath: string) => URI.parse(`file:///test/${relativePath}`), + } as unknown as IWorkspaceFolder; + return { + _serviceBrand: undefined, + getWorkspace: () => ({ folders: [folder] }), + getWorkspaceFolder: (uri: URI) => { + if (uri.toString().startsWith(folderUri.toString())) { + return folder; + } + return null; + }, + } as unknown as IWorkspaceContextService; + } + + function createMockChatService(): IChatService { + return { + _serviceBrand: undefined, + getSession: () => undefined, + } as unknown as IChatService; + } + + function createMockBulkEditService(): IBulkEditService & { appliedEdits: WorkspaceEdit[] } { + const appliedEdits: WorkspaceEdit[] = []; + return { + _serviceBrand: undefined, + apply: async (edit: WorkspaceEdit): Promise => { + appliedEdits.push(edit); + return { ariaSummary: '', isApplied: true }; + }, + appliedEdits, + } as unknown as IBulkEditService & { appliedEdits: WorkspaceEdit[] }; + } + + function createInvocation(parameters: Record): IToolInvocation { + return { parameters } as unknown as IToolInvocation; + } + + const noopCountTokens = async () => 0; + const noopProgress: ToolProgress = { report() { } }; + + function createTool(textModelService: ITextModelService, options?: { bulkEditService?: IBulkEditService }): RenameTool { + return new RenameTool( + langFeatures, + textModelService, + createMockWorkspaceService(), + createMockChatService(), + options?.bulkEditService ?? createMockBulkEditService(), + ); + } + + setup(() => { + langFeatures = new LanguageFeaturesService(); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('getToolData', () => { + + test('reports no providers when none registered', () => { + const tool = disposables.add(createTool(createMockTextModelService(null!))); + const data = tool.getToolData(); + assert.strictEqual(data.id, RenameToolId); + assert.ok(data.modelDescription.includes('No languages currently have rename providers')); + }); + + test('lists registered language ids', () => { + const model = disposables.add(createTextModel('', 'typescript', undefined, testUri)); + const tool = disposables.add(createTool(createMockTextModelService(model))); + disposables.add(langFeatures.renameProvider.register('typescript', { + provideRenameEdits: () => ({ edits: [] }), + })); + const data = tool.getToolData(); + assert.ok(data.modelDescription.includes('typescript')); + }); + + test('reports all languages for wildcard', () => { + const tool = disposables.add(createTool(createMockTextModelService(null!))); + disposables.add(langFeatures.renameProvider.register('*', { + provideRenameEdits: () => ({ edits: [] }), + })); + const data = tool.getToolData(); + assert.ok(data.modelDescription.includes('all languages')); + }); + }); + + suite('invoke', () => { + + test('returns error when no uri or filePath provided', async () => { + const tool = disposables.add(createTool(createMockTextModelService(null!))); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', newName: 'MyNewClass', lineContent: 'MyClass' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('Provide either')); + }); + + test('returns error when no rename provider available', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + const tool = disposables.add(createTool(createMockTextModelService(model))); + // No rename provider registered + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', newName: 'MyNewClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('No rename provider')); + }); + + test('returns error when line content not found', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + disposables.add(langFeatures.renameProvider.register('typescript', { + provideRenameEdits: () => ({ edits: [] }), + })); + const tool = disposables.add(createTool(createMockTextModelService(model))); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', newName: 'MyNewClass', uri: testUri.toString(), lineContent: 'nonexistent line' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('Could not find line content')); + }); + + test('returns error when symbol not found in line', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + disposables.add(langFeatures.renameProvider.register('typescript', { + provideRenameEdits: () => ({ edits: [] }), + })); + const tool = disposables.add(createTool(createMockTextModelService(model))); + const result = await tool.invoke( + createInvocation({ symbol: 'NotHere', newName: 'Something', uri: testUri.toString(), lineContent: 'function doSomething' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('Could not find symbol')); + }); + + test('returns error when rename is rejected', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + const provider: RenameProvider = { + provideRenameEdits: (): WorkspaceEdit & Rejection => ({ + edits: [], + rejectReason: 'Cannot rename this symbol', + }), + }; + disposables.add(langFeatures.renameProvider.register('typescript', provider)); + const tool = disposables.add(createTool(createMockTextModelService(model))); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', newName: 'MyNewClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('Rename rejected')); + assert.ok(getTextContent(result).includes('Cannot rename this symbol')); + }); + + test('returns error when rename produces no edits', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + const provider: RenameProvider = { + provideRenameEdits: (): WorkspaceEdit & Rejection => ({ + edits: [], + }), + }; + disposables.add(langFeatures.renameProvider.register('typescript', provider)); + const tool = disposables.add(createTool(createMockTextModelService(model))); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', newName: 'MyNewClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('no edits')); + }); + + test('successful rename applies edits via bulk edit and reports result', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + const otherUri = URI.parse('file:///test/other.ts'); + const edits = [ + makeEdit(testUri, new Range(1, 10, 1, 17), 'MyNewClass'), + makeEdit(testUri, new Range(4, 23, 4, 30), 'MyNewClass'), + makeEdit(otherUri, new Range(5, 14, 5, 21), 'MyNewClass'), + ]; + const provider: RenameProvider = { + provideRenameEdits: (): WorkspaceEdit & Rejection => ({ edits }), + }; + disposables.add(langFeatures.renameProvider.register('typescript', provider)); + + const bulkEditService = createMockBulkEditService(); + const tool = disposables.add(createTool(createMockTextModelService(model), { bulkEditService })); + + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', newName: 'MyNewClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + const text = getTextContent(result); + assert.ok(text.includes('Renamed')); + assert.ok(text.includes('MyClass')); + assert.ok(text.includes('MyNewClass')); + assert.ok(text.includes('3 edits')); + assert.ok(text.includes('2 files')); + assert.strictEqual(bulkEditService.appliedEdits.length, 1); + assert.strictEqual(bulkEditService.appliedEdits[0].edits.length, 3); + }); + + test('successful rename with single edit reports singular message', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + const edits = [ + makeEdit(testUri, new Range(1, 10, 1, 17), 'MyNewClass'), + ]; + const provider: RenameProvider = { + provideRenameEdits: (): WorkspaceEdit & Rejection => ({ edits }), + }; + disposables.add(langFeatures.renameProvider.register('typescript', provider)); + + const tool = disposables.add(createTool(createMockTextModelService(model))); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', newName: 'MyNewClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + const text = getTextContent(result); + assert.ok(text.includes('1 edit')); + assert.ok(text.includes('1 file')); + }); + + test('resolves filePath via workspace folders', async () => { + const fileUri = URI.parse('file:///test/src/file.ts'); + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, fileUri)); + const edits = [ + makeEdit(fileUri, new Range(1, 10, 1, 17), 'MyNewClass'), + ]; + const provider: RenameProvider = { + provideRenameEdits: (): WorkspaceEdit & Rejection => ({ edits }), + }; + disposables.add(langFeatures.renameProvider.register('typescript', provider)); + + const tool = disposables.add(createTool(createMockTextModelService(model))); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', newName: 'MyNewClass', filePath: 'src/file.ts', lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + assert.ok(getTextContent(result).includes('Renamed')); + }); + + test('result includes toolResultMessage', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + const edits = [ + makeEdit(testUri, new Range(1, 10, 1, 17), 'MyNewClass'), + ]; + const provider: RenameProvider = { + provideRenameEdits: (): WorkspaceEdit & Rejection => ({ edits }), + }; + disposables.add(langFeatures.renameProvider.register('typescript', provider)); + + const tool = disposables.add(createTool(createMockTextModelService(model))); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', newName: 'MyNewClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + assert.ok(result.toolResultMessage); + const msg = result.toolResultMessage as IMarkdownString; + assert.ok(msg.value.includes('Renamed')); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/toolHelpers.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/toolHelpers.test.ts new file mode 100644 index 0000000000000..b54cf20029b97 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/tools/toolHelpers.test.ts @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { resolveToolUri, findLineNumber, findSymbolColumn, errorResult } from '../../../browser/tools/toolHelpers.js'; + +suite('Tool Helpers', () => { + + const disposables = new DisposableStore(); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createMockWorkspaceService(folderUri?: URI): IWorkspaceContextService { + const uri = folderUri ?? URI.parse('file:///workspace'); + const folder = { + uri, + toResource: (relativePath: string) => URI.joinPath(uri, relativePath), + } as unknown as IWorkspaceFolder; + return { + _serviceBrand: undefined, + getWorkspace: () => ({ folders: [folder] }), + getWorkspaceFolder: (u: URI) => { + if (u.toString().startsWith(uri.toString())) { + return folder; + } + return null; + }, + } as unknown as IWorkspaceContextService; + } + + suite('resolveToolUri', () => { + + test('resolves full URI string', () => { + const ws = createMockWorkspaceService(); + const result = resolveToolUri({ symbol: 'x', lineContent: 'x', uri: 'file:///test/file.ts' }, ws); + assert.strictEqual(result?.toString(), 'file:///test/file.ts'); + }); + + test('resolves workspace-relative filePath', () => { + const ws = createMockWorkspaceService(URI.parse('file:///project')); + const result = resolveToolUri({ symbol: 'x', lineContent: 'x', filePath: 'src/index.ts' }, ws); + assert.strictEqual(result?.toString(), 'file:///project/src/index.ts'); + }); + + test('prefers uri over filePath', () => { + const ws = createMockWorkspaceService(); + const result = resolveToolUri({ symbol: 'x', lineContent: 'x', uri: 'file:///explicit.ts', filePath: 'other.ts' }, ws); + assert.strictEqual(result?.toString(), 'file:///explicit.ts'); + }); + + test('returns undefined when neither provided', () => { + const ws = createMockWorkspaceService(); + const result = resolveToolUri({ symbol: 'x', lineContent: 'x' }, ws); + assert.strictEqual(result, undefined); + }); + }); + + suite('findLineNumber', () => { + + test('finds exact match', () => { + const model = disposables.add(createTextModel('line one\nline two\nline three')); + assert.strictEqual(findLineNumber(model, 'line two'), 2); + }); + + test('handles whitespace normalization', () => { + const model = disposables.add(createTextModel('function doSomething(x: number) {}')); + assert.strictEqual(findLineNumber(model, 'function doSomething(x: number)'), 1); + }); + + test('returns undefined when not found', () => { + const model = disposables.add(createTextModel('hello world')); + assert.strictEqual(findLineNumber(model, 'not here'), undefined); + }); + + test('handles regex special characters in content', () => { + const model = disposables.add(createTextModel('const arr = [1, 2, 3];')); + assert.strictEqual(findLineNumber(model, '[1, 2, 3]'), 1); + }); + + test('finds partial line match', () => { + const model = disposables.add(createTextModel('import { MyClass } from "./myModule";')); + assert.strictEqual(findLineNumber(model, 'MyClass'), 1); + }); + + test('trims leading and trailing whitespace from input', () => { + const model = disposables.add(createTextModel('const x = 42;')); + assert.strictEqual(findLineNumber(model, ' const x = 42; '), 1); + }); + }); + + suite('findSymbolColumn', () => { + + test('finds symbol with word boundaries', () => { + assert.strictEqual(findSymbolColumn('const myVar = 42;', 'myVar'), 7); + }); + + test('returns 1-based column', () => { + assert.strictEqual(findSymbolColumn('x = 1', 'x'), 1); + }); + + test('does not match partial words', () => { + assert.strictEqual(findSymbolColumn('const myVariable = 42;', 'myVar'), undefined); + }); + + test('returns undefined when not found', () => { + assert.strictEqual(findSymbolColumn('hello world', 'missing'), undefined); + }); + + test('handles regex special characters in symbol name', () => { + assert.strictEqual(findSymbolColumn('arr[0] = 1', 'arr'), 1); + }); + + test('finds first occurrence', () => { + assert.strictEqual(findSymbolColumn('foo + foo', 'foo'), 1); + }); + }); + + suite('errorResult', () => { + + test('creates result with text content', () => { + const result = errorResult('something went wrong'); + const textPart = result.content.find(p => p.kind === 'text'); + assert.ok(textPart); + assert.strictEqual((textPart as { kind: 'text'; value: string }).value, 'something went wrong'); + }); + + test('sets toolResultMessage', () => { + const result = errorResult('error message'); + assert.ok(result.toolResultMessage); + }); + }); +}); diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index 6956d7918b3b1..1d28ae56418f0 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -265,6 +265,15 @@ Registry.as(ConfigurationExtensions.Configuration) description: localize('extensionsInQuickAccess', "When enabled, extensions can be searched for via Quick Access and report issues from there."), default: true }, + 'extensions.allowOpenInModalEditor': { + type: 'boolean', + description: localize('extensions.allowOpenInModalEditor', "Controls whether extensions and MCP servers open in a modal editor overlay."), + default: product.quality !== 'stable', // TODO@bpasero figure out the default for stable and retire this setting + tags: ['experimental'], + experiment: { + mode: 'auto' + } + }, [VerifyExtensionSignatureConfigKey]: { type: 'boolean', description: localize('extensions.verifySignature', "When enabled, extensions are verified to be signed before getting installed."), diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts index e9b7b03798826..6735f7f09fb30 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts @@ -35,7 +35,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IHostService } from '../../../services/host/browser/host.js'; import { URI } from '../../../../base/common/uri.js'; import { IExtension, ExtensionState, IExtensionsWorkbenchService, AutoUpdateConfigurationKey, AutoCheckUpdatesConfigurationKey, HasOutdatedExtensionsContext, AutoUpdateConfigurationValue, InstallExtensionOptions, ExtensionRuntimeState, ExtensionRuntimeActionType, AutoRestartConfigurationKey, VIEWLET_ID, IExtensionsViewPaneContainer, IExtensionsNotification } from '../common/extensions.js'; -import { IEditorService, MODAL_GROUP, SIDE_GROUP } from '../../../services/editor/common/editorService.js'; +import { ACTIVE_GROUP, IEditorService, MODAL_GROUP, SIDE_GROUP } from '../../../services/editor/common/editorService.js'; import { IURLService, IURLHandler, IOpenURLOptions } from '../../../../platform/url/common/url.js'; import { ExtensionsInput, IExtensionEditorOptions } from '../common/extensionsInput.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -1577,7 +1577,8 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension if (!extension) { throw new Error(`Extension not found. ${extension}`); } - await this.editorService.openEditor(this.instantiationService.createInstance(ExtensionsInput, extension), options, options?.sideByside ? SIDE_GROUP : MODAL_GROUP); + const useModal = this.configurationService.getValue('extensions.allowOpenInModalEditor'); + await this.editorService.openEditor(this.instantiationService.createInstance(ExtensionsInput, extension), options, options?.sideByside ? SIDE_GROUP : useModal ? MODAL_GROUP : ACTIVE_GROUP); } async openSearch(searchValue: string, preserveFoucs?: boolean): Promise { diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts index 76f69758c245a..c3bb43bc90b68 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts @@ -6,10 +6,10 @@ import './inlineChatDefaultModel.js'; import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; -import { IMenuItem, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IMenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { InlineChatController } from './inlineChatController.js'; import * as InlineChatActions from './inlineChatActions.js'; -import { CTX_INLINE_CHAT_EDITING, CTX_INLINE_CHAT_V1_ENABLED, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS, MENU_INLINE_CHAT_WIDGET_STATUS } from '../common/inlineChat.js'; +import { CTX_INLINE_CHAT_EDITING, CTX_INLINE_CHAT_V1_ENABLED, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS, MENU_INLINE_CHAT_WIDGET_STATUS, ACTION_START } from '../common/inlineChat.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; @@ -23,6 +23,8 @@ import { localize } from '../../../../nls.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { InlineChatAccessibilityHelp } from './inlineChatAccessibilityHelp.js'; +import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; +import { Codicon } from '../../../../base/common/codicons.js'; registerEditorContribution(InlineChatController.ID, InlineChatController, EditorContributionInstantiation.Eager); // EAGER because of notebook dispose/create of editors @@ -84,6 +86,19 @@ const cancelActionMenuItem: IMenuItem = { MenuRegistry.appendMenuItem(MENU_INLINE_CHAT_WIDGET_STATUS, cancelActionMenuItem); +// --- InlineChatEditorAffordance menu --- + +MenuRegistry.appendMenuItem(MenuId.InlineChatEditorAffordance, { + group: '0_chat', + order: 1, + command: { + id: ACTION_START, + title: localize('editCode', "Edit Code"), + icon: Codicon.sparkle, + }, + when: EditorContextKeys.hasNonEmptySelection, +}); + // --- actions --- registerAction2(InlineChatActions.StartSessionAction); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index 5dd9eab5dfa98..f7dc57db29273 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -79,11 +79,6 @@ export class StartSessionAction extends Action2 { id: MenuId.ChatEditorInlineGutter, group: '1_chat', order: 1, - }, { - id: MenuId.InlineChatEditorAffordance, - group: '1_chat', - order: 1, - when: EditorContextKeys.hasNonEmptySelection }] }); } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts index 5d53da9301b8b..58ecc761706fc 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts @@ -20,28 +20,51 @@ import { Selection, SelectionDirection } from '../../../../editor/common/core/se import { assertType } from '../../../../base/common/types.js'; import { CursorChangeReason } from '../../../../editor/common/cursorEvents.js'; import { IInlineChatSessionService } from './inlineChatSessionService.js'; +import { CodeActionController } from '../../../../editor/contrib/codeAction/browser/codeActionController.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; + +type InlineChatAffordanceEvent = { + mode: string; + id: string; +}; + +type InlineChatAffordanceClassification = { + mode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The affordance mode: gutter or editor.' }; + id: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'UUID to correlate shown and selected events.' }; + owner: 'jrieken'; + comment: 'Tracks when the inline chat affordance is shown or selected.'; +}; export class InlineChatAffordance extends Disposable { - private _menuData = observableValue<{ rect: DOMRect; above: boolean; lineNumber: number } | undefined>(this, undefined); + readonly #editor: ICodeEditor; + readonly #inputWidget: InlineChatInputWidget; + readonly #instantiationService: IInstantiationService; + readonly #menuData = observableValue<{ rect: DOMRect; above: boolean; lineNumber: number } | undefined>(this, undefined); constructor( - private readonly _editor: ICodeEditor, - private readonly _inputWidget: InlineChatInputWidget, - @IInstantiationService private readonly _instantiationService: IInstantiationService, + editor: ICodeEditor, + inputWidget: InlineChatInputWidget, + @IInstantiationService instantiationService: IInstantiationService, @IConfigurationService configurationService: IConfigurationService, @IChatEntitlementService chatEntiteldService: IChatEntitlementService, @IInlineChatSessionService inlineChatSessionService: IInlineChatSessionService, + @ITelemetryService telemetryService: ITelemetryService, ) { super(); + this.#editor = editor; + this.#inputWidget = inputWidget; + this.#instantiationService = instantiationService; - const editorObs = observableCodeEditor(this._editor); + const editorObs = observableCodeEditor(this.#editor); const affordance = observableConfigValue<'off' | 'gutter' | 'editor'>(InlineChatConfigKeys.Affordance, 'off', configurationService); const debouncedSelection = debouncedObservable(editorObs.cursorSelection, 500); const selectionData = observableValue(this, undefined); let explicitSelection = false; + let affordanceId: string | undefined; this._store.add(runOnChange(editorObs.selections, (value, _prev, events) => { explicitSelection = events.every(e => e.reason === CursorChangeReason.Explicit); @@ -52,10 +75,16 @@ export class InlineChatAffordance extends Disposable { this._store.add(autorun(r => { const value = debouncedSelection.read(r); - if (!value || value.isEmpty() || !explicitSelection || _editor.getModel()?.getValueInRange(value).match(/^\s+$/)) { + if (!value || value.isEmpty() || !explicitSelection || this.#editor.getModel()?.getValueInRange(value).match(/^\s+$/)) { selectionData.set(undefined, undefined); + affordanceId = undefined; return; } + affordanceId = generateUuid(); + const mode = affordance.read(undefined); + if (mode === 'gutter' || mode === 'editor') { + telemetryService.publicLog2('inlineChatAffordance/shown', { mode, id: affordanceId }); + } selectionData.set(value, undefined); })); @@ -77,61 +106,79 @@ export class InlineChatAffordance extends Disposable { } })); - this._store.add(this._instantiationService.createInstance( + this._store.add(this.#instantiationService.createInstance( InlineChatGutterAffordance, editorObs, derived(r => affordance.read(r) === 'gutter' ? selectionData.read(r) : undefined), - this._menuData + this.#menuData )); - this._store.add(this._instantiationService.createInstance( + const editorAffordance = this.#instantiationService.createInstance( InlineChatEditorAffordance, - this._editor, + this.#editor, derived(r => affordance.read(r) === 'editor' ? selectionData.read(r) : undefined) - )); + ); + this._store.add(editorAffordance); + this._store.add(editorAffordance.onDidRunAction(() => { + if (affordanceId) { + telemetryService.publicLog2('inlineChatAffordance/selected', { mode: 'editor', id: affordanceId }); + } + })); + + this._store.add(autorun(r => { + const isEditor = affordance.read(r) === 'editor'; + const controller = CodeActionController.get(this.#editor); + if (controller) { + controller.onlyLightBulbWithEmptySelection = isEditor; + } + })); this._store.add(autorun(r => { - const data = this._menuData.read(r); + const data = this.#menuData.read(r); if (!data) { return; } + if (affordanceId) { + telemetryService.publicLog2('inlineChatAffordance/selected', { mode: 'gutter', id: affordanceId }); + } + // Reveal the line in case it's outside the viewport (e.g., when triggered from sticky scroll) - this._editor.revealLineInCenterIfOutsideViewport(data.lineNumber, ScrollType.Immediate); + this.#editor.revealLineInCenterIfOutsideViewport(data.lineNumber, ScrollType.Immediate); - const editorDomNode = this._editor.getDomNode()!; + const editorDomNode = this.#editor.getDomNode()!; const editorRect = editorDomNode.getBoundingClientRect(); const left = data.rect.left - editorRect.left; // Show the overlay widget - this._inputWidget.show(data.lineNumber, left, data.above); + this.#inputWidget.show(data.lineNumber, left, data.above); })); this._store.add(autorun(r => { - const pos = this._inputWidget.position.read(r); + const pos = this.#inputWidget.position.read(r); if (pos === null) { - this._menuData.set(undefined, undefined); + this.#menuData.set(undefined, undefined); } })); } async showMenuAtSelection() { - assertType(this._editor.hasModel()); + assertType(this.#editor.hasModel()); - const direction = this._editor.getSelection().getDirection(); - const position = this._editor.getPosition(); - const editorDomNode = this._editor.getDomNode(); - const scrolledPosition = this._editor.getScrolledVisiblePosition(position); + const direction = this.#editor.getSelection().getDirection(); + const position = this.#editor.getPosition(); + const editorDomNode = this.#editor.getDomNode(); + const scrolledPosition = this.#editor.getScrolledVisiblePosition(position); const editorRect = editorDomNode.getBoundingClientRect(); const x = editorRect.left + scrolledPosition.left; const y = editorRect.top + scrolledPosition.top; - this._menuData.set({ + this.#menuData.set({ rect: new DOMRect(x, y, 0, scrolledPosition.height), above: direction === SelectionDirection.RTL, lineNumber: position.lineNumber }, undefined); - await waitForState(this._inputWidget.position, pos => pos === null); + await waitForState(this.#inputWidget.position, pos => pos === null); } } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts index 568fb591e5448..0aacdb125727d 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts @@ -7,9 +7,11 @@ import './media/inlineChatEditorAffordance.css'; import { IDimension } from '../../../../base/browser/dom.js'; import * as dom from '../../../../base/browser/dom.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from '../../../../editor/browser/editorBrowser.js'; import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; import { Selection, SelectionDirection } from '../../../../editor/common/core/selection.js'; +import { computeIndentLevel } from '../../../../editor/common/model/utils.js'; import { autorun, IObservable } from '../../../../base/common/observable.js'; import { MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; @@ -26,6 +28,8 @@ import { IThemeService } from '../../../../platform/theme/common/themeService.js import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { ACTION_START } from '../common/inlineChat.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; class QuickFixActionViewItem extends MenuEntryActionViewItem { @@ -40,9 +44,30 @@ class QuickFixActionViewItem extends MenuEntryActionViewItem { @IContextKeyService contextKeyService: IContextKeyService, @IThemeService themeService: IThemeService, @IContextMenuService contextMenuService: IContextMenuService, - @IAccessibilityService accessibilityService: IAccessibilityService + @IAccessibilityService accessibilityService: IAccessibilityService, + @ICommandService commandService: ICommandService ) { - super(action, { draggable: false }, keybindingService, notificationService, contextKeyService, themeService, contextMenuService, accessibilityService); + const wrappedAction = new class extends MenuItemAction { + constructor() { + super(action.item, action.alt?.item, {}, action.hideActions, action.menuKeybinding, contextKeyService, commandService); + } + + elementGetter: () => HTMLElement | undefined = () => undefined; + + override async run(...args: unknown[]): Promise { + const controller = CodeActionController.get(_editor); + const info = controller?.lightBulbState.get(); + const element = this.elementGetter(); + if (controller && info && element) { + const { bottom, left } = element.getBoundingClientRect(); + await controller.showCodeActions(info.trigger, info.actions, { x: left, y: bottom }); + } + } + }; + + super(wrappedAction, { draggable: false }, keybindingService, notificationService, contextKeyService, themeService, contextMenuService, accessibilityService); + + wrappedAction.elementGetter = () => this.element; } override render(container: HTMLElement): void { @@ -70,7 +95,7 @@ class QuickFixActionViewItem extends MenuEntryActionViewItem { const icon = info?.icon ?? Codicon.lightBulb; const iconClasses = ThemeIcon.asClassNameArray(icon); this.label.className = ''; - this.label.classList.add('codicon', ...iconClasses); + this.label.classList.add('codicon', 'action-label', ...iconClasses); } // Update tooltip @@ -80,6 +105,35 @@ class QuickFixActionViewItem extends MenuEntryActionViewItem { } } +class InlineChatStartActionViewItem extends MenuEntryActionViewItem { + + private readonly _kbLabel: string | undefined; + + constructor( + action: MenuItemAction, + @IKeybindingService keybindingService: IKeybindingService, + @INotificationService notificationService: INotificationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IThemeService themeService: IThemeService, + @IContextMenuService contextMenuService: IContextMenuService, + @IAccessibilityService accessibilityService: IAccessibilityService + ) { + super(action, { draggable: false }, keybindingService, notificationService, contextKeyService, themeService, contextMenuService, accessibilityService); + this.options.label = true; + this.options.icon = false; + this._kbLabel = keybindingService.lookupKeybinding(action.id)?.getLabel() ?? undefined; + } + + protected override updateLabel(): void { + if (this.label) { + dom.reset(this.label, + this.action.label, + ...(this._kbLabel ? [dom.$('span.inline-chat-keybinding', undefined, this._kbLabel)] : []) + ); + } + } +} + /** * Content widget that shows a small sparkle icon at the cursor position. * When clicked, it shows the overlay widget for inline chat. @@ -93,7 +147,10 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi private _position: IContentWidgetPosition | null = null; private _isVisible = false; - readonly allowEditorOverflow = true; + private readonly _onDidRunAction = this._store.add(new Emitter()); + readonly onDidRunAction: Event = this._onDidRunAction.event; + + readonly allowEditorOverflow = false; readonly suppressMouseDown = false; constructor( @@ -107,18 +164,22 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi this._domNode = dom.$('.inline-chat-content-widget'); // Create toolbar with the inline chat start action - this._store.add(instantiationService.createInstance(MenuWorkbenchToolBar, this._domNode, MenuId.InlineChatEditorAffordance, { + const toolbar = this._store.add(instantiationService.createInstance(MenuWorkbenchToolBar, this._domNode, MenuId.InlineChatEditorAffordance, { telemetrySource: 'inlineChatEditorAffordance', hiddenItemStrategy: HiddenItemStrategy.Ignore, menuOptions: { renderShortTitle: true }, - toolbarOptions: { primaryGroup: () => true }, + toolbarOptions: { primaryGroup: () => true, useSeparatorsInPrimaryActions: true }, actionViewItemProvider: (action: IAction) => { if (action instanceof MenuItemAction && action.id === quickFixCommandId) { return instantiationService.createInstance(QuickFixActionViewItem, action, this._editor); } + if (action instanceof MenuItemAction && action.id === ACTION_START) { + return instantiationService.createInstance(InlineChatStartActionViewItem, action); + } return undefined; } })); + this._store.add(toolbar.actionRunner.onDidRun(() => this._onDidRunAction.fire())); this._store.add(autorun(r => { const sel = selection.read(r); @@ -132,11 +193,24 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi private _show(selection: Selection): void { - // Position at the cursor (active end of selection) + if (selection.isEmpty()) { + this._showAtLineStart(selection.getPosition().lineNumber); + } else { + this._showAtSelection(selection); + } + + if (this._isVisible) { + this._editor.layoutContentWidget(this); + } else { + this._editor.addContentWidget(this); + this._isVisible = true; + } + } + + private _showAtSelection(selection: Selection): void { const cursorPosition = selection.getPosition(); const direction = selection.getDirection(); - // Show above for RTL (selection going up), below for LTR (selection going down) const preference = direction === SelectionDirection.RTL ? ContentWidgetPositionPreference.ABOVE : ContentWidgetPositionPreference.BELOW; @@ -145,13 +219,42 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi position: cursorPosition, preference: [preference], }; + } - if (this._isVisible) { - this._editor.layoutContentWidget(this); - } else { - this._editor.addContentWidget(this); - this._isVisible = true; + private _showAtLineStart(lineNumber: number): void { + const model = this._editor.getModel(); + if (!model) { + return; + } + + const tabSize = model.getOptions().tabSize; + const fontInfo = this._editor.getOptions().get(EditorOption.fontInfo); + const lineContent = model.getLineContent(lineNumber); + const indent = computeIndentLevel(lineContent, tabSize); + const lineHasSpace = indent < 0 ? true : fontInfo.spaceWidth * indent > 22; + + let effectiveLineNumber = lineNumber; + + if (!lineHasSpace) { + const isLineEmptyOrIndented = (ln: number): boolean => { + const content = model.getLineContent(ln); + return /^\s*$|^\s+/.test(content); + }; + + const lineCount = model.getLineCount(); + if (lineNumber > 1 && isLineEmptyOrIndented(lineNumber - 1)) { + effectiveLineNumber = lineNumber - 1; + } else if (lineNumber < lineCount && isLineEmptyOrIndented(lineNumber + 1)) { + effectiveLineNumber = lineNumber + 1; + } } + + const effectiveColumnNumber = /^\S\s*$/.test(model.getLineContent(effectiveLineNumber)) ? 2 : 1; + + this._position = { + position: { lineNumber: effectiveLineNumber, column: effectiveColumnNumber }, + preference: [ContentWidgetPositionPreference.EXACT], + }; } private _hide(): void { diff --git a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatEditorAffordance.css b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatEditorAffordance.css index 5eaa356dcfa01..14913c7eb451a 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatEditorAffordance.css +++ b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatEditorAffordance.css @@ -16,7 +16,24 @@ line-height: var(--vscode-inline-chat-affordance-height); } -.inline-chat-content-widget .icon.codicon { +.inline-chat-content-widget .action-label.codicon.codicon-light-bulb, +.inline-chat-content-widget .action-label.codicon.codicon-lightbulb-sparkle { margin: 0; color: var(--vscode-editorLightBulb-foreground); } + +.inline-chat-content-widget .action-label.codicon.codicon-lightbulb-autofix, +.inline-chat-content-widget .action-label.codicon.codicon-lightbulb-sparkle-autofix { + margin: 0; + color: var(--vscode-editorLightBulbAutoFix-foreground, var(--vscode-editorLightBulb-foreground)); +} + +.inline-chat-content-widget .action-label.codicon.codicon-sparkle-filled { + margin: 0; + color: var(--vscode-editorLightBulbAi-foreground, var(--vscode-icon-foreground)); +} + +.inline-chat-content-widget .inline-chat-keybinding { + opacity: 0.7; + margin-left: 4px; +} diff --git a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts index 19dd14ad537c1..a87f598400655 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts @@ -30,7 +30,7 @@ import { IUserDataProfilesService } from '../../../../platform/userDataProfile/c import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { MCP_CONFIGURATION_KEY, WORKSPACE_STANDALONE_CONFIGURATIONS } from '../../../services/configuration/common/configuration.js'; -import { IEditorService, MODAL_GROUP } from '../../../services/editor/common/editorService.js'; +import { ACTIVE_GROUP, IEditorService, MODAL_GROUP } from '../../../services/editor/common/editorService.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { DidUninstallWorkbenchMcpServerEvent, IWorkbenchLocalMcpServer, IWorkbenchMcpManagementService, IWorkbenchMcpServerInstallResult, IWorkbencMcpServerInstallOptions, LocalMcpServerScope, REMOTE_USER_CONFIG_ID, USER_CONFIG_ID, WORKSPACE_CONFIG_ID, WORKSPACE_FOLDER_CONFIG_ID_PREFIX } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; @@ -721,7 +721,8 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ } async open(extension: IWorkbenchMcpServer, options?: IEditorOptions): Promise { - await this.editorService.openEditor(this.instantiationService.createInstance(McpServerEditorInput, extension), options, MODAL_GROUP); + const useModal = this.configurationService.getValue('extensions.allowOpenInModalEditor'); + await this.editorService.openEditor(this.instantiationService.createInstance(McpServerEditorInput, extension), options, useModal ? MODAL_GROUP : ACTIVE_GROUP); } private getInstallState(extension: McpWorkbenchServer): McpServerInstallState {