Path: blob/main/src/vs/workbench/contrib/mcp/browser/mcpPromptArgumentPick.ts
5310 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, observableSignalFromEvent, 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 { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';15import { ILanguageService } from '../../../../editor/common/languages/language.js';16import { getIconClasses } from '../../../../editor/common/services/getIconClasses.js';17import { IModelService } from '../../../../editor/common/services/model.js';18import { localize } from '../../../../nls.js';19import { IFileService } from '../../../../platform/files/common/files.js';20import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';21import { ILabelService } from '../../../../platform/label/common/label.js';22import { IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js';23import { ICommandDetectionCapability, TerminalCapability } from '../../../../platform/terminal/common/capabilities/capabilities.js';24import { TerminalLocation } from '../../../../platform/terminal/common/terminal.js';25import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';26import { IEditorService } from '../../../services/editor/common/editorService.js';27import { QueryBuilder } from '../../../services/search/common/queryBuilder.js';28import { ISearchService } from '../../../services/search/common/search.js';29import { ITerminalGroupService, ITerminalInstance, ITerminalService } from '../../terminal/browser/terminal.js';30import { IMcpPrompt } from '../common/mcpTypes.js';31import { MCP } from '../common/modelContextProtocol.js';3233type PickItem = IQuickPickItem & (34| { action: 'text' | 'command' | 'suggest' }35| { action: 'file'; uri: URI }36| { action: 'selectedText'; uri: URI; selectedText: string }37);3839const SHELL_INTEGRATION_TIMEOUT = 5000;40const NO_SHELL_INTEGRATION_IDLE = 1000;41const SUGGEST_DEBOUNCE = 200;4243type Action = { type: 'arg'; value: string | undefined } | { type: 'back' } | { type: 'cancel' };4445export class McpPromptArgumentPick extends Disposable {46private readonly quickPick: IQuickPick<PickItem, { useSeparators: true }>;47private _terminal?: ITerminalInstance;4849constructor(50private readonly prompt: IMcpPrompt,51@IQuickInputService private readonly _quickInputService: IQuickInputService,52@ITerminalService private readonly _terminalService: ITerminalService,53@ISearchService private readonly _searchService: ISearchService,54@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService,55@ILabelService private readonly _labelService: ILabelService,56@IFileService private readonly _fileService: IFileService,57@IModelService private readonly _modelService: IModelService,58@ILanguageService private readonly _languageService: ILanguageService,59@ITerminalGroupService private readonly _terminalGroupService: ITerminalGroupService,60@IInstantiationService private readonly _instantiationService: IInstantiationService,61@ICodeEditorService private readonly _codeEditorService: ICodeEditorService,62@IEditorService private readonly _editorService: IEditorService,63) {64super();65this.quickPick = this._register(_quickInputService.createQuickPick({ useSeparators: true }));66}6768public async createArgs(token?: CancellationToken): Promise<Record<string, string | undefined> | undefined> {69const { quickPick, prompt } = this;7071quickPick.totalSteps = prompt.arguments.length;72quickPick.step = 0;73quickPick.ignoreFocusOut = true;74quickPick.sortByLabel = false;7576const args: Record<string, string | undefined> = {};77const backSnapshots: { value: string; items: readonly (PickItem | IQuickPickSeparator)[]; activeItems: readonly PickItem[] }[] = [];78for (let i = 0; i < prompt.arguments.length; i++) {79const arg = prompt.arguments[i];80const restore = backSnapshots.at(i);81quickPick.step = i + 1;82quickPick.placeholder = arg.required ? arg.description : `${arg.description || ''} (${localize('optional', 'Optional')})`;83quickPick.title = localize('mcp.prompt.pick.title', 'Value for: {0}', arg.title || arg.name);84quickPick.value = restore?.value ?? ((args.hasOwnProperty(arg.name) && args[arg.name]) || '');85quickPick.items = restore?.items ?? [];86quickPick.activeItems = restore?.activeItems ?? [];87quickPick.buttons = i > 0 ? [this._quickInputService.backButton] : [];8889const value = await this._getArg(arg, !!restore, args, token);90if (value.type === 'back') {91i -= 2;92} else if (value.type === 'cancel') {93return undefined;94} else if (value.type === 'arg') {95backSnapshots[i] = { value: quickPick.value, items: quickPick.items.slice(), activeItems: quickPick.activeItems.slice() };96args[arg.name] = value.value;97} else {98assertNever(value);99}100}101102quickPick.value = '';103quickPick.placeholder = localize('loading', 'Loading...');104quickPick.busy = true;105106return args;107}108109private async _getArg(arg: MCP.PromptArgument, didRestoreState: boolean, argsSoFar: Record<string, string | undefined>, token?: CancellationToken): Promise<Action> {110const { quickPick } = this;111const store = new DisposableStore();112113const input$ = observableValue(this, quickPick.value);114const asyncPicks = [115{116name: localize('mcp.arg.suggestions', 'Suggestions'),117observer: this._promptCompletions(arg, input$, argsSoFar),118},119{120name: localize('mcp.arg.activeFiles', 'Active File'),121observer: this._activeFileCompletions(),122},123{124name: localize('mcp.arg.files', 'Files'),125observer: this._fileCompletions(input$),126}127];128129store.add(autorun(reader => {130if (didRestoreState) {131input$.read(reader);132return; // don't overwrite initial items until the user types133}134135let items: (PickItem | IQuickPickSeparator)[] = [];136items.push({ id: 'insert-text', label: localize('mcp.arg.asText', 'Insert as text'), iconClass: ThemeIcon.asClassName(Codicon.textSize), action: 'text', alwaysShow: true });137items.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 });138139let busy = false;140for (const pick of asyncPicks) {141const state = pick.observer.read(reader);142busy ||= state.busy;143if (state.picks) {144items.push({ label: pick.name, type: 'separator' });145items = items.concat(state.picks);146}147}148149const previouslyActive = quickPick.activeItems;150quickPick.busy = busy;151quickPick.items = items;152153const lastActive = items.find(i => previouslyActive.some(a => a.id === i.id)) as PickItem | undefined;154const serverSuggestions = asyncPicks[0].observer;155// Keep any selection state, but otherwise select the first completion item, and avoid default-selecting the top item unless there are no compltions156if (lastActive) {157quickPick.activeItems = [lastActive];158} else if (serverSuggestions.read(reader).picks?.length) {159quickPick.activeItems = [items[3] as PickItem];160} else if (busy) {161quickPick.activeItems = [];162} else {163quickPick.activeItems = [items[0] as PickItem];164}165}));166167try {168const value = await new Promise<PickItem | 'back' | undefined>(resolve => {169if (token) {170store.add(token.onCancellationRequested(() => {171resolve(undefined);172}));173}174store.add(quickPick.onDidChangeValue(value => {175quickPick.validationMessage = undefined;176input$.set(value, undefined);177}));178store.add(quickPick.onDidAccept(() => {179const item = quickPick.selectedItems[0];180if (!quickPick.value && arg.required && (!item || item.action === 'text' || item.action === 'command')) {181quickPick.validationMessage = localize('mcp.arg.required', "This argument is required");182} else if (!item) {183// For optional arguments when no item is selected, return empty text action184resolve({ id: 'insert-text', label: '', action: 'text' });185} else {186resolve(item);187}188}));189store.add(quickPick.onDidTriggerButton(() => {190resolve('back');191}));192store.add(quickPick.onDidHide(() => {193resolve(undefined);194}));195quickPick.show();196});197198if (value === 'back') {199return { type: 'back' };200}201202if (value === undefined) {203return { type: 'cancel' };204}205206store.clear();207const cts = new CancellationTokenSource();208store.add(toDisposable(() => cts.dispose(true)));209store.add(quickPick.onDidHide(() => store.dispose()));210211switch (value.action) {212case 'text':213return { type: 'arg', value: quickPick.value || undefined };214case 'command':215if (!quickPick.value) {216return { type: 'arg', value: undefined };217}218quickPick.busy = true;219return { type: 'arg', value: await this._getTerminalOutput(quickPick.value, cts.token) };220case 'suggest':221return { type: 'arg', value: value.label };222case 'file':223quickPick.busy = true;224return { type: 'arg', value: await this._fileService.readFile(value.uri).then(c => c.value.toString()) };225case 'selectedText':226return { type: 'arg', value: value.selectedText };227default:228assertNever(value);229}230} finally {231store.dispose();232}233}234235private _promptCompletions(arg: MCP.PromptArgument, input: IObservable<string>, argsSoFar: Record<string, string | undefined>) {236const alreadyResolved: Record<string, string> = {};237for (const [key, value] of Object.entries(argsSoFar)) {238if (value) {239alreadyResolved[key] = value;240}241}242243return this._asyncCompletions(input, async (i, t) => {244const items = await this.prompt.complete(arg.name, i, alreadyResolved, t);245return items.map((i): PickItem => ({ id: `suggest:${i}`, label: i, action: 'suggest' }));246});247}248249private _fileCompletions(input: IObservable<string>) {250const qb = this._instantiationService.createInstance(QueryBuilder);251return this._asyncCompletions(input, async (i, token) => {252if (!i) {253return [];254}255256const query = qb.file(this._workspaceContextService.getWorkspace().folders, {257filePattern: i,258maxResults: 10,259});260261const { results } = await this._searchService.fileSearch(query, token);262263return results.map((i): PickItem => ({264id: i.resource.toString(),265label: basename(i.resource),266description: this._labelService.getUriLabel(i.resource),267iconClasses: getIconClasses(this._modelService, this._languageService, i.resource),268uri: i.resource,269action: 'file',270}));271});272}273274private _activeFileCompletions() {275const activeEditorChange = observableSignalFromEvent(this, this._editorService.onDidActiveEditorChange);276const activeEditor = derived(reader => {277activeEditorChange.read(reader);278return this._codeEditorService.getActiveCodeEditor();279});280281const resourceObs = activeEditor282.map(e => e ? observableSignalFromEvent(this, e.onDidChangeModel).map(() => e.getModel()?.uri) : undefined)283.map((o, reader) => o?.read(reader));284const selectionObs = activeEditor285.map(e => e ? observableSignalFromEvent(this, e.onDidChangeCursorSelection).map(() => ({ range: e.getSelection(), model: e.getModel() })) : undefined)286.map((o, reader) => o?.read(reader));287288return derived(reader => {289const resource = resourceObs.read(reader);290if (!resource) {291return { busy: false, picks: [] };292}293294const items: PickItem[] = [];295296// Add active file option297items.push({298id: 'active-file',299label: localize('mcp.arg.activeFile', 'Active File'),300description: this._labelService.getUriLabel(resource),301iconClasses: getIconClasses(this._modelService, this._languageService, resource),302uri: resource,303action: 'file',304});305306const selection = selectionObs.read(reader);307// Add selected text option if there's a selection308if (selection && selection.model && selection.range && !selection.range.isEmpty()) {309const selectedText = selection.model.getValueInRange(selection.range);310const lineCount = selection.range.endLineNumber - selection.range.startLineNumber + 1;311const description = lineCount === 1312? localize('mcp.arg.selectedText.singleLine', 'line {0}', selection.range.startLineNumber)313: localize('mcp.arg.selectedText.multiLine', '{0} lines', lineCount);314315items.push({316id: 'selected-text',317label: localize('mcp.arg.selectedText', 'Selected Text'),318description,319selectedText,320iconClass: ThemeIcon.asClassName(Codicon.selection),321uri: resource,322action: 'selectedText',323});324}325326return { picks: items, busy: false };327});328}329330private _asyncCompletions(input: IObservable<string>, mapper: (input: string, token: CancellationToken) => Promise<PickItem[]>): IObservable<{ busy: boolean; picks: PickItem[] | undefined }> {331const promise = derived(reader => {332const queryValue = input.read(reader);333const cts = new CancellationTokenSource();334reader.store.add(toDisposable(() => cts.dispose(true)));335return new ObservablePromise(336timeout(SUGGEST_DEBOUNCE, cts.token)337.then(() => mapper(queryValue, cts.token))338.catch(() => [])339);340});341342return promise.map((value, reader) => {343const result = value.promiseResult.read(reader);344return { picks: result?.data || [], busy: result === undefined };345});346}347348private async _getTerminalOutput(command: string, token: CancellationToken): Promise<string | undefined> {349// The terminal outlives the specific pick argument. This is both a feature and a bug.350// Feature: we can reuse the terminal if the user puts in multiple args351// Bug workaround: if we dispose the terminal here and that results in the panel352// closing, then focus moves out of the quickpick and into the active editor pane (chat input)353// https://github.com/microsoft/vscode/blob/6a016f2507cd200b12ca6eecdab2f59da15aacb1/src/vs/workbench/browser/parts/editor/editorGroupView.ts#L1084354const terminal = (this._terminal ??= this._register(await this._terminalService.createTerminal({355config: {356name: localize('mcp.terminal.name', "MCP Terminal"),357isTransient: true,358forceShellIntegration: true,359isFeatureTerminal: true,360},361location: TerminalLocation.Panel,362})));363364this._terminalService.setActiveInstance(terminal);365this._terminalGroupService.showPanel(false);366367const shellIntegration = terminal.capabilities.get(TerminalCapability.CommandDetection);368if (shellIntegration) {369return this._getTerminalOutputInner(terminal, command, shellIntegration, token);370}371372const store = new DisposableStore();373return await new Promise<string | undefined>(resolve => {374store.add(terminal.capabilities.onDidAddCapability(e => {375if (e.id === TerminalCapability.CommandDetection) {376store.dispose();377resolve(this._getTerminalOutputInner(terminal, command, e.capability, token));378}379}));380store.add(token.onCancellationRequested(() => {381store.dispose();382resolve(undefined);383}));384store.add(disposableTimeout(() => {385store.dispose();386resolve(this._getTerminalOutputInner(terminal, command, undefined, token));387}, SHELL_INTEGRATION_TIMEOUT));388});389}390391private async _getTerminalOutputInner(terminal: ITerminalInstance, command: string, shellIntegration: ICommandDetectionCapability | undefined, token: CancellationToken) {392const store = new DisposableStore();393return new Promise<string | undefined>(resolve => {394let allData: string = '';395store.add(terminal.onLineData(d => allData += d + '\n'));396if (shellIntegration) {397store.add(shellIntegration.onCommandFinished(e => resolve(e.getOutput() || allData)));398} else {399const done = store.add(new RunOnceScheduler(() => resolve(allData), NO_SHELL_INTEGRATION_IDLE));400store.add(terminal.onData(() => done.schedule()));401}402store.add(token.onCancellationRequested(() => resolve(undefined)));403store.add(terminal.onDisposed(() => resolve(undefined)));404405terminal.runCommand(command, true);406}).finally(() => {407store.dispose();408});409}410}411412413