diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletionUtils.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletionUtils.ts new file mode 100644 index 00000000000..405673dd4a6 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletionUtils.ts @@ -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*$/); +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts index aaabd345bac..e34a24b8c36 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts @@ -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, 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(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, }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts new file mode 100644 index 00000000000..686fee9beb9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts @@ -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'); + }); + }); +}); diff --git a/src/vs/workbench/contrib/notebook/browser/controller/chat/notebook.chat.contribution.ts b/src/vs/workbench/contrib/notebook/browser/controller/chat/notebook.chat.contribution.ts index 251cf6b7521..4ca8a0ff9e0 100644 --- a/src/vs/workbench/contrib/notebook/browser/controller/chat/notebook.chat.contribution.ts +++ b/src/vs/workbench/contrib/notebook/browser/controller/chat/notebook.chat.contribution.ts @@ -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';