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
2 changes: 1 addition & 1 deletion src/extension/xtab/node/xtabProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -764,7 +764,7 @@ export class XtabProvider implements IStatelessNextEditProvider {
}

const diffOptions: ResponseProcessor.DiffParams = {
emitFastCursorLineChange: this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabProviderEmitFastCursorLineChange, this.expService),
emitFastCursorLineChange: ResponseProcessor.mapEmitFastCursorLineChange(this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabProviderEmitFastCursorLineChange, this.expService)),
nLinesToConverge: this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabNNonSignificantLinesToConverge, this.expService),
nSignificantLinesToConverge: this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabNSignificantLinesToConverge, this.expService),
};
Expand Down
77 changes: 76 additions & 1 deletion src/extension/xtab/test/common/responseProcessor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { expect, suite, test } from 'vitest';
import { describe, expect, it, suite, test } from 'vitest';
import { ResponseProcessor } from '../../../../platform/inlineEdits/common/responseProcessor';
import { AsyncIterableObject } from '../../../../util/vs/base/common/async';
import { LineEdit, LineReplacement } from '../../../../util/vs/editor/common/core/edits/lineEdit';
Expand Down Expand Up @@ -351,3 +351,78 @@ suite('stream diffing', () => {

});
});

describe('isAdditiveEdit', () => {

it('should detect simple substring additions as additive', () => {
expect(ResponseProcessor.isAdditiveEdit('hello', 'hello world')).toMatchInlineSnapshot(`true`);
expect(ResponseProcessor.isAdditiveEdit('world', 'hello world')).toMatchInlineSnapshot(`true`);
expect(ResponseProcessor.isAdditiveEdit('hello world', 'hello world')).toMatchInlineSnapshot(`true`);
});

it('should detect insertions in the middle as additive', () => {
// The key case: adding parameters to a function
expect(ResponseProcessor.isAdditiveEdit('function fib() {', 'function fib(n: number) {')).toMatchInlineSnapshot(`true`);

// Adding type annotations
expect(ResponseProcessor.isAdditiveEdit('const x = 5', 'const x: number = 5')).toMatchInlineSnapshot(`true`);

// Adding modifiers
expect(ResponseProcessor.isAdditiveEdit('function foo() {}', 'async function foo() {}')).toMatchInlineSnapshot(`true`);
});

it('should detect character insertions as additive', () => {
expect(ResponseProcessor.isAdditiveEdit('abc', 'aXbYcZ')).toMatchInlineSnapshot(`true`);
expect(ResponseProcessor.isAdditiveEdit('abc', 'XXXaYYYbZZZc')).toMatchInlineSnapshot(`true`);
});

it('should detect deletions as non-additive', () => {
expect(ResponseProcessor.isAdditiveEdit('hello world', 'hello')).toMatchInlineSnapshot(`false`);
expect(ResponseProcessor.isAdditiveEdit('hello world', 'world')).toMatchInlineSnapshot(`false`);
expect(ResponseProcessor.isAdditiveEdit('function fib(n: number) {', 'function fib() {')).toMatchInlineSnapshot(`false`);
});

it('should detect replacements as non-additive', () => {
// Changing name (f → g) is a replacement
expect(ResponseProcessor.isAdditiveEdit('function fib() {', 'function gib() {')).toMatchInlineSnapshot(`false`);

// Changing value is a replacement
expect(ResponseProcessor.isAdditiveEdit('const x = 5', 'const x = 10')).toMatchInlineSnapshot(`false`);
});

it('should handle empty strings', () => {
expect(ResponseProcessor.isAdditiveEdit('', '')).toMatchInlineSnapshot(`true`);
expect(ResponseProcessor.isAdditiveEdit('', 'hello')).toMatchInlineSnapshot(`true`);
expect(ResponseProcessor.isAdditiveEdit('hello', '')).toMatchInlineSnapshot(`false`);
});

it('should handle whitespace changes', () => {
// Adding whitespace
expect(ResponseProcessor.isAdditiveEdit('a b', 'a b')).toMatchInlineSnapshot(`true`);
expect(ResponseProcessor.isAdditiveEdit('ab', 'a b')).toMatchInlineSnapshot(`true`);

// Removing whitespace is not additive
expect(ResponseProcessor.isAdditiveEdit('a b', 'a b')).toMatchInlineSnapshot(`false`);
});

it('should handle complex code transformations', () => {
// Adding optional chaining
expect(ResponseProcessor.isAdditiveEdit('obj.prop', 'obj?.prop')).toMatchInlineSnapshot(`true`);

// Adding template literal
expect(ResponseProcessor.isAdditiveEdit('`hello`', '`hello ${name}`')).toMatchInlineSnapshot(`true`);

// Adding array element
expect(ResponseProcessor.isAdditiveEdit('[1, 2]', '[1, 2, 3]')).toMatchInlineSnapshot(`true`);

// Adding object property (subsequence still works)
expect(ResponseProcessor.isAdditiveEdit('{ a: 1 }', '{ a: 1, b: 2 }')).toMatchInlineSnapshot(`true`);
});

it('should handle repeated characters correctly', () => {
// All 'a's from original must be matched in order
expect(ResponseProcessor.isAdditiveEdit('aaa', 'aaaa')).toMatchInlineSnapshot(`true`);
expect(ResponseProcessor.isAdditiveEdit('aaa', 'aXaYaZ')).toMatchInlineSnapshot(`true`);
expect(ResponseProcessor.isAdditiveEdit('aaaa', 'aaa')).toMatchInlineSnapshot(`false`);
});
});
2 changes: 1 addition & 1 deletion src/platform/configuration/common/configurationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -794,7 +794,7 @@ export namespace ConfigKey {
export const InlineEditsNextCursorPredictionRecentSnippetsIncludeLineNumbers = defineTeamInternalSetting<xtabPromptOptions.IncludeLineNumbersOption>('chat.advanced.inlineEdits.nextCursorPrediction.recentSnippets.includeLineNumbers', ConfigType.ExperimentBased, xtabPromptOptions.IncludeLineNumbersOption.None);
export const InlineEditsXtabDiffNEntries = defineTeamInternalSetting<number>('chat.advanced.inlineEdits.xtabProvider.diffNEntries', ConfigType.ExperimentBased, xtabPromptOptions.DEFAULT_OPTIONS.diffHistory.nEntries);
export const InlineEditsXtabDiffMaxTokens = defineTeamInternalSetting<number>('chat.advanced.inlineEdits.xtabProvider.diffMaxTokens', ConfigType.ExperimentBased, xtabPromptOptions.DEFAULT_OPTIONS.diffHistory.maxTokens);
export const InlineEditsXtabProviderEmitFastCursorLineChange = defineTeamInternalSetting<boolean>('chat.advanced.inlineEdits.xtabProvider.emitFastCursorLineChange', ConfigType.ExperimentBased, true);
export const InlineEditsXtabProviderEmitFastCursorLineChange = defineTeamInternalSetting<ResponseProcessor.EmitFastCursorLineChange>('chat.advanced.inlineEdits.xtabProvider.emitFastCursorLineChange', ConfigType.ExperimentBased, ResponseProcessor.EmitFastCursorLineChange.Always);
export const InlineEditsXtabIncludeViewedFiles = defineTeamInternalSetting<boolean>('chat.advanced.inlineEdits.xtabProvider.includeViewedFiles', ConfigType.ExperimentBased, xtabPromptOptions.DEFAULT_OPTIONS.recentlyViewedDocuments.includeViewedFiles);
export const InlineEditsXtabPageSize = defineTeamInternalSetting<number>('chat.advanced.inlineEdits.xtabProvider.pageSize', ConfigType.ExperimentBased, xtabPromptOptions.DEFAULT_OPTIONS.pagedClipping.pageSize);
export const InlineEditsXtabEditWindowMaxTokens = defineTeamInternalSetting<number | undefined>('chat.advanced.inlineEdits.xtabProvider.editWindowMaxTokens', ConfigType.ExperimentBased, 2000);
Expand Down
79 changes: 74 additions & 5 deletions src/platform/inlineEdits/common/responseProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,47 @@ import { LineRange } from '../../../util/vs/editor/common/core/ranges/lineRange'

export namespace ResponseProcessor {

/**
* Controls when to emit fast cursor line changes.
* - `off`: Never emit fast cursor line changes
* - `always`: Always emit when the cursor line changes (original behavior)
* - `additiveOnly`: Only emit when the edit on the cursor line is additive (only adds text)
*/
export const enum EmitFastCursorLineChange {
Off = 'off',
Always = 'always',
AdditiveOnly = 'additiveOnly',
}

export type DiffParams = {
/**
* Whether to emit a fast cursor line change event.
* Defaults to false.
* Controls when to emit a fast cursor line change event.
*/
readonly emitFastCursorLineChange: boolean;
readonly emitFastCursorLineChange: EmitFastCursorLineChange;
readonly nSignificantLinesToConverge: number;
readonly nLinesToConverge: number;
};

export const DEFAULT_DIFF_PARAMS: DiffParams = {
emitFastCursorLineChange: false,
emitFastCursorLineChange: EmitFastCursorLineChange.Off,
nSignificantLinesToConverge: 2,
nLinesToConverge: 3,
};

/**
* Maps the `emitFastCursorLineChange` setting value to the new type,
* preserving backward compatibility with the old boolean type.
*/
export function mapEmitFastCursorLineChange(value: boolean | EmitFastCursorLineChange): EmitFastCursorLineChange {
if (value === true) {
return EmitFastCursorLineChange.Always;
}
if (value === false) {
return EmitFastCursorLineChange.Off;
}
return value;
}

type DivergenceState =
| { k: 'aligned' }
| {
Expand Down Expand Up @@ -117,6 +142,43 @@ export namespace ResponseProcessor {
return !!s.match(/[a-zA-Z1-9]+/);
}

/**
* Checks if a line edit is additive (only adds text without removing any).
* An edit is additive if the original line is a subsequence of the new line,
* meaning all characters from the original appear in the new line in the same order.
*
* Examples:
* - "function fib() {" → "function fib(n: number) {" ✓ (additive)
* - "hello world" → "hello" ✗ (not additive, removes " world")
* - "abc" → "aXbYcZ" ✓ (additive)
*/
export function isAdditiveEdit(originalLine: string, newLine: string): boolean {
return isSubsequence(originalLine, newLine);
}

/**
* Returns true if `subsequence` is a subsequence of `str`.
* A subsequence means all characters appear in `str` in the same relative order,
* but not necessarily consecutively.
*/
function isSubsequence(subsequence: string, str: string): boolean {
if (subsequence.length === 0) {
return true;
}
if (subsequence.length > str.length) {
return false;
}

let subIdx = 0;
for (let i = 0; i < str.length && subIdx < subsequence.length; i++) {
if (str[i] === subsequence[subIdx]) {
subIdx++;
}
}

return subIdx === subsequence.length;
}

function checkForConvergence(
originalLines: string[],
cursorOriginalLinesOffset: number,
Expand All @@ -136,12 +198,19 @@ export namespace ResponseProcessor {
let candidates = lineToIndexes.get(state.newLines[newLinesIdx]).map((idx): [number, number] => [idx, idx]);

if (candidates.length === 0) {
if (!params.emitFastCursorLineChange ||
if (params.emitFastCursorLineChange === EmitFastCursorLineChange.Off ||
editWindowIdx !== cursorOriginalLinesOffset || state.newLines.length > 1
) {
return;
}

// Check if emit is allowed based on the setting
const originalLine = originalLines[editWindowIdx];
const newLine = state.newLines[0];
if (params.emitFastCursorLineChange === EmitFastCursorLineChange.AdditiveOnly && !isAdditiveEdit(originalLine, newLine)) {
return;
}

// we detected that line with the cursor has changed, so we immediately emit an edit for it
const zeroBasedLineRange = [editWindowIdx, editWindowIdx + 1];
const lineRange = new LineRange(zeroBasedLineRange[0] + 1, zeroBasedLineRange[1] + 1);
Expand Down