diff --git a/lib/shared/src/configuration.ts b/lib/shared/src/configuration.ts index 0ede7af88bc..325decc8adc 100644 --- a/lib/shared/src/configuration.ts +++ b/lib/shared/src/configuration.ts @@ -21,6 +21,23 @@ export interface AuthCredentials { tokenSource?: TokenSource | undefined } +export interface AutoEditsTokenLimit { + prefixTokens: number + suffixTokens: number + maxPrefixLinesInArea: number + maxSuffixLinesInArea: number + codeToRewritePrefixLines: number + codeToRewriteSuffixLines: number + contextSpecificTokenLimit: Record +} + +export interface AutoEditsModelConfig { + provider: string + model: string + apiKey: string + tokenLimit: AutoEditsTokenLimit +} + export interface NetConfiguration { mode?: string | undefined | null proxy?: { @@ -62,6 +79,7 @@ interface RawClientConfiguration { experimentalTracing: boolean experimentalSupercompletions: boolean + experimentalAutoedits: AutoEditsModelConfig | undefined experimentalCommitMessage: boolean experimentalNoodle: boolean experimentalMinionAnthropicKey: string | undefined diff --git a/lib/shared/src/prompt/prompt-string.ts b/lib/shared/src/prompt/prompt-string.ts index 9532ce3c567..87ea3509a9c 100644 --- a/lib/shared/src/prompt/prompt-string.ts +++ b/lib/shared/src/prompt/prompt-string.ts @@ -254,6 +254,10 @@ export class PromptString { return internal_createPromptString(document.getText(range), [document.uri]) } + public static fromStructuredGitDiff(uri: vscode.Uri, diff: string) { + return internal_createPromptString(diff, [uri]) + } + public static fromGitDiff(uri: vscode.Uri, oldContent: string, newContent: string) { const diff = createGitDiff(displayPath(uri), oldContent, newContent) return internal_createPromptString(diff, [uri]) diff --git a/vscode/package.json b/vscode/package.json index cc34cd3652e..423108e4e2b 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -729,6 +729,21 @@ "args": ["previous"], "key": "shift+ctrl+up", "when": "cody.activated && !editorReadonly && cody.hasActionableSupercompletion" + }, + { + "command": "cody.supersuggest.accept", + "key": "tab", + "when": "editorTextFocus && cody.activated && cody.supersuggest.active" + }, + { + "command": "cody.supersuggest.dismiss", + "key": "escape", + "when": "editorTextFocus && cody.activated && cody.supersuggest.active" + }, + { + "command": "cody.experimental.suggest", + "key": "ctrl+shift+enter", + "when": "cody.activated" } ], "submenus": [ diff --git a/vscode/src/autoedits/autoedits-provider.ts b/vscode/src/autoedits/autoedits-provider.ts new file mode 100644 index 00000000000..5284609957c --- /dev/null +++ b/vscode/src/autoedits/autoedits-provider.ts @@ -0,0 +1,110 @@ +import { + type AutoEditsTokenLimit, + type DocumentContext, + logDebug, + tokensToChars, +} from '@sourcegraph/cody-shared' +import { Observable } from 'observable-fns' +import * as vscode from 'vscode' +import { ContextMixer } from '../completions/context/context-mixer' +import { DefaultContextStrategyFactory } from '../completions/context/context-strategy' +import { getCurrentDocContext } from '../completions/get-current-doc-context' +import { getConfiguration } from '../configuration' +import type { PromptProvider } from './prompt-provider' +import { DeepSeekPromptProvider } from './providers/deepseek' +import { OpenAIPromptProvider } from './providers/openai' +import { AutoEditsRenderer } from './renderer' + +const AUTOEDITS_CONTEXT_STRATEGY = 'auto-edits' + +export interface AutoEditsProviderOptions { + document: vscode.TextDocument + position: vscode.Position +} + +export class AutoeditsProvider implements vscode.Disposable { + private disposables: vscode.Disposable[] = [] + private contextMixer: ContextMixer = new ContextMixer({ + strategyFactory: new DefaultContextStrategyFactory(Observable.of(AUTOEDITS_CONTEXT_STRATEGY)), + dataCollectionEnabled: false, + }) + private autoEditsTokenLimit: AutoEditsTokenLimit | undefined + private provider: PromptProvider | undefined + private model: string | undefined + private apiKey: string | undefined + private renderer: AutoEditsRenderer = new AutoEditsRenderer() + + constructor() { + const config = getConfiguration().experimentalAutoedits + if (config === undefined) { + logDebug('AutoEdits', 'No Configuration found in the settings') + return + } + this.initizlizePromptProvider(config.provider) + this.autoEditsTokenLimit = config.tokenLimit as AutoEditsTokenLimit + this.model = config.model + this.apiKey = config.apiKey + this.disposables.push( + this.contextMixer, + this.renderer, + vscode.commands.registerCommand('cody.experimental.suggest', () => this.getAutoedit()) + ) + } + + private initizlizePromptProvider(provider: string) { + if (provider === 'openai') { + this.provider = new OpenAIPromptProvider() + } else if (provider === 'deepseek') { + this.provider = new DeepSeekPromptProvider() + } else { + logDebug('AutoEdits', `provider ${provider} not supported`) + } + } + + public getAutoedit() { + this.predictAutoeditAtDocAndPosition({ + document: vscode.window.activeTextEditor!.document, + position: vscode.window.activeTextEditor!.selection.active, + }) + } + + public async predictAutoeditAtDocAndPosition(options: AutoEditsProviderOptions) { + if (!this.provider || !this.autoEditsTokenLimit || !this.model || !this.apiKey) { + logDebug('AutoEdits', 'No Provider or Token Limit found in the settings') + return + } + const start = Date.now() + const docContext = this.getDocContext(options.document, options.position) + const { context } = await this.contextMixer.getContext({ + document: options.document, + position: options.position, + docContext: docContext, + maxChars: 100000, + }) + const { codeToReplace, promptResponse: prompt } = this.provider.getPrompt( + docContext, + options.document, + context, + this.autoEditsTokenLimit + ) + const response = await this.provider.getModelResponse(this.model, this.apiKey, prompt) + const timeToResponse = Date.now() - start + logDebug('AutoEdits: (Time LLM Query):', timeToResponse.toString()) + await this.renderer.render(options, codeToReplace, response) + } + + private getDocContext(document: vscode.TextDocument, position: vscode.Position): DocumentContext { + return getCurrentDocContext({ + document, + position, + maxPrefixLength: tokensToChars(this.autoEditsTokenLimit?.prefixTokens ?? 0), + maxSuffixLength: tokensToChars(this.autoEditsTokenLimit?.suffixTokens ?? 0), + }) + } + + public dispose() { + for (const disposable of this.disposables) { + disposable.dispose() + } + } +} diff --git a/vscode/src/autoedits/prompt-provider.ts b/vscode/src/autoedits/prompt-provider.ts new file mode 100644 index 00000000000..46158f48481 --- /dev/null +++ b/vscode/src/autoedits/prompt-provider.ts @@ -0,0 +1,50 @@ +import type { AutoEditsTokenLimit, PromptString } from '@sourcegraph/cody-shared' +import type * as vscode from 'vscode' +import type { + AutocompleteContextSnippet, + DocumentContext, +} from '../../../lib/shared/src/completions/types' +import type * as utils from './prompt-utils' +export type CompletionsPrompt = PromptString +export type ChatPrompt = { + role: 'system' | 'user' | 'assistant' + content: PromptString +}[] +export type PromptProviderResponse = CompletionsPrompt | ChatPrompt + +export interface PromptResponseData { + codeToReplace: utils.CodeToReplaceData + promptResponse: PromptProviderResponse +} + +export interface PromptProvider { + getPrompt( + docContext: DocumentContext, + document: vscode.TextDocument, + context: AutocompleteContextSnippet[], + tokenBudget: AutoEditsTokenLimit + ): PromptResponseData + + postProcessResponse(completion: string | null): string + + getModelResponse(model: string, apiKey: string, prompt: PromptProviderResponse): Promise +} + +export async function getModelResponse(url: string, body: string, apiKey: string): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: body, + }) + if (response.status !== 200) { + const errorText = await response.text() + throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`) + } + const data = await response.json() + return data +} + +// ################################################################################################################ diff --git a/vscode/src/autoedits/prompt-utils.ts b/vscode/src/autoedits/prompt-utils.ts new file mode 100644 index 00000000000..0c79fda07f1 --- /dev/null +++ b/vscode/src/autoedits/prompt-utils.ts @@ -0,0 +1,475 @@ +import { type AutoEditsTokenLimit, PromptString, logDebug, ps } from '@sourcegraph/cody-shared' +import { Uri } from 'vscode' +import type * as vscode from 'vscode' +import type { + AutocompleteContextSnippet, + DocumentContext, +} from '../../../lib/shared/src/completions/types' +import { RetrieverIdentifier } from '../completions/context/utils' +import * as utils from './prompt-utils' + +const LINT_ERRORS_TAG_OPEN = ps`` +const LINT_ERRORS_TAG_CLOSE = ps`` +const EXTRACTED_CODE_SNIPPETS_TAG_OPEN = ps`` +const EXTRACTED_CODE_SNIPPETS_TAG_CLOSE = ps`` +const SNIPPET_TAG_OPEN = ps`` +const SNIPPET_TAG_CLOSE = ps`` +const RECENT_SNIPPET_VIEWS_TAG_OPEN = ps`` +const RECENT_SNIPPET_VIEWS_TAG_CLOSE = ps`` +const RECENT_EDITS_TAG_OPEN = ps`` +const RECENT_EDITS_TAG_CLOSE = ps`` +const RECENT_COPY_TAG_OPEN = ps`` +const RECENT_COPY_TAG_CLOSE = ps`` +const FILE_TAG_OPEN = ps`` +const FILE_TAG_CLOSE = ps`` +const AREA_FOR_CODE_MARKER = ps`<<>>` +const AREA_FOR_CODE_MARKER_OPEN = ps`` +const AREA_FOR_CODE_MARKER_CLOSE = ps`` +const CODE_TO_REWRITE_TAG_OPEN = ps`` +const CODE_TO_REWRITE_TAG_CLOSE = ps`` + +// Some common prompt instructions +export const SYSTEM_PROMPT = ps`You are an intelligent programmer named CodyBot. You are an expert at coding. Your goal is to help your colleague finish a code change.` +const BASE_USER_PROMPT = ps`Help me finish a coding change. In particular, you will see a series of snippets from current open files in my editor, files I have recently viewed, the file I am editing, then a history of my recent codebase changes, then current compiler and linter errors, content I copied from my codebase. You will then rewrite the , to match what you think I would do next in the codebase. Note: I might have stopped in the middle of typing.` +const FINAL_USER_PROMPT = ps`Now, continue where I left off and finish my change by rewriting "code_to_rewrite":` +const RECENT_VIEWS_INSTRUCTION = ps`Here are some snippets of code I have recently viewed, roughly from oldest to newest. It's possible these aren't entirely relevant to my code change:\n` +const JACCARD_SIMILARITY_INSTRUCTION = ps`Here are some snippets of code I have extracted from open files in my code editor. It's possible these aren't entirely relevant to my code change:\n` +const RECENT_EDITS_INSTRUCTION = ps`Here is my recent series of edits from oldest to newest.\n` +const LINT_ERRORS_INSTRUCTION = ps`Here are some linter errors from the code that you will rewrite.\n` +const RECENT_COPY_INSTRUCTION = ps`Here is some recent code I copied from the editor.\n` +const CURRENT_FILE_INSTRUCTION = ps`Here is the file that I am looking at ` + +export interface CurrentFilePromptOptions { + docContext: DocumentContext + document: vscode.TextDocument + maxPrefixLinesInArea: number + maxSuffixLinesInArea: number + codeToRewritePrefixLines: number + codeToRewriteSuffixLines: number +} + +export interface CodeToReplaceData { + codeToRewrite: PromptString + startLine: number + endLine: number +} + +export interface CurrentFilePromptResponse { + fileWithMarkerPrompt: PromptString + areaPrompt: PromptString + codeToReplace: CodeToReplaceData +} + +interface PrefixContext { + prefixBeforeArea: PromptString + prefixInArea: PromptString + codeToRewritePrefix: PromptString + codeToRewriteStartLines: number + totalPrefixLines: number +} + +interface SuffixContext { + suffixAfterArea: PromptString + suffixInArea: PromptString + codeToRewriteSuffix: PromptString + codeToRewriteEndLines: number + totalSuffixLines: number +} + +// Helper function to get prompt in some format +export function getBaseUserPrompt( + docContext: DocumentContext, + document: vscode.TextDocument, + context: AutocompleteContextSnippet[], + tokenBudget: AutoEditsTokenLimit +): { + codeToReplace: utils.CodeToReplaceData + promptResponse: PromptString +} { + const contextItemMapping = utils.getContextItemMappingWithTokenLimit( + context, + tokenBudget.contextSpecificTokenLimit + ) + const { fileWithMarkerPrompt, areaPrompt, codeToReplace } = utils.getCurrentFilePromptComponents({ + docContext, + document, + maxPrefixLinesInArea: tokenBudget.maxPrefixLinesInArea, + maxSuffixLinesInArea: tokenBudget.maxSuffixLinesInArea, + codeToRewritePrefixLines: tokenBudget.codeToRewritePrefixLines, + codeToRewriteSuffixLines: tokenBudget.codeToRewriteSuffixLines, + }) + const recentViewsPrompt = utils.getPromptForTheContextSource( + contextItemMapping.get(RetrieverIdentifier.RecentViewPortRetriever) || [], + RECENT_VIEWS_INSTRUCTION, + utils.getRecentlyViewedSnippetsPrompt + ) + + const recentEditsPrompt = utils.getPromptForTheContextSource( + contextItemMapping.get(RetrieverIdentifier.RecentEditsRetriever) || [], + RECENT_EDITS_INSTRUCTION, + utils.getRecentEditsPrompt + ) + + const lintErrorsPrompt = utils.getPromptForTheContextSource( + contextItemMapping.get(RetrieverIdentifier.DiagnosticsRetriever) || [], + LINT_ERRORS_INSTRUCTION, + utils.getLintErrorsPrompt + ) + + const recentCopyPrompt = utils.getPromptForTheContextSource( + contextItemMapping.get(RetrieverIdentifier.RecentCopyRetriever) || [], + RECENT_COPY_INSTRUCTION, + utils.getRecentCopyPrompt + ) + + const jaccardSimilarityPrompt = utils.getPromptForTheContextSource( + contextItemMapping.get(RetrieverIdentifier.JaccardSimilarityRetriever) || [], + JACCARD_SIMILARITY_INSTRUCTION, + utils.getJaccardSimilarityPrompt + ) + const finalPrompt = ps`${BASE_USER_PROMPT} +${jaccardSimilarityPrompt} +${recentViewsPrompt} +${CURRENT_FILE_INSTRUCTION}${fileWithMarkerPrompt} +${recentEditsPrompt} +${lintErrorsPrompt} +${recentCopyPrompt} +${areaPrompt} +${FINAL_USER_PROMPT} +` + logDebug('AutoEdits', 'Prompt\n', finalPrompt) + return { + codeToReplace: codeToReplace, + promptResponse: finalPrompt, + } +} + +export function getPromptForTheContextSource( + contextItems: AutocompleteContextSnippet[], + instructionPrompt: PromptString, + callback: (contextItems: AutocompleteContextSnippet[]) => PromptString +): PromptString { + const prompt = callback(contextItems) + if (contextItems.length === 0 || prompt.length === 0) { + return ps`` + } + return ps`${instructionPrompt}${prompt}\n` +} + +// Prompt components helper functions + +export function getCurrentFilePromptComponents( + options: CurrentFilePromptOptions +): CurrentFilePromptResponse { + const { prefix, suffix } = PromptString.fromAutocompleteDocumentContext( + options.docContext, + options.document.uri + ) + const completePrefixLines = options.docContext.completePrefix.split('\n').length + const prefixContext = getPrefixContext( + prefix, + options.maxPrefixLinesInArea, + options.codeToRewritePrefixLines + ) + const suffixContext = getSuffixContext( + suffix, + options.maxSuffixLinesInArea, + options.codeToRewriteSuffixLines + ) + const codeToRewrite = PromptString.join( + [prefixContext.codeToRewritePrefix, suffixContext.codeToRewriteSuffix], + ps`` + ) + + const codeToReplace = { + codeToRewrite: codeToRewrite, + startLine: completePrefixLines - prefixContext.codeToRewriteStartLines, + endLine: completePrefixLines + suffixContext.codeToRewriteEndLines, + } + + const fileWithMarker = ps`${prefixContext.prefixBeforeArea} +${AREA_FOR_CODE_MARKER} +${suffixContext.suffixAfterArea}` + + const filePrompt = getContextPromptWithPath( + PromptString.fromDisplayPath(options.document.uri), + ps`${FILE_TAG_OPEN} +${fileWithMarker} +${FILE_TAG_CLOSE} +` + ) + const areaPrompt = ps`${AREA_FOR_CODE_MARKER_OPEN} +${prefixContext.prefixInArea} +${CODE_TO_REWRITE_TAG_OPEN} +${codeToRewrite} +${CODE_TO_REWRITE_TAG_CLOSE} +${suffixContext.suffixInArea} +${AREA_FOR_CODE_MARKER_CLOSE} +` + return { fileWithMarkerPrompt: filePrompt, areaPrompt: areaPrompt, codeToReplace: codeToReplace } +} + +export function getLintErrorsPrompt(contextItems: AutocompleteContextSnippet[]): PromptString { + const lintErrors = getContextItemsForIdentifier( + contextItems, + RetrieverIdentifier.DiagnosticsRetriever + ) + if (lintErrors.length === 0) { + return ps`` + } + + // Create a mapping of URI to AutocompleteContextSnippet[] + const uriToSnippetsMap = new Map() + for (const item of lintErrors) { + const uriString = item.uri.toString() + if (!uriToSnippetsMap.has(uriString)) { + uriToSnippetsMap.set(uriString, []) + } + uriToSnippetsMap.get(uriString)!.push(item) + } + + // Combine snippets for each URI + const combinedPrompts: PromptString[] = [] + for (const [uriString, snippets] of uriToSnippetsMap) { + const uri = Uri.parse(uriString) + const snippetContents = snippets.map( + item => PromptString.fromAutocompleteContextSnippet(item).content + ) + const combinedContent = PromptString.join(snippetContents, ps`\n\n`) + const promptWithPath = getContextPromptWithPath( + PromptString.fromDisplayPath(uri), + combinedContent + ) + combinedPrompts.push(promptWithPath) + } + + const lintErrorsPrompt = PromptString.join(combinedPrompts, ps`\n`) + return ps`${LINT_ERRORS_TAG_OPEN} +${lintErrorsPrompt} +${LINT_ERRORS_TAG_CLOSE} +` +} + +export function getRecentCopyPrompt(contextItems: AutocompleteContextSnippet[]): PromptString { + const recentCopy = getContextItemsForIdentifier( + contextItems, + RetrieverIdentifier.RecentCopyRetriever + ) + if (recentCopy.length === 0) { + return ps`` + } + const recentCopyPrompts = recentCopy.map(item => + getContextPromptWithPath( + PromptString.fromDisplayPath(item.uri), + PromptString.fromAutocompleteContextSnippet(item).content + ) + ) + const recentCopyPrompt = PromptString.join(recentCopyPrompts, ps`\n`) + return ps`${RECENT_COPY_TAG_OPEN} +${recentCopyPrompt} +${RECENT_COPY_TAG_CLOSE} +` +} + +export function getRecentEditsPrompt(contextItems: AutocompleteContextSnippet[]): PromptString { + const recentEdits = getContextItemsForIdentifier( + contextItems, + RetrieverIdentifier.RecentEditsRetriever + ) + recentEdits.reverse() + if (recentEdits.length === 0) { + return ps`` + } + const recentEditsPrompts = recentEdits.map(item => + getContextPromptWithPath( + PromptString.fromDisplayPath(item.uri), + PromptString.fromAutocompleteContextSnippet(item).content + ) + ) + const recentEditsPrompt = PromptString.join(recentEditsPrompts, ps`\n`) + return ps`${RECENT_EDITS_TAG_OPEN} +${recentEditsPrompt} +${RECENT_EDITS_TAG_CLOSE} +` +} + +export function getRecentlyViewedSnippetsPrompt( + contextItems: AutocompleteContextSnippet[] +): PromptString { + const recentViewedSnippets = getContextItemsForIdentifier( + contextItems, + RetrieverIdentifier.RecentViewPortRetriever + ) + recentViewedSnippets.reverse() + if (recentViewedSnippets.length === 0) { + return ps`` + } + const recentViewedSnippetPrompts = recentViewedSnippets.map( + item => + ps`${SNIPPET_TAG_OPEN} +${getContextPromptWithPath( + PromptString.fromDisplayPath(item.uri), + PromptString.fromAutocompleteContextSnippet(item).content +)} +${SNIPPET_TAG_CLOSE} +` + ) + const snippetsPrompt = PromptString.join(recentViewedSnippetPrompts, ps`\n`) + return ps`${RECENT_SNIPPET_VIEWS_TAG_OPEN} +${snippetsPrompt} +${RECENT_SNIPPET_VIEWS_TAG_CLOSE} +` +} + +export function getJaccardSimilarityPrompt(contextItems: AutocompleteContextSnippet[]): PromptString { + const jaccardSimilarity = getContextItemsForIdentifier( + contextItems, + RetrieverIdentifier.JaccardSimilarityRetriever + ) + if (jaccardSimilarity.length === 0) { + return ps`` + } + const jaccardSimilarityPrompts = jaccardSimilarity.map( + item => + ps`${SNIPPET_TAG_OPEN} +${getContextPromptWithPath( + PromptString.fromDisplayPath(item.uri), + PromptString.fromAutocompleteContextSnippet(item).content +)} +${SNIPPET_TAG_CLOSE} +` + ) + const snippetsPrompt = PromptString.join(jaccardSimilarityPrompts, ps`\n`) + return ps`${EXTRACTED_CODE_SNIPPETS_TAG_OPEN} +${snippetsPrompt} +${EXTRACTED_CODE_SNIPPETS_TAG_CLOSE} +` +} + +function getPrefixContext( + prefix: PromptString, + prefixAreaLinesBudget: number, + codeToRewritePrefixLines: number +): PrefixContext { + const prefixLines = prefix.split('\n') + const totalLines = prefixLines.length + + // Ensure we don't exceed the total number of lines available + const actualPrefixLinesBudget = Math.min( + prefixAreaLinesBudget + codeToRewritePrefixLines, + totalLines + ) + const actualCodeToRewritePrefixLines = Math.min(codeToRewritePrefixLines, actualPrefixLinesBudget) + + // Calculate start indexes for each section + const codeToRewriteStart = totalLines - actualCodeToRewritePrefixLines + const prefixInAreaStart = + codeToRewriteStart - (actualPrefixLinesBudget - actualCodeToRewritePrefixLines) + + // Split the prefix into three parts + const prefixBeforeArea = PromptString.join(prefixLines.slice(0, prefixInAreaStart), ps`\n`) + const prefixInArea = PromptString.join( + prefixLines.slice(prefixInAreaStart, codeToRewriteStart), + ps`\n` + ) + const codeToRewritePrefix = PromptString.join(prefixLines.slice(codeToRewriteStart), ps`\n`) + + return { + prefixBeforeArea: prefixBeforeArea, + prefixInArea: prefixInArea, + codeToRewritePrefix: codeToRewritePrefix, + codeToRewriteStartLines: totalLines - codeToRewriteStart, + totalPrefixLines: totalLines, + } +} + +function getSuffixContext( + suffix: PromptString, + suffixAreaLinesBudget: number, + codeToRewriteSuffixLines: number +): SuffixContext { + const suffixLines = suffix.split('\n') + const totalLines = suffixLines.length + + // Ensure we don't exceed the total number of lines available + const actualSuffixAreaLinesBudget = Math.min( + suffixAreaLinesBudget + codeToRewriteSuffixLines, + totalLines + ) + const actualCodeToRewriteSuffixLines = Math.min( + codeToRewriteSuffixLines, + actualSuffixAreaLinesBudget + ) + + // Calculate end indexes for each section + const codeToRewriteEnd = actualCodeToRewriteSuffixLines + const suffixInAreaEnd = actualSuffixAreaLinesBudget + + // Split the suffix into three parts + const codeToRewriteSuffix = PromptString.join(suffixLines.slice(0, codeToRewriteEnd), ps`\n`) + const suffixInArea = PromptString.join(suffixLines.slice(codeToRewriteEnd, suffixInAreaEnd), ps`\n`) + const suffixAfterArea = PromptString.join(suffixLines.slice(suffixInAreaEnd), ps`\n`) + + return { + codeToRewriteSuffix: codeToRewriteSuffix, + suffixInArea: suffixInArea, + suffixAfterArea: suffixAfterArea, + codeToRewriteEndLines: codeToRewriteEnd - 1, + totalSuffixLines: totalLines, + } +} + +// Helper functions +export function getContextItemMappingWithTokenLimit( + contextItems: AutocompleteContextSnippet[], + contextTokenLimitMapping: Record +): Map { + const contextItemMapping = new Map() + // Group items by identifier + for (const item of contextItems) { + const identifier = item.identifier as RetrieverIdentifier + if (!contextItemMapping.has(identifier)) { + contextItemMapping.set(identifier, []) + } + contextItemMapping.get(identifier)!.push(item) + } + // Apply token limits + for (const [identifier, items] of contextItemMapping) { + const tokenLimit = + identifier in contextTokenLimitMapping ? contextTokenLimitMapping[identifier] : undefined + if (tokenLimit !== undefined) { + contextItemMapping.set(identifier, getContextItemsInTokenBudget(items, tokenLimit)) + } else { + logDebug('AutoEdits', `No token limit for ${identifier}`) + contextItemMapping.set(identifier, []) + } + } + return contextItemMapping +} + +function getContextItemsInTokenBudget( + contextItems: AutocompleteContextSnippet[], + tokenBudget: number +): AutocompleteContextSnippet[] { + const CHARS_PER_TOKEN = 4 + let currentCharsCount = 0 + const charsBudget = tokenBudget * CHARS_PER_TOKEN + for (let i = 0; i < contextItems.length; i++) { + currentCharsCount += contextItems[i].content.length + if (currentCharsCount > charsBudget) { + return contextItems.slice(0, i) + } + } + return contextItems +} + +function getContextItemsForIdentifier( + contextItems: AutocompleteContextSnippet[], + identifier: string +): AutocompleteContextSnippet[] { + return contextItems.filter(item => item.identifier === identifier) +} + +function getContextPromptWithPath(filePath: PromptString, content: PromptString): PromptString { + return ps`(\`${filePath}\`)\n\n${content}\n` +} diff --git a/vscode/src/autoedits/providers/deepseek.ts b/vscode/src/autoedits/providers/deepseek.ts new file mode 100644 index 00000000000..eb0d1036d12 --- /dev/null +++ b/vscode/src/autoedits/providers/deepseek.ts @@ -0,0 +1,66 @@ +import { type AutoEditsTokenLimit, type PromptString, logDebug, ps } from '@sourcegraph/cody-shared' +import type * as vscode from 'vscode' +import type { + AutocompleteContextSnippet, + DocumentContext, +} from '../../../../lib/shared/src/completions/types' +import type { PromptProvider, PromptProviderResponse, PromptResponseData } from '../prompt-provider' +import { getModelResponse } from '../prompt-provider' +import { SYSTEM_PROMPT, getBaseUserPrompt } from '../prompt-utils' + +export class DeepSeekPromptProvider implements PromptProvider { + private readonly bosToken: PromptString = ps`<|begin▁of▁sentence|>` + private readonly userToken: PromptString = ps`User: ` + private readonly assistantToken: PromptString = ps`Assistant: ` + + getPrompt( + docContext: DocumentContext, + document: vscode.TextDocument, + context: AutocompleteContextSnippet[], + tokenBudget: AutoEditsTokenLimit + ): PromptResponseData { + const { codeToReplace, promptResponse: userPrompt } = getBaseUserPrompt( + docContext, + document, + context, + tokenBudget + ) + const prompt = ps`${this.bosToken}${SYSTEM_PROMPT} + +${this.userToken}${userPrompt} + +${this.assistantToken}` + + return { + codeToReplace: codeToReplace, + promptResponse: prompt, + } + } + + postProcessResponse(response: string): string { + return response + } + + async getModelResponse( + model: string, + apiKey: string, + prompt: PromptProviderResponse + ): Promise { + try { + const response = await getModelResponse( + 'https://api.fireworks.ai/inference/v1/completions', + JSON.stringify({ + model: model, + prompt: prompt.toString(), + temperature: 0.5, + max_tokens: 256, + }), + apiKey + ) + return response.choices[0].text + } catch (error) { + logDebug('AutoEdits', 'Error calling Fireworks API:', error) + throw error + } + } +} diff --git a/vscode/src/autoedits/providers/openai.ts b/vscode/src/autoedits/providers/openai.ts new file mode 100644 index 00000000000..8c10292c7bc --- /dev/null +++ b/vscode/src/autoedits/providers/openai.ts @@ -0,0 +1,74 @@ +import { type AutoEditsTokenLimit, logDebug } from '@sourcegraph/cody-shared' +import type * as vscode from 'vscode' +import type { + AutocompleteContextSnippet, + DocumentContext, +} from '../../../../lib/shared/src/completions/types' +import type { + ChatPrompt, + PromptProvider, + PromptProviderResponse, + PromptResponseData, +} from '../prompt-provider' +import { getModelResponse } from '../prompt-provider' +import { SYSTEM_PROMPT, getBaseUserPrompt } from '../prompt-utils' + +export class OpenAIPromptProvider implements PromptProvider { + getPrompt( + docContext: DocumentContext, + document: vscode.TextDocument, + context: AutocompleteContextSnippet[], + tokenBudget: AutoEditsTokenLimit + ): PromptResponseData { + const { codeToReplace, promptResponse: userPrompt } = getBaseUserPrompt( + docContext, + document, + context, + tokenBudget + ) + const prompt: ChatPrompt = [ + { + role: 'system', + content: SYSTEM_PROMPT, + }, + { + role: 'user', + content: userPrompt, + }, + ] + return { + codeToReplace: codeToReplace, + promptResponse: prompt, + } + } + + postProcessResponse(response: string): string { + return response + } + + async getModelResponse( + model: string, + apiKey: string, + prompt: PromptProviderResponse + ): Promise { + try { + const response = await getModelResponse( + 'https://api.openai.com/v1/chat/completions', + JSON.stringify({ + model: model, + messages: prompt, + temperature: 0.5, + max_tokens: 256, + response_format: { + type: 'text', + }, + }), + apiKey + ) + return response.choices[0].message.content + } catch (error) { + logDebug('AutoEdits', 'Error calling OpenAI API:', error) + throw error + } + } +} diff --git a/vscode/src/autoedits/renderer.ts b/vscode/src/autoedits/renderer.ts new file mode 100644 index 00000000000..14047f54bfb --- /dev/null +++ b/vscode/src/autoedits/renderer.ts @@ -0,0 +1,202 @@ +import { displayPath, logDebug } from '@sourcegraph/cody-shared' +import { structuredPatch } from 'diff' +import * as vscode from 'vscode' +import { createGitDiff } from '../../../lib/shared/src/editor/create-git-diff' +import { GHOST_TEXT_COLOR } from '../commands/GhostHintDecorator' +import type { AutoEditsProviderOptions } from './autoedits-provider' +import type { CodeToReplaceData } from './prompt-utils' + +interface ProposedChange { + range: vscode.Range + newText: string +} + +interface DecorationLine { + line: number + text: string +} + +const strikeThroughDecorationType = vscode.window.createTextEditorDecorationType({ + textDecoration: 'line-through', +}) + +const suggesterType = vscode.window.createTextEditorDecorationType({ + before: { color: GHOST_TEXT_COLOR }, + after: { color: GHOST_TEXT_COLOR }, +}) + +export class AutoEditsRenderer implements vscode.Disposable { + private disposables: vscode.Disposable[] = [] + private activeProposedChange: ProposedChange | null = null + + constructor() { + this.disposables.push( + vscode.commands.registerCommand( + 'cody.supersuggest.accept', + () => this.acceptProposedChange(), + this.disposables + ) + ) + this.disposables.push( + vscode.commands.registerCommand( + 'cody.supersuggest.dismiss', + () => this.dismissProposedChange(), + this.disposables + ) + ) + } + + public async render( + options: AutoEditsProviderOptions, + codeToReplace: CodeToReplaceData, + predictedText: string + ) { + const editor = vscode.window.activeTextEditor + const document = editor?.document + if (!editor || !document || this.activeProposedChange) { + return + } + + const prevSuffixLine = codeToReplace.endLine - 1 + const range = new vscode.Range( + codeToReplace.startLine, + 0, + prevSuffixLine, + options.document.lineAt(prevSuffixLine).range.end.character + ) + this.activeProposedChange = { + range: range, + newText: predictedText, + } + + const currentFileText = options.document.getText() + const predictedFileText = + currentFileText.slice(0, document.offsetAt(range.start)) + + predictedText + + currentFileText.slice(document.offsetAt(range.end)) + this.logDiff(options.document.uri, currentFileText, predictedText, predictedFileText) + + const filename = displayPath(document.uri) + const patch = structuredPatch( + `a/${filename}`, + `b/${filename}`, + currentFileText, + predictedFileText + ) + let isChanged = false + + const removedLines: DecorationLine[] = [] + const addedLines: DecorationLine[] = [] + for (const hunk of patch.hunks) { + let oldLineNumber = hunk.oldStart + let newLineNumber = hunk.newStart + + for (const line of hunk.lines) { + if (line.length === 0) { + continue + } + if (line[0] === '-') { + isChanged = true + removedLines.push({ line: oldLineNumber - 1, text: line.slice(1) }) + oldLineNumber++ + } else if (line[0] === '+') { + isChanged = true + addedLines.push({ line: newLineNumber - 1, text: line.slice(1) }) + newLineNumber++ + } else if (line[0] === ' ') { + oldLineNumber++ + newLineNumber++ + } + } + } + + if (!isChanged) { + await this.showNoChangeMessageAtCursor() + return + } + + editor.setDecorations( + strikeThroughDecorationType, + removedLines.map(line => ({ + range: new vscode.Range(line.line, 0, line.line, document.lineAt(line.line).text.length), + })) + ) + editor.setDecorations( + suggesterType, + addedLines.map(line => ({ + range: new vscode.Range(line.line, 0, line.line, document.lineAt(line.line).text.length), + renderOptions: { + after: { + contentText: line.text, + }, + }, + })) + ) + await vscode.commands.executeCommand('setContext', 'cody.supersuggest.active', true) + } + + async acceptProposedChange(): Promise { + if (this.activeProposedChange === null) { + return + } + const editor = vscode.window.activeTextEditor + if (!editor) { + await this.dismissProposedChange() + return + } + const currentActiveChange = this.activeProposedChange + await editor.edit(editBuilder => { + editBuilder.replace(currentActiveChange.range, currentActiveChange.newText) + }) + await this.dismissProposedChange() + } + + async dismissProposedChange(): Promise { + this.activeProposedChange = null + await vscode.commands.executeCommand('setContext', 'cody.supersuggest.active', false) + const editor = vscode.window.activeTextEditor + if (!editor) { + return + } + editor.setDecorations(strikeThroughDecorationType, []) + editor.setDecorations(suggesterType, []) + } + + private async showNoChangeMessageAtCursor() { + this.activeProposedChange = null + const editor = vscode.window.activeTextEditor + if (!editor) { + return + } + + const position = editor.selection.active + const lineLength = editor.document.lineAt(position.line).text.length + const range = new vscode.Range(position.line, 0, position.line, lineLength) + editor.setDecorations(suggesterType, [ + { + range, + renderOptions: { + after: { + contentText: 'Cody: no suggested changes', + color: GHOST_TEXT_COLOR, + fontStyle: 'italic', + }, + }, + }, + ]) + await vscode.commands.executeCommand('setContext', 'cody.supersuggest.active', true) + } + + private logDiff(uri: vscode.Uri, codeToRewrite: string, predictedText: string, prediction: string) { + const predictedCodeXML = `\n${predictedText}\n` + logDebug('AutoEdits', '(Predicted Code@ Cursor Position)\n', predictedCodeXML) + const diff = createGitDiff(displayPath(uri), codeToRewrite, prediction) + logDebug('AutoEdits', '(Diff@ Cursor Position)\n', diff) + } + + public dispose() { + for (const disposable of this.disposables) { + disposable.dispose() + } + } +} diff --git a/vscode/src/commands/GhostHintDecorator.ts b/vscode/src/commands/GhostHintDecorator.ts index c06d1939a4b..4ca858effda 100644 --- a/vscode/src/commands/GhostHintDecorator.ts +++ b/vscode/src/commands/GhostHintDecorator.ts @@ -113,7 +113,7 @@ export async function getGhostHintEnablement(): Promise { } } -const GHOST_TEXT_COLOR = new vscode.ThemeColor('editorGhostText.foreground') +export const GHOST_TEXT_COLOR = new vscode.ThemeColor('editorGhostText.foreground') const UNICODE_SPACE = '\u00a0' /** diff --git a/vscode/src/completions/completion-provider-config.ts b/vscode/src/completions/completion-provider-config.ts index c54d0afa1a3..8484a824f09 100644 --- a/vscode/src/completions/completion-provider-config.ts +++ b/vscode/src/completions/completion-provider-config.ts @@ -62,6 +62,7 @@ class CompletionProviderConfig { 'recent-copy', 'diagnostics', 'recent-view-port', + 'auto-edits', ] return resolvedConfig.pipe( switchMap(({ configuration }) => { diff --git a/vscode/src/completions/context/context-data-logging.ts b/vscode/src/completions/context/context-data-logging.ts index e7dffd7847c..01125d7c40f 100644 --- a/vscode/src/completions/context/context-data-logging.ts +++ b/vscode/src/completions/context/context-data-logging.ts @@ -104,9 +104,14 @@ export class ContextRetrieverDataCollection implements vscode.Disposable { private createRetriever(config: RetrieverConfig): ContextRetriever | undefined { switch (config.identifier) { case RetrieverIdentifier.RecentEditsRetriever: - return new RecentEditsRetriever(10 * 60 * 1000) + return new RecentEditsRetriever({ + maxAgeMs: 10 * 60 * 1000, + }) case RetrieverIdentifier.DiagnosticsRetriever: - return new DiagnosticsRetriever() + return new DiagnosticsRetriever({ + contextLines: 3, + useXMLForPromptRendering: true, + }) case RetrieverIdentifier.RecentViewPortRetriever: return new RecentViewPortRetriever({ maxTrackedViewPorts: 50, diff --git a/vscode/src/completions/context/context-mixer.test.ts b/vscode/src/completions/context/context-mixer.test.ts index bfe14712c7e..68d285b4e46 100644 --- a/vscode/src/completions/context/context-mixer.test.ts +++ b/vscode/src/completions/context/context-mixer.test.ts @@ -66,7 +66,9 @@ describe('ContextMixer', () => { describe('with no retriever', () => { it('returns empty result if no retrievers', async () => { - const mixer = new ContextMixer(createMockStrategy([])) + const mixer = new ContextMixer({ + strategyFactory: createMockStrategy([]), + }) const { context, logSummary } = await mixer.getContext(defaultOptions) expect(normalize(context)).toEqual([]) @@ -83,8 +85,8 @@ describe('ContextMixer', () => { describe('with one retriever', () => { it('returns the results of the retriever', async () => { - const mixer = new ContextMixer( - createMockStrategy([ + const mixer = new ContextMixer({ + strategyFactory: createMockStrategy([ [ { identifier: 'jaccard-similarity', @@ -101,8 +103,8 @@ describe('ContextMixer', () => { endLine: 0, }, ], - ]) - ) + ]), + }) const { context, logSummary } = await mixer.getContext(defaultOptions) expect(normalize(context)).toEqual([ { @@ -141,8 +143,8 @@ describe('ContextMixer', () => { describe('with more retriever', () => { it('mixes the results of the retriever using reciprocal rank fusion', async () => { - const mixer = new ContextMixer( - createMockStrategy([ + const mixer = new ContextMixer({ + strategyFactory: createMockStrategy([ [ { identifier: 'retriever1', @@ -183,8 +185,8 @@ describe('ContextMixer', () => { endLine: 1, }, ], - ]) - ) + ]), + }) const { context, logSummary } = await mixer.getContext(defaultOptions) // The results have overlaps in `foo.ts` and `bar.ts`. `foo.ts` is ranked higher in both @@ -268,8 +270,8 @@ describe('ContextMixer', () => { ) }) it('mixes results are filtered', async () => { - const mixer = new ContextMixer( - createMockStrategy([ + const mixer = new ContextMixer({ + strategyFactory: createMockStrategy([ [ { identifier: 'retriever1', @@ -309,8 +311,8 @@ describe('ContextMixer', () => { endLine: 1, }, ], - ]) - ) + ]), + }) const { context } = await mixer.getContext(defaultOptions) const contextFiles = normalize(context) expect(contextFiles.map(c => c.fileName)).toEqual([ @@ -332,7 +334,10 @@ describe('ContextMixer', () => { retrievers.map(set => createMockedContextRetriever(set[0].identifier, set)) const setupTest = (primaryRetrievers: any[], loggingRetrievers: any[]) => { - mixer = new ContextMixer(createMockStrategy(primaryRetrievers)) + mixer = new ContextMixer({ + strategyFactory: createMockStrategy(primaryRetrievers), + dataCollectionEnabled: true, + }) getDataCollectionRetrieversSpy = vi.spyOn(mixer as any, 'getDataCollectionRetrievers') getDataCollectionRetrieversSpy.mockReturnValue(createMockedRetrievers(loggingRetrievers)) } diff --git a/vscode/src/completions/context/context-mixer.ts b/vscode/src/completions/context/context-mixer.ts index 8ee97f41f18..9470e36acff 100644 --- a/vscode/src/completions/context/context-mixer.ts +++ b/vscode/src/completions/context/context-mixer.ts @@ -66,6 +66,11 @@ export interface GetContextResult { contextLoggingSnippets: AutocompleteContextSnippet[] } +export interface ContextMixerOptions { + strategyFactory: ContextStrategyFactory + dataCollectionEnabled?: boolean +} + /** * The context mixer is responsible for combining multiple context retrieval strategies into a * single proposed context list. @@ -77,10 +82,15 @@ export interface GetContextResult { */ export class ContextMixer implements vscode.Disposable { private disposables: vscode.Disposable[] = [] - private contextDataCollector = new ContextRetrieverDataCollection() + private contextDataCollector: ContextRetrieverDataCollection | null = null + private strategyFactory: ContextStrategyFactory - constructor(private strategyFactory: ContextStrategyFactory) { - this.disposables.push(this.contextDataCollector) + constructor({ strategyFactory, dataCollectionEnabled = false }: ContextMixerOptions) { + this.strategyFactory = strategyFactory + if (dataCollectionEnabled) { + this.contextDataCollector = new ContextRetrieverDataCollection() + this.disposables.push(this.contextDataCollector) + } } public async getContext(options: GetContextOptions): Promise { @@ -230,7 +240,7 @@ export class ContextMixer implements vscode.Disposable { } private getDataCollectionRetrievers(repoName: string | undefined): ContextRetriever[] { - if (!this.contextDataCollector.shouldCollectContextDatapoint(repoName)) { + if (!this.contextDataCollector?.shouldCollectContextDatapoint(repoName)) { return [] } return this.contextDataCollector.dataCollectionRetrievers diff --git a/vscode/src/completions/context/context-strategy.ts b/vscode/src/completions/context/context-strategy.ts index e171490988c..4b9eeb973ed 100644 --- a/vscode/src/completions/context/context-strategy.ts +++ b/vscode/src/completions/context/context-strategy.ts @@ -29,6 +29,7 @@ export type ContextStrategy = | 'recent-copy' | 'diagnostics' | 'recent-view-port' + | 'auto-edits' export interface ContextStrategyFactory extends vscode.Disposable { getStrategy( @@ -39,7 +40,7 @@ export interface ContextStrategyFactory extends vscode.Disposable { export class DefaultContextStrategyFactory implements ContextStrategyFactory { private contextStrategySubscription: Unsubscribable - private localRetriever: ContextRetriever | undefined + private allLocalRetrievers: ContextRetriever[] | undefined private graphRetriever: ContextRetriever | undefined constructor(private contextStrategy: Observable) { @@ -50,54 +51,101 @@ export class DefaultContextStrategyFactory implements ContextStrategyFactory { case 'none': break case 'recent-edits': - this.localRetriever = new RecentEditsRetriever(60 * 1000) + this.allLocalRetrievers = [ + new RecentEditsRetriever({ + maxAgeMs: 60 * 1000, + }), + ] break case 'recent-edits-1m': - this.localRetriever = new RecentEditsRetriever(60 * 1000) + this.allLocalRetrievers = [ + new RecentEditsRetriever({ + maxAgeMs: 60 * 1000, + }), + ] break case 'recent-edits-5m': - this.localRetriever = new RecentEditsRetriever(60 * 5 * 1000) + this.allLocalRetrievers = [ + new RecentEditsRetriever({ + maxAgeMs: 60 * 5 * 1000, + }), + ] break case 'recent-edits-mixed': - this.localRetriever = new RecentEditsRetriever(60 * 1000) - this.graphRetriever = new JaccardSimilarityRetriever() + this.allLocalRetrievers = [ + new RecentEditsRetriever({ + maxAgeMs: 60 * 1000, + }), + new JaccardSimilarityRetriever(), + ] break case 'tsc-mixed': - this.localRetriever = new JaccardSimilarityRetriever() + this.allLocalRetrievers = [new JaccardSimilarityRetriever()] this.graphRetriever = loadTscRetriever() break case 'tsc': this.graphRetriever = loadTscRetriever() break case 'lsp-light': - this.localRetriever = new JaccardSimilarityRetriever() + this.allLocalRetrievers = [new JaccardSimilarityRetriever()] this.graphRetriever = new LspLightRetriever() break case 'recent-copy': - this.localRetriever = new RecentCopyRetriever({ - maxAgeMs: 60 * 1000, - maxSelections: 100, - }) + this.allLocalRetrievers = [ + new RecentCopyRetriever({ + maxAgeMs: 60 * 1000, + maxSelections: 100, + }), + ] break case 'diagnostics': - this.localRetriever = new DiagnosticsRetriever() + this.allLocalRetrievers = [ + new DiagnosticsRetriever({ + contextLines: 0, + useXMLForPromptRendering: true, + }), + ] break case 'recent-view-port': - this.localRetriever = new RecentViewPortRetriever({ - maxTrackedViewPorts: 50, - maxRetrievedViewPorts: 10, - }) + this.allLocalRetrievers = [ + new RecentViewPortRetriever({ + maxTrackedViewPorts: 50, + maxRetrievedViewPorts: 10, + }), + ] + break + case 'auto-edits': + this.allLocalRetrievers = [ + new RecentEditsRetriever({ + maxAgeMs: 10 * 60 * 1000, + addLineNumbersForDiff: true, + }), + new DiagnosticsRetriever({ + contextLines: 0, + useXMLForPromptRendering: false, + useCaretToIndicateErrorLocation: false, + }), + new RecentViewPortRetriever({ + maxTrackedViewPorts: 50, + maxRetrievedViewPorts: 10, + }), + new RecentCopyRetriever({ + maxAgeMs: 60 * 1000, + maxSelections: 100, + }), + new JaccardSimilarityRetriever(), + ] break case 'jaccard-similarity': - this.localRetriever = new JaccardSimilarityRetriever() + this.allLocalRetrievers = [new JaccardSimilarityRetriever()] break } return [ - this.localRetriever, + ...(this.allLocalRetrievers ?? []), this.graphRetriever, { dispose: () => { - this.localRetriever = undefined + this.allLocalRetrievers = undefined this.graphRetriever = undefined }, }, @@ -123,8 +171,8 @@ export class DefaultContextStrategyFactory implements ContextStrategyFactory { if (this.graphRetriever?.isSupportedForLanguageId(document.languageId)) { retrievers.push(this.graphRetriever) } - if (this.localRetriever) { - retrievers.push(this.localRetriever) + if (this.allLocalRetrievers) { + retrievers.push(...this.allLocalRetrievers) } break } @@ -134,8 +182,8 @@ export class DefaultContextStrategyFactory implements ContextStrategyFactory { if (this.graphRetriever?.isSupportedForLanguageId(document.languageId)) { retrievers.push(this.graphRetriever) } - if (this.localRetriever) { - retrievers.push(this.localRetriever) + if (this.allLocalRetrievers) { + retrievers.push(...this.allLocalRetrievers) } break @@ -146,18 +194,11 @@ export class DefaultContextStrategyFactory implements ContextStrategyFactory { case 'recent-edits-5m': case 'recent-copy': case 'diagnostics': - case 'recent-view-port': { - if (this.localRetriever) { - retrievers.push(this.localRetriever) - } - break - } + case 'recent-view-port': + case 'auto-edits': case 'recent-edits-mixed': { - if (this.localRetriever) { - retrievers.push(this.localRetriever) - } - if (this.graphRetriever?.isSupportedForLanguageId(document.languageId)) { - retrievers.push(this.graphRetriever) + if (this.allLocalRetrievers) { + retrievers.push(...this.allLocalRetrievers) } break } diff --git a/vscode/src/completions/context/retrievers/recent-user-actions/diagnostics-retriever.test.ts b/vscode/src/completions/context/retrievers/recent-user-actions/diagnostics-retriever.test.ts index b3f160a4da3..40356bdd157 100644 --- a/vscode/src/completions/context/retrievers/recent-user-actions/diagnostics-retriever.test.ts +++ b/vscode/src/completions/context/retrievers/recent-user-actions/diagnostics-retriever.test.ts @@ -11,7 +11,10 @@ describe('DiagnosticsRetriever', () => { beforeEach(() => { vi.useFakeTimers() - retriever = new DiagnosticsRetriever() + retriever = new DiagnosticsRetriever({ + contextLines: 3, + useXMLForPromptRendering: true, + }) parser = new XMLParser() }) diff --git a/vscode/src/completions/context/retrievers/recent-user-actions/diagnostics-retriever.ts b/vscode/src/completions/context/retrievers/recent-user-actions/diagnostics-retriever.ts index 978357effca..8775f381c6f 100644 --- a/vscode/src/completions/context/retrievers/recent-user-actions/diagnostics-retriever.ts +++ b/vscode/src/completions/context/retrievers/recent-user-actions/diagnostics-retriever.ts @@ -1,4 +1,5 @@ import type { AutocompleteContextSnippet } from '@sourcegraph/cody-shared' +import { isDefined } from '@sourcegraph/cody-shared' import { XMLBuilder } from 'fast-xml-parser' import * as vscode from 'vscode' import type { ContextRetriever, ContextRetrieverOptions } from '../../../types' @@ -6,8 +7,18 @@ import { RetrieverIdentifier } from '../../utils' // XML builder instance for formatting diagnostic messages const XML_BUILDER = new XMLBuilder({ format: true }) -// Number of lines of context to include around the diagnostic information in the prompt -const CONTEXT_LINES = 3 + +export interface DiagnosticsRetrieverOptions { + contextLines: number + useXMLForPromptRendering: boolean + useCaretToIndicateErrorLocation?: boolean +} + +interface RelatedInfo { + message: string + file: string + text: string +} interface DiagnosticInfo { message: string @@ -18,6 +29,16 @@ interface DiagnosticInfo { export class DiagnosticsRetriever implements vscode.Disposable, ContextRetriever { public identifier = RetrieverIdentifier.DiagnosticsRetriever private disposables: vscode.Disposable[] = [] + private contextLines: number + private useXMLForPromptRendering: boolean + private useCaretToIndicateErrorLocation: boolean + + constructor(options: DiagnosticsRetrieverOptions) { + // Number of lines of context to include around the diagnostic information in the prompt + this.contextLines = options.contextLines + this.useXMLForPromptRendering = options.useXMLForPromptRendering + this.useCaretToIndicateErrorLocation = options.useCaretToIndicateErrorLocation ?? true + } public async retrieve({ document, @@ -83,18 +104,37 @@ export class DiagnosticsRetriever implements vscode.Disposable, ContextRetriever } private async getDiagnosticPromptMessage(info: DiagnosticInfo): Promise { + if (this.useXMLForPromptRendering) { + return this.getDiagnosticPromptMessageXML(info) + } + return this.getDiagnosticPromptMessagePlainText(info) + } + + private async getDiagnosticPromptMessagePlainText(info: DiagnosticInfo): Promise { + const errorMessage = info.message + const relatedInfoList = info.relatedInformation + ? (await this.getRelatedInformationPrompt(info.relatedInformation)).filter(isDefined) + : [] + const relatedInfoPrompt = relatedInfoList + .map(info => `Err (Related Information) | ${info.message}, ${info.file}, ${info.text}`) + .join('\n') + return `${errorMessage}\n${relatedInfoPrompt}` + } + + private async getDiagnosticPromptMessageXML(info: DiagnosticInfo): Promise { + const relatedInfoList = info.relatedInformation + ? (await this.getRelatedInformationPrompt(info.relatedInformation)).filter(isDefined) + : [] const xmlObj: Record = { message: info.message, - related_information_list: info.relatedInformation - ? await this.getRelatedInformationPrompt(info.relatedInformation) - : undefined, + related_information_list: XML_BUILDER.build(relatedInfoList), } return XML_BUILDER.build({ diagnostic: xmlObj }) } private async getRelatedInformationPrompt( relatedInformation: vscode.DiagnosticRelatedInformation[] - ): Promise { + ): Promise { const relatedInfoList = await Promise.all( relatedInformation.map(async info => { const document = await vscode.workspace.openTextDocument(info.location.uri) @@ -105,7 +145,7 @@ export class DiagnosticsRetriever implements vscode.Disposable, ContextRetriever } }) ) - return XML_BUILDER.build(relatedInfoList) + return relatedInfoList } private getDiagnosticsText( @@ -127,8 +167,8 @@ export class DiagnosticsRetriever implements vscode.Disposable, ContextRetriever diagnosticLine: number, diagnosticText: string ): string { - const contextStartLine = Math.max(0, diagnosticLine - CONTEXT_LINES) - const contextEndLine = Math.min(document.lineCount - 1, diagnosticLine + CONTEXT_LINES) + const contextStartLine = Math.max(0, diagnosticLine - this.contextLines) + const contextEndLine = Math.min(document.lineCount - 1, diagnosticLine + this.contextLines) const prevLines = document.getText( new vscode.Range( contextStartLine, @@ -149,6 +189,9 @@ export class DiagnosticsRetriever implements vscode.Disposable, ContextRetriever } private getDiagnosticMessage(document: vscode.TextDocument, diagnostic: vscode.Diagnostic): string { + if (!this.useCaretToIndicateErrorLocation) { + return `Err | ${diagnostic.message}` + } const line = document.lineAt(diagnostic.range.start.line) const column = Math.max(0, diagnostic.range.start.character - 1) const diagnosticLength = Math.max( diff --git a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-retriever.test.ts b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-retriever.test.ts index 0ec8e3ff625..e66eb6da130 100644 --- a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-retriever.test.ts +++ b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-retriever.test.ts @@ -19,20 +19,26 @@ describe('RecentEditsRetriever', () => { vi.useFakeTimers() vi.spyOn(contextFiltersProvider, 'isUriIgnored').mockResolvedValue(false) - retriever = new RecentEditsRetriever(FIVE_MINUTES, { - onDidChangeTextDocument(listener) { - onDidChangeTextDocument = listener - return { dispose: () => {} } + retriever = new RecentEditsRetriever( + { + maxAgeMs: FIVE_MINUTES, + addLineNumbersForDiff: false, }, - onDidRenameFiles(listener) { - onDidRenameFiles = listener - return { dispose: () => {} } - }, - onDidDeleteFiles(listener) { - onDidDeleteFiles = listener - return { dispose: () => {} } - }, - }) + { + onDidChangeTextDocument(listener) { + onDidChangeTextDocument = listener + return { dispose: () => {} } + }, + onDidRenameFiles(listener) { + onDidRenameFiles = listener + return { dispose: () => {} } + }, + onDidDeleteFiles(listener) { + onDidDeleteFiles = listener + return { dispose: () => {} } + }, + } + ) }) afterEach(() => { retriever.dispose() diff --git a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-retriever.ts b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-retriever.ts index b4f8893eebb..bbdecc69164 100644 --- a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-retriever.ts +++ b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-retriever.ts @@ -1,6 +1,8 @@ import { PromptString, contextFiltersProvider } from '@sourcegraph/cody-shared' import type { AutocompleteContextSnippet } from '@sourcegraph/cody-shared' +import { structuredPatch } from 'diff' import * as vscode from 'vscode' +import { displayPath } from '../../../../../../lib/shared/src/editor/displayPath' import type { ContextRetriever, ContextRetrieverOptions } from '../../../types' import { RetrieverIdentifier, type ShouldUseContextParams, shouldBeUsedAsContext } from '../../utils' @@ -11,6 +13,11 @@ interface TrackedDocument { changes: { timestamp: number; change: vscode.TextDocumentContentChangeEvent }[] } +export interface RecentEditsRetrieverOptions { + maxAgeMs: number + addLineNumbersForDiff?: boolean +} + interface DiffAcrossDocuments { diff: PromptString uri: vscode.Uri @@ -24,14 +31,18 @@ export class RecentEditsRetriever implements vscode.Disposable, ContextRetriever private trackedDocuments: Map = new Map() public identifier = RetrieverIdentifier.RecentEditsRetriever private disposables: vscode.Disposable[] = [] + private readonly maxAgeMs: number + private readonly addLineNumbersForDiff: boolean constructor( - private readonly maxAgeMs: number, + options: RecentEditsRetrieverOptions, readonly workspace: Pick< typeof vscode.workspace, 'onDidChangeTextDocument' | 'onDidRenameFiles' | 'onDidDeleteFiles' > = vscode.workspace ) { + this.maxAgeMs = options.maxAgeMs + this.addLineNumbersForDiff = options.addLineNumbersForDiff ?? false this.disposables.push(workspace.onDidChangeTextDocument(this.onDidChangeTextDocument.bind(this))) this.disposables.push(workspace.onDidRenameFiles(this.onDidRenameFiles.bind(this))) this.disposables.push(workspace.onDidDeleteFiles(this.onDidDeleteFiles.bind(this))) @@ -121,10 +132,51 @@ export class RecentEditsRetriever implements vscode.Disposable, ContextRetriever oldContent, trackedDocument.changes.map(c => c.change) ) - + if (this.addLineNumbersForDiff) { + return this.computeDiffWithLineNumbers(uri, oldContent, newContent) + } return PromptString.fromGitDiff(uri, oldContent, newContent) } + private computeDiffWithLineNumbers( + uri: vscode.Uri, + originalContent: string, + modifiedContent: string + ): PromptString { + const hunkDiffs = [] + const filename = displayPath(uri) + const patch = structuredPatch(`a/${filename}`, `b/${filename}`, originalContent, modifiedContent) + for (const hunk of patch.hunks) { + const diffString = this.getDiffStringForHunkWithLineNumbers(hunk) + hunkDiffs.push(diffString) + } + const gitDiff = PromptString.fromStructuredGitDiff(uri, hunkDiffs.join('\nthen\n')) + return gitDiff + } + + private getDiffStringForHunkWithLineNumbers(hunk: Diff.Hunk): string { + const lines = [] + let oldLineNumber = hunk.oldStart + let newLineNumber = hunk.newStart + for (const line of hunk.lines) { + if (line.length === 0) { + continue + } + if (line[0] === '-') { + lines.push(`${oldLineNumber}${line[0]}| ${line.slice(1)}`) + oldLineNumber++ + } else if (line[0] === '+') { + lines.push(`${newLineNumber}${line[0]}| ${line.slice(1)}`) + newLineNumber++ + } else if (line[0] === ' ') { + lines.push(`${newLineNumber}${line[0]}| ${line.slice(1)}`) + oldLineNumber++ + newLineNumber++ + } + } + return lines.join('\n') + } + private onDidChangeTextDocument(event: vscode.TextDocumentChangeEvent): void { let trackedDocument = this.trackedDocuments.get(event.document.uri.toString()) if (!trackedDocument) { diff --git a/vscode/src/completions/get-inline-completions-tests/helpers.ts b/vscode/src/completions/get-inline-completions-tests/helpers.ts index 76cb24275bf..56294398951 100644 --- a/vscode/src/completions/get-inline-completions-tests/helpers.ts +++ b/vscode/src/completions/get-inline-completions-tests/helpers.ts @@ -211,7 +211,9 @@ export function params( config?.configuration?.autocompleteFirstCompletionTimeout ?? DEFAULT_VSCODE_SETTINGS.autocompleteFirstCompletionTimeout, requestManager: new RequestManager(), - contextMixer: new ContextMixer(new DefaultContextStrategyFactory(Observable.of('none'))), + contextMixer: new ContextMixer({ + strategyFactory: new DefaultContextStrategyFactory(Observable.of('none')), + }), smartThrottleService: null, completionIntent: getCompletionIntent({ document, diff --git a/vscode/src/completions/inline-completion-item-provider.ts b/vscode/src/completions/inline-completion-item-provider.ts index 820d0259b9e..254e2bea1ae 100644 --- a/vscode/src/completions/inline-completion-item-provider.ts +++ b/vscode/src/completions/inline-completion-item-provider.ts @@ -209,7 +209,10 @@ export class InlineCompletionItemProvider ) this.disposables.push(strategyFactory) - this.contextMixer = new ContextMixer(strategyFactory) + this.contextMixer = new ContextMixer({ + strategyFactory, + dataCollectionEnabled: true, + }) this.disposables.push(this.contextMixer) this.smartThrottleService = new SmartThrottleService() diff --git a/vscode/src/configuration.test.ts b/vscode/src/configuration.test.ts index 8a7a158423b..984fb075ce0 100644 --- a/vscode/src/configuration.test.ts +++ b/vscode/src/configuration.test.ts @@ -95,6 +95,8 @@ describe('getConfiguration', () => { return false case 'cody.experimental.supercompletions': return false + case 'cody.experimental.autoedit': + return undefined case 'cody.experimental.noodle': return false case 'cody.experimental.minion.anthropicKey': @@ -154,6 +156,7 @@ describe('getConfiguration', () => { }, commandCodeLenses: true, experimentalSupercompletions: false, + experimentalAutoedits: undefined, experimentalMinionAnthropicKey: undefined, experimentalTracing: true, experimentalCommitMessage: true, diff --git a/vscode/src/configuration.ts b/vscode/src/configuration.ts index 325746323bd..7913657e9ce 100644 --- a/vscode/src/configuration.ts +++ b/vscode/src/configuration.ts @@ -112,6 +112,7 @@ export function getConfiguration( experimentalTracing: getHiddenSetting('experimental.tracing', false), experimentalSupercompletions: getHiddenSetting('experimental.supercompletions', false), + experimentalAutoedits: getHiddenSetting('experimental.autoedit', undefined), experimentalMinionAnthropicKey: getHiddenSetting('experimental.minion.anthropicKey', undefined), experimentalGuardrailsTimeoutSeconds: getHiddenSetting('experimental.guardrailsTimeoutSeconds'), diff --git a/vscode/src/main.ts b/vscode/src/main.ts index 96f14202a78..22418907e6b 100644 --- a/vscode/src/main.ts +++ b/vscode/src/main.ts @@ -44,6 +44,7 @@ import { isReinstalling } from '../uninstall/reinstall' import type { CommandResult } from './CommandResult' import { showAccountMenu } from './auth/account-menu' import { showSignInMenu, showSignOutMenu, tokenCallbackHandler } from './auth/auth' +import { AutoeditsProvider } from './autoedits/autoedits-provider' import type { MessageProviderOptions } from './chat/MessageProvider' import { ChatsController, CodyChatEditorViewType } from './chat/chat-view/ChatsController' import { ContextRetriever } from './chat/chat-view/ContextRetriever' @@ -466,6 +467,14 @@ async function registerCodyCommands( ) ) + // Initialize autoedit provider if experimental feature is enabled + disposables.push( + enableFeature( + ({ configuration }) => configuration.experimentalAutoedits !== undefined, + () => new AutoeditsProvider() + ) + ) + // Experimental Command: Auto Edit disposables.push( vscode.commands.registerCommand('cody.command.auto-edit', a => executeAutoEditCommand(a)) diff --git a/vscode/src/supercompletions/supercompletion-provider.ts b/vscode/src/supercompletions/supercompletion-provider.ts index a4ada0b8e60..d8aff8d5df3 100644 --- a/vscode/src/supercompletions/supercompletion-provider.ts +++ b/vscode/src/supercompletions/supercompletion-provider.ts @@ -5,7 +5,7 @@ import type { CodyStatusBar } from '../services/StatusBar' import { type Supercompletion, getSupercompletions } from './get-supercompletion' import { SupercompletionRenderer } from './renderer' -const EDIT_HISTORY = 5 * 60 * 1000 +const EDIT_HISTORY_TIMEOUT = 5 * 60 * 1000 const SUPERCOMPLETION_TIMEOUT = 2 * 1000 export class SupercompletionProvider implements vscode.Disposable { @@ -27,7 +27,12 @@ export class SupercompletionProvider implements vscode.Disposable { > = vscode.workspace ) { this.renderer = new SupercompletionRenderer() - this.recentEditsRetriever = new RecentEditsRetriever(EDIT_HISTORY, workspace) + this.recentEditsRetriever = new RecentEditsRetriever( + { + maxAgeMs: EDIT_HISTORY_TIMEOUT, + }, + workspace + ) this.disposables.push( workspace.onDidChangeTextDocument(this.onDidChangeTextDocument.bind(this)), diff --git a/vscode/src/testutils/mocks.ts b/vscode/src/testutils/mocks.ts index 8107aae3e11..cf7c35f6b59 100644 --- a/vscode/src/testutils/mocks.ts +++ b/vscode/src/testutils/mocks.ts @@ -887,6 +887,7 @@ export const DEFAULT_VSCODE_SETTINGS = { }, commandCodeLenses: false, experimentalSupercompletions: false, + experimentalAutoedits: undefined, experimentalMinionAnthropicKey: undefined, experimentalTracing: false, experimentalCommitMessage: true,