diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a96aa0e8c4f..c42e3446877 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,7 +87,7 @@ Alternatively, if you want to build Void from the terminal, instead of pressing #### Common Fixes - Make sure you followed the prerequisite steps. -- Make sure you have the same NodeJS version as `.nvmrc`. +- Make sure you have Node version `20.16.0` (the version in `.nvmrc`)! - If you get `"TypeError: Failed to fetch dynamically imported module"`, make sure all imports end with `.js`. - If you see missing styles, wait a few seconds and then reload. - If you have any questions, feel free to [submit an issue](https://github.com/voideditor/void/issues/new). You can also refer to VSCode's complete [How to Contribute](https://github.com/microsoft/vscode/wiki/How-to-Contribute) page. diff --git a/README.md b/README.md index f1594fd46a9..15249823133 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ This repo contains the full sourcecode for Void. We are currently in [open beta] 2. To get started working on Void, see [Contributing](https://github.com/voideditor/void/blob/main/CONTRIBUTING.md). -3. We're open to collaborations of all types - just reach out. +3. We're open to collaborations and suggestions of all types - just reach out. ## Reference diff --git a/package-lock.json b/package-lock.json index 966a06e81fc..bc0458914c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.32.1", + "@floating-ui/react": "^0.27.3", "@google/generative-ai": "^0.21.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -1551,6 +1552,65 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.3.tgz", + "integrity": "sha512-CLHnes3ixIFFKVQDdICjel8muhFLOBdQH7fgtHNPY8UbCNqbeKZ262G7K66lGQOUQWWnYocf7ZbUsLJgGfsLHg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.9", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react/node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "license": "MIT" + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "license": "MIT" + }, "node_modules/@google/generative-ai": { "version": "0.21.0", "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.21.0.tgz", diff --git a/package.json b/package.json index c8f0159b496..a4ee38bb463 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.32.1", + "@floating-ui/react": "^0.27.3", "@google/generative-ai": "^0.21.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", diff --git a/remote/package.json b/remote/package.json index 98776776c12..cf913ad2842 100644 --- a/remote/package.json +++ b/remote/package.json @@ -26,6 +26,7 @@ "cookie": "^0.4.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", + "debounced": "1.0.2", "jschardet": "3.1.3", "kerberos": "2.1.1", "minimist": "^1.2.6", diff --git a/src/vs/platform/void/common/llmMessageService.ts b/src/vs/platform/void/common/llmMessageService.ts index 40855c8d309..fcec27f9ea8 100644 --- a/src/vs/platform/void/common/llmMessageService.ts +++ b/src/vs/platform/void/common/llmMessageService.ts @@ -3,7 +3,7 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { EventLLMMessageOnTextParams, EventLLMMessageOnErrorParams, EventLLMMessageOnFinalMessageParams, ServiceSendLLMMessageParams, MainLLMMessageParams, MainLLMMessageAbortParams, ServiceModelListParams, EventModelListOnSuccessParams, EventModelListOnErrorParams, MainModelListParams, OllamaModelResponse, OpenaiCompatibleModelResponse, } from './llmMessageTypes.js'; +import { EventLLMMessageOnTextParams, EventLLMMessageOnErrorParams, EventLLMMessageOnFinalMessageParams, ServiceSendLLMMessageParams, MainSendLLMMessageParams, MainLLMMessageAbortParams, ServiceModelListParams, EventModelListOnSuccessParams, EventModelListOnErrorParams, MainModelListParams, OllamaModelResponse, OpenaiCompatibleModelResponse, } from './llmMessageTypes.js'; import { IChannel } from '../../../base/parts/ipc/common/ipc.js'; import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; @@ -96,31 +96,29 @@ export class LLMMessageService extends Disposable implements ILLMMessageService onError({ message: 'Please add a Provider in Settings!', fullError: null }) return null } - const { providerName, modelName } = modelSelection - // add ai instructions here because we don't have access to voidSettingsService on the other side of the proxy - const aiInstructions = this.voidSettingsService.state.globalSettings.aiInstructions - if (aiInstructions) - proxyParams.messages.unshift({ role: 'system', content: aiInstructions }) + const { providerName, modelName } = modelSelection // add state for request id - const requestId_ = generateUuid(); - this.onTextHooks_llm[requestId_] = onText - this.onFinalMessageHooks_llm[requestId_] = onFinalMessage - this.onErrorHooks_llm[requestId_] = onError + const requestId = generateUuid(); + this.onTextHooks_llm[requestId] = onText + this.onFinalMessageHooks_llm[requestId] = onFinalMessage + this.onErrorHooks_llm[requestId] = onError + const { aiInstructions } = this.voidSettingsService.state.globalSettings const { settingsOfProvider } = this.voidSettingsService.state // params will be stripped of all its functions over the IPC channel this.channel.call('sendLLMMessage', { ...proxyParams, - requestId: requestId_, + aiInstructions, + requestId, providerName, modelName, settingsOfProvider, - } satisfies MainLLMMessageParams); + } satisfies MainSendLLMMessageParams); - return requestId_ + return requestId } @@ -147,6 +145,7 @@ export class LLMMessageService extends Disposable implements ILLMMessageService } satisfies MainModelListParams) } + openAICompatibleList = (params: ServiceModelListParams) => { const { onSuccess, onError, ...proxyParams } = params diff --git a/src/vs/platform/void/common/llmMessageTypes.ts b/src/vs/platform/void/common/llmMessageTypes.ts index fbf7a1b227d..5966f1b1e5b 100644 --- a/src/vs/platform/void/common/llmMessageTypes.ts +++ b/src/vs/platform/void/common/llmMessageTypes.ts @@ -3,7 +3,6 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { IRange } from '../../../editor/common/core/range' import { ProviderName, SettingsOfProvider } from './voidSettingsTypes.js' @@ -35,67 +34,84 @@ export type _InternalLLMMessage = { content: string; } +type _InternalOllamaFIMMessages = { + prefix: string; + suffix: string; + stopTokens: string[]; +} -export type ServiceSendLLMFeatureParams = { - useProviderFor: 'Ctrl+K'; - range: IRange; -} | { - useProviderFor: 'Ctrl+L'; +type SendLLMType = { + type: 'sendLLMMessage'; + messages: LLMMessage[]; } | { - useProviderFor: 'Autocomplete'; - range: IRange; + type: 'ollamaFIM'; + messages: _InternalOllamaFIMMessages; } +// service types +export type ServiceSendLLMMessageParams = { + onText: OnText; + onFinalMessage: OnFinalMessage; + onError: OnError; + logging: { loggingName: string, }; + useProviderFor: 'Ctrl+K' | 'Ctrl+L' | 'Autocomplete'; +} & SendLLMType + // params to the true sendLLMMessage function -export type LLMMMessageParams = { +export type SendLLMMessageParams = { onText: OnText; onFinalMessage: OnFinalMessage; onError: OnError; + logging: { loggingName: string, }; abortRef: AbortRef; - messages: LLMMessage[]; + aiInstructions: string; - logging: { - loggingName: string, - }; providerName: ProviderName; modelName: string; settingsOfProvider: SettingsOfProvider; -} +} & SendLLMType -export type ServiceSendLLMMessageParams = { - onText: OnText; - onFinalMessage: OnFinalMessage; - onError: OnError; - - messages: LLMMessage[]; - logging: { - loggingName: string, - }; -} & ServiceSendLLMFeatureParams // can't send functions across a proxy, use listeners instead export type BlockedMainLLMMessageParams = 'onText' | 'onFinalMessage' | 'onError' | 'abortRef' +export type MainSendLLMMessageParams = Omit & { requestId: string } & SendLLMType -export type MainLLMMessageParams = Omit & { requestId: string } export type MainLLMMessageAbortParams = { requestId: string } export type EventLLMMessageOnTextParams = Parameters[0] & { requestId: string } export type EventLLMMessageOnFinalMessageParams = Parameters[0] & { requestId: string } export type EventLLMMessageOnErrorParams = Parameters[0] & { requestId: string } -export type _InternalSendLLMMessageFnType = (params: { - messages: _InternalLLMMessage[]; - onText: OnText; - onFinalMessage: OnFinalMessage; - onError: OnError; - settingsOfProvider: SettingsOfProvider; - providerName: ProviderName; - modelName: string; - _setAborter: (aborter: () => void) => void; -}) => void +export type _InternalSendLLMMessageFnType = ( + params: { + onText: OnText; + onFinalMessage: OnFinalMessage; + onError: OnError; + providerName: ProviderName; + settingsOfProvider: SettingsOfProvider; + modelName: string; + _setAborter: (aborter: () => void) => void; + + messages: _InternalLLMMessage[]; + } +) => void + +export type _InternalOllamaFIMMessageFnType = ( + params: { + onText: OnText; + onFinalMessage: OnFinalMessage; + onError: OnError; + providerName: ProviderName; + settingsOfProvider: SettingsOfProvider; + modelName: string; + _setAborter: (aborter: () => void) => void; + + messages: _InternalOllamaFIMMessages; + } +) => void // service -> main -> internal -> event (back to main) // (browser) diff --git a/src/vs/platform/void/common/voidSettingsService.ts b/src/vs/platform/void/common/voidSettingsService.ts index c92810abead..811f43371e6 100644 --- a/src/vs/platform/void/common/voidSettingsService.ts +++ b/src/vs/platform/void/common/voidSettingsService.ts @@ -81,7 +81,7 @@ let _computeModelOptions = (settingsOfProvider: SettingsOfProvider) => { const defaultState = () => { const d: VoidSettingsState = { settingsOfProvider: deepClone(defaultSettingsOfProvider), - modelSelectionOfFeature: { 'Ctrl+L': null, 'Ctrl+K': null, 'Autocomplete': null }, + modelSelectionOfFeature: { 'Ctrl+L': null, 'Ctrl+K': null, 'Autocomplete': null, 'FastApply': null }, globalSettings: deepClone(defaultGlobalSettings), _modelOptions: _computeModelOptions(defaultSettingsOfProvider), // computed } @@ -137,6 +137,11 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { ...defaultSettingsOfProvider.gemini.models.filter(m => /* if cant find the model in readS (yes this is O(n^2), very small) */ !readS.settingsOfProvider.gemini.models.find(m2 => m2.modelName === m.modelName)) ] } + }, + modelSelectionOfFeature: { + // A HACK BECAUSE WE ADDED FastApply + ...{ 'FastApply': null }, + ...readS.modelSelectionOfFeature, } } diff --git a/src/vs/platform/void/common/voidSettingsTypes.ts b/src/vs/platform/void/common/voidSettingsTypes.ts index eb588abfb71..43a29f5b78a 100644 --- a/src/vs/platform/void/common/voidSettingsTypes.ts +++ b/src/vs/platform/void/common/voidSettingsTypes.ts @@ -86,9 +86,10 @@ export const defaultDeepseekModels = modelInfoOfDefaultNames([ // https://console.groq.com/docs/models export const defaultGroqModels = modelInfoOfDefaultNames([ - "mixtral-8x7b-32768", - "llama2-70b-4096", - "gemma-7b-it" + "distil-whisper-large-v3-en", + "llama-3.3-70b-versatile", + "llama-3.1-8b-instant", + "gemma2-9b-it" ]) @@ -431,14 +432,22 @@ export const modelSelectionsEqual = (m1: ModelSelection, m2: ModelSelection) => } // this is a state -export type ModelSelectionOfFeature = { - 'Ctrl+L': ModelSelection | null, - 'Ctrl+K': ModelSelection | null, - 'Autocomplete': ModelSelection | null, -} +export const featureNames = ['Ctrl+L', 'Ctrl+K', 'Autocomplete', 'FastApply'] as const +export type ModelSelectionOfFeature = Record<(typeof featureNames)[number], ModelSelection | null> export type FeatureName = keyof ModelSelectionOfFeature -export const featureNames = ['Ctrl+L', 'Ctrl+K', 'Autocomplete'] as const +export const displayInfoOfFeatureName = (featureName: FeatureName) => { + if (featureName === 'Autocomplete') + return 'Autocomplete' + else if (featureName === 'Ctrl+K') + return 'Quick Edit' + else if (featureName === 'Ctrl+L') + return 'Sidebar Chat' + else if (featureName === 'FastApply') + return 'Fast Apply' + else + throw new Error(`Feature Name ${featureName} not allowed`) +} diff --git a/src/vs/platform/void/electron-main/llmMessage/ollama.ts b/src/vs/platform/void/electron-main/llmMessage/ollama.ts index 957927000c8..e76b6186339 100644 --- a/src/vs/platform/void/electron-main/llmMessage/ollama.ts +++ b/src/vs/platform/void/electron-main/llmMessage/ollama.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------*/ import { Ollama } from 'ollama'; -import { _InternalModelListFnType, _InternalSendLLMMessageFnType, OllamaModelResponse } from '../../common/llmMessageTypes.js'; +import { _InternalModelListFnType, _InternalOllamaFIMMessageFnType, _InternalSendLLMMessageFnType, OllamaModelResponse } from '../../common/llmMessageTypes.js'; import { defaultProviderSettings } from '../../common/voidSettingsTypes.js'; export const ollamaList: _InternalModelListFnType = async ({ onSuccess: onSuccess_, onError: onError_, settingsOfProvider }) => { @@ -38,6 +38,44 @@ export const ollamaList: _InternalModelListFnType = async ( } +export const sendOllamaFIM: _InternalOllamaFIMMessageFnType = ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter }) => { + + const thisConfig = settingsOfProvider.ollama + // if endpoint is empty, normally ollama will send to 11434, but we want it to fail - the user should type it in + if (!thisConfig.endpoint) throw new Error(`Ollama Endpoint was empty (please enter ${defaultProviderSettings.ollama.endpoint} if you want the default).`) + + let fullText = '' + + const ollama = new Ollama({ host: thisConfig.endpoint }) + + ollama.generate({ + model: modelName, + prompt: messages.prefix, + suffix: messages.suffix, + options: { + stop: messages.stopTokens, + }, + raw: true, + stream: true, + // options: { num_predict: parseMaxTokensStr(thisConfig.maxTokens) } // this is max_tokens + }) + .then(async stream => { + _setAborter(() => stream.abort()) + // iterate through the stream + for await (const chunk of stream) { + const newText = chunk.response; + fullText += newText; + onText({ newText, fullText }); + } + onFinalMessage({ fullText }); + }) + // when error/fail + .catch((error) => { + onError({ message: error + '', fullError: error }) + }) +}; + + // Ollama export const sendOllamaMsg: _InternalSendLLMMessageFnType = ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter }) => { @@ -68,14 +106,6 @@ export const sendOllamaMsg: _InternalSendLLMMessageFnType = ({ messages, onText, }) // when error/fail .catch((error) => { - // if (typeof error === 'object') { - // const e = error.error as ErrorResponse['error'] - // if (e) { - // const name = error.name ?? 'Error' - // onError({ error: `${name}: ${e}` }) - // return; - // } - // } onError({ message: error + '', fullError: error }) }) diff --git a/src/vs/platform/void/electron-main/llmMessage/sendLLMMessage.ts b/src/vs/platform/void/electron-main/llmMessage/sendLLMMessage.ts index bb3884b3120..ab64fd85c76 100644 --- a/src/vs/platform/void/electron-main/llmMessage/sendLLMMessage.ts +++ b/src/vs/platform/void/electron-main/llmMessage/sendLLMMessage.ts @@ -3,11 +3,11 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { LLMMMessageParams, OnText, OnFinalMessage, OnError, LLMMessage, _InternalLLMMessage } from '../../common/llmMessageTypes.js'; +import { SendLLMMessageParams, OnText, OnFinalMessage, OnError, LLMMessage, _InternalLLMMessage } from '../../common/llmMessageTypes.js'; import { IMetricsService } from '../../common/metricsService.js'; import { sendAnthropicMsg } from './anthropic.js'; -import { sendOllamaMsg } from './ollama.js'; +import { sendOllamaFIM, sendOllamaMsg } from './ollama.js'; import { sendOpenAIMsg } from './openai.js'; import { sendGeminiMsg } from './gemini.js'; import { sendGroqMsg } from './groq.js'; @@ -49,6 +49,8 @@ const cleanMessages = (messages: LLMMessage[]): _InternalLLMMessage[] => { export const sendLLMMessage = ({ + type, + aiInstructions, messages: messages_, onText: onText_, onFinalMessage: onFinalMessage_, @@ -58,21 +60,28 @@ export const sendLLMMessage = ({ settingsOfProvider, providerName, modelName, -}: LLMMMessageParams, +}: SendLLMMessageParams, metricsService: IMetricsService ) => { - const messages = cleanMessages(messages_) + // messages.unshift({ role: 'system', content: aiInstructions }) + + const messagesArr = type === 'sendLLMMessage' ? cleanMessages(messages_) : [] // only captures number of messages and message "shape", no actual code, instructions, prompts, etc - const captureChatEvent = (eventId: string, extras?: object) => { + const captureLLMEvent = (eventId: string, extras?: object) => { metricsService.capture(eventId, { providerName, modelName, - numMessages: messages?.length, - messagesShape: messages?.map(msg => ({ role: msg.role, length: msg.content.length })), - origNumMessages: messages_?.length, - origMessagesShape: messages_?.map(msg => ({ role: msg.role, length: msg.content.length })), + ...type === 'sendLLMMessage' ? { + numMessages: messagesArr?.length, + messagesShape: messagesArr?.map(msg => ({ role: msg.role, length: msg.content.length })), + origNumMessages: messages_?.length, + origMessagesShape: messages_?.map(msg => ({ role: msg.role, length: msg.content.length })), + + } : type === 'ollamaFIM' ? { + + } : {}, ...extras, }) @@ -92,49 +101,52 @@ export const sendLLMMessage = ({ const onFinalMessage: OnFinalMessage = ({ fullText }) => { if (_didAbort) return - captureChatEvent(`${loggingName} - Received Full Message`, { messageLength: fullText.length, duration: new Date().getMilliseconds() - submit_time.getMilliseconds() }) + captureLLMEvent(`${loggingName} - Received Full Message`, { messageLength: fullText.length, duration: new Date().getMilliseconds() - submit_time.getMilliseconds() }) onFinalMessage_({ fullText }) } const onError: OnError = ({ message: error, fullError }) => { if (_didAbort) return console.error('sendLLMMessage onError:', error) - captureChatEvent(`${loggingName} - Error`, { error }) + captureLLMEvent(`${loggingName} - Error`, { error }) onError_({ message: error, fullError }) } const onAbort = () => { - captureChatEvent(`${loggingName} - Abort`, { messageLengthSoFar: _fullTextSoFar.length }) + captureLLMEvent(`${loggingName} - Abort`, { messageLengthSoFar: _fullTextSoFar.length }) try { _aborter?.() } // aborter sometimes automatically throws an error catch (e) { } _didAbort = true } abortRef_.current = onAbort - captureChatEvent(`${loggingName} - Sending Message`, { messageLength: messages[messages.length - 1]?.content.length }) + captureLLMEvent(`${loggingName} - Sending Message`, { messageLength: messagesArr[messagesArr.length - 1]?.content.length }) try { switch (providerName) { case 'anthropic': - sendAnthropicMsg({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }); + sendAnthropicMsg({ messages: messagesArr, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }); break; case 'openAI': case 'openRouter': case 'deepseek': case 'openAICompatible': - sendOpenAIMsg({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }); + sendOpenAIMsg({ messages: messagesArr, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }); break; case 'gemini': - sendGeminiMsg({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }); + sendGeminiMsg({ messages: messagesArr, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }); break; case 'ollama': - sendOllamaMsg({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }); + if (type === 'ollamaFIM') + sendOllamaFIM({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }) + else + sendOllamaMsg({ messages: messagesArr, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }); break; case 'groq': - sendGroqMsg({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }); + sendGroqMsg({ messages: messagesArr, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }); break; case 'mistral': - sendMistralMsg({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }); + sendMistralMsg({ messages: messagesArr, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }); break; default: onError({ message: `Error: Void provider was "${providerName}", which is not recognized.`, fullError: null }) diff --git a/src/vs/platform/void/electron-main/llmMessageChannel.ts b/src/vs/platform/void/electron-main/llmMessageChannel.ts index 2430fce2204..2c44e2ece18 100644 --- a/src/vs/platform/void/electron-main/llmMessageChannel.ts +++ b/src/vs/platform/void/electron-main/llmMessageChannel.ts @@ -8,7 +8,7 @@ import { IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { EventLLMMessageOnTextParams, EventLLMMessageOnErrorParams, EventLLMMessageOnFinalMessageParams, MainLLMMessageParams, AbortRef, LLMMMessageParams, MainLLMMessageAbortParams, MainModelListParams, ModelListParams, EventModelListOnSuccessParams, EventModelListOnErrorParams, OllamaModelResponse, OpenaiCompatibleModelResponse, } from '../common/llmMessageTypes.js'; +import { EventLLMMessageOnTextParams, EventLLMMessageOnErrorParams, EventLLMMessageOnFinalMessageParams, MainSendLLMMessageParams, AbortRef, SendLLMMessageParams, MainLLMMessageAbortParams, MainModelListParams, ModelListParams, EventModelListOnSuccessParams, EventModelListOnErrorParams, OllamaModelResponse, OpenaiCompatibleModelResponse, } from '../common/llmMessageTypes.js'; import { sendLLMMessage } from './llmMessage/sendLLMMessage.js' import { IMetricsService } from '../common/metricsService.js'; import { ollamaList } from './llmMessage/ollama.js'; @@ -91,13 +91,13 @@ export class LLMMessageChannel implements IServerChannel { } // the only place sendLLMMessage is actually called - private async _callSendLLMMessage(params: MainLLMMessageParams) { + private async _callSendLLMMessage(params: MainSendLLMMessageParams) { const { requestId } = params; if (!(requestId in this._abortRefOfRequestId_llm)) this._abortRefOfRequestId_llm[requestId] = { current: null } - const mainThreadParams: LLMMMessageParams = { + const mainThreadParams: SendLLMMessageParams = { ...params, onText: ({ newText, fullText }) => { this._onText_llm.fire({ requestId, newText, fullText }); }, onFinalMessage: ({ fullText }) => { this._onFinalMessage_llm.fire({ requestId, fullText }); }, diff --git a/src/vs/workbench/contrib/void/browser/autocompleteService.ts b/src/vs/workbench/contrib/void/browser/autocompleteService.ts index a7516be2a8b..eb4d9f7b6a3 100644 --- a/src/vs/workbench/contrib/void/browser/autocompleteService.ts +++ b/src/vs/workbench/contrib/void/browser/autocompleteService.ts @@ -1,826 +1,988 @@ -// /*-------------------------------------------------------------------------------------- -// * Copyright 2025 Glass Devtools, Inc. All rights reserved. -// * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. -// *--------------------------------------------------------------------------------------*/ - -// import { Disposable } from '../../../../base/common/lifecycle.js'; -// import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; -// import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; -// import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -// import { ITextModel } from '../../../../editor/common/model.js'; -// import { Position } from '../../../../editor/common/core/position.js'; -// import { InlineCompletion, InlineCompletionContext } from '../../../../editor/common/languages.js'; -// import { CancellationToken } from '../../../../base/common/cancellation.js'; -// import { Range } from '../../../../editor/common/core/range.js'; -// import { ILLMMessageService } from '../../../../platform/void/common/llmMessageService.js'; -// import { IEditorService } from '../../../services/editor/common/editorService.js'; -// import { isCodeEditor } from '../../../../editor/browser/editorBrowser.js'; -// import { EditorResourceAccessor } from '../../../common/editor.js'; -// import { IModelService } from '../../../../editor/common/services/model.js'; -// import { extractCodeFromRegular } from './helpers/extractCodeFromResult.js'; - -// // The extension this was called from is here - https://github.com/voideditor/void/blob/autocomplete/extensions/void/src/extension/extension.ts - - -// /* -// A summary of autotab: - -// Postprocessing -// -one common problem for all models is outputting unbalanced parentheses -// we solve this by trimming all extra closing parentheses from the generated string -// in future, should make sure parentheses are always balanced - -// -another problem is completing the middle of a string, eg. "const [x, CURSOR] = useState()" -// we complete up to first matchup character -// but should instead complete the whole line / block (difficult because of parenthesis accuracy) - -// -too much info is bad. usually we want to show the user 1 line, and have a preloaded response afterwards -// this should happen automatically with caching system -// should break preloaded responses into \n\n chunks - -// Preprocessing -// - we don't generate if cursor is at end / beginning of a line (no spaces) -// - we generate 1 line if there is text to the right of cursor -// - we generate 1 line if variable declaration -// - (in many cases want to show 1 line but generate multiple) - -// State -// - cache based on prefix (and do some trimming first) -// - when press tab on one line, should have an immediate followup response -// to do this, show autocompletes before they're fully finished -// - [todo] remove each autotab when accepted -// !- [todo] provide type information - -// Details -// -generated results are trimmed up to 1 leading/trailing space -// -prefixes are cached up to 1 trailing newline -// - -// */ - -// class LRUCache { -// public items: Map; -// private keyOrder: K[]; -// private maxSize: number; -// private disposeCallback?: (value: V, key?: K) => void; - -// constructor(maxSize: number, disposeCallback?: (value: V, key?: K) => void) { -// if (maxSize <= 0) throw new Error('Cache size must be greater than 0'); - -// this.items = new Map(); -// this.keyOrder = []; -// this.maxSize = maxSize; -// this.disposeCallback = disposeCallback; +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ITextModel } from '../../../../editor/common/model.js'; +import { Position } from '../../../../editor/common/core/position.js'; +import { InlineCompletion, InlineCompletionContext, LocationLink } from '../../../../editor/common/languages.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { ILLMMessageService } from '../../../../platform/void/common/llmMessageService.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { isCodeEditor } from '../../../../editor/browser/editorBrowser.js'; +import { EditorResourceAccessor } from '../../../common/editor.js'; +import { IModelService } from '../../../../editor/common/services/model.js'; +import { extractCodeFromRegular } from './helpers/extractCodeFromResult.js'; +import { isWindows } from '../../../../base/common/platform.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; + +// The extension this was called from is here - https://github.com/voideditor/void/blob/autocomplete/extensions/void/src/extension/extension.ts + + +/* +A summary of autotab: + +Postprocessing +-one common problem for all models is outputting unbalanced parentheses +we solve this by trimming all extra closing parentheses from the generated string +in future, should make sure parentheses are always balanced + +-another problem is completing the middle of a string, eg. "const [x, CURSOR] = useState()" +we complete up to first matchup character +but should instead complete the whole line / block (difficult because of parenthesis accuracy) + +-too much info is bad. usually we want to show the user 1 line, and have a preloaded response afterwards +this should happen automatically with caching system +should break preloaded responses into \n\n chunks + +Preprocessing +- we don't generate if cursor is at end / beginning of a line (no spaces) +- we generate 1 line if there is text to the right of cursor +- we generate 1 line if variable declaration +- (in many cases want to show 1 line but generate multiple) + +State +- cache based on prefix (and do some trimming first) +- when press tab on one line, should have an immediate followup response +to do this, show autocompletes before they're fully finished +- [todo] remove each autotab when accepted +!- [todo] provide type information + +Details +-generated results are trimmed up to 1 leading/trailing space +-prefixes are cached up to 1 trailing newline +- +*/ + +class LRUCache { + public items: Map; + private keyOrder: K[]; + private maxSize: number; + private disposeCallback?: (value: V, key?: K) => void; + + constructor(maxSize: number, disposeCallback?: (value: V, key?: K) => void) { + if (maxSize <= 0) throw new Error('Cache size must be greater than 0'); + + this.items = new Map(); + this.keyOrder = []; + this.maxSize = maxSize; + this.disposeCallback = disposeCallback; + } + + set(key: K, value: V): void { + // If key exists, remove it from the order list + if (this.items.has(key)) { + this.keyOrder = this.keyOrder.filter(k => k !== key); + } + // If cache is full, remove least recently used item + else if (this.items.size >= this.maxSize) { + const key = this.keyOrder[0]; + const value = this.items.get(key); + + // Call dispose callback if it exists + if (this.disposeCallback && value !== undefined) { + this.disposeCallback(value, key); + } + + this.items.delete(key); + this.keyOrder.shift(); + } + + // Add new item + this.items.set(key, value); + this.keyOrder.push(key); + } + + delete(key: K): boolean { + const value = this.items.get(key); + + if (value !== undefined) { + // Call dispose callback if it exists + if (this.disposeCallback) { + this.disposeCallback(value, key); + } + + this.items.delete(key); + this.keyOrder = this.keyOrder.filter(k => k !== key); + return true; + } + + return false; + } + + clear(): void { + // Call dispose callback for all items if it exists + if (this.disposeCallback) { + for (const [key, value] of this.items.entries()) { + this.disposeCallback(value, key); + } + } + + this.items.clear(); + this.keyOrder = []; + } + + get size(): number { + return this.items.size; + } + + has(key: K): boolean { + return this.items.has(key); + } +} + +type AutocompletionPredictionType = + | 'single-line-fill-middle' + | 'single-line-redo-suffix' + // | 'multi-line-start-here' + | 'multi-line-start-on-next-line' + | 'do-not-predict' + +type Autocompletion = { + id: number, + prefix: string, + suffix: string, + llmPrefix: string, + llmSuffix: string, + startTime: number, + endTime: number | undefined, + status: 'pending' | 'finished' | 'error', + type: AutocompletionPredictionType, + llmPromise: Promise | undefined, + insertText: string, + requestId: string | null, +} + +const DEBOUNCE_TIME = 500 +const TIMEOUT_TIME = 60000 +const MAX_CACHE_SIZE = 20 +const MAX_PENDING_REQUESTS = 2 + +// postprocesses the result +const joinSpaces = (result: string) => { + + // trim all whitespace except for a single leading/trailing space + // return result.trim() + + const hasLeadingSpace = result.startsWith(' '); + const hasTrailingSpace = result.endsWith(' '); + return (hasLeadingSpace ? ' ' : '') + + result.trim() + + (hasTrailingSpace ? ' ' : ''); + +} + + +// trims the end of the prefix to improve cache hit rate +const removeLeftTabsAndTrimEnds = (s: string): string => { + const trimmedString = s.trimEnd(); + const trailingEnd = s.slice(trimmedString.length); + + // keep only a single trailing newline + if (trailingEnd.includes(_ln)) { + s = trimmedString + _ln; + } + + s = s.replace(/^\s+/gm, ''); // remove left tabs + + return s; +} + + + +const removeAllWhitespace = (str: string): string => str.replace(/\s+/g, ''); + +function isSubsequence({ of, subsequence }: { of: string, subsequence: string }): boolean { + if (subsequence.length === 0) return true; + if (of.length === 0) return false; + + let subsequenceIndex = 0; + + for (let i = 0; i < of.length; i++) { + if (of[i] === subsequence[subsequenceIndex]) { + subsequenceIndex++; + } + if (subsequenceIndex === subsequence.length) { + return true; + } + } + + return false; +} + + +function getStringUpToUnbalancedClosingParenthesis(s: string, prefix: string): string { + + const pairs: Record = { ')': '(', '}': '{', ']': '[' }; + + // process all bracets in prefix + let stack: string[] = [] + const firstOpenIdx = prefix.search(/[[({]/); + if (firstOpenIdx !== -1) { + const brackets = prefix.slice(firstOpenIdx).split('').filter(c => '()[]{}'.includes(c)); + + for (const bracket of brackets) { + if (bracket === '(' || bracket === '{' || bracket === '[') { + stack.push(bracket); + } else { + if (stack.length > 0 && stack[stack.length - 1] === pairs[bracket]) { + stack.pop(); + } else { + stack.push(bracket); + } + } + } + } + + // iterate through each character + for (let i = 0; i < s.length; i++) { + const char = s[i]; + + if (char === '(' || char === '{' || char === '[') { stack.push(char); } + else if (char === ')' || char === '}' || char === ']') { + if (stack.length === 0 || stack.pop() !== pairs[char]) { return s.substring(0, i); } + } + } + return s; +} + + + +// further trim the autocompletion +const postprocessAutocompletion = ({ autocompletionMatchup, autocompletion, prefixAndSuffix }: { autocompletionMatchup: AutocompletionMatchupBounds, autocompletion: Autocompletion, prefixAndSuffix: PrefixAndSuffixInfo }) => { + + const { prefix, prefixToTheLeftOfCursor, suffixToTheRightOfCursor } = prefixAndSuffix + + const generatedMiddle = autocompletion.insertText + + let startIdx = autocompletionMatchup.startIdx + let endIdx = generatedMiddle.length // exclusive bounds + + // const naiveReturnValue = generatedMiddle.slice(startIdx) + // console.log('naiveReturnValue: ', JSON.stringify(naiveReturnValue)) + // return [{ insertText: naiveReturnValue, }] + + // do postprocessing for better ux + // this is a bit hacky but may change a lot + + // if there is space at the start of the completion and user has added it, remove it + const charToLeftOfCursor = prefixToTheLeftOfCursor.slice(-1)[0] || '' + const userHasAddedASpace = charToLeftOfCursor === ' ' || charToLeftOfCursor === '\t' + const rawFirstNonspaceIdx = generatedMiddle.slice(startIdx).search(/[^\t ]/) + if (rawFirstNonspaceIdx > -1 && userHasAddedASpace) { + const firstNonspaceIdx = rawFirstNonspaceIdx + startIdx; + // console.log('p0', startIdx, rawFirstNonspaceIdx) + startIdx = Math.max(startIdx, firstNonspaceIdx) + } + + // if user is on a blank line and the generation starts with newline(s), remove them + const numStartingNewlines = generatedMiddle.slice(startIdx).match(new RegExp(`^${_ln}+`))?.[0].length || 0; + if ( + !prefixToTheLeftOfCursor.trim() + && !suffixToTheRightOfCursor.trim() + && numStartingNewlines > 0 + ) { + // console.log('p1', numStartingNewlines) + startIdx += numStartingNewlines + } + + // if the generated FIM text matches with the suffix on the current line, stop + if (autocompletion.type === 'single-line-fill-middle' && suffixToTheRightOfCursor.trim()) { // completing in the middle of a line + // complete until there is a match + const rawMatchIndex = generatedMiddle.slice(startIdx).lastIndexOf(suffixToTheRightOfCursor.trim()[0]) + if (rawMatchIndex > -1) { + // console.log('p2', rawMatchIndex, startIdx, suffixToTheRightOfCursor.trim()[0], 'AAA', generatedMiddle.slice(startIdx)) + const matchIdx = rawMatchIndex + startIdx; + const matchChar = generatedMiddle[matchIdx] + if (`{}()[]<>\`'"`.includes(matchChar)) { + endIdx = Math.min(endIdx, matchIdx) + } + } + } + + const restOfLineToGenerate = generatedMiddle.slice(startIdx).split(_ln)[0] ?? '' + // condition to complete as a single line completion + if ( + prefixToTheLeftOfCursor.trim() + && !suffixToTheRightOfCursor.trim() + && restOfLineToGenerate.trim() + ) { + + const rawNewlineIdx = generatedMiddle.slice(startIdx).indexOf(_ln) + if (rawNewlineIdx > -1) { + // console.log('p3', startIdx, rawNewlineIdx) + const newlineIdx = rawNewlineIdx + startIdx; + endIdx = Math.min(endIdx, newlineIdx) + } + } + + // // if a generated line matches with a suffix line, stop + // if (suffixLines.length > 1) { + // console.log('4') + // const lines = [] + // for (const generatedLine of generatedLines) { + // if (suffixLines.slice(0, 10).some(suffixLine => + // generatedLine.trim() !== '' && suffixLine.trim() !== '' + // && generatedLine.trim().startsWith(suffixLine.trim()) + // )) break; + // lines.push(generatedLine) + // } + // endIdx = lines.join('\n').length // this is hacky, remove or refactor in future + // } + + // console.log('pFinal', startIdx, endIdx) + let completionStr = generatedMiddle.slice(startIdx, endIdx) + + // filter out unbalanced parentheses + completionStr = getStringUpToUnbalancedClosingParenthesis(completionStr, prefix) + // console.log('originalCompletionStr: ', JSON.stringify(generatedMiddle.slice(startIdx))) + // console.log('finalCompletionStr: ', JSON.stringify(completionStr)) + + + return completionStr + +} + +// returns the text in the autocompletion to display, assuming the prefix is already matched +const toInlineCompletions = ({ autocompletionMatchup, autocompletion, prefixAndSuffix, position, debug }: { autocompletionMatchup: AutocompletionMatchupBounds, autocompletion: Autocompletion, prefixAndSuffix: PrefixAndSuffixInfo, position: Position, debug?: boolean }): { insertText: string, range: Range }[] => { + + let trimmedInsertText = postprocessAutocompletion({ autocompletionMatchup, autocompletion, prefixAndSuffix, }) + let rangeToReplace: Range = new Range(position.lineNumber, position.column, position.lineNumber, position.column) + + // handle special cases + + // if we redid the suffix, replace the suffix + if (autocompletion.type === 'single-line-redo-suffix') { + if (isSubsequence({ // check that the old text contains the same brackets + symbols as the new text + subsequence: removeAllWhitespace(prefixAndSuffix.suffixToTheRightOfCursor), // old suffix + of: removeAllWhitespace(autocompletion.insertText), // new suffix (note that this should not be `trimmedInsertText`) + })) { + rangeToReplace = new Range(position.lineNumber, position.column, position.lineNumber, Number.MAX_SAFE_INTEGER) + } + else { + // TODO redo the autocompletion + trimmedInsertText = '' // for now set the mismatched text to '' + } + } + + return [{ + insertText: trimmedInsertText, + range: rangeToReplace, + }] + +} + + + + + +// returns whether this autocompletion is in the cache +// const doesPrefixMatchAutocompletion = ({ prefix, autocompletion }: { prefix: string, autocompletion: Autocompletion }): boolean => { + +// const originalPrefix = autocompletion.prefix +// const generatedMiddle = autocompletion.result +// const originalPrefixTrimmed = trimPrefix(originalPrefix) +// const currentPrefixTrimmed = trimPrefix(prefix) + +// if (currentPrefixTrimmed.length < originalPrefixTrimmed.length) { +// return false // } -// set(key: K, value: V): void { -// // If key exists, remove it from the order list -// if (this.items.has(key)) { -// this.keyOrder = this.keyOrder.filter(k => k !== key); -// } -// // If cache is full, remove least recently used item -// else if (this.items.size >= this.maxSize) { -// const key = this.keyOrder[0]; -// const value = this.items.get(key); - -// // Call dispose callback if it exists -// if (this.disposeCallback && value !== undefined) { -// this.disposeCallback(value, key); -// } - -// this.items.delete(key); -// this.keyOrder.shift(); -// } - -// // Add new item -// this.items.set(key, value); -// this.keyOrder.push(key); -// } - -// delete(key: K): boolean { -// const value = this.items.get(key); - -// if (value !== undefined) { -// // Call dispose callback if it exists -// if (this.disposeCallback) { -// this.disposeCallback(value, key); -// } - -// this.items.delete(key); -// this.keyOrder = this.keyOrder.filter(k => k !== key); -// return true; -// } - -// return false; -// } - -// clear(): void { -// // Call dispose callback for all items if it exists -// if (this.disposeCallback) { -// for (const [key, value] of this.items.entries()) { -// this.disposeCallback(value, key); -// } -// } - -// this.items.clear(); -// this.keyOrder = []; -// } - -// get size(): number { -// return this.items.size; -// } - -// has(key: K): boolean { -// return this.items.has(key); -// } -// } - - -// type Autocompletion = { -// id: number, -// prefix: string, -// suffix: string, -// llmPrefix: string, -// llmSuffix: string, -// startTime: number, -// endTime: number | undefined, -// status: 'pending' | 'finished' | 'error', -// type: 'single-line' | 'single-line-redo-suffix' | 'multi-line' -// llmPromise: Promise | undefined, -// insertText: string, -// requestId: string | null, -// } - -// const DEBOUNCE_TIME = 500 -// const TIMEOUT_TIME = 60000 -// const MAX_CACHE_SIZE = 20 -// const MAX_PENDING_REQUESTS = 2 - -// // postprocesses the result -// const postprocessResult = (result: string) => { - -// // trim all whitespace except for a single leading/trailing space -// // return result.trim() - -// const hasLeadingSpace = result.startsWith(' '); -// const hasTrailingSpace = result.endsWith(' '); -// return (hasLeadingSpace ? ' ' : '') -// + result.trim() -// + (hasTrailingSpace ? ' ' : ''); - -// } - - -// // trims the end of the prefix to improve cache hit rate -// const removeLeftTabsAndTrimEnds = (s: string): string => { -// const trimmedString = s.trimEnd(); -// const trailingEnd = s.slice(trimmedString.length); - -// // keep only a single trailing newline -// if (trailingEnd.includes('\n')) { -// s = trimmedString + '\n'; -// } - -// s = s.replace(/^\s+/gm, ''); // remove left tabs - -// return s; -// } - - - -// const removeAllWhitespace = (str: string): string => str.replace(/\s+/g, ''); - -// function isSubsequence({ of, subsequence }: { of: string, subsequence: string }): boolean { -// if (subsequence.length === 0) return true; -// if (of.length === 0) return false; - -// let subsequenceIndex = 0; - -// for (let i = 0; i < of.length; i++) { -// if (of[i] === subsequence[subsequenceIndex]) { -// subsequenceIndex++; -// } -// if (subsequenceIndex === subsequence.length) { -// return true; -// } -// } - -// return false; -// } - - -// function getStringUpToUnbalancedParenthesis(s: string, prefix: string): string { - -// const pairs: Record = { ')': '(', '}': '{', ']': '[' }; - -// // process all bracets in prefix -// let stack: string[] = [] -// const firstOpenIdx = prefix.search(/[[({]/); -// if (firstOpenIdx !== -1) { -// const brackets = prefix.slice(firstOpenIdx).split('').filter(c => '()[]{}'.includes(c)); - -// for (const bracket of brackets) { -// if (bracket === '(' || bracket === '{' || bracket === '[') { -// stack.push(bracket); -// } else { -// if (stack.length > 0 && stack[stack.length - 1] === pairs[bracket]) { -// stack.pop(); -// } else { -// stack.push(bracket); -// } -// } -// } -// } - -// // iterate through each character -// for (let i = 0; i < s.length; i++) { -// const char = s[i]; - -// if (char === '(' || char === '{' || char === '[') { stack.push(char); } -// else if (char === ')' || char === '}' || char === ']') { -// if (stack.length === 0 || stack.pop() !== pairs[char]) { return s.substring(0, i); } -// } -// } -// return s; -// } - - -// const parenthesisChars = `{}()[]<>\`'"` - -// // further trim the autocompletion -// const postprocessAutocompletion = ({ autocompletionMatchup, autocompletion, prefixAndSuffix }: { autocompletionMatchup: AutocompletionMatchupBounds, autocompletion: Autocompletion, prefixAndSuffix: PrefixAndSuffixInfo }) => { - -// const { prefix, prefixToTheLeftOfCursor, suffixToTheRightOfCursor } = prefixAndSuffix - -// const generatedMiddle = autocompletion.insertText - -// let startIdx = autocompletionMatchup.startIdx -// let endIdx = generatedMiddle.length // exclusive bounds - -// // const naiveReturnValue = generatedMiddle.slice(startIdx) -// // console.log('naiveReturnValue: ', JSON.stringify(naiveReturnValue)) -// // return [{ insertText: naiveReturnValue, }] - -// // do postprocessing for better ux -// // this is a bit hacky but may change a lot - -// // if there is space at the start of the completion and user has added it, remove it -// const charToLeftOfCursor = prefixToTheLeftOfCursor.slice(-1)[0] || '' -// const userHasAddedASpace = charToLeftOfCursor === ' ' || charToLeftOfCursor === '\t' -// const rawFirstNonspaceIdx = generatedMiddle.slice(startIdx).search(/[^\t ]/) -// if (rawFirstNonspaceIdx > -1 && userHasAddedASpace) { -// const firstNonspaceIdx = rawFirstNonspaceIdx + startIdx; -// // console.log('p0', startIdx, rawFirstNonspaceIdx) -// startIdx = Math.max(startIdx, firstNonspaceIdx) -// } - -// // if user is on a blank line and the generation starts with newline(s), remove them -// const numStartingNewlines = generatedMiddle.slice(startIdx).match(/^\n+/)?.[0].length || 0; -// if ( -// !prefixToTheLeftOfCursor.trim() -// && !suffixToTheRightOfCursor.trim() -// && numStartingNewlines > 0 -// ) { -// // console.log('p1', numStartingNewlines) -// startIdx += numStartingNewlines -// } - -// // if the generated text matches with the suffix on the current line, stop -// if (suffixToTheRightOfCursor.trim()) { // completing in the middle of a line -// // complete until there is a match -// const rawMatchIndex = generatedMiddle.slice(startIdx).lastIndexOf(suffixToTheRightOfCursor.trim()[0]) -// if (rawMatchIndex > -1) { -// // console.log('p2', rawMatchIndex, startIdx, suffixToTheRightOfCursor.trim()[0], 'AAA', generatedMiddle.slice(startIdx)) -// const matchIdx = rawMatchIndex + startIdx; -// const matchChar = generatedMiddle[matchIdx] -// if (parenthesisChars.includes(matchChar)) { -// endIdx = Math.min(endIdx, matchIdx) -// } -// } -// } - -// const restOfLineToGenerate = generatedMiddle.slice(startIdx).split('\n')[0] ?? '' -// // condition to complete as a single line completion -// if ( -// prefixToTheLeftOfCursor.trim() -// && !suffixToTheRightOfCursor.trim() -// && restOfLineToGenerate.trim() -// ) { - -// const rawNewlineIdx = generatedMiddle.slice(startIdx).indexOf('\n') -// if (rawNewlineIdx > -1) { -// // console.log('p3', startIdx, rawNewlineIdx) -// const newlineIdx = rawNewlineIdx + startIdx; -// endIdx = Math.min(endIdx, newlineIdx) -// } -// } - -// // // if a generated line matches with a suffix line, stop -// // if (suffixLines.length > 1) { -// // console.log('4') -// // const lines = [] -// // for (const generatedLine of generatedLines) { -// // if (suffixLines.slice(0, 10).some(suffixLine => -// // generatedLine.trim() !== '' && suffixLine.trim() !== '' -// // && generatedLine.trim().startsWith(suffixLine.trim()) -// // )) break; -// // lines.push(generatedLine) -// // } -// // endIdx = lines.join('\n').length // this is hacky, remove or refactor in future -// // } - -// // console.log('pFinal', startIdx, endIdx) -// let completionStr = generatedMiddle.slice(startIdx, endIdx) - -// // filter out unbalanced parentheses -// completionStr = getStringUpToUnbalancedParenthesis(completionStr, prefix) -// // console.log('originalCompletionStr: ', JSON.stringify(generatedMiddle.slice(startIdx))) -// // console.log('finalCompletionStr: ', JSON.stringify(completionStr)) - - -// return completionStr - -// } - -// // returns the text in the autocompletion to display, assuming the prefix is already matched -// const toInlineCompletions = ({ autocompletionMatchup, autocompletion, prefixAndSuffix, position, debug }: { autocompletionMatchup: AutocompletionMatchupBounds, autocompletion: Autocompletion, prefixAndSuffix: PrefixAndSuffixInfo, position: Position, debug?: boolean }): { insertText: string, range: Range }[] => { - -// let trimmedInsertText = postprocessAutocompletion({ autocompletionMatchup, autocompletion, prefixAndSuffix, }) - - -// // set the range to replace -// let rangeToReplace: Range = new Range(position.lineNumber, position.column, position.lineNumber, position.column) - -// if (autocompletion.type === 'single-line-redo-suffix' // did we redo the line? if so, replace the whole suffix -// && isSubsequence({ // check that the old text contains the same brackets + symbols as the new text -// subsequence: removeAllWhitespace(prefixAndSuffix.suffixToTheRightOfCursor), -// of: removeAllWhitespace(autocompletion.insertText), // should not be `trimmedInsertText` -// }) -// ) { -// rangeToReplace = new Range(position.lineNumber, position.column, position.lineNumber, Number.MAX_SAFE_INTEGER) -// } - - -// return [{ -// insertText: trimmedInsertText, -// range: rangeToReplace, -// }] +// const isMatch = (originalPrefixTrimmed + generatedMiddle).startsWith(currentPrefixTrimmed) +// return isMatch // } +const allLinebreakSymbols = ['\r\n', '\n'] +const _ln = isWindows ? allLinebreakSymbols[0] : allLinebreakSymbols[1] +type PrefixAndSuffixInfo = { prefix: string, suffix: string, prefixLines: string[], suffixLines: string[], prefixToTheLeftOfCursor: string, suffixToTheRightOfCursor: string } +const getPrefixAndSuffixInfo = (model: ITextModel, position: Position): PrefixAndSuffixInfo => { + const fullText = model.getValue(); -// // returns whether this autocompletion is in the cache -// // const doesPrefixMatchAutocompletion = ({ prefix, autocompletion }: { prefix: string, autocompletion: Autocompletion }): boolean => { - -// // const originalPrefix = autocompletion.prefix -// // const generatedMiddle = autocompletion.result -// // const originalPrefixTrimmed = trimPrefix(originalPrefix) -// // const currentPrefixTrimmed = trimPrefix(prefix) - -// // if (currentPrefixTrimmed.length < originalPrefixTrimmed.length) { -// // return false -// // } - -// // const isMatch = (originalPrefixTrimmed + generatedMiddle).startsWith(currentPrefixTrimmed) -// // return isMatch - -// // } - - -// type PrefixAndSuffixInfo = { prefix: string, suffix: string, prefixLines: string[], suffixLines: string[], prefixToTheLeftOfCursor: string, suffixToTheRightOfCursor: string } -// const getPrefixAndSuffixInfo = (model: ITextModel, position: Position): PrefixAndSuffixInfo => { - -// const fullText = model.getValue(); - -// const cursorOffset = model.getOffsetAt(position) -// const prefix = fullText.substring(0, cursorOffset) -// const suffix = fullText.substring(cursorOffset) - -// const prefixLines = prefix.split('\n') -// const suffixLines = suffix.split('\n') - -// const prefixToTheLeftOfCursor = prefixLines.slice(-1)[0] ?? '' -// const suffixToTheRightOfCursor = suffixLines[0] ?? '' - -// return { prefix, suffix, prefixLines, suffixLines, prefixToTheLeftOfCursor, suffixToTheRightOfCursor } - -// } - -// const getIndex = (str: string, line: number, char: number) => { -// return str.split('\n').slice(0, line).join('\n').length + (line > 0 ? 1 : 0) + char; -// } -// const getLastLine = (s: string): string => { -// const matches = s.match(/[^\n]*$/) -// return matches ? matches[0] : '' -// } - -// type AutocompletionMatchupBounds = { -// startLine: number, -// startCharacter: number, -// startIdx: number, -// } -// // returns the startIdx of the match if there is a match, or undefined if there is no match -// // all results are wrt `autocompletion.result` -// const getAutocompletionMatchup = ({ prefix, autocompletion }: { prefix: string, autocompletion: Autocompletion }): AutocompletionMatchupBounds | undefined => { - -// const trimmedCurrentPrefix = removeLeftTabsAndTrimEnds(prefix) -// const trimmedCompletionPrefix = removeLeftTabsAndTrimEnds(autocompletion.prefix) -// const trimmedCompletionMiddle = removeLeftTabsAndTrimEnds(autocompletion.insertText) - -// // console.log('@result: ', JSON.stringify(autocompletion.insertText)) -// // console.log('@trimmedCurrentPrefix: ', JSON.stringify(trimmedCurrentPrefix)) -// // console.log('@trimmedCompletionPrefix: ', JSON.stringify(trimmedCompletionPrefix)) -// // console.log('@trimmedCompletionMiddle: ', JSON.stringify(trimmedCompletionMiddle)) - -// if (trimmedCurrentPrefix.length < trimmedCompletionPrefix.length) { // user must write text beyond the original prefix at generation time -// console.log('@undefined1') -// return undefined -// } - -// if ( // check that completion starts with the prefix -// !(trimmedCompletionPrefix + trimmedCompletionMiddle) -// .startsWith(trimmedCurrentPrefix) -// ) { -// console.log('@undefined2') -// return undefined -// } - -// // reverse map to find position wrt `autocompletion.result` -// const lineStart = -// trimmedCurrentPrefix.split('\n').length - -// trimmedCompletionPrefix.split('\n').length; - -// if (lineStart < 0) { -// console.log('@undefined3') - -// console.error('Error: No line found.'); -// return undefined; -// } -// const currentPrefixLine = getLastLine(trimmedCurrentPrefix) -// const completionPrefixLine = lineStart === 0 ? getLastLine(trimmedCompletionPrefix) : '' -// const completionMiddleLine = autocompletion.insertText.split('\n')[lineStart] -// const fullCompletionLine = completionPrefixLine + completionMiddleLine - -// // console.log('currentPrefixLine', currentPrefixLine) -// // console.log('completionPrefixLine', completionPrefixLine) -// // console.log('completionMiddleLine', completionMiddleLine) - -// const charMatchIdx = fullCompletionLine.indexOf(currentPrefixLine) -// if (charMatchIdx < 0) { -// console.log('@undefined4', charMatchIdx) - -// console.error('Warning: Found character with negative index. This should never happen.') -// return undefined -// } - -// const character = (charMatchIdx + -// currentPrefixLine.length -// - completionPrefixLine.length -// ) + const cursorOffset = model.getOffsetAt(position) + const prefix = fullText.substring(0, cursorOffset) + const suffix = fullText.substring(cursorOffset) -// const startIdx = getIndex(autocompletion.insertText, lineStart, character) - -// return { -// startLine: lineStart, -// startCharacter: character, -// startIdx, -// } - - -// } - - - - -// const getCompletionOptions = (prefixAndSuffix: PrefixAndSuffixInfo) => { - -// const { prefix, suffix, prefixToTheLeftOfCursor, suffixToTheRightOfCursor, suffixLines } = prefixAndSuffix - -// // single line prediction unless the current line is blank -// let predictionType: Autocompletion['type'] -// let llmPrefix = prefix -// let llmSuffix = suffix - -// if (!prefixToTheLeftOfCursor.trim() && !suffixToTheRightOfCursor.trim()) { // line is empty -// predictionType = 'multi-line' -// sto ptokens here -// } else if (removeAllWhitespace(prefixAndSuffix.suffixToTheRightOfCursor).length < 4) { // suffix is less than 4 characters -// predictionType = 'single-line-redo-suffix' -// llmSuffix = '\n' + suffixLines.slice(1).join('\n') // ignore suffixToTheRightOfCursor -// } else { -// predictionType = 'single-line' -// } - -// // default parameters -// let shouldGenerate = true -// let stopTokens: string[] = ['\n\n', '\r\n\r\n'] // default to multi-line prediction - -// // Case 1: User is on a line with text to the left or right -// if (prefixToTheLeftOfCursor.trim() !== '' || suffixToTheRightOfCursor.trim() !== '') { -// stopTokens = ['\n', '\r\n'] // single line prediction -// } - -// // Don't generate if at the very beginning of a line -// if (prefixToTheLeftOfCursor === '') { -// shouldGenerate = false -// } - -// return { shouldGenerate, predictionType, stopTokens, llmPrefix, llmSuffix } - -// } - - - - -// export interface IAutocompleteService { -// readonly _serviceBrand: undefined; -// } - -// export const IAutocompleteService = createDecorator('AutocompleteService'); - -// export class AutocompleteService extends Disposable implements IAutocompleteService { -// _serviceBrand: undefined; - -// private _autocompletionId: number = 0; -// private _autocompletionsOfDocument: { [docUriStr: string]: LRUCache } = {} - -// private _lastCompletionTime = 0 -// private _lastPrefix: string = '' - -// // used internally by vscode -// // fires after every keystroke and returns the completion to show -// async _provideInlineCompletionItems( -// model: ITextModel, -// position: Position, -// context: InlineCompletionContext, -// token: CancellationToken, -// ): Promise { - -// const disabled = true -// const testMode = false - -// if (disabled) return []; - -// const docUriStr = model.uri.toString(); - -// const prefixAndSuffix = getPrefixAndSuffixInfo(model, position) -// const { prefix, suffix } = prefixAndSuffix - -// // initialize cache and other variables -// // note that whenever an autocompletion is rejected, it is removed from cache -// if (!this._autocompletionsOfDocument[docUriStr]) { -// this._autocompletionsOfDocument[docUriStr] = new LRUCache( -// MAX_CACHE_SIZE, -// (autocompletion: Autocompletion) => { -// if (autocompletion.requestId) -// this._llmMessageService.abort(autocompletion.requestId) -// } -// ) -// } -// this._lastPrefix = prefix - -// // print all pending autocompletions -// // let _numPending = 0 -// // this._autocompletionsOfDocument[docUriStr].items.forEach((a: Autocompletion) => { if (a.status === 'pending') _numPending += 1 }) -// // console.log('@numPending: ' + _numPending) - -// // get autocompletion from cache -// let cachedAutocompletion: Autocompletion | undefined = undefined -// let autocompletionMatchup: AutocompletionMatchupBounds | undefined = undefined -// for (const autocompletion of this._autocompletionsOfDocument[docUriStr].items.values()) { -// // if the user's change matches with the autocompletion -// autocompletionMatchup = getAutocompletionMatchup({ prefix, autocompletion }) -// if (autocompletionMatchup !== undefined) { -// cachedAutocompletion = autocompletion -// break; -// } -// } - -// // if there is a cached autocompletion, return it -// if (cachedAutocompletion && autocompletionMatchup) { - -// // console.log('id: ' + cachedAutocompletion.id) - -// if (cachedAutocompletion.status === 'finished') { -// // console.log('A1') - -// const inlineCompletions = toInlineCompletions({ autocompletionMatchup, autocompletion: cachedAutocompletion, prefixAndSuffix, position, debug: true }) -// return inlineCompletions - -// } else if (cachedAutocompletion.status === 'pending') { -// // console.log('A2') - -// try { -// await cachedAutocompletion.llmPromise; -// const inlineCompletions = toInlineCompletions({ autocompletionMatchup, autocompletion: cachedAutocompletion, prefixAndSuffix, position }) -// return inlineCompletions - -// } catch (e) { -// this._autocompletionsOfDocument[docUriStr].delete(cachedAutocompletion.id) -// console.error('Error creating autocompletion (1): ' + e) -// } - -// } else if (cachedAutocompletion.status === 'error') { -// // console.log('A3') -// } - -// return [] -// } - -// // else if no more typing happens, then go forwards with the request -// // wait DEBOUNCE_TIME for the user to stop typing -// const thisTime = Date.now() -// this._lastCompletionTime = thisTime -// const didTypingHappenDuringDebounce = await new Promise((resolve, reject) => -// setTimeout(() => { -// if (this._lastCompletionTime === thisTime) { -// resolve(false) -// } else { -// resolve(true) -// } -// }, DEBOUNCE_TIME) -// ) - -// // if more typing happened, then do not go forwards with the request -// if (didTypingHappenDuringDebounce) { -// return [] -// } - - -// // if there are too many pending requests, cancel the oldest one -// let numPending = 0 -// let oldestPending: Autocompletion | undefined = undefined -// for (const autocompletion of this._autocompletionsOfDocument[docUriStr].items.values()) { -// if (autocompletion.status === 'pending') { -// numPending += 1 -// if (oldestPending === undefined) { -// oldestPending = autocompletion -// } -// if (numPending >= MAX_PENDING_REQUESTS) { -// // cancel the oldest pending request and remove it from cache -// this._autocompletionsOfDocument[docUriStr].delete(oldestPending.id) -// break -// } -// } -// } - -// const { shouldGenerate, predictionType, stopTokens, llmPrefix, llmSuffix } = getCompletionOptions(prefixAndSuffix) // TODO use stop tokens - -// if (!shouldGenerate) return [] - -// if (testMode && this._autocompletionId !== 0) { // TODO remove this -// return [] -// } - -// // console.log('B') - -// // create a new autocompletion and add it to cache -// const newAutocompletion: Autocompletion = { -// id: this._autocompletionId++, -// prefix: prefix, // the actual prefix and suffix -// suffix: suffix, -// llmPrefix: llmPrefix, // the prefix and suffix the llm sees -// llmSuffix: llmSuffix, -// startTime: Date.now(), -// endTime: undefined, -// type: predictionType, -// status: 'pending', -// llmPromise: undefined, -// insertText: '', -// requestId: null, -// } - -// // set parameters of `newAutocompletion` appropriately -// newAutocompletion.llmPromise = new Promise((resolve, reject) => { - -// const requestId = this._llmMessageService.sendLLMMessage({ -// prefix: llmPrefix, -// suffix: llmSuffix, -// stopTokens:stopTokens, -// logging: { loggingName: 'Autocomplete' }, -// messages: [], -// onText: async ({ newText, fullText }) => { - -// newAutocompletion.insertText = fullText - -// // if generation doesn't match the prefix for the first few tokens generated, reject it -// if (!getAutocompletionMatchup({ prefix: this._lastPrefix, autocompletion: newAutocompletion })) { -// reject('LLM response did not match user\'s text.') -// } -// }, -// onFinalMessage: ({ fullText }) => { - -// // newAutocompletion.prefix = prefix -// // newAutocompletion.suffix = suffix -// // newAutocompletion.startTime = Date.now() -// newAutocompletion.endTime = Date.now() -// // newAutocompletion.abortRef = { current: () => { } } -// newAutocompletion.status = 'finished' -// // newAutocompletion.promise = undefined -// const [text, _] = extractCodeFromRegular({ text: fullText, recentlyAddedTextLen: 0 }) -// newAutocompletion.insertText = postprocessResult(text) - -// resolve(newAutocompletion.insertText) - -// }, -// onError: ({ message }) => { -// newAutocompletion.endTime = Date.now() -// newAutocompletion.status = 'error' -// reject(message) -// }, -// useProviderFor: 'Autocomplete', -// range: { startLineNumber: position.lineNumber, startColumn: position.column, endLineNumber: position.lineNumber, endColumn: position.column }, -// }) -// newAutocompletion.requestId = requestId - -// // if the request hasnt resolved in TIMEOUT_TIME seconds, reject it -// setTimeout(() => { -// if (newAutocompletion.status === 'pending') { -// reject('Timeout receiving message to LLM.') -// } -// }, TIMEOUT_TIME) - -// }) - - - -// // add autocompletion to cache -// this._autocompletionsOfDocument[docUriStr].set(newAutocompletion.id, newAutocompletion) - -// // show autocompletion -// try { -// await newAutocompletion.llmPromise -// // console.log('id: ' + newAutocompletion.id) - -// const autocompletionMatchup: AutocompletionMatchupBounds = { startIdx: 0, startLine: 0, startCharacter: 0 } -// const inlineCompletions = toInlineCompletions({ autocompletionMatchup, autocompletion: newAutocompletion, prefixAndSuffix, position }) -// return inlineCompletions - -// } catch (e) { -// this._autocompletionsOfDocument[docUriStr].delete(newAutocompletion.id) -// console.error('Error creating autocompletion (2): ' + e) -// return [] -// } - -// } - -// constructor( -// @ILanguageFeaturesService private _langFeatureService: ILanguageFeaturesService, -// @ILLMMessageService private readonly _llmMessageService: ILLMMessageService, -// @IEditorService private readonly _editorService: IEditorService, -// @IModelService private readonly _modelService: IModelService, -// ) { -// super() - -// this._langFeatureService.inlineCompletionsProvider.register('*', { -// provideInlineCompletions: async (model, position, context, token) => { -// const items = await this._provideInlineCompletionItems(model, position, context, token) - -// // console.log('item: ', items?.[0]?.insertText) -// return { items: items, } -// }, -// freeInlineCompletions: (completions) => { - -// // get the `docUriStr` and the `position` of the cursor -// const activePane = this._editorService.activeEditorPane; -// if (!activePane) return; -// const control = activePane.getControl(); -// if (!control || !isCodeEditor(control)) return; -// const position = control.getPosition(); -// if (!position) return; -// const resource = EditorResourceAccessor.getCanonicalUri(this._editorService.activeEditor); -// if (!resource) return; -// const model = this._modelService.getModel(resource) -// if (!model) return; -// const docUriStr = resource.toString(); -// if (!this._autocompletionsOfDocument[docUriStr]) return; - -// const { prefix, } = getPrefixAndSuffixInfo(model, position) - -// // go through cached items and remove matching ones -// // autocompletion.prefix + autocompletion.insertedText ~== insertedText -// completions.items.forEach(item => { -// this._autocompletionsOfDocument[docUriStr].items.forEach((autocompletion: Autocompletion) => { -// if (removeLeftTabsAndTrimEnds(prefix) -// === removeLeftTabsAndTrimEnds(autocompletion.prefix + autocompletion.insertText) -// ) { -// this._autocompletionsOfDocument[docUriStr].delete(autocompletion.id); -// } -// }); -// }); - -// }, -// }) - - -// } - - -// } + const prefixLines = prefix.split(_ln) + const suffixLines = suffix.split(_ln) + + const prefixToTheLeftOfCursor = prefixLines.slice(-1)[0] ?? '' + const suffixToTheRightOfCursor = suffixLines[0] ?? '' + + return { prefix, suffix, prefixLines, suffixLines, prefixToTheLeftOfCursor, suffixToTheRightOfCursor } + +} + +const getIndex = (str: string, line: number, char: number) => { + return str.split(_ln).slice(0, line).join(_ln).length + (line > 0 ? 1 : 0) + char; +} +const getLastLine = (s: string): string => { + const matches = s.match(new RegExp(`[^${_ln}]*$`)) + return matches ? matches[0] : '' +} + +type AutocompletionMatchupBounds = { + startLine: number, + startCharacter: number, + startIdx: number, +} +// returns the startIdx of the match if there is a match, or undefined if there is no match +// all results are wrt `autocompletion.result` +const getAutocompletionMatchup = ({ prefix, autocompletion }: { prefix: string, autocompletion: Autocompletion }): AutocompletionMatchupBounds | undefined => { + + const trimmedCurrentPrefix = removeLeftTabsAndTrimEnds(prefix) + const trimmedCompletionPrefix = removeLeftTabsAndTrimEnds(autocompletion.prefix) + const trimmedCompletionMiddle = removeLeftTabsAndTrimEnds(autocompletion.insertText) + + // console.log('@result: ', JSON.stringify(autocompletion.insertText)) + // console.log('@trimmedCurrentPrefix: ', JSON.stringify(trimmedCurrentPrefix)) + // console.log('@trimmedCompletionPrefix: ', JSON.stringify(trimmedCompletionPrefix)) + // console.log('@trimmedCompletionMiddle: ', JSON.stringify(trimmedCompletionMiddle)) + + if (trimmedCurrentPrefix.length < trimmedCompletionPrefix.length) { // user must write text beyond the original prefix at generation time + // console.log('@undefined1') + return undefined + } + + if ( // check that completion starts with the prefix + !(trimmedCompletionPrefix + trimmedCompletionMiddle) + .startsWith(trimmedCurrentPrefix) + ) { + // console.log('@undefined2') + return undefined + } + + // reverse map to find position wrt `autocompletion.result` + const lineStart = + trimmedCurrentPrefix.split(_ln).length - + trimmedCompletionPrefix.split(_ln).length; + + if (lineStart < 0) { + // console.log('@undefined3') + + console.error('Error: No line found.'); + return undefined; + } + const currentPrefixLine = getLastLine(trimmedCurrentPrefix) + const completionPrefixLine = lineStart === 0 ? getLastLine(trimmedCompletionPrefix) : '' + const completionMiddleLine = autocompletion.insertText.split(_ln)[lineStart] + const fullCompletionLine = completionPrefixLine + completionMiddleLine + + // console.log('currentPrefixLine', currentPrefixLine) + // console.log('completionPrefixLine', completionPrefixLine) + // console.log('completionMiddleLine', completionMiddleLine) + + const charMatchIdx = fullCompletionLine.indexOf(currentPrefixLine) + if (charMatchIdx < 0) { + // console.log('@undefined4', charMatchIdx) + + console.error('Warning: Found character with negative index. This should never happen.') + return undefined + } + + const character = (charMatchIdx + + currentPrefixLine.length + - completionPrefixLine.length + ) + + const startIdx = getIndex(autocompletion.insertText, lineStart, character) + + return { + startLine: lineStart, + startCharacter: character, + startIdx, + } + + +} + +// const x = [] +// const +// c[[]] +// asd[[]] = +// const [{{}}] +// +type CompletionOptions = { + predictionType: AutocompletionPredictionType, + shouldGenerate: boolean, + llmPrefix: string, + llmSuffix: string, + stopTokens: string[], +} +const getCompletionOptions = (prefixAndSuffix: PrefixAndSuffixInfo, relevantContext: string, justAcceptedAutocompletion: boolean): CompletionOptions => { + + const { prefix, suffix, prefixToTheLeftOfCursor, suffixToTheRightOfCursor, suffixLines } = prefixAndSuffix + + let completionOptions: CompletionOptions + + // if line is empty, do multiline completion + const isLineEmpty = !prefixToTheLeftOfCursor.trim() && !suffixToTheRightOfCursor.trim() + const isLinePrefixEmpty = removeAllWhitespace(prefixToTheLeftOfCursor).length === 0 + const isLineSuffixEmpty = removeAllWhitespace(suffixToTheRightOfCursor).length === 0 + + // TODO add context to prefix + // llmPrefix = '\n\n/* Relevant context:\n' + relevantContext + '\n*/\n' + llmPrefix + + // if we just accepted an autocompletion, predict a multiline completion starting on the next line + if (justAcceptedAutocompletion && isLineSuffixEmpty) { + const prefixWithNewline = prefix + _ln + completionOptions = { + predictionType: 'multi-line-start-on-next-line', + shouldGenerate: true, + llmPrefix: prefixWithNewline, + llmSuffix: suffix, + stopTokens: [`${_ln}${_ln}`] // double newlines + } + } + // if the current line is empty, predict a single-line completion + else if (isLineEmpty) { + completionOptions = { + predictionType: 'single-line-fill-middle', + shouldGenerate: true, + llmPrefix: prefix, + llmSuffix: suffix, + stopTokens: allLinebreakSymbols + } + } + // if suffix is 3 or less characters, attempt to complete the line ignorning it + else if (removeAllWhitespace(suffixToTheRightOfCursor).length <= 3) { + const suffixLinesIgnoringThisLine = suffixLines.slice(1) + const suffixStringIgnoringThisLine = suffixLinesIgnoringThisLine.length === 0 ? '' : _ln + suffixLinesIgnoringThisLine.join(_ln) + completionOptions = { + predictionType: 'single-line-redo-suffix', + shouldGenerate: true, + llmPrefix: prefix, + llmSuffix: suffixStringIgnoringThisLine, + stopTokens: allLinebreakSymbols + } + } + // else attempt to complete the middle of the line if there is a prefix (the completion looks bad if there is no prefix) + else if (!isLinePrefixEmpty) { + completionOptions = { + predictionType: 'single-line-fill-middle', + shouldGenerate: true, + llmPrefix: prefix, + llmSuffix: suffix, + stopTokens: allLinebreakSymbols + } + } else { + completionOptions = { + predictionType: 'do-not-predict', + shouldGenerate: false, + llmPrefix: prefix, + llmSuffix: suffix, + stopTokens: [] + } + } + + return completionOptions + +} + +export interface IAutocompleteService { + readonly _serviceBrand: undefined; +} + +export const IAutocompleteService = createDecorator('AutocompleteService'); + +export class AutocompleteService extends Disposable implements IAutocompleteService { + + static readonly ID = 'void.autocompleteService' + + _serviceBrand: undefined; + + private _autocompletionId: number = 0; + private _autocompletionsOfDocument: { [docUriStr: string]: LRUCache } = {} + + private _lastCompletionStart = 0 + private _lastCompletionAccept = 0 + // private _lastPrefix: string = '' + + // used internally by vscode + // fires after every keystroke and returns the completion to show + async _provideInlineCompletionItems( + model: ITextModel, + position: Position, + context: InlineCompletionContext, + token: CancellationToken, + ): Promise { + + console.log('START_0') + + const testMode = false + + const docUriStr = model.uri.toString(); + + const prefixAndSuffix = getPrefixAndSuffixInfo(model, position) + const { prefix, suffix } = prefixAndSuffix + + // initialize cache if it doesnt exist + // note that whenever an autocompletion is accepted, it is removed from cache + if (!this._autocompletionsOfDocument[docUriStr]) { + this._autocompletionsOfDocument[docUriStr] = new LRUCache( + MAX_CACHE_SIZE, + (autocompletion: Autocompletion) => { + if (autocompletion.requestId) + this._llmMessageService.abort(autocompletion.requestId) + } + ) + } + // this._lastPrefix = prefix + + // print all pending autocompletions + // let _numPending = 0 + // this._autocompletionsOfDocument[docUriStr].items.forEach((a: Autocompletion) => { if (a.status === 'pending') _numPending += 1 }) + // console.log('@numPending: ' + _numPending) + + // get autocompletion from cache + let cachedAutocompletion: Autocompletion | undefined = undefined + let autocompletionMatchup: AutocompletionMatchupBounds | undefined = undefined + for (const autocompletion of this._autocompletionsOfDocument[docUriStr].items.values()) { + // if the user's change matches with the autocompletion + autocompletionMatchup = getAutocompletionMatchup({ prefix, autocompletion }) + if (autocompletionMatchup !== undefined) { + cachedAutocompletion = autocompletion + break; + } + } + + // if there is a cached autocompletion, return it + if (cachedAutocompletion && autocompletionMatchup) { + + console.log('AA') + + + // console.log('id: ' + cachedAutocompletion.id) + + if (cachedAutocompletion.status === 'finished') { + console.log('A1') + + const inlineCompletions = toInlineCompletions({ autocompletionMatchup, autocompletion: cachedAutocompletion, prefixAndSuffix, position, debug: true }) + return inlineCompletions + + } else if (cachedAutocompletion.status === 'pending') { + console.log('A2') + + try { + await cachedAutocompletion.llmPromise; + const inlineCompletions = toInlineCompletions({ autocompletionMatchup, autocompletion: cachedAutocompletion, prefixAndSuffix, position }) + return inlineCompletions + + } catch (e) { + this._autocompletionsOfDocument[docUriStr].delete(cachedAutocompletion.id) + console.error('Error creating autocompletion (1): ' + e) + } + + } else if (cachedAutocompletion.status === 'error') { + console.log('A3') + } else { + console.log('A4') + } + + return [] + } + + // else if no more typing happens, then go forwards with the request + + // wait DEBOUNCE_TIME for the user to stop typing + const thisTime = Date.now() + + const justAcceptedAutocompletion = thisTime - this._lastCompletionAccept < 500 + + this._lastCompletionStart = thisTime + const didTypingHappenDuringDebounce = await new Promise((resolve, reject) => + setTimeout(() => { + if (this._lastCompletionStart === thisTime) { + resolve(false) + } else { + resolve(true) + } + }, DEBOUNCE_TIME) + ) + + // if more typing happened, then do not go forwards with the request + if (didTypingHappenDuringDebounce) { + return [] + } + + + // if there are too many pending requests, cancel the oldest one + let numPending = 0 + let oldestPending: Autocompletion | undefined = undefined + for (const autocompletion of this._autocompletionsOfDocument[docUriStr].items.values()) { + if (autocompletion.status === 'pending') { + numPending += 1 + if (oldestPending === undefined) { + oldestPending = autocompletion + } + if (numPending >= MAX_PENDING_REQUESTS) { + // cancel the oldest pending request and remove it from cache + this._autocompletionsOfDocument[docUriStr].delete(oldestPending.id) + break + } + } + } + + + // gather relevant context from the code around the user's selection and definitions + const relevantContext = await this._gatherRelevantContextForPosition( + model, + position, + 3, //recursion depth + 1 // number of lines to view in each recursion + ); + + const { shouldGenerate, predictionType, llmPrefix, llmSuffix, stopTokens } = getCompletionOptions(prefixAndSuffix, relevantContext, justAcceptedAutocompletion) + + if (!shouldGenerate) return [] + + if (testMode && this._autocompletionId !== 0) { // TODO remove this + return [] + } + + + + // console.log('B') + + // create a new autocompletion and add it to cache + const newAutocompletion: Autocompletion = { + id: this._autocompletionId++, + prefix: prefix, // the actual prefix and suffix + suffix: suffix, + llmPrefix: llmPrefix, // the prefix and suffix the llm sees + llmSuffix: llmSuffix, + startTime: Date.now(), + endTime: undefined, + type: predictionType, + status: 'pending', + llmPromise: undefined, + insertText: '', + requestId: null, + } + + console.log('BB') + console.log(predictionType) + + // set parameters of `newAutocompletion` appropriately + newAutocompletion.llmPromise = new Promise((resolve, reject) => { + + const requestId = this._llmMessageService.sendLLMMessage({ + type: 'ollamaFIM', + messages: { + prefix: llmPrefix, + suffix: llmSuffix, + stopTokens: stopTokens, + }, + logging: { loggingName: 'Autocomplete' }, + onText: async ({ fullText }) => { + + newAutocompletion.insertText = fullText + + // if generation doesn't match the prefix for the first few tokens generated, reject it + // if (!getAutocompletionMatchup({ prefix: this._lastPrefix, autocompletion: newAutocompletion })) { + // reject('LLM response did not match user\'s text.') + // } + }, + onFinalMessage: ({ fullText }) => { + + console.log('____res: ', JSON.stringify(newAutocompletion.insertText)) + + // newAutocompletion.prefix = prefix + // newAutocompletion.suffix = suffix + // newAutocompletion.startTime = Date.now() + newAutocompletion.endTime = Date.now() + // newAutocompletion.abortRef = { current: () => { } } + newAutocompletion.status = 'finished' + // newAutocompletion.promise = undefined + const [text, _] = extractCodeFromRegular({ text: fullText, recentlyAddedTextLen: 0 }) + newAutocompletion.insertText = joinSpaces(text) + + // handle special case for predicting starting on the next line, add a newline character + if (newAutocompletion.type === 'multi-line-start-on-next-line') { + newAutocompletion.insertText = _ln + newAutocompletion.insertText + } + + resolve(newAutocompletion.insertText) + + }, + onError: ({ message }) => { + newAutocompletion.endTime = Date.now() + newAutocompletion.status = 'error' + reject(message) + }, + useProviderFor: 'Autocomplete', + }) + newAutocompletion.requestId = requestId + + // if the request hasnt resolved in TIMEOUT_TIME seconds, reject it + setTimeout(() => { + if (newAutocompletion.status === 'pending') { + reject('Timeout receiving message to LLM.') + } + }, TIMEOUT_TIME) + + }) + + + + // add autocompletion to cache + this._autocompletionsOfDocument[docUriStr].set(newAutocompletion.id, newAutocompletion) + + // show autocompletion + try { + await newAutocompletion.llmPromise + // console.log('id: ' + newAutocompletion.id) + + const autocompletionMatchup: AutocompletionMatchupBounds = { startIdx: 0, startLine: 0, startCharacter: 0 } + const inlineCompletions = toInlineCompletions({ autocompletionMatchup, autocompletion: newAutocompletion, prefixAndSuffix, position }) + return inlineCompletions + + } catch (e) { + this._autocompletionsOfDocument[docUriStr].delete(newAutocompletion.id) + console.error('Error creating autocompletion (2): ' + e) + return [] + } + + } + + // helper method to gather ~N lines above and below the user's current line, + // and recursively gather lines around any symbol definitions encountered. + private async _gatherRelevantContextForPosition( + model: ITextModel, + position: Position, + recursionDepth: number, + linesAround: number + ): Promise { + // We'll do a BFS-like approach: for each position or definition, gather lines around it, + // then attempt to find the definition of any symbols in that range, up to 'recursionDepth' times. + + // A set of "key" strings to avoid repeating the same location or line chunk + const visitedRanges = new Set(); + const collectedSnippets: string[] = []; + + // A queue of tasks, each being a tuple of: (model, position, depth) + const tasks: Array<{ model: ITextModel, position: Position, depth: number }> = []; + tasks.push({ model, position, depth: recursionDepth }); + + const getSnippetAroundLine = (model: ITextModel, lineNumber: number, linesAround: number): string => { + const startLine = Math.max(1, lineNumber - linesAround); + const endLine = Math.min(model.getLineCount(), lineNumber + linesAround); + const lines: string[] = []; + for (let i = startLine; i <= endLine; i++) { + lines.push(model.getLineContent(i)); + } + return lines.join('\n'); + }; + + while (tasks.length > 0) { + const { model: currentModel, position: currentPos, depth } = tasks.shift()!; + + if (depth < 0) { + continue; + } + + // Gather snippet around the current line + const snippet = getSnippetAroundLine(currentModel, currentPos.lineNumber, linesAround); + const snippetKey = `${currentModel.uri.toString()}:${currentPos.lineNumber}`; + if (!visitedRanges.has(snippetKey)) { + visitedRanges.add(snippetKey); + collectedSnippets.push(`-- Snippet around line ${currentPos.lineNumber} --\n${snippet}\n`); + } + + // Attempt to gather definitions for the symbol at this position + // We just pick all definition providers and see if any has a definition + const providers = this._langFeatureService.definitionProvider.ordered(currentModel); + for (const provider of providers) { + try { + const definitions = await provider.provideDefinition(currentModel, currentPos, CancellationToken.None); + if (!definitions) continue; + + // definitions can be a single LocationLink or an array + const defArray: LocationLink[] = Array.isArray(definitions) ? definitions : [definitions]; + for (const def of defArray) { + if (!def.uri) continue; + if (typeof def.range === 'undefined') continue; + const definitionModel = this._modelService.getModel(def.uri); + if (!definitionModel) continue; + + // We'll queue up a new task for that definition range + const defPos = new Position(def.range.startLineNumber, def.range.startColumn); + const defKey = `${def.uri.toString()}:${defPos.lineNumber}`; + if (!visitedRanges.has(defKey)) { + tasks.push({ model: definitionModel, position: defPos, depth: depth - 1 }); + } + } + } catch (err) { + // If a provider fails, ignore + } + } + } + + // Return the joined context + return collectedSnippets.join('\n'); + } + + + constructor( + @ILanguageFeaturesService private _langFeatureService: ILanguageFeaturesService, + @ILLMMessageService private readonly _llmMessageService: ILLMMessageService, + @IEditorService private readonly _editorService: IEditorService, + @IModelService private readonly _modelService: IModelService, + ) { + super() + + this._langFeatureService.inlineCompletionsProvider.register('*', { + provideInlineCompletions: async (model, position, context, token) => { + const items = await this._provideInlineCompletionItems(model, position, context, token) + + // console.log('item: ', items?.[0]?.insertText) + return { items: items, } + }, + freeInlineCompletions: (completions) => { + // get the `docUriStr` and the `position` of the cursor + const activePane = this._editorService.activeEditorPane; + if (!activePane) return; + const control = activePane.getControl(); + if (!control || !isCodeEditor(control)) return; + const position = control.getPosition(); + if (!position) return; + const resource = EditorResourceAccessor.getCanonicalUri(this._editorService.activeEditor); + if (!resource) return; + const model = this._modelService.getModel(resource) + if (!model) return; + const docUriStr = resource.toString(); + if (!this._autocompletionsOfDocument[docUriStr]) return; + + const { prefix, } = getPrefixAndSuffixInfo(model, position) + + // go through cached items and remove matching ones + // autocompletion.prefix + autocompletion.insertedText ~== insertedText + this._autocompletionsOfDocument[docUriStr].items.forEach((autocompletion: Autocompletion) => { + // const matchup = getAutocompletionMatchup({ prefix, autocompletion }) + const matchup = removeAllWhitespace(prefix) === removeAllWhitespace(autocompletion.prefix + autocompletion.insertText) + if (matchup) { + console.log('ACCEPT', autocompletion.id) + this._lastCompletionAccept = Date.now() + this._autocompletionsOfDocument[docUriStr].delete(autocompletion.id); + } + }); + + }, + }) + } + + +} + +registerWorkbenchContribution2(AutocompleteService.ID, AutocompleteService, WorkbenchPhase.BlockRestore); -// registerSingleton(IAutocompleteService, AutocompleteService, InstantiationType.Eager); diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index 8c9ad0e5d71..468708b37cf 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -202,6 +202,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { this._setStreamState(threadId, { error: undefined }) const llmCancelToken = this._llmMessageService.sendLLMMessage({ + type: 'sendLLMMessage', logging: { loggingName: 'Chat' }, messages: [ { role: 'system', content: chat_systemMessage }, diff --git a/src/vs/workbench/contrib/void/browser/inlineDiffsService.ts b/src/vs/workbench/contrib/void/browser/inlineDiffsService.ts index fdbf84bb952..b7d43a6d1ae 100644 --- a/src/vs/workbench/contrib/void/browser/inlineDiffsService.ts +++ b/src/vs/workbench/contrib/void/browser/inlineDiffsService.ts @@ -25,7 +25,7 @@ import * as dom from '../../../../base/browser/dom.js'; import { Widget } from '../../../../base/browser/ui/widget.js'; import { URI } from '../../../../base/common/uri.js'; import { IConsistentEditorItemService, IConsistentItemService } from './helperServices/consistentItemService.js'; -import { ctrlKStream_prefixAndSuffix, ctrlKStream_userMessage, ctrlKStream_systemMessage, fastApply_userMessage, fastApply_systemMessage, defaultFimTags } from './prompt/prompts.js'; +import { voidPrefixAndSuffix, ctrlKStream_userMessage, ctrlKStream_systemMessage, fastApply_userMessage, fastApply_systemMessage, defaultFimTags } from './prompt/prompts.js'; import { ILLMMessageService } from '../../../../platform/void/common/llmMessageService.js'; import { mountCtrlK } from '../browser/react/out/quick-edit-tsx/index.js' @@ -1304,13 +1304,24 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { const instructions = _mountInfo?.textAreaRef.current?.value ?? '' // __TODO__ use Ollama's FIM api, if (isOllamaFIM) {...} else: - const { prefix, suffix } = ctrlKStream_prefixAndSuffix({ fullFileStr: currentFileStr, startLine, endLine }) + const { prefix, suffix } = voidPrefixAndSuffix({ fullFileStr: currentFileStr, startLine, endLine }) + // if (isOllamaFIM) { + // messages = { + // type: 'ollamaFIM', + // prefix, + // suffix, + // } + + // } + // else { const language = filenameToVscodeLanguage(uri.fsPath) ?? '' const userContent = ctrlKStream_userMessage({ selection: originalCode, instructions: instructions, prefix, suffix, isOllamaFIM: false, fimTags: modelFimTags, language }) + // type: 'messages', messages = [ { role: 'system', content: ctrlKStream_systemMessage({ fimTags: modelFimTags }), }, { role: 'user', content: userContent, } ] + // } } else { throw new Error(`featureName ${featureName} is invalid`) } @@ -1356,6 +1367,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { let prevIgnoredSuffix = '' streamRequestIdRef.current = this._llmMessageService.sendLLMMessage({ + type: 'sendLLMMessage', useProviderFor: featureName, logging: { loggingName: `startApplying - ${featureName}` }, messages, @@ -1400,7 +1412,6 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { onDone(true) }, - range: { startLineNumber: startLine, endLineNumber: endLine, startColumn: 1, endColumn: Number.MAX_SAFE_INTEGER }, }) return diffZone diff --git a/src/vs/workbench/contrib/void/browser/prompt/prompts.ts b/src/vs/workbench/contrib/void/browser/prompt/prompts.ts index 949e0604119..7dc23d53884 100644 --- a/src/vs/workbench/contrib/void/browser/prompt/prompts.ts +++ b/src/vs/workbench/contrib/void/browser/prompt/prompts.ts @@ -220,7 +220,7 @@ Please finish writing the new file by applying the change to the original file. -export const ctrlKStream_prefixAndSuffix = ({ fullFileStr, startLine, endLine }: { fullFileStr: string, startLine: number, endLine: number }) => { +export const voidPrefixAndSuffix = ({ fullFileStr, startLine, endLine }: { fullFileStr: string, startLine: number, endLine: number }) => { const fullFileLines = fullFileStr.split('\n') diff --git a/src/vs/workbench/contrib/void/browser/react/src/markdown/BlockCode.tsx b/src/vs/workbench/contrib/void/browser/react/src/markdown/BlockCode.tsx index 43abd5b1ddf..4c2cbea48aa 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/markdown/BlockCode.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/markdown/BlockCode.tsx @@ -13,10 +13,9 @@ export const BlockCode = ({ buttonsOnHover, ...codeEditorProps }: { buttonsOnHov return ( <> -
+
{buttonsOnHover === null ? null : ( -
+
{buttonsOnHover}
diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx index 8cb4864fb7f..0ea5febbafb 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -436,7 +436,7 @@ const ChatBubble_ = ({ isEditMode, isLoading, children, role }: { role: ChatMess className={` relative ${isEditMode ? 'px-2 w-full max-w-full' - : role === 'user' ? `px-2 self-end w-fit max-w-full` + : role === 'user' ? `px-2 self-end w-fit max-w-full whitespace-pre-wrap` // user words should be pre : role === 'assistant' ? `px-2 self-start w-full max-w-full` : '' } `} @@ -444,7 +444,7 @@ const ChatBubble_ = ({ isEditMode, isLoading, children, role }: { role: ChatMess
{ ) }, [previousMessages]) - return
- {/* thread selector */} -
- -
+ +
- {/* previous messages + current stream */} - - {/* previous messages */} - {prevMessagesHTML} - {/* message stream */} - + const messagesHTML = + {/* previous messages */} + {prevMessagesHTML} + {/* message stream */} + - {/* error message */} - {latestError === undefined ? null : -
- { chatThreadsService.dismissStreamError(currentThread.id) }} - showDismiss={true} - /> - { commandService.executeCommand(VOID_OPEN_SETTINGS_ACTION_ID) }} text='Open settings' /> -
- } + {/* error message */} + {latestError === undefined ? null : +
+ { chatThreadsService.dismissStreamError(currentThread.id) }} + showDismiss={true} + /> - + { commandService.executeCommand(VOID_OPEN_SETTINGS_ACTION_ID) }} text='Open settings' /> +
+ } +
- {/* input box */} -
0 ? 'absolute bottom-0' : ''}`} + const inputBox =
0 ? 'absolute bottom-0' : ''}`} + > +
{ + textAreaRef.current?.focus() + }} > + {/* top row */} + <> + {/* selections */} + + + + {/* middle row */} +
+ + {/* text input */} + { setInstructionsAreEmpty(!newStr) }, [setInstructionsAreEmpty])} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + onSubmit() + } + }} + ref={textAreaRef} + fnsRef={textAreaFnsRef} + multiline={true} + /> +
+ + {/* bottom row */}
{ - textAreaRef.current?.focus() - }} + className='flex flex-row justify-between items-end gap-1' > - {/* top row */} - <> - {/* selections */} - - - - {/* middle row */} -
- - {/* text input */} - { setInstructionsAreEmpty(!newStr) }, [setInstructionsAreEmpty])} - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey) { - onSubmit() - } - }} - ref={textAreaRef} - fnsRef={textAreaFnsRef} - multiline={true} - /> + {/* submit options */} +
+
- {/* bottom row */} -
- {/* submit options */} -
- -
+ {/* submit / stop button */} + {isStreaming ? + // stop button + + : + // submit button (up arrow) + + } +
+
+
- {/* submit / stop button */} - {isStreaming ? - // stop button - - : - // submit button (up arrow) - - } -
+ return
+ {threadSelector} + {messagesHTML} -
-
-
+ {inputBox} + +
} diff --git a/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx b/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx index b21df36fc59..357640c08d6 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx @@ -15,6 +15,7 @@ import { useAccessor } from './services.js'; import { ITextModel } from '../../../../../../../editor/common/model.js'; import { asCssVariable } from '../../../../../../../platform/theme/common/colorUtils.js'; import { inputBackground, inputForeground } from '../../../../../../../platform/theme/common/colorRegistry.js'; +import { useFloating, autoUpdate, offset, flip, shift, size, autoPlacement } from '@floating-ui/react'; // type guard @@ -296,6 +297,7 @@ export const VoidCheckBox = ({ label, value, onClick, className }: { label: stri } + export const VoidCustomSelectBox = ({ options, selectedOption: selectedOption_, @@ -306,7 +308,6 @@ export const VoidCustomSelectBox = ({ className, arrowTouchesText = true, matchInputWidth = false, - isMenuPositionFixed = true, gap = 0, }: { options: T[]; @@ -318,18 +319,58 @@ export const VoidCustomSelectBox = ({ className?: string; arrowTouchesText?: boolean; matchInputWidth?: boolean; - isMenuPositionFixed?: boolean; gap?: number; }) => { const [isOpen, setIsOpen] = useState(false); - const [readyToShow, setReadyToShow] = useState(false); - const [position, setPosition] = useState({ top: 0, left: 0, width: 0 }); - const containerRef = useRef(null); - const buttonRef = useRef(null); - const measureRef = useRef(null); - - - // if the selected option is null, use the 0th option as the selected, and set the option to options[0] + const measureRef = useRef(null); + + // Replace manual positioning with floating-ui + const { + x, + y, + strategy, + refs, + middlewareData, + update + } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + placement:'bottom-start', + + middleware: [ + offset(gap), + flip({ + boundary: document.body, + padding: 8 + }), + shift({ + boundary: document.body, + padding: 8, + }), + size({ + apply({ availableHeight, elements, rects }) { + const maxHeight = Math.min(availableHeight) + + Object.assign(elements.floating.style, { + maxHeight: `${maxHeight}px`, + overflowY: 'auto', + // Ensure the width isn't constrained by the parent + width: `${Math.max( + rects.reference.width, + measureRef.current?.offsetWidth ?? 0 + )}px` + }); + }, + padding: 8, + // Use viewport as boundary instead of any parent element + boundary: document.body, + }), + ], + whileElementsMounted: autoUpdate, + strategy:'fixed', + }); + + // if the selected option is null, use the 0th option useEffect(() => { if (!options[0]) return if (!selectedOption_) { @@ -338,84 +379,33 @@ export const VoidCustomSelectBox = ({ }, [selectedOption_, options]) const selectedOption = !selectedOption_ ? options[0] : selectedOption_ - - const updatePosition = useCallback(() => { - if (!buttonRef.current || !containerRef.current || !measureRef.current) return; - - const buttonRect = buttonRef.current.getBoundingClientRect(); - const containerRect = containerRef.current.getBoundingClientRect(); - const containerWidth = containerRef.current.offsetWidth; - const viewportHeight = window.innerHeight; - const spaceBelow = viewportHeight - buttonRect.bottom; - const spaceNeeded = options.length * 28; - const showAbove = spaceBelow < spaceNeeded && buttonRect.top > spaceBelow; - - // Calculate the menu width - let menuWidth = matchInputWidth ? containerWidth : buttonRect.width; - - // If not matchInputWidth, calculate content width from measurement div - if (!matchInputWidth) { - const contentWidth = measureRef.current.offsetWidth; - menuWidth = Math.max(buttonRect.width, contentWidth); - } - - if (isMenuPositionFixed) { - // Fixed positioning (relative to viewport) - setPosition({ - top: showAbove - ? buttonRect.top - spaceNeeded - : buttonRect.bottom + gap, - left: buttonRect.left, - width: menuWidth, - }); - } else { - // Absolute positioning (relative to parent container) - setPosition({ - top: showAbove - ? -(spaceNeeded + gap) - : buttonRect.height + gap, - left: 0, - width: menuWidth, - }); - } - - setReadyToShow(true); - }, [gap, matchInputWidth, options.length, isMenuPositionFixed]); - + // Handle clicks outside useEffect(() => { - if (isOpen) { - setReadyToShow(false); - updatePosition(); - window.addEventListener('scroll', updatePosition, true); - window.addEventListener('resize', updatePosition); - - return () => { - window.removeEventListener('scroll', updatePosition, true); - window.removeEventListener('resize', updatePosition); - }; - } else { - setReadyToShow(false); - } - }, [isOpen, updatePosition]); + if (!isOpen) return; - useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + const target = event.target as Node; + const floating = refs.floating.current; + const reference = refs.reference.current; + + // Check if reference is an HTML element before using contains + const isReferenceHTMLElement = reference && 'contains' in reference; + + if ( + floating && + (!isReferenceHTMLElement || !reference.contains(target)) && + !floating.contains(target) + ) { setIsOpen(false); } }; - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - } - }, [isOpen]); + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen, refs.floating, refs.reference]); return ( -
+
{/* Hidden measurement div */}
({ {/* Select Button */} {/* Dropdown Menu */} - {isOpen && readyToShow && ( + {isOpen && (
{options.map((option) => { diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/ModelDropdown.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/ModelDropdown.tsx index 67a9a9c8624..bef7f449425 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/ModelDropdown.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/ModelDropdown.tsx @@ -42,7 +42,7 @@ const ModelSelectBox = ({ options, featureName }: { options: ModelOption[], feat getOptionsEqual={(a, b) => optionsEqual([a], [b])} className={`text-xs text-void-fg-3 px-1`} matchInputWidth={false} - isMenuPositionFixed={featureName === 'Ctrl+K' ? false : true} + // isMenuPositionFixed={featureName === 'Ctrl+K' ? false : true} /> } // const ModelSelectBox = ({ options, featureName }: { options: ModelOption[], featureName: FeatureName }) => { diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx index 62de3bed82d..7c5751d70df 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx @@ -5,7 +5,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { InputBox } from '../../../../../../../base/browser/ui/inputbox/inputBox.js' -import { ProviderName, SettingName, displayInfoOfSettingName, providerNames, VoidModelInfo, globalSettingNames, customSettingNamesOfProvider, RefreshableProviderName, refreshableProviderNames, displayInfoOfProviderName, defaultProviderSettings, nonlocalProviderNames, localProviderNames, GlobalSettingName } from '../../../../../../../platform/void/common/voidSettingsTypes.js' +import { ProviderName, SettingName, displayInfoOfSettingName, providerNames, VoidModelInfo, globalSettingNames, customSettingNamesOfProvider, RefreshableProviderName, refreshableProviderNames, displayInfoOfProviderName, defaultProviderSettings, nonlocalProviderNames, localProviderNames, GlobalSettingName, featureNames, displayInfoOfFeatureName } from '../../../../../../../platform/void/common/voidSettingsTypes.js' import ErrorBoundary from '../sidebar-tsx/ErrorBoundary.js' import { VoidButton, VoidCheckBox, VoidCustomSelectBox, VoidInputBox, VoidInputBox2, VoidSwitch } from '../util/inputs.js' import { useAccessor, useIsDark, useRefreshModelListener, useRefreshModelState, useSettingsState } from '../util/services.js' @@ -14,7 +14,7 @@ import { useScrollbarStyles } from '../util/useScrollbarStyles.js' import { isWindows, isLinux, isMacintosh } from '../../../../../../../base/common/platform.js' import { URI } from '../../../../../../../base/common/uri.js' import { env } from '../../../../../../../base/common/process.js' -import { WarningBox } from './ModelDropdown.js' +import { WarningBox, ModelDropdown } from './ModelDropdown.js' import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js' const SubtleButton = ({ onClick, text, icon, disabled }: { onClick: () => void, text: string, icon: React.ReactNode, disabled: boolean }) => { @@ -392,7 +392,7 @@ export const AIInstructionsBox = () => { const voidSettingsService = accessor.get('IVoidSettingsService') const voidSettingsState = useSettingsState() return {
+
+

Model Selection

+ {featureNames.map(featureName => +
+

{displayInfoOfFeatureName(featureName)}

+ +
+ )} +
}