Skip to content

Commit 205bc92

Browse files
authored
add inline suggestions context provider for prompt files (#278)
* add inline suggestions context provider for prompt files * fix settings name * Copilot inline completions suggests invalid completions in prompt files * polish
1 parent 0f95b20 commit 205bc92

File tree

5 files changed

+406
-153
lines changed

5 files changed

+406
-153
lines changed

src/extension/extension/vscode-node/contributions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { CopilotDebugCommandContribution } from '../../onboardDebug/vscode-node/
3131
import { OnboardTerminalTestsContribution } from '../../onboardDebug/vscode-node/onboardTerminalTestsContribution';
3232
import { DebugCommandsContribution } from '../../prompt/vscode-node/debugCommands';
3333
import { RenameSuggestionsContrib } from '../../prompt/vscode-node/renameSuggestions';
34+
import { PromptFileContextContribution } from '../../promptFileContext/vscode-node/promptFileContextService';
3435
import { RelatedFilesProviderContribution } from '../../relatedFiles/vscode-node/relatedFiles.contribution';
3536
import { SearchPanelCommands } from '../../search/vscode-node/commands';
3637
import { SettingsSchemaFeature } from '../../settingsSchema/vscode-node/settingsSchemaFeature';
@@ -72,6 +73,7 @@ export const vscodeNodeContributions: IExtensionContributionFactory[] = [
7273
asContributionFactory(SearchPanelCommands),
7374
asContributionFactory(ChatQuotaContribution),
7475
asContributionFactory(NotebookFollowCommands),
76+
asContributionFactory(PromptFileContextContribution),
7577
workspaceIndexingContribution,
7678
];
7779

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as vscode from 'vscode';
7+
8+
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
9+
import { IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider';
10+
import { Copilot } from '../../../platform/inlineCompletions/vscode-node/api';
11+
import { ILogService } from '../../../platform/log/common/logService';
12+
import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';
13+
import { Disposable, DisposableStore, IDisposable } from '../../../util/vs/base/common/lifecycle';
14+
import { autorun, IObservable } from '../../../util/vs/base/common/observableInternal';
15+
16+
const promptFileSelector = ['prompt', 'instructions', 'chatmode'];
17+
18+
export class PromptFileContextContribution extends Disposable {
19+
20+
private readonly _enableCompletionContext: IObservable<boolean>;
21+
private registration: Promise<IDisposable> | undefined;
22+
23+
private models: string[] = ['GPT-4.1', 'GPT-4o'];
24+
25+
constructor(
26+
@IConfigurationService configurationService: IConfigurationService,
27+
@ILogService private readonly logService: ILogService,
28+
@IExperimentationService experimentationService: IExperimentationService,
29+
@IEndpointProvider private readonly endpointProvider: IEndpointProvider,
30+
) {
31+
super();
32+
this._enableCompletionContext = configurationService.getExperimentBasedConfigObservable(ConfigKey.Internal.PromptFileContext, experimentationService);
33+
this._register(autorun(reader => {
34+
if (this._enableCompletionContext.read(reader)) {
35+
this.registration = this.register();
36+
} else if (this.registration) {
37+
this.registration.then(disposable => disposable.dispose());
38+
this.registration = undefined;
39+
}
40+
}));
41+
42+
}
43+
44+
override dispose() {
45+
super.dispose();
46+
if (this.registration) {
47+
this.registration.then(disposable => disposable.dispose());
48+
this.registration = undefined;
49+
}
50+
}
51+
52+
private async register(): Promise<IDisposable> {
53+
const disposables = new DisposableStore();
54+
try {
55+
const copilotAPI = await this.getCopilotApi();
56+
if (copilotAPI === undefined) {
57+
this.logService.logger.warn('Copilot API is undefined, unable to register context provider.');
58+
return disposables;
59+
}
60+
const self = this;
61+
const resolver: Copilot.ContextResolver<Copilot.SupportedContextItem> = {
62+
async resolve(request: Copilot.ResolveRequest, token: vscode.CancellationToken): Promise<Copilot.SupportedContextItem[]> {
63+
const [document, position] = self.getDocumentAndPosition(request, token);
64+
if (document === undefined || position === undefined) {
65+
return [];
66+
}
67+
const tokenBudget = self.getTokenBudget(document);
68+
if (tokenBudget <= 0) {
69+
return [];
70+
}
71+
return self.getContext(document.languageId);
72+
}
73+
};
74+
75+
this.endpointProvider.getAllChatEndpoints().then(endpoints => {
76+
const modelNames = new Set<string>();
77+
for (const endpoint of endpoints) {
78+
if (endpoint.showInModelPicker) {
79+
modelNames.add(endpoint.name);
80+
}
81+
}
82+
this.models = [...modelNames.keys()];
83+
});
84+
85+
disposables.add(copilotAPI.registerContextProvider({
86+
id: 'promptfile-ai-context-provider',
87+
selector: promptFileSelector,
88+
resolver: resolver
89+
}));
90+
} catch (error) {
91+
this.logService.logger.error('Error regsistering prompt file context provider:', error);
92+
}
93+
return disposables;
94+
}
95+
96+
private getContext(languageId: string): Copilot.SupportedContextItem[] {
97+
98+
switch (languageId) {
99+
case 'prompt':
100+
return [
101+
{
102+
name: 'This is a prompt file that uses a frontmatter header with the following fields',
103+
value: `mode, description, model, tools`,
104+
},
105+
{
106+
name: '`mode` is optional and must be one of the following values',
107+
value: `ask, edit or agent`,
108+
},
109+
{
110+
name: '`model` is optional and must be one of the following values',
111+
value: this.models.join(', '),
112+
},
113+
{
114+
name: '`tools` is optional and is an array that can consist of any number of the following values',
115+
value: `'changes', 'codebase', 'editFiles', 'extensions', 'fetch', 'findTestFiles', 'githubRepo', 'new', 'openSimpleBrowser', 'problems', 'runCommands', 'runNotebooks', 'runTasks', 'runTests', 'search', 'searchResults', 'terminalLastCommand', 'terminalSelection', 'testFailure', 'usages', 'vscodeAPI'`
116+
},
117+
{
118+
name: 'Here is an example of a prompt file:',
119+
value: [
120+
`---`,
121+
`mode: 'agent'`,
122+
`description: This prompt is used to generate a new issue template for GitHub repositories.`,
123+
`model: ${this.models[0] || 'GPT-4.1'}`,
124+
`tools: ['changes', 'codebase', 'editFiles', 'extensions', 'fetch', 'findTestFiles', 'githubRepo', 'new', 'openSimpleBrowser', 'problems', 'runCommands', 'runNotebooks', 'runTasks', 'runTests', 'search', 'searchResults', 'terminalLastCommand', 'terminalSelection', 'testFailure', 'usages', 'vscodeAPI']`,
125+
`---`,
126+
`Generate a new issue template for a GitHub repository.`,
127+
].join('\n'),
128+
},
129+
];
130+
case 'instructions':
131+
return [
132+
{
133+
name: 'This is an instructions file that uses a frontmatter header with the following fields',
134+
value: `description, applyTo`,
135+
},
136+
{
137+
name: '`applyTo` is one or more glob patterns that specify which files the instructions apply to',
138+
value: `**`,
139+
},
140+
{
141+
name: 'Here is an example of a instruction file:',
142+
value: [
143+
`---`,
144+
`description: This file describes the TypeScript code style for the project.`,
145+
`applyTo: **/*.ts, **/*.js`,
146+
`---`,
147+
`For private fields, start the field name with an underscore (_).`,
148+
].join('\n'),
149+
},
150+
];
151+
case 'chatmode':
152+
return [
153+
{
154+
name: 'This is an custom mode file that uses a frontmatter header with the following fields',
155+
value: `description, model, tools`,
156+
},
157+
{
158+
name: '`model` is optional and must be one of the following values',
159+
value: this.models.join(', '),
160+
},
161+
{
162+
name: '`tools` is optional and is an array that can consist of any number of the following values',
163+
value: `'changes', 'codebase', 'editFiles', 'extensions', 'fetch', 'findTestFiles', 'githubRepo', 'new', 'openSimpleBrowser', 'problems', 'runCommands', 'runNotebooks', 'runTasks', 'runTests', 'search', 'searchResults', 'terminalLastCommand', 'terminalSelection', 'testFailure', 'usages', 'vscodeAPI'`
164+
},
165+
{
166+
name: 'Here is an example of a mode file:',
167+
value: [
168+
`---`,
169+
`description: This mode is used to plan a new feature.`,
170+
`model: GPT-4.1`,
171+
`tools: ['changes', 'codebase','extensions', 'fetch', 'findTestFiles', 'githubRepo', 'openSimpleBrowser', 'problems', 'search', 'searchResults', 'terminalLastCommand', 'terminalSelection', 'testFailure', 'usages', 'vscodeAPI']`,
172+
`---`,
173+
`First come up with a plan for the new feature. Write a todo list of tasks to complete the feature.`,
174+
].join('\n'),
175+
},
176+
];
177+
default:
178+
return [];
179+
}
180+
}
181+
182+
183+
private async getCopilotApi(): Promise<Copilot.ContextProviderApiV1 | undefined> {
184+
const copilotExtension = vscode.extensions.getExtension('GitHub.copilot');
185+
if (copilotExtension === undefined) {
186+
this.logService.logger.error('Copilot extension not found');
187+
return undefined;
188+
}
189+
try {
190+
const api = await copilotExtension.activate();
191+
return api.getContextProviderAPI('v1');
192+
} catch (error) {
193+
if (error instanceof Error) {
194+
this.logService.logger.error('Error activating Copilot extension:', error.message);
195+
} else {
196+
this.logService.logger.error('Error activating Copilot extension: Unknown error.');
197+
}
198+
return undefined;
199+
}
200+
}
201+
202+
public getTokenBudget(document: vscode.TextDocument): number {
203+
return Math.trunc((8 * 1024) - (document.getText().length / 4) - 256);
204+
}
205+
206+
private getDocumentAndPosition(request: Copilot.ResolveRequest, token?: vscode.CancellationToken): [vscode.TextDocument | undefined, vscode.Position | undefined] {
207+
let document: vscode.TextDocument | undefined;
208+
if (vscode.window.activeTextEditor?.document.uri.toString() === request.documentContext.uri) {
209+
document = vscode.window.activeTextEditor.document;
210+
} else {
211+
document = vscode.workspace.textDocuments.find((doc) => doc.uri.toString() === request.documentContext.uri);
212+
}
213+
if (document === undefined) {
214+
return [undefined, undefined];
215+
}
216+
const requestPos = request.documentContext.position;
217+
const position = requestPos !== undefined ? new vscode.Position(requestPos.line, requestPos.character) : document.positionAt(request.documentContext.offset);
218+
if (document.version > request.documentContext.version) {
219+
if (!token?.isCancellationRequested) {
220+
}
221+
return [undefined, undefined];
222+
}
223+
if (document.version < request.documentContext.version) {
224+
return [undefined, undefined];
225+
}
226+
return [document, position];
227+
}
228+
229+
230+
231+
}

0 commit comments

Comments
 (0)