Make vscode own /help, add API for agents to customize it (#197964)

* Make vscode own /help, add API for agents to customize it
Towards #197081

* Fix build error
This commit is contained in:
Rob Lourens 2023-11-11 16:28:30 -06:00 committed by GitHub
parent 55c1e8473c
commit ca8981c284
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 147 additions and 58 deletions

View file

@ -217,6 +217,9 @@ class ExtHostChatAgent {
private _fullName: string | undefined;
private _iconPath: vscode.Uri | { light: vscode.Uri; dark: vscode.Uri } | vscode.ThemeIcon | undefined;
private _isDefault: boolean | undefined;
private _helpTextPrefix: string | vscode.MarkdownString | undefined;
private _helpTextPostfix: string | vscode.MarkdownString | undefined;
private _sampleRequest?: string;
private _isSecondary: boolean | undefined;
private _onDidReceiveFeedback = new Emitter<vscode.ChatAgentResult2Feedback>();
private _onDidPerformAction = new Emitter<vscode.ChatAgentUserActionEvent>();
@ -259,7 +262,14 @@ class ExtHostChatAgent {
return [];
}
this._lastSlashCommands = result;
return result.map(c => ({ name: c.name, description: c.description, followupPlaceholder: c.followupPlaceholder, shouldRepopulate: c.shouldRepopulate }));
return result
.map(c => ({
name: c.name,
description: c.description,
followupPlaceholder: c.followupPlaceholder,
shouldRepopulate: c.shouldRepopulate,
sampleRequest: c.sampleRequest
}));
}
async provideFollowups(result: vscode.ChatAgentResult2, token: CancellationToken): Promise<IChatFollowup[]> {
@ -300,6 +310,9 @@ class ExtHostChatAgent {
hasFollowup: this._followupProvider !== undefined,
isDefault: this._isDefault,
isSecondary: this._isSecondary,
helpTextPrefix: (!this._helpTextPrefix || typeof this._helpTextPrefix === 'string') ? this._helpTextPrefix : typeConvert.MarkdownString.from(this._helpTextPrefix),
helpTextPostfix: (!this._helpTextPostfix || typeof this._helpTextPostfix === 'string') ? this._helpTextPostfix : typeConvert.MarkdownString.from(this._helpTextPostfix),
sampleRequest: this._sampleRequest,
});
updateScheduled = false;
});
@ -354,6 +367,32 @@ class ExtHostChatAgent {
that._isDefault = v;
updateMetadataSoon();
},
get helpTextPrefix() {
checkProposedApiEnabled(that.extension, 'defaultChatAgent');
return that._helpTextPrefix;
},
set helpTextPrefix(v) {
checkProposedApiEnabled(that.extension, 'defaultChatAgent');
if (!that._isDefault) {
throw new Error('helpTextPrefix is only available on the default chat agent');
}
that._helpTextPrefix = v;
updateMetadataSoon();
},
get helpTextPostfix() {
checkProposedApiEnabled(that.extension, 'defaultChatAgent');
return that._helpTextPostfix;
},
set helpTextPostfix(v) {
checkProposedApiEnabled(that.extension, 'defaultChatAgent');
if (!that._isDefault) {
throw new Error('helpTextPostfix is only available on the default chat agent');
}
that._helpTextPostfix = v;
updateMetadataSoon();
},
get isSecondary() {
checkProposedApiEnabled(that.extension, 'defaultChatAgent');
return that._isSecondary;
@ -363,6 +402,13 @@ class ExtHostChatAgent {
that._isSecondary = v;
updateMetadataSoon();
},
get sampleRequest() {
return that._sampleRequest;
},
set sampleRequest(v) {
that._sampleRequest = v;
updateMetadataSoon();
},
get onDidReceiveFeedback() {
return that._onDidReceiveFeedback.event;
},

View file

@ -8,19 +8,15 @@ import { ServicesAccessor } from 'vs/editor/browser/editorExtensions';
import { localize } from 'vs/nls';
import { Action2, MenuId, registerAction2 } from 'vs/platform/actions/common/actions';
import { CHAT_CATEGORY } from 'vs/workbench/contrib/chat/browser/actions/chatActions';
import { IChatWidget } from 'vs/workbench/contrib/chat/browser/chat';
import { IChatWidget, IChatWidgetService } from 'vs/workbench/contrib/chat/browser/chat';
import { CONTEXT_CHAT_INPUT_HAS_TEXT, CONTEXT_CHAT_REQUEST_IN_PROGRESS } from 'vs/workbench/contrib/chat/common/chatContextKeys';
import { IChatService } from 'vs/workbench/contrib/chat/common/chatService';
export interface IChatExecuteActionContext {
widget: IChatWidget;
widget?: IChatWidget;
inputValue?: string;
}
export function isExecuteActionContext(thing: unknown): thing is IChatExecuteActionContext {
return typeof thing === 'object' && thing !== null && 'widget' in thing;
}
export class SubmitAction extends Action2 {
static readonly ID = 'workbench.action.chat.submit';
@ -44,12 +40,11 @@ export class SubmitAction extends Action2 {
}
run(accessor: ServicesAccessor, ...args: any[]) {
const context = args[0];
if (!isExecuteActionContext(context)) {
return;
}
const context: IChatExecuteActionContext = args[0];
context.widget.acceptInput(context.inputValue);
const widgetService = accessor.get(IChatWidgetService);
const widget = context.widget ?? widgetService.lastFocusedWidget;
widget?.acceptInput(context.inputValue);
}
}
@ -76,8 +71,8 @@ export function registerChatExecuteActions() {
}
run(accessor: ServicesAccessor, ...args: any[]) {
const context = args[0];
if (!isExecuteActionContext(context)) {
const context: IChatExecuteActionContext = args[0];
if (!context.widget) {
return;
}

View file

@ -18,7 +18,7 @@ import { EditorExtensions, IEditorFactoryRegistry } from 'vs/workbench/common/ed
import { registerChatActions } from 'vs/workbench/contrib/chat/browser/actions/chatActions';
import { registerChatCodeBlockActions } from 'vs/workbench/contrib/chat/browser/actions/chatCodeblockActions';
import { registerChatCopyActions } from 'vs/workbench/contrib/chat/browser/actions/chatCopyActions';
import { registerChatExecuteActions } from 'vs/workbench/contrib/chat/browser/actions/chatExecuteActions';
import { IChatExecuteActionContext, SubmitAction, registerChatExecuteActions } from 'vs/workbench/contrib/chat/browser/actions/chatExecuteActions';
import { registerQuickChatActions } from 'vs/workbench/contrib/chat/browser/actions/chatQuickInputActions';
import { registerChatTitleActions } from 'vs/workbench/contrib/chat/browser/actions/chatTitleActions';
import { registerChatExportActions } from 'vs/workbench/contrib/chat/browser/actions/chatImportExport';
@ -45,7 +45,7 @@ import { ChatAccessibilityService } from 'vs/workbench/contrib/chat/browser/chat
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
import { AccessibilityVerbositySettingId, AccessibleViewProviderId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration';
import { ChatWelcomeMessageModel } from 'vs/workbench/contrib/chat/common/chatModel';
import { IMarkdownString } from 'vs/base/common/htmlContent';
import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent';
import { ChatProviderService, IChatProviderService } from 'vs/workbench/contrib/chat/common/chatProvider';
import { ChatSlashCommandService, IChatSlashCommandService } from 'vs/workbench/contrib/chat/common/chatSlashCommands';
import { alertFocusChange } from 'vs/workbench/contrib/accessibility/browser/accessibilityContributions';
@ -56,6 +56,8 @@ import { registerChatFileTreeActions } from 'vs/workbench/contrib/chat/browser/a
import { QuickChatService } from 'vs/workbench/contrib/chat/browser/chatQuick';
import { ChatAgentService, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents';
import { ChatVariablesService } from 'vs/workbench/contrib/chat/browser/chatVariables';
import { chatAgentLeader, chatSubcommandLeader } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { CancellationToken } from 'vs/base/common/cancellation';
// Register configuration
const configurationRegistry = Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration);
@ -221,16 +223,52 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable {
constructor(
@IChatSlashCommandService slashCommandService: IChatSlashCommandService,
@ICommandService commandService: ICommandService,
@IChatAgentService chatAgentService: IChatAgentService,
) {
super();
this._store.add(slashCommandService.registerSlashCommand({
command: 'clear',
detail: nls.localize('clear', "Clear the session"),
sortText: 'z_clear',
sortText: 'z2_clear',
executeImmediately: true
}, async () => {
commandService.executeCommand(ACTION_ID_CLEAR_CHAT);
}));
this._store.add(slashCommandService.registerSlashCommand({
command: 'help',
detail: '',
sortText: 'z1_help',
executeImmediately: true
}, async (prompt, progress) => {
const defaultAgent = chatAgentService.getDefaultAgent();
const agents = chatAgentService.getAgents();
if (defaultAgent?.metadata.helpTextPrefix) {
progress.report({ content: defaultAgent.metadata.helpTextPrefix });
progress.report({ content: '\n\n' });
}
const agentText = (await Promise.all(agents
.filter(a => a.id !== defaultAgent?.id)
.map(async a => {
const agentWithLeader = `${chatAgentLeader}${a.id}`;
const actionArg: IChatExecuteActionContext = { inputValue: `${agentWithLeader} ${a.metadata.sampleRequest}` };
const urlSafeArg = encodeURIComponent(JSON.stringify(actionArg));
const agentLine = `* [\`${agentWithLeader}\`](command:${SubmitAction.ID}?${urlSafeArg}) - ${a.metadata.description}`;
const commands = await a.provideSlashCommands(CancellationToken.None);
const commandText = commands.map(c => {
const actionArg: IChatExecuteActionContext = { inputValue: `${agentWithLeader} ${chatSubcommandLeader}${c.name} ${c.sampleRequest ?? ''}` };
const urlSafeArg = encodeURIComponent(JSON.stringify(actionArg));
return `\t* [\`${chatSubcommandLeader}${c.name}\`](command:${SubmitAction.ID}?${urlSafeArg}) - ${c.description}`;
}).join('\n');
return agentLine + '\n' + commandText;
}))).join('\n');
progress.report({ content: new MarkdownString(agentText, { isTrusted: { enabledCommands: [SubmitAction.ID] } }) });
if (defaultAgent?.metadata.helpTextPostfix) {
progress.report({ content: '\n\n' });
progress.report({ content: defaultAgent.metadata.helpTextPostfix });
}
}));
}
}

View file

@ -5,13 +5,14 @@
import { CancellationToken } from 'vs/base/common/cancellation';
import { Emitter, Event } from 'vs/base/common/event';
import { IMarkdownString } from 'vs/base/common/htmlContent';
import { Iterable } from 'vs/base/common/iterator';
import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { ThemeIcon } from 'vs/base/common/themables';
import { URI } from 'vs/base/common/uri';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { IChatMessage } from 'vs/workbench/contrib/chat/common/chatProvider';
import { IChatFollowup, IChatProgress, IChatResponseErrorDetails, IChatResponseProgressFileTreeData } from 'vs/workbench/contrib/chat/common/chatService';
import { IChatFollowup, IChatProgress, IChatResponseErrorDetails } from 'vs/workbench/contrib/chat/common/chatService';
import { IChatRequestVariableValue } from 'vs/workbench/contrib/chat/common/chatVariables';
//#region agent service, commands etc
@ -27,40 +28,44 @@ export interface IChatAgent extends IChatAgentData {
provideSlashCommands(token: CancellationToken): Promise<IChatAgentCommand[]>;
}
export interface IChatAgentFragment {
content: string | { treeData: IChatResponseProgressFileTreeData };
}
export interface IChatAgentCommand {
name: string;
description: string;
/**
* Whether the command should execute as soon
* as it is entered. Defaults to `false`.
*/
executeImmediately?: boolean;
/**
* Whether executing the command puts the
* chat into a persistent mode, where the
* slash command is prepended to the chat input.
*/
shouldRepopulate?: boolean;
/**
* Placeholder text to render in the chat input
* when the slash command has been repopulated.
* Has no effect if `shouldRepopulate` is `false`.
*/
followupPlaceholder?: string;
sampleRequest?: string;
}
export interface IChatAgentMetadata {
description?: string;
isDefault?: boolean; // The agent invoked when no agent is specified
helpTextPrefix?: string | IMarkdownString;
helpTextPostfix?: string | IMarkdownString;
isSecondary?: boolean; // Invoked by ctrl/cmd+enter
fullName?: string;
icon?: URI;
iconDark?: URI;
themeIcon?: ThemeIcon;
sampleRequest?: string;
}
export interface IChatAgentRequest {

View file

@ -169,7 +169,11 @@ export class Response implements IResponse {
} else if (lastResponsePart) {
// Combine this part with the last, non-resolving string part
if (isMarkdownString(responsePart)) {
this._responseParts[responsePartLength] = { string: new MarkdownString(lastResponsePart.string.value + responsePart.value, responsePart) };
// Merge all enabled commands
const lastPartEnabledCommands = typeof lastResponsePart.string.isTrusted === 'object' ? lastResponsePart.string.isTrusted.enabledCommands : [];
const thisPartEnabledCommands = typeof responsePart.isTrusted === 'object' ? responsePart.isTrusted.enabledCommands : [];
const enabledCommands = [...lastPartEnabledCommands, ...thisPartEnabledCommands];
this._responseParts[responsePartLength] = { string: new MarkdownString(lastResponsePart.string.value + responsePart.value, { isTrusted: { enabledCommands } }) };
} else {
this._responseParts[responsePartLength] = { string: new MarkdownString(lastResponsePart.string.value + responsePart, lastResponsePart.string) };
}

View file

@ -28,7 +28,7 @@ import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashC
import { ChatMessageRole, IChatMessage } from 'vs/workbench/contrib/chat/common/chatProvider';
import { ChatRequestParser } from 'vs/workbench/contrib/chat/common/chatRequestParser';
import { IChat, IChatCompleteResponse, IChatDetail, IChatDynamicRequest, IChatFollowup, IChatProgress, IChatProvider, IChatProviderInfo, IChatRequest, IChatResponse, IChatService, IChatTransferredSessionData, IChatUserActionEvent, ISlashCommand, InteractiveSessionCopyKind, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService';
import { IChatSlashCommandService, IChatSlashFragment } from 'vs/workbench/contrib/chat/common/chatSlashCommands';
import { IChatSlashCommandService } from 'vs/workbench/contrib/chat/common/chatSlashCommands';
import { IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
@ -567,10 +567,8 @@ export class ChatService extends Disposable implements IChatService {
history.push({ role: ChatMessageRole.User, content: request.message.text });
history.push({ role: ChatMessageRole.Assistant, content: request.response.response.asString() });
}
const commandResult = await this.chatSlashCommandService.executeCommand(commandPart.slashCommand.command, message.substring(commandPart.slashCommand.command.length + 1).trimStart(), new Progress<IChatSlashFragment>(p => {
const { content } = p;
const data = isCompleteInteractiveProgressTreeData(content) ? content : { content };
progressCallback(data);
const commandResult = await this.chatSlashCommandService.executeCommand(commandPart.slashCommand.command, message.substring(commandPart.slashCommand.command.length + 1).trimStart(), new Progress<IChatProgress>(p => {
progressCallback(p);
}), history, token);
agentOrCommandFollowups = Promise.resolve(commandResult?.followUp);
rawResponse = { session: model.session! };

View file

@ -5,11 +5,11 @@
import { CancellationToken } from 'vs/base/common/cancellation';
import { Emitter, Event } from 'vs/base/common/event';
import { Disposable, IDisposable, combinedDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { IProgress } from 'vs/platform/progress/common/progress';
import { IChatMessage } from 'vs/workbench/contrib/chat/common/chatProvider';
import { IChatFollowup, IChatResponseProgressFileTreeData } from 'vs/workbench/contrib/chat/common/chatService';
import { IChatFollowup, IChatProgress, IChatResponseProgressFileTreeData } from 'vs/workbench/contrib/chat/common/chatService';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
//#region slash service, commands etc
@ -29,21 +29,18 @@ export interface IChatSlashData {
export interface IChatSlashFragment {
content: string | { treeData: IChatResponseProgressFileTreeData };
}
export type IChatSlashCallback = { (prompt: string, progress: IProgress<IChatSlashFragment>, history: IChatMessage[], token: CancellationToken): Promise<{ followUp: IChatFollowup[] } | void> };
export type IChatSlashCallback = { (prompt: string, progress: IProgress<IChatProgress>, history: IChatMessage[], token: CancellationToken): Promise<{ followUp: IChatFollowup[] } | void> };
export const IChatSlashCommandService = createDecorator<IChatSlashCommandService>('chatSlashCommandService');
/**
* This currently only exists to drive /clear. Delete this when the agent service can handle that scenario
* This currently only exists to drive /clear and /help
*/
export interface IChatSlashCommandService {
_serviceBrand: undefined;
readonly onDidChangeCommands: Event<void>;
registerSlashData(data: IChatSlashData): IDisposable;
registerSlashCallback(id: string, command: IChatSlashCallback): IDisposable;
registerSlashCommand(data: IChatSlashData, command: IChatSlashCallback): IDisposable;
executeCommand(id: string, prompt: string, progress: IProgress<IChatSlashFragment>, history: IChatMessage[], token: CancellationToken): Promise<{ followUp: IChatFollowup[] } | void>;
executeCommand(id: string, prompt: string, progress: IProgress<IChatProgress>, history: IChatMessage[], token: CancellationToken): Promise<{ followUp: IChatFollowup[] } | void>;
getCommands(): Array<IChatSlashData>;
hasCommand(id: string): boolean;
}
@ -68,11 +65,12 @@ export class ChatSlashCommandService extends Disposable implements IChatSlashCom
this._commands.clear();
}
registerSlashData(data: IChatSlashData): IDisposable {
registerSlashCommand(data: IChatSlashData, command: IChatSlashCallback): IDisposable {
if (this._commands.has(data.command)) {
throw new Error(`Already registered a command with id ${data.command}}`);
}
this._commands.set(data.command, { data });
this._commands.set(data.command, { data, command });
this._onDidChangeCommands.fire();
return toDisposable(() => {
@ -82,22 +80,6 @@ export class ChatSlashCommandService extends Disposable implements IChatSlashCom
});
}
registerSlashCallback(id: string, command: IChatSlashCallback): IDisposable {
const data = this._commands.get(id);
if (!data) {
throw new Error(`No command with id ${id} registered`);
}
data.command = command;
return toDisposable(() => data.command = undefined);
}
registerSlashCommand(data: IChatSlashData, command: IChatSlashCallback): IDisposable {
return combinedDisposable(
this.registerSlashData(data),
this.registerSlashCallback(data.command, command)
);
}
getCommands(): Array<IChatSlashData> {
return Array.from(this._commands.values(), v => v.data);
}
@ -106,7 +88,7 @@ export class ChatSlashCommandService extends Disposable implements IChatSlashCom
return this._commands.has(id);
}
async executeCommand(id: string, prompt: string, progress: IProgress<IChatSlashFragment>, history: IChatMessage[], token: CancellationToken): Promise<{ followUp: IChatFollowup[] } | void> {
async executeCommand(id: string, prompt: string, progress: IProgress<IChatProgress>, history: IChatMessage[], token: CancellationToken): Promise<{ followUp: IChatFollowup[] } | void> {
const data = this._commands.get(id);
if (!data) {
throw new Error('No command with id ${id} NOT registered');

View file

@ -29,7 +29,6 @@ import { IViewsService } from 'vs/workbench/common/views';
import { IChatContributionService } from 'vs/workbench/contrib/chat/common/chatContributionService';
import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry';
import { KeyCode } from 'vs/base/common/keyCodes';
import { isExecuteActionContext } from 'vs/workbench/contrib/chat/browser/actions/chatExecuteActions';
import { IWorkbenchLayoutService, Parts } from 'vs/workbench/services/layout/browser/layoutService';
import { HasSpeechProvider, ISpeechService, SpeechToTextStatus } from 'vs/workbench/contrib/speech/common/speechService';
import { RunOnceScheduler } from 'vs/base/common/async';
@ -41,6 +40,7 @@ import { contrastBorder, focusBorder } from 'vs/platform/theme/common/colorRegis
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { isNumber } from 'vs/base/common/types';
import { AccessibilityVoiceSettingId, SpeechTimeoutDefault } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration';
import { IChatExecuteActionContext } from 'vs/workbench/contrib/chat/browser/actions/chatExecuteActions';
const CONTEXT_VOICE_CHAT_GETTING_READY = new RawContextKey<boolean>('voiceChatGettingReady', false, { type: 'boolean', description: localize('voiceChatGettingReady', "True when getting ready for receiving voice input from the microphone for voice chat.") });
const CONTEXT_VOICE_CHAT_IN_PROGRESS = new RawContextKey<boolean>('voiceChatInProgress', false, { type: 'boolean', description: localize('voiceChatInProgress', "True when voice recording from microphone is in progress for voice chat.") });
@ -486,7 +486,8 @@ export class StartVoiceChatAction extends Action2 {
const instantiationService = accessor.get(IInstantiationService);
const commandService = accessor.get(ICommandService);
if (isExecuteActionContext(context)) {
const widget = (context as IChatExecuteActionContext)?.widget;
if (widget) {
// if we already get a context when the action is executed
// from a toolbar within the chat widget, then make sure
// to move focus into the input field so that the controller
@ -494,7 +495,7 @@ export class StartVoiceChatAction extends Action2 {
// TODO@bpasero this will actually not work if the button
// is clicked from the inline editor while focus is in a
// chat input field in a view or picker
context.widget.focusInput();
widget.focusInput();
}
const controller = await VoiceChatSessionControllerFactory.create(accessor, 'focused');

View file

@ -91,6 +91,11 @@ declare module 'vscode' {
*/
readonly description: string;
/**
* When the user clicks this slash command in `/help`, this text will be submitted to this slash command
*/
readonly sampleRequest?: string;
/**
* Whether executing the command puts the
* chat into a persistent mode, where the
@ -204,6 +209,11 @@ declare module 'vscode' {
*/
followupProvider?: FollowupProvider;
/**
* When the user clicks this agent in `/help`, this text will be submitted to this slash command
*/
sampleRequest?: string;
/**
* An event that fires whenever feedback for a result is received, e.g. when a user up- or down-votes
* a result.

View file

@ -16,5 +16,15 @@ declare module 'vscode' {
* TODO@API name
*/
isSecondary?: boolean;
/**
* A string that will be added before the listing of chat agents in `/help`.
*/
helpTextPrefix?: string | MarkdownString;
/**
* A string that will be appended after the listing of chat agents in `/help`.
*/
helpTextPostfix?: string | MarkdownString;
}
}