Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/mcp/browser/mcpPromptArgumentPick.ts
5310 views
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 { assertNever } from '../../../../base/common/assert.js';
7
import { disposableTimeout, RunOnceScheduler, timeout } from '../../../../base/common/async.js';
8
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
9
import { Codicon } from '../../../../base/common/codicons.js';
10
import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
11
import { autorun, derived, IObservable, ObservablePromise, observableSignalFromEvent, observableValue } from '../../../../base/common/observable.js';
12
import { basename } from '../../../../base/common/resources.js';
13
import { ThemeIcon } from '../../../../base/common/themables.js';
14
import { URI } from '../../../../base/common/uri.js';
15
import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';
16
import { ILanguageService } from '../../../../editor/common/languages/language.js';
17
import { getIconClasses } from '../../../../editor/common/services/getIconClasses.js';
18
import { IModelService } from '../../../../editor/common/services/model.js';
19
import { localize } from '../../../../nls.js';
20
import { IFileService } from '../../../../platform/files/common/files.js';
21
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
22
import { ILabelService } from '../../../../platform/label/common/label.js';
23
import { IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js';
24
import { ICommandDetectionCapability, TerminalCapability } from '../../../../platform/terminal/common/capabilities/capabilities.js';
25
import { TerminalLocation } from '../../../../platform/terminal/common/terminal.js';
26
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
27
import { IEditorService } from '../../../services/editor/common/editorService.js';
28
import { QueryBuilder } from '../../../services/search/common/queryBuilder.js';
29
import { ISearchService } from '../../../services/search/common/search.js';
30
import { ITerminalGroupService, ITerminalInstance, ITerminalService } from '../../terminal/browser/terminal.js';
31
import { IMcpPrompt } from '../common/mcpTypes.js';
32
import { MCP } from '../common/modelContextProtocol.js';
33
34
type PickItem = IQuickPickItem & (
35
| { action: 'text' | 'command' | 'suggest' }
36
| { action: 'file'; uri: URI }
37
| { action: 'selectedText'; uri: URI; selectedText: string }
38
);
39
40
const SHELL_INTEGRATION_TIMEOUT = 5000;
41
const NO_SHELL_INTEGRATION_IDLE = 1000;
42
const SUGGEST_DEBOUNCE = 200;
43
44
type Action = { type: 'arg'; value: string | undefined } | { type: 'back' } | { type: 'cancel' };
45
46
export class McpPromptArgumentPick extends Disposable {
47
private readonly quickPick: IQuickPick<PickItem, { useSeparators: true }>;
48
private _terminal?: ITerminalInstance;
49
50
constructor(
51
private readonly prompt: IMcpPrompt,
52
@IQuickInputService private readonly _quickInputService: IQuickInputService,
53
@ITerminalService private readonly _terminalService: ITerminalService,
54
@ISearchService private readonly _searchService: ISearchService,
55
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService,
56
@ILabelService private readonly _labelService: ILabelService,
57
@IFileService private readonly _fileService: IFileService,
58
@IModelService private readonly _modelService: IModelService,
59
@ILanguageService private readonly _languageService: ILanguageService,
60
@ITerminalGroupService private readonly _terminalGroupService: ITerminalGroupService,
61
@IInstantiationService private readonly _instantiationService: IInstantiationService,
62
@ICodeEditorService private readonly _codeEditorService: ICodeEditorService,
63
@IEditorService private readonly _editorService: IEditorService,
64
) {
65
super();
66
this.quickPick = this._register(_quickInputService.createQuickPick({ useSeparators: true }));
67
}
68
69
public async createArgs(token?: CancellationToken): Promise<Record<string, string | undefined> | undefined> {
70
const { quickPick, prompt } = this;
71
72
quickPick.totalSteps = prompt.arguments.length;
73
quickPick.step = 0;
74
quickPick.ignoreFocusOut = true;
75
quickPick.sortByLabel = false;
76
77
const args: Record<string, string | undefined> = {};
78
const backSnapshots: { value: string; items: readonly (PickItem | IQuickPickSeparator)[]; activeItems: readonly PickItem[] }[] = [];
79
for (let i = 0; i < prompt.arguments.length; i++) {
80
const arg = prompt.arguments[i];
81
const restore = backSnapshots.at(i);
82
quickPick.step = i + 1;
83
quickPick.placeholder = arg.required ? arg.description : `${arg.description || ''} (${localize('optional', 'Optional')})`;
84
quickPick.title = localize('mcp.prompt.pick.title', 'Value for: {0}', arg.title || arg.name);
85
quickPick.value = restore?.value ?? ((args.hasOwnProperty(arg.name) && args[arg.name]) || '');
86
quickPick.items = restore?.items ?? [];
87
quickPick.activeItems = restore?.activeItems ?? [];
88
quickPick.buttons = i > 0 ? [this._quickInputService.backButton] : [];
89
90
const value = await this._getArg(arg, !!restore, args, token);
91
if (value.type === 'back') {
92
i -= 2;
93
} else if (value.type === 'cancel') {
94
return undefined;
95
} else if (value.type === 'arg') {
96
backSnapshots[i] = { value: quickPick.value, items: quickPick.items.slice(), activeItems: quickPick.activeItems.slice() };
97
args[arg.name] = value.value;
98
} else {
99
assertNever(value);
100
}
101
}
102
103
quickPick.value = '';
104
quickPick.placeholder = localize('loading', 'Loading...');
105
quickPick.busy = true;
106
107
return args;
108
}
109
110
private async _getArg(arg: MCP.PromptArgument, didRestoreState: boolean, argsSoFar: Record<string, string | undefined>, token?: CancellationToken): Promise<Action> {
111
const { quickPick } = this;
112
const store = new DisposableStore();
113
114
const input$ = observableValue(this, quickPick.value);
115
const asyncPicks = [
116
{
117
name: localize('mcp.arg.suggestions', 'Suggestions'),
118
observer: this._promptCompletions(arg, input$, argsSoFar),
119
},
120
{
121
name: localize('mcp.arg.activeFiles', 'Active File'),
122
observer: this._activeFileCompletions(),
123
},
124
{
125
name: localize('mcp.arg.files', 'Files'),
126
observer: this._fileCompletions(input$),
127
}
128
];
129
130
store.add(autorun(reader => {
131
if (didRestoreState) {
132
input$.read(reader);
133
return; // don't overwrite initial items until the user types
134
}
135
136
let items: (PickItem | IQuickPickSeparator)[] = [];
137
items.push({ id: 'insert-text', label: localize('mcp.arg.asText', 'Insert as text'), iconClass: ThemeIcon.asClassName(Codicon.textSize), action: 'text', alwaysShow: true });
138
items.push({ id: 'run-command', label: localize('mcp.arg.asCommand', 'Run as Command'), description: localize('mcp.arg.asCommand.description', 'Inserts the command output as the prompt argument'), iconClass: ThemeIcon.asClassName(Codicon.terminal), action: 'command', alwaysShow: true });
139
140
let busy = false;
141
for (const pick of asyncPicks) {
142
const state = pick.observer.read(reader);
143
busy ||= state.busy;
144
if (state.picks) {
145
items.push({ label: pick.name, type: 'separator' });
146
items = items.concat(state.picks);
147
}
148
}
149
150
const previouslyActive = quickPick.activeItems;
151
quickPick.busy = busy;
152
quickPick.items = items;
153
154
const lastActive = items.find(i => previouslyActive.some(a => a.id === i.id)) as PickItem | undefined;
155
const serverSuggestions = asyncPicks[0].observer;
156
// Keep any selection state, but otherwise select the first completion item, and avoid default-selecting the top item unless there are no compltions
157
if (lastActive) {
158
quickPick.activeItems = [lastActive];
159
} else if (serverSuggestions.read(reader).picks?.length) {
160
quickPick.activeItems = [items[3] as PickItem];
161
} else if (busy) {
162
quickPick.activeItems = [];
163
} else {
164
quickPick.activeItems = [items[0] as PickItem];
165
}
166
}));
167
168
try {
169
const value = await new Promise<PickItem | 'back' | undefined>(resolve => {
170
if (token) {
171
store.add(token.onCancellationRequested(() => {
172
resolve(undefined);
173
}));
174
}
175
store.add(quickPick.onDidChangeValue(value => {
176
quickPick.validationMessage = undefined;
177
input$.set(value, undefined);
178
}));
179
store.add(quickPick.onDidAccept(() => {
180
const item = quickPick.selectedItems[0];
181
if (!quickPick.value && arg.required && (!item || item.action === 'text' || item.action === 'command')) {
182
quickPick.validationMessage = localize('mcp.arg.required', "This argument is required");
183
} else if (!item) {
184
// For optional arguments when no item is selected, return empty text action
185
resolve({ id: 'insert-text', label: '', action: 'text' });
186
} else {
187
resolve(item);
188
}
189
}));
190
store.add(quickPick.onDidTriggerButton(() => {
191
resolve('back');
192
}));
193
store.add(quickPick.onDidHide(() => {
194
resolve(undefined);
195
}));
196
quickPick.show();
197
});
198
199
if (value === 'back') {
200
return { type: 'back' };
201
}
202
203
if (value === undefined) {
204
return { type: 'cancel' };
205
}
206
207
store.clear();
208
const cts = new CancellationTokenSource();
209
store.add(toDisposable(() => cts.dispose(true)));
210
store.add(quickPick.onDidHide(() => store.dispose()));
211
212
switch (value.action) {
213
case 'text':
214
return { type: 'arg', value: quickPick.value || undefined };
215
case 'command':
216
if (!quickPick.value) {
217
return { type: 'arg', value: undefined };
218
}
219
quickPick.busy = true;
220
return { type: 'arg', value: await this._getTerminalOutput(quickPick.value, cts.token) };
221
case 'suggest':
222
return { type: 'arg', value: value.label };
223
case 'file':
224
quickPick.busy = true;
225
return { type: 'arg', value: await this._fileService.readFile(value.uri).then(c => c.value.toString()) };
226
case 'selectedText':
227
return { type: 'arg', value: value.selectedText };
228
default:
229
assertNever(value);
230
}
231
} finally {
232
store.dispose();
233
}
234
}
235
236
private _promptCompletions(arg: MCP.PromptArgument, input: IObservable<string>, argsSoFar: Record<string, string | undefined>) {
237
const alreadyResolved: Record<string, string> = {};
238
for (const [key, value] of Object.entries(argsSoFar)) {
239
if (value) {
240
alreadyResolved[key] = value;
241
}
242
}
243
244
return this._asyncCompletions(input, async (i, t) => {
245
const items = await this.prompt.complete(arg.name, i, alreadyResolved, t);
246
return items.map((i): PickItem => ({ id: `suggest:${i}`, label: i, action: 'suggest' }));
247
});
248
}
249
250
private _fileCompletions(input: IObservable<string>) {
251
const qb = this._instantiationService.createInstance(QueryBuilder);
252
return this._asyncCompletions(input, async (i, token) => {
253
if (!i) {
254
return [];
255
}
256
257
const query = qb.file(this._workspaceContextService.getWorkspace().folders, {
258
filePattern: i,
259
maxResults: 10,
260
});
261
262
const { results } = await this._searchService.fileSearch(query, token);
263
264
return results.map((i): PickItem => ({
265
id: i.resource.toString(),
266
label: basename(i.resource),
267
description: this._labelService.getUriLabel(i.resource),
268
iconClasses: getIconClasses(this._modelService, this._languageService, i.resource),
269
uri: i.resource,
270
action: 'file',
271
}));
272
});
273
}
274
275
private _activeFileCompletions() {
276
const activeEditorChange = observableSignalFromEvent(this, this._editorService.onDidActiveEditorChange);
277
const activeEditor = derived(reader => {
278
activeEditorChange.read(reader);
279
return this._codeEditorService.getActiveCodeEditor();
280
});
281
282
const resourceObs = activeEditor
283
.map(e => e ? observableSignalFromEvent(this, e.onDidChangeModel).map(() => e.getModel()?.uri) : undefined)
284
.map((o, reader) => o?.read(reader));
285
const selectionObs = activeEditor
286
.map(e => e ? observableSignalFromEvent(this, e.onDidChangeCursorSelection).map(() => ({ range: e.getSelection(), model: e.getModel() })) : undefined)
287
.map((o, reader) => o?.read(reader));
288
289
return derived(reader => {
290
const resource = resourceObs.read(reader);
291
if (!resource) {
292
return { busy: false, picks: [] };
293
}
294
295
const items: PickItem[] = [];
296
297
// Add active file option
298
items.push({
299
id: 'active-file',
300
label: localize('mcp.arg.activeFile', 'Active File'),
301
description: this._labelService.getUriLabel(resource),
302
iconClasses: getIconClasses(this._modelService, this._languageService, resource),
303
uri: resource,
304
action: 'file',
305
});
306
307
const selection = selectionObs.read(reader);
308
// Add selected text option if there's a selection
309
if (selection && selection.model && selection.range && !selection.range.isEmpty()) {
310
const selectedText = selection.model.getValueInRange(selection.range);
311
const lineCount = selection.range.endLineNumber - selection.range.startLineNumber + 1;
312
const description = lineCount === 1
313
? localize('mcp.arg.selectedText.singleLine', 'line {0}', selection.range.startLineNumber)
314
: localize('mcp.arg.selectedText.multiLine', '{0} lines', lineCount);
315
316
items.push({
317
id: 'selected-text',
318
label: localize('mcp.arg.selectedText', 'Selected Text'),
319
description,
320
selectedText,
321
iconClass: ThemeIcon.asClassName(Codicon.selection),
322
uri: resource,
323
action: 'selectedText',
324
});
325
}
326
327
return { picks: items, busy: false };
328
});
329
}
330
331
private _asyncCompletions(input: IObservable<string>, mapper: (input: string, token: CancellationToken) => Promise<PickItem[]>): IObservable<{ busy: boolean; picks: PickItem[] | undefined }> {
332
const promise = derived(reader => {
333
const queryValue = input.read(reader);
334
const cts = new CancellationTokenSource();
335
reader.store.add(toDisposable(() => cts.dispose(true)));
336
return new ObservablePromise(
337
timeout(SUGGEST_DEBOUNCE, cts.token)
338
.then(() => mapper(queryValue, cts.token))
339
.catch(() => [])
340
);
341
});
342
343
return promise.map((value, reader) => {
344
const result = value.promiseResult.read(reader);
345
return { picks: result?.data || [], busy: result === undefined };
346
});
347
}
348
349
private async _getTerminalOutput(command: string, token: CancellationToken): Promise<string | undefined> {
350
// The terminal outlives the specific pick argument. This is both a feature and a bug.
351
// Feature: we can reuse the terminal if the user puts in multiple args
352
// Bug workaround: if we dispose the terminal here and that results in the panel
353
// closing, then focus moves out of the quickpick and into the active editor pane (chat input)
354
// https://github.com/microsoft/vscode/blob/6a016f2507cd200b12ca6eecdab2f59da15aacb1/src/vs/workbench/browser/parts/editor/editorGroupView.ts#L1084
355
const terminal = (this._terminal ??= this._register(await this._terminalService.createTerminal({
356
config: {
357
name: localize('mcp.terminal.name', "MCP Terminal"),
358
isTransient: true,
359
forceShellIntegration: true,
360
isFeatureTerminal: true,
361
},
362
location: TerminalLocation.Panel,
363
})));
364
365
this._terminalService.setActiveInstance(terminal);
366
this._terminalGroupService.showPanel(false);
367
368
const shellIntegration = terminal.capabilities.get(TerminalCapability.CommandDetection);
369
if (shellIntegration) {
370
return this._getTerminalOutputInner(terminal, command, shellIntegration, token);
371
}
372
373
const store = new DisposableStore();
374
return await new Promise<string | undefined>(resolve => {
375
store.add(terminal.capabilities.onDidAddCapability(e => {
376
if (e.id === TerminalCapability.CommandDetection) {
377
store.dispose();
378
resolve(this._getTerminalOutputInner(terminal, command, e.capability, token));
379
}
380
}));
381
store.add(token.onCancellationRequested(() => {
382
store.dispose();
383
resolve(undefined);
384
}));
385
store.add(disposableTimeout(() => {
386
store.dispose();
387
resolve(this._getTerminalOutputInner(terminal, command, undefined, token));
388
}, SHELL_INTEGRATION_TIMEOUT));
389
});
390
}
391
392
private async _getTerminalOutputInner(terminal: ITerminalInstance, command: string, shellIntegration: ICommandDetectionCapability | undefined, token: CancellationToken) {
393
const store = new DisposableStore();
394
return new Promise<string | undefined>(resolve => {
395
let allData: string = '';
396
store.add(terminal.onLineData(d => allData += d + '\n'));
397
if (shellIntegration) {
398
store.add(shellIntegration.onCommandFinished(e => resolve(e.getOutput() || allData)));
399
} else {
400
const done = store.add(new RunOnceScheduler(() => resolve(allData), NO_SHELL_INTEGRATION_IDLE));
401
store.add(terminal.onData(() => done.schedule()));
402
}
403
store.add(token.onCancellationRequested(() => resolve(undefined)));
404
store.add(terminal.onDisposed(() => resolve(undefined)));
405
406
terminal.runCommand(command, true);
407
}).finally(() => {
408
store.dispose();
409
});
410
}
411
}
412
413