Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(autoedit): combine inline completion provider and selection change #6147

Merged
merged 6 commits into from
Nov 20, 2024
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
131 changes: 91 additions & 40 deletions vscode/src/autoedits/autoedits-provider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
type AutoEditsModelConfig,
type AutoEditsTokenLimit,
type DocumentContext,
currentResolvedConfig,
dotcomTokenToGatewayToken,
tokensToChars,
Expand All @@ -13,6 +14,7 @@ import { ContextMixer } from '../completions/context/context-mixer'
import { DefaultContextStrategyFactory } from '../completions/context/context-strategy'
import { RetrieverIdentifier } from '../completions/context/utils'
import { getCurrentDocContext } from '../completions/get-current-doc-context'
import { completionMatchesSuffix } from '../completions/is-completion-visible'
import { lines } from '../completions/text-processing'
import { getConfiguration } from '../configuration'
import { CodyGatewayAdapter } from './adapters/cody-gateway'
Expand All @@ -23,14 +25,20 @@ import { autoeditsLogger } from './logger'
import type { AutoeditsModelAdapter } from './prompt-provider'
import type { CodeToReplaceData } from './prompt-utils'
import { AutoEditsRendererManager } from './renderer'
import {
adjustPredictionIfInlineCompletionPossible,
extractInlineCompletionFromRewrittenCode,
} from './utils'

const AUTOEDITS_CONTEXT_STRATEGY = 'auto-edits'
const INLINE_COMPLETETION_DEFAULT_DEBOUNCE_INTERVAL_MS = 150
const ONSELECTION_CHANGE_DEFAULT_DEBOUNCE_INTERVAL_MS = 150
const RESET_SUGGESTION_ON_CURSOR_CHANGE_AFTER_INTERVAL_MS = 60 * 1000

export interface AutoEditsProviderOptions {
document: vscode.TextDocument
position: vscode.Position
docContext: DocumentContext
abortSignal?: AbortSignal
}

