diff --git a/package-lock.json b/package-lock.json index afa203c83..797d10430 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "ISC", "devDependencies": { "@cloudflare/workers-types": "^4.20250109.0", - "@github1s/vscode-web": "^0.30.1", + "@github1s/vscode-web": "^0.30.2", "chokidar": "^4.0.3", "clean-css": "^5.3.3", "copy-webpack-plugin": "^14.0.0", @@ -251,9 +251,9 @@ } }, "node_modules/@github1s/vscode-web": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/@github1s/vscode-web/-/vscode-web-0.30.1.tgz", - "integrity": "sha512-W4vzRSdqc5UcYbAA9fgfTd2KJerNthoY6Z/J34x7Qla9TGbLCJmiheCbzKG2KG3ku1N40XNmK7H+POL6waL67A==", + "version": "0.30.2", + "resolved": "https://registry.npmjs.org/@github1s/vscode-web/-/vscode-web-0.30.2.tgz", + "integrity": "sha512-k4xjaMbGK1dhn6EmZSbm9o68i/8yqClC3T24EhIcb6Drago+smgpx+NdkLo9aGDrfjvF5bJZlfWV+XcVZqfa5g==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 81c533a46..c85dbb12e 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "license": "ISC", "devDependencies": { "@cloudflare/workers-types": "^4.20250109.0", - "@github1s/vscode-web": "^0.30.1", + "@github1s/vscode-web": "^0.30.2", "chokidar": "^4.0.3", "clean-css": "^5.3.3", "copy-webpack-plugin": "^14.0.0", diff --git a/src/config.ts b/src/config.ts index 1312a0ce9..1e7561548 100644 --- a/src/config.ts +++ b/src/config.ts @@ -34,6 +34,7 @@ const createConfigurationDefaults = (disableSomeAnyCodeFeatures: boolean) => { 'workbench.startupEditor': 'readme', 'workbench.editorAssociations': { '*.md': 'vscode.markdown.preview.editor' }, 'markdown.preview.doubleClickToSwitchToEditor': false, + 'chat.disableAIFeatures': true, } as Record; // disable some anycode features when we can use sourcegraph instead diff --git a/vscode-web/package-lock.json b/vscode-web/package-lock.json index 950a9662b..9faca8ef3 100644 --- a/vscode-web/package-lock.json +++ b/vscode-web/package-lock.json @@ -1,12 +1,12 @@ { "name": "@github1s/vscode-web", - "version": "0.30.1", + "version": "0.30.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@github1s/vscode-web", - "version": "0.30.1", + "version": "0.30.2", "license": "MIT", "devDependencies": { "chokidar": "^4.0.3", diff --git a/vscode-web/package.json b/vscode-web/package.json index db106c6e5..e64cf694b 100644 --- a/vscode-web/package.json +++ b/vscode-web/package.json @@ -1,6 +1,6 @@ { "name": "@github1s/vscode-web", - "version": "0.30.1", + "version": "0.30.2", "description": "VS Code web for GitHub1s", "author": "github1s", "license": "MIT", diff --git a/vscode-web/scripts/.patch b/vscode-web/scripts/.patch index 6771478ab..f4246ba24 100644 --- a/vscode-web/scripts/.patch +++ b/vscode-web/scripts/.patch @@ -6,5 +6,6 @@ "src/vs/workbench/services/extensionManagement/browser/builtinExtensionsScannerService.ts": "16fc1f8830432097a2de87ba04f9f11e930408df8f672bb7a4bbbe3c1a7c509d", "src/vs/workbench/services/label/common/labelService.ts": "9e4e2aafeabc04ccf183b8f2f4ce8a4d39f240831e4005b06149684458caa8d4", "src/vs/workbench/services/textfile/browser/textFileService.ts": "886566605815367d3866be243994829729fd0b85c04ab7450b9de22ee93053eb", - "src/vs/base/common/network.ts": "d264af1ba5dbbfaf950c5da336100fb1242802eab92ed3b4692d3c10282b2dee" + "src/vs/base/common/network.ts": "d264af1ba5dbbfaf950c5da336100fb1242802eab92ed3b4692d3c10282b2dee", + "src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts": "f77c8e0d95ff5e0b15f0ea5135f3b454ee6b12dbf07c793ae3e053ead954e47a" } \ No newline at end of file diff --git a/vscode-web/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts b/vscode-web/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts new file mode 100644 index 000000000..374860cf7 --- /dev/null +++ b/vscode-web/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts @@ -0,0 +1,422 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { coalesce, isNonEmptyArray } from '../../../../base/common/arrays.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { toErrorMessage } from '../../../../base/common/errorMessage.js'; +import { Event } from '../../../../base/common/event.js'; +import { createCommandUri, MarkdownString } from '../../../../base/common/htmlContent.js'; +import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; +import * as strings from '../../../../base/common/strings.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { ExtensionIdentifier, IExtensionManifest } from '../../../../platform/extensions/common/extensions.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; +import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { IViewContainersRegistry, IViewDescriptor, IViewsRegistry, ViewContainer, ViewContainerLocation, Extensions as ViewExtensions } from '../../../common/views.js'; +import { Extensions, IExtensionFeaturesRegistry, IExtensionFeatureTableRenderer, IRenderedData, IRowData, ITableData } from '../../../services/extensionManagement/common/extensionFeatures.js'; +import { isProposedApiEnabled } from '../../../services/extensions/common/extensions.js'; +import * as extensionsRegistry from '../../../services/extensions/common/extensionsRegistry.js'; +import { showExtensionsWithIdsCommandId } from '../../extensions/browser/extensionsActions.js'; +import { IExtension, IExtensionsWorkbenchService } from '../../extensions/common/extensions.js'; +import { IChatAgentData, IChatAgentService } from '../common/participants/chatAgents.js'; +import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; +import { IRawChatParticipantContribution } from '../common/participants/chatParticipantContribTypes.js'; +import { ChatAgentLocation, ChatModeKind } from '../common/constants.js'; +import { ChatViewId, ChatViewContainerId } from './chat.js'; +import { ChatViewPane } from './widgetHosts/viewPane/chatViewPane.js'; + +// --- Chat Container & View Registration + +const chatViewIcon = registerIcon('chat-view-icon', Codicon.chatSparkle, localize('chatViewIcon', 'View icon of the chat view.')); + +const chatViewContainer: ViewContainer = Registry.as(ViewExtensions.ViewContainersRegistry).registerViewContainer({ + id: ChatViewContainerId, + title: localize2('chat.viewContainer.label', "Chat"), + icon: chatViewIcon, + ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [ChatViewContainerId, { mergeViewWithContainerWhenSingleView: true }]), + storageId: ChatViewContainerId, + hideIfEmpty: true, + order: 1, +}, ViewContainerLocation.AuxiliaryBar, { isDefault: true, doNotRegisterOpenCommand: true }); + +const chatViewDescriptor: IViewDescriptor = { + id: ChatViewId, + containerIcon: chatViewContainer.icon, + containerTitle: chatViewContainer.title.value, + singleViewPaneContainerTitle: chatViewContainer.title.value, + name: localize2('chat.viewContainer.label', "Chat"), + canToggleVisibility: false, + canMoveView: true, + openCommandActionDescriptor: { + id: ChatViewContainerId, + title: chatViewContainer.title, + mnemonicTitle: localize({ key: 'miToggleChat', comment: ['&& denotes a mnemonic'] }, "&&Chat"), + keybindings: { + primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyI, + mac: { + primary: KeyMod.CtrlCmd | KeyMod.WinCtrl | KeyCode.KeyI + } + }, + order: 1 + }, + ctorDescriptor: new SyncDescriptor(ChatViewPane), + when: ContextKeyExpr.and( + /* below codes are changed by github1s */ + ContextKeyExpr.notEquals('config.chat.disableAIFeatures', true), + /* above codes are changed by github1s */ + ChatContextKeys.accountPolicyGateActive.negate(), + ContextKeyExpr.or( + ContextKeyExpr.and( + ChatContextKeys.Setup.hidden.negate(), + ChatContextKeys.Setup.disabledInWorkspace.negate(), + ), + ChatContextKeys.panelParticipantRegistered, + ChatContextKeys.extensionInvalid + ) + ) +}; +Registry.as(ViewExtensions.ViewsRegistry).registerViews([chatViewDescriptor], chatViewContainer); + +const chatParticipantExtensionPoint = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint({ + extensionPoint: 'chatParticipants', + jsonSchema: { + description: localize('vscode.extension.contributes.chatParticipant', 'Contributes a chat participant'), + type: 'array', + items: { + additionalProperties: false, + type: 'object', + defaultSnippets: [{ body: { name: '', description: '' } }], + required: ['name', 'id'], + properties: { + id: { + description: localize('chatParticipantId', "A unique id for this chat participant."), + type: 'string' + }, + name: { + description: localize('chatParticipantName', "User-facing name for this chat participant. The user will use '@' with this name to invoke the participant. Name must not contain whitespace."), + type: 'string', + pattern: '^[\\w-]+$' + }, + fullName: { + markdownDescription: localize('chatParticipantFullName', "The full name of this chat participant, which is shown as the label for responses coming from this participant. If not provided, {0} is used.", '`name`'), + type: 'string' + }, + description: { + description: localize('chatParticipantDescription', "A description of this chat participant, shown in the UI."), + type: 'string' + }, + isSticky: { + description: localize('chatCommandSticky', "Whether invoking the command puts the chat into a persistent mode, where the command is automatically added to the chat input for the next message."), + type: 'boolean' + }, + sampleRequest: { + description: localize('chatSampleRequest', "When the user clicks this participant in `/help`, this text will be submitted to the participant."), + type: 'string' + }, + when: { + description: localize('chatParticipantWhen', "A condition which must be true to enable this participant."), + type: 'string' + }, + disambiguation: { + description: localize('chatParticipantDisambiguation', "Metadata to help with automatically routing user questions to this chat participant."), + type: 'array', + items: { + additionalProperties: false, + type: 'object', + defaultSnippets: [{ body: { category: '', description: '', examples: [] } }], + required: ['category', 'description', 'examples'], + properties: { + category: { + markdownDescription: localize('chatParticipantDisambiguationCategory', "A detailed name for this category, e.g. `workspace_questions` or `web_questions`."), + type: 'string' + }, + description: { + description: localize('chatParticipantDisambiguationDescription', "A detailed description of the kinds of questions that are suitable for this chat participant."), + type: 'string' + }, + examples: { + description: localize('chatParticipantDisambiguationExamples', "A list of representative example questions that are suitable for this chat participant."), + type: 'array' + }, + } + } + }, + commands: { + markdownDescription: localize('chatCommandsDescription', "Commands available for this chat participant, which the user can invoke with a `/`."), + type: 'array', + items: { + additionalProperties: false, + type: 'object', + defaultSnippets: [{ body: { name: '', description: '' } }], + required: ['name'], + properties: { + name: { + description: localize('chatCommand', "A short name by which this command is referred to in the UI, e.g. `fix` or `explain` for commands that fix an issue or explain code. The name should be unique among the commands provided by this participant."), + type: 'string' + }, + description: { + description: localize('chatCommandDescription', "A description of this command."), + type: 'string' + }, + when: { + description: localize('chatCommandWhen', "A condition which must be true to enable this command."), + type: 'string' + }, + sampleRequest: { + description: localize('chatCommandSampleRequest', "When the user clicks this command in `/help`, this text will be submitted to the participant."), + type: 'string' + }, + isSticky: { + description: localize('chatCommandSticky', "Whether invoking the command puts the chat into a persistent mode, where the command is automatically added to the chat input for the next message."), + type: 'boolean' + }, + disambiguation: { + description: localize('chatCommandDisambiguation', "Metadata to help with automatically routing user questions to this chat command."), + type: 'array', + items: { + additionalProperties: false, + type: 'object', + defaultSnippets: [{ body: { category: '', description: '', examples: [] } }], + required: ['category', 'description', 'examples'], + properties: { + category: { + markdownDescription: localize('chatCommandDisambiguationCategory', "A detailed name for this category, e.g. `workspace_questions` or `web_questions`."), + type: 'string' + }, + description: { + description: localize('chatCommandDisambiguationDescription', "A detailed description of the kinds of questions that are suitable for this chat command."), + type: 'string' + }, + examples: { + description: localize('chatCommandDisambiguationExamples', "A list of representative example questions that are suitable for this chat command."), + type: 'array' + }, + } + } + } + } + } + }, + } + } + }, + activationEventsGenerator: function* (contributions: readonly IRawChatParticipantContribution[]) { + for (const contrib of contributions) { + yield `onChatParticipant:${contrib.id}`; + } + }, +}); + +export class ChatExtensionPointHandler implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.chatExtensionPointHandler'; + + private _participantRegistrationDisposables = new DisposableMap(); + + constructor( + @IChatAgentService private readonly _chatAgentService: IChatAgentService, + ) { + this.handleAndRegisterChatExtensions(); + } + + private handleAndRegisterChatExtensions(): void { + chatParticipantExtensionPoint.setHandler((extensions, delta) => { + for (const extension of delta.added) { + for (const providerDescriptor of extension.value) { + if (!providerDescriptor.name?.match(/^[\w-]+$/)) { + extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT register participant with invalid name: ${providerDescriptor.name}. Name must match /^[\\w-]+$/.`); + continue; + } + + if (providerDescriptor.fullName && strings.AmbiguousCharacters.getInstance(new Set()).containsAmbiguousCharacter(providerDescriptor.fullName)) { + extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT register participant with fullName that contains ambiguous characters: ${providerDescriptor.fullName}.`); + continue; + } + + // Spaces are allowed but considered "invisible" + if (providerDescriptor.fullName && strings.InvisibleCharacters.containsInvisibleCharacter(providerDescriptor.fullName.replace(/ /g, ''))) { + extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT register participant with fullName that contains invisible characters: ${providerDescriptor.fullName}.`); + continue; + } + + if ((providerDescriptor.isDefault || providerDescriptor.modes) && !isProposedApiEnabled(extension.description, 'defaultChatParticipant')) { + extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT use API proposal: defaultChatParticipant.`); + continue; + } + + if (providerDescriptor.locations && !isProposedApiEnabled(extension.description, 'chatParticipantAdditions')) { + extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT use API proposal: chatParticipantAdditions.`); + continue; + } + + if (!providerDescriptor.id || !providerDescriptor.name) { + extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT register participant without both id and name.`); + continue; + } + + const participantsDisambiguation: { + category: string; + description: string; + examples: string[]; + }[] = []; + + if (providerDescriptor.disambiguation?.length) { + participantsDisambiguation.push(...providerDescriptor.disambiguation.map((d) => ({ + ...d, category: d.category ?? d.categoryName + }))); + } + + try { + const store = new DisposableStore(); + store.add(this._chatAgentService.registerAgent( + providerDescriptor.id, + { + extensionId: extension.description.identifier, + extensionVersion: extension.description.version, + publisherDisplayName: extension.description.publisherDisplayName ?? extension.description.publisher, // May not be present in OSS + extensionPublisherId: extension.description.publisher, + extensionDisplayName: extension.description.displayName ?? extension.description.name, + id: providerDescriptor.id, + description: providerDescriptor.description, + when: providerDescriptor.when, + metadata: { + isSticky: providerDescriptor.isSticky, + sampleRequest: providerDescriptor.sampleRequest, + }, + name: providerDescriptor.name, + fullName: providerDescriptor.fullName, + isDefault: providerDescriptor.isDefault, + locations: isNonEmptyArray(providerDescriptor.locations) ? + providerDescriptor.locations.map(ChatAgentLocation.fromRaw) : + [ChatAgentLocation.Chat], + modes: providerDescriptor.isDefault ? (providerDescriptor.modes ?? [ChatModeKind.Ask]) : [ChatModeKind.Agent, ChatModeKind.Ask, ChatModeKind.Edit], + slashCommands: providerDescriptor.commands ?? [], + disambiguation: coalesce(participantsDisambiguation.flat()), + } satisfies IChatAgentData)); + + this._participantRegistrationDisposables.set( + getParticipantKey(extension.description.identifier, providerDescriptor.id), + store + ); + } catch (e) { + extension.collector.error(`Failed to register participant ${providerDescriptor.id}: ${toErrorMessage(e, true)}`); + } + } + } + + for (const extension of delta.removed) { + for (const providerDescriptor of extension.value) { + this._participantRegistrationDisposables.deleteAndDispose(getParticipantKey(extension.description.identifier, providerDescriptor.id)); + } + } + }); + } +} + +function getParticipantKey(extensionId: ExtensionIdentifier, participantName: string): string { + return `${extensionId.value}_${participantName}`; +} + +export class ChatCompatibilityNotifier extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.chatCompatNotifier'; + + private registeredWelcomeView = false; + + constructor( + @IExtensionsWorkbenchService extensionsWorkbenchService: IExtensionsWorkbenchService, + @IContextKeyService contextKeyService: IContextKeyService, + @IProductService private readonly productService: IProductService, + ) { + super(); + + // It may be better to have some generic UI for this, for any extension that is incompatible, + // but this is only enabled for Chat now and it needs to be obvious. + const isInvalid = ChatContextKeys.extensionInvalid.bindTo(contextKeyService); + this._register(Event.runAndSubscribe( + extensionsWorkbenchService.onDidChangeExtensionsNotification, + () => { + const notification = extensionsWorkbenchService.getExtensionsNotification(); + const chatExtension = notification?.extensions.find(ext => ExtensionIdentifier.equals(ext.identifier.id, this.productService.defaultChatAgent?.chatExtensionId)); + if (chatExtension) { + isInvalid.set(true); + this.registerWelcomeView(chatExtension); + } else { + isInvalid.set(false); + } + } + )); + } + + private registerWelcomeView(chatExtension: IExtension) { + if (this.registeredWelcomeView) { + return; + } + + this.registeredWelcomeView = true; + const showExtensionLabel = localize('showExtension', "Show Extension"); + const mainMessage = localize('chatFailErrorMessage', "Chat failed to load because the installed version of the Copilot Chat extension is not compatible with this version of {0}. Please ensure that the Copilot Chat extension is up to date.", this.productService.nameLong); + const commandButton = `[${showExtensionLabel}](${createCommandUri(showExtensionsWithIdsCommandId, [this.productService.defaultChatAgent?.chatExtensionId])})`; + const versionMessage = `Copilot Chat version: ${chatExtension.version}`; + const viewsRegistry = Registry.as(ViewExtensions.ViewsRegistry); + this._register(viewsRegistry.registerViewWelcomeContent(ChatViewId, { + content: [mainMessage, commandButton, versionMessage].join('\n\n'), + when: ChatContextKeys.extensionInvalid, + })); + } +} + +class ChatParticipantDataRenderer extends Disposable implements IExtensionFeatureTableRenderer { + readonly type = 'table'; + + shouldRender(manifest: IExtensionManifest): boolean { + return !!manifest.contributes?.chatParticipants; + } + + render(manifest: IExtensionManifest): IRenderedData { + const nonDefaultContributions = manifest.contributes?.chatParticipants?.filter(c => !c.isDefault) ?? []; + if (!nonDefaultContributions.length) { + return { data: { headers: [], rows: [] }, dispose: () => { } }; + } + + const headers = [ + localize('participantName', "Name"), + localize('participantFullName', "Full Name"), + localize('participantDescription', "Description"), + localize('participantCommands', "Commands"), + ]; + + const rows: IRowData[][] = nonDefaultContributions.map(d => { + return [ + '@' + d.name, + d.fullName, + d.description ?? '-', + d.commands?.length ? new MarkdownString(d.commands.map(c => `- /` + c.name).join('\n')) : '-' + ]; + }); + + return { + data: { + headers, + rows + }, + dispose: () => { } + }; + } +} + +Registry.as(Extensions.ExtensionFeaturesRegistry).registerExtensionFeature({ + id: 'chatParticipants', + label: localize('chatParticipants', "Chat Participants"), + access: { + canToggle: false + }, + renderer: new SyncDescriptor(ChatParticipantDataRenderer), +});