diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index a807fca2d00..56518bd78e4 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -407,13 +407,19 @@ class ExtHostChatAgent { return []; } return result - .map(c => ({ - name: c.name, - description: c.description, - followupPlaceholder: c.followupPlaceholder, - shouldRepopulate: c.shouldRepopulate, - sampleRequest: c.sampleRequest - })); + .map(c => { + if ('repopulate2' in c) { + checkProposedApiEnabled(this.extension, 'chatAgents2Additions'); + } + + return { + name: c.name, + description: c.description, + followupPlaceholder: c.isSticky2?.placeholder, + shouldRepopulate: c.isSticky2?.isSticky ?? c.isSticky, + sampleRequest: c.sampleRequest + }; + }); } async provideFollowups(result: vscode.ChatAgentResult2, token: CancellationToken): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index ff95527a982..daa5ac41eb1 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -9,6 +9,7 @@ import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { Selection } from 'vs/editor/common/core/selection'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { IChatWidgetContrib } from 'vs/workbench/contrib/chat/browser/chatWidget'; +import { IChatAgentCommand, IChatAgentData } from 'vs/workbench/contrib/chat/common/chatAgents'; import { IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes'; import { IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, IChatWelcomeMessageViewModel } from 'vs/workbench/contrib/chat/common/chatViewModel'; @@ -101,6 +102,7 @@ export type IChatWidgetViewContext = IChatViewViewContext | IChatResourceViewCon export interface IChatWidget { readonly onDidChangeViewModel: Event; readonly onDidAcceptInput: Event; + readonly onDidSubmitAgent: Event<{ agent: IChatAgentData; slashCommand?: IChatAgentCommand }>; readonly viewContext: IChatWidgetViewContext; readonly viewModel: IChatViewModel | undefined; readonly inputEditor: ICodeEditor; diff --git a/src/vs/workbench/contrib/chat/browser/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/chatWidget.ts index e243033f713..ab61b6bde4f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWidget.ts @@ -35,7 +35,7 @@ import { ChatViewModel, IChatResponseViewModel, isRequestVM, isResponseVM, isWel import { IThemeService } from 'vs/platform/theme/common/themeService'; import { IParsedChatRequest, chatAgentLeader, chatSubcommandLeader } from 'vs/workbench/contrib/chat/common/chatParserTypes'; import { ChatRequestParser } from 'vs/workbench/contrib/chat/common/chatRequestParser'; -import { IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { IChatAgentCommand, IChatAgentData, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; const $ = dom.$; @@ -73,6 +73,9 @@ export interface IChatWidgetContrib extends IDisposable { export class ChatWidget extends Disposable implements IChatWidget { public static readonly CONTRIBS: { new(...args: [IChatWidget, ...any]): IChatWidgetContrib }[] = []; + private readonly _onDidSubmitAgent = this._register(new Emitter<{ agent: IChatAgentData; slashCommand?: IChatAgentCommand }>()); + public readonly onDidSubmitAgent = this._onDidSubmitAgent.event; + private _onDidFocus = this._register(new Emitter()); readonly onDidFocus = this._onDidFocus.event; @@ -586,6 +589,7 @@ export class ChatWidget extends Disposable implements IChatWidget { if (result) { const inputState = this.collectInputState(); this.inputPart.acceptInput(isUserQuery ? input : undefined, isUserQuery ? inputState : undefined); + this._onDidSubmitAgent.fire({ agent: result.agent, slashCommand: result.slashCommand }); result.responseCompletePromise.then(async () => { const responses = this.viewModel?.getItems().filter(isResponseVM); const lastResponse = responses?.[responses.length - 1]; diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts index e5e44863dfd..a6cf114ed33 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts @@ -29,7 +29,6 @@ import { IChatAgentCommand, IChatAgentData, IChatAgentService } from 'vs/workben import { chatSlashCommandBackground, chatSlashCommandForeground } from 'vs/workbench/contrib/chat/common/chatColors'; import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, ChatRequestVariablePart, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader, chatVariableLeader } from 'vs/workbench/contrib/chat/common/chatParserTypes'; import { ChatRequestParser } from 'vs/workbench/contrib/chat/common/chatRequestParser'; -import { IChatService } from 'vs/workbench/contrib/chat/common/chatService'; import { IChatSlashCommandService } from 'vs/workbench/contrib/chat/common/chatSlashCommands'; import { IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables'; import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; @@ -39,8 +38,8 @@ const placeholderDecorationType = 'chat-session-detail'; const slashCommandTextDecorationType = 'chat-session-text'; const variableTextDecorationType = 'chat-variable-text'; -function agentAndCommandToKey(agent: string, subcommand: string): string { - return `${agent}__${subcommand}`; +function agentAndCommandToKey(agent: string, subcommand: string | undefined): string { + return subcommand ? `${agent}__${subcommand}` : agent; } class InputEditorDecorations extends Disposable { @@ -55,7 +54,6 @@ class InputEditorDecorations extends Disposable { private readonly widget: IChatWidget, @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @IThemeService private readonly themeService: IThemeService, - @IChatService private readonly chatService: IChatService, @IChatAgentService private readonly chatAgentService: IChatAgentService, ) { super(); @@ -72,10 +70,8 @@ class InputEditorDecorations extends Disposable { this.previouslyUsedAgents.clear(); this.updateInputEditorDecorations(); })); - this._register(this.chatService.onDidSubmitAgent((e) => { - if (e.sessionId === this.widget.viewModel?.sessionId) { - this.previouslyUsedAgents.add(agentAndCommandToKey(e.agent.id, e.slashCommand.name)); - } + this._register(this.widget.onDidSubmitAgent((e) => { + this.previouslyUsedAgents.add(agentAndCommandToKey(e.agent.id, e.slashCommand?.name)); })); this._register(this.chatAgentService.onDidChangeAgents(() => this.updateInputEditorDecorations())); @@ -168,20 +164,24 @@ class InputEditorDecorations extends Disposable { return nextPart && nextPart instanceof ChatRequestTextPart && nextPart.text === ' '; }; + const getRangeForPlaceholder = (part: IParsedChatRequestPart) => ({ + startLineNumber: part.editorRange.startLineNumber, + endLineNumber: part.editorRange.endLineNumber, + startColumn: part.editorRange.endColumn + 1, + endColumn: 1000 + }); + const onlyAgentAndWhitespace = agentPart && parsedRequest.every(p => p instanceof ChatRequestTextPart && !p.text.trim().length || p instanceof ChatRequestAgentPart); if (onlyAgentAndWhitespace) { // Agent reference with no other text - show the placeholder + const isFollowupSlashCommand = this.previouslyUsedAgents.has(agentAndCommandToKey(agentPart.agent.id, undefined)); + const shouldRenderFollowupPlaceholder = isFollowupSlashCommand && agentPart.agent.metadata.followupPlaceholder; if (agentPart.agent.metadata.description && exactlyOneSpaceAfterPart(agentPart)) { placeholderDecoration = [{ - range: { - startLineNumber: agentPart.editorRange.startLineNumber, - endLineNumber: agentPart.editorRange.endLineNumber, - startColumn: agentPart.editorRange.endColumn + 1, - endColumn: 1000 - }, + range: getRangeForPlaceholder(agentPart), renderOptions: { after: { - contentText: agentPart.agent.metadata.description, + contentText: shouldRenderFollowupPlaceholder ? agentPart.agent.metadata.followupPlaceholder : agentPart.agent.metadata.description, color: this.getPlaceholderColor(), } } @@ -196,12 +196,7 @@ class InputEditorDecorations extends Disposable { const shouldRenderFollowupPlaceholder = isFollowupSlashCommand && agentSubcommandPart.command.followupPlaceholder; if (agentSubcommandPart?.command.description && exactlyOneSpaceAfterPart(agentSubcommandPart)) { placeholderDecoration = [{ - range: { - startLineNumber: agentSubcommandPart.editorRange.startLineNumber, - endLineNumber: agentSubcommandPart.editorRange.endLineNumber, - startColumn: agentSubcommandPart.editorRange.endColumn + 1, - endColumn: 1000 - }, + range: getRangeForPlaceholder(agentSubcommandPart), renderOptions: { after: { contentText: shouldRenderFollowupPlaceholder ? agentSubcommandPart.command.followupPlaceholder : agentSubcommandPart.command.description, @@ -212,27 +207,6 @@ class InputEditorDecorations extends Disposable { } } - const onlySlashCommandAndWhitespace = slashCommandPart && parsedRequest.every(p => p instanceof ChatRequestTextPart && !p.text.trim().length || p instanceof ChatRequestSlashCommandPart); - if (onlySlashCommandAndWhitespace) { - // Command reference with no other text - show the placeholder - if (slashCommandPart.slashCommand.detail && exactlyOneSpaceAfterPart(slashCommandPart)) { - placeholderDecoration = [{ - range: { - startLineNumber: slashCommandPart.editorRange.startLineNumber, - endLineNumber: slashCommandPart.editorRange.endLineNumber, - startColumn: slashCommandPart.editorRange.endColumn + 1, - endColumn: 1000 - }, - renderOptions: { - after: { - contentText: slashCommandPart.slashCommand.detail, - color: this.getPlaceholderColor(), - } - } - }]; - } - } - this.widget.inputEditor.setDecorationsByType(decorationDescription, placeholderDecorationType, placeholderDecoration ?? []); const textDecorations: IDecorationOptions[] | undefined = []; @@ -264,21 +238,23 @@ class InputEditorSlashCommandMode extends Disposable { constructor( private readonly widget: IChatWidget, - @IChatService private readonly chatService: IChatService ) { super(); - this._register(this.chatService.onDidSubmitAgent(e => { - if (this.widget.viewModel?.sessionId !== e.sessionId) { - return; - } - + this._register(this.widget.onDidSubmitAgent(e => { this.repopulateAgentCommand(e.agent, e.slashCommand); })); } - private async repopulateAgentCommand(agent: IChatAgentData, slashCommand: IChatAgentCommand) { - if (slashCommand.shouldRepopulate) { - const value = `${chatAgentLeader}${agent.id} ${chatSubcommandLeader}${slashCommand.name} `; + private async repopulateAgentCommand(agent: IChatAgentData, slashCommand: IChatAgentCommand | undefined) { + let value: string | undefined; + if (slashCommand && slashCommand.shouldRepopulate) { + value = `${chatAgentLeader}${agent.id} ${chatSubcommandLeader}${slashCommand.name} `; + } else { + // Agents always repopulate, and slash commands fall back to the agent if they don't repopulate + value = `${chatAgentLeader}${agent.id} `; + } + + if (value) { this.widget.inputEditor.setValue(value); this.widget.inputEditor.setPosition({ lineNumber: 1, column: value.length + 1 }); } diff --git a/src/vs/workbench/contrib/chat/common/chatAgents.ts b/src/vs/workbench/contrib/chat/common/chatAgents.ts index 8647b65ad43..289d3cb15b4 100644 --- a/src/vs/workbench/contrib/chat/common/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/chatAgents.ts @@ -77,6 +77,7 @@ export interface IChatAgentMetadata { themeIcon?: ThemeIcon; sampleRequest?: string; supportIssueReporting?: boolean; + followupPlaceholder?: string; } diff --git a/src/vs/workbench/contrib/chat/common/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService.ts index 4764e9f23bd..769476c60a7 100644 --- a/src/vs/workbench/contrib/chat/common/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService.ts @@ -262,13 +262,18 @@ export interface IChatTransferredSessionData { inputValue: string; } +export interface IChatSendRequestData { + responseCompletePromise: Promise; + agent: IChatAgentData; + slashCommand?: IChatAgentCommand; +} + export const IChatService = createDecorator('IChatService'); export interface IChatService { _serviceBrand: undefined; transferredSessionData: IChatTransferredSessionData | undefined; - onDidSubmitAgent: Event<{ agent: IChatAgentData; slashCommand: IChatAgentCommand; sessionId: string }>; onDidRegisterProvider: Event<{ providerId: string }>; onDidUnregisterProvider: Event<{ providerId: string }>; registerProvider(provider: IChatProvider): IDisposable; @@ -283,7 +288,7 @@ export interface IChatService { /** * Returns whether the request was accepted. */ - sendRequest(sessionId: string, message: string): Promise<{ responseCompletePromise: Promise } | undefined>; + sendRequest(sessionId: string, message: string): Promise; removeRequest(sessionid: string, requestId: string): Promise; cancelCurrentRequestForSession(sessionId: string): void; clearSession(sessionId: string): void; diff --git a/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts index 1191afb209a..ebe25ec1b3a 100644 --- a/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts @@ -20,13 +20,13 @@ import { Progress } from 'vs/platform/progress/common/progress'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; -import { IChatAgentCommand, IChatAgentData, IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentResult, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentResult, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; import { CONTEXT_PROVIDER_EXISTS } from 'vs/workbench/contrib/chat/common/chatContextKeys'; import { ChatModel, ChatModelInitState, ChatRequestModel, ChatWelcomeMessageModel, IChatModel, IChatRequestVariableData, IChatRequestVariableData2, ISerializableChatData, ISerializableChatsData } from 'vs/workbench/contrib/chat/common/chatModel'; import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestVariablePart, IParsedChatRequest, getPromptText } from 'vs/workbench/contrib/chat/common/chatParserTypes'; import { ChatMessageRole, IChatMessage } from 'vs/workbench/contrib/chat/common/chatProvider'; import { ChatRequestParser } from 'vs/workbench/contrib/chat/common/chatRequestParser'; -import { ChatAgentCopyKind, IChat, IChatCompleteResponse, IChatDetail, IChatDynamicRequest, IChatFollowup, IChatProgress, IChatProvider, IChatProviderInfo, IChatService, IChatTransferredSessionData, IChatUserActionEvent, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService'; +import { ChatAgentCopyKind, IChat, IChatCompleteResponse, IChatDetail, IChatDynamicRequest, IChatFollowup, IChatProgress, IChatProvider, IChatProviderInfo, IChatSendRequestData, IChatService, IChatTransferredSessionData, IChatUserActionEvent, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService'; 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'; @@ -146,9 +146,6 @@ export class ChatService extends Disposable implements IChatService { private readonly _onDidPerformUserAction = this._register(new Emitter()); public readonly onDidPerformUserAction: Event = this._onDidPerformUserAction.event; - private readonly _onDidSubmitAgent = this._register(new Emitter<{ agent: IChatAgentData; slashCommand: IChatAgentCommand; sessionId: string }>()); - public readonly onDidSubmitAgent = this._onDidSubmitAgent.event; - private readonly _onDidDisposeSession = this._register(new Emitter<{ sessionId: string; providerId: string; reason: 'initializationFailed' | 'cleared' }>()); public readonly onDidDisposeSession = this._onDidDisposeSession.event; @@ -438,7 +435,7 @@ export class ChatService extends Disposable implements IChatService { return this._startSession(data.providerId, data, CancellationToken.None); } - async sendRequest(sessionId: string, request: string): Promise<{ responseCompletePromise: Promise } | undefined> { + async sendRequest(sessionId: string, request: string): Promise { this.trace('sendRequest', `sessionId: ${sessionId}, message: ${request.substring(0, 20)}${request.length > 20 ? '[...]' : ''}}`); if (!request.trim()) { this.trace('sendRequest', 'Rejected empty message'); @@ -461,8 +458,16 @@ export class ChatService extends Disposable implements IChatService { return; } + const parsedRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(sessionId, request); + const agent = parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart)?.agent ?? this.chatAgentService.getDefaultAgent()!; + const agentSlashCommandPart = parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart); + // This method is only returning whether the request was accepted - don't block on the actual request - return { responseCompletePromise: this._sendRequestAsync(model, sessionId, provider, request) }; + return { + responseCompletePromise: this._sendRequestAsync(model, sessionId, provider, parsedRequest), + agent, + slashCommand: agentSlashCommandPart?.command, + }; } private refreshFollowupsCancellationToken(sessionId: string): CancellationToken { @@ -473,10 +478,8 @@ export class ChatService extends Disposable implements IChatService { return newTokenSource.token; } - private async _sendRequestAsync(model: ChatModel, sessionId: string, provider: IChatProvider, message: string): Promise { + private async _sendRequestAsync(model: ChatModel, sessionId: string, provider: IChatProvider, parsedRequest: IParsedChatRequest): Promise { const followupsCancelToken = this.refreshFollowupsCancellationToken(sessionId); - const parsedRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(sessionId, message); - let request: ChatRequestModel; const agentPart = 'kind' in parsedRequest ? undefined : parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart); const agentSlashCommandPart = 'kind' in parsedRequest ? undefined : parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart); @@ -523,10 +526,6 @@ export class ChatService extends Disposable implements IChatService { }); try { - if (agentPart && agentSlashCommandPart?.command) { - this._onDidSubmitAgent.fire({ agent: agentPart.agent, slashCommand: agentSlashCommandPart.command, sessionId: model.sessionId }); - } - let rawResult: IChatAgentResult | null | undefined; let agentOrCommandFollowups: Promise | undefined = undefined; @@ -570,7 +569,7 @@ export class ChatService extends Disposable implements IChatService { rawResult = agentResult; agentOrCommandFollowups = this.chatAgentService.getFollowups(agent.id, requestProps, agentResult, followupsCancelToken); } else if (commandPart && this.chatSlashCommandService.hasCommand(commandPart.slashCommand.command)) { - request = model.addRequest(parsedRequest, { message, variables: {} }); + request = model.addRequest(parsedRequest, { message: parsedRequest.text, variables: {} }); // contributed slash commands // TODO: spell this out in the UI const history: IChatMessage[] = []; @@ -581,6 +580,7 @@ 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 message = parsedRequest.text; const commandResult = await this.chatSlashCommandService.executeCommand(commandPart.slashCommand.command, message.substring(commandPart.slashCommand.command.length + 1).trimStart(), new Progress(p => { progressCallback(p); }), history, token); diff --git a/src/vscode-dts/vscode.proposed.chatAgents2.d.ts b/src/vscode-dts/vscode.proposed.chatAgents2.d.ts index 4ec0f8a92fc..d2a76eb8736 100644 --- a/src/vscode-dts/vscode.proposed.chatAgents2.d.ts +++ b/src/vscode-dts/vscode.proposed.chatAgents2.d.ts @@ -180,19 +180,10 @@ declare module 'vscode' { readonly sampleRequest?: string; /** - * Whether executing the command puts the - * chat into a persistent mode, where the - * command is prepended to the chat input. + * Whether executing the command puts the chat into a persistent mode, where the command is automatically added to the chat input for the next message. + * If this is not set, the chat input will fall back to the agent after submitting this command. */ - readonly shouldRepopulate?: boolean; - - /** - * Placeholder text to render in the chat input - * when the command has been repopulated. - * Has no effect if `shouldRepopulate` is `false`. - */ - // TODO@API merge this with shouldRepopulate? so that invalid state cannot be represented? - readonly followupPlaceholder?: string; + readonly isSticky?: boolean; } export interface ChatAgentCommandProvider { @@ -221,7 +212,7 @@ declare module 'vscode' { prompt: string; /** - * By default, the followup goes to the same agent/subCommand. But these properties can be set to override that. + * By default, the followup goes to the same agent/command. But these properties can be set to override that. */ agentId?: string; diff --git a/src/vscode-dts/vscode.proposed.chatAgents2Additions.d.ts b/src/vscode-dts/vscode.proposed.chatAgents2Additions.d.ts index f42f874d0dd..b934eeea995 100644 --- a/src/vscode-dts/vscode.proposed.chatAgents2Additions.d.ts +++ b/src/vscode-dts/vscode.proposed.chatAgents2Additions.d.ts @@ -230,4 +230,18 @@ declare module 'vscode' { */ kind?: string; } + + export interface ChatAgentCommand { + readonly isSticky2?: { + /** + * Indicates that the command should be automatically repopulated. + */ + isSticky: true; + + /** + * This can be set to a string to use a different placeholder message in the input box when the command has been repopulated. + */ + placeholder?: string; + }; + } }