Path: blob/main/extensions/copilot/src/platform/inlineEdits/common/responseProcessor.ts
13400 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/4import { illegalArgument } from '../../../util/vs/base/common/errors';5import { LineReplacement } from '../../../util/vs/editor/common/core/edits/lineEdit';6import { LineRange } from '../../../util/vs/editor/common/core/ranges/lineRange';789export namespace ResponseProcessor {1011/**12* Controls when to emit fast cursor line changes.13* - `off`: Never emit fast cursor line changes14* - `additiveOnly`: Only emit when the edit on the cursor line is additive (only adds text)15*/16export const enum EmitFastCursorLineChange {17Off = 'off',18AdditiveOnly = 'additiveOnly',19}2021export type DiffParams = {22/**23* Controls when to emit a fast cursor line change event.24*/25readonly emitFastCursorLineChange: EmitFastCursorLineChange;26readonly nSignificantLinesToConverge: number;27readonly nLinesToConverge: number;28};2930export const DEFAULT_DIFF_PARAMS: DiffParams = {31emitFastCursorLineChange: EmitFastCursorLineChange.Off,32nSignificantLinesToConverge: 2,33nLinesToConverge: 3,34};3536/**37* Maps the `emitFastCursorLineChange` setting value to the new type,38* preserving backward compatibility with the old boolean type.39*/40export function mapEmitFastCursorLineChange(value: boolean | EmitFastCursorLineChange): EmitFastCursorLineChange {41if (value === true) {42return EmitFastCursorLineChange.AdditiveOnly;43}44if (value === false) {45return EmitFastCursorLineChange.Off;46}47return value;48}4950type DivergenceState =51| { k: 'aligned' }52| {53k: 'diverged';54startLineIdx: number;55newLines: string[];56convergenceCandidates?: number[];57};5859/**60*61* @param originalLines62* @param modifiedLines63* @param cursorOriginalLinesOffset offset of cursor within original lines64*/65export async function* diff(originalLines: string[], modifiedLines: AsyncIterable<string>, cursorOriginalLinesOffset: number, params: DiffParams): AsyncIterable<LineReplacement> {6667const lineToIdxs = new ArrayMap<string, number>();68for (const [i, line] of originalLines.entries()) {69lineToIdxs.add(line, i);70}7172let editWindowIdx = 0;73let updatedEditWindowIdx = -1;7475let state: DivergenceState = { k: 'aligned' };7677for await (const line of modifiedLines) {78++updatedEditWindowIdx;7980// handle modifiedLines.length > originalLines.length81if (editWindowIdx >= originalLines.length) {82switch (state.k) {83case 'aligned': {84state = { k: 'diverged', startLineIdx: editWindowIdx, newLines: [line] };85break;86}87case 'diverged': {88state.newLines.push(line);89}90}91continue;92}9394if (state.k === 'aligned') {95if (originalLines[editWindowIdx] === line) { // if line is the same as in originalLines, skip over it96++editWindowIdx;97continue;98}99state = { k: 'diverged', startLineIdx: editWindowIdx, newLines: [] };100}101102state.newLines.push(line);103104const convergenceResult = checkForConvergence(105originalLines,106cursorOriginalLinesOffset,107lineToIdxs,108state,109editWindowIdx,110params,111);112113if (convergenceResult) {114yield convergenceResult.singleLineEdit;115editWindowIdx = convergenceResult.convergenceEndIdx;116state = { k: 'aligned' };117}118}119120switch (state.k) {121case 'diverged': {122const lineRange = new LineRange(state.startLineIdx + 1, originalLines.length + 1);123yield new LineReplacement(lineRange, state.newLines);124break;125}126127case 'aligned': {128if (editWindowIdx < originalLines.length) {129const lineRange = new LineRange(editWindowIdx + 1, originalLines.length + 1);130yield new LineReplacement(lineRange, []);131}132break;133}134}135}136137function isSignificant(s: string) {138return !!s.match(/[a-zA-Z1-9]+/);139}140141/**142* Checks if a line edit is additive (only adds text without removing any).143* An edit is additive if the original line is a subsequence of the new line,144* meaning all characters from the original appear in the new line in the same order.145*146* Examples:147* - "function fib() {" → "function fib(n: number) {" ✓ (additive)148* - "hello world" → "hello" ✗ (not additive, removes " world")149* - "abc" → "aXbYcZ" ✓ (additive)150*/151export function isAdditiveEdit(originalLine: string, newLine: string): boolean {152return isSubsequence(originalLine, newLine);153}154155/**156* Returns true if `subsequence` is a subsequence of `str`.157* A subsequence means all characters appear in `str` in the same relative order,158* but not necessarily consecutively.159*/160function isSubsequence(subsequence: string, str: string): boolean {161if (subsequence.length === 0) {162return true;163}164if (subsequence.length > str.length) {165return false;166}167168let subIdx = 0;169for (let i = 0; i < str.length && subIdx < subsequence.length; i++) {170if (str[i] === subsequence[subIdx]) {171subIdx++;172}173}174175return subIdx === subsequence.length;176}177178function checkForConvergence(179originalLines: string[],180cursorOriginalLinesOffset: number,181lineToIndexes: ArrayMap<string, number>,182state: DivergenceState & { k: 'diverged' },183editWindowIdx: number,184params: DiffParams,185): undefined | {186singleLineEdit: LineReplacement;187convergenceEndIdx: number;188} {189if (state.newLines.length === 0) {190throw illegalArgument('Cannot check for convergence without new lines');191}192193let newLinesIdx = state.newLines.length - 1;194let candidates = lineToIndexes.get(state.newLines[newLinesIdx]).map((idx): [number, number] => [idx, idx]);195196if (candidates.length === 0) {197if (params.emitFastCursorLineChange === EmitFastCursorLineChange.Off ||198editWindowIdx !== cursorOriginalLinesOffset || state.newLines.length > 1199) {200return;201}202203// Check if emit is allowed based on the setting204const originalLine = originalLines[editWindowIdx];205const newLine = state.newLines[0];206207// When the cursor is on an empty line and the model outputs content that matches208// (or is a prefix of) the next original line, it's likely deleting the empty line209// rather than replacing it. Skip fast-emit to avoid line duplication.210if (originalLine.trim() === '' && editWindowIdx + 1 < originalLines.length) {211const nextLine = originalLines[editWindowIdx + 1];212if (newLine === nextLine || nextLine.startsWith(newLine)) {213return;214}215}216217if (!isAdditiveEdit(originalLine, newLine)) {218return;219}220221// we detected that line with the cursor has changed, so we immediately emit an edit for it222const zeroBasedLineRange = [editWindowIdx, editWindowIdx + 1];223const lineRange = new LineRange(zeroBasedLineRange[0] + 1, zeroBasedLineRange[1] + 1);224return {225singleLineEdit: new LineReplacement(lineRange, state.newLines),226convergenceEndIdx: editWindowIdx + 1,227};228}229230// we don't have enough lines even for significant-lines convergence which's less than non-significant231if (state.newLines.length < params.nSignificantLinesToConverge) {232return;233}234235let nNonSigMatches = 1;236let nSigMatches = isSignificant(state.newLines[newLinesIdx]) ? 1 : 0;237--newLinesIdx;238239let result: 'found_matches' | 'found_significant_matches' | undefined;240let match: [number, number] = candidates[0];241242// if several lines are being just replaced and we found a convergence right after, we want to treat this as a significant match243// original | modified244// a | a245// b | b'246// c | c'247// d | d <-- match here should allow convergence248// e | e249if (nNonSigMatches > 0 && (match[0] - state.startLineIdx) === state.newLines.length - 1 /* to discount for converging line */) {250result = 'found_significant_matches';251}252253for (; newLinesIdx >= 0; --newLinesIdx) {254candidates = candidates.map(([convEndIdx, convIdx]): [number, number] => [convEndIdx, convIdx - 1]);255candidates = candidates.filter(([_, currentIdx]) => currentIdx >= 0 && editWindowIdx <= currentIdx);256candidates = candidates.filter(([_, currentIdx]) => originalLines[currentIdx] === state.newLines[newLinesIdx]);257258// count in matches for current batch259if (candidates.length === 0) {260break;261} else {262++nNonSigMatches;263if (isSignificant(state.newLines[newLinesIdx])) {264++nSigMatches;265}266}267if (nSigMatches === params.nSignificantLinesToConverge) {268result = 'found_significant_matches';269match = candidates[0];270}271if (nNonSigMatches === params.nLinesToConverge) {272result = 'found_matches';273match = candidates[0];274break;275}276}277278if (!result) {279return;280}281282const originalLinesConvIdx = match[1];283const originalLinesConvEndIdx = match[0];284const nLinesToConverge = originalLinesConvEndIdx - originalLinesConvIdx + 1;285286const nLinesRemoved = originalLinesConvIdx - state.startLineIdx;287const linesInserted = state.newLines.slice(0, state.newLines.length - nLinesToConverge);288const nLinesInserted = linesInserted.length;289if (nLinesRemoved - nLinesInserted > 1 && nLinesInserted > 0) {290return;291}292293const zeroBasedLineRange: [startLineOffset: number, endLineOffset: number] = [state.startLineIdx, originalLinesConvIdx];294const lineRange = new LineRange(zeroBasedLineRange[0] + 1, zeroBasedLineRange[1] + 1);295const singleLineEdit = new LineReplacement(lineRange, linesInserted);296return {297singleLineEdit,298convergenceEndIdx: originalLinesConvEndIdx + 1,299};300}301}302303export class ArrayMap<K, V> {304private map = new Map<K, V[]>();305306/**307* Appends a value to the array of values for the given key.308*/309add(key: K, value: V): void {310const values = this.map.get(key);311if (values) {312values.push(value);313} else {314this.map.set(key, [value]);315}316}317318/**319* Gets the array of values for the given key.320* Returns an empty array if the key does not exist.321*/322get(key: K): V[] {323return this.map.get(key) || [];324}325}326327328