Path: blob/main/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts
5272 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { CancellationTokenSource } from '../../../../../base/common/cancellation.js';6import { Emitter, Event } from '../../../../../base/common/event.js';7import { Disposable, DisposableMap, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js';8import { Schemas } from '../../../../../base/common/network.js';9import { autorun } from '../../../../../base/common/observable.js';10import { basename, isEqual } from '../../../../../base/common/resources.js';11import { ThemeIcon } from '../../../../../base/common/themables.js';12import { URI } from '../../../../../base/common/uri.js';13import { getCodeEditor, ICodeEditor } from '../../../../../editor/browser/editorBrowser.js';14import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js';15import { isLocation, Location } from '../../../../../editor/common/languages.js';16import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';17import { IWorkbenchContribution } from '../../../../common/contributions.js';18import { EditorsOrder } from '../../../../common/editor.js';19import { IEditorService } from '../../../../services/editor/common/editorService.js';20import { getNotebookEditorFromEditorPane, INotebookEditor } from '../../../notebook/browser/notebookBrowser.js';21import { WebviewEditor } from '../../../webviewPanel/browser/webviewEditor.js';22import { WebviewInput } from '../../../webviewPanel/browser/webviewEditorInput.js';23import { IChatEditingService } from '../../common/editing/chatEditingService.js';24import { IChatService } from '../../common/chatService/chatService.js';25import { IChatRequestImplicitVariableEntry, IChatRequestVariableEntry, isStringImplicitContextValue, StringChatContextValue } from '../../common/attachments/chatVariableEntries.js';26import { ChatAgentLocation } from '../../common/constants.js';27import { ILanguageModelIgnoredFilesService } from '../../common/ignoredFiles.js';28import { getPromptsTypeForLanguageId } from '../../common/promptSyntax/promptTypes.js';29import { IChatWidget, IChatWidgetService } from '../chat.js';30import { IChatContextService } from '../contextContrib/chatContextService.js';31import { ITextModel } from '../../../../../editor/common/model.js';32import { IRange } from '../../../../../editor/common/core/range.js';3334export class ChatImplicitContextContribution extends Disposable implements IWorkbenchContribution {35static readonly ID = 'chat.implicitContext';3637private readonly _currentCancelTokenSource: MutableDisposable<CancellationTokenSource>;3839private _implicitContextEnablement: { [mode: string]: string };4041constructor(42@ICodeEditorService private readonly codeEditorService: ICodeEditorService,43@IEditorService private readonly editorService: IEditorService,44@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,45@IChatService private readonly chatService: IChatService,46@IChatEditingService private readonly chatEditingService: IChatEditingService,47@IConfigurationService private readonly configurationService: IConfigurationService,48@ILanguageModelIgnoredFilesService private readonly ignoredFilesService: ILanguageModelIgnoredFilesService,49@IChatContextService private readonly chatContextService: IChatContextService50) {51super();52this._currentCancelTokenSource = this._register(new MutableDisposable<CancellationTokenSource>());53this._implicitContextEnablement = this.configurationService.getValue<{ [mode: string]: string }>('chat.implicitContext.enabled');5455const activeEditorDisposables = this._register(new DisposableStore());5657this._register(Event.runAndSubscribe(58editorService.onDidActiveEditorChange,59(() => {60activeEditorDisposables.clear();61const codeEditor = this.findActiveCodeEditor();62if (codeEditor) {63activeEditorDisposables.add(Event.debounce(64Event.any(65codeEditor.onDidChangeModel,66codeEditor.onDidChangeModelLanguage,67codeEditor.onDidChangeCursorSelection,68codeEditor.onDidScrollChange),69() => undefined,70500)(() => this.updateImplicitContext()));71}7273const notebookEditor = this.findActiveNotebookEditor();74if (notebookEditor) {75const activeCellDisposables = activeEditorDisposables.add(new DisposableStore());76activeEditorDisposables.add(notebookEditor.onDidChangeActiveCell(() => {77activeCellDisposables.clear();78const codeEditor = this.codeEditorService.getActiveCodeEditor();79if (codeEditor && codeEditor.getModel()?.uri.scheme === Schemas.vscodeNotebookCell) {80activeCellDisposables.add(Event.debounce(81Event.any(82codeEditor.onDidChangeModel,83codeEditor.onDidChangeCursorSelection,84codeEditor.onDidScrollChange),85() => undefined,86500)(() => this.updateImplicitContext()));87}88}));8990activeEditorDisposables.add(Event.debounce(91Event.any(92notebookEditor.onDidChangeModel,93notebookEditor.onDidChangeActiveCell94),95() => undefined,96500)(() => this.updateImplicitContext()));97}98const webviewEditor = this.findActiveWebviewEditor();99if (webviewEditor) {100activeEditorDisposables.add(Event.debounce((webviewEditor.input as WebviewInput).webview.onMessage, () => undefined, 500)(() => {101this.updateImplicitContext();102}));103}104105this.updateImplicitContext();106})));107this._register(autorun((reader) => {108this.chatEditingService.editingSessionsObs.read(reader);109this.updateImplicitContext();110}));111this._register(this.configurationService.onDidChangeConfiguration(e => {112if (e.affectsConfiguration('chat.implicitContext.enabled')) {113this._implicitContextEnablement = this.configurationService.getValue<{ [mode: string]: string }>('chat.implicitContext.enabled');114this.updateImplicitContext();115}116}));117this._register(this.chatService.onDidSubmitRequest(({ chatSessionResource }) => {118const widget = this.chatWidgetService.getWidgetBySessionResource(chatSessionResource);119if (!widget?.input.implicitContext) {120return;121}122if (this._implicitContextEnablement[widget.location] === 'first' && widget.viewModel?.getItems().length !== 0) {123widget.input.implicitContext.setValues([]);124}125}));126this._register(this.chatWidgetService.onDidAddWidget(async (widget) => {127await this.updateImplicitContext(widget);128}));129}130131private findActiveCodeEditor(): ICodeEditor | undefined {132const codeEditor = this.codeEditorService.getActiveCodeEditor();133if (codeEditor) {134const model = codeEditor.getModel();135if (model?.uri.scheme === Schemas.vscodeNotebookCell) {136return undefined;137}138139if (model) {140return codeEditor;141}142}143for (const codeOrDiffEditor of this.editorService.getVisibleTextEditorControls(EditorsOrder.MOST_RECENTLY_ACTIVE)) {144const codeEditor = getCodeEditor(codeOrDiffEditor);145if (!codeEditor) {146continue;147}148149const model = codeEditor.getModel();150if (model) {151return codeEditor;152}153}154return undefined;155}156157private findActiveWebviewEditor(): WebviewEditor | undefined {158const activeEditorPane = this.editorService.activeEditorPane;159if (activeEditorPane?.input instanceof WebviewInput) {160return activeEditorPane as WebviewEditor;161}162return undefined;163}164165private findActiveNotebookEditor(): INotebookEditor | undefined {166return getNotebookEditorFromEditorPane(this.editorService.activeEditorPane);167}168169private async updateImplicitContext(updateWidget?: IChatWidget): Promise<void> {170const cancelTokenSource = this._currentCancelTokenSource.value = new CancellationTokenSource();171const codeEditor = this.findActiveCodeEditor();172const model = codeEditor?.getModel();173const selection = codeEditor?.getSelection();174let newValue: Location | URI | StringChatContextValue | undefined;175let isSelection = false;176177let languageId: string | undefined;178let providerContext: StringChatContextValue | undefined;179if (model) {180languageId = model.getLanguageId();181if (selection && !selection.isEmpty()) {182newValue = { uri: model.uri, range: selection } satisfies Location;183isSelection = true;184} else {185if (this.configurationService.getValue('chat.implicitContext.suggestedContext')) {186newValue = model.uri;187} else {188const visibleRanges = codeEditor?.getVisibleRanges();189if (visibleRanges && visibleRanges.length > 0) {190// Merge visible ranges. Maybe the reference value could actually be an array of Locations?191// Something like a Location with an array of Ranges?192let range = visibleRanges[0];193visibleRanges.slice(1).forEach(r => {194range = range.plusRange(r);195});196newValue = { uri: model.uri, range } satisfies Location;197} else {198newValue = model.uri;199}200}201}202// Also check if a chat context provider can provide additional context for this text editor resource203providerContext = await this.chatContextService.contextForResource(model.uri, languageId);204}205206const notebookEditor = this.findActiveNotebookEditor();207if (notebookEditor?.isReplHistory) {208// The chat APIs don't work well with Interactive Windows209newValue = undefined;210} else if (notebookEditor) {211const activeCell = notebookEditor.getActiveCell();212if (activeCell) {213const codeEditor = this.codeEditorService.getActiveCodeEditor();214const selection = codeEditor?.getSelection();215const visibleRanges = codeEditor?.getVisibleRanges() || [];216newValue = activeCell.uri;217const cellModel = codeEditor?.getModel();218if (cellModel && isEqual(cellModel.uri, activeCell.uri)) {219if (selection && !selection.isEmpty()) {220newValue = { uri: activeCell.uri, range: selection } satisfies Location;221isSelection = true;222} else if (visibleRanges.length > 0) {223// If the entire cell is visible, just use the cell URI, no need to specify range.224if (!isEntireCellVisible(cellModel, visibleRanges)) {225// Merge visible ranges. Maybe the reference value could actually be an array of Locations?226// Something like a Location with an array of Ranges?227let range = visibleRanges[0];228visibleRanges.slice(1).forEach(r => {229range = range.plusRange(r);230});231newValue = { uri: activeCell.uri, range } satisfies Location;232}233}234}235} else {236newValue = notebookEditor.textModel?.uri;237}238}239240const webviewEditor = this.findActiveWebviewEditor();241if (webviewEditor?.input?.resource) {242const webviewContext = await this.chatContextService.contextForResource(webviewEditor.input.resource);243if (webviewContext) {244newValue = webviewContext;245}246}247248const uri = newValue instanceof URI ? newValue : (isStringImplicitContextValue(newValue) ? undefined : newValue?.uri);249if (uri && (250await this.ignoredFilesService.fileIsIgnored(uri, cancelTokenSource.token) ||251uri.path.endsWith('.copilotmd'))252) {253newValue = undefined;254}255256if (cancelTokenSource.token.isCancellationRequested) {257return;258}259260const isPromptFile = languageId && getPromptsTypeForLanguageId(languageId) !== undefined;261262const widgets = updateWidget ? [updateWidget] : [...this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat), ...this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.EditorInline)];263for (const widget of widgets) {264if (!widget.input.implicitContext) {265continue;266}267const setting = this._implicitContextEnablement[widget.location];268const isFirstInteraction = widget.viewModel?.getItems().length === 0;269if ((setting === 'always' || setting === 'first' && isFirstInteraction) && !isPromptFile) { // disable implicit context for prompt files270widget.input.implicitContext.setValues([{ value: newValue, isSelection }, { value: providerContext, isSelection: false }]);271} else {272widget.input.implicitContext.setValues([]);273}274}275}276}277278function isEntireCellVisible(cellModel: ITextModel, visibleRanges: IRange[]): boolean {279if (visibleRanges.length === 1 && visibleRanges[0].startLineNumber === 1 && visibleRanges[0].startColumn === 1 && visibleRanges[0].endLineNumber === cellModel.getLineCount() && visibleRanges[0].endColumn === cellModel.getLineMaxColumn(visibleRanges[0].endLineNumber)) {280return true;281}282return false;283}284285interface ImplicitContextWithSelection {286value: Location | URI | StringChatContextValue | undefined;287isSelection: boolean;288}289290export class ChatImplicitContexts extends Disposable {291private _onDidChangeValue = this._register(new Emitter<void>());292readonly onDidChangeValue = this._onDidChangeValue.event;293294private _values: DisposableMap<ChatImplicitContext, DisposableStore> = this._register(new DisposableMap());295private readonly _valuesDisposables: DisposableStore = this._register(new DisposableStore());296297setValues(values: ImplicitContextWithSelection[]): void {298this._valuesDisposables.clear();299this._values.clearAndDisposeAll();300301if (!values || values.length === 0) {302this._onDidChangeValue.fire();303return;304}305306const definedValues = values.filter(value => value.value !== undefined);307for (const value of definedValues) {308const implicitContext = new ChatImplicitContext();309implicitContext.setValue(value.value, value.isSelection);310const disposableStore = new DisposableStore();311disposableStore.add(implicitContext.onDidChangeValue(() => {312this._onDidChangeValue.fire();313}));314disposableStore.add(implicitContext);315this._values.set(implicitContext, disposableStore);316}317this._onDidChangeValue.fire();318}319320get values(): ChatImplicitContext[] {321return Array.from(this._values.keys());322}323324get hasEnabled(): boolean {325return Array.from(this._values.keys()).some(v => v.enabled);326}327328setEnabled(enabled: boolean): void {329this.values.forEach((v) => v.enabled = enabled);330}331332get hasValue(): boolean {333return this.values.some(v => v.value !== undefined);334}335336get hasNonUri(): boolean {337return this.values.some(v => v.value !== undefined && !URI.isUri(v.value));338}339340getLocations(): Location[] {341return this.values.filter(v => isLocation(v.value)).map(v => v.value as Location);342}343344getUris(): URI[] {345return this.values.filter(v => URI.isUri(v.value)).map(v => v.value as URI);346}347348get hasNonStringContext(): boolean {349return this.values.some(v => v.value !== undefined && !isStringImplicitContextValue(v.value));350}351352enabledBaseEntries(includeAllLocations: boolean): IChatRequestVariableEntry[] {353return this.values.flatMap(v => {354if (v.enabled) {355return v.toBaseEntries();356} else if (includeAllLocations && isLocation(v.value)) {357return v.toBaseEntries();358}359return [];360});361}362}363364export class ChatImplicitContext extends Disposable implements IChatRequestImplicitVariableEntry {365get id() {366if (URI.isUri(this.value)) {367return 'vscode.implicit.file';368} else if (isStringImplicitContextValue(this.value)) {369return 'vscode.implicit.string';370} else if (this.value) {371if (this._isSelection) {372return 'vscode.implicit.selection';373} else {374return 'vscode.implicit.viewport';375}376} else {377return 'vscode.implicit';378}379}380381get name(): string {382if (URI.isUri(this.value)) {383return `file:${basename(this.value)}`;384}385if (isLocation(this.value)) {386return `file:${basename(this.value.uri)}`;387}388if (isStringImplicitContextValue(this.value)) {389if (this.value.name === undefined && this.value.resourceUri === undefined) {390throw new Error('ChatContextItem must have either a label or a resourceUri');391}392return this.value.name ?? basename(this.value.resourceUri!);393}394return 'implicit';395}396397readonly kind = 'implicit';398399get modelDescription(): string {400if (URI.isUri(this.value)) {401return `User's active file`;402} else if (isStringImplicitContextValue(this.value)) {403if (this.value.name === undefined && this.value.resourceUri === undefined) {404throw new Error('ChatContextItem must have either a label or a resourceUri');405}406const contextName = this.value.name ?? basename(this.value.resourceUri!);407return this.value.modelDescription ?? `User's active context from ${contextName}`;408} else if (this._isSelection) {409return `User's active selection`;410} else {411return `User's current visible code`;412}413}414415readonly isFile = true;416417private _isSelection = false;418public get isSelection(): boolean {419return this._isSelection;420}421422private _onDidChangeValue = this._register(new Emitter<void>());423readonly onDidChangeValue = this._onDidChangeValue.event;424425private _value: Location | URI | StringChatContextValue | undefined;426get value() {427return this._value;428}429430private _enabled = false;431get enabled() {432return this._enabled;433}434435set enabled(value: boolean) {436this._enabled = value;437this._onDidChangeValue.fire();438}439440private _uri: URI | undefined;441get uri(): URI | undefined {442if (isStringImplicitContextValue(this.value)) {443return this.value.uri;444}445return this._uri;446}447448get icon(): ThemeIcon | undefined {449if (isStringImplicitContextValue(this.value)) {450return this.value.icon;451}452return undefined;453}454455setValue(value: Location | URI | StringChatContextValue | undefined, isSelection: boolean): void {456if (isStringImplicitContextValue(value)) {457this._value = value;458} else {459this._value = value;460this._uri = URI.isUri(value) ? value : value?.uri;461}462this._isSelection = isSelection;463this._onDidChangeValue.fire();464}465466public toBaseEntries(): IChatRequestVariableEntry[] {467if (!this.value) {468return [];469}470471if (isStringImplicitContextValue(this.value)) {472return [473{474kind: 'string',475id: this.id,476name: this.name,477value: this.value.value ?? this.name,478modelDescription: this.modelDescription,479icon: this.value.icon,480uri: this.value.uri,481resourceUri: this.value.resourceUri,482handle: this.value.handle,483commandId: this.value.commandId484}485];486}487488return [{489kind: 'file',490id: this.id,491name: this.name,492value: this.value,493modelDescription: this.modelDescription,494}];495}496497}498499500