diff --git a/src/vs/workbench/contrib/chat/common/chatRequestParser.ts b/src/vs/workbench/contrib/chat/common/chatRequestParser.ts index 8eb255ca882..d74c12c6f4f 100644 --- a/src/vs/workbench/contrib/chat/common/chatRequestParser.ts +++ b/src/vs/workbench/contrib/chat/common/chatRequestParser.ts @@ -16,7 +16,7 @@ import { IPromptsService } from './promptSyntax/service/types.js'; const agentReg = /^@([\w_\-\.]+)(?=(\s|$|\b))/i; // An @-agent const variableReg = /^#([\w_\-]+)(:\d+)?(?=(\s|$|\b))/i; // A #-variable with an optional numeric : arg (@response:2) -const slashReg = /\/([\w_\-\.:]+)(?=(\s|$|\b))/i; // A / command +const slashReg = /^\/([\w_\-\.:]+)(?=(\s|$|\b))/i; // A / command export interface IChatParserContext { /** Used only as a disambiguator, when the query references an agent that has a duplicate with the same name. */ @@ -169,8 +169,16 @@ export class ChatRequestParser { return; } - if (parts.some(p => p instanceof ChatRequestSlashCommandPart)) { - // Only one slash command allowed + if (parts.some(p => !(p instanceof ChatRequestAgentPart) && !(p instanceof ChatRequestTextPart && p.text.trim() === ''))) { + // no other part than agent or non-whitespace text allowed: that also means no other slash command + return; + } + + // only whitespace after the last part + const previousPart = parts.at(-1); + const previousPartEnd = previousPart?.range.endExclusive ?? 0; + const textSincePreviousPart = fullMessage.slice(previousPartEnd, offset); + if (textSincePreviousPart.trim() !== '') { return; } @@ -180,18 +188,6 @@ export class ChatRequestParser { const usedAgent = parts.find((p): p is ChatRequestAgentPart => p instanceof ChatRequestAgentPart); if (usedAgent) { - // The slash command must come immediately after the agent - if (parts.some(p => (p instanceof ChatRequestTextPart && p.text.trim() !== '') || !(p instanceof ChatRequestAgentPart) && !(p instanceof ChatRequestTextPart))) { - return; - } - - const previousPart = parts.at(-1); - const previousPartEnd = previousPart?.range.endExclusive ?? 0; - const textSincePreviousPart = fullMessage.slice(previousPartEnd, offset); - if (textSincePreviousPart.trim() !== '') { - return; - } - const subCommand = usedAgent.agent.slashCommands.find(c => c.name === command); if (subCommand) { // Valid agent subcommand diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 17432f0e966..b86cbf24283 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -144,7 +144,7 @@ export class PromptsService extends Disposable implements IPromptsService { } public asPromptSlashCommand(command: string): IChatPromptSlashCommand | undefined { - if (command.match(/^[\w_\-\.]+/)) { + if (command.match(/^[\w_\-\.]+$/)) { return { command, detail: localize('prompt.file.detail', 'Prompt file: {0}', command) }; } return undefined; diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_prompt_slash_command.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_prompt_slash_command.0.snap new file mode 100644 index 00000000000..70b24f7309e --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_prompt_slash_command.0.snap @@ -0,0 +1,33 @@ +{ + parts: [ + { + range: { + start: 0, + endExclusive: 4 + }, + editorRange: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 5 + }, + text: " ", + kind: "text" + }, + { + range: { + start: 4, + endExclusive: 11 + }, + editorRange: { + startLineNumber: 1, + startColumn: 5, + endLineNumber: 1, + endColumn: 12 + }, + slashPromptCommand: { command: "prompt" }, + kind: "prompt" + } + ], + text: " /prompt" +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_prompt_slash_command_after_slash.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_prompt_slash_command_after_slash.0.snap new file mode 100644 index 00000000000..ce48a80a13a --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_prompt_slash_command_after_slash.0.snap @@ -0,0 +1,19 @@ +{ + parts: [ + { + range: { + start: 0, + endExclusive: 41 + }, + editorRange: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 42 + }, + text: "/ route and the request of /search-option", + kind: "text" + } + ], + text: "/ route and the request of /search-option" +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_prompt_slash_command_after_text.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_prompt_slash_command_after_text.0.snap new file mode 100644 index 00000000000..120c1cc46dd --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_prompt_slash_command_after_text.0.snap @@ -0,0 +1,19 @@ +{ + parts: [ + { + range: { + start: 0, + endExclusive: 52 + }, + editorRange: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 53 + }, + text: "handle the / route and the request of /search-option", + kind: "text" + } + ], + text: "handle the / route and the request of /search-option" +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_slash_command_after_whitespace.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_slash_command_after_whitespace.0.snap new file mode 100644 index 00000000000..b0f2a2c0d40 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_slash_command_after_whitespace.0.snap @@ -0,0 +1,33 @@ +{ + parts: [ + { + range: { + start: 0, + endExclusive: 4 + }, + editorRange: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 5 + }, + text: " ", + kind: "text" + }, + { + range: { + start: 4, + endExclusive: 8 + }, + editorRange: { + startLineNumber: 1, + startColumn: 5, + endLineNumber: 1, + endColumn: 9 + }, + slashCommand: { command: "fix" }, + kind: "slash" + } + ], + text: " /fix" +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_slash_command_not_first.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_slash_command_not_first.0.snap new file mode 100644 index 00000000000..b36f6191154 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_slash_command_not_first.0.snap @@ -0,0 +1,19 @@ +{ + parts: [ + { + range: { + start: 0, + endExclusive: 10 + }, + editorRange: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 11 + }, + text: "Hello /fix", + kind: "text" + } + ], + text: "Hello /fix" +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_slash_in_text.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_slash_in_text.0.snap new file mode 100644 index 00000000000..4a0dd5fb12d --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_slash_in_text.0.snap @@ -0,0 +1,19 @@ +{ + parts: [ + { + range: { + start: 0, + endExclusive: 65 + }, + editorRange: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 66 + }, + text: "can we add a new file for an Express router to handle the / route", + kind: "text" + } + ], + text: "can we add a new file for an Express router to handle the / route" +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/chatRequestParser.test.ts b/src/vs/workbench/contrib/chat/test/common/chatRequestParser.test.ts index 6d8091a23fa..a3b527ade6c 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatRequestParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatRequestParser.test.ts @@ -61,6 +61,13 @@ suite('ChatRequestParser', () => { await assertSnapshot(result); }); + test('slash in text', async () => { + parser = instantiationService.createInstance(ChatRequestParser); + const text = 'can we add a new file for an Express router to handle the / route'; + const result = parser.parseChatRequest('1', text); + await assertSnapshot(result); + }); + test('slash command', async () => { const slashCommandService = mockObject()({}); slashCommandService.getCommands.returns([{ command: 'fix' }]); @@ -94,6 +101,89 @@ suite('ChatRequestParser', () => { await assertSnapshot(result); }); + test('slash command not first', async () => { + const slashCommandService = mockObject()({}); + slashCommandService.getCommands.returns([{ command: 'fix' }]); + instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + + parser = instantiationService.createInstance(ChatRequestParser); + const text = 'Hello /fix'; + const result = parser.parseChatRequest('1', text); + await assertSnapshot(result); + }); + + test('slash command after whitespace', async () => { + const slashCommandService = mockObject()({}); + slashCommandService.getCommands.returns([{ command: 'fix' }]); + instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + + parser = instantiationService.createInstance(ChatRequestParser); + const text = ' /fix'; + const result = parser.parseChatRequest('1', text); + await assertSnapshot(result); + }); + + test('prompt slash command', async () => { + const slashCommandService = mockObject()({}); + slashCommandService.getCommands.returns([{ command: 'fix' }]); + instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + + const promptSlashCommandService = mockObject()({}); + promptSlashCommandService.asPromptSlashCommand.callsFake((command: string) => { + if (command.match(/^[\w_\-\.]+$/)) { + return { command }; + } + return undefined; + }); + instantiationService.stub(IPromptsService, promptSlashCommandService as any); + + parser = instantiationService.createInstance(ChatRequestParser); + const text = ' /prompt'; + const result = parser.parseChatRequest('1', text); + await assertSnapshot(result); + }); + + test('prompt slash command after text', async () => { + const slashCommandService = mockObject()({}); + slashCommandService.getCommands.returns([{ command: 'fix' }]); + instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + + const promptSlashCommandService = mockObject()({}); + promptSlashCommandService.asPromptSlashCommand.callsFake((command: string) => { + if (command.match(/^[\w_\-\.]+$/)) { + return { command }; + } + return undefined; + }); + instantiationService.stub(IPromptsService, promptSlashCommandService as any); + + parser = instantiationService.createInstance(ChatRequestParser); + const text = 'handle the / route and the request of /search-option'; + const result = parser.parseChatRequest('1', text); + await assertSnapshot(result); + }); + + test('prompt slash command after slash', async () => { + const slashCommandService = mockObject()({}); + slashCommandService.getCommands.returns([{ command: 'fix' }]); + instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + + const promptSlashCommandService = mockObject()({}); + promptSlashCommandService.asPromptSlashCommand.callsFake((command: string) => { + if (command.match(/^[\w_\-\.]+$/)) { + return { command }; + } + return undefined; + }); + instantiationService.stub(IPromptsService, promptSlashCommandService as any); + + parser = instantiationService.createInstance(ChatRequestParser); + const text = '/ route and the request of /search-option'; + const result = parser.parseChatRequest('1', text); + await assertSnapshot(result); + }); + + // test('variables', async () => { // varService.hasVariable.returns(true); // varService.getVariable.returns({ id: 'copilot.selection' }); diff --git a/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts index f403ec3f156..9e9fb46b4ac 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts @@ -5,7 +5,6 @@ import { URI } from '../../../../../base/common/uri.js'; import { ITextModel } from '../../../../../editor/common/model.js'; -import { PROMPT_FILE_EXTENSION } from '../../../../../platform/prompts/common/constants.js'; import { TextModelPromptParser } from '../../common/promptSyntax/parsers/textModelPromptParser.js'; import { IChatPromptSlashCommand, IMetadata, IPromptPath, IPromptsService, TCombinedToolsMetadata, TPromptsType } from '../../common/promptSyntax/service/types.js'; @@ -27,13 +26,7 @@ export class MockPromptsService implements IPromptsService { getSourceFolders(_type: TPromptsType): readonly IPromptPath[] { throw new Error('Method not implemented.'); } - public asPromptSlashCommand(name: string): IChatPromptSlashCommand | undefined { - if (name.endsWith(PROMPT_FILE_EXTENSION)) { - const command = `prompt:${name.substring(0, -PROMPT_FILE_EXTENSION.length)}`; - return { - command, detail: name, - }; - } + public asPromptSlashCommand(command: string): IChatPromptSlashCommand | undefined { return undefined; } resolvePromptSlashCommand(_data: IChatPromptSlashCommand): Promise {