Expand All @@ -51,15 +59,14 @@ interface ProviderConfig {
/**
* Provides inline completions and auto-edits functionality.
*/
export class AutoeditsProvider implements vscode.Disposable {
export class AutoeditsProvider implements vscode.InlineCompletionItemProvider, vscode.Disposable {
private readonly disposables: vscode.Disposable[] = []
private readonly contextMixer: ContextMixer
private readonly rendererManager: AutoEditsRendererManager
private readonly config: ProviderConfig
private readonly onSelectionChangeDebounced: DebouncedFunc<typeof this.autoeditOnSelectionChange>
// Keeps track of the last time the text was changed in the editor.
private lastTextChangeTimeStamp: number | undefined
private currentController: AbortController | null = null

constructor() {
this.contextMixer = new ContextMixer({
Expand All @@ -79,14 +86,6 @@ export class AutoeditsProvider implements vscode.Disposable {
this.disposables.push(
this.contextMixer,
this.rendererManager,
// Command is used to manually debug the autoedits provider
vscode.commands.registerCommand('cody.experimental.suggest', () => {
const editor = vscode.window.activeTextEditor
if (!editor) {
return
}
this.triggerAutoedits(editor.document, editor.selection.active)
}),
vscode.window.onDidChangeTextEditorSelection(this.onSelectionChangeDebounced),
vscode.workspace.onDidChangeTextDocument(event => {
this.onDidChangeTextDocument(event)
Expand Down Expand Up @@ -139,63 +138,119 @@ export class AutoeditsProvider implements vscode.Disposable {
Date.now() - this.lastTextChangeTimeStamp <
RESET_SUGGESTION_ON_CURSOR_CHANGE_AFTER_INTERVAL_MS
) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this check have been helpful to ensure that we don't trigger the feature unnecessarily.
Removing it triggers the feature for every cursor movement even when I am just looking at the code. It could lead to more false positive and high load on the deployment.
I think this is a good heuristics to use, one initial change from the user gives us a indication that they might want to modify the code, and a cool down period of 60 seconds without no changes gives an indication that user might not want to modify the code or may just want to read.

Let me know your thoughts

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This creates a hidden mode that users aren’t aware of, making it really hard to figure out intuitively. How does this look to users? They start editing and get helpful suggestions. Then, after making an edit, they spend more than 60 seconds searching for the next place to update, only to find they’re not getting suggestions in the same way they did a minute ago.

High load on the deployment

We’ve enabled completion preloading with a 150ms debounce time and haven’t experienced any critical load spikes from it. Would it make sense to try unconditional loading and see if we encounter throughput issues?

A cooldown period of 60 seconds without changes suggests the user might not want to modify the code

If we rely on suggestions being discarded upon cursor movement, we could implement a dynamic debounce timeout. This timeout could increase after several consecutive suggestions and reset to the minimum upon any document edit or suggestion acceptance. This way, we avoid introducing a new hidden control mode (e.g., "if you don’t edit for X seconds, we disable the feature"). Instead, it remains tied to cursor movement and typing but becomes less intrusive if the user signals they’re not interested.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They start editing and get helpful suggestions. Then, after making an edit, they spend more than 60 seconds searching for the next place to update, only to find they’re not getting suggestions in the same way they did a minute ago.

I think this is a trade-off, I initially integrated the PR without this check and sometimes it would show me false positive even if I am just looking at the code and that that would be distracting. If the user does a text modification, we again start showing the suggestion on cursor movement, so this should be okay imo. We can decide the timeout as we play along with the feature or come up with a better heuristic but I think some logic is definitely required to not always trigger the feature on cursor movements but when the user intends to make a change.

this.triggerAutoedits(document, lastSelection.active)
await vscode.commands.executeCommand('editor.action.inlineSuggest.trigger')
}
}

private async triggerAutoedits(
public async provideInlineCompletionItems(
document: vscode.TextDocument,
position: vscode.Position
): Promise<void> {
if (this.currentController) {
this.currentController.abort()
}
position: vscode.Position,
context: vscode.InlineCompletionContext,
token?: vscode.CancellationToken
): Promise<vscode.InlineCompletionItem[] | vscode.InlineCompletionList | null> {
const controller = new AbortController()
this.currentController = controller
await this.showAutoEdit(document, position, controller.signal)
if (this.currentController === controller) {
this.currentController = null
}
token?.onCancellationRequested(() => controller.abort())

await new Promise(resolve =>
setTimeout(resolve, INLINE_COMPLETETION_DEFAULT_DEBOUNCE_INTERVAL_MS)
)
return this.showAutoEdit(document, position, controller.signal)
}

private async showAutoEdit(
document: vscode.TextDocument,
position: vscode.Position,
abortSignal: AbortSignal
): Promise<void> {
): Promise<vscode.InlineCompletionItem[] | vscode.InlineCompletionList | null> {
if (abortSignal.aborted) {
return
return null
}
const docContext = getCurrentDocContext({
document,
position,
maxPrefixLength: tokensToChars(this.config.tokenLimit.prefixTokens),
maxSuffixLength: tokensToChars(this.config.tokenLimit.suffixTokens),
})
const autoeditResponse = await this.inferEdit({
document,
position,
docContext,
abortSignal,
})
if (abortSignal.aborted || !autoeditResponse) {
return
return null
}
const { prediction, codeToReplaceData } = autoeditResponse
const inlineCompletionItems = this.tryMakeInlineCompletionResponse(
prediction,
codeToReplaceData,
document,
position,
docContext
)
if (inlineCompletionItems) {
return inlineCompletionItems
}
await this.showEditAsDecorations(document, codeToReplaceData, prediction)
return null
}

private tryMakeInlineCompletionResponse(
originalPrediction: string,
codeToReplace: CodeToReplaceData,
document: vscode.TextDocument,
position: vscode.Position,
docContext: DocumentContext
): vscode.InlineCompletionItem[] | null {
const prediction = adjustPredictionIfInlineCompletionPossible(
originalPrediction,
codeToReplace.codeToRewritePrefix,
codeToReplace.codeToRewriteSuffix
)
const codeToRewriteAfterCurrentLine = codeToReplace.codeToRewriteSuffix.slice(
docContext.currentLineSuffix.length + 1 // Additional char for newline
)
const isPrefixMatch = prediction.startsWith(codeToReplace.codeToRewritePrefix)
const isSuffixMatch =
// The current line suffix should not require any char removals to render the completion.
completionMatchesSuffix(
{ insertText: codeToReplace.codeToRewriteSuffix },
docContext.currentLineSuffix
) &&
// The new lines suggested after the current line must be equal to the prediction.
prediction.endsWith(codeToRewriteAfterCurrentLine)

if (isPrefixMatch && isSuffixMatch) {
const autocompleteInlineResponse = extractInlineCompletionFromRewrittenCode(
prediction,
codeToReplace.codeToRewritePrefix,
codeToReplace.codeToRewriteSuffix
)
const autocompleteResponse = docContext.currentLinePrefix + autocompleteInlineResponse
const inlineCompletionItem = new vscode.InlineCompletionItem(
autocompleteResponse,
new vscode.Range(
document.lineAt(position).range.start,
document.lineAt(position).range.end
)
)
autoeditsLogger.logDebug('Autocomplete Inline Response: ', autocompleteResponse)
return [inlineCompletionItem]
}
return null
}

private async inferEdit(options: AutoEditsProviderOptions): Promise<AutoeditsPrediction | null> {
const start = Date.now()

const docContext = getCurrentDocContext({
document: options.document,
position: options.position,
maxPrefixLength: tokensToChars(this.config.tokenLimit.prefixTokens),
maxSuffixLength: tokensToChars(this.config.tokenLimit.suffixTokens),
})
const { context } = await this.contextMixer.getContext({
document: options.document,
position: options.position,
docContext,
docContext: options.docContext,
maxChars: 32_000,
})

const { codeToReplace, promptResponse: prompt } = this.config.provider.getPrompt(
docContext,
options.docContext,
options.document,
options.position,
context,
Expand Down Expand Up @@ -265,11 +320,11 @@ export class AutoeditsProvider implements vscode.Disposable {
): boolean {
const currentFileLines = lines(currentFileText)
const predictedFileLines = lines(predictedFileText)
const { addedLines } = getLineLevelDiff(currentFileLines, predictedFileLines)
let { addedLines } = getLineLevelDiff(currentFileLines, predictedFileLines)
if (addedLines.length === 0) {
return false
}
addedLines.sort()
addedLines = addedLines.sort((a, b) => a - b)
const minAddedLineIndex = addedLines[0]
const maxAddedLineIndex = addedLines[addedLines.length - 1]
const allAddedLines = predictedFileLines.slice(minAddedLineIndex, maxAddedLineIndex + 1)
Expand Down Expand Up @@ -332,10 +387,6 @@ export class AutoeditsProvider implements vscode.Disposable {

public dispose(): void {
this.onSelectionChangeDebounced.cancel()
if (this.currentController) {
this.currentController.abort()
this.currentController = null
}
for (const disposable of this.disposables) {
disposable.dispose()
}
Expand Down
2 changes: 1 addition & 1 deletion vscode/src/autoedits/prompt-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export function getCurrentFilePromptComponents(
suffixAfterArea: currentFileContext.suffixAfterArea.toString(),
prefixInArea: currentFileContext.prefixInArea.toString(),
suffixInArea: currentFileContext.suffixInArea.toString(),
}
} satisfies CodeToReplaceData

const fileWithMarker = ps`${currentFileContext.prefixBeforeArea}
${AREA_FOR_CODE_MARKER}
Expand Down
2 changes: 2 additions & 0 deletions vscode/src/autoedits/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ export class AutoEditsRenderer implements vscode.Disposable {
'\u00A0'.repeat(3) +
_replaceLeadingTrailingChars(decoration.lineText, ' ', '\u00A0'),
margin: `0 0 0 ${replacerCol - line.range.end.character}ch`,
textDecoration: 'none; position: absolute;',
},
},
})
Expand All @@ -445,6 +446,7 @@ export class AutoEditsRenderer implements vscode.Disposable {
contentText:
'\u00A0' +
_replaceLeadingTrailingChars(decoration.lineText, ' ', '\u00A0'),
textDecoration: 'none; position: absolute;',
},
},
})
Expand Down
2 changes: 1 addition & 1 deletion vscode/src/completions/is-completion-visible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ function completionMatchesPopupItem(
return true
}

function completionMatchesSuffix(
export function completionMatchesSuffix(
completion: Pick<AutocompleteItem, 'insertText'>,
currentLineSuffix: string
): boolean {
Expand Down
9 changes: 8 additions & 1 deletion vscode/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -718,7 +718,14 @@ function registerAutoEdits(disposables: vscode.Disposable[]): void {
map(([config, authStatus, autoeditEnabled]) => {
if (shouldEnableExperimentalAutoedits(config, autoeditEnabled, authStatus)) {
const provider = new AutoeditsProvider()
return provider

const completionRegistration =
vscode.languages.registerInlineCompletionItemProvider(
[{ scheme: 'file', language: '*' }, { notebookType: '*' }],
provider
)

return vscode.Disposable.from(provider, completionRegistration)
}
return []
})
Expand Down
Loading