diff --git a/src/vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer.ts index 029fa947dda..43368ffd247 100644 --- a/src/vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer.ts @@ -14,8 +14,16 @@ import { ILogService } from 'vs/platform/log/common/log'; import { IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; import { ChatRequestAgentPart, ChatRequestDynamicVariablePart, ChatRequestTextPart, IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes'; import { contentRefUrl } from '../common/annotations'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { h } from 'vs/base/browser/dom'; +import { FileAccess } from 'vs/base/common/network'; +import { ThemeIcon } from 'vs/base/common/themables'; +import { localize } from 'vs/nls'; +import { showExtensionsWithIdsCommandId } from 'vs/workbench/contrib/extensions/browser/extensionsActions'; const variableRefUrl = 'http://_vscodedecoration_'; +const agentRefUrl = 'http://_chatagent_'; export class ChatMarkdownDecorationsRenderer { constructor( @@ -23,6 +31,7 @@ export class ChatMarkdownDecorationsRenderer { @ILabelService private readonly labelService: ILabelService, @ILogService private readonly logService: ILogService, @IChatAgentService private readonly chatAgentService: IChatAgentService, + @IHoverService private readonly hoverService: IHoverService, ) { } convertParsedRequestToMarkdown(parsedRequest: IParsedChatRequest): string { @@ -30,6 +39,14 @@ export class ChatMarkdownDecorationsRenderer { for (const part of parsedRequest.parts) { if (part instanceof ChatRequestTextPart) { result += part.text; + } else if (part instanceof ChatRequestAgentPart) { + let text = part.text; + const isDupe = this.chatAgentService.getAgentsByName(part.agent.name).length > 1; + if (isDupe) { + text += ` (${part.agent.extensionPublisher})`; + } + + result += `[${text}](${agentRefUrl}?${encodeURIComponent(part.agent.id)})`; } else { const uri = part instanceof ChatRequestDynamicVariablePart && part.data.map(d => d.value).find((d): d is URI => d instanceof URI) || undefined; @@ -37,14 +54,7 @@ export class ChatMarkdownDecorationsRenderer { part instanceof ChatRequestAgentPart ? part.agent.id : ''; - let text = part.text; - if (part instanceof ChatRequestAgentPart) { - const isDupe = this.chatAgentService.getAgentsByName(part.agent.name).length > 1; - if (isDupe) { - text += ` (${part.agent.extensionPublisher})`; - } - } - + const text = part.text; result += `[${text}](${variableRefUrl}?${title})`; } } @@ -56,7 +66,12 @@ export class ChatMarkdownDecorationsRenderer { element.querySelectorAll('a').forEach(a => { const href = a.getAttribute('data-href'); if (href) { - if (href.startsWith(variableRefUrl)) { + if (href.startsWith(agentRefUrl)) { + const title = decodeURIComponent(href.slice(agentRefUrl.length + 1)); + a.parentElement!.replaceChild( + this.renderAgentWidget(a.textContent!, title), + a); + } else if (href.startsWith(variableRefUrl)) { const title = decodeURIComponent(href.slice(variableRefUrl.length + 1)); a.parentElement!.replaceChild( this.renderResourceWidget(a.textContent!, title), @@ -70,6 +85,58 @@ export class ChatMarkdownDecorationsRenderer { }); } + private renderAgentWidget(name: string, id: string): HTMLElement { + const agent = this.chatAgentService.getAgent(id)!; + + const container = dom.$('span.chat-resource-widget'); + const alias = dom.$('span', undefined, name); + + const hoverElement = h( + '.chat-agent-hover@root', + [ + h('.chat-agent-hover-header', [ + h('.chat-agent-hover-icon@icon'), + h('.chat-agent-hover-details', [ + h('.chat-agent-hover-name@name'), + h('.chat-agent-hover-extension', [ + h('.chat-agent-hover-extension-name@extensionName'), + h('.chat-agent-hover-separator@separator'), + h('.chat-agent-hover-publisher@publisher'), + ]), + ]), + ]), + h('.chat-agent-hover-description@description'), + ]); + + if (agent.metadata.icon instanceof URI) { + const avatarIcon = dom.$('img.icon'); + avatarIcon.src = FileAccess.uriToBrowserUri(agent.metadata.icon).toString(true); + hoverElement.icon.replaceChildren(dom.$('.avatar', undefined, avatarIcon)); + } else if (agent.metadata.themeIcon) { + const avatarIcon = dom.$(ThemeIcon.asCSSSelector(agent.metadata.themeIcon)); + hoverElement.icon.replaceChildren(dom.$('.avatar.codicon-avatar', undefined, avatarIcon)); + } + + hoverElement.name.textContent = `@${agent.name}`; + hoverElement.extensionName.textContent = agent.extensionDisplayName; + hoverElement.separator.textContent = ' | '; + hoverElement.publisher.textContent = agent.extensionPublisher; + + const description = agent.description && !agent.description.endsWith('.') ? + `${agent.description}. ` : + (agent.description || ''); + hoverElement.description.textContent = description; + + const marketplaceLink = document.createElement('a'); + marketplaceLink.setAttribute('href', `command:${showExtensionsWithIdsCommandId}?${encodeURIComponent(JSON.stringify([agent.extensionId.value]))}`); + marketplaceLink.textContent = localize('marketplaceLabel', "View in Marketplace") + '.'; + hoverElement.description.appendChild(marketplaceLink); + + this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('element'), container, hoverElement.root); + container.appendChild(alias); + return container; + } + private renderFileWidget(href: string, a: HTMLAnchorElement): void { // TODO this can be a nicer FileLabel widget with an icon. Do a simple link for now. const fullUri = URI.parse(href); diff --git a/src/vs/workbench/contrib/chat/browser/chatParticipantContributions.ts b/src/vs/workbench/contrib/chat/browser/chatParticipantContributions.ts index 47eb784a351..f977169991e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatParticipantContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatParticipantContributions.ts @@ -203,6 +203,7 @@ export class ChatExtensionPointHandler implements IWorkbenchContribution { { extensionId: extension.description.identifier, extensionPublisher: extension.description.publisherDisplayName ?? extension.description.publisher, // May not be present in OSS + extensionDisplayName: extension.description.displayName ?? extension.description.name, // ? id: providerDescriptor.id, description: providerDescriptor.description, metadata: { diff --git a/src/vs/workbench/contrib/chat/browser/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/chatWidget.ts index f68e047bfde..abc721bc583 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWidget.ts @@ -14,6 +14,7 @@ import { isEqual } from 'vs/base/common/resources'; import { isDefined } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; import 'vs/css!./media/chat'; +import 'vs/css!./media/chatHover'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; import { MenuId } from 'vs/platform/actions/common/actions'; diff --git a/src/vs/workbench/contrib/chat/browser/media/chat.css b/src/vs/workbench/contrib/chat/browser/media/chat.css index 4360f8f1cc2..911ef890922 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/media/chat.css @@ -101,7 +101,7 @@ width: 24px; height: 24px; border-radius: 50%; - outline: 1px solid var(--vscode-chat-requestBorder) + outline: 1px solid var(--vscode-chat-requestBorder); } .interactive-item-container .header .avatar.codicon-avatar { diff --git a/src/vs/workbench/contrib/chat/browser/media/chatHover.css b/src/vs/workbench/contrib/chat/browser/media/chatHover.css new file mode 100644 index 00000000000..01f68c6a84b --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/media/chatHover.css @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.chat-agent-hover { + line-height: unset; + padding: 6px 0px; +} + +.chat-agent-hover-header { + display: flex; + gap: 5px; +} + +.chat-agent-hover-icon img, +.chat-agent-hover-icon .codicon { + width: 32px; + height: 32px; + border-radius: 50%; + outline: 1px solid var(--vscode-chat-requestBorder); +} + +.chat-agent-hover-icon .codicon { + font-size: 23px; + display: flex; + justify-content: center; + align-items: center; +} + +.chat-agent-hover-header .chat-agent-hover-name { + font-size: 15px; + font-weight: 600; +} + +.chat-agent-hover-extension { + display: flex; +} + +.chat-agent-hover-separator { + opacity: 0.7; + margin: 0px 6px; +} + +.chat-agent-hover-description { + font-size: 13px; +} diff --git a/src/vs/workbench/contrib/chat/common/chatAgents.ts b/src/vs/workbench/contrib/chat/common/chatAgents.ts index 15413850dd2..7692b56d8c7 100644 --- a/src/vs/workbench/contrib/chat/common/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/chatAgents.ts @@ -52,6 +52,7 @@ export interface IChatAgentData { description?: string; extensionId: ExtensionIdentifier; extensionPublisher: string; + extensionDisplayName: string; /** The agent invoked when no agent is specified */ isDefault?: boolean; metadata: IChatAgentMetadata; @@ -317,6 +318,7 @@ export class MergedChatAgent implements IChatAgent { get description(): string { return this.data.description ?? ''; } get extensionId(): ExtensionIdentifier { return this.data.extensionId; } get extensionPublisher(): string { return this.data.extensionPublisher; } + get extensionDisplayName(): string { return this.data.extensionDisplayName; } get isDefault(): boolean | undefined { return this.data.isDefault; } get metadata(): IChatAgentMetadata { return this.data.metadata; } get slashCommands(): IChatAgentCommand[] { return this.data.slashCommands; } diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts index 182b4183d18..f5144870fa7 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts @@ -2975,7 +2975,8 @@ CommandsRegistry.registerCommand('workbench.extensions.action.showExtensionsForL }); }); -CommandsRegistry.registerCommand('workbench.extensions.action.showExtensionsWithIds', function (accessor: ServicesAccessor, extensionIds: string[]) { +export const showExtensionsWithIdsCommandId = 'workbench.extensions.action.showExtensionsWithIds'; +CommandsRegistry.registerCommand(showExtensionsWithIdsCommandId, function (accessor: ServicesAccessor, extensionIds: string[]) { const paneCompositeService = accessor.get(IPaneCompositePartService); return paneCompositeService.openPaneComposite(VIEWLET_ID, ViewContainerLocation.Sidebar, true)