Skip to content

Commit b127cf9

Browse files
authored
feat: support multi inline completion (#3874)
* chore: implement intelligent completions api * chore: implement diff algorithm * feat: handle diff result decoration render * chore: improve trigger * chore: add tests * feat: support accept & esc * refactor: inline completion provider * feat: add context bean * fix: ci * fix: ci * feat: improve code * chore: use enableMultiLine
1 parent 71469b4 commit b127cf9

26 files changed

Lines changed: 1513 additions & 399 deletions
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { MultiLineDiffComputer } from '@opensumi/ide-ai-native/lib/browser/contrib/intelligent-completions/diff-computer';
2+
3+
describe('MultiLineDiffComputer', () => {
4+
let diffComputer: MultiLineDiffComputer;
5+
6+
beforeEach(() => {
7+
diffComputer = new MultiLineDiffComputer();
8+
});
9+
10+
test('equals method should return true for equal strings', () => {
11+
expect(diffComputer['equals']('a', 'a')).toBe(true);
12+
});
13+
14+
test('equals method should return false for different strings', () => {
15+
expect(diffComputer['equals']('a', 'b')).toBe(false);
16+
});
17+
18+
test('extractCommon method should find common elements', () => {
19+
const element = { newPos: 0, changeResult: [] };
20+
const modified = ['a', 'b', 'c'];
21+
const original = ['a', 'b', 'c'];
22+
const diagonal = 0;
23+
24+
const result = diffComputer['extractCommon'](element, modified, original, diagonal);
25+
expect(result).toBe(2);
26+
expect(element.newPos).toBe(2);
27+
expect(element.changeResult).toEqual([{ count: 2, value: '' }]);
28+
});
29+
30+
test('diff method should return undefined for no differences', () => {
31+
const originalContent = 'a\nb\nc';
32+
const modifiedContent = 'a\nb\nc';
33+
const result = diffComputer.diff(originalContent, modifiedContent);
34+
expect(Array.isArray(result)).toBeTruthy();
35+
expect(result).toStrictEqual([{ value: modifiedContent, count: modifiedContent.length }]);
36+
});
37+
38+
test('diff method should detect all lines added', () => {
39+
const originalContent = '';
40+
const modifiedContent = 'a\nb\nc';
41+
const result = diffComputer.diff(originalContent, modifiedContent);
42+
expect(result).toEqual([{ added: true, count: modifiedContent.length, value: modifiedContent }]);
43+
});
44+
45+
test('diff method should detect all lines removed', () => {
46+
const originalContent = 'a\nb\nc';
47+
const modifiedContent = '';
48+
const result = diffComputer.diff(originalContent, modifiedContent);
49+
expect(result).toEqual([{ removed: true, count: originalContent.length, value: originalContent }]);
50+
});
51+
52+
test('diff method should detect some lines added and some removed', () => {
53+
const originalContent = 'a\nb\nc';
54+
const modifiedContent = 'a\nx\nc';
55+
const result = diffComputer.diff(originalContent, modifiedContent);
56+
expect(result).toEqual([
57+
{ count: 2, value: 'a\n' },
58+
{ added: undefined, removed: true, count: 1, value: 'b' },
59+
{ added: true, removed: undefined, count: 1, value: 'x' },
60+
{ count: 2, value: '\nc' },
61+
]);
62+
});
63+
64+
test('diff method should detect mixed changes', () => {
65+
const originalContent = 'a\nb\nc\nd';
66+
const modifiedContent = 'a\nx\nc\ny';
67+
const result = diffComputer.diff(originalContent, modifiedContent);
68+
expect(result).toEqual([
69+
{ count: 2, value: 'a\n' },
70+
{ added: undefined, removed: true, count: 1, value: 'b' },
71+
{ added: true, removed: undefined, count: 1, value: 'x' },
72+
{ count: 3, value: '\nc\n' },
73+
{ added: undefined, removed: true, count: 1, value: 'd' },
74+
{ added: true, removed: undefined, count: 1, value: 'y' },
75+
]);
76+
});
77+
});
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import {
2+
GHOST_TEXT,
3+
GHOST_TEXT_DESCRIPTION,
4+
MultiLineDecorationModel,
5+
} from '@opensumi/ide-ai-native/lib/browser/contrib/intelligent-completions/multi-line.decoration';
6+
import { ICodeEditor, IPosition } from '@opensumi/ide-monaco';
7+
import { monacoApi } from '@opensumi/ide-monaco/lib/browser/monaco-api';
8+
9+
import { IDiffChangeResult } from '../../../../lib/browser/contrib/intelligent-completions/diff-computer';
10+
import { EnhanceDecorationsCollection } from '../../../../src/browser/model/enhanceDecorationsCollection';
11+
12+
describe('MultiLineDecorationModel', () => {
13+
let editor: ICodeEditor;
14+
let decorationsCollection: EnhanceDecorationsCollection;
15+
let multiLineDecorationModel: MultiLineDecorationModel;
16+
17+
beforeEach(() => {
18+
editor = monacoApi.editor.create(document.createElement('div'), {});
19+
multiLineDecorationModel = new MultiLineDecorationModel(editor);
20+
decorationsCollection = multiLineDecorationModel['ghostTextDecorations'];
21+
22+
editor.setValue(`export class Person {
23+
name: string;
24+
age: number;
25+
}
26+
27+
// 注释内容
28+
const person: Person = {
29+
name: "OpenSumi",
30+
age: 18
31+
};
32+
33+
function greet(person: Person) {
34+
console.log(\`Hello, \${person.name}!\`);
35+
}
36+
37+
greet(person); // Output: "Hello, OpenSumi!"`);
38+
});
39+
40+
it('should initialize correctly', () => {
41+
expect(multiLineDecorationModel).toBeDefined();
42+
expect(decorationsCollection.clear).toBeDefined();
43+
});
44+
45+
it('should split diff changes correctly', () => {
46+
const lines: IDiffChangeResult[] = [
47+
{ value: 'line1\nline2', added: true, removed: false },
48+
{ value: 'line3', added: false, removed: true },
49+
];
50+
const result = multiLineDecorationModel['splitDiffChanges'](lines, '\n');
51+
expect(result).toEqual([
52+
{ value: 'line1', added: true, removed: false },
53+
{ value: '\n', added: true, removed: false },
54+
{ value: 'line2', added: true, removed: false },
55+
{ value: 'line3', added: false, removed: true },
56+
]);
57+
});
58+
59+
it('should combine continuous modifications correctly', () => {
60+
const modifications = [
61+
{ newValue: 'line1', oldValue: '', isEolLine: false },
62+
{ newValue: 'line2', oldValue: '', isEolLine: true },
63+
{ newValue: 'line3', oldValue: '', isEolLine: false },
64+
];
65+
const result = multiLineDecorationModel['combineContinuousMods'](modifications);
66+
expect(result).toEqual(['line1', 'line3']);
67+
});
68+
69+
it('should process line modifications correctly', () => {
70+
const modifications = [
71+
{ newValue: 'line1', oldValue: '', isEolLine: false },
72+
{ newValue: 'line2', oldValue: '', isEolLine: true },
73+
];
74+
const previous = { value: 'prev', added: false, removed: true };
75+
const next = { value: 'next', added: true, removed: false };
76+
const result = multiLineDecorationModel['processLineModifications'](modifications, '\n', previous, next);
77+
expect(result).toEqual({
78+
fullLineMods: [],
79+
inlineMods: [{ status: 'beginning', newValue: 'prevline1', oldValue: 'prev' }],
80+
});
81+
});
82+
83+
it('should apply inline decorations correctly', () => {
84+
const changes: IDiffChangeResult[] = [
85+
{ value: 'const person: Person = {\n name: "' },
86+
{ value: 'Hello ', added: true, removed: undefined },
87+
{ value: 'OpenSumi",\n age: 18' },
88+
{ value: ' + 1', added: true, removed: undefined },
89+
{ value: '\n};' },
90+
];
91+
const cursorPosition: IPosition = { lineNumber: 7, column: 1 };
92+
const result = multiLineDecorationModel.applyInlineDecorations(
93+
editor,
94+
changes,
95+
cursorPosition.lineNumber,
96+
cursorPosition,
97+
);
98+
99+
expect(result).toEqual([
100+
{
101+
lineNumber: 8,
102+
column: 10,
103+
newValue: ' name: "Hello ',
104+
oldValue: ' name: "',
105+
},
106+
{
107+
lineNumber: 9,
108+
column: 10,
109+
newValue: ' age: 18 + 1',
110+
oldValue: ' age: 18',
111+
},
112+
]);
113+
});
114+
115+
it('should update line modification decorations correctly', () => {
116+
/**
117+
* 例如原始内容是:
118+
* const person: Person = {
119+
* name: "OpenSumi",
120+
* age: 18
121+
* };
122+
*
123+
* 修改后的内容是:
124+
* const person: Person = {
125+
* name: "Hello OpenSumi",
126+
* age: 18 + 1
127+
* };
128+
*
129+
* 则期望在 editor 当中的 ghost-text 装饰器应该是在第 8 行中的 "Hello " 和第 9 行的 " + 1"。
130+
*/
131+
let modifications = [
132+
{
133+
lineNumber: 8,
134+
column: 10,
135+
newValue: ' name: "Hello ',
136+
oldValue: ' name: "',
137+
},
138+
{
139+
lineNumber: 9,
140+
column: 10,
141+
newValue: ' age: 18 + 1',
142+
oldValue: ' age: 18',
143+
},
144+
];
145+
146+
multiLineDecorationModel.updateLineModificationDecorations(modifications);
147+
148+
jest.setTimeout(10);
149+
150+
let lineDecorations = editor.getLineDecorations(8) || [];
151+
let findDecoration = lineDecorations.find((lineDecoration) => lineDecoration.options.description === GHOST_TEXT);
152+
153+
expect(findDecoration).not.toBeUndefined();
154+
expect(findDecoration!.options.after?.content).toEqual('Hello ');
155+
expect(findDecoration!.options.after?.inlineClassName).toEqual(GHOST_TEXT_DESCRIPTION);
156+
157+
lineDecorations = editor.getLineDecorations(9) || [];
158+
findDecoration = lineDecorations.find((lineDecoration) => lineDecoration.options.description === GHOST_TEXT);
159+
160+
expect(findDecoration).not.toBeUndefined();
161+
expect(findDecoration!.options.after?.content).toEqual(' + 1');
162+
expect(findDecoration!.options.after?.inlineClassName).toEqual(GHOST_TEXT_DESCRIPTION);
163+
164+
/**
165+
* 例如原始内容是:
166+
* function greet(person: Person) {
167+
* console.log(\`Hello, \${person.name}!\`);
168+
* }
169+
*
170+
* 修改后的内容是:
171+
* function greets(persons: Persons) {
172+
* console.log(\`Hello, \${persons.name}!\`);
173+
* }
174+
*
175+
* 则期望在 editor 当中的 ghost-text 装饰器应该分别是:
176+
* 在第 12 行中 "function greet" 后面的 "s"、"person" 后面的 "s" 以及 "Person" 后面的 "s";
177+
* 在第 13 行的 "console.log(\`Hello, \${person" 后面的 "s"
178+
*/
179+
modifications = [
180+
{
181+
lineNumber: 12,
182+
column: 15,
183+
newValue: 'function greets',
184+
oldValue: 'function greet',
185+
},
186+
{
187+
lineNumber: 12,
188+
column: 22,
189+
newValue: '(persons',
190+
oldValue: '(person',
191+
},
192+
{
193+
lineNumber: 12,
194+
column: 30,
195+
newValue: ': Persons',
196+
oldValue: ': Person',
197+
},
198+
{
199+
lineNumber: 13,
200+
column: 33,
201+
newValue: '${persons',
202+
oldValue: '${person',
203+
},
204+
];
205+
206+
multiLineDecorationModel.updateLineModificationDecorations(modifications);
207+
208+
jest.setTimeout(10);
209+
210+
lineDecorations = editor.getLineDecorations(12) || [];
211+
const filterDecoration = lineDecorations.filter(
212+
(lineDecoration) => lineDecoration.options.description === GHOST_TEXT,
213+
);
214+
215+
expect(filterDecoration.length).toBe(3);
216+
217+
lineDecorations = editor.getLineDecorations(13) || [];
218+
findDecoration = lineDecorations.find((lineDecoration) => lineDecoration.options.description === GHOST_TEXT);
219+
220+
expect(findDecoration).not.toBeUndefined();
221+
expect(findDecoration!.options.after?.content).toEqual('s');
222+
expect(findDecoration!.options.after?.inlineClassName).toEqual(GHOST_TEXT_DESCRIPTION);
223+
});
224+
});

packages/ai-native/src/browser/ai-core.contribution.ts

Lines changed: 12 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
ChatRenderRegistryToken,
4646
CommandService,
4747
InlineChatFeatureRegistryToken,
48+
IntelligentCompletionsRegistryToken,
4849
RenameCandidatesProviderRegistryToken,
4950
ResolveConflictRegistryToken,
5051
TerminalRegistryToken,
@@ -81,9 +82,9 @@ import { AIChatTabRenderer, AILeftTabRenderer, AIRightTabRenderer } from './layo
8182
import { AIChatLogoAvatar } from './layout/view/avatar/avatar.view';
8283
import {
8384
AINativeCoreContribution,
84-
IAIMiddleware,
8585
IChatFeatureRegistry,
8686
IChatRenderRegistry,
87+
IIntelligentCompletionsRegistry,
8788
IRenameCandidatesProviderRegistry,
8889
IResolveConflictRegistry,
8990
ITerminalProviderRegistry,
@@ -139,6 +140,9 @@ export class AINativeBrowserContribution
139140
@Autowired(TerminalRegistryToken)
140141
private readonly terminalProviderRegistry: ITerminalProviderRegistry;
141142

143+
@Autowired(IntelligentCompletionsRegistryToken)
144+
private readonly intelligentCompletionsRegistry: IIntelligentCompletionsRegistry;
145+
142146
@Autowired(AINativeConfigService)
143147
private readonly aiNativeConfigService: AINativeConfigService;
144148

@@ -228,33 +232,15 @@ export class AINativeBrowserContribution
228232
}
229233

230234
private registerFeature() {
231-
const middlewares: IAIMiddleware[] = [];
232-
233235
this.contributions.getContributions().forEach((contribution) => {
234-
if (contribution.registerInlineChatFeature) {
235-
contribution.registerInlineChatFeature(this.inlineChatFeatureRegistry);
236-
}
237-
if (contribution.registerChatFeature) {
238-
contribution.registerChatFeature(this.chatFeatureRegistry);
239-
}
240-
if (contribution.registerResolveConflictFeature) {
241-
contribution.registerResolveConflictFeature(this.resolveConflictRegistry);
242-
}
243-
if (contribution.registerRenameProvider) {
244-
contribution.registerRenameProvider(this.renameCandidatesProviderRegistry);
245-
}
246-
if (contribution.registerChatRender) {
247-
contribution.registerChatRender(this.chatRenderRegistry);
248-
}
249-
if (contribution.registerTerminalProvider) {
250-
contribution.registerTerminalProvider(this.terminalProviderRegistry);
251-
}
252-
if (contribution.middleware) {
253-
middlewares.push(contribution.middleware);
254-
}
236+
contribution.registerInlineChatFeature?.(this.inlineChatFeatureRegistry);
237+
contribution.registerChatFeature?.(this.chatFeatureRegistry);
238+
contribution.registerResolveConflictFeature?.(this.resolveConflictRegistry);
239+
contribution.registerRenameProvider?.(this.renameCandidatesProviderRegistry);
240+
contribution.registerChatRender?.(this.chatRenderRegistry);
241+
contribution.registerTerminalProvider?.(this.terminalProviderRegistry);
242+
contribution.registerIntelligentCompletionFeature?.(this.intelligentCompletionsRegistry);
255243
});
256-
257-
this.inlineCompletionHandler.updateConfig(middlewares);
258244
}
259245

260246
registerSetting(registry: ISettingRegistry) {

0 commit comments

Comments
 (0)