Merge pull request #294777 from microsoft/pierceboggan/fix-@

@ can be used to reference context, just like #
This commit is contained in:
Pierce Boggan
2026-03-27 14:57:53 -06:00
committed by GitHub
4 changed files with 372 additions and 66 deletions

View File

@@ -0,0 +1,58 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Position } from '../../../../../../../editor/common/core/position.js';
import { Range } from '../../../../../../../editor/common/core/range.js';
import { IWordAtPosition, getWordAtText } from '../../../../../../../editor/common/core/wordHelper.js';
import { ITextModel } from '../../../../../../../editor/common/model.js';
export function escapeForCharClass(text: string): string {
return text.replace(/[-\\^\]]/g, '\\$&');
}
export interface IChatCompletionRangeResult {
insert: Range;
replace: Range;
varWord: IWordAtPosition | null;
}
export function computeCompletionRanges(model: ITextModel, position: Position, reg: RegExp, onlyOnWordStart = false): IChatCompletionRangeResult | undefined {
const varWord = getWordAtText(position.column, reg, model.getLineContent(position.lineNumber), 0);
if (!varWord && model.getWordUntilPosition(position).word) {
// inside a "normal" word
return;
}
if (!varWord && position.column > 1) {
const textBefore = model.getValueInRange(new Range(position.lineNumber, position.column - 1, position.lineNumber, position.column));
if (textBefore !== ' ') {
return;
}
}
if (varWord && onlyOnWordStart) {
const wordBefore = model.getWordUntilPosition({ lineNumber: position.lineNumber, column: varWord.startColumn });
if (wordBefore.word) {
// inside a word
return;
}
}
let insert: Range;
let replace: Range;
if (!varWord) {
insert = replace = Range.fromPositions(position);
} else {
insert = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, position.column);
replace = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, varWord.endColumn);
}
return { insert, replace, varWord };
}
export function isEmptyUpToCompletionWord(model: ITextModel, rangeResult: IChatCompletionRangeResult): boolean {
const startToCompletionWordStart = new Range(1, 1, rangeResult.replace.startLineNumber, rangeResult.replace.startColumn);
return !!model.getValueInRange(startToCompletionWordStart).match(/^\s*$/);
}

View File

