Skip to content

Commit 14908a2

Browse files
authored
Fix: Prevent autocompletion from triggering incorrectly within words (#15030)
This commit fixes an issue where autocompletion would incorrectly trigger when typing a variable character '#' in the middle of words (e.g., "Hello#"). Changes: - Improved boundary detection in the variable argument picker to prevent triggering when '#' appears within words - Added better context validation to ensure completion only happens in appropriate cases Fixed #15028
1 parent 65f542c commit 14908a2

File tree

1 file changed

+120
-75
lines changed

1 file changed

+120
-75
lines changed

packages/ai-chat-ui/src/browser/chat-view-language-contribution.ts

Lines changed: 120 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ const VARIABLE_RESOLUTION_CONTEXT = { context: 'chat-input-autocomplete' };
3131
const VARIABLE_ARGUMENT_PICKER_COMMAND = 'trigger-variable-argument-picker';
3232
const VARIABLE_ADD_CONTEXT_COMMAND = 'add-context-variable';
3333

34+
interface CompletionSource<T> {
35+
triggerCharacter: string;
36+
getItems: () => T[];
37+
kind: monaco.languages.CompletionItemKind;
38+
getId: (item: T) => string;
39+
getName: (item: T) => string;
40+
getDescription: (item: T) => string;
41+
command?: monaco.languages.Command;
42+
}
43+
3444
@injectable()
3545
export class ChatViewLanguageContribution implements FrontendApplicationContribution {
3646

@@ -49,37 +59,84 @@ export class ChatViewLanguageContribution implements FrontendApplicationContribu
4959
onStart(_app: FrontendApplication): MaybePromise<void> {
5060
monaco.languages.register({ id: CHAT_VIEW_LANGUAGE_ID, extensions: [CHAT_VIEW_LANGUAGE_EXTENSION] });
5161

52-
monaco.languages.registerCompletionItemProvider(CHAT_VIEW_LANGUAGE_ID, {
53-
triggerCharacters: [PromptText.AGENT_CHAR],
54-
provideCompletionItems: (model, position, _context, _token): ProviderResult<monaco.languages.CompletionList> => this.provideAgentCompletions(model, position),
62+
this.registerCompletionProviders();
63+
64+
monaco.editor.registerCommand(VARIABLE_ARGUMENT_PICKER_COMMAND, this.triggerVariableArgumentPicker.bind(this));
65+
monaco.editor.registerCommand(VARIABLE_ADD_CONTEXT_COMMAND, (_, ...args) => args.length > 1 ? this.addContextVariable(args[0], args[1]) : undefined);
66+
}
67+
68+
protected registerCompletionProviders(): void {
69+
this.registerStandardCompletionProvider({
70+
triggerCharacter: PromptText.AGENT_CHAR,
71+
getItems: () => this.agentService.getAgents(),
72+
kind: monaco.languages.CompletionItemKind.Value,
73+
getId: agent => `${agent.id} `,
74+
getName: agent => agent.name,
75+
getDescription: agent => agent.description
5576
});
56-
monaco.languages.registerCompletionItemProvider(CHAT_VIEW_LANGUAGE_ID, {
57-
triggerCharacters: [PromptText.VARIABLE_CHAR],
58-
provideCompletionItems: (model, position, _context, _token): ProviderResult<monaco.languages.CompletionList> => this.provideVariableCompletions(model, position),
77+
78+
this.registerStandardCompletionProvider({
79+
triggerCharacter: PromptText.VARIABLE_CHAR,
80+
getItems: () => this.variableService.getVariables(),
81+
kind: monaco.languages.CompletionItemKind.Variable,
82+
getId: variable => variable.args?.some(arg => !arg.isOptional) ? variable.name + PromptText.VARIABLE_SEPARATOR_CHAR : `${variable.name} `,
83+
getName: variable => variable.name,
84+
getDescription: variable => variable.description,
85+
command: {
86+
title: nls.localize('theia/ai/chat-ui/selectVariableArguments', 'Select variable arguments'),
87+
id: VARIABLE_ARGUMENT_PICKER_COMMAND,
88+
}
89+
});
90+
91+
this.registerStandardCompletionProvider({
92+
triggerCharacter: PromptText.FUNCTION_CHAR,
93+
getItems: () => this.toolInvocationRegistry.getAllFunctions(),
94+
kind: monaco.languages.CompletionItemKind.Function,
95+
getId: tool => `${tool.id} `,
96+
getName: tool => tool.name,
97+
getDescription: tool => tool.description ?? ''
5998
});
99+
100+
// Register the variable argument completion provider (special case)
60101
monaco.languages.registerCompletionItemProvider(CHAT_VIEW_LANGUAGE_ID, {
61102
triggerCharacters: [PromptText.VARIABLE_CHAR, PromptText.VARIABLE_SEPARATOR_CHAR],
62-
provideCompletionItems: (model, position, _context, _token): ProviderResult<monaco.languages.CompletionList> => this.provideVariableWithArgCompletions(model, position),
103+
provideCompletionItems: (model, position, _context, _token): ProviderResult<monaco.languages.CompletionList> =>
104+
this.provideVariableWithArgCompletions(model, position),
63105
});
106+
}
107+
108+
protected registerStandardCompletionProvider<T>(source: CompletionSource<T>): void {
64109
monaco.languages.registerCompletionItemProvider(CHAT_VIEW_LANGUAGE_ID, {
65-
triggerCharacters: [PromptText.FUNCTION_CHAR],
66-
provideCompletionItems: (model, position, _context, _token): ProviderResult<monaco.languages.CompletionList> => this.provideToolCompletions(model, position),
110+
triggerCharacters: [source.triggerCharacter],
111+
provideCompletionItems: (model, position, _context, _token): ProviderResult<monaco.languages.CompletionList> =>
112+
this.provideCompletions(model, position, source),
67113
});
68-
69-
monaco.editor.registerCommand(VARIABLE_ARGUMENT_PICKER_COMMAND, this.triggerVariableArgumentPicker.bind(this));
70-
monaco.editor.registerCommand(VARIABLE_ADD_CONTEXT_COMMAND, (_, ...args) => args.length > 1 ? this.addContextVariable(args[0], args[1]) : undefined);
71114
}
72115

73116
getCompletionRange(model: monaco.editor.ITextModel, position: monaco.Position, triggerCharacter: string): monaco.Range | undefined {
74117
const wordInfo = model.getWordUntilPosition(position);
75118
const lineContent = model.getLineContent(position.lineNumber);
76119
// one to the left, and -1 for 0-based index
77120
const characterBeforeCurrentWord = lineContent[wordInfo.startColumn - 1 - 1];
78-
// return suggestions only if the word is directly preceded by the trigger character
121+
79122
if (characterBeforeCurrentWord !== triggerCharacter) {
80123
return undefined;
81124
}
82125

126+
// we are not at the beginning of the line
127+
if (wordInfo.startColumn > 2) {
128+
const charBeforeTrigger = model.getValueInRange({
129+
startLineNumber: position.lineNumber,
130+
startColumn: wordInfo.startColumn - 2,
131+
endLineNumber: position.lineNumber,
132+
endColumn: wordInfo.startColumn - 1
133+
});
134+
// If the character before the trigger is not whitespace, don't provide completions
135+
if (!/\s/.test(charBeforeTrigger)) {
136+
return undefined;
137+
}
138+
}
139+
83140
return new monaco.Range(
84141
position.lineNumber,
85142
wordInfo.startColumn,
@@ -88,73 +145,65 @@ export class ChatViewLanguageContribution implements FrontendApplicationContribu
88145
);
89146
}
90147

91-
private getSuggestions<T>(
148+
protected provideCompletions<T>(
92149
model: monaco.editor.ITextModel,
93150
position: monaco.Position,
94-
triggerChar: string,
95-
items: T[],
96-
kind: monaco.languages.CompletionItemKind,
97-
getId: (item: T) => string,
98-
getName: (item: T) => string,
99-
getDescription: (item: T) => string,
100-
command?: monaco.languages.Command
151+
source: CompletionSource<T>
101152
): ProviderResult<monaco.languages.CompletionList> {
102-
const completionRange = this.getCompletionRange(model, position, triggerChar);
153+
const completionRange = this.getCompletionRange(model, position, source.triggerCharacter);
103154
if (completionRange === undefined) {
104155
return { suggestions: [] };
105156
}
157+
158+
const items = source.getItems();
106159
const suggestions = items.map(item => ({
107-
insertText: getId(item),
108-
kind: kind,
109-
label: getName(item),
160+
insertText: source.getId(item),
161+
kind: source.kind,
162+
label: source.getName(item),
110163
range: completionRange,
111-
detail: getDescription(item),
112-
command
164+
detail: source.getDescription(item),
165+
command: source.command
113166
}));
167+
114168
return { suggestions };
115169
}
116170

117-
provideAgentCompletions(model: monaco.editor.ITextModel, position: monaco.Position): ProviderResult<monaco.languages.CompletionList> {
118-
return this.getSuggestions(
119-
model,
120-
position,
121-
PromptText.AGENT_CHAR,
122-
this.agentService.getAgents(),
123-
monaco.languages.CompletionItemKind.Value,
124-
agent => `${agent.id} `,
125-
agent => agent.name,
126-
agent => agent.description
127-
);
128-
}
171+
async provideVariableWithArgCompletions(model: monaco.editor.ITextModel, position: monaco.Position): Promise<monaco.languages.CompletionList> {
172+
// Get the text of the current line up to the cursor position
173+
const textUntilPosition = model.getValueInRange({
174+
startLineNumber: position.lineNumber,
175+
startColumn: 1,
176+
endLineNumber: position.lineNumber,
177+
endColumn: position.column,
178+
});
129179

130-
provideVariableCompletions(model: monaco.editor.ITextModel, position: monaco.Position): ProviderResult<monaco.languages.CompletionList> {
131-
return this.getSuggestions(
132-
model,
133-
position,
134-
PromptText.VARIABLE_CHAR,
135-
this.variableService.getVariables(),
136-
monaco.languages.CompletionItemKind.Variable,
137-
variable => variable.args?.some(arg => !arg.isOptional) ? variable.name + PromptText.VARIABLE_SEPARATOR_CHAR : `${variable.name} `,
138-
variable => variable.name,
139-
variable => variable.description,
140-
{
141-
title: nls.localize('theia/ai/chat-ui/selectVariableArguments', 'Select variable arguments'),
142-
id: VARIABLE_ARGUMENT_PICKER_COMMAND,
143-
}
144-
);
145-
}
180+
// Regex that captures the variable name in contexts like "#varname" or "#var-name:args"
181+
// Matches only when # is at the beginning of the string or after whitespace
182+
const variableRegex = /(?:^|\s)#([\w-]*)/;
183+
const match = textUntilPosition.match(variableRegex);
184+
185+
if (!match) {
186+
return { suggestions: [] };
187+
}
188+
189+
const currentVariableName = match[1];
190+
const hasColonSeparator = textUntilPosition.includes(`${currentVariableName}:`);
146191

147-
async provideVariableWithArgCompletions(model: monaco.editor.ITextModel, position: monaco.Position): Promise<monaco.languages.CompletionList> {
148192
const variables = this.variableService.getVariables();
149193
const suggestions: monaco.languages.CompletionItem[] = [];
194+
150195
for (const variable of variables) {
196+
// If we have a variable:arg pattern, only process the matching variable
197+
if (hasColonSeparator && variable.name !== currentVariableName) {
198+
continue;
199+
}
200+
151201
const provider = await this.variableService.getArgumentCompletionProvider(variable.name);
152202
if (provider) {
153203
const items = await provider(model, position);
154204
if (items) {
155205
suggestions.push(...items.map(item => ({
156206
...item,
157-
// trigger command to check if we should add a context variable
158207
command: {
159208
title: nls.localize('theia/ai/chat-ui/addContextVariable', 'Add context variable'),
160209
id: VARIABLE_ADD_CONTEXT_COMMAND,
@@ -164,62 +213,57 @@ export class ChatViewLanguageContribution implements FrontendApplicationContribu
164213
}
165214
}
166215
}
167-
return { suggestions };
168-
}
169216

170-
provideToolCompletions(model: monaco.editor.ITextModel, position: monaco.Position): ProviderResult<monaco.languages.CompletionList> {
171-
return this.getSuggestions(
172-
model,
173-
position,
174-
PromptText.FUNCTION_CHAR,
175-
this.toolInvocationRegistry.getAllFunctions(),
176-
monaco.languages.CompletionItemKind.Function,
177-
tool => `${tool.id} `,
178-
tool => tool.name,
179-
tool => tool.description ?? ''
180-
);
217+
return { suggestions };
181218
}
182219

183220
protected async triggerVariableArgumentPicker(): Promise<void> {
184221
const inputEditor = monaco.editor.getEditors().find(editor => editor.hasTextFocus());
185222
if (!inputEditor) {
186223
return;
187224
}
225+
188226
const model = inputEditor.getModel();
189227
const position = inputEditor.getPosition();
190228
if (!model || !position) {
191229
return;
192230
}
193231

232+
// // Get the word at cursor
233+
const wordInfo = model.getWordUntilPosition(position);
234+
194235
// account for the variable separator character if present
195236
let endOfWordPosition = position.column;
196-
let insertTextPrefix = PromptText.VARIABLE_SEPARATOR_CHAR;
197-
if (this.getCharacterBeforePosition(model, position) === PromptText.VARIABLE_SEPARATOR_CHAR) {
237+
if (wordInfo.word === '' && this.getCharacterBeforePosition(model, position) === PromptText.VARIABLE_SEPARATOR_CHAR) {
198238
endOfWordPosition = position.column - 1;
199-
insertTextPrefix = '';
239+
} else {
240+
return;
200241
}
201242

202243
const variableName = model.getWordAtPosition({ ...position, column: endOfWordPosition })?.word;
203244
if (!variableName) {
204245
return;
205246
}
247+
206248
const provider = await this.variableService.getArgumentPicker(variableName, VARIABLE_RESOLUTION_CONTEXT);
207249
if (!provider) {
208250
return;
209251
}
252+
210253
const arg = await provider(VARIABLE_RESOLUTION_CONTEXT);
211254
if (!arg) {
212255
return;
213256
}
257+
214258
inputEditor.executeEdits('variable-argument-picker', [{
215259
range: new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column),
216-
text: insertTextPrefix + arg
260+
text: arg
217261
}]);
262+
218263
await this.addContextVariable(variableName, arg);
219264
}
220265

221266
protected getCharacterBeforePosition(model: monaco.editor.ITextModel, position: monaco.Position): string {
222-
// one to the left, and -1 for 0-based index
223267
return model.getLineContent(position.lineNumber)[position.column - 1 - 1];
224268
}
225269

@@ -228,6 +272,7 @@ export class ChatViewLanguageContribution implements FrontendApplicationContribu
228272
if (!variable || !AIContextVariable.is(variable)) {
229273
return;
230274
}
275+
231276
const widget = this.shell.getWidgetById(ChatViewWidget.ID);
232277
if (widget instanceof ChatViewWidget) {
233278
widget.addContext({ variable, arg });

0 commit comments

Comments
 (0)