diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 2a7f17fce61..31fa610d64c 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -44,6 +44,9 @@ const _allApiProposals = { chatReferenceDiagnostic: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatReferenceDiagnostic.d.ts', }, + chatStatusItem: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts', + }, chatTab: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatTab.d.ts', }, diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index f0bf62c67ff..7a778aa030b 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -89,6 +89,7 @@ import './mainThreadProfileContentHandlers.js'; import './mainThreadAiRelatedInformation.js'; import './mainThreadAiEmbeddingVector.js'; import './mainThreadMcp.js'; +import './mainThreadChatStatus.js'; export class ExtensionPoints implements IWorkbenchContribution { diff --git a/src/vs/workbench/api/browser/mainThreadChatStatus.ts b/src/vs/workbench/api/browser/mainThreadChatStatus.ts new file mode 100644 index 00000000000..e2b8cd24fc6 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadChatStatus.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../base/common/lifecycle.js'; +import { IChatStatusItemService } from '../../contrib/chat/browser/chatStatusItemService.js'; +import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; +import { ChatStatusItemDto, MainContext, MainThreadChatStatusShape } from '../common/extHost.protocol.js'; + +@extHostNamedCustomer(MainContext.MainThreadChatStatus) +export class MainThreadChatStatus extends Disposable implements MainThreadChatStatusShape { + + constructor( + _extHostContext: IExtHostContext, + @IChatStatusItemService private readonly _chatStatusItemService: IChatStatusItemService, + ) { + super(); + } + + $setEntry(id: string, entry: ChatStatusItemDto): void { + this._chatStatusItemService.setOrUpdateEntry({ + id, + label: entry.title, + description: entry.description, + detail: entry.detail, + }); + } + + $disposeEntry(id: string): void { + this._chatStatusItemService.deleteEntry(id); + } +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 6d9aee51c46..877769c14e3 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import type * as vscode from 'vscode'; import { CancellationTokenSource } from '../../../base/common/cancellation.js'; import * as errors from '../../../base/common/errors.js'; import { Emitter, Event } from '../../../base/common/event.js'; @@ -21,6 +22,12 @@ import { ILogService, ILoggerService, LogLevel } from '../../../platform/log/com import { getRemoteName } from '../../../platform/remote/common/remoteHosts.js'; import { TelemetryTrustedValue } from '../../../platform/telemetry/common/telemetryUtils.js'; import { EditSessionIdentityMatch } from '../../../platform/workspace/common/editSessions.js'; +import { DebugConfigurationProviderTriggerKind } from '../../contrib/debug/common/debug.js'; +import { ExtensionDescriptionRegistry } from '../../services/extensions/common/extensionDescriptionRegistry.js'; +import { UIKind } from '../../services/extensions/common/extensionHostProtocol.js'; +import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; +import { ProxyIdentifier } from '../../services/extensions/common/proxyIdentifier.js'; +import { ExcludeSettingOptions, TextSearchCompleteMessageType, TextSearchContext2, TextSearchMatch2 } from '../../services/search/common/searchExtTypes.js'; import { CandidatePortSource, ExtHostContext, ExtHostLogLevelServiceShape, MainContext } from './extHost.protocol.js'; import { ExtHostRelatedInformation } from './extHostAiRelatedInformation.js'; import { ExtHostApiCommands } from './extHostApiCommands.js'; @@ -28,8 +35,10 @@ import { IExtHostApiDeprecationService } from './extHostApiDeprecationService.js import { IExtHostAuthentication } from './extHostAuthentication.js'; import { ExtHostBulkEdits } from './extHostBulkEdits.js'; import { ExtHostChatAgents2 } from './extHostChatAgents2.js'; +import { ExtHostChatStatus } from './extHostChatStatus.js'; import { ExtHostClipboard } from './extHostClipboard.js'; import { ExtHostEditorInsets } from './extHostCodeInsets.js'; +import { ExtHostCodeMapper } from './extHostCodeMapper.js'; import { IExtHostCommands } from './extHostCommands.js'; import { createExtHostComments } from './extHostComments.js'; import { ExtHostConfigProvider, IExtHostConfiguration } from './extHostConfiguration.js'; @@ -59,6 +68,7 @@ import { IExtHostLanguageModels } from './extHostLanguageModels.js'; import { ExtHostLanguages } from './extHostLanguages.js'; import { IExtHostLocalizationService } from './extHostLocalizationService.js'; import { IExtHostManagedSockets } from './extHostManagedSockets.js'; +import { IExtHostMpcService } from './extHostMcp.js'; import { ExtHostMessageService } from './extHostMessageService.js'; import { ExtHostNotebookController } from './extHostNotebook.js'; import { ExtHostNotebookDocumentSaveParticipant } from './extHostNotebookDocumentSaveParticipant.js'; @@ -100,15 +110,6 @@ import { ExtHostWebviewPanels } from './extHostWebviewPanels.js'; import { ExtHostWebviewViews } from './extHostWebviewView.js'; import { IExtHostWindow } from './extHostWindow.js'; import { IExtHostWorkspace } from './extHostWorkspace.js'; -import { DebugConfigurationProviderTriggerKind } from '../../contrib/debug/common/debug.js'; -import { ExtensionDescriptionRegistry } from '../../services/extensions/common/extensionDescriptionRegistry.js'; -import { UIKind } from '../../services/extensions/common/extensionHostProtocol.js'; -import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; -import { ProxyIdentifier } from '../../services/extensions/common/proxyIdentifier.js'; -import { ExcludeSettingOptions, TextSearchCompleteMessageType, TextSearchContext2, TextSearchMatch2 } from '../../services/search/common/searchExtTypes.js'; -import type * as vscode from 'vscode'; -import { ExtHostCodeMapper } from './extHostCodeMapper.js'; -import { IExtHostMpcService } from './extHostMcp.js'; export interface IExtensionRegistries { mine: ExtensionDescriptionRegistry; @@ -231,6 +232,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostClipboard = new ExtHostClipboard(rpcProtocol); const extHostMessageService = new ExtHostMessageService(rpcProtocol, extHostLogService); const extHostDialogs = new ExtHostDialogs(rpcProtocol); + const extHostChatStatus = new ExtHostChatStatus(rpcProtocol); // Register API-ish commands ExtHostApiCommands.register(extHostCommands); @@ -929,7 +931,11 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I get nativeHandle(): Uint8Array | undefined { checkProposedApiEnabled(extension, 'nativeWindowHandle'); return extHostWindow.nativeHandle; - } + }, + createChatStatusItem: (id: string) => { + checkProposedApiEnabled(extension, 'chatStatusItem'); + return extHostChatStatus.createChatStatusItem(extension, id); + }, }; // namespace: workspace diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index a24df956dff..51f8c0cfc3b 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3059,6 +3059,18 @@ export interface MainThreadTestingShape { $markTestRetired(testIds: string[] | undefined): void; } +export type ChatStatusItemDto = { + id: string; + title: string; + description: string; + detail: string | undefined; +}; + +export interface MainThreadChatStatusShape { + $setEntry(id: string, entry: ChatStatusItemDto): void; + $disposeEntry(id: string): void; +} + // --- proxy identifiers export const MainContext = { @@ -3132,7 +3144,8 @@ export const MainContext = { MainThreadLocalization: createProxyIdentifier('MainThreadLocalizationShape'), MainThreadMcp: createProxyIdentifier('MainThreadMcpShape'), MainThreadAiRelatedInformation: createProxyIdentifier('MainThreadAiRelatedInformation'), - MainThreadAiEmbeddingVector: createProxyIdentifier('MainThreadAiEmbeddingVector') + MainThreadAiEmbeddingVector: createProxyIdentifier('MainThreadAiEmbeddingVector'), + MainThreadChatStatus: createProxyIdentifier('MainThreadChatStatus'), }; export const ExtHostContext = { diff --git a/src/vs/workbench/api/common/extHostChatStatus.ts b/src/vs/workbench/api/common/extHostChatStatus.ts new file mode 100644 index 00000000000..38f22b171d7 --- /dev/null +++ b/src/vs/workbench/api/common/extHostChatStatus.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import * as extHostProtocol from './extHost.protocol.js'; +import { ExtensionIdentifier, IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; + +export class ExtHostChatStatus { + + private readonly _proxy: extHostProtocol.MainThreadChatStatusShape; + + private readonly _items = new Map(); + + constructor( + mainContext: extHostProtocol.IMainContext + ) { + this._proxy = mainContext.getProxy(extHostProtocol.MainContext.MainThreadChatStatus); + } + + createChatStatusItem(extension: IExtensionDescription, id: string): vscode.ChatStatusItem { + const internalId = asChatItemIdentifier(extension.identifier, id); + if (this._items.has(internalId)) { + throw new Error(`Chat status item '${id}' already exists`); + } + + const state: extHostProtocol.ChatStatusItemDto = { + id: internalId, + title: '', + description: '', + detail: '', + }; + + let disposed = false; + let visible = false; + const syncState = () => { + if (disposed) { + throw new Error('Chat status item is disposed'); + } + + if (!visible) { + return; + } + + this._proxy.$setEntry(id, state); + }; + + const item = Object.freeze({ + id: id, + + get title(): string { + return state.title; + }, + set title(value: string) { + state.title = value; + syncState(); + }, + + get description(): string { + return state.description; + }, + set description(value: string) { + state.description = value; + syncState(); + }, + + get detail(): string | undefined { + return state.detail; + }, + set detail(value: string | undefined) { + state.detail = value; + syncState(); + }, + + show: () => { + visible = true; + syncState(); + }, + hide: () => { + visible = false; + this._proxy.$disposeEntry(id); + }, + dispose: () => { + disposed = true; + this._proxy.$disposeEntry(id); + this._items.delete(internalId); + }, + }); + + this._items.set(internalId, item); + return item; + } +} + +function asChatItemIdentifier(extension: ExtensionIdentifier, id: string): string { + return `${ExtensionIdentifier.toKey(extension)}.${id}`; +} + diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus.ts b/src/vs/workbench/contrib/chat/browser/chatStatus.ts index ab23c0c2703..d9f21bc1ff4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus.ts @@ -28,10 +28,13 @@ import { isObject } from '../../../../base/common/types.js'; import { ILanguageService } from '../../../../editor/common/languages/language.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; +import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification } from '../../../../base/common/actions.js'; +import { parseLinkedText } from '../../../../base/common/linkedText.js'; +import { Link } from '../../../../platform/opener/browser/link.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../base/common/actions.js'; - -//#region --- colors +import { IChatStatusItemService, ChatStatusEntry } from './chatStatusItemService.js'; const gaugeBackground = registerColor('gauge.background', { dark: inputValidationInfoBorder, @@ -99,9 +102,9 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu private dashboard = new Lazy(() => this.instantiationService.createInstance(ChatStatusDashboard)); constructor( - @IStatusbarService private readonly statusbarService: IStatusbarService, @IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService, - @IInstantiationService private readonly instantiationService: IInstantiationService + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IStatusbarService private readonly statusbarService: IStatusbarService, ) { super(); @@ -211,11 +214,13 @@ class ChatStatusDashboard extends Disposable { constructor( @IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @IHoverService private readonly hoverService: IHoverService, - @IEditorService private readonly editorService: IEditorService, - @ILanguageService private readonly languageService: ILanguageService, + @IChatStatusItemService private readonly chatStatusItemService: IChatStatusItemService, @ICommandService private readonly commandService: ICommandService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IEditorService private readonly editorService: IEditorService, + @IHoverService private readonly hoverService: IHoverService, + @ILanguageService private readonly languageService: ILanguageService, + @IOpenerService private readonly openerService: IOpenerService, @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); @@ -271,6 +276,29 @@ class ChatStatusDashboard extends Disposable { })(); } + // Contributions + { + for (const item of this.chatStatusItemService.getEntries()) { + addSeparator(undefined); + const chatItemDisposables = disposables.add(new MutableDisposable()); + + let rendered = this.renderContributedChatStatusItem(item); + chatItemDisposables.value = rendered.disposables; + this.element.appendChild(rendered.element); + + disposables.add(this.chatStatusItemService.onDidChange(e => { + if (e.entry.id === item.id) { + const oldEl = rendered.element; + + rendered = this.renderContributedChatStatusItem(e.entry); + chatItemDisposables.value = rendered.disposables; + + oldEl.replaceWith(rendered.element); + } + })); + } + } + // Settings { addSeparator(localize('settingsTitle', "Settings")); @@ -296,6 +324,37 @@ class ChatStatusDashboard extends Disposable { return this.element; } + private renderContributedChatStatusItem(item: ChatStatusEntry): { element: HTMLElement; disposables: DisposableStore } { + const disposables = new DisposableStore(); + + const entryEl = $('div.contribution'); + + entryEl.appendChild($('div.header', undefined, item.label)); + + const bodyEl = entryEl.appendChild($('div.body')); + + const descriptionEl = bodyEl.appendChild($('span.description')); + this._renderTextPlus(descriptionEl, item.description, disposables); + + if (item.detail) { + const itemElement = bodyEl.appendChild($('div.detail-item')); + this._renderTextPlus(itemElement, item.detail, disposables); + } + + return { element: entryEl, disposables }; + } + + private _renderTextPlus(target: HTMLElement, text: string, store: DisposableStore): void { + for (const node of parseLinkedText(text).nodes) { + if (typeof node === 'string') { + const parts = renderLabelWithIcons(node); + target.append(...parts); + } else { + store.add(new Link(target, node, undefined, this.hoverService, this.openerService)); + } + } + } + private runCommandAndClose(commandOrFn: string | Function): void { if (typeof commandOrFn === 'function') { commandOrFn(); diff --git a/src/vs/workbench/contrib/chat/browser/chatStatusItemService.ts b/src/vs/workbench/contrib/chat/browser/chatStatusItemService.ts new file mode 100644 index 00000000000..3f009e58eb0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatStatusItemService.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +export const IChatStatusItemService = createDecorator('IChatStatusItemService'); + +export interface IChatStatusItemService { + readonly _serviceBrand: undefined; + + readonly onDidChange: Event; + + setOrUpdateEntry(entry: ChatStatusEntry): void; + + deleteEntry(id: string): void; + + getEntries(): Iterable; +} + + +export interface IChatStatusItemChangeEvent { + readonly entry: ChatStatusEntry; +} + +export type ChatStatusEntry = { + id: string; + label: string; + description: string; + detail: string | undefined; +}; + + +class ChatStatusItemService implements IChatStatusItemService { + readonly _serviceBrand: undefined; + + private readonly _entries = new Map(); + + private readonly _onDidChange = new Emitter(); + readonly onDidChange = this._onDidChange.event; + + setOrUpdateEntry(entry: ChatStatusEntry): void { + const isUpdate = this._entries.has(entry.id); + this._entries.set(entry.id, entry); + if (isUpdate) { + this._onDidChange.fire({ entry }); + } + } + + deleteEntry(id: string): void { + this._entries.delete(id); + } + + getEntries(): Iterable { + return this._entries.values(); + } +} + +registerSingleton(IChatStatusItemService, ChatStatusItemService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/media/chatStatus.css b/src/vs/workbench/contrib/chat/browser/media/chatStatus.css index 82f35aebb20..081aea5ed2c 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chatStatus.css +++ b/src/vs/workbench/contrib/chat/browser/media/chatStatus.css @@ -111,3 +111,17 @@ .chat-status-bar-entry-tooltip .settings .setting.disabled .setting-label { color: var(--vscode-disabledForeground); } + +/* Contributions */ + +.chat-status-bar-entry-tooltip .contribution .body { + display: flex; + flex-direction: row; + gap: 6px; + color: var(--vscode-descriptionForeground); + + .detail-item { + margin-left: auto; + font-weight: normal; + } +} diff --git a/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts b/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts new file mode 100644 index 00000000000..28ba4157291 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + export interface ChatStatusItem { + /** + * The identifier of this item. + */ + readonly id: string; + + /** + * The main name of the entry, like 'Indexing Status' + */ + title: string; + + /** + * Optional additional description of the entry. + * + * This is rendered after the title. Supports Markdown style links (`[text](http://example.com)`) and rendering of + * {@link ThemeIcon theme icons} via the `$()`-syntax. + */ + description: string; + + /** + * Optional additional details of the entry. + * + * This is rendered less prominently after the title. Supports Markdown style links (`[text](http://example.com)`) and rendering of + * {@link ThemeIcon theme icons} via the `$()`-syntax. + */ + detail: string | undefined; + + /** + * Shows the entry in the chat status. + */ + show(): void; + + /** + * Hide the entry in the chat status. + */ + hide(): void; + + /** + * Dispose and free associated resources + */ + dispose(): void; + } + + namespace window { + /** + * Create a new chat status item. + * + * @param id The unique identifier of the status bar item. + * + * @returns A new chat status item. + */ + export function createChatStatusItem(id: string): ChatStatusItem; + } +}