From 268d03fad5f61e219d4a81047b83d07a9676e7c5 Mon Sep 17 00:00:00 2001 From: mp Date: Fri, 8 Nov 2024 01:23:09 -0800 Subject: [PATCH 1/7] Add working autocomplete with caching --- extensions/void/package-lock.json | 1 - extensions/void/src/common/systemPrompts.ts | 7 + .../void/src/extension/AutcompleteProvider.ts | 218 ++++++++++++++++++ extensions/void/src/extension/DiffProvider.ts | 8 +- extensions/void/src/extension/autocomplete.ts | 130 +++++++++++ extensions/void/src/extension/ctrlK.ts | 38 +-- extensions/void/src/extension/extension.ts | 42 ++-- 7 files changed, 389 insertions(+), 55 deletions(-) create mode 100644 extensions/void/src/extension/AutcompleteProvider.ts create mode 100644 extensions/void/src/extension/autocomplete.ts diff --git a/extensions/void/package-lock.json b/extensions/void/package-lock.json index 2c69af18..fea021c3 100644 --- a/extensions/void/package-lock.json +++ b/extensions/void/package-lock.json @@ -6029,7 +6029,6 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.0.tgz", "integrity": "sha512-OeWhNpABLCeTqubfqLMXGsqf6OmPU6pHM85kF3dhy6kq5hnhuVS1p3VrEW/XhWHc71P2tHyS5JFySD8mgs1crw==", "dev": true, - "license": "MIT", "peer": true }, "node_modules/ms": { diff --git a/extensions/void/src/common/systemPrompts.ts b/extensions/void/src/common/systemPrompts.ts index 7e443053..edbfa03b 100644 --- a/extensions/void/src/common/systemPrompts.ts +++ b/extensions/void/src/common/systemPrompts.ts @@ -397,6 +397,13 @@ COMPLETION export default Sidebar;\`\`\` ` +// used for ctrl+l +const partialGenerationInstructions = `` + + +// used for ctrl+k, autocomplete +const fimInstructions = `` + export { diff --git a/extensions/void/src/extension/AutcompleteProvider.ts b/extensions/void/src/extension/AutcompleteProvider.ts new file mode 100644 index 00000000..e0f0cc9c --- /dev/null +++ b/extensions/void/src/extension/AutcompleteProvider.ts @@ -0,0 +1,218 @@ +import * as vscode from 'vscode'; +import { AbortRef, LLMMessage, sendLLMMessage } from '../common/sendLLMMessage'; +import { getVoidConfigFromPartial } from '../webviews/common/contextForConfig'; + +type AutocompletionStatus = 'pending' | 'finished' | 'error'; +type Autocompletion = { + prefix: string, + suffix: string, + startTime: number, + endTime: number | undefined, + abortRef: AbortRef, + status: AutocompletionStatus, + promise: Promise | undefined, + result: string, +} + +const TIMEOUT_TIME = 10000 + +const toInlineCompletion = ({ prefix, suffix, autocompletion }: { prefix: string, suffix: string, autocompletion: Autocompletion }): vscode.InlineCompletionItem => { + + const originalPrefix = autocompletion.prefix + const generatedMiddle = autocompletion.result + const fullPrefix = originalPrefix + generatedMiddle + + // check if the currently generated text matches with the prefix + let remainingText = '' + if (fullPrefix.startsWith(prefix)) { + + // example: + // originalPrefix = abcd + // generatedMiddle = efgh + // originalSuffix = ijkl + // the user has typed "ef" so prefix = abcdef + // we want to return the rest of the generatedMiddle, which is "gh" + + const index = (prefix.length - originalPrefix.length) - 1 + remainingText = generatedMiddle.substring(index + 1) + } + + console.log('----') + console.log('fullPrefix', fullPrefix) + console.log('----') + console.log('----') + console.log('prefix', prefix) + console.log('----') + console.log('completion: ', remainingText) + + return new vscode.InlineCompletionItem(remainingText) + +} + +export class AutocompleteProvider implements vscode.InlineCompletionItemProvider { + + private _extensionContext: vscode.ExtensionContext; + + private _autocompletionsOfDocument: { [docUriStr: string]: Autocompletion[] } = {} + + // used internally by vscode + // fires after every keystroke + async provideInlineCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + context: vscode.InlineCompletionContext, + token: vscode.CancellationToken, + ): Promise { + + const docUriStr = document.uri.toString() + + const fullText = document.getText(); + const cursorOffset = document.offsetAt(position); + const prefix = fullText.substring(0, cursorOffset); + const suffix = fullText.substring(cursorOffset); + + if (!this._autocompletionsOfDocument[docUriStr]) { + this._autocompletionsOfDocument[docUriStr] = [] + } + + const voidConfig = getVoidConfigFromPartial(this._extensionContext.globalState.get('partialVoidConfig') ?? {}) + + + // get autocompletion from cache + let cachedAutocompletion: Autocompletion | undefined = undefined + loop: for (const autocompletion of this._autocompletionsOfDocument[docUriStr]!) { + const originalPrefix = autocompletion.prefix + const generatedMiddle = autocompletion.result + // if the user's change matches up with the generated text + if ((originalPrefix + generatedMiddle).startsWith(prefix)) { + cachedAutocompletion = autocompletion + break loop; + } + } + + // if there is an autocompletion for this line, return it + if (cachedAutocompletion) { + + if (cachedAutocompletion.status === 'finished') { + + const inlineCompletion = toInlineCompletion({ autocompletion: cachedAutocompletion, prefix, suffix, }) + return [inlineCompletion] + + } else if (cachedAutocompletion.status === 'pending') { + + try { + // await the result; if it hasnt resolved in 10 seconds assume the request is dead + await Promise.race([ + cachedAutocompletion.promise, + new Promise((resolve, reject) => setTimeout(() => reject('Request timed out'), TIMEOUT_TIME)), + ]) + const inlineCompletion = toInlineCompletion({ autocompletion: cachedAutocompletion, prefix, suffix, }) + return [inlineCompletion] + + } catch (e) { + console.error('Error creating autocompletion (1): ' + e) + return [] + } + + } else { + return [] + } + + } + + // if there is no autocomplete for this line, create it and add it to cache + let messages: LLMMessage[] = [] + switch (voidConfig.default.whichApi) { + case 'ollama': + messages = [ + { role: 'user', content: `[SUFFIX]${suffix}[PREFIX]${prefix} ` } + ] + break; + case 'anthropic': + case 'openAI': + messages = [ + { role: 'system', content: '' }, + { role: 'user', content: `[SUFFIX]${suffix}[PREFIX]${prefix}` }, + ] + break; + default: + throw new Error(`We do not recommend using autocomplete with your selected provider (${voidConfig.default.whichApi}).`); + } + + const newAutocompletion: Autocompletion = { + prefix: prefix, + suffix: suffix, + startTime: Date.now(), + endTime: undefined, + abortRef: { current: () => { } }, + status: 'pending', + promise: undefined, + result: '', + } + + + // set the parameters of `newAutocompletion` appropriately + newAutocompletion.promise = new Promise((resolve, reject) => { + + sendLLMMessage({ + messages: messages, + onText: async (tokenStr, completionStr) => { + // TODO filter out bad responses here + newAutocompletion.result = completionStr + }, + onFinalMessage: (finalMessage) => { + console.log('finalMessage:', finalMessage); + + // newAutocompletion.prefix = prefix + // newAutocompletion.suffix = suffix + // newAutocompletion.startTime = Date.now() + newAutocompletion.endTime = Date.now() + // newAutocompletion.abortRef = { current: () => { } } + newAutocompletion.status = 'finished' + // newAutocompletion.promise = undefined + newAutocompletion.result = finalMessage + + resolve(finalMessage) + }, + onError: (e) => { + newAutocompletion.endTime = Date.now() + newAutocompletion.status = 'error' + newAutocompletion.result = '' + + reject(e) + }, + voidConfig, + abortRef: newAutocompletion.abortRef, + }) + }) + + this._autocompletionsOfDocument[docUriStr]!.push(newAutocompletion) + + + try { + const result = await Promise.race([ + newAutocompletion.promise, + new Promise((resolve, reject) => setTimeout(() => reject('Request timed out'), TIMEOUT_TIME)), + ]) + + const inlineCompletion = toInlineCompletion({ autocompletion: newAutocompletion, prefix, suffix, }) + return [inlineCompletion] + + } catch (e) { + console.error('Error creating autocompletion (2): ' + e) + return [] + } + + + } + + constructor(context: vscode.ExtensionContext) { + + this._extensionContext = context + + } + + + + +} diff --git a/extensions/void/src/extension/DiffProvider.ts b/extensions/void/src/extension/DiffProvider.ts index 9836dfde..1b7d8ce8 100644 --- a/extensions/void/src/extension/DiffProvider.ts +++ b/extensions/void/src/extension/DiffProvider.ts @@ -48,8 +48,6 @@ export class DiffProvider implements vscode.CodeLensProvider { constructor(context: vscode.ExtensionContext) { this._extensionUri = context.extensionUri - console.log('Creating DisplayChangesProvider') - // this acts as a useEffect every time text changes vscode.workspace.onDidChangeTextDocument((e) => { @@ -167,7 +165,9 @@ export class DiffProvider implements vscode.CodeLensProvider { } const originalFile = this._originalFileOfDocument[docUriStr] if (!originalFile) { - console.log('Error: No original file!') + if (this._diffAreasOfDocument[docUriStr]?.length > 0) { + console.log('Error: More than one diff area exists, but no original file was set.') + } return; } @@ -189,7 +189,6 @@ export class DiffProvider implements vscode.CodeLensProvider { // add the diffs to `this._diffsOfDocument[docUriStr]` this.createDiffs(editor.document.uri, diffs, diffArea) - // // print diffs // console.log('!ORIGINAL FILE:', JSON.stringify(originalFile)) // console.log('!NEW FILE :', JSON.stringify(editor.document.getText().replace(/\r\n/g, '\n'))) @@ -451,6 +450,7 @@ export class DiffProvider implements vscode.CodeLensProvider { const diffareaRange = new vscode.Range(diffArea.startLine, 0, diffArea.endLine, Number.MAX_SAFE_INTEGER) workspaceEdit.replace(editor.document.uri, diffareaRange, newCode) await vscode.workspace.applyEdit(workspaceEdit) + }, THROTTLE_TIME) } diff --git a/extensions/void/src/extension/autocomplete.ts b/extensions/void/src/extension/autocomplete.ts new file mode 100644 index 00000000..abe18e34 --- /dev/null +++ b/extensions/void/src/extension/autocomplete.ts @@ -0,0 +1,130 @@ +import * as vscode from 'vscode'; +import { AbortRef, OnFinalMessage, OnText, sendLLMMessage } from "../common/sendLLMMessage" +import { VoidConfig } from '../webviews/common/contextForConfig'; + +type AutocompletionStatus = 'pending' | 'complete' | 'error'; +type Autocompletion = { + prefix: string, + suffix: string, + startTime: number, + endTime: number, + abortRef: AbortRef, + status: AutocompletionStatus, + result: string, +} + +const recentEdits = [] +const autocompletionsOfDocument: { [docUriStr: string]: Autocompletion[] } = {} + + +const showRecentAutocompletion = () => { + console.log('showRecentAutocompletion') + const editor = vscode.window.activeTextEditor + if (!editor) return; + + const docUriStr = editor.document.uri.toString(); + const autocompletions = autocompletionsOfDocument[docUriStr] + if (!autocompletions || autocompletions.length === 0) return; + + const completion = autocompletions[autocompletions.length - 1] + if (completion.status === 'pending') return; + if (completion.status === 'error') return; + + const decorationType = vscode.window.createTextEditorDecorationType({ + after: { contentText: completion.result, color: '#888', } + }); + const position = editor.document.positionAt(completion.prefix.length); + const decorationOptions = [{ range: new vscode.Range(position, position) }]; + editor.setDecorations(decorationType, decorationOptions); + + +} + +export const setupAutocomplete = ({ voidConfig, abortRef }: { voidConfig: VoidConfig, abortRef: AbortRef }) => { + + + vscode.workspace.onDidChangeTextDocument(e => { + let shouldAutocomplete = true; + // 1. determine if we should do an autocomplete + // -check that we're not predicting too many changes at a time + // -look at cache and see if current location has already been predicted + // -check if the user's selection has overlap with the current prediction they are selecting + + const editor = vscode.window.activeTextEditor + if (!editor) return; + if (e.document !== editor.document) return; + if (e.contentChanges.length === 0) return; + + const docUriStr = editor.document.uri.toString(); + + // get the prefix + suffix + const change = e.contentChanges[e.contentChanges.length - 1]; + const fullText = editor.document.getText(); + const startOffset = editor.document.offsetAt(change.range.start); + const cursorOffset = startOffset + (change.text.length > 0 ? change.text.length : 0); + const prefix = fullText.substring(0, cursorOffset); + const suffix = fullText.substring(cursorOffset); + + // TODO do checks as mentioned above + + if (!shouldAutocomplete) return; + // 2. if we should do an autocomplete, get the relevant quantities + // -LSP types of variables around the cursor + // -LSP imports of variables around the cursor + // -code context of recent edits + + // 3. create an autocompletion + + if (!autocompletionsOfDocument[docUriStr]) { + autocompletionsOfDocument[docUriStr] = [] + } + + let promptContent = ``; + switch (voidConfig.default.whichApi) { + case 'ollama': + promptContent = `[SUFFIX]${suffix}[PREFIX]${prefix}`; + break; + case 'anthropic': + case 'openAI': + promptContent = `[SUFFIX]${suffix}[PREFIX]${prefix}`; + break; + default: + throw new Error(`We do not recommend using autocomplete with your selected provider (${voidConfig.default.whichApi}).`); + } + + const startTime = Date.now(); + sendLLMMessage({ + messages: [{ role: 'user', content: promptContent, }], + onText: async (tokenStr, completionStr) => { + // TODO filter out bad responses here + }, + onFinalMessage: (finalMessage) => { + console.log('finalMessage:', finalMessage); + const autocompletion: Autocompletion = { + prefix, + suffix, + abortRef, + startTime, + endTime: Date.now(), + status: 'complete', + result: finalMessage, + } + autocompletionsOfDocument[docUriStr].push(autocompletion) + showRecentAutocompletion() + }, + onError: (e) => { + console.error('Error generating autocompletion:', e); + }, + voidConfig, + abortRef, + }) + + + + + + + + }) + +} diff --git a/extensions/void/src/extension/ctrlK.ts b/extensions/void/src/extension/ctrlK.ts index 63aaf200..2d7e35cd 100644 --- a/extensions/void/src/extension/ctrlK.ts +++ b/extensions/void/src/extension/ctrlK.ts @@ -5,31 +5,6 @@ import { searchDiffChunkInstructions, writeFileWithDiffInstructions } from '../c import { throttle } from 'lodash'; import { readFileContentOfUri } from './extensionLib/readFileContentOfUri'; -type Res = ((value: T) => void) - -const THRTOTLE_TIME = 100 // minimum time between edits -const LINES_PER_CHUNK = 20 // number of lines to search at a time - -const applyCtrlLChangesToFile = throttle( - ({ fileUri, newCurrentLine, oldCurrentLine, fullCompletedStr, oldFileStr, debug }: { fileUri: vscode.Uri, newCurrentLine: number, oldCurrentLine: number, fullCompletedStr: string, oldFileStr: string, debug?: string }) => { - - // write the change to the file - const WRITE_TO_FILE = ( - fullCompletedStr.split('\n').slice(0, newCurrentLine + 1).join('\n') // newFile[:newCurrentLine+1] - + oldFileStr.split('\n').slice(oldCurrentLine + 1).join('\n') // oldFile[oldCurrentLine+1:] - ) - const workspaceEdit = new vscode.WorkspaceEdit() - workspaceEdit.replace(fileUri, new vscode.Range(0, 0, Number.MAX_SAFE_INTEGER, 0), WRITE_TO_FILE) - vscode.workspace.applyEdit(workspaceEdit) - - // highlight the `newCurrentLine` in white - // highlight the remaining part of the file in gray - - }, - THRTOTLE_TIME, { trailing: true } -) - - const applyCtrlK = async ({ fileUri, startLine, endLine, instructions, voidConfig, abortRef }: { fileUri: vscode.Uri, startLine: number, endLine: number, instructions: string, voidConfig: VoidConfig, abortRef: AbortRef }) => { const fileStr = await readFileContentOfUri(fileUri) @@ -47,24 +22,19 @@ const applyCtrlK = async ({ fileUri, startLine, endLine, instructions, voidConfi The user wants to apply the following instructions to the selection: ${instructions} -Please rewrite the selection following the user's instructions. -Instructions to follow: +Instructions: 1. Follow the user's instructions 2. You may ONLY CHANGE the selection, and nothing else in the file 3. Make sure all brackets in the new selection are balanced the same was as in the original selection -3. Be careful not to duplicate or remove variables, comments, or other syntax by mistake +4. Be careful not to duplicate or remove variables, comments, or other syntax by mistake -Complete the following: +Please rewrite the complete the following code, following the user's instructions. \`\`\`
${prefix}
${suffix} `; - - // TODO initialize stream - - // update stream sendLLMMessage({ messages: [{ role: 'user', content: promptContent, }], onText: async (tokenStr, completionStr) => { @@ -97,4 +67,4 @@ Complete the following: -export { applyCtrlK } \ No newline at end of file +export { applyCtrlK } diff --git a/extensions/void/src/extension/extension.ts b/extensions/void/src/extension/extension.ts index cc7cbed6..92a4caba 100644 --- a/extensions/void/src/extension/extension.ts +++ b/extensions/void/src/extension/extension.ts @@ -9,21 +9,23 @@ import { DiffProvider } from './DiffProvider'; import { readFileContentOfUri } from './extensionLib/readFileContentOfUri'; import { SidebarWebviewProvider } from './providers/SidebarWebviewProvider'; import { CtrlKWebviewProvider } from './providers/CtrlKWebviewProvider'; +import { setupAutocomplete } from './autocomplete'; +import { AutocompleteProvider } from './AutcompleteProvider'; -// this comes from vscode.proposed.editorInsets.d.ts -declare module 'vscode' { - export interface WebviewEditorInset { - readonly editor: vscode.TextEditor; - readonly line: number; - readonly height: number; - readonly webview: vscode.Webview; - readonly onDidDispose: Event; - dispose(): void; - } - export namespace window { - export function createWebviewTextEditorInset(editor: vscode.TextEditor, line: number, height: number, options?: vscode.WebviewOptions): WebviewEditorInset; - } -} +// // this comes from vscode.proposed.editorInsets.d.ts +// declare module 'vscode' { +// export interface WebviewEditorInset { +// readonly editor: vscode.TextEditor; +// readonly line: number; +// readonly height: number; +// readonly webview: vscode.Webview; +// readonly onDidDispose: Event; +// dispose(): void; +// } +// export namespace window { +// export function createWebviewTextEditorInset(editor: vscode.TextEditor, line: number, height: number, options?: vscode.WebviewOptions): WebviewEditorInset; +// } +// } const roundRangeToLines = (selection: vscode.Selection) => { let endLine = selection.end.character === 0 ? selection.end.line - 1 : selection.end.line // e.g. if the user triple clicks, it selects column=0, line=line -> column=0, line=line+1 @@ -112,7 +114,7 @@ export function activate(context: vscode.ExtensionContext) { // Receive messages in the extension from the sidebar webview (messages are sent using `postMessage`) webview.onDidReceiveMessage(async (m: MessageFromSidebar) => { - const abortApplyRef: AbortRef = { current: null } + const abortRef: AbortRef = { current: null } if (m.type === 'requestFiles') { @@ -146,7 +148,7 @@ export function activate(context: vscode.ExtensionContext) { const fileStr = await readFileContentOfUri(docUri) const voidConfig = getVoidConfigFromPartial(context.globalState.get('partialVoidConfig') ?? {}) - await applyDiffLazily({ docUri, oldFileStr: fileStr, diffRepr: m.diffRepr, voidConfig, diffProvider, diffArea, abortRef: abortApplyRef }) + await applyDiffLazily({ docUri, oldFileStr: fileStr, diffRepr: m.diffRepr, voidConfig, diffProvider, diffArea, abortRef: abortRef }) } else if (m.type === 'getPartialVoidConfig') { const partialVoidConfig = context.globalState.get('partialVoidConfig') ?? {} @@ -180,6 +182,14 @@ export function activate(context: vscode.ExtensionContext) { } ) + // 6. Autocomplete + const autocompleteProvider = new AutocompleteProvider(context); + context.subscriptions.push(vscode.languages.registerInlineCompletionItemProvider('*', autocompleteProvider)); + + const voidConfig = getVoidConfigFromPartial(context.globalState.get('partialVoidConfig') ?? {}) + const abortRef: AbortRef = { current: null } + + // setupAutocomplete({ voidConfig, abortRef }) From 652f64cd14e4819bd36831dfae277df95dad40f7 Mon Sep 17 00:00:00 2001 From: mp Date: Fri, 8 Nov 2024 21:57:33 -0800 Subject: [PATCH 2/7] Add basic system prompt --- .../void/src/extension/AutcompleteProvider.ts | 59 ++++---- extensions/void/src/extension/autocomplete.ts | 130 ------------------ extensions/void/src/extension/extension.ts | 1 - .../src/webviews/common/contextForConfig.tsx | 4 +- extensions/void/tsconfig.json | 4 +- 5 files changed, 32 insertions(+), 166 deletions(-) delete mode 100644 extensions/void/src/extension/autocomplete.ts diff --git a/extensions/void/src/extension/AutcompleteProvider.ts b/extensions/void/src/extension/AutcompleteProvider.ts index e0f0cc9c..3438d50a 100644 --- a/extensions/void/src/extension/AutcompleteProvider.ts +++ b/extensions/void/src/extension/AutcompleteProvider.ts @@ -14,7 +14,7 @@ type Autocompletion = { result: string, } -const TIMEOUT_TIME = 10000 +const TIMEOUT_TIME = 60000 const toInlineCompletion = ({ prefix, suffix, autocompletion }: { prefix: string, suffix: string, autocompletion: Autocompletion }): vscode.InlineCompletionItem => { @@ -37,13 +37,8 @@ const toInlineCompletion = ({ prefix, suffix, autocompletion }: { prefix: string remainingText = generatedMiddle.substring(index + 1) } - console.log('----') - console.log('fullPrefix', fullPrefix) - console.log('----') - console.log('----') - console.log('prefix', prefix) - console.log('----') - console.log('completion: ', remainingText) + console.log('generated middle: ', JSON.stringify(generatedMiddle)) + console.log('remaining text: ', JSON.stringify(remainingText)) return new vscode.InlineCompletionItem(remainingText) @@ -55,6 +50,10 @@ export class AutocompleteProvider implements vscode.InlineCompletionItemProvider private _autocompletionsOfDocument: { [docUriStr: string]: Autocompletion[] } = {} + constructor(context: vscode.ExtensionContext) { + this._extensionContext = context + } + // used internally by vscode // fires after every keystroke async provideInlineCompletionItems( @@ -66,10 +65,12 @@ export class AutocompleteProvider implements vscode.InlineCompletionItemProvider const docUriStr = document.uri.toString() + console.log('initial _autocompletionsOfDocument', this._autocompletionsOfDocument[docUriStr]) + const fullText = document.getText(); const cursorOffset = document.offsetAt(position); - const prefix = fullText.substring(0, cursorOffset); - const suffix = fullText.substring(cursorOffset); + const prefix = fullText.substring(0, cursorOffset) + const suffix = fullText.substring(cursorOffset) if (!this._autocompletionsOfDocument[docUriStr]) { this._autocompletionsOfDocument[docUriStr] = [] @@ -94,44 +95,45 @@ export class AutocompleteProvider implements vscode.InlineCompletionItemProvider if (cachedAutocompletion) { if (cachedAutocompletion.status === 'finished') { + console.log('AAA1') const inlineCompletion = toInlineCompletion({ autocompletion: cachedAutocompletion, prefix, suffix, }) return [inlineCompletion] } else if (cachedAutocompletion.status === 'pending') { + console.log('AAA2') try { // await the result; if it hasnt resolved in 10 seconds assume the request is dead - await Promise.race([ - cachedAutocompletion.promise, - new Promise((resolve, reject) => setTimeout(() => reject('Request timed out'), TIMEOUT_TIME)), - ]) + await cachedAutocompletion.promise; const inlineCompletion = toInlineCompletion({ autocompletion: cachedAutocompletion, prefix, suffix, }) return [inlineCompletion] } catch (e) { console.error('Error creating autocompletion (1): ' + e) - return [] } - } else { - return [] + } else if (cachedAutocompletion.status === 'error') { + console.log('AAA3') } + return [] } + console.log('BBB') + // if there is no autocomplete for this line, create it and add it to cache let messages: LLMMessage[] = [] switch (voidConfig.default.whichApi) { case 'ollama': messages = [ - { role: 'user', content: `[SUFFIX]${suffix}[PREFIX]${prefix} ` } + { role: 'user', content: `[SUFFIX]${suffix}[PREFIX]${prefix} Fill in the middle between the prefix and suffix. Return only the middle. [MIDDLE]` } ] break; case 'anthropic': case 'openAI': messages = [ - { role: 'system', content: '' }, + { role: 'system', content: 'Fill in the prefix up to the suffix. Return only the result and be very concise.' }, { role: 'user', content: `[SUFFIX]${suffix}[PREFIX]${prefix}` }, ] break; @@ -161,7 +163,6 @@ export class AutocompleteProvider implements vscode.InlineCompletionItemProvider newAutocompletion.result = completionStr }, onFinalMessage: (finalMessage) => { - console.log('finalMessage:', finalMessage); // newAutocompletion.prefix = prefix // newAutocompletion.suffix = suffix @@ -184,16 +185,19 @@ export class AutocompleteProvider implements vscode.InlineCompletionItemProvider voidConfig, abortRef: newAutocompletion.abortRef, }) + + setTimeout(() => { // if the request hasnt resolved in TIMEOUT_TIME seconds, end it + if (newAutocompletion.status === 'pending') { + reject('Timeout') + } + }, TIMEOUT_TIME) }) this._autocompletionsOfDocument[docUriStr]!.push(newAutocompletion) try { - const result = await Promise.race([ - newAutocompletion.promise, - new Promise((resolve, reject) => setTimeout(() => reject('Request timed out'), TIMEOUT_TIME)), - ]) + await newAutocompletion.promise; const inlineCompletion = toInlineCompletion({ autocompletion: newAutocompletion, prefix, suffix, }) return [inlineCompletion] @@ -203,13 +207,6 @@ export class AutocompleteProvider implements vscode.InlineCompletionItemProvider return [] } - - } - - constructor(context: vscode.ExtensionContext) { - - this._extensionContext = context - } diff --git a/extensions/void/src/extension/autocomplete.ts b/extensions/void/src/extension/autocomplete.ts deleted file mode 100644 index abe18e34..00000000 --- a/extensions/void/src/extension/autocomplete.ts +++ /dev/null @@ -1,130 +0,0 @@ -import * as vscode from 'vscode'; -import { AbortRef, OnFinalMessage, OnText, sendLLMMessage } from "../common/sendLLMMessage" -import { VoidConfig } from '../webviews/common/contextForConfig'; - -type AutocompletionStatus = 'pending' | 'complete' | 'error'; -type Autocompletion = { - prefix: string, - suffix: string, - startTime: number, - endTime: number, - abortRef: AbortRef, - status: AutocompletionStatus, - result: string, -} - -const recentEdits = [] -const autocompletionsOfDocument: { [docUriStr: string]: Autocompletion[] } = {} - - -const showRecentAutocompletion = () => { - console.log('showRecentAutocompletion') - const editor = vscode.window.activeTextEditor - if (!editor) return; - - const docUriStr = editor.document.uri.toString(); - const autocompletions = autocompletionsOfDocument[docUriStr] - if (!autocompletions || autocompletions.length === 0) return; - - const completion = autocompletions[autocompletions.length - 1] - if (completion.status === 'pending') return; - if (completion.status === 'error') return; - - const decorationType = vscode.window.createTextEditorDecorationType({ - after: { contentText: completion.result, color: '#888', } - }); - const position = editor.document.positionAt(completion.prefix.length); - const decorationOptions = [{ range: new vscode.Range(position, position) }]; - editor.setDecorations(decorationType, decorationOptions); - - -} - -export const setupAutocomplete = ({ voidConfig, abortRef }: { voidConfig: VoidConfig, abortRef: AbortRef }) => { - - - vscode.workspace.onDidChangeTextDocument(e => { - let shouldAutocomplete = true; - // 1. determine if we should do an autocomplete - // -check that we're not predicting too many changes at a time - // -look at cache and see if current location has already been predicted - // -check if the user's selection has overlap with the current prediction they are selecting - - const editor = vscode.window.activeTextEditor - if (!editor) return; - if (e.document !== editor.document) return; - if (e.contentChanges.length === 0) return; - - const docUriStr = editor.document.uri.toString(); - - // get the prefix + suffix - const change = e.contentChanges[e.contentChanges.length - 1]; - const fullText = editor.document.getText(); - const startOffset = editor.document.offsetAt(change.range.start); - const cursorOffset = startOffset + (change.text.length > 0 ? change.text.length : 0); - const prefix = fullText.substring(0, cursorOffset); - const suffix = fullText.substring(cursorOffset); - - // TODO do checks as mentioned above - - if (!shouldAutocomplete) return; - // 2. if we should do an autocomplete, get the relevant quantities - // -LSP types of variables around the cursor - // -LSP imports of variables around the cursor - // -code context of recent edits - - // 3. create an autocompletion - - if (!autocompletionsOfDocument[docUriStr]) { - autocompletionsOfDocument[docUriStr] = [] - } - - let promptContent = ``; - switch (voidConfig.default.whichApi) { - case 'ollama': - promptContent = `[SUFFIX]${suffix}[PREFIX]${prefix}`; - break; - case 'anthropic': - case 'openAI': - promptContent = `[SUFFIX]${suffix}[PREFIX]${prefix}`; - break; - default: - throw new Error(`We do not recommend using autocomplete with your selected provider (${voidConfig.default.whichApi}).`); - } - - const startTime = Date.now(); - sendLLMMessage({ - messages: [{ role: 'user', content: promptContent, }], - onText: async (tokenStr, completionStr) => { - // TODO filter out bad responses here - }, - onFinalMessage: (finalMessage) => { - console.log('finalMessage:', finalMessage); - const autocompletion: Autocompletion = { - prefix, - suffix, - abortRef, - startTime, - endTime: Date.now(), - status: 'complete', - result: finalMessage, - } - autocompletionsOfDocument[docUriStr].push(autocompletion) - showRecentAutocompletion() - }, - onError: (e) => { - console.error('Error generating autocompletion:', e); - }, - voidConfig, - abortRef, - }) - - - - - - - - }) - -} diff --git a/extensions/void/src/extension/extension.ts b/extensions/void/src/extension/extension.ts index 92a4caba..f54df186 100644 --- a/extensions/void/src/extension/extension.ts +++ b/extensions/void/src/extension/extension.ts @@ -9,7 +9,6 @@ import { DiffProvider } from './DiffProvider'; import { readFileContentOfUri } from './extensionLib/readFileContentOfUri'; import { SidebarWebviewProvider } from './providers/SidebarWebviewProvider'; import { CtrlKWebviewProvider } from './providers/CtrlKWebviewProvider'; -import { setupAutocomplete } from './autocomplete'; import { AutocompleteProvider } from './AutcompleteProvider'; // // this comes from vscode.proposed.editorInsets.d.ts diff --git a/extensions/void/src/webviews/common/contextForConfig.tsx b/extensions/void/src/webviews/common/contextForConfig.tsx index e1333154..47e96e60 100644 --- a/extensions/void/src/webviews/common/contextForConfig.tsx +++ b/extensions/void/src/webviews/common/contextForConfig.tsx @@ -121,8 +121,8 @@ const voidConfigInfo: Record< // TODO we should allow user to select model inside Void, but for now we'll just let them handle the Ollama setup on their own model: configEnum( 'Ollama model to use.', - 'llama3.1', - ["codegemma", "codegemma:2b", "codegemma:7b", "codellama", "codellama:7b", "codellama:13b", "codellama:34b", "codellama:70b", "codellama:code", "codellama:python", "command-r", "command-r:35b", "command-r-plus", "command-r-plus:104b", "deepseek-coder-v2", "deepseek-coder-v2:16b", "deepseek-coder-v2:236b", "falcon2", "falcon2:11b", "firefunction-v2", "firefunction-v2:70b", "gemma", "gemma:2b", "gemma:7b", "gemma2", "gemma2:2b", "gemma2:9b", "gemma2:27b", "llama2", "llama2:7b", "llama2:13b", "llama2:70b", "llama3", "llama3:8b", "llama3:70b", "llama3-chatqa", "llama3-chatqa:8b", "llama3-chatqa:70b", "llama3-gradient", "llama3-gradient:8b", "llama3-gradient:70b", "llama3.1", "llama3.2", "llama3.1:8b", "llama3.1:70b", "llama3.1:405b", "llava", "llava:7b", "llava:13b", "llava:34b", "llava-llama3", "llava-llama3:8b", "llava-phi3", "llava-phi3:3.8b", "mistral", "mistral:7b", "mistral-large", "mistral-large:123b", "mistral-nemo", "mistral-nemo:12b", "mixtral", "mixtral:8x7b", "mixtral:8x22b", "moondream", "moondream:1.8b", "openhermes", "openhermes:v2.5", "phi3", "phi3:3.8b", "phi3:14b", "phi3.5", "phi3.5:3.8b", "qwen", "qwen:7b", "qwen:14b", "qwen:32b", "qwen:72b", "qwen:110b", "qwen2", "qwen2:0.5b", "qwen2:1.5b", "qwen2:7b", "qwen2:72b", "smollm", "smollm:135m", "smollm:360m", "smollm:1.7b"] as const + 'codestral', + ["codestral", "codegemma", "codegemma:2b", "codegemma:7b", "codellama", "codellama:7b", "codellama:13b", "codellama:34b", "codellama:70b", "codellama:code", "codellama:python", "command-r", "command-r:35b", "command-r-plus", "command-r-plus:104b", "deepseek-coder-v2", "deepseek-coder-v2:16b", "deepseek-coder-v2:236b", "falcon2", "falcon2:11b", "firefunction-v2", "firefunction-v2:70b", "gemma", "gemma:2b", "gemma:7b", "gemma2", "gemma2:2b", "gemma2:9b", "gemma2:27b", "llama2", "llama2:7b", "llama2:13b", "llama2:70b", "llama3", "llama3:8b", "llama3:70b", "llama3-chatqa", "llama3-chatqa:8b", "llama3-chatqa:70b", "llama3-gradient", "llama3-gradient:8b", "llama3-gradient:70b", "llama3.1", "llama3.2", "llama3.1:8b", "llama3.1:70b", "llama3.1:405b", "llava", "llava:7b", "llava:13b", "llava:34b", "llava-llama3", "llava-llama3:8b", "llava-phi3", "llava-phi3:3.8b", "mistral", "mistral:7b", "mistral-large", "mistral-large:123b", "mistral-nemo", "mistral-nemo:12b", "mixtral", "mixtral:8x7b", "mixtral:8x22b", "moondream", "moondream:1.8b", "openhermes", "openhermes:v2.5", "phi3", "phi3:3.8b", "phi3:14b", "phi3.5", "phi3.5:3.8b", "qwen", "qwen:7b", "qwen:14b", "qwen:32b", "qwen:72b", "qwen:110b", "qwen2", "qwen2:0.5b", "qwen2:1.5b", "qwen2:7b", "qwen2:72b", "smollm", "smollm:135m", "smollm:360m", "smollm:1.7b"] as const ), }, openRouter: { diff --git a/extensions/void/tsconfig.json b/extensions/void/tsconfig.json index 63e3afa9..79aef824 100644 --- a/extensions/void/tsconfig.json +++ b/extensions/void/tsconfig.json @@ -1,7 +1,7 @@ { "include": [ "src/**/*" - ], +, "../../src/vs/workbench/contrib/welcomeGettingStarted/common/AutcompleteProvider.tsx" ], "exclude": [ "node_modules" ], @@ -27,4 +27,4 @@ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ // "noUnusedParameters": true, /* Report errors on unused parameters. */ } -} \ No newline at end of file +} From aab065139ee6d8e2b3c4290a05eb49170e81e71b Mon Sep 17 00:00:00 2001 From: mp Date: Sun, 10 Nov 2024 23:52:25 -0800 Subject: [PATCH 3/7] Trim prefixes to increase cache hit rate --- .../void/src/extension/AutcompleteProvider.ts | 152 ++++++++++++------ 1 file changed, 104 insertions(+), 48 deletions(-) diff --git a/extensions/void/src/extension/AutcompleteProvider.ts b/extensions/void/src/extension/AutcompleteProvider.ts index 3438d50a..28db0bcf 100644 --- a/extensions/void/src/extension/AutcompleteProvider.ts +++ b/extensions/void/src/extension/AutcompleteProvider.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { AbortRef, LLMMessage, sendLLMMessage } from '../common/sendLLMMessage'; -import { getVoidConfigFromPartial } from '../webviews/common/contextForConfig'; +import { getVoidConfigFromPartial, VoidConfig } from '../webviews/common/contextForConfig'; type AutocompletionStatus = 'pending' | 'finished' | 'error'; type Autocompletion = { @@ -14,42 +14,80 @@ type Autocompletion = { result: string, } +const DEBOUNCE_TIME = 500 const TIMEOUT_TIME = 60000 -const toInlineCompletion = ({ prefix, suffix, autocompletion }: { prefix: string, suffix: string, autocompletion: Autocompletion }): vscode.InlineCompletionItem => { +// trims the end of the prefix to improve cache hit rate +const trimPrefix = (prefix: string) => { + const trimmedPrefix = prefix.trimEnd() + const trailingEnd = prefix.substring(trimmedPrefix.length) + + // keep only a single trailing newline + if (trailingEnd.includes('\n')) { + return trimmedPrefix + '\n' + } + + // else ignore all spaces and return the trimmed prefix + return trimmedPrefix +} + +// finds the text in the autocompletion to display +const toInlineCompletion = ({ prefix, autocompletion }: { prefix: string, autocompletion: Autocompletion }): vscode.InlineCompletionItem => { + const originalPrefix = autocompletion.prefix + const generatedMiddle = autocompletion.result + + const trimmedOriginalPrefix = trimPrefix(originalPrefix) + const trimmedCurrentPrefix = trimPrefix(prefix) + + const lastMatchupIndex = trimmedCurrentPrefix.length - trimmedOriginalPrefix.length + + console.log('generatedMiddle ', generatedMiddle) + console.log('trimmedOriginalPrefix ', trimmedOriginalPrefix) + console.log('trimmedCurrentPrefix ', trimmedCurrentPrefix) + console.log('lastMatchupIndex ', lastMatchupIndex) + if (lastMatchupIndex < 0) { + return new vscode.InlineCompletionItem('') + } + + // example: + // originalPrefix = abcd + // generatedMiddle = efgh + // originalSuffix = ijkl + // the user has typed "ef" so prefix = abcdef + // we want to return the rest of the generatedMiddle, which is "gh" + const completionStr = generatedMiddle.substring(lastMatchupIndex) + + return new vscode.InlineCompletionItem(completionStr) + +} + +// returns whether we can use this autocompletion to complete the prefix +const doesPrefixMatchAutocompletion = ({ prefix, autocompletion }: { prefix: string, autocompletion: Autocompletion }): boolean => { const originalPrefix = autocompletion.prefix const generatedMiddle = autocompletion.result - const fullPrefix = originalPrefix + generatedMiddle + const trimmedOriginalPrefix = trimPrefix(originalPrefix) + const trimmedCurrentPrefix = trimPrefix(prefix) - // check if the currently generated text matches with the prefix - let remainingText = '' - if (fullPrefix.startsWith(prefix)) { - - // example: - // originalPrefix = abcd - // generatedMiddle = efgh - // originalSuffix = ijkl - // the user has typed "ef" so prefix = abcdef - // we want to return the rest of the generatedMiddle, which is "gh" - - const index = (prefix.length - originalPrefix.length) - 1 - remainingText = generatedMiddle.substring(index + 1) + if (trimmedCurrentPrefix.length < trimmedOriginalPrefix.length) { + return false } - console.log('generated middle: ', JSON.stringify(generatedMiddle)) - console.log('remaining text: ', JSON.stringify(remainingText)) - - return new vscode.InlineCompletionItem(remainingText) + const isMatch = (trimmedOriginalPrefix + generatedMiddle).startsWith(trimmedCurrentPrefix) + return isMatch } + + export class AutocompleteProvider implements vscode.InlineCompletionItemProvider { private _extensionContext: vscode.ExtensionContext; private _autocompletionsOfDocument: { [docUriStr: string]: Autocompletion[] } = {} + private _lastTime = 0 + constructor(context: vscode.ExtensionContext) { this._extensionContext = context } @@ -65,8 +103,6 @@ export class AutocompleteProvider implements vscode.InlineCompletionItemProvider const docUriStr = document.uri.toString() - console.log('initial _autocompletionsOfDocument', this._autocompletionsOfDocument[docUriStr]) - const fullText = document.getText(); const cursorOffset = document.offsetAt(position); const prefix = fullText.substring(0, cursorOffset) @@ -78,35 +114,33 @@ export class AutocompleteProvider implements vscode.InlineCompletionItemProvider const voidConfig = getVoidConfigFromPartial(this._extensionContext.globalState.get('partialVoidConfig') ?? {}) - // get autocompletion from cache let cachedAutocompletion: Autocompletion | undefined = undefined loop: for (const autocompletion of this._autocompletionsOfDocument[docUriStr]!) { - const originalPrefix = autocompletion.prefix - const generatedMiddle = autocompletion.result // if the user's change matches up with the generated text - if ((originalPrefix + generatedMiddle).startsWith(prefix)) { + if (doesPrefixMatchAutocompletion({ prefix, autocompletion })) { cachedAutocompletion = autocompletion break loop; } } - // if there is an autocompletion for this line, return it + // if there is a cached autocompletion, return it if (cachedAutocompletion) { if (cachedAutocompletion.status === 'finished') { console.log('AAA1') - const inlineCompletion = toInlineCompletion({ autocompletion: cachedAutocompletion, prefix, suffix, }) + const inlineCompletion = toInlineCompletion({ autocompletion: cachedAutocompletion, prefix, }) + + return [inlineCompletion] } else if (cachedAutocompletion.status === 'pending') { console.log('AAA2') try { - // await the result; if it hasnt resolved in 10 seconds assume the request is dead await cachedAutocompletion.promise; - const inlineCompletion = toInlineCompletion({ autocompletion: cachedAutocompletion, prefix, suffix, }) + const inlineCompletion = toInlineCompletion({ autocompletion: cachedAutocompletion, prefix, }) return [inlineCompletion] } catch (e) { @@ -120,9 +154,42 @@ export class AutocompleteProvider implements vscode.InlineCompletionItemProvider return [] } + + // if there is no cached autocompletion, create it and add it to cache + + // wait DEBOUNCE_TIME for the user to stop typing + const thisTime = Date.now() + this._lastTime = thisTime + const didTypingHappenDuringDebounce = await new Promise((resolve, reject) => + setTimeout(() => { + if (this._lastTime === thisTime) { + resolve(false) + } else { + resolve(true) + } + }, DEBOUNCE_TIME) + ) + + // if more typing happened, then do not go forwards with the request + if (didTypingHappenDuringDebounce) { + return [] + } + console.log('BBB') - // if there is no autocomplete for this line, create it and add it to cache + // else if no more typing happens, then go forwards with the request + const newAutocompletion: Autocompletion = { + prefix: prefix, + suffix: suffix, + startTime: Date.now(), + endTime: undefined, + abortRef: { current: () => { } }, + status: 'pending', + promise: undefined, + result: '', + } + + let messages: LLMMessage[] = [] switch (voidConfig.default.whichApi) { case 'ollama': @@ -141,19 +208,7 @@ export class AutocompleteProvider implements vscode.InlineCompletionItemProvider throw new Error(`We do not recommend using autocomplete with your selected provider (${voidConfig.default.whichApi}).`); } - const newAutocompletion: Autocompletion = { - prefix: prefix, - suffix: suffix, - startTime: Date.now(), - endTime: undefined, - abortRef: { current: () => { } }, - status: 'pending', - promise: undefined, - result: '', - } - - - // set the parameters of `newAutocompletion` appropriately + // set parameters of `newAutocompletion` appropriately newAutocompletion.promise = new Promise((resolve, reject) => { sendLLMMessage({ @@ -186,20 +241,21 @@ export class AutocompleteProvider implements vscode.InlineCompletionItemProvider abortRef: newAutocompletion.abortRef, }) - setTimeout(() => { // if the request hasnt resolved in TIMEOUT_TIME seconds, end it + setTimeout(() => { // if the request hasnt resolved in TIMEOUT_TIME seconds, reject it if (newAutocompletion.status === 'pending') { reject('Timeout') } }, TIMEOUT_TIME) }) - this._autocompletionsOfDocument[docUriStr]!.push(newAutocompletion) - + // add autocompletion to cache + this._autocompletionsOfDocument[docUriStr]?.push(newAutocompletion) + // show autocompletion try { await newAutocompletion.promise; - const inlineCompletion = toInlineCompletion({ autocompletion: newAutocompletion, prefix, suffix, }) + const inlineCompletion = toInlineCompletion({ autocompletion: newAutocompletion, prefix, }) return [inlineCompletion] } catch (e) { From 339aff5d3143bca928f3607c8e12c3ce5d68b37f Mon Sep 17 00:00:00 2001 From: mp Date: Tue, 12 Nov 2024 01:36:32 -0800 Subject: [PATCH 4/7] Refactor sendLLMMessage and add FIM mode --- extensions/void/src/common/getPrompt.ts | 105 ++++++++++++++++++ extensions/void/src/common/sendLLMMessage.ts | 81 +++++++++++--- extensions/void/src/common/systemPrompts.ts | 16 ++- .../void/src/extension/AutcompleteProvider.ts | 71 ++++++------ extensions/void/src/extension/ctrlK.ts | 5 +- 5 files changed, 222 insertions(+), 56 deletions(-) create mode 100644 extensions/void/src/common/getPrompt.ts diff --git a/extensions/void/src/common/getPrompt.ts b/extensions/void/src/common/getPrompt.ts new file mode 100644 index 00000000..f1c7567b --- /dev/null +++ b/extensions/void/src/common/getPrompt.ts @@ -0,0 +1,105 @@ +import { configFields, VoidConfig } from "../webviews/common/contextForConfig" +import { FimInfo } from "./sendLLMMessage" + + +type GetFIMPrompt = ({ voidConfig, fimInfo }: { voidConfig: VoidConfig, fimInfo: FimInfo, }) => string + +export const getFIMSystem: GetFIMPrompt = ({ voidConfig, fimInfo }) => { + + switch (voidConfig.default.whichApi) { + case 'ollama': + return '' + case 'anthropic': + case 'openAI': + case 'gemini': + case 'greptile': + case 'openRouter': + case 'openAICompatible': + case 'azure': + default: + return `You are given the START and END to a piece of code. Please FILL IN THE MIDDLE between the START and END. + +Instruction summary: +1. Return the MIDDLE of the code between the START and END. +2. Do not give an explanation, description, or any other code besides the middle. +2. Do not return duplicate code from either START or END. +3. Make sure the MIDDLE piece of code has balanced brackets that match the START and END. +4. The MIDDLE begins on the same line as START. Please include a newline character if you want to begin on the next line. + +# EXAMPLE + +## START: +\`\`\` python +def add(a,b): + return a + b +def subtract(a,b): + return a - b +\`\`\` +## END: +\`\`\` python +def divide(a,b): + return a / b +\`\`\` +## EXPECTED OUTPUT: +\`\`\` python + +def multiply(a,b): + return a * b +\`\`\` + +# EXAMPLE +## START: +\`\`\` javascript +const x = 1 + +const y +\`\`\` +## END: +\`\`\` javascript + +const z = 3 +\`\`\` +## EXPECTED OUTPUT: +\`\`\` javascript += 2 +\`\`\` +` + } + + +} + + +export const getFIMPrompt: GetFIMPrompt = ({ voidConfig, fimInfo }) => { + + // if no prefix or suffix, return empty string + if (!fimInfo.prefix.trim() && !fimInfo.suffix.trim()) return '' + + // TODO may want to trim the prefix and suffix + switch (voidConfig.default.whichApi) { + case 'ollama': + if (voidConfig.ollama.model === 'codestral') { + return `[SUFFIX]${fimInfo.suffix}[PREFIX] ${fimInfo.prefix}` + } + return '' + case 'anthropic': + case 'openAI': + case 'gemini': + case 'greptile': + case 'openRouter': + case 'openAICompatible': + case 'azure': + default: + return `## START: +\`\`\` +${fimInfo.prefix} +\`\`\` +## END: +\`\`\` +${fimInfo.suffix} +\`\`\` +` + + } +} + diff --git a/extensions/void/src/common/sendLLMMessage.ts b/extensions/void/src/common/sendLLMMessage.ts index 5a6e8f0f..a86f5c49 100644 --- a/extensions/void/src/common/sendLLMMessage.ts +++ b/extensions/void/src/common/sendLLMMessage.ts @@ -3,6 +3,7 @@ import OpenAI from 'openai'; import { Ollama } from 'ollama/browser' import { Content, GoogleGenerativeAI, GoogleGenerativeAIError, GoogleGenerativeAIFetchError } from '@google/generative-ai'; import { VoidConfig } from '../webviews/common/contextForConfig' +import { getFIMPrompt, getFIMSystem } from './getPrompt'; export type AbortRef = { current: (() => void) | null } @@ -21,23 +22,32 @@ export type LLMMessage = { } type SendLLMMessageFnTypeInternal = (params: { + mode: 'chat' | 'fim', messages: LLMMessage[], onText: OnText, onFinalMessage: OnFinalMessage, onError: (error: string) => void, - voidConfig: VoidConfig, abortRef: AbortRef, + voidConfig: VoidConfig, }) => void -type SendLLMMessageFnTypeExternal = (params: { - messages: LLMMessage[], + +type SendLLMMessageFnTypeExternal = (params: ( + | { mode?: 'chat', messages: LLMMessage[], fimInfo?: undefined, } + | { mode: 'fim', fimInfo: FimInfo, messages?: undefined, } +) & { onText: OnText, - onFinalMessage: (fullText: string) => void, + onFinalMessage: OnFinalMessage, onError: (error: string) => void, - voidConfig: VoidConfig | null, abortRef: AbortRef, + voidConfig: VoidConfig | null, // these may be absent }) => void +export type FimInfo = { + prefix: string, + suffix: string, +} + const parseMaxTokensStr = (maxTokensStr: string) => { // parse the string but only if the full string is a valid number, eg parseInt('100abc') should return NaN let int = isNaN(Number(maxTokensStr)) ? undefined : parseInt(maxTokensStr) @@ -232,7 +242,7 @@ const sendOpenAIMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinal }; // Ollama -export const sendOllamaMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, onError, voidConfig, abortRef }) => { +export const sendOllamaMsg: SendLLMMessageFnTypeInternal = ({ mode, messages, onText, onFinalMessage, onError, voidConfig, abortRef }) => { let didAbort = false let fullText = "" @@ -243,6 +253,10 @@ export const sendOllamaMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, const ollama = new Ollama({ host: voidConfig.ollama.endpoint }) + type GenerateResponse = Awaited> + type ChatResponse = Awaited> + + // First check if model exists ollama.list() .then(async models => { @@ -256,6 +270,18 @@ export const sendOllamaMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, return Promise.reject(); } + if (mode === 'fim') { + + // the fim prompt is the last message + let prompt = messages[messages.length - 1].content + return ollama.generate({ + model: voidConfig.ollama.model, + prompt: prompt, + stream: true, + raw: true, + }) + } + return ollama.chat({ model: voidConfig.ollama.model, messages: messages, @@ -271,7 +297,11 @@ export const sendOllamaMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, } for await (const chunk of stream) { if (didAbort) return; - const newText = chunk.message.content; + + const newText = (mode === 'fim' + ? (chunk as GenerateResponse).response + : (chunk as ChatResponse).message.content + ) fullText += newText; onText(newText, fullText); } @@ -357,26 +387,49 @@ const sendGreptileMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFin } -export const sendLLMMessage: SendLLMMessageFnTypeExternal = ({ messages, onText, onFinalMessage, onError, voidConfig, abortRef }) => { - if (!voidConfig) return; +export const sendLLMMessage: SendLLMMessageFnTypeExternal = ({ mode, messages, fimInfo, onText, onFinalMessage, onError, voidConfig, abortRef }) => { + if (!voidConfig) + return onError('No config file found for LLM.'); + + // handle defaults + if (!mode) mode = 'chat' + if (!messages) messages = [] + + // build messages + if (mode === 'chat') { + // nothing needed + } else if (mode === 'fim') { + fimInfo = fimInfo! + + const system = getFIMSystem({ voidConfig, fimInfo }) + const prompt = getFIMPrompt({ voidConfig, fimInfo }) + messages = ([ + { role: 'system', content: system }, + { role: 'user', content: prompt } + ] as const) + .filter(m => m.content.trim() !== '') + } // trim message content (Anthropic and other providers give an error if there is trailing whitespace) messages = messages.map(m => ({ ...m, content: m.content.trim() })) + if (messages.length === 0) + return onError('No messages provided to LLM.'); switch (voidConfig.default.whichApi) { case 'anthropic': - return sendAnthropicMsg({ messages, onText, onFinalMessage, onError, voidConfig, abortRef }); + return sendAnthropicMsg({ mode, messages, onText, onFinalMessage, onError, voidConfig, abortRef }); case 'openAI': case 'openRouter': case 'openAICompatible': - return sendOpenAIMsg({ messages, onText, onFinalMessage, onError, voidConfig, abortRef }); + return sendOpenAIMsg({ mode, messages, onText, onFinalMessage, onError, voidConfig, abortRef }); case 'gemini': - return sendGeminiMsg({ messages, onText, onFinalMessage, onError, voidConfig, abortRef }); + return sendGeminiMsg({ mode, messages, onText, onFinalMessage, onError, voidConfig, abortRef }); case 'ollama': - return sendOllamaMsg({ messages, onText, onFinalMessage, onError, voidConfig, abortRef }); + return sendOllamaMsg({ mode, messages, onText, onFinalMessage, onError, voidConfig, abortRef }); case 'greptile': - return sendGreptileMsg({ messages, onText, onFinalMessage, onError, voidConfig, abortRef }); + return sendGreptileMsg({ mode, messages, onText, onFinalMessage, onError, voidConfig, abortRef }); default: onError(`Error: whichApi was ${voidConfig.default.whichApi}, which is not recognized!`) } + } diff --git a/extensions/void/src/common/systemPrompts.ts b/extensions/void/src/common/systemPrompts.ts index edbfa03b..276c8570 100644 --- a/extensions/void/src/common/systemPrompts.ts +++ b/extensions/void/src/common/systemPrompts.ts @@ -1,4 +1,14 @@ + + +// used for ctrl+l +const partialGenerationInstructions = `` + + +// used for ctrl+k, autocomplete +const fimInstructions = `` + + const generateDiffInstructions = ` You are a coding assistant. You are given a list of relevant files \`files\`, a selection that the user is making \`selection\`, and instructions to follow \`instructions\`. @@ -397,12 +407,6 @@ COMPLETION export default Sidebar;\`\`\` ` -// used for ctrl+l -const partialGenerationInstructions = `` - - -// used for ctrl+k, autocomplete -const fimInstructions = `` diff --git a/extensions/void/src/extension/AutcompleteProvider.ts b/extensions/void/src/extension/AutcompleteProvider.ts index 28db0bcf..336d587c 100644 --- a/extensions/void/src/extension/AutcompleteProvider.ts +++ b/extensions/void/src/extension/AutcompleteProvider.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { AbortRef, LLMMessage, sendLLMMessage } from '../common/sendLLMMessage'; import { getVoidConfigFromPartial, VoidConfig } from '../webviews/common/contextForConfig'; +import { result } from 'lodash'; type AutocompletionStatus = 'pending' | 'finished' | 'error'; type Autocompletion = { @@ -17,6 +18,30 @@ type Autocompletion = { const DEBOUNCE_TIME = 500 const TIMEOUT_TIME = 60000 +// postprocesses the result +const postprocessResult = (result: string) => { + + // remove leading whitespace from result + return result.trimStart() + +} + + +const extractCodeFromResult = (result: string) => { + + // extract the code between triple backticks + const parts = result.split(/```/); + + // if there is no ``` then return the raw result + if (parts.length === 1) { + return result; + } + + // else return the code between the triple backticks + return parts[1] + +} + // trims the end of the prefix to improve cache hit rate const trimPrefix = (prefix: string) => { const trimmedPrefix = prefix.trimEnd() @@ -31,7 +56,13 @@ const trimPrefix = (prefix: string) => { return trimmedPrefix } -// finds the text in the autocompletion to display +// finds the text in the autocompletion to display, assuming the prefix is already matched +// example: +// originalPrefix = abcd +// generatedMiddle = efgh +// originalSuffix = ijkl +// the user has typed "ef" so prefix = abcdef +// we want to return the rest of the generatedMiddle, which is "gh" const toInlineCompletion = ({ prefix, autocompletion }: { prefix: string, autocompletion: Autocompletion }): vscode.InlineCompletionItem => { const originalPrefix = autocompletion.prefix const generatedMiddle = autocompletion.result @@ -44,18 +75,13 @@ const toInlineCompletion = ({ prefix, autocompletion }: { prefix: string, autoco console.log('generatedMiddle ', generatedMiddle) console.log('trimmedOriginalPrefix ', trimmedOriginalPrefix) console.log('trimmedCurrentPrefix ', trimmedCurrentPrefix) - console.log('lastMatchupIndex ', lastMatchupIndex) + console.log('index: ', lastMatchupIndex) if (lastMatchupIndex < 0) { return new vscode.InlineCompletionItem('') } - // example: - // originalPrefix = abcd - // generatedMiddle = efgh - // originalSuffix = ijkl - // the user has typed "ef" so prefix = abcdef - // we want to return the rest of the generatedMiddle, which is "gh" const completionStr = generatedMiddle.substring(lastMatchupIndex) + console.log('completionStr: ', completionStr) return new vscode.InlineCompletionItem(completionStr) @@ -131,8 +157,6 @@ export class AutocompleteProvider implements vscode.InlineCompletionItemProvider console.log('AAA1') const inlineCompletion = toInlineCompletion({ autocompletion: cachedAutocompletion, prefix, }) - - return [inlineCompletion] } else if (cachedAutocompletion.status === 'pending') { @@ -189,30 +213,12 @@ export class AutocompleteProvider implements vscode.InlineCompletionItemProvider result: '', } - - let messages: LLMMessage[] = [] - switch (voidConfig.default.whichApi) { - case 'ollama': - messages = [ - { role: 'user', content: `[SUFFIX]${suffix}[PREFIX]${prefix} Fill in the middle between the prefix and suffix. Return only the middle. [MIDDLE]` } - ] - break; - case 'anthropic': - case 'openAI': - messages = [ - { role: 'system', content: 'Fill in the prefix up to the suffix. Return only the result and be very concise.' }, - { role: 'user', content: `[SUFFIX]${suffix}[PREFIX]${prefix}` }, - ] - break; - default: - throw new Error(`We do not recommend using autocomplete with your selected provider (${voidConfig.default.whichApi}).`); - } - // set parameters of `newAutocompletion` appropriately newAutocompletion.promise = new Promise((resolve, reject) => { sendLLMMessage({ - messages: messages, + mode: 'fim', + fimInfo: { prefix, suffix }, onText: async (tokenStr, completionStr) => { // TODO filter out bad responses here newAutocompletion.result = completionStr @@ -226,9 +232,10 @@ export class AutocompleteProvider implements vscode.InlineCompletionItemProvider // newAutocompletion.abortRef = { current: () => { } } newAutocompletion.status = 'finished' // newAutocompletion.promise = undefined - newAutocompletion.result = finalMessage + newAutocompletion.result = postprocessResult(extractCodeFromResult(finalMessage)) + + resolve(newAutocompletion.result) - resolve(finalMessage) }, onError: (e) => { newAutocompletion.endTime = Date.now() diff --git a/extensions/void/src/extension/ctrlK.ts b/extensions/void/src/extension/ctrlK.ts index 2d7e35cd..8b3f2ab6 100644 --- a/extensions/void/src/extension/ctrlK.ts +++ b/extensions/void/src/extension/ctrlK.ts @@ -1,8 +1,6 @@ import * as vscode from 'vscode'; import { AbortRef, OnFinalMessage, OnText, sendLLMMessage } from "../common/sendLLMMessage" import { VoidConfig } from '../webviews/common/contextForConfig'; -import { searchDiffChunkInstructions, writeFileWithDiffInstructions } from '../common/systemPrompts'; -import { throttle } from 'lodash'; import { readFileContentOfUri } from './extensionLib/readFileContentOfUri'; const applyCtrlK = async ({ fileUri, startLine, endLine, instructions, voidConfig, abortRef }: { fileUri: vscode.Uri, startLine: number, endLine: number, instructions: string, voidConfig: VoidConfig, abortRef: AbortRef }) => { @@ -22,14 +20,13 @@ const applyCtrlK = async ({ fileUri, startLine, endLine, instructions, voidConfi The user wants to apply the following instructions to the selection: ${instructions} - Instructions: 1. Follow the user's instructions 2. You may ONLY CHANGE the selection, and nothing else in the file 3. Make sure all brackets in the new selection are balanced the same was as in the original selection 4. Be careful not to duplicate or remove variables, comments, or other syntax by mistake -Please rewrite the complete the following code, following the user's instructions. +Please rewrite the complete the following code, following the instructions. \`\`\`
${prefix}
${suffix} From 19a8d36e07465875f125bb620ba33987f4d43b67 Mon Sep 17 00:00:00 2001 From: mp Date: Tue, 12 Nov 2024 02:31:26 -0800 Subject: [PATCH 5/7] Add LRU cache --- extensions/void/package-lock.json | 37 ++++++++----------- extensions/void/package.json | 4 +- extensions/void/src/common/SimpleLruCache.ts | 32 ++++++++++++++++ .../void/src/extension/AutcompleteProvider.ts | 16 ++++---- 4 files changed, 57 insertions(+), 32 deletions(-) create mode 100644 extensions/void/src/common/SimpleLruCache.ts diff --git a/extensions/void/package-lock.json b/extensions/void/package-lock.json index fea021c3..d020b8ac 100644 --- a/extensions/void/package-lock.json +++ b/extensions/void/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "void", "version": "0.0.1", + "dependencies": { + "lru-cache": "^11.0.2" + }, "devDependencies": { "@anthropic-ai/sdk": "^0.29.2", "@eslint/js": "^9.9.1", @@ -35,7 +38,6 @@ "eslint-plugin-react": "^7.35.1", "eslint-plugin-react-hooks": "^4.6.2", "globals": "^15.9.0", - "lodash": "^4.17.21", "marked": "^14.1.0", "ollama": "^0.5.9", "openai": "^4.68.4", @@ -5036,12 +5038,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5106,11 +5102,12 @@ } }, "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz", + "integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==", + "engines": { + "node": "20 || >=22" + } }, "node_modules/make-dir": { "version": "4.0.0", @@ -6618,6 +6615,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, "node_modules/picocolors": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", @@ -7458,16 +7461,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/lru-cache": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.1.tgz", - "integrity": "sha512-CgeuL5uom6j/ZVrg7G/+1IXqRY8JXX4Hghfy5YE0EhoYQWvndP1kufu58cmZLNIDKnRhZrXfdS9urVWx98AipQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/rimraf/node_modules/minimatch": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", diff --git a/extensions/void/package.json b/extensions/void/package.json index 7322c277..45820ca1 100644 --- a/extensions/void/package.json +++ b/extensions/void/package.json @@ -139,7 +139,6 @@ "eslint-plugin-react": "^7.35.1", "eslint-plugin-react-hooks": "^4.6.2", "globals": "^15.9.0", - "lodash": "^4.17.21", "marked": "^14.1.0", "ollama": "^0.5.9", "openai": "^4.68.4", @@ -155,5 +154,8 @@ "typescript": "5.5.4", "typescript-eslint": "^8.3.0", "uuid": "^10.0.0" + }, + "dependencies": { + "lru-cache": "^11.0.2" } } diff --git a/extensions/void/src/common/SimpleLruCache.ts b/extensions/void/src/common/SimpleLruCache.ts new file mode 100644 index 00000000..7118bc8f --- /dev/null +++ b/extensions/void/src/common/SimpleLruCache.ts @@ -0,0 +1,32 @@ +import { LRUCache } from 'lru-cache'; + +const DEFAULT_MAX_SIZE = 20 + + +export class SimpleLRUCache { + private cache: LRUCache; + private maxSize: number + public length: number + + constructor(maxSize?: number) { + + maxSize = maxSize ?? DEFAULT_MAX_SIZE + + this.cache = new LRUCache({ max: maxSize }); + this.length = 0 + this.maxSize = maxSize + } + + push(value: T): void { + const key = this.cache.size; + this.cache.set(key, value); + this.length++ + this.length = Math.min(this.length, this.maxSize) + } + + values() { + return this.cache.values() + } + + +} \ No newline at end of file diff --git a/extensions/void/src/extension/AutcompleteProvider.ts b/extensions/void/src/extension/AutcompleteProvider.ts index 336d587c..5ca8fdac 100644 --- a/extensions/void/src/extension/AutcompleteProvider.ts +++ b/extensions/void/src/extension/AutcompleteProvider.ts @@ -1,7 +1,8 @@ import * as vscode from 'vscode'; import { AbortRef, LLMMessage, sendLLMMessage } from '../common/sendLLMMessage'; import { getVoidConfigFromPartial, VoidConfig } from '../webviews/common/contextForConfig'; -import { result } from 'lodash'; +import { LRUCache } from 'lru-cache'; +import { SimpleLRUCache } from '../common/SimpleLruCache'; type AutocompletionStatus = 'pending' | 'finished' | 'error'; type Autocompletion = { @@ -15,7 +16,7 @@ type Autocompletion = { result: string, } -const DEBOUNCE_TIME = 500 +const DEBOUNCE_TIME = 300 const TIMEOUT_TIME = 60000 // postprocesses the result @@ -26,7 +27,6 @@ const postprocessResult = (result: string) => { } - const extractCodeFromResult = (result: string) => { // extract the code between triple backticks @@ -110,7 +110,7 @@ export class AutocompleteProvider implements vscode.InlineCompletionItemProvider private _extensionContext: vscode.ExtensionContext; - private _autocompletionsOfDocument: { [docUriStr: string]: Autocompletion[] } = {} + private _autocompletionsOfDocument: { [docUriStr: string]: SimpleLRUCache } = {} private _lastTime = 0 @@ -135,14 +135,14 @@ export class AutocompleteProvider implements vscode.InlineCompletionItemProvider const suffix = fullText.substring(cursorOffset) if (!this._autocompletionsOfDocument[docUriStr]) { - this._autocompletionsOfDocument[docUriStr] = [] + this._autocompletionsOfDocument[docUriStr] = new SimpleLRUCache() } const voidConfig = getVoidConfigFromPartial(this._extensionContext.globalState.get('partialVoidConfig') ?? {}) // get autocompletion from cache let cachedAutocompletion: Autocompletion | undefined = undefined - loop: for (const autocompletion of this._autocompletionsOfDocument[docUriStr]!) { + loop: for (const autocompletion of this._autocompletionsOfDocument[docUriStr].values()) { // if the user's change matches up with the generated text if (doesPrefixMatchAutocompletion({ prefix, autocompletion })) { cachedAutocompletion = autocompletion @@ -240,8 +240,6 @@ export class AutocompleteProvider implements vscode.InlineCompletionItemProvider onError: (e) => { newAutocompletion.endTime = Date.now() newAutocompletion.status = 'error' - newAutocompletion.result = '' - reject(e) }, voidConfig, @@ -256,7 +254,7 @@ export class AutocompleteProvider implements vscode.InlineCompletionItemProvider }) // add autocompletion to cache - this._autocompletionsOfDocument[docUriStr]?.push(newAutocompletion) + this._autocompletionsOfDocument[docUriStr].push(newAutocompletion) // show autocompletion try { From a765d738e18740c08c1ec410b2ce955008134224 Mon Sep 17 00:00:00 2001 From: mp Date: Thu, 14 Nov 2024 18:08:52 -0800 Subject: [PATCH 6/7] Basic LSP Usage --- extensions/void/package.json | 6 +- .../common/LangaugeServer/findFunctions.ts | 63 +++++++++++++++++++ extensions/void/src/extension/extension.ts | 6 ++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 extensions/void/src/common/LangaugeServer/findFunctions.ts diff --git a/extensions/void/package.json b/extensions/void/package.json index 45820ca1..a3db23ec 100644 --- a/extensions/void/package.json +++ b/extensions/void/package.json @@ -21,6 +21,10 @@ "properties": {} }, "commands": [ + { + "command": "typeInspector.inspect", + "title": "Inspect Types of All Variables" + }, { "command": "void.ctrl+l", "title": "Show Sidebar" @@ -158,4 +162,4 @@ "dependencies": { "lru-cache": "^11.0.2" } -} +} \ No newline at end of file diff --git a/extensions/void/src/common/LangaugeServer/findFunctions.ts b/extensions/void/src/common/LangaugeServer/findFunctions.ts new file mode 100644 index 00000000..dafc0287 --- /dev/null +++ b/extensions/void/src/common/LangaugeServer/findFunctions.ts @@ -0,0 +1,63 @@ +import * as vscode from 'vscode'; + +const legend = new vscode.SemanticTokensLegend([], []); + +export async function getFunctionTokens() { + + const editor = vscode.window.activeTextEditor; + if (!editor) return; + const document = editor.document; + + const tokens = await vscode.commands.executeCommand( + 'vscode.provideDocumentSemanticTokens', + document.uri + ); + + if (!tokens) { + console.error('No tokens found'); + return []; + } + + const allTokens = decodeTokens(tokens, document); + + console.log("Tokens:", allTokens); + + return allTokens; +} + +function decodeTokens(tokens: vscode.SemanticTokens, document: vscode.TextDocument) { + const data = tokens.data; + const decodedTokens = []; + let line = 0; + let character = 0; + + for (let i = 0; i < data.length; i += 5) { + const deltaLine = data[i]; + const deltaStartChar = data[i + 1]; + const length = data[i + 2]; + const tokenTypeIdx = data[i + 3]; + const tokenModifierIdx = data[i + 4]; + + line += deltaLine; + character = deltaLine === 0 ? character + deltaStartChar : deltaStartChar; + + const type = legend.tokenTypes[tokenTypeIdx] || `(${tokenTypeIdx})`; + const modifier = legend.tokenModifiers[tokenModifierIdx] || `(${tokenModifierIdx})`; + + const tokenRange = new vscode.Range(line, character, line, character + length); + const tokenText = document.getText(tokenRange); + + decodedTokens.push({ + line, + startCharacter: character, + length, + type, + modifier, + text: tokenText, + }); + + console.log(`Token: '${tokenText}' | Type: ${type} | Modifier: ${modifier} | Line: ${line}, Character: ${character}`); + } + + return decodedTokens; +} \ No newline at end of file diff --git a/extensions/void/src/extension/extension.ts b/extensions/void/src/extension/extension.ts index f54df186..5ac18db3 100644 --- a/extensions/void/src/extension/extension.ts +++ b/extensions/void/src/extension/extension.ts @@ -10,6 +10,7 @@ import { readFileContentOfUri } from './extensionLib/readFileContentOfUri'; import { SidebarWebviewProvider } from './providers/SidebarWebviewProvider'; import { CtrlKWebviewProvider } from './providers/CtrlKWebviewProvider'; import { AutocompleteProvider } from './AutcompleteProvider'; +import { getFunctionTokens } from '../common/LangaugeServer/findFunctions'; // // this comes from vscode.proposed.editorInsets.d.ts // declare module 'vscode' { @@ -191,6 +192,11 @@ export function activate(context: vscode.ExtensionContext) { // setupAutocomplete({ voidConfig, abortRef }) + // 7. Language Server + let disposable = vscode.commands.registerCommand('typeInspector.inspect', getFunctionTokens); + + context.subscriptions.push(disposable); + // Gets called when user presses ctrl + k (mounts ctrl+k-style codelens) // TODO need to build this From 0ca2ad4930345973b92ca63a2e31f6e05baa5b7e Mon Sep 17 00:00:00 2001 From: mp Date: Sun, 17 Nov 2024 16:48:23 -0800 Subject: [PATCH 7/7] Create basic language server functionality --- extensions/void/package-lock.json | 69 +++- extensions/void/package.json | 7 +- .../LangaugeServer/createJsProgramGraph.ts | 333 ++++++++++++++++++ .../common/LangaugeServer/findFunctions.ts | 5 +- extensions/void/src/extension/extension.ts | 5 +- 5 files changed, 411 insertions(+), 8 deletions(-) create mode 100644 extensions/void/src/common/LangaugeServer/createJsProgramGraph.ts diff --git a/extensions/void/package-lock.json b/extensions/void/package-lock.json index d020b8ac..d8e80af1 100644 --- a/extensions/void/package-lock.json +++ b/extensions/void/package-lock.json @@ -8,7 +8,10 @@ "name": "void", "version": "0.0.1", "dependencies": { - "lru-cache": "^11.0.2" + "lru-cache": "^11.0.2", + "tree-sitter": "^0.21.1", + "tree-sitter-javascript": "^0.23.1", + "tree-sitter-python": "^0.23.4" }, "devDependencies": { "@anthropic-ai/sdk": "^0.29.2", @@ -6073,6 +6076,14 @@ "dev": true, "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.2.2.tgz", + "integrity": "sha512-9emqXAKhVoNrQ792nLI/wpzPpJ/bj/YXxW0CvAau1+RdGBcCRF1Dmz7719zgVsQNrzHl9Tzn3ImZ4qWFarWL0A==", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -6114,6 +6125,16 @@ } } }, + "node_modules/node-gyp-build": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.3.tgz", + "integrity": "sha512-EMS95CMJzdoSKoIiXo8pxKoL8DYxwIZXYlLmgPb8KUv794abpnLK6ynsCAWNliOjREKruYKdzbh76HHYUHX7nw==", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", @@ -8259,6 +8280,52 @@ "dev": true, "license": "MIT" }, + "node_modules/tree-sitter": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", + "integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==", + "hasInstallScript": true, + "dependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0" + } + }, + "node_modules/tree-sitter-javascript": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/tree-sitter-javascript/-/tree-sitter-javascript-0.23.1.tgz", + "integrity": "sha512-/bnhbrTD9frUYHQTiYnPcxyHORIw157ERBa6dqzaKxvR/x3PC4Yzd+D1pZIMS6zNg2v3a8BZ0oK7jHqsQo9fWA==", + "hasInstallScript": true, + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-python": { + "version": "0.23.4", + "resolved": "https://registry.npmjs.org/tree-sitter-python/-/tree-sitter-python-0.23.4.tgz", + "integrity": "sha512-MbmUAl7y5UCUWqHscHke7DdRDwQnVNMNKQYQc4Gq2p09j+fgPxaU8JVsuOI/0HD3BSEEe5k9j3xmdtIWbDtDgw==", + "hasInstallScript": true, + "dependencies": { + "node-addon-api": "^8.2.1", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", diff --git a/extensions/void/package.json b/extensions/void/package.json index a3db23ec..4a8e3995 100644 --- a/extensions/void/package.json +++ b/extensions/void/package.json @@ -160,6 +160,9 @@ "uuid": "^10.0.0" }, "dependencies": { - "lru-cache": "^11.0.2" + "lru-cache": "^11.0.2", + "tree-sitter": "^0.21.1", + "tree-sitter-javascript": "^0.23.1", + "tree-sitter-python": "^0.23.4" } -} \ No newline at end of file +} diff --git a/extensions/void/src/common/LangaugeServer/createJsProgramGraph.ts b/extensions/void/src/common/LangaugeServer/createJsProgramGraph.ts new file mode 100644 index 00000000..4fb9bcaf --- /dev/null +++ b/extensions/void/src/common/LangaugeServer/createJsProgramGraph.ts @@ -0,0 +1,333 @@ +import * as vscode from 'vscode'; +import Parser from 'tree-sitter'; +import JavaScript from 'tree-sitter-javascript'; + +interface Definition { + file: string; + node: Parser.SyntaxNode; +} + +interface DefnUse { + parent: Parser.SyntaxNode; + file: string; +} + +interface ImportInfo { + source: string; + imported: string; +} + +class ProjectAnalyzer { + private parser: Parser; + private graph: Map>; + private visited: Set; + private parsedFiles: Map; + private imports: Map>; + private definitions: Map; + private fileStack: Set; + + constructor() { + this.parser = new Parser(); + this.parser.setLanguage(JavaScript); + this.graph = new Map(); + this.visited = new Set(); + this.parsedFiles = new Map(); + this.imports = new Map(); + this.definitions = new Map(); + this.fileStack = new Set(); + } + + async parseFile(filePath: string): Promise { + if (this.parsedFiles.has(filePath)) { + return this.parsedFiles.get(filePath)!; + } + + if (this.fileStack.has(filePath)) { + return null; // Circular import + } + + this.fileStack.add(filePath); + + try { + const uri = vscode.Uri.file(filePath); + const document = await vscode.workspace.openTextDocument(uri); + const code = document.getText(); + const tree = this.parser.parse(code); + + this.parsedFiles.set(filePath, tree); + this.collectImports(filePath, tree); + this.collectDefinitions(filePath, tree); + + return tree; + } catch (error) { + console.error(`Error parsing ${filePath}:`, error); + return null; + } finally { + this.fileStack.delete(filePath); + } + } + + private collectImports(filePath: string, tree: Parser.Tree): void { + const fileImports = new Map(); + + const visit = (node: Parser.SyntaxNode): void => { + if (node.type === 'import_declaration') { + const source = node.childForFieldName('source')?.text.slice(1, -1) ?? ''; + const specifiers = node.childForFieldName('specifiers'); + + specifiers?.children.forEach(spec => { + if (spec.type === 'import_specifier') { + const local = spec.childForFieldName('local')?.text ?? ''; + const imported = spec.childForFieldName('imported')?.text ?? ''; + fileImports.set(local, { source, imported }); + } + }); + } + node.children.forEach(visit); + }; + + visit(tree.rootNode); + this.imports.set(filePath, fileImports); + } + + private collectDefinitions(filePath: string, tree: Parser.Tree): void { + const visit = (node: Parser.SyntaxNode): void => { + if (node.type === 'function_declaration') { + const name = node.childForFieldName('name')?.text ?? ''; + this.definitions.set(name, { file: filePath, node }); + } + else if (node.type === 'variable_declarator') { + const name = node.childForFieldName('name')?.text; + const value = node.childForFieldName('value'); + if (name && (value?.type === 'arrow_function' || value?.type === 'function')) { + this.definitions.set(name, { file: filePath, node: value }); + } + } + node.children.forEach(visit); + }; + + visit(tree.rootNode); + } + + private async getTypeFromPosition(uri: vscode.Uri, position: vscode.Position): Promise { + const hover = await vscode.commands.executeCommand( + 'vscode.executeHoverProvider', + uri, + position + ); + + if (hover?.[0]?.contents.length) { + for (const content of hover[0].contents) { + let hoverText = typeof content === 'string' ? + content : + ('value' in content ? content.value : ''); + + // Remove typescript backticks if present + hoverText = hoverText.replace(/```typescript\s*/, '').replace(/```\s*$/, ''); + console.log('Processing hover text:', hoverText); + + // Extract the type information - look for the type after the colon + const typeMatches = [ + /:\s*([\w<>]+)(?:\[\])?/, // matches "foo: Type" or "foo: Type[]" + /var\s+\w+:\s*([\w<>]+)/, // matches "var foo: Type" + /\(type\)\s+[\w<>]+:\s*([\w<>]+)/, // matches "(type) foo: Type" + /\(method\)\s*([\w<>]+)\./ // matches "(method) Type.method" + ]; + + for (const pattern of typeMatches) { + const match = pattern.exec(hoverText); + if (match) { + let type = match[1]; + // Handle array types + if (hoverText.includes('[]')) { + return 'Array'; + } + // Extract base type from generics + if (type.includes('<')) { + type = type.split('<')[0]; + } + return type; + } + } + } + } + return null; + } + + private async getCallsInDefn(defnNode: Parser.SyntaxNode, currentFile: string): Promise> { + const calls = new Set(); + const fileImports = this.imports.get(currentFile) ?? new Map(); + const uri = vscode.Uri.file(currentFile); + + const visit = async (node: Parser.SyntaxNode): Promise => { + if (node.type === 'call_expression') { + const callee = node.childForFieldName('function'); + if (callee?.type === 'identifier') { + const name = callee.text; + const importInfo = fileImports.get(name); + if (importInfo) { + calls.add(`${importInfo.source}:${importInfo.imported}`); + } else { + calls.add(name); + } + } + else if (callee?.type === 'member_expression') { + const method = callee.childForFieldName('property')?.text; + const object = callee.childForFieldName('object'); + + if (method && object) { + const position = new vscode.Position( + object.startPosition.row, + object.startPosition.column + ); + + const type = await this.getTypeFromPosition(uri, position); + if (type) { + calls.add(`${type}.${method}`); + } else { + calls.add(`method:${method}`); + } + } + } + } + + for (const child of node.children) { + await visit(child); + } + }; + + await visit(defnNode); + return calls; + } + + private gotoDefn(name: string): Definition | null { + if (name.includes(':')) { + const [file, funcName] = name.split(':'); + const def = this.definitions.get(funcName); + return def ?? null; + } + + return this.definitions.get(name) ?? null; + } + + private getUses(defnNode: Parser.SyntaxNode, currentFile: string): DefnUse[] { + const uses: DefnUse[] = []; + + let fnName: string | undefined; + if (defnNode.type === 'function_declaration') { + fnName = defnNode.childForFieldName('name')?.text; + } else if (defnNode.type === 'arrow_function' || defnNode.type === 'function') { + const parent = defnNode.parent; + if (parent?.type === 'variable_declarator') { + fnName = parent.childForFieldName('name')?.text; + } + } + + if (!fnName) return uses; + + for (const [file, tree] of this.parsedFiles) { + const visit = (node: Parser.SyntaxNode): void => { + if (node.type === 'call_expression') { + const callee = node.childForFieldName('function'); + if (callee?.type === 'identifier' && callee.text === fnName) { + let current: Parser.SyntaxNode | null = node; + while (current) { + if (current.type === 'function_declaration' || + current.type === 'arrow_function' || + current.type === 'function') { + uses.push({ parent: current, file }); + break; + } + current = current.parent; + } + } + } + node.children.forEach(visit); + }; + + visit(tree.rootNode); + } + + return uses; + } + + private async visitAllNodesInGraphFromDefinition(defn: Parser.SyntaxNode, currentFile: string): Promise { + let defnName: string | undefined; + if (defn.type === 'function_declaration') { + defnName = defn.childForFieldName('name')?.text; + } else if (defn.type === 'arrow_function' || defn.type === 'function') { + const parent = defn.parent; + if (parent?.type === 'variable_declarator') { + defnName = parent.childForFieldName('name')?.text; + } + } + + if (!defnName) return; + + const fullName = `${currentFile}:${defnName}`; + if (this.visited.has(fullName)) return; + + const calls = await this.getCallsInDefn(defn, currentFile); + this.graph.set(fullName, calls); + this.visited.add(fullName); + + const callDefns = Array.from(calls).map(call => this.gotoDefn(call)); + for (const callDefn of callDefns) { + if (callDefn) { + await this.visitAllNodesInGraphFromDefinition(callDefn.node, callDefn.file); + } + } + + const defnUses = this.getUses(defn, currentFile); + for (const defnUse of defnUses) { + await this.visitAllNodesInGraphFromDefinition(defnUse.parent, defnUse.file); + } + } + + async analyze(entryFile: string): Promise>> { + const tree = await this.parseFile(entryFile); + if (!tree) return new Map(); + + const visit = async (node: Parser.SyntaxNode): Promise => { + if (node.type === 'function_declaration') { + await this.visitAllNodesInGraphFromDefinition(node, entryFile); + } + else if (node.type === 'variable_declarator') { + const value = node.childForFieldName('value'); + if (value?.type === 'arrow_function' || value?.type === 'function') { + await this.visitAllNodesInGraphFromDefinition(value, entryFile); + } + } + for (const child of node.children) { + await visit(child); + } + }; + + await visit(tree.rootNode); + return this.graph; + } +} + +export async function runTreeSitter(filePath?: string): Promise> | null> { + const editor = vscode.window.activeTextEditor; + if (!editor && !filePath) { + vscode.window.showWarningMessage('No active editor found'); + return null; + } + + try { + const targetPath = filePath ?? editor!.document.uri.fsPath; + const analyzer = new ProjectAnalyzer(); + const graph = await analyzer.analyze(targetPath); + + for (const [defn, calls] of graph) { + console.log(`${defn} calls: ${[...calls].join(', ')}`); + } + + return graph; + } catch (error) { + console.error('Error analyzing file:', error); + vscode.window.showErrorMessage('Error analyzing file'); + return null; + } +} \ No newline at end of file diff --git a/extensions/void/src/common/LangaugeServer/findFunctions.ts b/extensions/void/src/common/LangaugeServer/findFunctions.ts index dafc0287..570b4369 100644 --- a/extensions/void/src/common/LangaugeServer/findFunctions.ts +++ b/extensions/void/src/common/LangaugeServer/findFunctions.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; const legend = new vscode.SemanticTokensLegend([], []); -export async function getFunctionTokens() { +export async function findFunctions() { const editor = vscode.window.activeTextEditor; if (!editor) return; @@ -20,7 +20,6 @@ export async function getFunctionTokens() { const allTokens = decodeTokens(tokens, document); - console.log("Tokens:", allTokens); return allTokens; } @@ -60,4 +59,4 @@ function decodeTokens(tokens: vscode.SemanticTokens, document: vscode.TextDocume } return decodedTokens; -} \ No newline at end of file +} diff --git a/extensions/void/src/extension/extension.ts b/extensions/void/src/extension/extension.ts index 5ac18db3..82de53b9 100644 --- a/extensions/void/src/extension/extension.ts +++ b/extensions/void/src/extension/extension.ts @@ -10,7 +10,7 @@ import { readFileContentOfUri } from './extensionLib/readFileContentOfUri'; import { SidebarWebviewProvider } from './providers/SidebarWebviewProvider'; import { CtrlKWebviewProvider } from './providers/CtrlKWebviewProvider'; import { AutocompleteProvider } from './AutcompleteProvider'; -import { getFunctionTokens } from '../common/LangaugeServer/findFunctions'; +import { runTreeSitter } from '../common/LangaugeServer/createJsProgramGraph'; // // this comes from vscode.proposed.editorInsets.d.ts // declare module 'vscode' { @@ -193,7 +193,8 @@ export function activate(context: vscode.ExtensionContext) { // 7. Language Server - let disposable = vscode.commands.registerCommand('typeInspector.inspect', getFunctionTokens); + console.log('run lsp') + let disposable = vscode.commands.registerCommand('typeInspector.inspect', runTreeSitter); context.subscriptions.push(disposable);