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