Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,7 +29,7 @@ export class AgentHostSkillCompletionProvider extends Disposable implements IAge
}

async provideCompletionItems(params: CompletionsParams, token: CancellationToken): Promise<readonly CompletionItem[]> {
const leading = extractLeadingSlashToken(params.text, params.offset);
const leading = extractWhitespaceDelimitedSlashToken(params.text, params.offset);
if (!leading) {
Comment thread
DonJayamanne marked this conversation as resolved.
return [];
}
Expand Down
38 changes: 35 additions & 3 deletions src/vs/platform/agentHost/node/agentHostSlashCompletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
Expand All @@ -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 };
}
}

/**
* 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;
}
Comment thread
DonJayamanne marked this conversation as resolved.

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 */;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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')])];
Expand Down
Loading