From 49b3544bda90968479b2971a5d4d48ad395062b4 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Wed, 10 Jun 2026 12:23:37 +1000 Subject: [PATCH] agentHost: complete skills after whitespace Support skill slash completions when the token appears after whitespace in a user message while keeping slash commands leading-only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/agentHostSkillCompletionProvider.ts | 4 +- .../node/agentHostSlashCompletion.ts | 38 +++++++++++++-- .../agentHostSkillCompletionProvider.test.ts | 48 +++++++++++++++++++ 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts b/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts index a8092f0b9bb46..f5c5219618cf0 100644 --- a/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts @@ -11,7 +11,7 @@ import { CompletionItem, CompletionItemKind, CompletionsParams } from '../common import { MessageAttachmentKind } from '../common/state/protocol/state.js'; import { CustomizationType, SkillCustomization } from '../common/state/sessionState.js'; import { CompletionTriggerCharacter, IAgentHostCompletionItemProvider } from './agentHostCompletions.js'; -import { extractLeadingSlashToken } from './agentHostSlashCompletion.js'; +import { extractWhitespaceDelimitedSlashToken } from './agentHostSlashCompletion.js'; /** * Generic completion provider that contributes slash completions for skills @@ -29,7 +29,7 @@ export class AgentHostSkillCompletionProvider extends Disposable implements IAge } async provideCompletionItems(params: CompletionsParams, token: CancellationToken): Promise { - const leading = extractLeadingSlashToken(params.text, params.offset); + const leading = extractWhitespaceDelimitedSlashToken(params.text, params.offset); if (!leading) { return []; } diff --git a/src/vs/platform/agentHost/node/agentHostSlashCompletion.ts b/src/vs/platform/agentHost/node/agentHostSlashCompletion.ts index 61eca0dc38384..0c1a71d8fc851 100644 --- a/src/vs/platform/agentHost/node/agentHostSlashCompletion.ts +++ b/src/vs/platform/agentHost/node/agentHostSlashCompletion.ts @@ -28,8 +28,7 @@ export function extractLeadingSlashToken(text: string, offset: number): ILeading } let end = 1; while (end < text.length) { - const ch = text.charCodeAt(end); - if (ch === 0x20 /* space */ || ch === 0x09 /* tab */ || ch === 0x0a /* \n */ || ch === 0x0d /* \r */) { + if (isSlashTokenWhitespace(text.charCodeAt(end))) { break; } end++; @@ -39,4 +38,37 @@ export function extractLeadingSlashToken(text: string, offset: number): ILeading } const token = text.slice(0, end); return { token, typed: token.slice(1), rangeStart: 0, rangeEnd: end }; -} \ No newline at end of file +} + +/** + * Extracts the slash token containing the cursor when the slash is either at + * the start of the input or immediately follows whitespace. + */ +export function extractWhitespaceDelimitedSlashToken(text: string, offset: number): ILeadingSlashToken | undefined { + if (text.length === 0 || offset < 0 || offset > text.length) { + return undefined; + } + + let start = offset; + while (start > 0 && !isSlashTokenWhitespace(text.charCodeAt(start - 1))) { + start--; + } + if (start >= text.length || text.charCodeAt(start) !== 0x2f /* / */) { + return undefined; + } + + let end = start + 1; + while (end < text.length && !isSlashTokenWhitespace(text.charCodeAt(end))) { + end++; + } + if (offset > end) { + return undefined; + } + + const token = text.slice(start, end); + return { token, typed: token.slice(1), rangeStart: start, rangeEnd: end }; +} + +function isSlashTokenWhitespace(ch: number): boolean { + return ch === 0x20 /* space */ || ch === 0x09 /* tab */ || ch === 0x0a /* \n */ || ch === 0x0d /* \r */; +} diff --git a/src/vs/platform/agentHost/test/node/agentHostSkillCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostSkillCompletionProvider.test.ts index 16249fa696543..8eff59dc59d8d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSkillCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSkillCompletionProvider.test.ts @@ -136,6 +136,54 @@ suite('AgentHostSkillCompletionProvider', () => { ]); }); + test('filters skills by an in-message slash prefix and replaces only that token', async () => { + const agent = new MockAgent('mock'); + agent.getSessionCustomizations = async () => [plugin('skills', [skill('alpha'), skill('beta')])]; + const provider = createProvider(agent); + const text = 'use /b extra'; + + const result = await run(provider, text, text.indexOf('/b') + '/b'.length); + + assert.deepStrictEqual(result.map(item => ({ insertText: item.insertText, rangeStart: item.rangeStart, rangeEnd: item.rangeEnd })), [ + { insertText: '/beta ', rangeStart: 4, rangeEnd: 6 }, + ]); + }); + + test('returns skills for a slash token after whitespace', async () => { + const agent = new MockAgent('mock'); + agent.getSessionCustomizations = async () => [plugin('skills', [skill('alpha'), skill('beta')])]; + const provider = createProvider(agent); + const text = 'use /'; + + const result = await run(provider, text); + + assert.deepStrictEqual(result.map(item => ({ insertText: item.insertText, rangeStart: item.rangeStart, rangeEnd: item.rangeEnd })), [ + { insertText: '/alpha ', rangeStart: 4, rangeEnd: 5 }, + { insertText: '/beta ', rangeStart: 4, rangeEnd: 5 }, + ]); + }); + + test('does not complete slash tokens embedded in non-whitespace text', async () => { + const agent = new MockAgent('mock'); + agent.getSessionCustomizations = async () => [plugin('skills', [skill('alpha')])]; + const provider = createProvider(agent); + + const result = await run(provider, 'foo/bar', 'foo/bar'.length); + + assert.deepStrictEqual(result, []); + }); + + test('returns an empty list when the cursor is past an in-message slash token', async () => { + const agent = new MockAgent('mock'); + agent.getSessionCustomizations = async () => [plugin('skills', [skill('cached-skill')])]; + const provider = createProvider(agent); + const text = 'use /cached-skill trailing'; + + const result = await run(provider, text, text.indexOf('trailing')); + + assert.deepStrictEqual(result, []); + }); + test('returns an empty list when the cursor is past the leading slash token', async () => { const agent = new MockAgent('mock'); agent.getSessionCustomizations = async () => [plugin('skills', [skill('cached-skill')])];