diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index a0d367ea964d6..9928b50f61611 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -309,10 +309,16 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._newSession.value = session; // Wire pickers to the new session - this._folderPicker.setNewSession(session); - this._repoPicker.setNewSession(session); - this._isolationModePicker.setNewSession(session); - this._branchPicker.setNewSession(session); + const target = this._targetPicker.selectedTarget; + if (target === AgentSessionProviders.Background) { + this._folderPicker.setNewSession(session); + this._isolationModePicker.setNewSession(session); + this._branchPicker.setNewSession(session); + } + + if (target === AgentSessionProviders.Cloud) { + this._repoPicker.setNewSession(session); + } // Set the current model on the session (for local sessions) const currentModel = this._currentLanguageModel.get(); diff --git a/src/vs/sessions/contrib/chat/browser/newSession.ts b/src/vs/sessions/contrib/chat/browser/newSession.ts index c0879b5875d7a..f8211f04300cf 100644 --- a/src/vs/sessions/contrib/chat/browser/newSession.ts +++ b/src/vs/sessions/contrib/chat/browser/newSession.ts @@ -214,7 +214,8 @@ export class RemoteNewSession extends Disposable implements INewSession { this._repoUri = uri; this._onDidChange.fire('repoUri'); this._onDidChange.fire('disabled'); - this.setOption('repository', uri.fsPath); + const id = uri.path.substring(1); + this.setOption('repositories', { id, name: id }); } setIsolationMode(_mode: IsolationMode): void { diff --git a/src/vs/sessions/contrib/chat/browser/repoPicker.ts b/src/vs/sessions/contrib/chat/browser/repoPicker.ts index ca45f083cd2e8..bc263c8234a1d 100644 --- a/src/vs/sessions/contrib/chat/browser/repoPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/repoPicker.ts @@ -14,6 +14,8 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { INewSession } from './newSession.js'; +import { URI } from '../../../../base/common/uri.js'; +import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; const OPEN_REPO_COMMAND = 'github.copilot.chat.cloudSessions.openRepository'; const STORAGE_KEY_LAST_REPO = 'agentSessions.lastPickedRepo'; @@ -82,7 +84,7 @@ export class RepoPicker extends Disposable { this._newSession = session; this._browseGeneration++; if (session && this._selectedRepo) { - session.setOption('repositories', this._selectedRepo); + this._setRepo(this._selectedRepo); } } @@ -178,7 +180,7 @@ export class RepoPicker extends Disposable { this._addToRecentlyPicked(item); this.storageService.store(STORAGE_KEY_LAST_REPO, JSON.stringify(item), StorageScope.PROFILE, StorageTarget.MACHINE); this._updateTriggerLabel(); - this._newSession?.setOption('repositories', item); + this._setRepo(item); this._onDidSelectRepo.fire(item.id); } @@ -267,4 +269,9 @@ export class RepoPicker extends Disposable { labelSpan.textContent = label; dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); } + + private _setRepo(repo: IRepoItem): void { + this._newSession?.setRepoUri(URI.parse(`${GITHUB_REMOTE_FILE_SCHEME}://github/${repo.id}`)); + } + } diff --git a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts index 09b03f5a160f9..4e310aecaf869 100644 --- a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts +++ b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts @@ -58,7 +58,7 @@ export class WorkspaceFolderManagementContribution extends Disposable implements } private getActiveSessionFolderData(session: IActiveSessionItem | undefined): IWorkspaceFolderCreationData | undefined { - if (session?.providerType !== AgentSessionProviders.Background) { + if (!session) { return undefined; } @@ -70,7 +70,14 @@ export class WorkspaceFolderManagementContribution extends Disposable implements } if (session.repository) { - return { uri: session.repository }; + if (session.providerType === AgentSessionProviders.Background) { + return { uri: session.repository }; + } + // if (session.providerType === AgentSessionProviders.Cloud) { + // return { + // uri: session.repository + // }; + // } } return undefined; diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index a8089d43ed381..4fdf590d55372 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -207,6 +207,22 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA if (newItem) { chatSessionResource = newItem.resource; isUntitled = false; + + // Update the model's contributed session with the resolved resource + // so subsequent requests don't re-invoke newChatSessionItemHandler + // and getChatSessionFromInternalUri returns the real resource. + chatSession?.setContributedChatSession({ + chatSessionType: contributedSession.chatSessionType, + chatSessionResource, + isUntitled: false, + initialSessionOptions: contributedSession.initialSessionOptions, + }); + + // Register alias so session-option lookups work with the new resource + this._chatSessionService.registerSessionResourceAlias( + contributedSession.chatSessionResource, + chatSessionResource + ); } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 292cf32d80d7a..b2995f02d6b68 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -477,9 +477,10 @@ export class OpenModePickerAction extends Action2 { ContextKeyExpr.or( ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeys.chatSessionHasCustomAgentTarget), - // Hide in welcome view when session type is not local + // Show in welcome view for local sessions or sessions with custom agent target ContextKeyExpr.or( ChatContextKeys.inAgentSessionsWelcome.negate(), + ChatContextKeys.chatSessionHasCustomAgentTarget, ChatContextKeys.agentSessionType.isEqualTo(AgentSessionProviders.Local))), group: 'navigation', }, diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts index 9d2bc74d70922..3ca175477f688 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts @@ -157,13 +157,12 @@ class UninstallPluginAction extends Action { constructor( private readonly plugin: IAgentPlugin, - @IPluginInstallService private readonly pluginInstallService: IPluginInstallService, ) { super(UninstallPluginAction.ID, localize('uninstall', "Uninstall")); } override async run(): Promise { - this.pluginInstallService.uninstallPlugin(this.plugin.uri); + this.plugin.remove(); } } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index aad03e3583ae4..355094d8011ec 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -143,7 +143,7 @@ import './widget/input/editor/chatInputEditorContrib.js'; import './widget/input/editor/chatInputEditorHover.js'; import { LanguageModelToolsConfirmationService } from './tools/languageModelToolsConfirmationService.js'; import { LanguageModelToolsService, globalAutoApproveDescription } from './tools/languageModelToolsService.js'; -import { AgentPluginService, ConfiguredAgentPluginDiscovery } from '../common/plugins/agentPluginServiceImpl.js'; +import { AgentPluginService, ConfiguredAgentPluginDiscovery, MarketplaceAgentPluginDiscovery } from '../common/plugins/agentPluginServiceImpl.js'; import { IAgentPluginRepositoryService } from '../common/plugins/agentPluginRepositoryService.js'; import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; import { IPluginMarketplaceService, PluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; @@ -1701,6 +1701,7 @@ registerAction2(ConfigureToolSets); registerEditorFeature(ChatPasteProvidersFeature); agentPluginDiscoveryRegistry.register(new SyncDescriptor(ConfiguredAgentPluginDiscovery)); +agentPluginDiscoveryRegistry.register(new SyncDescriptor(MarketplaceAgentPluginDiscovery)); registerSingleton(IChatTransferService, ChatTransferService, InstantiationType.Delayed); registerSingleton(IChatService, ChatService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index f0161aec65010..4d426fcaa17be 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -300,6 +300,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ private readonly _sessionTypeInputPlaceholders: Map = new Map(); private readonly _sessions = new ResourceMap(); + private readonly _resourceAliases = new ResourceMap(); // real resource -> untitled resource private readonly _hasCanDelegateProvidersKey: IContextKey; @@ -1078,20 +1079,33 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } public hasAnySessionOptions(sessionResource: URI): boolean { - const session = this._sessions.get(sessionResource); + const session = this._sessions.get(this._resolveResource(sessionResource)); return !!session && !!session.options && Object.keys(session.options).length > 0; } public getSessionOption(sessionResource: URI, optionId: string): string | IChatSessionProviderOptionItem | undefined { - const session = this._sessions.get(sessionResource); + const session = this._sessions.get(this._resolveResource(sessionResource)); return session?.getOption(optionId); } public setSessionOption(sessionResource: URI, optionId: string, value: string | IChatSessionProviderOptionItem): boolean { - const session = this._sessions.get(sessionResource); + const session = this._sessions.get(this._resolveResource(sessionResource)); return !!session?.setOption(optionId, value); } + /** + * Resolve a resource through the alias map. If the resource is a real + * resource that has been aliased to an untitled resource, return the + * untitled resource (the canonical key in {@link _sessions}). + */ + private _resolveResource(resource: URI): URI { + return this._resourceAliases.get(resource) ?? resource; + } + + public registerSessionResourceAlias(untitledResource: URI, realResource: URI): void { + this._resourceAliases.set(realResource, untitledResource); + } + /** * Store option groups for a session type */ @@ -1134,7 +1148,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ for (const u of updates) { this.setSessionOption(sessionResource, u.optionId, u.value); } - this._onDidChangeSessionOptions.fire(sessionResource); + this._onDidChangeSessionOptions.fire(this._resolveResource(sessionResource)); this._logService.trace(`[ChatSessionsService] notifySessionOptionsChange: finished for ${sessionResource}`); } diff --git a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts index 740442742c4c4..fe24a8fda9b9c 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts @@ -5,20 +5,18 @@ import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; -import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; -import { ChatConfiguration } from '../common/constants.js'; import { IAgentPluginRepositoryService } from '../common/plugins/agentPluginRepositoryService.js'; import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; -import { IMarketplacePlugin } from '../common/plugins/pluginMarketplaceService.js'; +import { IMarketplacePlugin, IPluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; export class PluginInstallService implements IPluginInstallService { declare readonly _serviceBrand: undefined; constructor( @IAgentPluginRepositoryService private readonly _pluginRepositoryService: IAgentPluginRepositoryService, - @IConfigurationService private readonly _configurationService: IConfigurationService, + @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, @IFileService private readonly _fileService: IFileService, @INotificationService private readonly _notificationService: INotificationService, ) { } @@ -54,7 +52,7 @@ export class PluginInstallService implements IPluginInstallService { return; } - this._addPluginPath(pluginDir.fsPath); + this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin); } async updatePlugin(plugin: IMarketplacePlugin): Promise { @@ -65,43 +63,7 @@ export class PluginInstallService implements IPluginInstallService { }); } - async uninstallPlugin(pluginUri: URI): Promise { - await this._removePluginPath(pluginUri.fsPath); - } - getPluginInstallUri(plugin: IMarketplacePlugin): URI { return this._pluginRepositoryService.getPluginInstallUri(plugin); } - - /** - * Adds the given file-system path to `chat.plugins.paths` in user-local config. - */ - private _addPluginPath(fsPath: string): void { - const current = this._configurationService.getValue>(ChatConfiguration.PluginPaths) ?? {}; - if (Object.prototype.hasOwnProperty.call(current, fsPath)) { - return; - } - this._configurationService.updateValue( - ChatConfiguration.PluginPaths, - { ...current, [fsPath]: true }, - ConfigurationTarget.USER_LOCAL, - ); - } - - /** - * Removes the given file-system path from `chat.plugins.paths` in user-local config. - */ - private _removePluginPath(fsPath: string) { - const current = this._configurationService.getValue>(ChatConfiguration.PluginPaths) ?? {}; - if (!Object.prototype.hasOwnProperty.call(current, fsPath)) { - return; - } - const updated = { ...current }; - delete updated[fsPath]; - return this._configurationService.updateValue( - ChatConfiguration.PluginPaths, - updated, - ConfigurationTarget.USER_LOCAL, - ); - } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index ddd912f7c0c55..fbdfccb2402f4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -55,6 +55,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private _prevButton: Button | undefined; private _nextButton: Button | undefined; private readonly _nextButtonHover: MutableDisposable<{ dispose(): void }> = this._register(new MutableDisposable()); + private _submitButton: Button | undefined; + private readonly _submitButtonHover: MutableDisposable<{ dispose(): void }> = this._register(new MutableDisposable()); private _skipAllButton: Button | undefined; private _isSkipped = false; @@ -164,6 +166,11 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent nextButton.label = `$(${Codicon.chevronRight.id})`; this._nextButton = nextButton; + const submitButton = interactiveStore.add(new Button(this._navigationButtons, { ...defaultButtonStyles })); + submitButton.element.classList.add('chat-question-submit-button'); + submitButton.label = localize('submit', 'Submit'); + this._submitButton = submitButton; + this._navigationButtons.appendChild(arrowsContainer); this._footerRow.appendChild(this._navigationButtons); this.domNode.append(this._footerRow); @@ -171,7 +178,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Register event listeners interactiveStore.add(prevButton.onDidClick(() => this.navigate(-1))); - interactiveStore.add(nextButton.onDidClick(() => this.handleNext())); + interactiveStore.add(nextButton.onDidClick(() => this.navigate(1))); + interactiveStore.add(submitButton.onDidClick(() => this.submit())); if (this._skipAllButton) { interactiveStore.add(this._skipAllButton.onDidClick(() => this.ignore())); } @@ -192,7 +200,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (isTextInput || isFreeformTextarea) { e.preventDefault(); e.stopPropagation(); - this.handleNext(); + this.handleNextOrSubmit(); } } else if ((event.ctrlKey || event.metaKey) && (event.keyCode === KeyCode.Backspace || event.keyCode === KeyCode.Delete)) { e.stopPropagation(); @@ -228,10 +236,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } /** - * Handles the next/submit button action. - * Either advances to the next question or submits. + * Handles the next/submit behavior for keyboard and option selection flows. + * Either advances to the next question or submits when on the last question. */ - private handleNext(): void { + private handleNextOrSubmit(): void { this.saveCurrentAnswer(); if (this._currentIndex < this.carousel.questions.length - 1) { @@ -245,6 +253,15 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } } + /** + * Handles explicit submit action from the dedicated submit button. + */ + private submit(): void { + this.saveCurrentAnswer(); + this._options.onSubmit(this._answers); + this.hideAndShowSummary(); + } + /** * Focuses the container element and announces the question for screen reader users. */ @@ -291,10 +308,13 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._singleSelectItems.clear(); this._multiSelectCheckboxes.clear(); this._freeformTextareas.clear(); + this._nextButtonHover.value = undefined; + this._submitButtonHover.value = undefined; // Clear references to disposed elements this._prevButton = undefined; this._nextButton = undefined; + this._submitButton = undefined; this._skipAllButton = undefined; this._questionContainer = undefined; this._navigationButtons = undefined; @@ -464,7 +484,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } private renderCurrentQuestion(focusContainerForScreenReader: boolean = false): void { - if (!this._questionContainer || !this._prevButton || !this._nextButton) { + if (!this._questionContainer || !this._prevButton || !this._nextButton || !this._submitButton) { return; } @@ -488,6 +508,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Render question header row with title and close button const headerRow = dom.$('.chat-question-header-row'); + const titleRow = dom.$('.chat-question-title-row'); // Render question title (short header) in the header bar as plain text if (question.title) { @@ -518,14 +539,16 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent title.textContent = messageContent; } } - headerRow.appendChild(title); + titleRow.appendChild(title); } // Add close button to header row (if allowSkip is enabled) if (this._closeButtonContainer) { - headerRow.appendChild(this._closeButtonContainer); + titleRow.appendChild(this._closeButtonContainer); } + headerRow.appendChild(titleRow); + this._questionContainer.appendChild(headerRow); // Render full question text below the header row (supports multi-line and markdown) @@ -556,24 +579,22 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._prevButton!.enabled = this._currentIndex > 0; this._prevButton!.element.style.display = isSingleQuestion ? 'none' : ''; - // Update next button icon/label for last question + // Keep navigation arrows stable and disable next on the last question const isLastQuestion = this._currentIndex === this.carousel.questions.length - 1; const submitLabel = localize('submit', 'Submit'); const nextLabel = localize('next', 'Next'); const nextLabelWithKeybinding = this.getLabelWithKeybinding(nextLabel, NEXT_QUESTION_ACTION_ID); - if (isLastQuestion) { - this._nextButton!.label = submitLabel; - this._nextButton!.element.setAttribute('aria-label', submitLabel); - // Switch to primary style for submit - this._nextButton!.element.classList.add('chat-question-nav-submit'); - this._nextButtonHover.value = this._hoverService.setupDelayedHover(this._nextButton!.element, { content: submitLabel }); - } else { - this._nextButton!.label = `$(${Codicon.chevronRight.id})`; - this._nextButton!.element.setAttribute('aria-label', nextLabelWithKeybinding); - // Keep secondary style for next - this._nextButton!.element.classList.remove('chat-question-nav-submit'); - this._nextButtonHover.value = this._hoverService.setupDelayedHover(this._nextButton!.element, { content: nextLabelWithKeybinding }); - } + this._nextButton!.label = `$(${Codicon.chevronRight.id})`; + this._nextButton!.enabled = !isLastQuestion; + this._nextButton!.element.setAttribute('aria-label', nextLabelWithKeybinding); + this._nextButtonHover.value = this._hoverService.setupDelayedHover(this._nextButton!.element, { content: nextLabelWithKeybinding }); + + this._submitButton!.enabled = isLastQuestion; + this._submitButton!.element.style.display = isLastQuestion ? '' : 'none'; + this._submitButton!.element.setAttribute('aria-label', submitLabel); + this._submitButtonHover.value = isLastQuestion + ? this._hoverService.setupDelayedHover(this._submitButton!.element, { content: submitLabel }) + : undefined; // Update aria-label to reflect the current question this._updateAriaLabel(); @@ -747,7 +768,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (freeform) { freeform.value = ''; } - this.handleNext(); + this.handleNextOrSubmit(); })); this._inputBoxes.add(this._hoverService.setupDelayedHover(listItem, { @@ -815,7 +836,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Enter confirms current selection and advances to next question e.preventDefault(); e.stopPropagation(); - this.handleNext(); + this.handleNextOrSubmit(); return; } else if (event.keyCode >= KeyCode.Digit1 && event.keyCode <= KeyCode.Digit9) { // Number keys 1-9 select the corresponding option, or focus freeform for next number @@ -1011,7 +1032,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } else if (event.keyCode === KeyCode.Enter) { e.preventDefault(); e.stopPropagation(); - this.handleNext(); + this.handleNextOrSubmit(); } else if (event.keyCode === KeyCode.Space) { e.preventDefault(); // Toggle the currently focused checkbox using click() to trigger onChange diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 9407257b4789c..28f9603b6699b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -48,9 +48,22 @@ padding: 0 16px 10px 16px; overflow: hidden; + .chat-question-title-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + min-width: 0; + } + .chat-question-title { flex: 1; min-width: 0; + word-wrap: break-word; + overflow-wrap: break-word; + font-weight: 500; + font-size: var(--vscode-chat-font-size-body-s); + margin: 0; padding-top: 4px; padding-bottom: 4px; margin-left: -16px; @@ -59,28 +72,18 @@ padding-right: 16px; border-bottom: 1px solid var(--vscode-chat-requestBorder); - .chat-question-title { - flex: 1; - min-width: 0; - word-wrap: break-word; - overflow-wrap: break-word; - font-weight: 500; - font-size: var(--vscode-chat-font-size-body-s); - margin: 0; - - .rendered-markdown { - a { - color: var(--vscode-textLink-foreground); - } + .rendered-markdown { + a { + color: var(--vscode-textLink-foreground); + } - a:hover, - a:active { - color: var(--vscode-textLink-activeForeground); - } + a:hover, + a:active { + color: var(--vscode-textLink-activeForeground); + } - p { - margin: 0; - } + p { + margin: 0; } } @@ -371,16 +374,16 @@ background: var(--vscode-button-secondaryHoverBackground) !important; } - /* Submit button (next on last question) uses primary background */ - .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-submit { + /* Dedicated submit button uses primary background */ + .chat-question-carousel-nav .monaco-button.chat-question-submit-button { background: var(--vscode-button-background) !important; color: var(--vscode-button-foreground) !important; - width: auto; + height: 22px; min-width: auto; padding: 0 8px; } - .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-submit:hover:not(.disabled) { + .chat-question-carousel-nav .monaco-button.chat-question-submit-button:hover:not(.disabled) { background: var(--vscode-button-hoverBackground) !important; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts index fac726fe312a2..c03c6f69f393e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts @@ -159,11 +159,14 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { } private _getDropdownActions(): IActionWidgetDropdownAction[] { + const isSteerDefault = this._isSteerDefault(); + const queueAction: IActionWidgetDropdownAction = { id: ChatQueueMessageAction.ID, label: localize('chat.queueMessage', "Add to Queue"), tooltip: '', enabled: true, + checked: !isSteerDefault, icon: Codicon.add, class: undefined, hover: { @@ -179,6 +182,7 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { label: localize('chat.steerWithMessage', "Steer with Message"), tooltip: '', enabled: true, + checked: isSteerDefault, icon: Codicon.arrowRight, class: undefined, hover: { diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 858254564bead..c609524139d5f 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -306,6 +306,12 @@ export interface IChatSessionsService { * Returns undefined if the controller doesn't have a handler or if no controller is registered. */ createNewChatSessionItem(chatSessionType: string, request: IChatAgentRequest, token: CancellationToken): Promise; + + /** + * Registers an alias so that session-option lookups by the real resource + * are redirected to the canonical (untitled) resource in the internal session map. + */ + registerSessionResourceAlias(untitledResource: URI, realResource: URI): void; } export function isSessionInProgressStatus(state: ChatSessionStatus): boolean { diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index c22a9c0166b51..81ba54b263fa5 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -45,6 +45,8 @@ export interface IAgentPlugin { readonly uri: URI; readonly enabled: IObservable; setEnabled(enabled: boolean): void; + /** Removes this plugin from its discovery source (config or installed storage). */ + remove(): void; readonly hooks: IObservable; readonly commands: IObservable; readonly skills: IObservable; diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index 8a2b48a7cf595..fa2ed06dbc456 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -253,41 +253,51 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic type PluginEntry = IAgentPlugin & { enabled: ISettableObservable }; -export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgentPluginDiscovery { +/** + * Describes a single discovered plugin source, before the shared + * infrastructure builds the full {@link IAgentPlugin} from it. + */ +interface IPluginSource { + readonly uri: URI; + readonly enabled: boolean; + readonly fromMarketplace: IMarketplacePlugin | undefined; + /** Called when setEnabled is invoked on the plugin */ + setEnabled(value: boolean): void; + /** Called when remove is invoked on the plugin */ + remove(): void; +} + +/** + * Shared base class for plugin discovery implementations. Contains the common + * logic for reading plugin contents (commands, skills, agents, hooks, MCP server + * definitions) from the filesystem and watching for live updates. + * + * Subclasses implement {@link _discoverPluginSources} to determine *which* + * plugins exist, while this class handles the rest. + */ +export abstract class AbstractAgentPluginDiscovery extends Disposable implements IAgentPluginDiscovery { - private readonly _pluginPathsConfig: IObservable>; private readonly _pluginEntries = new Map(); private readonly _plugins = observableValue('discoveredAgentPlugins', []); public readonly plugins: IObservable = this._plugins; - private _discoverVersion = 0; + protected _discoverVersion = 0; constructor( - @IConfigurationService private readonly _configurationService: IConfigurationService, - @IFileService private readonly _fileService: IFileService, - @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, - @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, - @IPathService private readonly _pathService: IPathService, - @ILogService private readonly _logService: ILogService, - @IInstantiationService private readonly _instantiationService: IInstantiationService, + protected readonly _fileService: IFileService, + protected readonly _pathService: IPathService, + protected readonly _logService: ILogService, + protected readonly _instantiationService: IInstantiationService, ) { super(); - this._pluginPathsConfig = observableConfigValue>(ChatConfiguration.PluginPaths, {}, _configurationService); } - public start(): void { - const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0)); - this._register(autorun(reader => { - this._pluginPathsConfig.read(reader); - scheduler.schedule(); - })); - scheduler.schedule(); - } + public abstract start(): void; - private async _refreshPlugins(): Promise { + protected async _refreshPlugins(): Promise { const version = ++this._discoverVersion; - const plugins = await this._discoverPlugins(); + const plugins = await this._discoverAndBuildPlugins(); if (version !== this._discoverVersion || this._store.isDisposed) { return; } @@ -295,38 +305,20 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent this._plugins.set(plugins, undefined); } - private async _discoverPlugins(): Promise { + /** Subclasses return plugin sources to discover. */ + protected abstract _discoverPluginSources(): Promise; + + private async _discoverAndBuildPlugins(): Promise { + const sources = await this._discoverPluginSources(); const plugins: IAgentPlugin[] = []; const seenPluginUris = new Set(); - const config = this._pluginPathsConfig.get(); - - for (const [path, enabled] of Object.entries(config)) { - if (!path.trim()) { - continue; - } - - const resources = this._resolvePluginPath(path.trim()); - for (const resource of resources) { - let stat; - try { - stat = await this._fileService.resolve(resource); - } catch { - this._logService.debug(`[ConfiguredAgentPluginDiscovery] Could not resolve plugin path: ${resource.toString()}`); - continue; - } - - if (!stat.isDirectory) { - this._logService.debug(`[ConfiguredAgentPluginDiscovery] Plugin path is not a directory: ${resource.toString()}`); - continue; - } - const key = stat.resource.toString(); - if (!seenPluginUris.has(key)) { - const adapter = await this._detectPluginFormatAdapter(stat.resource); - const fromMarketplace = await this._pluginMarketplaceService.getMarketplacePluginMetadata(stat.resource); - seenPluginUris.add(key); - plugins.push(this._toPlugin(stat.resource, path, enabled, adapter, fromMarketplace)); - } + for (const source of sources) { + const key = source.uri.toString(); + if (!seenPluginUris.has(key)) { + seenPluginUris.add(key); + const adapter = await this._detectPluginFormatAdapter(source.uri); + plugins.push(this._toPlugin(source.uri, source.enabled, adapter, source.fromMarketplace, value => source.setEnabled(value), () => source.remove())); } } @@ -336,58 +328,6 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent return plugins; } - /** - * Resolves a plugin path to one or more resource URIs. Absolute paths are - * used directly; relative paths are resolved against each workspace folder. - */ - private _resolvePluginPath(path: string): URI[] { - if (win32.isAbsolute(path) || posix.isAbsolute(path)) { - return [URI.file(path)]; - } - - return this._workspaceContextService.getWorkspace().folders.map( - folder => joinPath(folder.uri, path) - ); - } - - /** - * Updates the enabled state of a plugin path in the configuration, - * writing to the most specific config target where the key is defined. - */ - private _updatePluginPathEnabled(configKey: string, value: boolean): void { - const inspected = this._configurationService.inspect>(ChatConfiguration.PluginPaths); - - // Walk from most specific to least specific to find where this key is defined - const targets = [ - ConfigurationTarget.WORKSPACE_FOLDER, - ConfigurationTarget.WORKSPACE, - ConfigurationTarget.USER_LOCAL, - ConfigurationTarget.USER_REMOTE, - ConfigurationTarget.USER, - ConfigurationTarget.APPLICATION, - ]; - - for (const target of targets) { - const mapping = getConfigValueInTarget(inspected, target); - if (mapping && Object.prototype.hasOwnProperty.call(mapping, configKey)) { - this._configurationService.updateValue( - ChatConfiguration.PluginPaths, - { ...mapping, [configKey]: value }, - target, - ); - return; - } - } - - // Key not found in any target; write to USER_LOCAL as default - const current = getConfigValueInTarget(inspected, ConfigurationTarget.USER_LOCAL) ?? {}; - this._configurationService.updateValue( - ChatConfiguration.PluginPaths, - { ...current, [configKey]: value }, - ConfigurationTarget.USER_LOCAL, - ); - } - private async _detectPluginFormatAdapter(pluginUri: URI): Promise { const isInClaudeDirectory = pluginUri.path.split('/').includes('.claude'); if (isInClaudeDirectory || await this._pathExists(joinPath(pluginUri, '.claude-plugin', 'plugin.json'))) { @@ -397,7 +337,7 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent return this._instantiationService.createInstance(CopilotPluginFormatAdapter); } - private async _pathExists(resource: URI): Promise { + protected async _pathExists(resource: URI): Promise { try { await this._fileService.resolve(resource); return true; @@ -406,7 +346,7 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent } } - private _toPlugin(uri: URI, configKey: string, initialEnabled: boolean, adapter: IAgentPluginFormatAdapter, fromMarketplace: IMarketplacePlugin | undefined): IAgentPlugin { + private _toPlugin(uri: URI, initialEnabled: boolean, adapter: IAgentPluginFormatAdapter, fromMarketplace: IMarketplacePlugin | undefined, setEnabledCallback: (value: boolean) => void, removeCallback: () => void): IAgentPlugin { const key = uri.toString(); const existing = this._pluginEntries.get(key); if (existing) { @@ -476,9 +416,8 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent const plugin: PluginEntry = { uri, enabled, - setEnabled: (value: boolean) => { - this._updatePluginPathEnabled(configKey, value); - }, + setEnabled: setEnabledCallback, + remove: removeCallback, hooks, commands, skills, @@ -758,3 +697,203 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent } } +export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery { + + private readonly _pluginPathsConfig: IObservable>; + + constructor( + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IFileService fileService: IFileService, + @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IPathService pathService: IPathService, + @ILogService logService: ILogService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(fileService, pathService, logService, instantiationService); + this._pluginPathsConfig = observableConfigValue>(ChatConfiguration.PluginPaths, {}, _configurationService); + } + + public override start(): void { + const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0)); + this._register(autorun(reader => { + this._pluginPathsConfig.read(reader); + scheduler.schedule(); + })); + scheduler.schedule(); + } + + protected override async _discoverPluginSources(): Promise { + const sources: IPluginSource[] = []; + const config = this._pluginPathsConfig.get(); + + for (const [path, enabled] of Object.entries(config)) { + if (!path.trim()) { + continue; + } + + const resources = this._resolvePluginPath(path.trim()); + for (const resource of resources) { + let stat; + try { + stat = await this._fileService.resolve(resource); + } catch { + this._logService.debug(`[ConfiguredAgentPluginDiscovery] Could not resolve plugin path: ${resource.toString()}`); + continue; + } + + if (!stat.isDirectory) { + this._logService.debug(`[ConfiguredAgentPluginDiscovery] Plugin path is not a directory: ${resource.toString()}`); + continue; + } + + const fromMarketplace = this._pluginMarketplaceService.getMarketplacePluginMetadata(stat.resource); + const configKey = path; + sources.push({ + uri: stat.resource, + enabled, + fromMarketplace, + setEnabled: (value: boolean) => this._updatePluginPathEnabled(configKey, value), + remove: () => this._removePluginPath(configKey), + }); + } + } + + return sources; + } + + /** + * Resolves a plugin path to one or more resource URIs. Absolute paths are + * used directly; relative paths are resolved against each workspace folder. + */ + private _resolvePluginPath(path: string): URI[] { + if (win32.isAbsolute(path) || posix.isAbsolute(path)) { + return [URI.file(path)]; + } + + return this._workspaceContextService.getWorkspace().folders.map( + folder => joinPath(folder.uri, path) + ); + } + + /** + * Updates the enabled state of a plugin path in the configuration, + * writing to the most specific config target where the key is defined. + */ + private _updatePluginPathEnabled(configKey: string, value: boolean): void { + const inspected = this._configurationService.inspect>(ChatConfiguration.PluginPaths); + + // Walk from most specific to least specific to find where this key is defined + const targets = [ + ConfigurationTarget.WORKSPACE_FOLDER, + ConfigurationTarget.WORKSPACE, + ConfigurationTarget.USER_LOCAL, + ConfigurationTarget.USER_REMOTE, + ConfigurationTarget.USER, + ConfigurationTarget.APPLICATION, + ]; + + for (const target of targets) { + const mapping = getConfigValueInTarget(inspected, target); + if (mapping && Object.prototype.hasOwnProperty.call(mapping, configKey)) { + this._configurationService.updateValue( + ChatConfiguration.PluginPaths, + { ...mapping, [configKey]: value }, + target, + ); + return; + } + } + + // Key not found in any target; write to USER_LOCAL as default + const current = getConfigValueInTarget(inspected, ConfigurationTarget.USER_LOCAL) ?? {}; + this._configurationService.updateValue( + ChatConfiguration.PluginPaths, + { ...current, [configKey]: value }, + ConfigurationTarget.USER_LOCAL, + ); + } + + /** + * Removes a plugin path from `chat.plugins.paths` in the most specific + * config target where the key is defined. + */ + private _removePluginPath(configKey: string): void { + const inspected = this._configurationService.inspect>(ChatConfiguration.PluginPaths); + + const targets = [ + ConfigurationTarget.WORKSPACE_FOLDER, + ConfigurationTarget.WORKSPACE, + ConfigurationTarget.USER_LOCAL, + ConfigurationTarget.USER_REMOTE, + ConfigurationTarget.USER, + ConfigurationTarget.APPLICATION, + ]; + + for (const target of targets) { + const mapping = getConfigValueInTarget(inspected, target); + if (mapping && Object.prototype.hasOwnProperty.call(mapping, configKey)) { + const updated = { ...mapping }; + delete updated[configKey]; + this._configurationService.updateValue( + ChatConfiguration.PluginPaths, + updated, + target, + ); + return; + } + } + } +} + +export class MarketplaceAgentPluginDiscovery extends AbstractAgentPluginDiscovery { + + constructor( + @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, + @IFileService fileService: IFileService, + @IPathService pathService: IPathService, + @ILogService logService: ILogService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(fileService, pathService, logService, instantiationService); + } + + public override start(): void { + const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0)); + this._register(autorun(reader => { + this._pluginMarketplaceService.installedPlugins.read(reader); + scheduler.schedule(); + })); + scheduler.schedule(); + } + + protected override async _discoverPluginSources(): Promise { + const installed = this._pluginMarketplaceService.installedPlugins.get(); + const sources: IPluginSource[] = []; + + for (const entry of installed) { + let stat; + try { + stat = await this._fileService.resolve(entry.pluginUri); + } catch { + this._logService.debug(`[MarketplaceAgentPluginDiscovery] Could not resolve installed plugin: ${entry.pluginUri.toString()}`); + continue; + } + + if (!stat.isDirectory) { + this._logService.debug(`[MarketplaceAgentPluginDiscovery] Installed plugin path is not a directory: ${entry.pluginUri.toString()}`); + continue; + } + + sources.push({ + uri: stat.resource, + enabled: entry.enabled, + fromMarketplace: entry.plugin, + setEnabled: (value: boolean) => this._pluginMarketplaceService.setInstalledPluginEnabled(entry.pluginUri, value), + remove: () => this._pluginMarketplaceService.removeInstalledPlugin(entry.pluginUri), + }); + } + + return sources; + } +} diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts index abdf81f730311..10a89c2e4b18d 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts @@ -14,15 +14,10 @@ export interface IPluginInstallService { /** * Clones the marketplace repository (if not already cached) and registers - * the plugin's source directory in the user's `chat.plugins.paths` config. + * the plugin in the marketplace service's installed plugins storage. */ installPlugin(plugin: IMarketplacePlugin): Promise; - /** - * Removes the plugin from `chat.plugins.paths` config. - */ - uninstallPlugin(pluginUri: URI): Promise; - /** * Pulls the latest changes for an already-cloned marketplace repository. */ diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index e536a09d199e2..11a8db5482303 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -7,13 +7,16 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Event } from '../../../../../base/common/event.js'; import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { Lazy } from '../../../../../base/common/lazy.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; import { revive } from '../../../../../base/common/marshalling.js'; -import { isEqualOrParent, joinPath, normalizePath, relativePath } from '../../../../../base/common/resources.js'; -import { URI } from '../../../../../base/common/uri.js'; +import { IObservable } from '../../../../../base/common/observable.js'; +import { isEqual, isEqualOrParent, joinPath, normalizePath, relativePath } from '../../../../../base/common/resources.js'; +import { URI, UriComponents } from '../../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; +import { ObservableMemento, observableMemento } from '../../../../../platform/observable/common/observableMemento.js'; import { asJson, IRequestService } from '../../../../../platform/request/common/request.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import type { Dto } from '../../../../services/extensions/common/proxyIdentifier.js'; @@ -69,13 +72,24 @@ interface IMarketplaceJson { }[]; } +export interface IMarketplaceInstalledPlugin { + readonly pluginUri: URI; + readonly plugin: IMarketplacePlugin; + readonly enabled: boolean; +} + export const IPluginMarketplaceService = createDecorator('pluginMarketplaceService'); export interface IPluginMarketplaceService { readonly _serviceBrand: undefined; readonly onDidChangeMarketplaces: Event; + /** Installed marketplace plugins, backed by storage. */ + readonly installedPlugins: IObservable; fetchMarketplacePlugins(token: CancellationToken): Promise; - getMarketplacePluginMetadata(pluginUri: URI): Promise; + getMarketplacePluginMetadata(pluginUri: URI): IMarketplacePlugin | undefined; + addInstalledPlugin(pluginUri: URI, plugin: IMarketplacePlugin): void; + removeInstalledPlugin(pluginUri: URI): void; + setInstalledPluginEnabled(pluginUri: URI, enabled: boolean): void; } /** @@ -98,12 +112,31 @@ interface IGitHubMarketplaceCacheEntry { type IStoredGitHubMarketplaceCache = Dto>; -export class PluginMarketplaceService implements IPluginMarketplaceService { +interface IStoredInstalledPlugin { + readonly pluginUri: UriComponents; + readonly plugin: IMarketplacePlugin; + readonly enabled: boolean; +} + +const installedPluginsMemento = observableMemento({ + defaultValue: [], + key: 'chat.plugins.installed.v1', + toStorage: value => JSON.stringify(value), + fromStorage: value => { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + }, +}); + +export class PluginMarketplaceService extends Disposable implements IPluginMarketplaceService { declare readonly _serviceBrand: undefined; private readonly _gitHubMarketplaceCache = new Lazy>(() => this._loadPersistedGitHubMarketplaceCache()); + private readonly _installedPluginsStore: ObservableMemento; readonly onDidChangeMarketplaces: Event; + readonly installedPlugins: IObservable; + constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @IRequestService private readonly _requestService: IRequestService, @@ -112,6 +145,14 @@ export class PluginMarketplaceService implements IPluginMarketplaceService { @ILogService private readonly _logService: ILogService, @IStorageService private readonly _storageService: IStorageService, ) { + super(); + + this._installedPluginsStore = this._register( + installedPluginsMemento(StorageScope.APPLICATION, StorageTarget.MACHINE, _storageService) + ); + + this.installedPlugins = this._installedPluginsStore.map(s => revive(s)); + this.onDidChangeMarketplaces = Event.filter( _configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(ChatConfiguration.PluginsEnabled) || e.affectsConfiguration(ChatConfiguration.PluginMarketplaces), @@ -295,64 +336,30 @@ export class PluginMarketplaceService implements IPluginMarketplaceService { ); } - async getMarketplacePluginMetadata(pluginUri: URI): Promise { - const configuredRefs = this._configurationService.getValue(ChatConfiguration.PluginMarketplaces) ?? []; - const refs = parseMarketplaceReferences(configuredRefs); - - for (const ref of refs) { - let repoDir: URI; - try { - repoDir = this._pluginRepositoryService.getRepositoryUri(ref); - } catch { - continue; - } - - if (!isEqualOrParent(pluginUri, repoDir)) { - continue; - } - - for (const def of MARKETPLACE_DEFINITIONS) { - const definitionUri = joinPath(repoDir, def.path); - let json: IMarketplaceJson | undefined; - try { - const contents = await this._fileService.readFile(definitionUri); - json = parseJSONC(contents.value.toString()) as IMarketplaceJson | undefined; - } catch { - continue; - } - - if (!json?.plugins || !Array.isArray(json.plugins)) { - continue; - } - - for (const p of json.plugins) { - if (typeof p.name !== 'string' || !p.name) { - continue; - } - - const source = resolvePluginSource(json.metadata?.pluginRoot, p.source ?? ''); - if (source === undefined) { - continue; - } + getMarketplacePluginMetadata(pluginUri: URI): IMarketplacePlugin | undefined { + const installed = this.installedPlugins.get(); + return installed.find(e => isEqualOrParent(pluginUri, e.pluginUri))?.plugin; + } - const pluginSourceUri = normalizePath(joinPath(repoDir, source)); - if (isEqualOrParent(pluginUri, pluginSourceUri)) { - return { - name: p.name, - description: p.description ?? '', - version: p.version ?? '', - source, - marketplace: ref.displayLabel, - marketplaceReference: ref, - marketplaceType: def.type, - readmeUri: getMarketplaceReadmeFileUri(repoDir, source), - }; - } - } - } + addInstalledPlugin(pluginUri: URI, plugin: IMarketplacePlugin): void { + const current = this.installedPlugins.get(); + if (current.some(e => isEqual(e.pluginUri, pluginUri))) { + return; } + this._installedPluginsStore.set([...current, { pluginUri, plugin, enabled: true }], undefined); + } - return undefined; + removeInstalledPlugin(pluginUri: URI): void { + const current = this.installedPlugins.get(); + this._installedPluginsStore.set(current.filter(e => !isEqual(e.pluginUri, pluginUri)), undefined); + } + + setInstalledPluginEnabled(pluginUri: URI, enabled: boolean): void { + const current = this.installedPlugins.get(); + this._installedPluginsStore.set( + current.map(e => isEqual(e.pluginUri, pluginUri) ? { ...e, enabled } : e), + undefined, + ); } private async _fetchFromClonedRepo(reference: IMarketplaceReference, token: CancellationToken): Promise { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts index f6b160c7ee276..0de7e30f98220 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts @@ -416,14 +416,66 @@ export class PromptBody { const fileReferences: IBodyFileReference[] = []; const variableReferences: IBodyVariableReference[] = []; const bodyOffset = Iterable.reduce(Iterable.slice(this.linesWithEOL, 0, this.range.startLineNumber - 1), (len, line) => line.length + len, 0); + let inFencedCodeBlock = false; + let fencedCodeBlockFenceChar: string | undefined; + let fencedCodeBlockFenceLength = 0; for (let i = this.range.startLineNumber - 1, lineStartOffset = bodyOffset; i < this.range.endLineNumber - 1; i++) { const line = this.linesWithEOL[i]; + const trimmedLine = line.trimStart(); + + // Detect fenced code block lines (``` or ~~~, 3 or more chars) + const fenceMatch = /^(?(`{3,}|~{3,}))/u.exec(trimmedLine); + if (fenceMatch) { + const fence = fenceMatch.groups!.fence; + const fenceChar = fence[0]; + const fenceLength = fence.length; + const restOfLine = trimmedLine.slice(fence.length); + + if (!inFencedCodeBlock) { + // Opening fence: record fence char/length and enter fenced code block + inFencedCodeBlock = true; + fencedCodeBlockFenceChar = fenceChar; + fencedCodeBlockFenceLength = fenceLength; + lineStartOffset += line.length; + continue; + } + + // Potential closing fence: must match fence char and have at least the same length, + // and only whitespace is allowed after the fence. + if (fencedCodeBlockFenceChar === fenceChar && fenceLength >= fencedCodeBlockFenceLength && /^\s*$/.test(restOfLine)) { + inFencedCodeBlock = false; + fencedCodeBlockFenceChar = undefined; + fencedCodeBlockFenceLength = 0; + lineStartOffset += line.length; + continue; + } + } + + // Skip all lines inside fenced code blocks + if (inFencedCodeBlock) { + lineStartOffset += line.length; + continue; + } + + // Collect inline code spans (backtick-delimited) to exclude from matching + const inlineCodeRanges: { start: number; end: number }[] = []; + for (const inlineMatch of line.matchAll(/`[^`]+`/g)) { + inlineCodeRanges.push({ start: inlineMatch.index, end: inlineMatch.index + inlineMatch[0].length }); + } + + const isInsideInlineCode = (offset: number) => { + return inlineCodeRanges.some(r => offset >= r.start && offset < r.end); + }; + // Match markdown links: [text](link) const linkMatch = line.matchAll(/\[(.*?)\]\((.+?)\)/g); for (const match of linkMatch) { if (match.index > 0 && line[match.index - 1] === '!') { continue; // skip image links } + if (isInsideInlineCode(match.index)) { + continue; // skip matches inside inline code + } const linkEndOffset = match.index + match[0].length - 1; // before the parenthesis const linkStartOffset = match.index + match[0].length - match[2].length - 1; const range = new Range(i + 1, linkStartOffset + 1, i + 1, linkEndOffset + 1); @@ -440,6 +492,9 @@ export class PromptBody { if (markdownLinkRanges.some(mdRange => Range.areIntersectingOrTouching(mdRange, fullRange))) { continue; } + if (isInsideInlineCode(match.index)) { + continue; // skip matches inside inline code + } const contentMatch = match.groups?.['filePath'] || match.groups?.['toolName']; if (!contentMatch) { continue; diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts index 7fbf788530a3b..66d10c9b487c3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts @@ -283,16 +283,29 @@ suite('ChatQuestionCarouselPart', () => { assert.ok(prevButton.classList.contains('disabled') || prevButton.disabled, 'Previous button should be disabled on first question'); }); - test('next button shows submit icon on last question', () => { + test('next button stays as arrow and is disabled on last question', () => { const carousel = createMockCarousel([ { id: 'q1', type: 'text', title: 'Only Question' } ]); createWidget(carousel); // Use dedicated class selector for stability - const nextButton = widget.domNode.querySelector('.chat-question-nav-next') as HTMLElement; + const nextButton = widget.domNode.querySelector('.chat-question-nav-next') as HTMLButtonElement; assert.ok(nextButton, 'Next button should exist'); - assert.strictEqual(nextButton.getAttribute('aria-label'), 'Submit', 'Next button should have Submit aria-label on last question'); + assert.strictEqual(nextButton.getAttribute('aria-label'), 'Next', 'Next button should preserve Next aria-label on last question'); + assert.ok(nextButton.classList.contains('disabled') || nextButton.disabled, 'Next button should be disabled on last question'); + }); + + test('submit button is shown on last question', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Only Question' } + ]); + createWidget(carousel); + + const submitButton = widget.domNode.querySelector('.chat-question-submit-button') as HTMLButtonElement; + assert.ok(submitButton, 'Submit button should exist'); + assert.strictEqual(submitButton.getAttribute('aria-label'), 'Submit'); + assert.notStrictEqual(submitButton.style.display, 'none', 'Submit button should be visible on last question'); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index 46cdb2f5e0968..0136bfb939361 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -229,6 +229,10 @@ export class MockChatSessionsService implements IChatSessionsService { return undefined; } + registerSessionResourceAlias(_untitledResource: URI, _realResource: URI): void { + // noop + } + registerChatModelChangeListeners(chatService: IChatService, chatSessionType: string, onChange: () => void): IDisposable { // Store the emitter so tests can trigger it this.onChange = onChange; diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts index 4b42ab0ea040c..46bb4fbcb3f25 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts @@ -4,8 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { VSBuffer } from '../../../../../../base/common/buffer.js'; -import { joinPath } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -118,205 +116,52 @@ suite('PluginMarketplaceService', () => { suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - const repoDir = URI.file('/cache/agentPlugins/github.com/microsoft/plugins'); const marketplaceRef = parseMarketplaceReference('microsoft/plugins')!; - function createMarketplaceJson(plugins: object[], metadata?: object): string { - return JSON.stringify({ metadata, plugins }); - } - - function createService(fileContents: Map): PluginMarketplaceService { + function createService(): PluginMarketplaceService { const instantiationService = store.add(new TestInstantiationService()); - const configService = new TestConfigurationService({ + instantiationService.stub(IConfigurationService, new TestConfigurationService({ [ChatConfiguration.PluginMarketplaces]: ['microsoft/plugins'], [ChatConfiguration.PluginsEnabled]: true, - }); - - const fileService = { - readFile: async (uri: URI) => { - const content = fileContents.get(uri.path); - if (content !== undefined) { - return { value: VSBuffer.fromString(content) }; - } - throw new Error('File not found'); - }, - } as unknown as IFileService; - - const repositoryService = { - getRepositoryUri: () => repoDir, - getPluginInstallUri: (plugin: { source: string }) => joinPath(repoDir, plugin.source), - } as unknown as IAgentPluginRepositoryService; - - instantiationService.stub(IConfigurationService, configService); - instantiationService.stub(IFileService, fileService); - instantiationService.stub(IAgentPluginRepositoryService, repositoryService); + })); + instantiationService.stub(IFileService, {} as unknown as IFileService); + instantiationService.stub(IAgentPluginRepositoryService, {} as unknown as IAgentPluginRepositoryService); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IRequestService, {} as unknown as IRequestService); instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); - return instantiationService.createInstance(PluginMarketplaceService); + return store.add(instantiationService.createInstance(PluginMarketplaceService)); } - test('returns metadata for a plugin that matches by source', async () => { - const files = new Map(); - files.set( - joinPath(repoDir, '.github/plugin/marketplace.json').path, - createMarketplaceJson([ - { name: 'my-plugin', description: 'A test plugin', version: '2.0.0', source: 'plugins/my-plugin' }, - ]), - ); - - const service = createService(files); - const pluginUri = joinPath(repoDir, 'plugins/my-plugin'); - - const result = await service.getMarketplacePluginMetadata(pluginUri); - - assert.deepStrictEqual(result && { - name: result.name, - description: result.description, - version: result.version, - source: result.source, - marketplace: result.marketplace, - marketplaceType: result.marketplaceType, - }, { + test('returns metadata for an installed plugin', () => { + const service = createService(); + const pluginUri = URI.file('/cache/agentPlugins/my-plugin'); + const plugin = { name: 'my-plugin', description: 'A test plugin', version: '2.0.0', source: 'plugins/my-plugin', marketplace: marketplaceRef.displayLabel, + marketplaceReference: marketplaceRef, marketplaceType: MarketplaceType.Copilot, - }); - }); - - test('returns undefined for a URI outside all marketplace repos', async () => { - const files = new Map(); - files.set( - joinPath(repoDir, '.github/plugin/marketplace.json').path, - createMarketplaceJson([ - { name: 'my-plugin', version: '1.0.0', source: 'plugins/my-plugin' }, - ]), - ); + }; - const service = createService(files); - const unrelatedUri = URI.file('/some/other/path'); + service.addInstalledPlugin(pluginUri, plugin); + const result = service.getMarketplacePluginMetadata(pluginUri); - const result = await service.getMarketplacePluginMetadata(unrelatedUri); - assert.strictEqual(result, undefined); + assert.deepStrictEqual(result, plugin); }); - test('returns undefined when plugin URI is in repo but no source matches', async () => { - const files = new Map(); - files.set( - joinPath(repoDir, '.github/plugin/marketplace.json').path, - createMarketplaceJson([ - { name: 'my-plugin', version: '1.0.0', source: 'plugins/my-plugin' }, - ]), - ); - - const service = createService(files); - const noMatchUri = joinPath(repoDir, 'plugins/other-plugin'); - - const result = await service.getMarketplacePluginMetadata(noMatchUri); + test('returns undefined for a URI that is not installed', () => { + const service = createService(); + const result = service.getMarketplacePluginMetadata(URI.file('/some/other/path')); assert.strictEqual(result, undefined); }); - test('returns undefined when no marketplace.json files exist', async () => { - const service = createService(new Map()); - const pluginUri = joinPath(repoDir, 'plugins/my-plugin'); - - const result = await service.getMarketplacePluginMetadata(pluginUri); + test('returns undefined when no plugins are installed', () => { + const service = createService(); + const result = service.getMarketplacePluginMetadata(URI.file('/any/path')); assert.strictEqual(result, undefined); }); - - test('falls back to Claude marketplace.json when Copilot one is missing', async () => { - const files = new Map(); - files.set( - joinPath(repoDir, '.claude-plugin/marketplace.json').path, - createMarketplaceJson([ - { name: 'claude-plugin', version: '3.0.0', source: 'src/claude-plugin' }, - ]), - ); - - const service = createService(files); - const pluginUri = joinPath(repoDir, 'src/claude-plugin'); - - const result = await service.getMarketplacePluginMetadata(pluginUri); - assert.ok(result); - assert.strictEqual(result!.name, 'claude-plugin'); - assert.strictEqual(result!.marketplaceType, MarketplaceType.Claude); - }); - - test('resolves source relative to pluginRoot metadata', async () => { - const files = new Map(); - files.set( - joinPath(repoDir, '.github/plugin/marketplace.json').path, - createMarketplaceJson( - [{ name: 'nested', version: '1.0.0', source: 'my-plugin' }], - { pluginRoot: 'packages' }, - ), - ); - - const service = createService(files); - const pluginUri = joinPath(repoDir, 'packages/my-plugin'); - - const result = await service.getMarketplacePluginMetadata(pluginUri); - assert.ok(result); - assert.strictEqual(result!.name, 'nested'); - assert.strictEqual(result!.source, 'packages/my-plugin'); - }); - - test('selects the correct plugin among multiple entries', async () => { - const files = new Map(); - files.set( - joinPath(repoDir, '.github/plugin/marketplace.json').path, - createMarketplaceJson([ - { name: 'alpha', version: '1.0.0', source: 'plugins/alpha' }, - { name: 'beta', version: '2.0.0', source: 'plugins/beta' }, - { name: 'gamma', version: '3.0.0', source: 'plugins/gamma' }, - ]), - ); - - const service = createService(files); - const pluginUri = joinPath(repoDir, 'plugins/beta'); - - const result = await service.getMarketplacePluginMetadata(pluginUri); - assert.ok(result); - assert.strictEqual(result!.name, 'beta'); - assert.strictEqual(result!.version, '2.0.0'); - }); - - test('returns undefined when no marketplaces are configured', async () => { - const instantiationService = store.add(new TestInstantiationService()); - instantiationService.stub(IConfigurationService, new TestConfigurationService({ - [ChatConfiguration.PluginMarketplaces]: [], - [ChatConfiguration.PluginsEnabled]: true, - })); - instantiationService.stub(IFileService, {} as unknown as IFileService); - instantiationService.stub(IAgentPluginRepositoryService, {} as unknown as IAgentPluginRepositoryService); - instantiationService.stub(ILogService, new NullLogService()); - instantiationService.stub(IRequestService, {} as unknown as IRequestService); - instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); - - const service = instantiationService.createInstance(PluginMarketplaceService); - const result = await service.getMarketplacePluginMetadata(URI.file('/any/path')); - assert.strictEqual(result, undefined); - }); - - test('matches when pluginUri is a subdirectory inside a plugin source', async () => { - const files = new Map(); - files.set( - joinPath(repoDir, '.github/plugin/marketplace.json').path, - createMarketplaceJson([ - { name: 'my-plugin', version: '1.0.0', source: 'plugins/my-plugin' }, - ]), - ); - - const service = createService(files); - const nestedUri = joinPath(repoDir, 'plugins/my-plugin/src/tool.ts'); - - const result = await service.getMarketplacePluginMetadata(nestedUri); - assert.ok(result); - assert.strictEqual(result!.name, 'my-plugin'); - }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts index 136ed9195ccac..d50fa8b4fe2fa 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts @@ -239,6 +239,183 @@ suite('PromptFileParser', () => { assert.deepEqual(result.header.tools, ['search', 'terminal']); }); + test('ignores links and variables inside inline code and fenced code blocks', async () => { + const uri = URI.parse('file:///test/prompt3.md'); + const content = [ + '---', + `description: "Prompt with markdown code"`, + '---', + 'Outside #tool:outside and [outside](./outside.md).', + 'Inline code: `#tool:inline and [inline](./inline.md)` should be ignored.', + '```ts', + '#tool:block and #file:./inside-block.md and [block](./block.md)', + '```', + 'After block #file:./after.md and [after](./after-link.md).', + ].join('\n'); + + const result = new PromptFileParser().parse(uri, content); + assert.ok(result.body); + assert.deepEqual(result.body.fileReferences.map(reference => ({ content: reference.content, isMarkdownLink: reference.isMarkdownLink })), [ + { content: './outside.md', isMarkdownLink: true }, + { content: './after.md', isMarkdownLink: false }, + { content: './after-link.md', isMarkdownLink: true } + ]); + assert.deepEqual(result.body.variableReferences.map(reference => reference.name), ['outside']); + }); + + test('ignores references in multiple inline code spans on the same line', async () => { + const uri = URI.parse('file:///test/prompt-inline.md'); + const content = [ + '---', + 'description: "test"', + '---', + 'Before `#tool:ignored1` middle #tool:visible `[link](./ignored.md)` after [real](./real.md).', + ].join('\n'); + + const result = new PromptFileParser().parse(uri, content); + assert.ok(result.body); + assert.deepEqual(result.body.fileReferences.map(r => ({ content: r.content, isMarkdownLink: r.isMarkdownLink })), [ + { content: './real.md', isMarkdownLink: true }, + ]); + assert.deepEqual(result.body.variableReferences.map(r => r.name), ['visible']); + }); + + test('handles fenced code block without language specifier', async () => { + const uri = URI.parse('file:///test/prompt-fence.md'); + const content = [ + '---', + 'description: "test"', + '---', + '```', + '#file:./ignored.md', + '[link](./ignored-link.md)', + '```', + '#file:./visible.md', + ].join('\n'); + + const result = new PromptFileParser().parse(uri, content); + assert.ok(result.body); + assert.deepEqual(result.body.fileReferences.map(r => ({ content: r.content, isMarkdownLink: r.isMarkdownLink })), [ + { content: './visible.md', isMarkdownLink: false }, + ]); + assert.deepEqual(result.body.variableReferences, []); + }); + + test('handles multiple fenced code blocks', async () => { + const uri = URI.parse('file:///test/prompt-multi-fence.md'); + const content = [ + '---', + 'description: "test"', + '---', + '#tool:before', + '```js', + '#tool:ignored1', + '```', + '#tool:between', + '```python', + '#tool:ignored2', + '```', + '#tool:after', + ].join('\n'); + + const result = new PromptFileParser().parse(uri, content); + assert.ok(result.body); + assert.deepEqual(result.body.variableReferences.map(r => r.name), ['before', 'between', 'after']); + }); + + test('unclosed fenced code block ignores all remaining lines', async () => { + const uri = URI.parse('file:///test/prompt-unclosed.md'); + const content = [ + '---', + 'description: "test"', + '---', + '#tool:visible', + '```', + '#tool:ignored', + '#file:./ignored.md', + ].join('\n'); + + const result = new PromptFileParser().parse(uri, content); + assert.ok(result.body); + assert.deepEqual(result.body.variableReferences.map(r => r.name), ['visible']); + assert.deepEqual(result.body.fileReferences, []); + }); + + test('adjacent inline code does not suppress outside references', async () => { + const uri = URI.parse('file:///test/prompt-adjacent.md'); + const content = [ + '---', + 'description: "test"', + '---', + '`code`#tool:attached `more`[link](./file.md)', + ].join('\n'); + + const result = new PromptFileParser().parse(uri, content); + assert.ok(result.body); + // #tool:attached starts right after the closing backtick, so it's outside inline code + assert.deepEqual(result.body.variableReferences.map(r => r.name), ['attached']); + // [link](./file.md) starts after the second inline code span + assert.deepEqual(result.body.fileReferences.map(r => ({ content: r.content, isMarkdownLink: r.isMarkdownLink })), [ + { content: './file.md', isMarkdownLink: true }, + ]); + }); + + test('indented fenced code block is still detected', async () => { + const uri = URI.parse('file:///test/prompt-indent.md'); + const content = [ + '---', + 'description: "test"', + '---', + ' ```ts', + ' #tool:ignored', + ' ```', + '#tool:visible', + ].join('\n'); + + const result = new PromptFileParser().parse(uri, content); + assert.ok(result.body); + assert.deepEqual(result.body.variableReferences.map(r => r.name), ['visible']); + }); + + test('fenced code block with 4 backticks', async () => { + const uri = URI.parse('file:///test/prompt-4tick.md'); + const content = [ + '---', + 'description: "test"', + '---', + '````', + '#tool:ignored and [link](./ignored.md)', + '````', + '#tool:visible', + ].join('\n'); + + const result = new PromptFileParser().parse(uri, content); + assert.ok(result.body); + assert.deepEqual(result.body.variableReferences.map(r => r.name), ['visible']); + assert.deepEqual(result.body.fileReferences, []); + }); + + test('fenced code block with tilde fence (~~~)', async () => { + const uri = URI.parse('file:///test/prompt-tilde.md'); + const content = [ + '---', + 'description: "test"', + '---', + '~~~', + '#file:./ignored.md and [link](./ignored-link.md)', + '#tool:ignored', + '~~~', + '[real](./real.md)', + ].join('\n'); + + const result = new PromptFileParser().parse(uri, content); + assert.ok(result.body); + assert.deepEqual(result.body.fileReferences.map(r => ({ content: r.content, isMarkdownLink: r.isMarkdownLink })), [ + { content: './real.md', isMarkdownLink: true }, + ]); + assert.deepEqual(result.body.variableReferences, []); + }); + test('agent with agents', async () => { const uri = URI.parse('file:///test/test.agent.md'); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 59673c29689fd..90e6605a00dd3 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -3506,6 +3506,7 @@ suite('PromptsService', () => { uri: URI.file(path), enabled, setEnabled: () => { }, + remove: () => { }, hooks, commands, skills, diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts index 49743c1f10a06..27cd47200856f 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServer.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -1087,7 +1087,7 @@ export class McpTool implements IMcpTool { arguments: params, task: shouldUseTask ? {} : undefined, _meta: meta, - }, token); + }, token, progress ? (message) => progress.report({ message }) : undefined); // Wait for tools to refresh for dynamic servers (#261611) await this._server.awaitToolRefresh(); diff --git a/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts b/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts index 218f3ab72c888..55c0519c291e5 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts @@ -514,11 +514,11 @@ export class McpServerRequestHandler extends Disposable { /** * Call a specific tool. Supports tasks automatically if `task` is set on the request. */ - async callTool(params: MCP.CallToolRequest['params'] & MCP.Request['params'], token?: CancellationToken): Promise { + async callTool(params: MCP.CallToolRequest['params'] & MCP.Request['params'], token?: CancellationToken, onStatusMessage?: (message: string) => void): Promise { const response = await this.sendRequest({ method: 'tools/call', params }, token); if (isTaskResult(response)) { - const task = new McpTask(response.task, token); + const task = new McpTask(response.task, token, onStatusMessage); this._taskManager.adoptClientTask(task); task.setHandler(this); return task.result.finally(() => { @@ -603,7 +603,8 @@ export class McpTask extends Disposable implements IMcpTas constructor( private readonly _task: MCP.Task, - _token: CancellationToken = CancellationToken.None + _token: CancellationToken = CancellationToken.None, + private readonly _onStatusMessage?: (message: string) => void, ) { super(); @@ -735,6 +736,9 @@ export class McpTask extends Disposable implements IMcpTas onDidUpdateState(task: MCP.Task) { this._lastTaskState.set(task, undefined); + if (task.statusMessage && this._onStatusMessage) { + this._onStatusMessage(task.statusMessage); + } } setHandler(handler: McpServerRequestHandler | undefined): void { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts index 54d5a7899a809..8b1bcca004799 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts @@ -160,17 +160,10 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb : {}; const configFileUri = URI.joinPath(this._tempDir, `vscode-sandbox-settings-${this._sandboxSettingsId}.json`); - const allowedDomainsSet = new Set(networkSetting.allowedDomains ?? []); + let allowedDomains = networkSetting.allowedDomains ?? []; if (networkSetting.allowTrustedDomains) { - for (const domain of this._trustedDomainService.trustedDomains) { - // Filter out sole wildcard '*' as sandbox runtime doesn't allow it - // Wildcards like '*.github.com' are OK - if (domain !== '*') { - allowedDomainsSet.add(domain); - } - } + allowedDomains = this._addTrustedDomainsToAllowedDomains(allowedDomains); } - const allowedDomains = Array.from(allowedDomainsSet); const sandboxSettings = { network: { @@ -211,4 +204,19 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb } } } + + private _addTrustedDomainsToAllowedDomains(allowedDomains: string[]): string[] { + const allowedDomainsSet = new Set(allowedDomains); + for (const domain of this._trustedDomainService.trustedDomains) { + try { + const uri = new URL(domain); + allowedDomainsSet.add(uri.hostname); + } catch { + if (domain !== '*') { + allowedDomainsSet.add(domain); + } + } + } + return Array.from(allowedDomainsSet); + } }