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