@@ -21,7 +21,7 @@ import { ICodeEditor, getCodeEditor, isCodeEditor } from '../../../../../../../e
import { ICodeEditorService } from '../../../../../../../editor/browser/services/codeEditorService.js';
import { Position } from '../../../../../../../editor/common/core/position.js';
import { Range } from '../../../../../../../editor/common/core/range.js';
import { IWordAtPosition, getWordAtText } from '../../../../../../../editor/common/core/wordHelper.js';
import { IWordAtPosition } from '../../../../../../../editor/common/core/wordHelper.js';
import { CompletionContext, CompletionItem, CompletionItemKind, CompletionItemProvider, CompletionList, DocumentSymbol, Location, ProviderResult, SymbolKind, SymbolKinds } from '../../../../../../../editor/common/languages.js';
import { ITextModel } from '../../../../../../../editor/common/model.js';
import { ILanguageFeaturesService } from '../../../../../../../editor/common/services/languageFeatures.js';
@@ -68,6 +68,7 @@ import { IChatDebugService } from '../../../../common/chatDebugService.js';
import { createDebugEventsAttachment } from '../../../chatDebug/chatDebugAttachment.js';
import { getPromptFileType } from '../../../../common/promptSyntax/config/promptFileLocations.js';
import { getChatSessionType } from '../../../../common/model/chatUri.js';
import { computeCompletionRanges, escapeForCharClass, IChatCompletionRangeResult, isEmptyUpToCompletionWord } from './chatInputCompletionUtils.js';
import { getAgentSessionProviderIcon, AgentSessionProviders } from '../../../agentSessions/agentSessions.js';
/**
@@ -858,7 +859,7 @@ interface IVariableCompletionsDetails {
class BuiltinDynamicCompletions extends Disposable {
private static readonly addReferenceCommand = '_addReferenceCmd';
private static readonly addDebugEventsSnapshotCommand = '_addDebugEventsSnapshotCmd';
private static readonly VariableNameDef = new RegExp(`${chatVariableLeader}[\\w:-]*`, 'g'); // MUST be using `g`-flag
private static readonly VariableNameDef = new RegExp(`[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}][\\w:-]*`, 'g'); // MUST be using `g`-flag
constructor(
@@ -880,7 +881,7 @@ class BuiltinDynamicCompletions extends Disposable {
super();
// File/Folder completions in one go and m
const fileWordPattern = new RegExp(`${chatVariableLeader}[^\\s]*`, 'g');
const fileWordPattern = new RegExp(`[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}][^\\s]*`, 'g');
this.registerVariableCompletions('fileAndFolder', async ({ widget, range }, token) => {
if (!widget.supportsFileReferences) {
return;
@@ -921,15 +922,16 @@ class BuiltinDynamicCompletions extends Disposable {
return;
}
const typedLeader = range.varWord?.word?.charAt(0) === chatAgentLeader ? chatAgentLeader : chatVariableLeader;
const basename = this.labelService.getUriBasenameLabel(currentResource);
const text = `${chatVariableLeader}file:${basename}:${currentSelection.startLineNumber}-${currentSelection.endLineNumber}`;
const text = `${typedLeader}file:${basename}:${currentSelection.startLineNumber}-${currentSelection.endLineNumber}`;
const fullRangeText = `:${currentSelection.startLineNumber}:${currentSelection.startColumn}-${currentSelection.endLineNumber}:${currentSelection.endColumn}`;
const description = this.labelService.getUriLabel(currentResource, { relative: true }) + fullRangeText;
const result: CompletionList = { suggestions: [] };
result.suggestions.push({
label: { label: `${chatVariableLeader}selection`, description },
filterText: `${chatVariableLeader}selection`,
label: { label: `${typedLeader}selection`, description },
filterText: `${typedLeader}selection`,
insertText: range.varWord?.endColumn === range.replace.endColumn ? `${text} ` : text,
range,
kind: CompletionItemKind.Text,
@@ -953,7 +955,7 @@ class BuiltinDynamicCompletions extends Disposable {
}
const result: CompletionList = { suggestions: [] };
const range2 = computeCompletionRanges(model, position, new RegExp(`${chatVariableLeader}[^\\s]*`, 'g'), true);
const range2 = computeCompletionRanges(model, position, new RegExp(`[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}][^\\s]*`, 'g'), true);
if (range2) {
this.addSymbolEntries(widget, result, range2, token);
}
@@ -1103,7 +1105,7 @@ class BuiltinDynamicCompletions extends Disposable {
private registerVariableCompletions(debugName: string, provider: (details: IVariableCompletionsDetails, token: CancellationToken) => ProviderResult<CompletionList>, wordPattern: RegExp = BuiltinDynamicCompletions.VariableNameDef) {
this._register(this.languageFeaturesService.completionProvider.register({ scheme: Schemas.vscodeChatInput, hasAccessToAllModels: true }, {
_debugDisplayName: `chatVarCompletions-${debugName}`,
triggerCharacters: [chatVariableLeader],
triggerCharacters: [chatVariableLeader, chatAgentLeader],
provideCompletionItems: async (model: ITextModel, position: Position, context: CompletionContext, token: CancellationToken) => {
const widget = this.chatWidgetService.getWidgetByInputUri(model.uri);
if (!widget) {
@@ -1124,9 +1126,11 @@ class BuiltinDynamicCompletions extends Disposable {
private async addFileAndFolderEntries(widget: IChatWidget, result: CompletionList, info: { insert: Range; replace: Range; varWord: IWordAtPosition | null }, token: CancellationToken) {
const typedLeader = info.varWord?.word?.charAt(0) === chatAgentLeader ? chatAgentLeader : chatVariableLeader;
const makeCompletionItem = (resource: URI, kind: FileKind, description?: string, boostPriority?: boolean): CompletionItem => {
const basename = this.labelService.getUriBasenameLabel(resource);
const text = `${chatVariableLeader}file:${basename}`;
const text = `${typedLeader}file:${basename}`;
const uriLabel = this.labelService.getUriLabel(resource, { relative: true });
const labelDescription = description
? localize('fileEntryDescription', '{0} ({1})', uriLabel, description)
@@ -1136,7 +1140,7 @@ class BuiltinDynamicCompletions extends Disposable {
return {
label: { label: basename, description: labelDescription },
filterText: `${chatVariableLeader}${basename}`,
filterText: `${basename} ${typedLeader}${basename} ${uriLabel}`,
insertText: info.varWord?.endColumn === info.replace.endColumn ? `${text} ` : text,
range: info,
kind: kind === FileKind.FILE ? CompletionItemKind.File : CompletionItemKind.Folder,
@@ -1154,8 +1158,8 @@ class BuiltinDynamicCompletions extends Disposable {
};
let pattern: string | undefined;
if (info.varWord?.word && info.varWord.word.startsWith(chatVariableLeader)) {
pattern = info.varWord.word.toLowerCase().slice(1); // remove leading #
if (info.varWord?.word && (info.varWord.word.startsWith(chatVariableLeader) || info.varWord.word.startsWith(chatAgentLeader))) {
pattern = info.varWord.word.toLowerCase().slice(1); // remove leading # or @
}
const seen = new ResourceSet();
@@ -1172,8 +1176,10 @@ class BuiltinDynamicCompletions extends Disposable {
if (pattern) {
// use pattern if available
const uriLabel = this.labelService.getUriLabel(resource, { relative: true }).toLowerCase();
const basename = this.labelService.getUriBasenameLabel(resource).toLowerCase();
if (!isPatternInWord(pattern, 0, pattern.length, basename, 0, basename.length)) {
const combined = `${basename} ${uriLabel}`;
if (!isPatternInWord(pattern, 0, pattern.length, combined, 0, combined.length)) {
continue;
}
}
@@ -1218,15 +1224,17 @@ class BuiltinDynamicCompletions extends Disposable {
const timeoutMs = 100;
const stopwatch = new StopWatch();
const typedLeader = info.varWord?.word?.charAt(0) === chatAgentLeader ? chatAgentLeader : chatVariableLeader;
const makeSymbolCompletionItem = (symbolItem: { name: string; location: Location; kind: SymbolKind }, pattern: string): CompletionItem => {
const text = `${chatVariableLeader}sym:${symbolItem.name}`;
const text = `${typedLeader}sym:${symbolItem.name}`;
const resource = symbolItem.location.uri;
const uriLabel = this.labelService.getUriLabel(resource, { relative: true });
const sortText = pattern ? '{' /* after z */ : '|' /* after { */;
return {
label: { label: symbolItem.name, description: uriLabel },
filterText: `${chatVariableLeader}${symbolItem.name}`,
filterText: `${typedLeader}${symbolItem.name}`,
insertText: info.varWord?.endColumn === info.replace.endColumn ? `${text} ` : text,
range: info,
kind: SymbolKinds.toCompletionKind(symbolItem.kind),
@@ -1244,8 +1252,8 @@ class BuiltinDynamicCompletions extends Disposable {
};
let pattern: string | undefined;
if (info.varWord?.word && info.varWord.word.startsWith(chatVariableLeader)) {
pattern = info.varWord.word.toLowerCase().slice(1); // remove leading #
if (info.varWord?.word && (info.varWord.word.startsWith(chatVariableLeader) || info.varWord.word.startsWith(chatAgentLeader))) {
pattern = info.varWord.word.toLowerCase().slice(1); // remove leading # or @
}
const symbolsToAdd: { symbol: DocumentSymbol; uri: URI }[] = [];
@@ -1295,54 +1303,9 @@ class BuiltinDynamicCompletions extends Disposable {
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(BuiltinDynamicCompletions, LifecyclePhase.Eventually);
export interface IChatCompletionRangeResult {
insert: Range;
replace: Range;
varWord: IWordAtPosition | null;
}
export function computeCompletionRanges(model: ITextModel, position: Position, reg: RegExp, onlyOnWordStart = false): IChatCompletionRangeResult | undefined {
const varWord = getWordAtText(position.column, reg, model.getLineContent(position.lineNumber), 0);
if (!varWord && model.getWordUntilPosition(position).word) {
// inside a "normal" word
return;
}
if (!varWord && position.column > 1) {
const textBefore = model.getValueInRange(new Range(position.lineNumber, position.column - 1, position.lineNumber, position.column));
if (textBefore !== ' ') {
return;
}
}
if (varWord && onlyOnWordStart) {
const wordBefore = model.getWordUntilPosition({ lineNumber: position.lineNumber, column: varWord.startColumn });
if (wordBefore.word) {
// inside a word
return;
}
}
let insert: Range;
let replace: Range;
if (!varWord) {
insert = replace = Range.fromPositions(position);
} else {
insert = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, position.column);
replace = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, varWord.endColumn);
}
return { insert, replace, varWord };
}
function isEmptyUpToCompletionWord(model: ITextModel, rangeResult: IChatCompletionRangeResult): boolean {
const startToCompletionWordStart = new Range(1, 1, rangeResult.replace.startLineNumber, rangeResult.replace.startColumn);
return !!model.getValueInRange(startToCompletionWordStart).match(/^\s*$/);
}
class ToolCompletions extends Disposable {
private static readonly VariableNameDef = new RegExp(`(?<=^|\\s)${chatVariableLeader}\\w*`, 'g'); // MUST be using `g`-flag
private static readonly VariableNameDef = new RegExp(`(?<=^|\\s)[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}]\\w*`, 'g'); // MUST be using `g`-flag
constructor(
@ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService,
@@ -1353,7 +1316,7 @@ class ToolCompletions extends Disposable {
this._register(this.languageFeaturesService.completionProvider.register({ scheme: Schemas.vscodeChatInput, hasAccessToAllModels: true }, {
_debugDisplayName: 'chatVariables',
triggerCharacters: [chatVariableLeader],
triggerCharacters: [chatVariableLeader, chatAgentLeader],
provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => {
const widget = this.chatWidgetService.getWidgetByInputUri(model.uri);
if (!widget) {
@@ -1383,6 +1346,8 @@ class ToolCompletions extends Disposable {
}
}
const typedLeader = range.varWord?.word?.charAt(0) === chatAgentLeader ? chatAgentLeader : chatVariableLeader;
const pattern = range.varWord?.word ? range.varWord.word.toLowerCase().slice(1) : '';
const suggestions: CompletionItem[] = [];
@@ -1412,12 +1377,20 @@ class ToolCompletions extends Disposable {
continue;
}
const withLeader = `${chatVariableLeader}${name}`;
if (pattern) {
const lowerName = name.toLowerCase();
if (!isPatternInWord(pattern, 0, pattern.length, lowerName, 0, lowerName.length)) {
continue;
}
}
const withLeader = `${typedLeader}${name}`;
suggestions.push({
label: withLeader,
range,
detail,
documentation,
filterText: `${typedLeader}${name}`,
insertText: withLeader + ' ',
kind: CompletionItemKind.Tool,
});

View File

@@ -0,0 +1,275 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { URI } from '../../../../../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../../base/test/common/utils.js';
import { Position } from '../../../../../../../../editor/common/core/position.js';
import { Range } from '../../../../../../../../editor/common/core/range.js';
import { createTextModel } from '../../../../../../../../editor/test/common/testTextModel.js';
import { DisposableStore } from '../../../../../../../../base/common/lifecycle.js';
import { computeCompletionRanges, escapeForCharClass } from '../../../../../browser/widget/input/editor/chatInputCompletionUtils.js';
import { chatAgentLeader, chatVariableLeader } from '../../../../../common/requestParser/chatParserTypes.js';
suite('escapeForCharClass', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('passes through simple characters unchanged', () => {
assert.strictEqual(escapeForCharClass('a'), 'a');
assert.strictEqual(escapeForCharClass('#'), '#');
assert.strictEqual(escapeForCharClass('@'), '@');
});
test('escapes backslash', () => {
assert.strictEqual(escapeForCharClass('\\'), '\\\\');
});
test('escapes closing bracket', () => {
assert.strictEqual(escapeForCharClass(']'), '\\]');
});
test('escapes caret', () => {
assert.strictEqual(escapeForCharClass('^'), '\\^');
});
test('escapes hyphen', () => {
assert.strictEqual(escapeForCharClass('-'), '\\-');
});
test('escapes multiple special chars in one string', () => {
assert.strictEqual(escapeForCharClass('-^]\\'), '\\-\\^\\]\\\\');
});
test('is safe to use for chatVariableLeader and chatAgentLeader', () => {
// These are the actual values used in the product code
const escaped = `[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}]`;
const re = new RegExp(escaped);
assert.ok(re.test('#'));
assert.ok(re.test('@'));
assert.ok(!re.test('a'));
assert.ok(!re.test('/'));
});
});
suite('computeCompletionRanges', () => {
let store: DisposableStore;
setup(() => {
store = new DisposableStore();
});
teardown(() => {
store.dispose();
});
ensureNoDisposablesAreLeakedInTestSuite();
// Helper: builds the same regex patterns used in the product code
function variableNameDef() {
return new RegExp(`[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}][\\w:-]*`, 'g');
}
function fileWordPattern() {
return new RegExp(`[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}][^\\s]*`, 'g');
}
function toolVariableNameDef() {
return new RegExp(`(?<=^|\\s)[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}]\\w*`, 'g');
}
// --- VariableNameDef pattern tests ---
suite('with VariableNameDef regex', () => {
test('matches #variable at start of line', () => {
const model = store.add(createTextModel('#file', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 6), variableNameDef());
assert.ok(result);
assert.deepStrictEqual(result, {
insert: new Range(1, 1, 1, 6),
replace: new Range(1, 1, 1, 6),
varWord: { word: '#file', startColumn: 1, endColumn: 6 },
});
});
test('matches @variable at start of line', () => {
const model = store.add(createTextModel('@file', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 6), variableNameDef());
assert.ok(result);
assert.deepStrictEqual(result, {
insert: new Range(1, 1, 1, 6),
replace: new Range(1, 1, 1, 6),
varWord: { word: '@file', startColumn: 1, endColumn: 6 },
});
});
test('matches #variable mid-line after space', () => {
const model = store.add(createTextModel('hello #file', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 12), variableNameDef());
assert.ok(result);
assert.deepStrictEqual(result, {
insert: new Range(1, 7, 1, 12),
replace: new Range(1, 7, 1, 12),
varWord: { word: '#file', startColumn: 7, endColumn: 12 },
});
});
test('matches @variable mid-line after space', () => {
const model = store.add(createTextModel('hello @file', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 12), variableNameDef());
assert.ok(result);
assert.deepStrictEqual(result, {
insert: new Range(1, 7, 1, 12),
replace: new Range(1, 7, 1, 12),
varWord: { word: '@file', startColumn: 7, endColumn: 12 },
});
});
test('matches # alone (just the leader)', () => {
const model = store.add(createTextModel('#', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 2), variableNameDef());
assert.ok(result);
assert.strictEqual(result.varWord?.word, '#');
});
test('matches @ alone (just the leader)', () => {
const model = store.add(createTextModel('@', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 2), variableNameDef());
assert.ok(result);
assert.strictEqual(result.varWord?.word, '@');
});
test('matches variable with colons and hyphens', () => {
const model = store.add(createTextModel('#file:test-1', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 13), variableNameDef());
assert.ok(result);
assert.strictEqual(result.varWord?.word, '#file:test-1');
});
test('cursor in middle of variable produces partial insert range', () => {
const model = store.add(createTextModel('@selection', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 5), variableNameDef());
assert.ok(result);
assert.deepStrictEqual(result, {
insert: new Range(1, 1, 1, 5),
replace: new Range(1, 1, 1, 11),
varWord: { word: '@selection', startColumn: 1, endColumn: 11 },
});
});
});
// --- fileWordPattern tests ---
suite('with fileWordPattern regex', () => {
test('matches #file:path/to/file.ts', () => {
const model = store.add(createTextModel('#file:path/to/file.ts', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 22), fileWordPattern());
assert.ok(result);
assert.strictEqual(result.varWord?.word, '#file:path/to/file.ts');
});
test('matches @file:path/to/file.ts', () => {
const model = store.add(createTextModel('@file:path/to/file.ts', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 22), fileWordPattern());
assert.ok(result);
assert.strictEqual(result.varWord?.word, '@file:path/to/file.ts');
});
test('stops at whitespace', () => {
const model = store.add(createTextModel('#file:test rest', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 11), fileWordPattern());
assert.ok(result);
assert.strictEqual(result.varWord?.word, '#file:test');
});
});
// --- toolVariableNameDef tests ---
suite('with toolVariableNameDef regex', () => {
test('matches #tool at start of line', () => {
const model = store.add(createTextModel('#tool', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 6), toolVariableNameDef());
assert.ok(result);
assert.strictEqual(result.varWord?.word, '#tool');
});
test('matches @tool at start of line', () => {
const model = store.add(createTextModel('@tool', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 6), toolVariableNameDef());
assert.ok(result);
assert.strictEqual(result.varWord?.word, '@tool');
});
test('matches #tool after space', () => {
const model = store.add(createTextModel('use #fetch', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 11), toolVariableNameDef());
assert.ok(result);
assert.strictEqual(result.varWord?.word, '#fetch');
});
test('matches @tool after space', () => {
const model = store.add(createTextModel('use @fetch', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 11), toolVariableNameDef());
assert.ok(result);
assert.strictEqual(result.varWord?.word, '@fetch');
});
});
// --- Edge cases ---
suite('edge cases', () => {
test('returns undefined inside a normal word', () => {
const model = store.add(createTextModel('hello', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 3), variableNameDef());
assert.strictEqual(result, undefined);
});
test('returns undefined when no space before cursor mid-line', () => {
const model = store.add(createTextModel('ab', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 3), variableNameDef());
assert.strictEqual(result, undefined);
});
test('returns empty range at blank position after space', () => {
const model = store.add(createTextModel('hello ', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 7), variableNameDef());
assert.ok(result);
assert.strictEqual(result.varWord, null);
assert.deepStrictEqual(result.insert, Range.fromPositions(new Position(1, 7)));
});
test('returns empty range at start of empty line', () => {
const model = store.add(createTextModel('', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 1), variableNameDef());
assert.ok(result);
assert.strictEqual(result.varWord, null);
});
test('onlyOnWordStart=true rejects variable preceded by a word', () => {
const model = store.add(createTextModel('abc#file', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 9), variableNameDef(), true);
assert.strictEqual(result, undefined);
});
test('onlyOnWordStart=true accepts variable after space', () => {
const model = store.add(createTextModel('abc #file', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 10), variableNameDef(), true);
assert.ok(result);
assert.strictEqual(result.varWord?.word, '#file');
});
test('onlyOnWordStart=true accepts @variable after space', () => {
const model = store.add(createTextModel('abc @file', null, undefined, URI.parse('test:input')));
const result = computeCompletionRanges(model, new Position(1, 10), variableNameDef(), true);
assert.ok(result);
assert.strictEqual(result.varWord?.word, '@file');
});
});
});

View File

@@ -24,7 +24,7 @@ import { IEditorService } from '../../../../../services/editor/common/editorServ
import { IChatWidget, IChatWidgetService } from '../../../../chat/browser/chat.js';
import { IChatContextPicker, IChatContextPickerItem, IChatContextPickerPickItem, IChatContextPickService } from '../../../../chat/browser/attachments/chatContextPickService.js';
import { ChatDynamicVariableModel } from '../../../../chat/browser/attachments/chatDynamicVariables.js';
import { computeCompletionRanges } from '../../../../chat/browser/widget/input/editor/chatInputCompletions.js';
import { computeCompletionRanges } from '../../../../chat/browser/widget/input/editor/chatInputCompletionUtils.js';
import { IChatAgentService } from '../../../../chat/common/participants/chatAgents.js';
import { ChatContextKeys } from '../../../../chat/common/actions/chatContextKeys.js';
import { chatVariableLeader } from '../../../../chat/common/requestParser/chatParserTypes.js';