diff --git a/package.json b/package.json index 52387a3e..d786053c 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "extensions-ci-pr": "node ./node_modules/gulp/bin/gulp.js extensions-ci-pr", "perf": "node scripts/code-perf.js", "update-build-ts-version": "npm install typescript@next && tsc -p ./build/tsconfig.build.json" - }, + }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@floating-ui/react": "^0.27.5", diff --git a/product.json b/product.json index b7b9c269..00cc284c 100644 --- a/product.json +++ b/product.json @@ -1,6 +1,7 @@ { "nameShort": "Void", "nameLong": "Void", + "voidVersion": "1.0.2", "applicationName": "void", "dataFolderName": ".void-editor", "win32MutexName": "voideditor", diff --git a/scripts/appimage/create_appimage.sh b/scripts/appimage/create_appimage.sh index 64c9acf2..75ad322a 100755 --- a/scripts/appimage/create_appimage.sh +++ b/scripts/appimage/create_appimage.sh @@ -107,13 +107,46 @@ find . -maxdepth 1 ! -name VoidApp.AppDir ! -name "." ! -name ".." -exec cp -r { cp void.png VoidApp.AppDir/ && \ echo "[Desktop Entry]" > VoidApp.AppDir/void.desktop && \ echo "Name=Void" >> VoidApp.AppDir/void.desktop && \ -echo "Exec=void" >> VoidApp.AppDir/void.desktop && \ +echo "Comment=Open source AI code editor." >> VoidApp.AppDir/void.desktop && \ +echo "GenericName=Text Editor" >> VoidApp.AppDir/void.desktop && \ +echo "Exec=void %F" >> VoidApp.AppDir/void.desktop && \ echo "Icon=void" >> VoidApp.AppDir/void.desktop && \ echo "Type=Application" >> VoidApp.AppDir/void.desktop && \ -echo "Categories=Utility;" >> VoidApp.AppDir/void.desktop && \ -echo "Comment=Void Linux Application" >> VoidApp.AppDir/void.desktop && \ +echo "StartupNotify=false" >> VoidApp.AppDir/void.desktop && \ +echo "StartupWMClass=Void" >> VoidApp.AppDir/void.desktop && \ +echo "Categories=TextEditor;Development;IDE;" >> VoidApp.AppDir/void.desktop && \ +echo "MimeType=application/x-void-workspace;" >> VoidApp.AppDir/void.desktop && \ +echo "Keywords=void;" >> VoidApp.AppDir/void.desktop && \ +echo "Actions=new-empty-window;" >> VoidApp.AppDir/void.desktop && \ +echo "[Desktop Action new-empty-window]" >> VoidApp.AppDir/void.desktop && \ +echo "Name=New Empty Window" >> VoidApp.AppDir/void.desktop && \ +echo "Name[de]=Neues leeres Fenster" >> VoidApp.AppDir/void.desktop && \ +echo "Name[es]=Nueva ventana vacía" >> VoidApp.AppDir/void.desktop && \ +echo "Name[fr]=Nouvelle fenêtre vide" >> VoidApp.AppDir/void.desktop && \ +echo "Name[it]=Nuova finestra vuota" >> VoidApp.AppDir/void.desktop && \ +echo "Name[ja]=新しい空のウィンドウ" >> VoidApp.AppDir/void.desktop && \ +echo "Name[ko]=새 빈 창" >> VoidApp.AppDir/void.desktop && \ +echo "Name[ru]=Новое пустое окно" >> VoidApp.AppDir/void.desktop && \ +echo "Name[zh_CN]=新建空窗口" >> VoidApp.AppDir/void.desktop && \ +echo "Name[zh_TW]=開新空視窗" >> VoidApp.AppDir/void.desktop && \ +echo "Exec=void --new-window %F" >> VoidApp.AppDir/void.desktop && \ +echo "Icon=void" >> VoidApp.AppDir/void.desktop && \ chmod +x VoidApp.AppDir/void.desktop && \ cp VoidApp.AppDir/void.desktop VoidApp.AppDir/usr/share/applications/ && \ +echo "[Desktop Entry]" > VoidApp.AppDir/void-url-handler.desktop && \ +echo "Name=Void - URL Handler" > VoidApp.AppDir/void-url-handler.desktop && \ +echo "Comment=Open source AI code editor." > VoidApp.AppDir/void-url-handler.desktop && \ +echo "GenericName=Text Editor" > VoidApp.AppDir/void-url-handler.desktop && \ +echo "Exec=void --open-url %U" > VoidApp.AppDir/void-url-handler.desktop && \ +echo "Icon=void" > VoidApp.AppDir/void-url-handler.desktop && \ +echo "Type=Application" > VoidApp.AppDir/void-url-handler.desktop && \ +echo "NoDisplay=true" > VoidApp.AppDir/void-url-handler.desktop && \ +echo "StartupNotify=true" > VoidApp.AppDir/void-url-handler.desktop && \ +echo "Categories=Utility;TextEditor;Development;IDE;" > VoidApp.AppDir/void-url-handler.desktop && \ +echo "MimeType=x-scheme-handler/void;" > VoidApp.AppDir/void-url-handler.desktop && \ +echo "Keywords=void;" > VoidApp.AppDir/void-url-handler.desktop && \ +chmod +x VoidApp.AppDir/void-url-handler.desktop && \ +cp VoidApp.AppDir/void-url-handler.desktop VoidApp.AppDir/usr/share/applications/ && \ echo "#!/bin/bash" > VoidApp.AppDir/AppRun && \ echo "HERE=\$(dirname \"\$(readlink -f \"\${0}\")\")" >> VoidApp.AppDir/AppRun && \ echo "export PATH=\${HERE}/usr/bin:\${PATH}" >> VoidApp.AppDir/AppRun && \ diff --git a/scripts/appimage/void-url-handler.desktop b/scripts/appimage/void-url-handler.desktop new file mode 100644 index 00000000..948a823b --- /dev/null +++ b/scripts/appimage/void-url-handler.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Name=Void - URL Handler +Comment=Open source AI code editor. +GenericName=Text Editor +Exec=void --open-url %U +Icon=void +Type=Application +NoDisplay=true +StartupNotify=true +Categories=Utility;TextEditor;Development;IDE; +MimeType=x-scheme-handler/void; +Keywords=void; diff --git a/scripts/appimage/void.desktop b/scripts/appimage/void.desktop index 8649b00a..0ccbce43 100755 --- a/scripts/appimage/void.desktop +++ b/scripts/appimage/void.desktop @@ -1,7 +1,27 @@ [Desktop Entry] -Name=void -Exec=void +Name=Void +Comment=Open source AI code editor. +GenericName=Text Editor +Exec=void %F Icon=void Type=Application -Categories=Utility; -Comment=Void Linux Application +StartupNotify=false +StartupWMClass=Void +Categories=TextEditor;Development;IDE; +MimeType=application/x-void-workspace; +Keywords=void; +Actions=new-empty-window; + +[Desktop Action new-empty-window] +Name=New Empty Window +Name[de]=Neues leeres Fenster +Name[es]=Nueva ventana vacía +Name[fr]=Nouvelle fenêtre vide +Name[it]=Nuova finestra vuota +Name[ja]=新しい空のウィンドウ +Name[ko]=새 빈 창 +Name[ru]=Новое пустое окно +Name[zh_CN]=新建空窗口 +Name[zh_TW]=開新空視窗 +Exec=void --new-window %F +Icon=void diff --git a/src/vs/base/browser/ui/codicons/codicon/codicon-modifiers.css b/src/vs/base/browser/ui/codicons/codicon/codicon-modifiers.css index 9666216f..7839fbd8 100644 --- a/src/vs/base/browser/ui/codicons/codicon/codicon-modifiers.css +++ b/src/vs/base/browser/ui/codicons/codicon/codicon-modifiers.css @@ -26,7 +26,7 @@ } /* custom speed & easing for loading icon */ -.codicon-loading, +.codicon-loading:not(.codicon-no-default-spin), /* Void changed this as it is literally broken to the !important */ .codicon-tree-item-loading::before { animation-duration: 1s !important; animation-timing-function: cubic-bezier(0.53, 0.21, 0.29, 0.67) !important; diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 52c4c943..c0a400d2 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -56,6 +56,7 @@ export type ExtensionVirtualWorkspaceSupport = { export interface IProductConfiguration { readonly version: string; + readonly voidVersion?: string; // Void added this readonly date?: string; readonly quality?: string; readonly commit?: string; diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index 3fdecc87..65d56c06 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -3570,7 +3570,7 @@ class EditorQuickSuggestions extends BaseEditorOption this._logMarker(marker)); // } // }; diff --git a/src/vs/workbench/contrib/void/browser/aiRegexService.ts b/src/vs/workbench/contrib/void/browser/aiRegexService.ts index 4b2a919a..6495a310 100644 --- a/src/vs/workbench/contrib/void/browser/aiRegexService.ts +++ b/src/vs/workbench/contrib/void/browser/aiRegexService.ts @@ -34,7 +34,7 @@ // // const result = await new Promise((res, rej) => { // // sendLLMMessage({ // // messages, -// // tools: ['search'], +// // tools: ['text_search'], // // onFinalMessage: ({ result: r, }) => { // // res(r) // // }, @@ -73,7 +73,7 @@ // // const result = new Promise((res, rej) => { // // sendLLMMessage({ // // messages, -// // tools: ['search'], +// // tools: ['text_search'], // // onResult: (r) => { // // res(r) // // } diff --git a/src/vs/workbench/contrib/void/browser/autocompleteService.ts b/src/vs/workbench/contrib/void/browser/autocompleteService.ts index 8d219f1c..34c7025c 100644 --- a/src/vs/workbench/contrib/void/browser/autocompleteService.ts +++ b/src/vs/workbench/contrib/void/browser/autocompleteService.ts @@ -15,7 +15,7 @@ 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 { extractCodeFromRegular } from '../common/helpers/extractCodeFromResult.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { ILLMMessageService } from '../common/sendLLMMessageService.js'; import { isWindows } from '../../../../base/common/platform.js'; @@ -637,9 +637,12 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ token: CancellationToken, ): Promise { + const isEnabled = this._settingsService.state.globalSettings.enableAutocomplete + if (!isEnabled) return [] + const testMode = false - const docUriStr = model.uri.toString(); + const docUriStr = model.uri.fsPath; const prefixAndSuffix = getPrefixAndSuffixInfo(model, position) const { prefix, suffix } = prefixAndSuffix @@ -792,10 +795,9 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ const modelSelection = this._settingsService.state.modelSelectionOfFeature[featureName] const modelSelectionOptions = modelSelection ? this._settingsService.state.optionsOfModelSelection[modelSelection.providerName]?.[modelSelection.modelName] : undefined - const isEnabled = this._settingsService.state.globalSettings.enableAutocomplete // set parameters of `newAutocompletion` appropriately - newAutocompletion.llmPromise = isEnabled ? new Promise((resolve, reject) => reject('Autocomplete is disabled')) : new Promise((resolve, reject) => { + newAutocompletion.llmPromise = new Promise((resolve, reject) => { const requestId = this._llmMessageService.sendLLMMessage({ messagesType: 'FIMMessage', @@ -850,6 +852,7 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ newAutocompletion.status = 'error' reject(message) }, + onAbort: () => { }, }) newAutocompletion.requestId = requestId @@ -913,7 +916,7 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ if (!resource) return; const model = this._modelService.getModel(resource) if (!model) return; - const docUriStr = resource.toString(); + const docUriStr = resource.fsPath; if (!this._autocompletionsOfDocument[docUriStr]) return; const { prefix, } = getPrefixAndSuffixInfo(model, position) @@ -942,4 +945,3 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ registerWorkbenchContribution2(AutocompleteService.ID, AutocompleteService, WorkbenchPhase.BlockRestore); - diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index f02f9e2c..e3024792 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -10,18 +10,25 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { URI } from '../../../../base/common/uri.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { IRange } from '../../../../editor/common/core/range.js'; import { ILLMMessageService } from '../common/sendLLMMessageService.js'; -import { chat_userMessageContent, chat_systemMessage, chat_lastUserMessageWithFilesAdded, chat_selectionsString } from './prompt/prompts.js'; -import { InternalToolInfo, IToolsService, ToolCallParams, ToolResultType, ToolName, toolNamesThatRequireApproval, voidTools } from './toolsService.js'; -import { AnthropicReasoning, LLMChatMessage, ToolCallType } from '../common/sendLLMMessageTypes.js'; +import { chat_userMessageContent, chat_systemMessage, chat_lastUserMessageWithFilesAdded, chat_selectionsString, voidTools } from '../common/prompt/prompts.js'; +import { getErrorMessage, LLMChatMessage, ToolCallType } from '../common/sendLLMMessageTypes.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { IVoidFileService } from '../common/voidFileService.js'; import { generateUuid } from '../../../../base/common/uuid.js'; -import { getErrorMessage } from '../../../../base/common/errors.js'; -import { ChatMode, FeatureName } from '../common/voidSettingsTypes.js'; +import { ChatMode, FeatureName, ModelSelection, ModelSelectionOptions } from '../common/voidSettingsTypes.js'; import { IVoidSettingsService } from '../common/voidSettingsService.js'; - +import { ToolName, ToolCallParams, ToolResultType, toolNamesThatRequireApproval, InternalToolInfo } from '../common/toolsServiceTypes.js'; +import { IToolsService } from './toolsService.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; +import { ChatMessage, CodespanLocationLink, StagingSelectionItem, ToolMessage, ToolRequestApproval } from '../common/chatThreadServiceTypes.js'; +import { Position } from '../../../../editor/common/core/position.js'; +import { ITerminalToolService } from './terminalToolService.js'; +import { IMetricsService } from '../common/metricsService.js'; +import { shorten } from '../../../../base/common/labels.js'; +import { IVoidModelService } from '../common/voidModelService.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; const findLastIndex = (arr: T[], condition: (t: T) => boolean): number => { for (let i = arr.length - 1; i >= 0; i--) { @@ -55,95 +62,43 @@ const toLLMChatMessages = (chatMessages: ChatMessage[]): LLMChatMessage[] => { } -// one of the square items that indicates a selection in a chat bubble (NOT a file, a Selection of text) -export type CodeSelection = { - type: 'Selection'; - fileURI: URI; - selectionStr: string; - range: IRange; - state: { - isOpened: boolean; - }; -} - -export type FileSelection = { - type: 'File'; - fileURI: URI; - selectionStr: null; - range: null; - state: { - isOpened: boolean; - }; -} - -export type StagingSelectionItem = CodeSelection | FileSelection - - -export type ToolMessage = { - role: 'tool'; - name: T; // internal use - paramsStr: string; // internal use - id: string; // apis require this tool use id - content: string; // give this result to LLM - result: { type: 'success'; params: ToolCallParams[T]; value: ToolResultType[T], } | { type: 'error'; value: string }; // give this result to user -} -export type ToolRequestApproval = { - role: 'tool_request'; - name: T; // internal use - params: ToolCallParams[T]; // internal use - voidToolId: string; // internal id Void uses -} - -// WARNING: changing this format is a big deal!!!!!! need to migrate old format to new format on users' computers so people don't get errors. -export type ChatMessage = - | { - role: 'user'; - content: string; // content displayed to the LLM on future calls - allowed to be '', will be replaced with (empty) - displayContent: string | null; // content displayed to user - allowed to be '', will be ignored - selections: StagingSelectionItem[] | null; // the user's selection - state: { - stagingSelections: StagingSelectionItem[]; - isBeingEdited: boolean; - } - } | { - role: 'assistant'; - content: string; // content received from LLM - allowed to be '', will be replaced with (empty) - reasoning: string; // reasoning from the LLM, used for step-by-step thinking - - anthropicReasoning: AnthropicReasoning[] | null; // anthropic reasoning - } - | ToolMessage - | ToolRequestApproval - type UserMessageType = ChatMessage & { role: 'user' } type UserMessageState = UserMessageType['state'] - -export const defaultMessageState: UserMessageState = { +const defaultMessageState: UserMessageState = { stagingSelections: [], isBeingEdited: false, } // a 'thread' means a chat message history -export type ChatThreads = { - [id: string]: { - id: string; // store the id here too - createdAt: string; // ISO string - lastModified: string; // ISO string - messages: ChatMessage[]; - state: { - stagingSelections: StagingSelectionItem[]; - focusedMessageIdx: number | undefined; // index of the message that is being edited (undefined if none) - isCheckedOfSelectionId: { [selectionId: string]: boolean }; // TODO + +type ThreadType = { + id: string; // store the id here too + createdAt: string; // ISO string + lastModified: string; // ISO string + messages: ChatMessage[]; + state: { + stagingSelections: StagingSelectionItem[]; + focusedMessageIdx: number | undefined; // index of the message that is being edited (undefined if none) + + linksOfMessageIdx: { // eg. link = linksOfMessageIdx[4]['RangeFunction'] + [messageIdx: number]: { + [codespanName: string]: CodespanLocationLink + } } - }; + + isCheckedOfSelectionId: { [selectionId: string]: boolean }; // TODO + } } -type ThreadType = ChatThreads[string] +type ChatThreads = { + [id: string]: undefined | ThreadType; +} -const defaultThreadState: ThreadType['state'] = { +export const defaultThreadState: ThreadType['state'] = { stagingSelections: [], focusedMessageIdx: undefined, - isCheckedOfSelectionId: {} + isCheckedOfSelectionId: {}, + linksOfMessageIdx: {}, } export type ThreadsState = { @@ -151,16 +106,22 @@ export type ThreadsState = { currentThreadId: string; // intended for internal use only } +export type IsRunningType = undefined | 'message' | 'tool' | 'awaiting_user' export type ThreadStreamState = { [threadId: string]: undefined | { + // state related to streaming (not just when streaming) + isRunning?: IsRunningType; // whether or not actually running the agent loop (can be running and not streaming, like if it's calling a tool and awaiting user response) error?: { message: string, fullError: Error | null, }; + + // streaming related - when streaming message + streamingToken?: string; messageSoFar?: string; reasoningSoFar?: string; - streamingToken?: string; + toolNameSoFar?: string; + toolParamsSoFar?: string; } } - const newThreadObject = () => { const now = new Date().toISOString() return { @@ -169,7 +130,6 @@ const newThreadObject = () => { lastModified: now, messages: [], state: defaultThreadState, - } satisfies ChatThreads[string] } @@ -184,42 +144,47 @@ export interface IChatThreadService { readonly _serviceBrand: undefined; readonly state: ThreadsState; - readonly streamState: ThreadStreamState; + readonly streamState: ThreadStreamState; // not persistent onDidChangeCurrentThread: Event; onDidChangeStreamState: Event<{ threadId: string }> - getCurrentThread(): ChatThreads[string]; + getCurrentThread(): ThreadType; openNewThread(): void; switchToThread(threadId: string): void; - // you can edit multiple messages - // the one you're currently editing is "focused", and we add items to that one when you press cmd+L. - getFocusedMessageIdx(): number | undefined; - isFocusingMessage(): boolean; - setFocusedMessageIdx(messageIdx: number | undefined): void; - // exposed getters/setters + // these all apply to current thread getCurrentMessageState: (messageIdx: number) => UserMessageState setCurrentMessageState: (messageIdx: number, newState: Partial) => void getCurrentThreadState: () => ThreadType['state'] setCurrentThreadState: (newState: Partial) => void + // you can edit multiple messages - the one you're currently editing is "focused", and we add items to that one when you press cmd+L. + getCurrentFocusedMessageIdx(): number | undefined; + isCurrentlyFocusingMessage(): boolean; + setCurrentlyFocusedMessageIdx(messageIdx: number | undefined): void; + // current thread's staging selections + closeCurrentStagingSelectionsInMessage(opts: { messageIdx: number }): void; + closeCurrentStagingSelectionsInThread(): void; - closeStagingSelectionsInCurrentThread(): void; - closeStagingSelectionsInMessage(messageIdx: number): void; + // codespan links (link to symbols in the markdown) + getCodespanLink(opts: { codespanStr: string, messageIdx: number, threadId: string }): CodespanLocationLink | undefined; + addCodespanLink(opts: { newLinkText: string, newLinkLocation: CodespanLocationLink, messageIdx: number, threadId: string }): void; + generateCodespanLink(opts: { codespanStr: string, threadId: string }): Promise - - // call to edit a message - editUserMessageAndStreamResponse({ userMessage, chatMode, messageIdx }: { userMessage: string, chatMode: ChatMode, messageIdx: number }): Promise; - - // call to add a message - addUserMessageAndStreamResponse({ userMessage, chatMode }: { userMessage: string, chatMode: ChatMode }): Promise; - - cancelStreaming(threadId: string): void; + // entry pts + stopRunning(threadId: string): void; dismissStreamError(threadId: string): void; - approveTool(toolId: string): void; - rejectTool(toolId: string): void; + // call to edit a message + editUserMessageAndStreamResponse({ userMessage, messageIdx, threadId }: { userMessage: string, messageIdx: number, threadId: string }): Promise; + + // call to add a message + addUserMessageAndStreamResponse({ userMessage, threadId }: { userMessage: string, threadId: string }): Promise; + + // approve/reject + approveTool(threadId: string): void; + rejectTool(threadId: string): void; } export const IChatThreadService = createDecorator('voidChatThreadService'); @@ -230,19 +195,24 @@ class ChatThreadService extends Disposable implements IChatThreadService { private readonly _onDidChangeCurrentThread = new Emitter(); readonly onDidChangeCurrentThread: Event = this._onDidChangeCurrentThread.event; - readonly streamState: ThreadStreamState = {} private readonly _onDidChangeStreamState = new Emitter<{ threadId: string }>(); readonly onDidChangeStreamState: Event<{ threadId: string }> = this._onDidChangeStreamState.event; + readonly streamState: ThreadStreamState = {} state: ThreadsState // allThreads is persisted, currentThread is not constructor( @IStorageService private readonly _storageService: IStorageService, - @IVoidFileService private readonly _voidFileService: IVoidFileService, + @IVoidModelService private readonly _voidModelService: IVoidModelService, @ILLMMessageService private readonly _llmMessageService: ILLMMessageService, @IToolsService private readonly _toolsService: IToolsService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @IVoidSettingsService private readonly _settingsService: IVoidSettingsService, + @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + @ITerminalToolService private readonly _terminalToolService: ITerminalToolService, + @IMetricsService private readonly _metricsService: IMetricsService, + @IEditorService private readonly _editorService: IEditorService, + @ICodeEditorService private readonly _codeEditorService: ICodeEditorService, ) { super() this.state = { allThreads: {}, currentThreadId: null as unknown as string } // default state @@ -257,13 +227,77 @@ class ChatThreadService extends Disposable implements IChatThreadService { // always be in a thread this.openNewThread() + + // when the user changes files, automatically add the new file as a stagingSelection + this._register(this._editorService.onDidActiveEditorChange(() => this._addCurrentFileAsStagingSelectionDuringFileChange())); + } + + private _addCurrentFileAsStagingSelectionDuringFileChange() { + + + // add the current file to the thread being edited + const newModel = this._codeEditorService.getActiveCodeEditor()?.getModel() ?? null + if (!newModel) { return; } + + const newStagingSelection: StagingSelectionItem = { + type: 'File', + fileURI: newModel.uri, + language: newModel.getLanguageId(), + selectionStr: null, + range: null, + state: { isOpened: false, wasAddedAsCurrentFile: true } + } + + const focusedMessageIdx = this.getCurrentFocusedMessageIdx(); + + // add the selection + if (focusedMessageIdx === undefined) { // user is in the default thread + + const oldStagingSelections = this.getCurrentThreadState().stagingSelections || []; + + // remove all old selectons that are marked as `wasAddedAsCurrentFile` + const newStagingSelections: StagingSelectionItem[] = oldStagingSelections.filter(s => !s.state?.wasAddedAsCurrentFile); + + // add the new file if it doesn't exist + const fileIsAdded = oldStagingSelections.some(s => s.type === 'File' && s.fileURI.fsPath === newStagingSelection.fileURI.fsPath) + if (!fileIsAdded) { + newStagingSelections.push(newStagingSelection) + } + + // update thread state with new selections + this.setCurrentThreadState({ stagingSelections: newStagingSelections }); + + + + } else { // user is editing a message + + // do nothing. I don't think it feels good to auto-add the current file when you're editing a message. + + // const oldStagingSelections = this.getCurrentMessageState(focusedMessageIdx).stagingSelections || []; + // const newStagingSelections = [...filteredStagingSelections, newSelection]; + // this.setCurrentMessageState(focusedMessageIdx, { stagingSelections: newSelections }); + + // // if the file already exists, do nothing + // const alreadyHasFile = oldStagingSelections.some(s => s.type === 'File' && s.fileURI.fsPath === newSelection.fileURI.fsPath) + // if (alreadyHasFile) { return; } + + // const filteredStagingSelections = oldStagingSelections.filter(s => !s.state?.wasAddedDuringFileChange); // remove all old selectons that were added during a file change + + + } + + + } + + // !!! this is important for properly restoring URIs from storage + // should probably re-use code from void/src/vs/base/common/marshalling.ts instead. but this is simple enough private _convertThreadDataFromStorage(threadsStr: string): ChatThreads { return JSON.parse(threadsStr, (key, value) => { - if (value && typeof value === 'object' && value.$mid === 1) { //$mid is the MarshalledId. $mid === 1 means it is a URI - return URI.from(value); + if (value && typeof value === 'object' && value.$mid === 1) { // $mid is the MarshalledId. $mid === 1 means it is a URI + return URI.from(value); // TODO URI.revive instead of this? } return value; }); @@ -274,7 +308,228 @@ class ChatThreadService extends Disposable implements IChatThreadService { if (!threadsStr) { return null } - return this._convertThreadDataFromStorage(threadsStr); + const threads = this._convertThreadDataFromStorage(threadsStr); + + threads['abc'] = { + id: 'abc', + createdAt: new Date().toISOString(), + lastModified: new Date().toISOString(), + messages: [ + { + role: 'tool', + name: 'pathname_search', + id: 'tool-1', + paramsStr: '{"query": "hello", "pageNumber": 0}', + content: '/users/andrew/void/Desktop/etc/abc.txt', + result: { type: 'success', params: { queryStr: 'hello', pageNumber: 0 }, value: { uris: [URI.file('/Users/username/Downloads/helloooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo.txt'), URI.file('/Users/username/Downloads/hello1.txt'), URI.file('/Users/username/Downloads/hello2.txt'), URI.file('/Users/username/Downloads/hello3.txt'), URI.file('/Users/username/hello.txt')], hasNextPage: true } }, + } satisfies ToolMessage<'pathname_search'>, + { + role: 'tool', + name: 'pathname_search', + id: 'tool-1', + paramsStr: '{"query": "hello", "pageNumber": 0}', + content: '/users/andrew/void/Desktop/etc/abc.txt', + result: { type: 'success', params: { queryStr: 'hello', pageNumber: 0 }, value: { uris: [], hasNextPage: false } }, + } satisfies ToolMessage<'pathname_search'>, + + // { + // role: 'tool_request', + // name: 'pathname_search', + // params: { queryStr: 'hello', pageNumber: 0 }, + // paramsStr: '{"query": "hello", "pageNumber": 0}', + // id: 'request-1', + // } satisfies ToolRequestApproval<'pathname_search'>, + + { + role: 'tool', + name: 'list_dir', + id: 'tool-2', + paramsStr: '{"uri": "/Users/username/Documents"}', + content: 'Directory listing of /Users/username/Documents', + result: { + type: 'success', + params: { rootURI: URI.file('/Users/username/Documents'), pageNumber: 1, }, + value: { + children: [ + { uri: URI.file('/Users/username/Documents/file1.txt'), name: 'file1.txt', isDirectory: false, isSymbolicLink: false }, + { uri: URI.file('/Users/username/Documents/folder1'), name: 'folder1', isDirectory: true, isSymbolicLink: false } + ], + hasNextPage: true, + hasPrevPage: true, + itemsRemaining: 5, + } + }, + } satisfies ToolMessage<'list_dir'>, + + // { + // role: 'tool_request', + // name: 'list_dir', + // params: { rootURI: URI.file('/Users/username/Documents'), pageNumber: 0 }, + // paramsStr: '{"uri": "/Users/username/Documents"}', + // id: 'request-2', + // } satisfies ToolRequestApproval<'list_dir'>, + + { + role: 'tool', + name: 'read_file', + id: 'tool-3', + paramsStr: '{"uri": "/Users/username/Documents/file1.txt"}', + content: 'Content of file1.txt\nThis is a sample file.\nHello world!', + result: { + type: 'success', + params: { uri: URI.file('/src/vs/workbench/hi'), pageNumber: 0 }, + value: { fileContents: 'Content of file1.txt\nThis is a sample file.\nHello world!', hasNextPage: false } + }, + } satisfies ToolMessage<'read_file'>, + + // { + // role: 'tool_request', + // name: 'read_file', + // params: { uri: URI.file('/Users/username/Documents/file1.txt'), pageNumber: 0 }, + // paramsStr: '{"uri": "/Users/username/Documents/file1.txt"}', + // id: 'request-3', + // } satisfies ToolRequestApproval<'read_file'>, + + { + role: 'tool', + name: 'text_search', + id: 'tool-4', + paramsStr: '{"query": "function main"}', + content: 'Found matches in 3 files', + result: { + type: 'success', + params: { queryStr: 'function main', pageNumber: 0 }, + value: { + uris: [ + URI.file('/Users/username/Project/main.js'), + URI.file('/Users/username/Project/src/app.js'), + URI.file('/Users/username/Project/test/test.js') + ], + hasNextPage: false + } + }, + } satisfies ToolMessage<'text_search'>, + + // { + // role: 'tool_request', + // name: 'text_search', + // params: { queryStr: 'function main', pageNumber: 0 }, + // paramsStr: '{"query": "function main"}', + // id: 'request-4', + // } satisfies ToolRequestApproval<'text_search'>, + + // --- + + { + role: 'tool', + name: 'edit', + id: 'tool-5', + paramsStr: '{"uri": "/Users/username/Project/main.js", "changeDescription": "Add console.log statement"}', + content: 'Successfully edited the file at /Users/username/Project/main.js', + result: { + type: 'success', + params: { uri: URI.file('/Users/username/Project/main.js'), changeDescription: 'I think we should do this:\n```typescript\n//Add console.log statement\n for i in ...\n\t\tdo:\nabc\n```' }, + value: Promise.resolve() + }, + } satisfies ToolMessage<'edit'>, + { + role: 'tool_request', + name: 'edit', + params: { uri: URI.file('/Users/username/Project/main.js'), changeDescription: 'I think we should do this:\n```typescript\n//Add console.log statement\n for i in ...\n\t\tdo:\nabc\n```' }, + paramsStr: '{"uri": "/Users/username/Project/main.js", "changeDescription": "I think we should do this:```Add console.log statement\n for i in ...\n\t\tdo:\nabc```"}', + id: 'request-5', + } satisfies ToolRequestApproval<'edit'>, + + { + role: 'tool', + name: 'create_uri', + id: 'tool-6', + paramsStr: '{"uri": "/Users/username/Project/new-file.js"}', + content: 'Successfully created file at /Users/username/Project/new-file/', + result: { + type: 'success', + params: { uri: URI.file('Users/andrew/Desktop/void/src/vs/workbench/hi/'), isFolder: true }, + value: {} + }, + } satisfies ToolMessage<'create_uri'>, + { + role: 'tool_request', + name: 'create_uri', + params: { uri: URI.file('/Users/username/Project/new-file.js'), isFolder: false }, + paramsStr: '{"uri": "/Users/username/Project/new-file.js"}', + id: 'request-6', + } satisfies ToolRequestApproval<'create_uri'>, + + { + role: 'tool', + name: 'delete_uri', + id: 'tool-7', + paramsStr: '{"uri": "/Users/username/Project/old-file.js", "params": ""}', + content: 'Successfully deleted file at /Users/username/Project/old-file.js', + result: { + type: 'success', + params: { uri: URI.file('/Users/username/Project/old-file.js'), isRecursive: false, isFolder: false }, + value: {} + }, + } satisfies ToolMessage<'delete_uri'>, + { + role: 'tool_request', + name: 'delete_uri', + params: { uri: URI.file('/Users/username/Project/old-file.js'), isRecursive: false, isFolder: false }, + paramsStr: '{"uri": "/Users/username/Project/old-file.js", "params": ""}', + id: 'request-7', + } satisfies ToolRequestApproval<'delete_uri'>, + + { + role: 'tool', + name: 'terminal_command', + id: 'tool-8', + paramsStr: '{"command": "npm install", "waitForCompletion": "true"}', + content: 'Command executed: npm install\nAdded 123 packages in 3.5s', + result: { + type: 'success', + params: { command: 'npm install', proposedTerminalId: '1', waitForCompletion: true }, + value: { + terminalId: '1', + didCreateTerminal: false, + result: 'Added 123 packages in 3.5s', + resolveReason: { type: 'done', exitCode: 0 } + } + }, + } satisfies ToolMessage<'terminal_command'>, + { + role: 'tool_request', + name: 'terminal_command', + params: { command: 'npm install', proposedTerminalId: '1', waitForCompletion: true }, + paramsStr: '{"command": "npm install", "waitForCompletion": "true"}', + id: 'request-8', + } satisfies ToolRequestApproval<'terminal_command'>, + + + + // Examples of error and rejected states + { + role: 'tool', + name: 'pathname_search', + id: 'tool-error', + paramsStr: '{"query": "invalid**query"}', + content: 'Error: Invalid search pattern', + result: { type: 'error', params: { queryStr: 'invalid**query', pageNumber: 0 }, value: 'Error: Invalid search pattern' }, + } satisfies ToolMessage<'pathname_search'>, + + { + role: 'tool', + name: 'pathname_search', + id: 'tool-rejected', + paramsStr: '{"query": "sensitive-data"}', + content: 'Tool call was rejected by the user.', + result: { type: 'rejected', params: { queryStr: 'sensitive-data', pageNumber: 0 } }, + } satisfies ToolMessage<'pathname_search'>, + ], + state: defaultThreadState, + } + + return threads } private _storeAllThreads(threads: ChatThreads) { @@ -298,8 +553,9 @@ class ChatThreadService extends Disposable implements IChatThreadService { this._onDidChangeCurrentThread.fire() } - private _getAllSelections() { - const thread = this.getCurrentThread() + private _getAllSelections(threadId: string) { + const thread = this.state.allThreads[threadId] + if (!thread) return [] return thread.messages.flatMap(m => m.role === 'user' && m.selections || []) } @@ -309,11 +565,23 @@ class ChatThreadService extends Disposable implements IChatThreadService { return prevMessages.flatMap(m => m.role === 'user' && m.selections || []) } - private _setStreamState(threadId: string, state: Partial>) { - this.streamState[threadId] = { - ...this.streamState[threadId], - ...state + private _setStreamState(threadId: string, state: Partial>, behavior: 'set' | 'merge') { + if (state === undefined) + delete this.streamState[threadId] + + else { + if (behavior === 'merge') { + this.streamState[threadId] = { + ...this.streamState[threadId], + ...state + } + } + else if (behavior === 'set') { + this.streamState[threadId] = state + } } + + this._onDidChangeStreamState.fire({ threadId }) } @@ -322,9 +590,10 @@ class ChatThreadService extends Disposable implements IChatThreadService { - async editUserMessageAndStreamResponse({ userMessage, chatMode, messageIdx }: { userMessage: string, chatMode: ChatMode, messageIdx: number }) { + editUserMessageAndStreamResponse: IChatThreadService['editUserMessageAndStreamResponse'] = async ({ userMessage, messageIdx, threadId }) => { - const thread = this.getCurrentThread() + const thread = this.state.allThreads[threadId] + if (!thread) return // should never happen if (thread.messages?.[messageIdx]?.role !== 'user') { throw new Error(`Error: editing a message with role !=='user'`) @@ -347,32 +616,348 @@ class ChatThreadService extends Disposable implements IChatThreadService { }, true) // re-add the message and stream it - this.addUserMessageAndStreamResponse({ userMessage, chatMode, chatSelections: { prevSelns, currSelns } }) + this.addUserMessageAndStreamResponse({ userMessage, _chatSelections: { prevSelns, currSelns }, threadId }) } - private resRejOfToolAwaitingApproval: { [toolId: string]: { res: () => void, rej: () => void } } = {} - approveTool(toolId: string) { - const resRej = this.resRejOfToolAwaitingApproval[toolId] - resRej?.res() - delete this.resRejOfToolAwaitingApproval[toolId] - } - rejectTool(toolId: string) { - const resRej = this.resRejOfToolAwaitingApproval[toolId] - resRej?.rej() - delete this.resRejOfToolAwaitingApproval[toolId] + private _currentModelSelectionProps = () => { + // these settings should not change throughout the loop (eg anthropic breaks if you change its thinking mode and it's using tools) + const featureName: FeatureName = 'Chat' + const modelSelection = this._settingsService.state.modelSelectionOfFeature[featureName] + const modelSelectionOptions = modelSelection ? this._settingsService.state.optionsOfModelSelection[modelSelection.providerName]?.[modelSelection.modelName] : undefined + return { modelSelection, modelSelectionOptions } } - async addUserMessageAndStreamResponse({ userMessage, chatMode, chatSelections }: { userMessage: string, chatMode: ChatMode, chatSelections?: { prevSelns?: StagingSelectionItem[], currSelns?: StagingSelectionItem[] } }) { + approveTool(threadId: string) { + const thread = this.state.allThreads[threadId] + if (!thread) return // should never happen - const thread = this.getCurrentThread() - const threadId = thread.id + + const lastMessage = thread.messages[thread.messages.length - 1] + if (lastMessage.role !== 'tool_request') return // should never happen + + const lastUserMsgIdx = findLastIndex(thread.messages, m => m.role === 'user') + const lastUserMessage = thread.messages[lastUserMsgIdx] as ChatMessage & { role: 'user' } + if (lastUserMsgIdx === -1 || !lastUserMessage) return // should never happen + + const instructions = lastUserMessage.displayContent || '' + const prevSelns: StagingSelectionItem[] = this._getAllSelections(threadId) + const currSelns: StagingSelectionItem[] = [] + + const callThisToolFirst: ToolRequestApproval = lastMessage + + this._runChatAgent({ callThisToolFirst, prevSelns, currSelns, threadId, userMessageContent: instructions, ...this._currentModelSelectionProps() }) + } + rejectTool(threadId: string) { + const thread = this.state.allThreads[threadId] + if (!thread) return // should never happen + + const lastMessage = thread.messages[thread.messages.length - 1] + if (lastMessage.role !== 'tool_request') return // should never happen + const { name, params, paramsStr, id } = lastMessage + + const errorMessage = this.errMsgs.rejected + this._addMessageToThread(threadId, { role: 'tool', name: name, paramsStr: paramsStr, id, content: errorMessage, result: { type: 'rejected', params: params }, }) + this._setStreamState(threadId, {}, 'set') + } + stopRunning(threadId: string) { + const thread = this.state.allThreads[threadId] + if (!thread) return // should never happen + + const isRunning = this.streamState[threadId]?.isRunning + // reject the tool for the user + if (isRunning === 'awaiting_user') { + this.rejectTool(threadId) + } + // interrupt the tool + else if (isRunning === 'tool') { + this._currentlyRunningToolInterruptor[threadId]?.() + } + // interrupt assistant message + else if (isRunning === 'message') { + // abort the stream first so it doesn't change any state + const messageSoFar = this.streamState[threadId]?.messageSoFar ?? '' + const reasoningSoFar = this.streamState[threadId]?.reasoningSoFar ?? '' + + const llmCancelToken = this.streamState[threadId]?.streamingToken + if (llmCancelToken !== undefined) { this._llmMessageService.abort(llmCancelToken) } + + this._addMessageToThread(threadId, { role: 'assistant', content: messageSoFar, reasoning: reasoningSoFar, anthropicReasoning: null }) + } + + this._setStreamState(threadId, {}, 'set') + } + + + + private _tools = (chatMode: ChatMode) => { + const toolNames: ToolName[] | undefined = chatMode === 'normal' ? undefined + : chatMode === 'gather' ? (Object.keys(voidTools) as ToolName[]).filter(toolName => !toolNamesThatRequireApproval.has(toolName)) + : chatMode === 'agent' ? Object.keys(voidTools) as ToolName[] + : undefined + + const tools: InternalToolInfo[] | undefined = toolNames?.map(toolName => voidTools[toolName]) + return tools + } + + + + private readonly errMsgs = { + rejected: 'Tool call was rejected by the user.', + errWhenStringifying: (error: any) => `Tool call succeeded, but there was an error stringifying the output.\n${getErrorMessage(error)}` + } + + + private readonly _currentlyRunningToolInterruptor: { [threadId: string]: (() => void) | undefined } = {} + + private async _runChatAgent({ + threadId, + prevSelns, + currSelns, + modelSelection, + modelSelectionOptions, + userMessageContent, + callThisToolFirst, + }: { + threadId: string, + prevSelns: StagingSelectionItem[], + currSelns: StagingSelectionItem[], + modelSelection: ModelSelection | null, + modelSelectionOptions: ModelSelectionOptions | undefined, + userMessageContent: string, // content of LATEST user message + + callThisToolFirst?: ToolRequestApproval + }) { + + // define helper functions so we can tell what's going on + // for now, do not recompute selections as we run (it seems to confuse tool-use models) + const selectionsStr = await chat_selectionsString(prevSelns, currSelns, this._voidModelService) // all the file CONTENTS or "selections" de-duped + const userMessageFullContent = chat_lastUserMessageWithFilesAdded(userMessageContent, selectionsStr) // full last message: user message + CONTENTS of all files + const getLatestMessages = async () => { + // replace last userMessage with userMessageFullContent (which contains all the files too) + const thread = this.state.allThreads[threadId] + const latestMessages = thread?.messages ?? [] + const messages_ = toLLMChatMessages(latestMessages) + const lastUserMsgIdx = findLastIndex(messages_, m => m.role === 'user') + if (lastUserMsgIdx === -1) return [] // should never happen (or how did they send the message?!) + + // system message + const workspaceFolders = this._workspaceContextService.getWorkspace().folders.map(f => f.uri.fsPath) + const terminalIds = this._terminalToolService.listTerminalIds() + const systemMessage = chat_systemMessage(workspaceFolders, terminalIds, chatMode) + + // all messages so far in the chat history (including tools) + const messages: LLMChatMessage[] = [ + { role: 'system', content: systemMessage, }, + ...messages_.slice(0, lastUserMsgIdx), + { role: 'user', content: userMessageFullContent }, + ...messages_.slice(lastUserMsgIdx + 1, Infinity), + ] + // console.log('MESSAGES!!!', messages) + return messages + } + + + + // returns true when the tool call is waiting for user approval + const handleToolCall = async ( + tool: ToolCallType, + opts?: { preapproved: true, toolParams: ToolCallParams[ToolName] }, + ): Promise<{ awaitingUserApproval?: boolean, interrupted?: boolean }> => { + const toolName: ToolName = tool.name + const toolParamsStr = tool.paramsStr + const toolId = tool.id + + // compute these below + let toolParams: ToolCallParams[ToolName] + let toolResult: ToolResultType[typeof toolName] + let toolResultStr: string + + if (!opts?.preapproved) { // skip this if pre-approved + // 1. validate tool params + try { + const params = await this._toolsService.validateParams[toolName](toolParamsStr) + toolParams = params + } catch (error) { + const errorMessage = getErrorMessage(error) + this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: toolParamsStr, id: toolId, content: errorMessage, result: { type: 'error', params: undefined, value: errorMessage }, }) + return {} + } + + // 2. if tool requires approval, break from the loop, awaiting approval + const requiresApproval = toolNamesThatRequireApproval.has(toolName) + if (requiresApproval) { + const autoApprove = this._settingsService.state.globalSettings.autoApprove + // add a tool_request because we use it for UI if a tool is loading (this should be improved in the future) + this._addMessageToThread(threadId, { role: 'tool_request', name: toolName, paramsStr: toolParamsStr, params: toolParams, id: toolId }) + if (!autoApprove) { + return { awaitingUserApproval: true } + } + } + } + else { + toolParams = opts.toolParams + } + + // 3. call the tool + this._setStreamState(threadId, { isRunning: 'tool' }, 'merge') + let interrupted = false + try { + const { result, interruptTool } = await this._toolsService.callTool[toolName](toolParams as any) + this._currentlyRunningToolInterruptor[threadId] = () => { + interrupted = true; + interruptTool?.(); + delete this._currentlyRunningToolInterruptor[threadId]; + } + toolResult = await result // ts is bad... await is needed + } + catch (error) { + if (interrupted) { + // ideally this should have same implementation as abort - addMessage should get called in stopRunning + this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: toolParamsStr, id: toolId, content: this.errMsgs.rejected, result: { type: 'rejected', params: toolParams }, }) + return { interrupted: true } + } + const errorMessage = getErrorMessage(error) + this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: toolParamsStr, id: toolId, content: errorMessage, result: { type: 'error', params: toolParams, value: errorMessage }, }) + return {} + } + + // 4. stringify the result to give to the LLM + try { + toolResultStr = this._toolsService.stringOfResult[toolName](toolParams as any, toolResult as any) + } catch (error) { + const errorMessage = this.errMsgs.errWhenStringifying(error) + this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: toolParamsStr, id: toolId, content: errorMessage, result: { type: 'error', params: toolParams, value: errorMessage }, }) + return {} + } + + // 5. add to history and keep going + this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: toolParamsStr, id: toolId, content: toolResultStr, result: { type: 'success', params: toolParams, value: toolResult }, }) + return {} + }; + + // above just defines helpers, below starts the actual function + const { chatMode } = this._settingsService.state.globalSettings // should not change as we loop even if user changes it, so it goes here + const tools = this._tools(chatMode) + + // clear any previous error + this._setStreamState(threadId, { error: undefined }, 'set') + + let nMessagesSent = 0 + let shouldSendAnotherMessage = true + let isRunningWhenEnd: IsRunningType = undefined + let aborted = false + + // before enter loop, call tool + if (callThisToolFirst) { + const { interrupted } = await handleToolCall(callThisToolFirst, { preapproved: true, toolParams: callThisToolFirst.params }) + if (interrupted) return + } + + // tool use loop + while (shouldSendAnotherMessage) { + // false by default each iteration + shouldSendAnotherMessage = false + isRunningWhenEnd = undefined + nMessagesSent += 1 + + let resMessageIsDonePromise: (toolCalls?: ToolCallType[] | undefined) => void // resolves when user approves this tool use (or if tool doesn't require approval) + const messageIsDonePromise = new Promise((res, rej) => { resMessageIsDonePromise = res }) + + // send llm message + this._setStreamState(threadId, { isRunning: 'message' }, 'merge') + const messages = await getLatestMessages() + const llmCancelToken = this._llmMessageService.sendLLMMessage({ + messagesType: 'chatMessages', + messages, + tools: tools, + modelSelection, + modelSelectionOptions, + logging: { loggingName: `Chat - ${chatMode}`, loggingExtras: { threadId, nMessagesSent, chatMode } }, + onText: ({ fullText, fullReasoning, fullToolName, fullToolParams }) => { + this._setStreamState(threadId, { messageSoFar: fullText, reasoningSoFar: fullReasoning, toolNameSoFar: fullToolName, toolParamsSoFar: fullToolParams }, 'merge') + }, + onFinalMessage: async ({ fullText, toolCalls, fullReasoning, anthropicReasoning }) => { + this._addMessageToThread(threadId, { role: 'assistant', content: fullText, reasoning: fullReasoning, anthropicReasoning }) + // added to history and no longer streaming this, so clear messages so far and streamingToken (but do not stop isRunning) + this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, toolNameSoFar: undefined, toolParamsSoFar: undefined }, 'merge') + // resolve with tool calls + resMessageIsDonePromise(toolCalls) + }, + onError: (error) => { + const messageSoFar = this.streamState[threadId]?.messageSoFar ?? '' + const reasoningSoFar = this.streamState[threadId]?.reasoningSoFar ?? '' + // add assistant's message to chat history, and clear selection + this._addMessageToThread(threadId, { role: 'assistant', content: messageSoFar, reasoning: reasoningSoFar, anthropicReasoning: null }) + this._setStreamState(threadId, { error }, 'set') + resMessageIsDonePromise() + }, + onAbort: () => { + // stop the loop to free up the promise, but don't modify state (already handled by whatever stopped it) + resMessageIsDonePromise() + this._metricsService.capture('Agent Loop Done (Aborted)', { nMessagesSent, chatMode }) + aborted = true + }, + }) + + // should never happen, just for safety + if (llmCancelToken === null) { + this._setStreamState(threadId, { + error: { message: 'There was an unexpected error when sending your chat message.', fullError: null } + }, 'set') + break + } + this._setStreamState(threadId, { streamingToken: llmCancelToken }, 'merge') // new stream token for the new message + const toolCalls = await messageIsDonePromise // wait for message to complete + if (aborted) { return } + this._setStreamState(threadId, { streamingToken: undefined }, 'merge') // streaming message is done + + // call tool if there is one + const tool: ToolCallType | undefined = toolCalls?.[0] + if (tool) { + const { awaitingUserApproval, interrupted } = await handleToolCall(tool) + + // stop if interrupted. we don't have to do this for llmMessage because we have a stream token for it and onAbort gets called, but we don't have the equivalent for tools. + // just detect tool interruption which is the same as chat interruption right now + if (interrupted) { return } + + if (awaitingUserApproval) { + isRunningWhenEnd = 'awaiting_user' + } + else { + shouldSendAnotherMessage = true + } + } + + } // end while + + + // if awaiting user approval, keep isRunning true, else end isRunning + this._setStreamState(threadId, { isRunning: isRunningWhenEnd }, 'merge') + + // capture number of messages sent + this._metricsService.capture('Agent Loop Done', { nMessagesSent, chatMode }) + + } + + + + + + + async addUserMessageAndStreamResponse({ userMessage, _chatSelections, threadId }: { userMessage: string, _chatSelections?: { prevSelns?: StagingSelectionItem[], currSelns?: StagingSelectionItem[], }, threadId: string }) { + const thread = this.state.allThreads[threadId] + if (!thread) return // should never happen + + // if the current thread is already streaming, stop it (this simply resolves the promise to free up space) + const llmCancelToken = this.streamState[threadId]?.streamingToken + if (llmCancelToken !== undefined) this._llmMessageService.abort(llmCancelToken) // selections in all past chats, then in current chat (can have many duplicates here) - const prevSelns: StagingSelectionItem[] = chatSelections?.prevSelns ?? this._getAllSelections() - const currSelns: StagingSelectionItem[] = chatSelections?.currSelns ?? thread.state.stagingSelections + const prevSelns: StagingSelectionItem[] = _chatSelections?.prevSelns ?? this._getAllSelections(threadId) + const currSelns: StagingSelectionItem[] = _chatSelections?.currSelns ?? thread.state.stagingSelections // add user's message to chat history const instructions = userMessage @@ -381,183 +966,256 @@ class ChatThreadService extends Disposable implements IChatThreadService { const userHistoryElt: ChatMessage = { role: 'user', content: userMessageContent, displayContent: instructions, selections: currSelns, state: defaultMessageState } this._addMessageToThread(threadId, userHistoryElt) - this._setStreamState(threadId, { error: undefined }) - - - const tools: InternalToolInfo[] | undefined = ( - chatMode === 'chat' ? undefined - : chatMode === 'agent' ? Object.keys(voidTools).map(toolName => voidTools[toolName as ToolName]) - : undefined) - - // these settings should not change throughout the loop (eg anthropic breaks if you change its thinking mode and it's using tools) - const featureName: FeatureName = 'Chat' - const modelSelection = this._settingsService.state.modelSelectionOfFeature[featureName] - const modelSelectionOptions = modelSelection ? this._settingsService.state.optionsOfModelSelection[modelSelection.providerName]?.[modelSelection.modelName] : undefined - - - // agent loop - const agentLoop = async () => { - - let shouldSendAnotherMessage = true - let nMessagesSent = 0 - - while (shouldSendAnotherMessage) { - // recompute files at last message - const selectionsStr = await chat_selectionsString(prevSelns, currSelns, this._voidFileService) // all the file CONTENTS or "selections" de-duped - const userMessageFullContent = chat_lastUserMessageWithFilesAdded(userMessageContent, selectionsStr) // full last message: user message + CONTENTS of all files - - shouldSendAnotherMessage = false // false by default - nMessagesSent += 1 - - let res_: () => void // resolves when user approves this tool use (or if tool doesn't require approval) - const awaitable = new Promise((res, rej) => { res_ = res }) - - // replace last userMessage with userMessageFullContent (which contains all the files too) - const messages_ = toLLMChatMessages(this.getCurrentThread().messages) - const lastUserMsgIdx = findLastIndex(messages_, m => m.role === 'user') - - if (lastUserMsgIdx === -1) throw new Error(`Void: No user message found.`) // should never be -1 - - const messages: LLMChatMessage[] = [ - { role: 'system', content: chat_systemMessage(this._workspaceContextService.getWorkspace().folders.map(f => f.uri.fsPath), chatMode), }, - ...messages_.slice(0, lastUserMsgIdx), - { role: 'user', content: userMessageFullContent }, - ...messages_.slice(lastUserMsgIdx + 1, Infinity), - ] - - - const llmCancelToken = this._llmMessageService.sendLLMMessage({ - messagesType: 'chatMessages', - messages, - tools: tools, - modelSelection, - modelSelectionOptions, - logging: { loggingName: `Agent` }, - onText: ({ fullText, fullReasoning }) => { - this._setStreamState(threadId, { messageSoFar: fullText, reasoningSoFar: fullReasoning }) - }, - onFinalMessage: async ({ fullText, toolCalls, fullReasoning, anthropicReasoning }) => { - - if ((toolCalls?.length ?? 0) === 0) { - this._addMessageToThread(threadId, { role: 'assistant', content: fullText, reasoning: fullReasoning, anthropicReasoning }) - this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined }) - } - else { - this._addMessageToThread(threadId, { role: 'assistant', content: fullText, reasoning: fullReasoning, anthropicReasoning }) - this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined }) // clear streaming message - - // deal with the tool - const tool: ToolCallType | undefined = toolCalls?.[0] - if (!tool) { - res_() - return - } - const toolName = tool.name - shouldSendAnotherMessage = true - - // 1. validate tool params - let toolParams: ToolCallParams[typeof toolName] - try { - const params = await this._toolsService.validateParams[toolName](tool.paramsStr) - toolParams = params - } catch (error) { - const errorMessage = getErrorMessage(error) - this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: errorMessage, result: { type: 'error', value: errorMessage }, }) - res_() - return - } - - // 2. if tool requires approval, await the approval - if (toolNamesThatRequireApproval.has(toolName)) { - const voidToolId = generateUuid() - const toolApprovalPromise = new Promise((res, rej) => { this.resRejOfToolAwaitingApproval[voidToolId] = { res, rej } }) - this._addMessageToThread(threadId, { role: 'tool_request', name: toolName, params: toolParams, voidToolId: voidToolId }) - try { - await toolApprovalPromise - // accepted tool - } - catch (e) { - // TODO!!! test rejection - // if (Math.random() > 0) throw new Error('TESTING') - const errorMessage = 'Tool call was rejected by the user.' - this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: errorMessage, result: { type: 'error', value: errorMessage }, }) - res_() - return - } - } - - // 3. call the tool - let toolResult: ToolResultType[typeof toolName] - try { - toolResult = await this._toolsService.callTool[toolName](toolParams as any) // typescript is so bad it doesn't even couple the type of ToolResult with the type of the function being called here - } catch (error) { - const errorMessage = getErrorMessage(error) - this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: errorMessage, result: { type: 'error', value: errorMessage }, }) - res_() - return - } - - // 4. stringify the result to give the LLM - let toolResultStr: string - try { - toolResultStr = this._toolsService.stringOfResult[toolName](toolParams as any, toolResult as any) - } catch (error) { - const errorMessage = `Tool call succeeded, but there was an error stringifying the output.\n${getErrorMessage(error)}` - this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: errorMessage, result: { type: 'error', value: errorMessage }, }) - res_() - return - } - - // 5. add to history - this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: toolResultStr, result: { type: 'success', params: toolParams, value: toolResult }, }) - res_() - } - - }, - onError: (error) => { - const messageSoFar = this.streamState[threadId]?.messageSoFar ?? '' - const reasoningSoFar = this.streamState[threadId]?.reasoningSoFar ?? '' - // add assistant's message to chat history, and clear selection - this._addMessageToThread(threadId, { role: 'assistant', content: messageSoFar, reasoning: reasoningSoFar, anthropicReasoning: null }) - this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, error }) - res_() - }, - }) - if (llmCancelToken === null) break - this._setStreamState(threadId, { streamingToken: llmCancelToken }) - - await awaitable - } - } - - agentLoop() - - } - - cancelStreaming(threadId: string) { - const llmCancelToken = this.streamState[threadId]?.streamingToken - if (llmCancelToken !== undefined) this._llmMessageService.abort(llmCancelToken) - const messageSoFar = this.streamState[threadId]?.messageSoFar ?? '' - const reasoningSoFar = this.streamState[threadId]?.reasoningSoFar ?? '' - this._addMessageToThread(threadId, { role: 'assistant', content: messageSoFar, reasoning: reasoningSoFar, anthropicReasoning: null }) - this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined }) + this._runChatAgent({ prevSelns, currSelns, threadId, userMessageContent, ...this._currentModelSelectionProps(), }) } dismissStreamError(threadId: string): void { - this._setStreamState(threadId, { error: undefined }) + this._setStreamState(threadId, { error: undefined }, 'merge') } // ---------- the rest ---------- - getCurrentThread(): ChatThreads[string] { + // gets the location of codespan link so the user can click on it + generateCodespanLink: IChatThreadService['generateCodespanLink'] = async ({ codespanStr: _codespanStr, threadId }) => { + + // process codespan to understand what we are searching for + // TODO account for more complicated patterns eg `ITextEditorService.openEditor()` + const functionOrMethodPattern = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; // `fUnCt10n_name` + const functionParensPattern = /^([^\s(]+)\([^)]*\)$/; // `functionName( args )` + + let target = _codespanStr // the string to search for + let codespanType: 'file-or-folder' | 'function-or-class' | 'unsearchable' = 'unsearchable'; + if (target.includes('.') || target.includes('/')) { + + codespanType = 'file-or-folder' + target = _codespanStr + + } else if (functionOrMethodPattern.test(target)) { + + codespanType = 'function-or-class' + target = _codespanStr + + } else if (functionParensPattern.test(target)) { + const match = target.match(functionParensPattern) + if (match && match[1]) { + + codespanType = 'function-or-class' + target = match[1] + + } + } + + if (codespanType === 'unsearchable') { + return null + } + + // get history of all AI and user added files in conversation + store in reverse order (MRU) + const prevUris = this._getAllSelections(threadId) + .map(s => s.fileURI) + .filter((uri, index, array) => array.findIndex(u => u.fsPath === uri.fsPath) === index) // O(n^2) but this is small + .reverse() + + + if (codespanType === 'file-or-folder') { + + + const doesUriMatchTarget = (uri: URI) => uri.path.includes(target) + + // check if any prevFiles are the `target` + for (const [idx, uri] of prevUris.entries()) { + if (doesUriMatchTarget(uri)) { + + // shorten it + + // TODO make this logic more general + const prevUriStrs = prevUris.map(uri => uri.fsPath) + const shortenedUriStrs = shorten(prevUriStrs) + let displayText = shortenedUriStrs[idx] + const ellipsisIdx = displayText.lastIndexOf('…/'); + if (ellipsisIdx >= 0) { + displayText = displayText.slice(ellipsisIdx + 2) + } + + return { uri, displayText } + } + } + + // else search codebase for `target` + let uris: URI[] = [] + try { + const { result } = await this._toolsService.callTool['pathname_search']({ queryStr: target, pageNumber: 0 }) + uris = result.uris + } catch (e) { + return null + } + + for (const [idx, uri] of uris.entries()) { + if (doesUriMatchTarget(uri)) { + + // TODO make this logic more general + const prevUriStrs = prevUris.map(uri => uri.fsPath) + const shortenedUriStrs = shorten(prevUriStrs) + let displayText = shortenedUriStrs[idx] + const ellipsisIdx = displayText.lastIndexOf('…/'); + if (ellipsisIdx >= 0) { + displayText = displayText.slice(ellipsisIdx + 2) + } + + + return { uri, displayText } + } + } + + } + + + if (codespanType === 'function-or-class') { + + + // check all prevUris for the target + for (const uri of prevUris) { + + const modelRef = await this._voidModelService.getModelSafe(uri) + const { model } = modelRef + if (!model) continue + + const matches = model.findMatches( + target, + false, // searchOnlyEditableRange + false, // isRegex + true, // matchCase + ' ', // wordSeparators + true // captureMatches + ); + + const firstThree = matches.slice(0, 3); + + // take first 3 occurences, attempt to goto definition on them + for (const match of firstThree) { + const position = new Position(match.range.startLineNumber, match.range.startColumn); + const definitionProviders = this._languageFeaturesService.definitionProvider.ordered(model); + + for (const provider of definitionProviders) { + + const _definitions = await provider.provideDefinition(model, position, CancellationToken.None); + + if (!_definitions) continue; + + const definitions = Array.isArray(_definitions) ? _definitions : [_definitions]; + + for (const definition of definitions) { + + return { + uri: definition.uri, + selection: { + startLineNumber: definition.range.startLineNumber, + startColumn: definition.range.startColumn, + endLineNumber: definition.range.endLineNumber, + endColumn: definition.range.endColumn, + }, + displayText: _codespanStr, + }; + + // const defModelRef = await this._textModelService.createModelReference(definition.uri); + // const defModel = defModelRef.object.textEditorModel; + + // try { + // const symbolProviders = this._languageFeaturesService.documentSymbolProvider.ordered(defModel); + + // for (const symbolProvider of symbolProviders) { + // const symbols = await symbolProvider.provideDocumentSymbols( + // defModel, + // CancellationToken.None + // ); + + // if (symbols) { + // const symbol = symbols.find(s => { + // const symbolRange = s.range; + // return symbolRange.startLineNumber <= definition.range.startLineNumber && + // symbolRange.endLineNumber >= definition.range.endLineNumber && + // (symbolRange.startLineNumber !== definition.range.startLineNumber || symbolRange.startColumn <= definition.range.startColumn) && + // (symbolRange.endLineNumber !== definition.range.endLineNumber || symbolRange.endColumn >= definition.range.endColumn); + // }); + + // // if we got to a class/function get the full range and return + // if (symbol?.kind === SymbolKind.Function || symbol?.kind === SymbolKind.Method || symbol?.kind === SymbolKind.Class) { + // return { + // uri: definition.uri, + // selection: { + // startLineNumber: definition.range.startLineNumber, + // startColumn: definition.range.startColumn, + // endLineNumber: definition.range.endLineNumber, + // endColumn: definition.range.endColumn, + // } + // }; + // } + // } + // } + // } finally { + // defModelRef.dispose(); + // } + } + } + } + } + + // unlike above do not search codebase (doesnt make sense) + + } + + return null + + } + + getCodespanLink({ codespanStr, messageIdx, threadId }: { codespanStr: string, messageIdx: number, threadId: string }): CodespanLocationLink | undefined { + const thread = this.state.allThreads[threadId] + if (!thread) return undefined; + + const links = thread.state.linksOfMessageIdx?.[messageIdx] + if (!links) return undefined; + + const link = links[codespanStr] + + return link + } + + async addCodespanLink({ newLinkText, newLinkLocation, messageIdx, threadId }: { newLinkText: string, newLinkLocation: CodespanLocationLink, messageIdx: number, threadId: string }) { + const thread = this.state.allThreads[threadId] + if (!thread) return + + this._setState({ + + allThreads: { + ...this.state.allThreads, + [threadId]: { + ...thread, + state: { + ...thread.state, + linksOfMessageIdx: { + ...thread.state.linksOfMessageIdx, + [messageIdx]: { + ...thread.state.linksOfMessageIdx?.[messageIdx], + [newLinkText]: newLinkLocation + } + } + } + + } + } + }, true) + } + + + getCurrentThread(): ThreadType { const state = this.state const thread = state.allThreads[state.currentThreadId] + if (!thread) throw new Error(`Current thread should never be undefined`) return thread } - getFocusedMessageIdx() { + getCurrentFocusedMessageIdx() { const thread = this.getCurrentThread() // get the focusedMessageIdx @@ -572,8 +1230,8 @@ class ChatThreadService extends Disposable implements IChatThreadService { return focusedMessageIdx } - isFocusingMessage() { - return this.getFocusedMessageIdx() !== undefined + isCurrentlyFocusingMessage() { + return this.getCurrentFocusedMessageIdx() !== undefined } switchToThread(threadId: string) { @@ -585,9 +1243,17 @@ class ChatThreadService extends Disposable implements IChatThreadService { // if a thread with 0 messages already exists, switch to it const { allThreads: currentThreads } = this.state for (const threadId in currentThreads) { - if (currentThreads[threadId].messages.length === 0) { + if (currentThreads[threadId]!.messages.length === 0) { + + // switch to the thread this.switchToThread(threadId) - return + + // add the current file as a staging selection + const model = this._codeEditorService.getActiveCodeEditor()?.getModel() + if (model) { + this._setCurrentThreadState({ ...defaultThreadState, stagingSelections: [{ type: 'File', fileURI: model.uri, language: model.getLanguageId(), selectionStr: null, range: null, state: { isOpened: false, wasAddedAsCurrentFile: true } }] }) + } + return; } } // otherwise, start a new thread @@ -607,6 +1273,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { const { allThreads } = this.state const oldThread = allThreads[threadId] + if (!oldThread) return // should never happen // update state and store it const newThreads = { @@ -622,7 +1289,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { } // sets the currently selected message (must be undefined if no message is selected) - setFocusedMessageIdx(messageIdx: number | undefined) { + setCurrentlyFocusedMessageIdx(messageIdx: number | undefined) { const threadId = this.state.currentThreadId const thread = this.state.allThreads[threadId] @@ -692,7 +1359,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { } - closeStagingSelectionsInCurrentThread = () => { + closeCurrentStagingSelectionsInThread = () => { const currThread = this.getCurrentThreadState() // close all stagingSelections @@ -705,7 +1372,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { } - closeStagingSelectionsInMessage = (messageIdx: number) => { + closeCurrentStagingSelectionsInMessage: IChatThreadService['closeCurrentStagingSelectionsInMessage'] = ({ messageIdx }) => { const currMessage = this.getCurrentMessageState(messageIdx) // close all stagingSelections @@ -724,7 +1391,6 @@ class ChatThreadService extends Disposable implements IChatThreadService { const currentThread = this.getCurrentThread() return currentThread.state } - setCurrentThreadState = (newState: Partial) => { this._setCurrentThreadState(newState) } diff --git a/src/vs/workbench/contrib/void/browser/editCodeService.ts b/src/vs/workbench/contrib/void/browser/editCodeService.ts index 9ef06ede..ccf5484e 100644 --- a/src/vs/workbench/contrib/void/browser/editCodeService.ts +++ b/src/vs/workbench/contrib/void/browser/editCodeService.ts @@ -6,7 +6,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { ICodeEditor, IOverlayWidget, IViewZone, OverlayWidgetPositionPreference } from '../../../../editor/browser/editorBrowser.js'; +import { ICodeEditor, IOverlayWidget, IViewZone } from '../../../../editor/browser/editorBrowser.js'; // import { IUndoRedoService } from '../../../../platform/undoRedo/common/undoRedo.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; @@ -25,13 +25,12 @@ 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 { voidPrefixAndSuffix, ctrlKStream_userMessage, ctrlKStream_systemMessage, defaultQuickEditFimTags, rewriteCode_systemMessage, rewriteCode_userMessage, searchReplace_systemMessage, searchReplace_userMessage, } from './prompt/prompts.js'; +import { voidPrefixAndSuffix, ctrlKStream_userMessage, ctrlKStream_systemMessage, defaultQuickEditFimTags, rewriteCode_systemMessage, rewriteCode_userMessage, searchReplace_systemMessage, searchReplace_userMessage, } from '../common/prompt/prompts.js'; import { mountCtrlK } from './react/out/quick-edit-tsx/index.js' import { QuickEditPropsType } from './quickEditActions.js'; import { IModelContentChangedEvent } from '../../../../editor/common/textModelEvents.js'; -import { extractCodeFromFIM, extractCodeFromRegular, ExtractedSearchReplaceBlock, extractSearchReplaceBlocks } from './helpers/extractCodeFromResult.js'; -import { filenameToVscodeLanguage } from '../common/helpers/detectLanguage.js'; +import { extractCodeFromFIM, extractCodeFromRegular, ExtractedSearchReplaceBlock, extractSearchReplaceBlocks } from '../common/helpers/extractCodeFromResult.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { isMacintosh } from '../../../../base/common/platform.js'; import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; @@ -41,19 +40,22 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j import { ILLMMessageService } from '../common/sendLLMMessageService.js'; import { LLMChatMessage, OnError, errorDetails } from '../common/sendLLMMessageTypes.js'; import { IMetricsService } from '../common/metricsService.js'; -import { IVoidFileService } from '../common/voidFileService.js'; -import { IEditCodeService, URIStreamState, AddCtrlKOpts, StartApplyingOpts } from './editCodeServiceInterface.js'; +import { IEditCodeService, AddCtrlKOpts, StartApplyingOpts } from './editCodeServiceInterface.js'; import { IVoidSettingsService } from '../common/voidSettingsService.js'; import { FeatureName } from '../common/voidSettingsTypes.js'; +import { IVoidModelService } from '../common/voidModelService.js'; +import { ITextFileService } from '../../../services/textfile/common/textfiles.js'; +import { deepClone } from '../../../../base/common/objects.js'; +import { acceptBg, acceptBorder, buttonFontSize, buttonTextColor, rejectBg, rejectBorder } from '../common/helpers/colors.js'; const configOfBG = (color: Color) => { return { dark: color, light: color, hcDark: color, hcLight: color, } } // gets converted to --vscode-void-greenBG, see void.css, asCssVariable -const greenBG = new Color(new RGBA(155, 185, 85, .3)); // default is RGBA(155, 185, 85, .2) +const greenBG = new Color(new RGBA(155, 185, 85, .2)); // default is RGBA(155, 185, 85, .2) registerColor('void.greenBG', configOfBG(greenBG), '', true); -const redBG = new Color(new RGBA(255, 0, 0, .3)); // default is RGBA(255, 0, 0, .2) +const redBG = new Color(new RGBA(255, 0, 0, .2)); // default is RGBA(255, 0, 0, .2) registerColor('void.redBG', configOfBG(redBG), '', true); const sweepBG = new Color(new RGBA(100, 100, 100, .2)); @@ -67,7 +69,7 @@ registerColor('void.sweepIdxBG', configOfBG(sweepIdxBG), '', true); - +const numLinesOfStr = (str: string) => str.split('\n').length const getLeadingWhitespacePx = (editor: ICodeEditor, startLine: number): number => { @@ -118,7 +120,7 @@ const findTextInCode = (text: string, fileContents: string, startingAtLine?: num const lastIdx = fileContents.lastIndexOf(text) if (lastIdx !== idx) return 'Not unique' as const const startLine = fileContents.substring(0, idx).split('\n').length - const numLines = text.split('\n').length + const numLines = numLinesOfStr(text) const endLine = startLine + numLines - 1 return [startLine, endLine] as const } @@ -174,7 +176,7 @@ type CtrlKZone = { } & CommonZoneProps -type DiffZone = { +export type DiffZone = { type: 'DiffZone', originalCode: string; _diffOfId: Record; // diffid -> diff in this DiffArea @@ -204,7 +206,7 @@ type TrackingZone = { // called DiffArea for historical purposes, we can rename to something like TextRegion if we want -type DiffArea = CtrlKZone | DiffZone | TrackingZone +export type DiffArea = CtrlKZone | DiffZone | TrackingZone const diffAreaSnapshotKeys = [ 'type', @@ -235,31 +237,34 @@ type StreamLocationMutable = { line: number, col: number, addedSplitYet: boolean class EditCodeService extends Disposable implements IEditCodeService { _serviceBrand: undefined; - // URI <--> model - diffAreasOfURI: Record> = {} + diffAreasOfURI: Record | undefined> = {}; // uri -> diffareaId - diffAreaOfId: Record = {}; - diffOfId: Record = {}; // redundant with diffArea._diffs + diffAreaOfId: Record = {}; // diffareaId -> diffArea + diffOfId: Record = {}; // diffid -> diff (redundant with diffArea._diffOfId) - // only applies to diffZones - // streamingDiffZones: Set = new Set() - private readonly _onDidChangeDiffZoneStreaming = new Emitter<{ uri: URI; diffareaid: number }>(); + // events + + + // uri: diffZones // listen on change diffZones private readonly _onDidAddOrDeleteDiffZones = new Emitter<{ uri: URI }>(); + onDidAddOrDeleteDiffZones = this._onDidAddOrDeleteDiffZones.event; - private readonly _onDidChangeCtrlKZoneStreaming = new Emitter<{ uri: URI; diffareaid: number }>(); - onDidChangeCtrlKZoneStreaming = this._onDidChangeCtrlKZoneStreaming.event - - private readonly _onDidChangeURIStreamState = new Emitter<{ uri: URI; state: URIStreamState }>(); - onDidChangeURIStreamState = this._onDidChangeURIStreamState.event - + // diffZone: [uri], diffs, isStreaming // listen on change diffs, change streaming (uri is const) + private readonly _onDidChangeDiffsInDiffZone = new Emitter<{ uri: URI, diffareaid: number }>(); + private readonly _onDidChangeStreamingInDiffZone = new Emitter<{ uri: URI, diffareaid: number }>(); + onDidChangeDiffsInDiffZone = this._onDidChangeDiffsInDiffZone.event; + onDidChangeStreamingInDiffZone = this._onDidChangeStreamingInDiffZone.event; + // ctrlKZone: [uri], isStreaming // listen on change streaming + private readonly _onDidChangeStreamingInCtrlKZone = new Emitter<{ uri: URI; diffareaid: number }>(); + onDidChangeStreamingInCtrlKZone = this._onDidChangeStreamingInCtrlKZone.event constructor( // @IHistoryService private readonly _historyService: IHistoryService, // history service is the history of pressing alt left/right - @ICodeEditorService private readonly _editorService: ICodeEditorService, + @ICodeEditorService private readonly _codeEditorService: ICodeEditorService, @IModelService private readonly _modelService: IModelService, @IUndoRedoService private readonly _undoRedoService: IUndoRedoService, // undoRedo service is the history of pressing ctrl+z @ILLMMessageService private readonly _llmMessageService: ILLMMessageService, @@ -269,17 +274,26 @@ class EditCodeService extends Disposable implements IEditCodeService { @IMetricsService private readonly _metricsService: IMetricsService, @INotificationService private readonly _notificationService: INotificationService, @ICommandService private readonly _commandService: ICommandService, - @IVoidFileService private readonly _voidFileService: IVoidFileService, @IVoidSettingsService private readonly _settingsService: IVoidSettingsService, + // @IFileService private readonly _fileService: IFileService, + @IVoidModelService private readonly _voidModelService: IVoidModelService, + @ITextFileService private readonly _textFileService: ITextFileService, ) { super(); // this function initializes data structures and listens for changes - const initializeModel = (model: ITextModel) => { + const registeredModelURIs = new Set() + const initializeModel = async (model: ITextModel) => { + + await this._voidModelService.initializeModel(model.uri) + + // do not add listeners to the same model twice - important, or will see duplicates + if (registeredModelURIs.has(model.uri.fsPath)) return + registeredModelURIs.add(model.uri.fsPath) + if (!(model.uri.fsPath in this.diffAreasOfURI)) { this.diffAreasOfURI[model.uri.fsPath] = new Set(); } - else return // do not add listeners to the same model twice - important, or will see duplicates // when the user types, realign diff areas and re-render them this._register( @@ -291,36 +305,12 @@ class EditCodeService extends Disposable implements IEditCodeService { }) ) - // when a stream starts or ends, fire the event for onDidChangeURIStreamState - let prevStreamState = this.getURIStreamState({ uri: model.uri }) - const updateAcceptRejectAllUI = () => { - const state = this.getURIStreamState({ uri: model.uri }) - let prevStateActual = prevStreamState - prevStreamState = state - if (state === prevStateActual) return - this._onDidChangeURIStreamState.fire({ uri: model.uri, state }) - } - - - let _removeAcceptRejectAllUI: (() => void) | null = null - this._register(this._onDidChangeURIStreamState.event(({ uri, state }) => { - if (uri.fsPath !== model.uri.fsPath) return - if (state === 'acceptRejectAll') { - if (!_removeAcceptRejectAllUI) - _removeAcceptRejectAllUI = this._addAcceptRejectAllUI(model.uri) ?? null - } else { - _removeAcceptRejectAllUI?.() - _removeAcceptRejectAllUI = null - } - })) - this._register(this._onDidChangeDiffZoneStreaming.event(({ uri: uri_ }) => { if (uri_.fsPath === model.uri.fsPath) updateAcceptRejectAllUI() })) - this._register(this._onDidAddOrDeleteDiffZones.event(({ uri: uri_ }) => { if (uri_.fsPath === model.uri.fsPath) updateAcceptRejectAllUI() })) - - + // when the model first mounts, refresh any diffs that might be on it (happens if diffs were added in the BG) + this._refreshStylesAndDiffsInURI(model.uri) } // initialize all existing models + initialize when a new model mounts for (let model of this._modelService.getModels()) { initializeModel(model) } - this._register(this._modelService.onModelAdded(model => initializeModel(model))); + this._register(this._modelService.onModelAdded(model => { initializeModel(model) })); // this function adds listeners to refresh styles when editor changes tab @@ -328,9 +318,11 @@ class EditCodeService extends Disposable implements IEditCodeService { const uri = editor.getModel()?.uri ?? null if (uri) this._refreshStylesAndDiffsInURI(uri) } + // add listeners for all existing editors + listen for editor being added - for (let editor of this._editorService.listCodeEditors()) { initializeEditor(editor) } - this._register(this._editorService.onCodeEditorAdd(editor => { initializeEditor(editor) })) + for (let editor of this._codeEditorService.listCodeEditors()) { initializeEditor(editor) } + this._register(this._codeEditorService.onCodeEditorAdd(editor => { initializeEditor(editor) })) + } @@ -342,16 +334,6 @@ class EditCodeService extends Disposable implements IEditCodeService { this._refreshStylesAndDiffsInURI(uri) } - private _onInternalChangeContent(uri: URI, { shouldRealign }: { shouldRealign: false | { newText: string, oldRange: IRange } }) { - if (shouldRealign) { - const { newText, oldRange } = shouldRealign - // console.log('realiging', newText, oldRange) - this._realignAllDiffAreasLines(uri, newText, oldRange) - } - this._refreshStylesAndDiffsInURI(uri) - - } - @@ -395,7 +377,7 @@ class EditCodeService extends Disposable implements IEditCodeService { private _addDiffAreaStylesToURI = (uri: URI) => { - const model = this._getModel(uri) + const { model } = this._voidModelService.getModel(uri) for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { const diffArea = this.diffAreaOfId[diffareaid] @@ -424,7 +406,9 @@ class EditCodeService extends Disposable implements IEditCodeService { private _computeDiffsAndAddStylesToURI = (uri: URI) => { - const fullFileText = this._readURI(uri) ?? '' + const { model } = this._voidModelService.getModel(uri) + if (model === null) return + const fullFileText = model.getValue(EndOfLinePreference.LF) for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { const diffArea = this.diffAreaOfId[diffareaid] @@ -446,46 +430,13 @@ class EditCodeService extends Disposable implements IEditCodeService { } } - private _addAcceptRejectAllUI(uri: URI) { - - // find all diffzones that aren't streaming - const diffZones: DiffZone[] = [] - for (let diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { - const diffArea = this.diffAreaOfId[diffareaid] - if (diffArea.type !== 'DiffZone') continue - if (diffArea._streamState.isStreaming) continue - diffZones.push(diffArea) - } - if (diffZones.length === 0) return - - const consistentItemId = this._consistentItemService.addConsistentItemToURI({ - uri, - fn: (editor) => { - const buttonsWidget = new AcceptAllRejectAllWidget({ - editor, - onAcceptAll: () => { - this.removeDiffAreas({ uri, behavior: 'accept', removeCtrlKs: false }) - this._metricsService.capture('Accept All', {}) - }, - onRejectAll: () => { - this.removeDiffAreas({ uri, behavior: 'reject', removeCtrlKs: false }) - this._metricsService.capture('Reject All', {}) - }, - }) - return () => { buttonsWidget.dispose() } - } - }) - - - return () => { this._consistentItemService.removeConsistentItemFromURI(consistentItemId) } - } mostRecentTextOfCtrlKZoneId: Record = {} private _addCtrlKZoneInput = (ctrlKZone: CtrlKZone) => { const { editorId } = ctrlKZone - const editor = this._editorService.listCodeEditors().find(e => e.getId() === editorId) + const editor = this._codeEditorService.listCodeEditors().find(e => e.getId() === editorId) if (!editor) { return null } let zoneId: string | null = null @@ -515,8 +466,9 @@ class EditCodeService extends Disposable implements IEditCodeService { }) // mount react + let disposeFn: (() => void) | undefined = undefined this._instantiationService.invokeFunction(accessor => { - mountCtrlK(domNode, accessor, { + disposeFn = mountCtrlK(domNode, accessor, { diffareaid: ctrlKZone.diffareaid, @@ -541,14 +493,14 @@ class EditCodeService extends Disposable implements IEditCodeService { this.mostRecentTextOfCtrlKZoneId[ctrlKZone.diffareaid] = text; }, initText: this.mostRecentTextOfCtrlKZoneId[ctrlKZone.diffareaid] ?? null, - } satisfies QuickEditPropsType) - + } satisfies QuickEditPropsType)?.dispose }) - return () => editor.changeViewZones(accessor => { - if (zoneId) - accessor.removeZone(zoneId) - }) + // cleanup + return () => { + editor.changeViewZones(accessor => { if (zoneId) accessor.removeZone(zoneId) }) + disposeFn?.() + } }) return { @@ -573,7 +525,7 @@ class EditCodeService extends Disposable implements IEditCodeService { if (diffArea.type !== 'CtrlKZone') continue if (!diffArea._mountInfo) { diffArea._mountInfo = this._addCtrlKZoneInput(diffArea) - // console.log('MOUNTED', diffArea.diffareaid) + console.log('MOUNTED CTRLK', diffArea.diffareaid) } else { diffArea._mountInfo.refresh() @@ -587,7 +539,7 @@ class EditCodeService extends Disposable implements IEditCodeService { const disposeInThisEditorFns: (() => void)[] = [] - const model = this._modelService.getModel(uri) + const { model } = this._voidModelService.getModel(uri) // green decoration and minimap decoration if (type !== 'deletion') { @@ -697,15 +649,15 @@ class EditCodeService extends Disposable implements IEditCodeService { } else { throw new Error('Void 1') } - const buttonsWidget = new AcceptRejectWidget({ + const buttonsWidget = new AcceptRejectInlineWidget({ editor, onAccept: () => { this.acceptDiff({ diffid }) - this._metricsService.capture('Accept Diff', {}) + this._metricsService.capture('Accept Diff', { diffid }) }, onReject: () => { this.rejectDiff({ diffid }) - this._metricsService.capture('Reject Diff', {}) + this._metricsService.capture('Reject Diff', { diffid }) }, diffid: diffid.toString(), startLine, @@ -724,22 +676,9 @@ class EditCodeService extends Disposable implements IEditCodeService { - private _getModel(uri: URI) { - const model = this._modelService.getModel(uri) - if (!model || model.isDisposed()) { - return null - } - return model - } - private _readURI(uri: URI, range?: IRange): string | null { - if (!range) return this._getModel(uri)?.getValue(EndOfLinePreference.LF) ?? null - else return this._getModel(uri)?.getValueInRange(range, EndOfLinePreference.LF) ?? null - } - private _getNumLines(uri: URI): number | null { - return this._getModel(uri)?.getLineCount() ?? null - } + private _getActiveEditorURI(): URI | null { - const editor = this._editorService.getActiveCodeEditor() + const editor = this._codeEditorService.getActiveCodeEditor() if (!editor) return null const uri = editor.getModel()?.uri if (!uri) return null @@ -747,38 +686,47 @@ class EditCodeService extends Disposable implements IEditCodeService { } weAreWriting = false - private _writeText(uri: URI, text: string, range: IRange, { shouldRealignDiffAreas }: { shouldRealignDiffAreas: boolean }) { - const model = this._getModel(uri) - if (!model) return - const uriStr = this._readURI(uri, range) - if (uriStr === null) return - - - // heuristic check if don't need to make edits - const dontNeedToWrite = uriStr === text - if (dontNeedToWrite) { - // at the end of a write, we still expect to refresh all styles - // e.g. sometimes we expect to restore all the decorations even if no edits were made when _writeText is used - this._refreshStylesAndDiffsInURI(uri) + private _writeURIText(uri: URI, text: string, range_: IRange | 'wholeFileRange', { shouldRealignDiffAreas, }: { shouldRealignDiffAreas: boolean, }) { + const { model } = this._voidModelService.getModel(uri) + if (!model) { + this._refreshStylesAndDiffsInURI(uri) // at the end of a write, we still expect to refresh all styles. e.g. sometimes we expect to restore all the decorations even if no edits were made when _writeText is used + return + } + + const range: IRange = range_ === 'wholeFileRange' ? + { startLineNumber: 1, startColumn: 1, endLineNumber: model.getLineCount(), endColumn: Number.MAX_SAFE_INTEGER } // whole file + : range_ + + // realign is 100% independent from written text (diffareas are nonphysical), can do this first + if (shouldRealignDiffAreas) { + const newText = text + const oldRange = range + this._realignAllDiffAreasLines(uri, newText, oldRange) + } + + const uriStr = model.getValue(EndOfLinePreference.LF) + + // heuristic check + const dontNeedToWrite = uriStr === text + if (dontNeedToWrite) { + this._refreshStylesAndDiffsInURI(uri) // at the end of a write, we still expect to refresh all styles. e.g. sometimes we expect to restore all the decorations even if no edits were made when _writeText is used return } - // minimal edits so not so flashy - // const edits = this.worker.$Void_computeMoreMinimalEdits(uri.toString(), [{ range, text }], false) this.weAreWriting = true model.applyEdits([{ range, text }]) this.weAreWriting = false - this._onInternalChangeContent(uri, { shouldRealign: shouldRealignDiffAreas && { newText: text, oldRange: range } }) - + this._refreshStylesAndDiffsInURI(uri) } - - private _addToHistory(uri: URI, opts?: { onUndo?: () => void }) { + private _addToHistory(uri: URI, opts?: { onWillUndo?: () => void }) { const getCurrentSnapshot = (): HistorySnapshot => { + + const { model } = this._voidModelService.getModel(uri) const snapshottedDiffAreaOfId: Record = {} for (const diffareaid in this.diffAreaOfId) { @@ -786,17 +734,21 @@ class EditCodeService extends Disposable implements IEditCodeService { if (diffArea._URI.fsPath !== uri.fsPath) continue - snapshottedDiffAreaOfId[diffareaid] = structuredClone( // a structured clone must be on a JSON object + snapshottedDiffAreaOfId[diffareaid] = deepClone( Object.fromEntries(diffAreaSnapshotKeys.map(key => [key, diffArea[key]])) ) as DiffAreaSnapshot } + + const entireFileCode = model ? model.getValue(EndOfLinePreference.LF) : '' + + // this._noLongerNeedModelReference(uri) return { snapshottedDiffAreaOfId, - entireFileCode: this._readURI(uri) ?? '', // the whole file's code + entireFileCode, // the whole file's code } } - const restoreDiffAreas = (snapshot: HistorySnapshot) => { + const restoreDiffAreas = async (snapshot: HistorySnapshot) => { // for each diffarea in this uri, stop streaming if currently streaming for (const diffareaid in this.diffAreaOfId) { @@ -807,9 +759,9 @@ class EditCodeService extends Disposable implements IEditCodeService { // delete all diffareas on this uri (clearing their styles) this._deleteAllDiffAreas(uri) - this.diffAreasOfURI[uri.fsPath].clear() + this.diffAreasOfURI[uri.fsPath]?.clear() - const { snapshottedDiffAreaOfId, entireFileCode: entireModelCode } = structuredClone(snapshot) // don't want to destroy the snapshot + const { snapshottedDiffAreaOfId, entireFileCode: entireModelCode } = deepClone(snapshot) // don't want to destroy the snapshot // restore diffAreaOfId and diffAreasOfModelId for (const diffareaid in snapshottedDiffAreaOfId) { @@ -835,19 +787,16 @@ class EditCodeService extends Disposable implements IEditCodeService { _linkedStreamingDiffZone: null, // when restoring, we will never be streaming } } - this.diffAreasOfURI[uri.fsPath].add(diffareaid) + this._addOrInitializeDiffAreaAtURI(uri, diffareaid) } this._onDidAddOrDeleteDiffZones.fire({ uri }) // restore file content - const numLines = this._getNumLines(uri) - if (numLines === null) return - - - this._writeText(uri, entireModelCode, - { startColumn: 1, startLineNumber: 1, endLineNumber: numLines, endColumn: Number.MAX_SAFE_INTEGER }, + this._writeURIText(uri, entireModelCode, + 'wholeFileRange', { shouldRealignDiffAreas: false } ) + // this._noLongerNeedModelReference(uri) } const beforeSnapshot: HistorySnapshot = getCurrentSnapshot() @@ -856,14 +805,19 @@ class EditCodeService extends Disposable implements IEditCodeService { const elt: IUndoRedoElement = { type: UndoRedoElementType.Resource, resource: uri, - label: 'Void Changes', + label: 'Void Agent', code: 'undoredo.editCode', - undo: () => { restoreDiffAreas(beforeSnapshot); opts?.onUndo?.() }, + undo: () => { opts?.onWillUndo?.(); restoreDiffAreas(beforeSnapshot); }, redo: () => { if (afterSnapshot) restoreDiffAreas(afterSnapshot) } } this._undoRedoService.pushElement(elt) - const onFinishEdit = () => { afterSnapshot = getCurrentSnapshot() } + const onFinishEdit = async () => { + afterSnapshot = getCurrentSnapshot() + await this._textFileService.save(uri, { // we want [our change] -> [save] so it's all treated as one change. + skipSaveParticipants: true // avoid triggering extensions etc (if they reformat the page, it will add another item to the undo stack) + }) + } return { onFinishEdit } } @@ -906,26 +860,26 @@ class EditCodeService extends Disposable implements IEditCodeService { private _deleteDiffZone(diffZone: DiffZone) { this._clearAllDiffAreaEffects(diffZone) delete this.diffAreaOfId[diffZone.diffareaid] - this.diffAreasOfURI[diffZone._URI.fsPath].delete(diffZone.diffareaid.toString()) + this.diffAreasOfURI[diffZone._URI.fsPath]?.delete(diffZone.diffareaid.toString()) this._onDidAddOrDeleteDiffZones.fire({ uri: diffZone._URI }) } private _deleteTrackingZone(trackingZone: TrackingZone) { delete this.diffAreaOfId[trackingZone.diffareaid] - this.diffAreasOfURI[trackingZone._URI.fsPath].delete(trackingZone.diffareaid.toString()) + this.diffAreasOfURI[trackingZone._URI.fsPath]?.delete(trackingZone.diffareaid.toString()) } private _deleteCtrlKZone(ctrlKZone: CtrlKZone) { this._clearAllEffects(ctrlKZone._URI) ctrlKZone._mountInfo?.dispose() delete this.diffAreaOfId[ctrlKZone.diffareaid] - this.diffAreasOfURI[ctrlKZone._URI.fsPath].delete(ctrlKZone.diffareaid.toString()) + this.diffAreasOfURI[ctrlKZone._URI.fsPath]?.delete(ctrlKZone.diffareaid.toString()) } private _deleteAllDiffAreas(uri: URI) { const diffAreas = this.diffAreasOfURI[uri.fsPath] - diffAreas.forEach(diffareaid => { + diffAreas?.forEach(diffareaid => { const diffArea = this.diffAreaOfId[diffareaid] if (diffArea.type === 'DiffZone') this._deleteDiffZone(diffArea) @@ -934,13 +888,16 @@ class EditCodeService extends Disposable implements IEditCodeService { }) } - + private _addOrInitializeDiffAreaAtURI = (uri: URI, diffareaid: string | number) => { + if (!(uri.fsPath in this.diffAreasOfURI)) this.diffAreasOfURI[uri.fsPath] = new Set() + this.diffAreasOfURI[uri.fsPath]?.add(diffareaid.toString()) + } private _diffareaidPool = 0 // each diffarea has an id private _addDiffArea(diffArea: Omit): T { const diffareaid = this._diffareaidPool++ const diffArea2 = { ...diffArea, diffareaid } as T - this.diffAreasOfURI[diffArea2._URI.fsPath].add(diffareaid.toString()) + this._addOrInitializeDiffAreaAtURI(diffArea._URI, diffareaid) this.diffAreaOfId[diffareaid] = diffArea2 return diffArea2 } @@ -958,7 +915,7 @@ class EditCodeService extends Disposable implements IEditCodeService { } const fn = this._addDiffStylesToURI(uri, newDiff) - diffZone._removeStylesFns.add(fn) + if (fn) diffZone._removeStylesFns.add(fn) this.diffOfId[diffid] = newDiff diffZone._diffOfId[diffid] = newDiff @@ -974,9 +931,6 @@ class EditCodeService extends Disposable implements IEditCodeService { // console.log('recent change', recentChange) - const model = this._getModel(uri) - if (!model) return - // compute net number of newlines lines that were added/removed const startLine = recentChange.startLineNumber const endLine = recentChange.endLineNumber @@ -984,7 +938,7 @@ class EditCodeService extends Disposable implements IEditCodeService { const newTextHeight = (text.match(/\n/g) || []).length + 1 // number of newlines is number of \n's + 1, e.g. "ab\ncd" // compute overlap with each diffArea and shrink/elongate each diffArea accordingly - for (const diffareaid of this.diffAreasOfURI[model.uri.fsPath] || []) { + for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { const diffArea = this.diffAreaOfId[diffareaid] // if the diffArea is entirely above the range, it is not affected @@ -1033,6 +987,19 @@ class EditCodeService extends Disposable implements IEditCodeService { } + + private _fireChangeDiffsIfNotStreaming(uri: URI) { + for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { + const diffArea = this.diffAreaOfId[diffareaid] + if (diffArea?.type !== 'DiffZone') continue + // fire changed diffs (this is the only place Diffs are added) + if (!diffArea._streamState.isStreaming) { + this._onDidChangeDiffsInDiffZone.fire({ uri, diffareaid: diffArea.diffareaid }) + } + } + } + + private _refreshStylesAndDiffsInURI(uri: URI) { // 1. clear DiffArea styles and Diffs @@ -1046,6 +1013,9 @@ class EditCodeService extends Disposable implements IEditCodeService { // 4. refresh ctrlK zones this._refreshCtrlKInputs(uri) + + // 5. this is the only place where diffs are changed, so can fire here only + this._fireChangeDiffsIfNotStreaming(uri) } @@ -1085,7 +1055,7 @@ class EditCodeService extends Disposable implements IEditCodeService { // at the start, add a newline between the stream and originalCode to make reasoning easier if (!latestMutable.addedSplitYet) { - this._writeText(uri, '\n', + this._writeURIText(uri, '\n', { startLineNumber: latestMutable.line, startColumn: latestMutable.col, endLineNumber: latestMutable.line, endColumn: latestMutable.col, }, { shouldRealignDiffAreas: true } ) @@ -1094,7 +1064,7 @@ class EditCodeService extends Disposable implements IEditCodeService { } // insert deltaText at latest line and col - this._writeText(uri, deltaText, + this._writeURIText(uri, deltaText, { startLineNumber: latestMutable.line, startColumn: latestMutable.col, endLineNumber: latestMutable.line, endColumn: latestMutable.col }, { shouldRealignDiffAreas: true } ) @@ -1108,7 +1078,7 @@ class EditCodeService extends Disposable implements IEditCodeService { if (latestMutable.originalCodeStartLine < startLineInOriginalCode) { // moved up, delete const numLinesDeleted = startLineInOriginalCode - latestMutable.originalCodeStartLine - this._writeText(uri, '', + this._writeURIText(uri, '', { startLineNumber: latestMutable.line, startColumn: latestMutable.col, endLineNumber: latestMutable.line + numLinesDeleted, endColumn: Number.MAX_SAFE_INTEGER, }, { shouldRealignDiffAreas: true } ) @@ -1116,7 +1086,7 @@ class EditCodeService extends Disposable implements IEditCodeService { } else if (latestMutable.originalCodeStartLine > startLineInOriginalCode) { const newText = '\n' + originalCode.split('\n').slice((startLineInOriginalCode - 1), (latestMutable.originalCodeStartLine - 1) - 1 + 1).join('\n') - this._writeText(uri, newText, + this._writeURIText(uri, newText, { startLineNumber: latestMutable.line, startColumn: latestMutable.col, endLineNumber: latestMutable.line, endColumn: latestMutable.col }, { shouldRealignDiffAreas: true } ) @@ -1133,9 +1103,13 @@ class EditCodeService extends Disposable implements IEditCodeService { // called first, then call startApplying public addCtrlKZone({ startLine, endLine, editor }: AddCtrlKOpts) { + // don't need to await this, because in order to add a ctrl+K zone must already have the model open on your screen + // await this._ensureModelExists(uri) + const uri = editor.getModel()?.uri if (!uri) return + // check if there's overlap with any other ctrlKZone and if so, focus it const overlappingCtrlKZone = this._findOverlappingDiffArea({ startLine, endLine, uri, filter: (diffArea) => diffArea.type === 'CtrlKZone' }) if (overlappingCtrlKZone) { @@ -1189,11 +1163,28 @@ class EditCodeService extends Disposable implements IEditCodeService { - // throws if there's an error - public startApplying(opts: StartApplyingOpts): [URI, Promise] | null { + // the applyDonePromise this returns can reject, and should be caught with .catch + public async startApplying(opts: StartApplyingOpts): Promise<[URI, Promise] | null> { let res: [DiffZone, Promise] | undefined = undefined - if (opts.type === 'rewrite') res = this._initializeWriteoverStream(opts) - else if (opts.type === 'searchReplace') res = this._initializeSearchAndReplaceStream(opts) + + if (opts.from === 'QuickEdit') { + res = await this._initializeWriteoverStream(opts) // rewrite + } + else if (opts.from === 'ClickApply') { + if (this._settingsService.state.globalSettings.enableFastApply) { + const numCharsInFile = this._fileLengthOfGivenURI(opts.uri) + if (numCharsInFile === null) return null + if (numCharsInFile < 1000) { // slow apply for short files (especially important for empty files) + res = await this._initializeWriteoverStream(opts) + } + else { + res = await this._initializeSearchAndReplaceStream(opts) // fast apply + } + } + else { + res = await this._initializeWriteoverStream(opts) // rewrite + } + } if (!res) return null const [diffZone, applyDonePromise] = res @@ -1222,65 +1213,55 @@ class EditCodeService extends Disposable implements IEditCodeService { - private _initializeWriteoverStream(opts: StartApplyingOpts): [DiffZone, Promise] | undefined { - const { from, } = opts - let startLine: number - let endLine: number - let uri: URI + private _startStreamingDiffZone({ + uri, + startBehavior, + streamRequestIdRef, + linkedCtrlKZone, + onWillUndo, + }: { + uri: URI, + startBehavior: 'accept-conflicts' | 'reject-conflicts' | 'keep-conflicts', + streamRequestIdRef: { current: string | null }, + linkedCtrlKZone: CtrlKZone | null, + onWillUndo: () => void, + }) { + const { model } = this._voidModelService.getModel(uri) + if (!model) return - if (from === 'ClickApply') { + // treat like full file, unless linkedCtrlKZone was provided in which case use its diff's range - const uri_ = this._getActiveEditorURI() - if (!uri_) return - uri = uri_ + const startLine = linkedCtrlKZone ? linkedCtrlKZone.startLine : 1 + const endLine = linkedCtrlKZone ? linkedCtrlKZone.endLine : model.getLineCount() + const range = { startLineNumber: startLine, startColumn: 1, endLineNumber: endLine, endColumn: Number.MAX_SAFE_INTEGER } - // reject all diffZones on this URI, adding to history (there can't possibly be overlap after this) - this.removeDiffAreas({ uri, behavior: 'reject', removeCtrlKs: true }) + const originalFileStr = model.getValue(EndOfLinePreference.LF) + let originalCode = model.getValueInRange(range, EndOfLinePreference.LF) - // in ctrl+L the start and end lines are the full document - const numLines = this._getNumLines(uri) - if (numLines === null) return - startLine = 1 - endLine = numLines + + // add to history as a checkpoint, before we start modifying + const { onFinishEdit } = this._addToHistory(uri, { onWillUndo }) + + // clear diffZones so no conflict + if (startBehavior === 'keep-conflicts') { + if (linkedCtrlKZone) { + // ctrlkzone should never have any conflicts + } + else { + // keep conflict on whole file - to keep conflict, revert the change and use those contents as original, then un-revert the file + this.acceptOrRejectAllDiffAreas({ uri, removeCtrlKs: true, behavior: 'reject', _addToHistory: false }) + const oldFileStr = model.getValue(EndOfLinePreference.LF) // use this as original code + this._writeURIText(uri, originalFileStr, 'wholeFileRange', { shouldRealignDiffAreas: true }) // un-revert + originalCode = oldFileStr + } } - else if (from === 'QuickEdit') { - const { diffareaid } = opts - const ctrlKZone = this.diffAreaOfId[diffareaid] - if (ctrlKZone.type !== 'CtrlKZone') return - - const { startLine: startLine_, endLine: endLine_, _URI } = ctrlKZone - uri = _URI - startLine = startLine_ - endLine = endLine_ + else if (startBehavior === 'accept-conflicts' || startBehavior === 'reject-conflicts') { + const behavior = startBehavior === 'accept-conflicts' ? 'accept' : 'reject' + this.acceptOrRejectAllDiffAreas({ uri, removeCtrlKs: true, behavior, _addToHistory: false }) } - else { - throw new Error(`Void: diff.type not recognized on: ${from}`) - } - - const currentFileStr = this._readURI(uri) - if (currentFileStr === null) return - const originalCode = currentFileStr.split('\n').slice((startLine - 1), (endLine - 1) + 1).join('\n') - - - let streamRequestIdRef: { current: string | null } = { current: null } - - - // promise that resolves when the apply is done - let resApplyPromise: () => void - let rejApplyPromise: (e: any) => void - const applyPromise = new Promise((res_, rej_) => { resApplyPromise = res_; rejApplyPromise = rej_ }) - - - // add to history - const { onFinishEdit } = this._addToHistory(uri, { - onUndo: () => { if (diffZone._streamState.isStreaming) rejApplyPromise(new Error('Edit was interrupted by pressing undo.')) } - }) - - // __TODO__ let users customize modelFimTags - const quickEditFIMTags = defaultQuickEditFimTags const adding: Omit = { type: 'DiffZone', @@ -1296,38 +1277,92 @@ class EditCodeService extends Disposable implements IEditCodeService { _diffOfId: {}, // added later _removeStylesFns: new Set(), } + const diffZone = this._addDiffArea(adding) - this._onDidChangeDiffZoneStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) + this._onDidChangeStreamingInDiffZone.fire({ uri, diffareaid: diffZone.diffareaid }) this._onDidAddOrDeleteDiffZones.fire({ uri }) - if (from === 'QuickEdit') { - const { diffareaid } = opts - const ctrlKZone = this.diffAreaOfId[diffareaid] - if (ctrlKZone.type !== 'CtrlKZone') return - + // a few items related to the ctrlKZone that started streaming this diffZone + if (linkedCtrlKZone) { + const ctrlKZone = linkedCtrlKZone ctrlKZone._linkedStreamingDiffZone = diffZone.diffareaid - this._onDidChangeCtrlKZoneStreaming.fire({ uri, diffareaid: ctrlKZone.diffareaid }) + this._onDidChangeStreamingInCtrlKZone.fire({ uri, diffareaid: ctrlKZone.diffareaid }) } - // now handle messages - let messages: LLMChatMessage[] + + return { diffZone, onFinishEdit } + } + + + + + private _uriIsStreaming(uri: URI) { + const diffAreas = this.diffAreasOfURI[uri.fsPath] + if (!diffAreas) return false + for (const diffareaid of diffAreas) { + const diffArea = this.diffAreaOfId[diffareaid] + if (diffArea?.type !== 'DiffZone') continue + if (diffArea._streamState.isStreaming) return true + } + return false + } + + + private async _initializeWriteoverStream(opts: StartApplyingOpts): Promise<[DiffZone, Promise] | undefined> { + + const { from, } = opts + + let uri: URI + let startRange: 'fullFile' | [number, number] + + let ctrlKZoneIfQuickEdit: CtrlKZone | null = null if (from === 'ClickApply') { - const userContent = rewriteCode_userMessage({ originalCode, applyStr: opts.applyStr, uri }) + const uri_ = this._uriOfGivenURI(opts.uri) + if (!uri_) return + uri = uri_ + startRange = 'fullFile' + } + else if (from === 'QuickEdit') { + const { diffareaid } = opts + const ctrlKZone = this.diffAreaOfId[diffareaid] + if (ctrlKZone?.type !== 'CtrlKZone') return + ctrlKZoneIfQuickEdit = ctrlKZone + const { startLine: startLine_, endLine: endLine_, _URI } = ctrlKZone + uri = _URI + startRange = [startLine_, endLine_] + } + else { + throw new Error(`Void: diff.type not recognized on: ${from}`) + } + + await this._voidModelService.initializeModel(uri) + const { model } = this._voidModelService.getModel(uri) + if (!model) return + + let streamRequestIdRef: { current: string | null } = { current: null } // can use this as a proxy to set the diffArea's stream state requestId + + // build messages + const quickEditFIMTags = defaultQuickEditFimTags // TODO can eventually let users customize modelFimTags + const originalFileCode = model.getValue(EndOfLinePreference.LF) + const originalCode = startRange === 'fullFile' ? originalFileCode : originalFileCode.split('\n').slice((startRange[0] - 1), (startRange[1] - 1) + 1).join('\n') + const language = model.getLanguageId() + let messages: LLMChatMessage[] + if (from === 'ClickApply') { + const userContent = rewriteCode_userMessage({ originalCode, applyStr: opts.applyStr, language }) messages = [ { role: 'system', content: rewriteCode_systemMessage, }, { role: 'user', content: userContent, } ] } else if (from === 'QuickEdit') { - const { diffareaid } = opts - const ctrlKZone = this.diffAreaOfId[diffareaid] - if (ctrlKZone.type !== 'CtrlKZone') return - const { _mountInfo } = ctrlKZone + if (!ctrlKZoneIfQuickEdit) return + const { _mountInfo } = ctrlKZoneIfQuickEdit const instructions = _mountInfo?.textAreaRef.current?.value ?? '' - const { prefix, suffix } = voidPrefixAndSuffix({ fullFileStr: currentFileStr, startLine, endLine }) - const language = filenameToVscodeLanguage(uri.fsPath) ?? '' + const startLine = startRange === 'fullFile' ? 1 : startRange[0] + const endLine = startRange === 'fullFile' ? model.getLineCount() : startRange[1] + const { prefix, suffix } = voidPrefixAndSuffix({ fullFileStr: originalFileCode, startLine, endLine }) const userContent = ctrlKStream_userMessage({ selection: originalCode, instructions: instructions, prefix, suffix, isOllamaFIM: false, fimTags: quickEditFIMTags, language }) // type: 'messages', messages = [ @@ -1337,25 +1372,50 @@ class EditCodeService extends Disposable implements IEditCodeService { } else { throw new Error(`featureName ${from} is invalid`) } + // if URI is already streaming, return (should never happen, caller is responsible for checking) + if (this._uriIsStreaming(uri)) return + // start diffzone + const res = this._startStreamingDiffZone({ + uri, + streamRequestIdRef, + startBehavior: opts.startBehavior, + linkedCtrlKZone: ctrlKZoneIfQuickEdit, + onWillUndo: () => { + if (streamRequestIdRef.current) { + this._llmMessageService.abort(streamRequestIdRef.current) + } + }, + + }) + if (!res) return + const { diffZone, onFinishEdit, } = res + + + // helpers const onDone = () => { + console.log('called onDone') diffZone._streamState = { isStreaming: false, } - this._onDidChangeDiffZoneStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) + this._onDidChangeStreamingInDiffZone.fire({ uri, diffareaid: diffZone.diffareaid }) - if (from === 'QuickEdit') { - const ctrlKZone = this.diffAreaOfId[opts.diffareaid] as CtrlKZone + if (ctrlKZoneIfQuickEdit) { + const ctrlKZone = ctrlKZoneIfQuickEdit ctrlKZone._linkedStreamingDiffZone = null - this._onDidChangeCtrlKZoneStreaming.fire({ uri, diffareaid: ctrlKZone.diffareaid }) + this._onDidChangeStreamingInCtrlKZone.fire({ uri, diffareaid: ctrlKZone.diffareaid }) this._deleteCtrlKZone(ctrlKZone) } this._refreshStylesAndDiffsInURI(uri) onFinishEdit() } - // refresh now in case onText takes a while to get 1st message - this._refreshStylesAndDiffsInURI(uri) - + // throws + const onError = (e: { message: string; fullError: Error | null; }) => { + this._notifyError(e) + onDone() + this._undoHistory(uri) + throw e.fullError + } const extractText = (fullText: string, recentlyAddedTextLen: number) => { if (from === 'QuickEdit') { @@ -1367,148 +1427,154 @@ class EditCodeService extends Disposable implements IEditCodeService { throw new Error('Void 1') } - const latestStreamInfoMutable: StreamLocationMutable = { line: diffZone.startLine, addedSplitYet: false, col: 1, originalCodeStartLine: 1 } + // refresh now in case onText takes a while to get 1st message + this._refreshStylesAndDiffsInURI(uri) - // state used in onText: - let fullTextSoFar = '' // so far (INCLUDING ignored suffix) - let prevIgnoredSuffix = '' + const latestStreamLocationMutable: StreamLocationMutable = { line: diffZone.startLine, addedSplitYet: false, col: 1, originalCodeStartLine: 1 } const featureName: FeatureName = opts.from === 'ClickApply' ? 'Apply' : 'Ctrl+K' const modelSelection = this._settingsService.state.modelSelectionOfFeature[featureName] const modelSelectionOptions = modelSelection ? this._settingsService.state.optionsOfModelSelection[modelSelection.providerName]?.[modelSelection.modelName] : undefined - const writeover = async () => { + // allowed to throw errors - this is called inside a promise that handles everything + const runWriteover = async () => { + let shouldSendAnotherMessage = true + while (shouldSendAnotherMessage) { + shouldSendAnotherMessage = false - let resMessageDonePromise: () => void = () => { } - const messageDonePromise = new Promise((res_) => { resMessageDonePromise = res_ }) + let resMessageDonePromise: () => void = () => { } + const messageDonePromise = new Promise((res_) => { resMessageDonePromise = res_ }) - streamRequestIdRef.current = this._llmMessageService.sendLLMMessage({ - messagesType: 'chatMessages', - logging: { loggingName: `Edit (Writeover) - ${from}` }, - messages, - modelSelection, - modelSelectionOptions, - onText: (params) => { - const { fullText: fullText_ } = params - const newText_ = fullText_.substring(fullTextSoFar.length, Infinity) + // state used in onText: + let fullTextSoFar = '' // so far (INCLUDING ignored suffix) + let prevIgnoredSuffix = '' + let aborted = false + let weAreAborting = false - const newText = prevIgnoredSuffix + newText_ // add the previously ignored suffix because it's no longer the suffix! - fullTextSoFar += newText // full text, including ```, etc - const [croppedText, deltaCroppedText, croppedSuffix] = extractText(fullTextSoFar, newText.length) - const { endLineInLlmTextSoFar } = this._writeStreamedDiffZoneLLMText(uri, originalCode, croppedText, deltaCroppedText, latestStreamInfoMutable) - diffZone._streamState.line = (diffZone.startLine - 1) + endLineInLlmTextSoFar // change coordinate systems from originalCode to full file + streamRequestIdRef.current = this._llmMessageService.sendLLMMessage({ + messagesType: 'chatMessages', + logging: { loggingName: `Edit (Writeover) - ${from}` }, + messages, + modelSelection, + modelSelectionOptions, + onText: (params) => { + const { fullText: fullText_ } = params + const newText_ = fullText_.substring(fullTextSoFar.length, Infinity) - this._refreshStylesAndDiffsInURI(uri) + const newText = prevIgnoredSuffix + newText_ // add the previously ignored suffix because it's no longer the suffix! + fullTextSoFar += newText // full text, including ```, etc - prevIgnoredSuffix = croppedSuffix - }, - onFinalMessage: (params) => { - const { fullText } = params - // console.log('DONE! FULL TEXT\n', extractText(fullText), diffZone.startLine, diffZone.endLine) - // at the end, re-write whole thing to make sure no sync errors - const [croppedText, _1, _2] = extractText(fullText, 0) - this._writeText(uri, croppedText, - { startLineNumber: diffZone.startLine, startColumn: 1, endLineNumber: diffZone.endLine, endColumn: Number.MAX_SAFE_INTEGER }, // 1-indexed - { shouldRealignDiffAreas: true } - ) - onDone() - resMessageDonePromise() - }, - onError: (e) => { - this._notifyError(e) - onDone() - this._undoHistory(uri) - resMessageDonePromise() - }, - }) + const [croppedText, deltaCroppedText, croppedSuffix] = extractText(fullTextSoFar, newText.length) + const { endLineInLlmTextSoFar } = this._writeStreamedDiffZoneLLMText(uri, originalCode, croppedText, deltaCroppedText, latestStreamLocationMutable) + diffZone._streamState.line = (diffZone.startLine - 1) + endLineInLlmTextSoFar // change coordinate systems from originalCode to full file - await messageDonePromise - } + this._refreshStylesAndDiffsInURI(uri) - writeover().then(() => resApplyPromise()).catch((e) => rejApplyPromise(e)) + prevIgnoredSuffix = croppedSuffix + }, + onFinalMessage: (params) => { + const { fullText } = params + // console.log('DONE! FULL TEXT\n', extractText(fullText), diffZone.startLine, diffZone.endLine) + // at the end, re-write whole thing to make sure no sync errors + const [croppedText, _1, _2] = extractText(fullText, 0) + this._writeURIText(uri, croppedText, + { startLineNumber: diffZone.startLine, startColumn: 1, endLineNumber: diffZone.endLine, endColumn: Number.MAX_SAFE_INTEGER }, // 1-indexed + { shouldRealignDiffAreas: true } + ) - return [diffZone, applyPromise] + onDone() + resMessageDonePromise() + }, + onError: (e) => { + onError(e) + }, + onAbort: () => { + if (weAreAborting) return + // stop the loop to free up the promise, but don't modify state (already handled by whatever stopped it) + aborted = true + resMessageDonePromise() + }, + }) + // should never happen, just for safety + if (streamRequestIdRef.current === null) { return } + + await messageDonePromise + if (aborted) { + throw new Error(`Edit was interrupted by the user.`) + } + } // end while + } // end writeover + + const applyDonePromise = new Promise((res, rej) => { runWriteover().then(res).catch(rej) }) + return [diffZone, applyDonePromise] } - - private _initializeSearchAndReplaceStream(opts: StartApplyingOpts & { from: 'ClickApply' }): [DiffZone, Promise] | undefined { - const { from, applyStr, uri: givenURI, } = opts - let uri: URI - + _uriOfGivenURI(givenURI: URI | 'current') { if (givenURI === 'current') { const uri_ = this._getActiveEditorURI() if (!uri_) return - uri = uri_ - } - else { - uri = givenURI + return uri_ } + return givenURI + } + _fileLengthOfGivenURI(givenURI: URI | 'current') { + const uri = this._uriOfGivenURI(givenURI) + if (!uri) return null + const { model } = this._voidModelService.getModel(uri) + if (!model) return null + const numCharsInFile = model.getValueLength(EndOfLinePreference.LF) + return numCharsInFile + } - // generate search/replace block text - const originalFileCode = this._voidFileService.readModel(uri) - if (originalFileCode === null) return + private async _initializeSearchAndReplaceStream(opts: StartApplyingOpts & { from: 'ClickApply' }): Promise<[DiffZone, Promise] | undefined> { + const { from, applyStr, uri: givenURI, } = opts - const numLines = this._getNumLines(uri) - if (numLines === null) return + const uri = this._uriOfGivenURI(givenURI) + if (!uri) return - // reject all diffZones on this URI, adding to history (there can't possibly be overlap after this) - this.removeDiffAreas({ uri, behavior: 'reject', removeCtrlKs: true }) + await this._voidModelService.initializeModel(uri) + const { model } = this._voidModelService.getModel(uri) + if (!model) return - const startLine = 1 - const endLine = numLines + let streamRequestIdRef: { current: string | null } = { current: null } // can use this as a proxy to set the diffArea's stream state requestId + + // build messages - ask LLM to generate search/replace block text + const originalFileCode = model.getValue(EndOfLinePreference.LF) const userMessageContent = searchReplace_userMessage({ originalCode: originalFileCode, applyStr: applyStr }) const messages: LLMChatMessage[] = [ { role: 'system', content: searchReplace_systemMessage }, { role: 'user', content: userMessageContent }, ] - // can use this as a proxy to set the diffArea's stream state requestId - let streamRequestIdRef: { current: string | null } = { current: null } + // if URI is already streaming, return (should never happen, caller is responsible for checking) + if (this._uriIsStreaming(uri)) return - - // promise that resolves when the apply is done - let resApplyDonePromise: () => void - let rejApplyDonePromise: (e: any) => void - const applyDonePromise = new Promise((res_, rej_) => { resApplyDonePromise = res_; rejApplyDonePromise = rej_ }) - - // add to history - const { onFinishEdit } = this._addToHistory(uri, { - onUndo: () => { if (diffZone._streamState.isStreaming) rejApplyDonePromise(new Error('Edit was interrupted by pressing undo.')) } + // start diffzone + const res = this._startStreamingDiffZone({ + uri, + streamRequestIdRef, + startBehavior: opts.startBehavior, + linkedCtrlKZone: null, + onWillUndo: () => { + if (streamRequestIdRef.current) { + this._llmMessageService.abort(streamRequestIdRef.current) // triggers onAbort() + } + }, }) + if (!res) return + const { diffZone, onFinishEdit } = res - // TODO replace these with whatever block we're on initially if already started (caching apply) + // helpers type SearchReplaceDiffAreaMetadata = { originalBounds: [number, number], // 1-indexed originalCode: string, } - - - const adding: Omit = { - type: 'DiffZone', - originalCode: originalFileCode, - startLine, - endLine, - _URI: uri, - _streamState: { - isStreaming: true, - streamRequestIdRef, - line: startLine, - }, - _diffOfId: {}, // added later - _removeStylesFns: new Set(), - } - const diffZone = this._addDiffArea(adding) - this._onDidChangeDiffZoneStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) - this._onDidAddOrDeleteDiffZones.fire({ uri }) - - - const convertOriginalRangeToFinalRange = (originalRange: readonly [number, number]): [number, number] => { // adjust based on the changes by computing line offset const [originalStart, originalEnd] = originalRange @@ -1527,18 +1593,28 @@ class EditCodeService extends Disposable implements IEditCodeService { } - const errMsgOfInvalidStr = (str: string & ReturnType, blockOrig: string) => { - return str === `Not found` ? - `The ORIGINAL code provided could not be found in the file. Please output all SEARCH/REPLACE blocks again, making sure the code in ORIGINAL is identical to a code snippet in the file. The ORIGINAL code provided: ${JSON.stringify(blockOrig)}` - : str === `Not unique` ? - `The ORIGINAL code provided shows up multiple times in the file. Please output all SEARCH/REPLACE blocks again, making sure the code in each ORIGINAL section is unique in the file. The ORIGINAL code provided: ${JSON.stringify(blockOrig)}` - : `` - } + const errContentOfInvalidStr = (str: string & ReturnType, blockOrig: string, blockNum: number, blocks: ExtractedSearchReplaceBlock[]) => { + const descStr = str === `Not found` ? + `The most recent ORIGINAL code could not be found in the file, so you were interrupted. The text in ORIGINAL must EXACTLY match lines of code. The problematic ORIGINAL code was:\n${JSON.stringify(blockOrig)}` + : str === `Not unique` ? + `The most recent ORIGINAL code shows up multiple times in the file, so you were interrupted. You might want to expand the ORIGINAL excerpt so it's unique. The problematic ORIGINAL code was:\n${JSON.stringify(blockOrig)}` + : `` + + // string of <<<<< ORIGINAL >>>>> REPLACE blocks so far so LLM can understand what it currently has + // const blocksSoFarStr = blocks.slice(0, blockNum).map(block => `${ORIGINAL}\n${block.orig}\n${DIVIDER}\n${block.final}\n${FINAL}`).join('\n') + // const soFarStr = blocksSoFarStr ? `These are the Search/Replace blocks that have been applied so far:${tripleTick[0]}\n${blocksSoFarStr}\n${tripleTick[1]}` : '' + // const continueMsg = soFarStr ? `${soFarStr}Please continue outputting SEARCH/REPLACE blocks starting where this leaves off.` : '' + // const errMsg = `${descStr}${continueMsg ? `\n${continueMsg}` : ''}` + const soFarStr = 'All of your previous outputs have been ignored. Please re-output ALL SEARCH/REPLACE blocks starting from the first one, and avoid the error.' + const errMsg = `${descStr}\n${soFarStr}` + return errMsg + + } const onDone = () => { diffZone._streamState = { isStreaming: false, } - this._onDidChangeDiffZoneStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) + this._onDidChangeStreamingInDiffZone.fire({ uri, diffareaid: diffZone.diffareaid }) this._refreshStylesAndDiffsInURI(uri) // delete the tracking zones @@ -1548,32 +1624,48 @@ class EditCodeService extends Disposable implements IEditCodeService { onFinishEdit() } + const onError = (e: { message: string; fullError: Error | null; }) => { + this._notifyError(e) + onDone() + this._undoHistory(uri) + throw e.fullError // throw error h + } + // refresh now in case onText takes a while to get 1st message this._refreshStylesAndDiffsInURI(uri) - // stateful - const addedTrackingZoneOfBlockNum: TrackingZone[] = [] - - // stream style related + // stream style related - TODO replace these with whatever block we're on initially if already started (if add caching of apply S/R blocks) let latestStreamLocationMutable: StreamLocationMutable | null = null let shouldUpdateOrigStreamStyle = true - let oldBlocks: ExtractedSearchReplaceBlock[] = [] - + const addedTrackingZoneOfBlockNum: TrackingZone[] = [] + diffZone._streamState.line = 1 const featureName: FeatureName = 'Apply' const modelSelection = this._settingsService.state.modelSelectionOfFeature[featureName] const modelSelectionOptions = modelSelection ? this._settingsService.state.optionsOfModelSelection[modelSelection.providerName]?.[modelSelection.modelName] : undefined + const N_RETRIES = 5 - const retryLoop = async () => { + // allowed to throw errors - this is called inside a promise that handles everything + const runSearchReplace = async () => { // this generates >>>>>>> ORIGINAL <<<<<<< REPLACE blocks and and simultaneously applies it let shouldSendAnotherMessage = true let nMessagesSent = 0 let currStreamingBlockNum = 0 + let aborted = false + let weAreAborting = false while (shouldSendAnotherMessage) { shouldSendAnotherMessage = false nMessagesSent += 1 + if (nMessagesSent >= N_RETRIES) { + const e = { + message: `Tried to Fast Apply ${N_RETRIES} times but failed. This may be related to model intelligence, or it may an edit that's too complex. Please retry or disable Fast Apply.`, + fullError: null + } + onError(e) + break + } let resMessageDonePromise: () => void = () => { } const messageDonePromise = new Promise((res, rej) => { resMessageDonePromise = res }) @@ -1606,54 +1698,73 @@ class EditCodeService extends Disposable implements IEditCodeService { shouldUpdateOrigStreamStyle = false } } + + // // starting line is at least the number of lines in the generated code minus 1 + // const numLinesInOrig = numLinesOfStr(block.orig) + // const newLine = Math.max(numLinesInOrig - 1, 1, diffZone._streamState.line ?? 1) + // if (newLine !== diffZone._streamState.line) { + // diffZone._streamState.line = newLine + // this._refreshStylesAndDiffsInURI(uri) + // } + + // must be done writing original to move on to writing streamed content continue } shouldUpdateOrigStreamStyle = true - // if this is the first time we're seeing this block, add it as a diffarea so we can start streaming + // if this is the first time we're seeing this block, add it as a diffarea so we can start streaming in it if (!(blockNum in addedTrackingZoneOfBlockNum)) { - console.log('finding text in code...', { orig: block.orig }) - const originalBounds = findTextInCode(block.orig, originalFileCode) + + const originalBounds = findTextInCode(block.orig, originalFileCode) // if error if (typeof originalBounds === 'string') { - const content = errMsgOfInvalidStr(originalBounds, block.orig) + console.log('--------------Error finding text in code:') + console.log('originalFileCode', { originalFileCode }) + console.log('fullText', { fullText }) + console.log('error:', originalBounds) + console.log('block.orig:', block.orig) + console.log('---------') + const content = errContentOfInvalidStr(originalBounds, block.orig, blockNum, blocks) messages.push( { role: 'assistant', content: fullText, anthropicReasoning: null }, // latest output { role: 'user', content: content } // user explanation of what's wrong ) - - // REVERT - const numLines = this._getNumLines(uri) - if (numLines !== null) this._writeText(uri, originalFileCode, - { startLineNumber: 1, startColumn: 1, endLineNumber: numLines, endColumn: Number.MAX_SAFE_INTEGER }, - { shouldRealignDiffAreas: false } - ) - // reset state - diffZone.startLine = 1 - diffZone.endLine = numLines ?? 1 - if (diffZone._streamState.isStreaming) { - diffZone._streamState.line = 1 - } - + // REVERT ALL BLOCKS currStreamingBlockNum = 0 latestStreamLocationMutable = null shouldUpdateOrigStreamStyle = true oldBlocks = [] - addedTrackingZoneOfBlockNum.splice(0, Infinity) // clear the array + for (const trackingZone of addedTrackingZoneOfBlockNum) + this._deleteTrackingZone(trackingZone) + addedTrackingZoneOfBlockNum.splice(0, Infinity) + + this._writeURIText(uri, originalFileCode, 'wholeFileRange', { shouldRealignDiffAreas: true }) // abort and resolve shouldSendAnotherMessage = true - if (streamRequestIdRef.current) this._llmMessageService.abort(streamRequestIdRef.current) - this._refreshStylesAndDiffsInURI(uri) + if (streamRequestIdRef.current) { + weAreAborting = true + this._llmMessageService.abort(streamRequestIdRef.current) + weAreAborting = false + } + diffZone._streamState.line = 1 resMessageDonePromise() + this._refreshStylesAndDiffsInURI(uri) return } + console.log('---------adding-------') + console.log('CURRENT TEXT!!!', { current: model?.getValue() }) + console.log('block', deepClone(block)) + console.log('origBounds', originalBounds) + + const [startLine, endLine] = convertOriginalRangeToFinalRange(originalBounds) + console.log('start end', startLine, endLine) // otherwise if no error, add the position as a diffarea const adding: Omit, 'diffareaid'> = { @@ -1669,7 +1780,7 @@ class EditCodeService extends Disposable implements IEditCodeService { const trackingZone = this._addDiffArea(adding) addedTrackingZoneOfBlockNum.push(trackingZone) latestStreamLocationMutable = { line: startLine, addedSplitYet: false, col: 1, originalCodeStartLine: 1 } - } // <-- done adding diffarea + } // end adding diffarea // should always be in streaming state here @@ -1677,12 +1788,11 @@ class EditCodeService extends Disposable implements IEditCodeService { console.error('DiffZone was not in streaming state in _initializeSearchAndReplaceStream') continue } - if (!latestStreamLocationMutable) continue // if a block is done, finish it by writing all if (block.state === 'done') { const { startLine: finalStartLine, endLine: finalEndLine } = addedTrackingZoneOfBlockNum[blockNum] - this._writeText(uri, block.final, + this._writeURIText(uri, block.final, { startLineNumber: finalStartLine, startColumn: 1, endLineNumber: finalEndLine, endColumn: Number.MAX_SAFE_INTEGER }, // 1-indexed { shouldRealignDiffAreas: true } ) @@ -1692,7 +1802,11 @@ class EditCodeService extends Disposable implements IEditCodeService { } // write the added text to the file - const deltaFinalText = block.final.substring((oldBlocks[blockNum]?.final ?? '').length, Infinity) + if (!latestStreamLocationMutable) continue + const oldBlock = oldBlocks[blockNum] + const oldFinalLen = (oldBlock?.final ?? '').length + const deltaFinalText = block.final.substring(oldFinalLen, Infinity) + this._writeStreamedDiffZoneLLMText(uri, block.orig, block.final, deltaFinalText, latestStreamLocationMutable) oldBlocks = blocks // oldblocks is only used if writingFinal @@ -1700,15 +1814,14 @@ class EditCodeService extends Disposable implements IEditCodeService { // diffZone._streamState.line = currentEndLine diffZone._streamState.line = latestStreamLocationMutable.line - - } // end for this._refreshStylesAndDiffsInURI(uri) }, onFinalMessage: async (params) => { const { fullText } = params - console.log('final message!!', fullText) + + console.log('DONE - editCode!', { fullText }) // 1. wait 500ms and fix lint errors - call lint error workflow // (update react state to say "Fixing errors") @@ -1719,6 +1832,15 @@ class EditCodeService extends Disposable implements IEditCodeService { } // writeover the whole file let newCode = originalFileCode + + // IMPORTANT - sort by lineNum + addedTrackingZoneOfBlockNum.sort((a, b) => a.metadata.originalBounds[0] - b.metadata.originalBounds[0]) + + const { model } = this._voidModelService.getModel(uri) + console.log('CURRENT TEXT!!!', { current: model?.getValue() }) + console.log('addedTrackingZoneOfBlockNum', addedTrackingZoneOfBlockNum) + console.log('blocks', deepClone(blocks)) + for (let blockNum = addedTrackingZoneOfBlockNum.length - 1; blockNum >= 0; blockNum -= 1) { const { originalBounds } = addedTrackingZoneOfBlockNum[blockNum].metadata const finalCode = blocks[blockNum].final @@ -1733,38 +1855,55 @@ class EditCodeService extends Disposable implements IEditCodeService { ...lines.slice((originalEnd - 1) + 1, Infinity) ].join('\n') } - const numLines = this._getNumLines(uri) - if (numLines !== null) { - this._writeText(uri, newCode, - { startLineNumber: 1, startColumn: 1, endLineNumber: numLines, endColumn: Number.MAX_SAFE_INTEGER }, - { shouldRealignDiffAreas: true } - ) - } + + this._writeURIText(uri, newCode, + 'wholeFileRange', + { shouldRealignDiffAreas: true } + ) onDone() resMessageDonePromise() }, onError: (e) => { - this._notifyError(e) - onDone() - this._undoHistory(uri) + onError(e) + }, + onAbort: () => { + if (weAreAborting) return + // stop the loop to free up the promise, but don't modify state (already handled by whatever stopped it) + aborted = true resMessageDonePromise() }, - }) - await messageDonePromise + // should never happen, just for safety + if (streamRequestIdRef.current === null) { break } + await messageDonePromise + if (aborted) { + throw new Error(`Edit was interrupted by the user.`) + } } // end while } // end retryLoop - retryLoop().then(() => resApplyDonePromise()).catch((e) => rejApplyDonePromise(e)) - + const applyDonePromise = new Promise((res, rej) => { runSearchReplace().then(res).catch(rej) }) return [diffZone, applyDonePromise] } + _undoHistory(uri: URI) { + this._undoRedoService.undo(uri) + } + + + + isCtrlKZoneStreaming({ diffareaid }: { diffareaid: number }) { + const ctrlKZone = this.diffAreaOfId[diffareaid] + if (!ctrlKZone) return false + if (ctrlKZone.type !== 'CtrlKZone') return false + return !!ctrlKZone._linkedStreamingDiffZone + } + private _stopIfStreaming(diffZone: DiffZone) { const uri = diffZone._URI @@ -1775,32 +1914,7 @@ class EditCodeService extends Disposable implements IEditCodeService { this._llmMessageService.abort(streamRequestId) diffZone._streamState = { isStreaming: false, } - this._onDidChangeDiffZoneStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) - } - - _undoHistory(uri: URI) { - this._undoRedoService.undo(uri) - } - - - - - - _interruptSingleDiffZoneStreaming({ diffareaid }: { diffareaid: number }) { - const diffZone = this.diffAreaOfId[diffareaid] - if (diffZone?.type !== 'DiffZone') return - if (!diffZone._streamState.isStreaming) return - - this._stopIfStreaming(diffZone) - this._undoHistory(diffZone._URI) - } - - - isCtrlKZoneStreaming({ diffareaid }: { diffareaid: number }) { - const ctrlKZone = this.diffAreaOfId[diffareaid] - if (!ctrlKZone) return false - if (ctrlKZone.type !== 'CtrlKZone') return false - return !!ctrlKZone._linkedStreamingDiffZone + this._onDidChangeStreamingInDiffZone.fire({ uri, diffareaid: diffZone.diffareaid }) } @@ -1814,24 +1928,11 @@ class EditCodeService extends Disposable implements IEditCodeService { if (!linkedStreamingDiffZone) return if (linkedStreamingDiffZone.type !== 'DiffZone') return - this._interruptSingleDiffZoneStreaming({ diffareaid: linkedStreamingDiffZone.diffareaid }) + this._stopIfStreaming(linkedStreamingDiffZone) + this._undoHistory(linkedStreamingDiffZone._URI) } - - - getURIStreamState = ({ uri }: { uri: URI | null }) => { - if (uri === null) return 'idle' - - const diffZones = [...this.diffAreasOfURI[uri.fsPath].values()] - .map(diffareaid => this.diffAreaOfId[diffareaid]) - .filter(diffArea => !!diffArea && diffArea.type === 'DiffZone') - const isStreaming = diffZones.find(diffZone => !!diffZone._streamState.isStreaming) - - const state: URIStreamState = isStreaming ? 'streaming' : (diffZones.length === 0 ? 'idle' : 'acceptRejectAll') - return state - } - interruptURIStreaming({ uri }: { uri: URI }) { // brute force for now is OK for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { @@ -1855,31 +1956,32 @@ class EditCodeService extends Disposable implements IEditCodeService { // onFinishEdit() // } - private _revertAndDeleteDiffZone(diffZone: DiffZone) { + private _revertDiffZone(diffZone: DiffZone) { const uri = diffZone._URI const writeText = diffZone.originalCode const toRange: IRange = { startLineNumber: diffZone.startLine, startColumn: 1, endLineNumber: diffZone.endLine, endColumn: Number.MAX_SAFE_INTEGER } - this._writeText(uri, writeText, toRange, { shouldRealignDiffAreas: true }) - - this._deleteDiffZone(diffZone) + this._writeURIText(uri, writeText, toRange, { shouldRealignDiffAreas: true }) } // remove a batch of diffareas all at once (and handle accept/reject of their diffs) - public removeDiffAreas({ uri, removeCtrlKs, behavior }: { uri: URI, removeCtrlKs: boolean, behavior: 'reject' | 'accept' }) { + public acceptOrRejectAllDiffAreas: IEditCodeService['acceptOrRejectAllDiffAreas'] = async ({ uri, behavior, removeCtrlKs, _addToHistory }) => { const diffareaids = this.diffAreasOfURI[uri.fsPath] - if (diffareaids.size === 0) return // do nothing + if ((diffareaids?.size ?? 0) === 0) return // do nothing - const { onFinishEdit } = this._addToHistory(uri) + const { onFinishEdit } = _addToHistory === false ? { onFinishEdit: () => { } } : this._addToHistory(uri) - for (const diffareaid of diffareaids) { + for (const diffareaid of diffareaids ?? []) { const diffArea = this.diffAreaOfId[diffareaid] if (!diffArea) continue if (diffArea.type === 'DiffZone') { - if (behavior === 'reject') this._revertAndDeleteDiffZone(diffArea) + if (behavior === 'reject') { + this._revertDiffZone(diffArea) + this._deleteDiffZone(diffArea) + } else if (behavior === 'accept') this._deleteDiffZone(diffArea) } else if (diffArea.type === 'CtrlKZone' && removeCtrlKs) { @@ -1896,6 +1998,8 @@ class EditCodeService extends Disposable implements IEditCodeService { // called on void.acceptDiff public async acceptDiff({ diffid }: { diffid: number }) { + // TODO could use an ITextModelto do this instead, would be much simpler + const diff = this.diffOfId[diffid] if (!diff) return @@ -2031,7 +2135,7 @@ class EditCodeService extends Disposable implements IEditCodeService { } // update the file - this._writeText(uri, writeText, toRange, { shouldRealignDiffAreas: true }) + this._writeURIText(uri, writeText, toRange, { shouldRealignDiffAreas: true }) // originalCode does not change! @@ -2049,62 +2153,18 @@ class EditCodeService extends Disposable implements IEditCodeService { } - - - - // testDiffs(): DiffZone | undefined { - // const uri = this._getActiveEditorURI() - // if (!uri) return - - // const startLine = 1 - // const endLine = 4 - - // const currentFileStr = this._readURI(uri) - // if (currentFileStr === null) return - // const originalCode = currentFileStr.split('\n').slice((startLine - 1), (endLine - 1) + 1).join('\n') - - // const { onFinishEdit } = this._addToHistory(uri) - // const adding: Omit = { - // type: 'DiffZone', - // originalCode, - // startLine, - // endLine, - // _URI: uri, - // _streamState: { isStreaming: false, }, - // _diffOfId: {}, // added later - // _removeStylesFns: new Set(), - // } - // const diffZone = this._addDiffArea(adding) - // const endResult = `\ - // const x = 1; - // if (x > 0) { - // console.log('hi!') - // }` - // this._writeText(uri, endResult, - // { startLineNumber: diffZone.startLine, startColumn: 1, endLineNumber: diffZone.endLine, endColumn: Number.MAX_SAFE_INTEGER }, // 1-indexed - // { shouldRealignDiffAreas: true } - // ) - // diffZone._streamState = { isStreaming: false, } - // this._refreshStylesAndDiffsInURI(uri) - // onFinishEdit() - - // return diffZone - // } - } registerSingleton(IEditCodeService, EditCodeService, InstantiationType.Eager); -const acceptBg = '#1a7431' -const acceptAllBg = '#1e8538' -const acceptBorder = '1px solid #145626' -const rejectBg = '#b42331' -const rejectAllBg = '#cf2838' -const rejectBorder = '1px solid #8e1c27' -const buttonFontSize = '11px' -const buttonTextColor = 'white' -class AcceptRejectWidget extends Widget implements IOverlayWidget { + + + + + + +class AcceptRejectInlineWidget extends Widget implements IOverlayWidget { public getId() { return this.ID } public getDomNode() { return this._domNode; } @@ -2220,80 +2280,3 @@ class AcceptRejectWidget extends Widget implements IOverlayWidget { - -class AcceptAllRejectAllWidget extends Widget implements IOverlayWidget { - private readonly _domNode: HTMLElement; - private readonly editor: ICodeEditor; - private readonly ID: string; - - constructor({ editor, onAcceptAll, onRejectAll }: { editor: ICodeEditor, onAcceptAll: () => void, onRejectAll: () => void }) { - super(); - - this.ID = editor.getModel()?.uri.fsPath + ''; - this.editor = editor; - - // Create container div with buttons - const { acceptButton, rejectButton, buttons } = dom.h('div@buttons', [ - dom.h('button@acceptButton', []), - dom.h('button@rejectButton', []) - ]); - - // Style the container - buttons.style.zIndex = '2'; - buttons.style.padding = '4px'; - buttons.style.display = 'flex'; - buttons.style.gap = '4px'; - buttons.style.alignItems = 'center'; - - // Style accept button - acceptButton.addEventListener('click', onAcceptAll) - acceptButton.textContent = 'Accept All'; - acceptButton.style.backgroundColor = acceptAllBg; - acceptButton.style.border = acceptBorder; - acceptButton.style.color = buttonTextColor; - acceptButton.style.fontSize = buttonFontSize; - acceptButton.style.padding = '4px 8px'; - acceptButton.style.borderRadius = '6px'; - acceptButton.style.cursor = 'pointer'; - - // Style reject button - rejectButton.addEventListener('click', onRejectAll) - rejectButton.textContent = 'Reject All'; - rejectButton.style.backgroundColor = rejectAllBg; - rejectButton.style.border = rejectBorder; - rejectButton.style.color = buttonTextColor; - rejectButton.style.fontSize = buttonFontSize; - rejectButton.style.color = 'white'; - rejectButton.style.padding = '4px 8px'; - rejectButton.style.borderRadius = '6px'; - rejectButton.style.cursor = 'pointer'; - - this._domNode = buttons; - - // Mount the widget - editor.addOverlayWidget(this); - } - - - public getId(): string { - return this.ID; - } - - public getDomNode(): HTMLElement { - return this._domNode; - } - - public getPosition() { - return { - preference: OverlayWidgetPositionPreference.BOTTOM_RIGHT_CORNER, - } - } - - public override dispose(): void { - this.editor.removeOverlayWidget(this); - super.dispose(); - } -} - - - diff --git a/src/vs/workbench/contrib/void/browser/editCodeServiceInterface.ts b/src/vs/workbench/contrib/void/browser/editCodeServiceInterface.ts index 0ffbf047..8173f837 100644 --- a/src/vs/workbench/contrib/void/browser/editCodeServiceInterface.ts +++ b/src/vs/workbench/contrib/void/browser/editCodeServiceInterface.ts @@ -7,19 +7,21 @@ import { Event } from '../../../../base/common/event.js'; import { URI } from '../../../../base/common/uri.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { Diff, DiffArea } from './editCodeService.js'; +export type StartBehavior = 'accept-conflicts' | 'reject-conflicts' | 'keep-conflicts' export type StartApplyingOpts = ({ from: 'QuickEdit'; - type: 'rewrite'; diffareaid: number; // id of the CtrlK area (contains text selection) + startBehavior: StartBehavior; } | { from: 'ClickApply'; - type: 'searchReplace' | 'rewrite'; applyStr: string; uri: 'current' | URI; + startBehavior: StartBehavior; }) @@ -30,28 +32,33 @@ export type AddCtrlKOpts = { editor: ICodeEditor, } -export type URIStreamState = 'idle' | 'acceptRejectAll' | 'streaming' - - export const IEditCodeService = createDecorator('editCodeService'); export interface IEditCodeService { readonly _serviceBrand: undefined; - startApplying(opts: StartApplyingOpts): [URI, Promise] | null; + startApplying(opts: StartApplyingOpts): Promise<[URI, Promise] | null>; addCtrlKZone(opts: AddCtrlKOpts): number | undefined; removeCtrlKZone(opts: { diffareaid: number }): void; - removeDiffAreas(opts: { uri: URI, removeCtrlKs: boolean, behavior: 'reject' | 'accept' }): void; + + diffAreaOfId: Record; + diffAreasOfURI: Record | undefined>; + diffOfId: Record; + + acceptOrRejectAllDiffAreas(opts: { uri: URI, removeCtrlKs: boolean, behavior: 'reject' | 'accept', _addToHistory?: boolean }): void; + + // events + onDidAddOrDeleteDiffZones: Event<{ uri: URI }>; + onDidChangeDiffsInDiffZone: Event<{ uri: URI; diffareaid: number }>; // only fires when not streaming!!! streaming would be too much + onDidChangeStreamingInDiffZone: Event<{ uri: URI; diffareaid: number }>; + onDidChangeStreamingInCtrlKZone: Event<{ uri: URI; diffareaid: number }>; // CtrlKZone streaming state isCtrlKZoneStreaming(opts: { diffareaid: number }): boolean; interruptCtrlKStreaming(opts: { diffareaid: number }): void; - onDidChangeCtrlKZoneStreaming: Event<{ uri: URI; diffareaid: number }>; // // DiffZone codeBoxId streaming state - getURIStreamState(opts: { uri: URI | null }): URIStreamState; interruptURIStreaming(opts: { uri: URI }): void; - onDidChangeURIStreamState: Event<{ uri: URI; state: URIStreamState }>; // testDiffs(): void; } diff --git a/src/vs/workbench/contrib/void/browser/helperServices/consistentItemService.ts b/src/vs/workbench/contrib/void/browser/helperServices/consistentItemService.ts index 3165e57f..ba906ff5 100644 --- a/src/vs/workbench/contrib/void/browser/helperServices/consistentItemService.ts +++ b/src/vs/workbench/contrib/void/browser/helperServices/consistentItemService.ts @@ -25,7 +25,7 @@ export interface IConsistentItemService { export const IConsistentItemService = createDecorator('ConsistentItemService'); -export class ConsistentItemService extends Disposable { +export class ConsistentItemService extends Disposable implements IConsistentItemService { readonly _serviceBrand: undefined diff --git a/src/vs/workbench/contrib/void/browser/helpers/readFile.ts b/src/vs/workbench/contrib/void/browser/helpers/readFile.ts deleted file mode 100644 index f7afa5dd..00000000 --- a/src/vs/workbench/contrib/void/browser/helpers/readFile.ts +++ /dev/null @@ -1,52 +0,0 @@ -/*-------------------------------------------------------------------------------------- - * Copyright 2025 Glass Devtools, Inc. All rights reserved. - * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. - *--------------------------------------------------------------------------------------*/ - -import { URI } from '../../../../../base/common/uri.js' -import { EndOfLinePreference } from '../../../../../editor/common/model.js' -import { IModelService } from '../../../../../editor/common/services/model.js' -import { IFileService } from '../../../../../platform/files/common/files.js' - - -// attempts to read URI of currently opened model, then of raw file -export const VSReadFile = async (uri: URI, modelService: IModelService, fileService: IFileService) => { - - const modelResult = await _VSReadModel(modelService, uri) - if (modelResult) return modelResult - - const fileResult = await _VSReadFileRaw(fileService, uri) - if (fileResult) return fileResult - - return '' - -} - -// read files from VSCode. preferred (but appears to only work if the model of this URI already exists. If it doesn't use the other function.) -const _VSReadModel = async (modelService: IModelService, uri: URI): Promise => { - - // attempt to read saved model (doesn't work if application was reloaded...) - const model = modelService.getModel(uri) - if (model) { - return model.getValue(EndOfLinePreference.LF) - } - - // backup logic - look at all opened models and check if they have the same `fsPath` - const models = modelService.getModels() - for (const model of models) { - if (model.uri.fsPath === uri.fsPath) - return model.getValue(EndOfLinePreference.LF); - } - - return null -} - -const _VSReadFileRaw = async (fileService: IFileService, uri: URI) => { - try { - const res = await fileService.readFile(uri) - const str = res.value.toString() - return str - } catch (e) { - return null - } -} diff --git a/src/vs/workbench/contrib/void/browser/media/void.css b/src/vs/workbench/contrib/void/browser/media/void.css index e5e9793e..3a420737 100644 --- a/src/vs/workbench/contrib/void/browser/media/void.css +++ b/src/vs/workbench/contrib/void/browser/media/void.css @@ -83,7 +83,7 @@ .void-scrollable-element::-webkit-scrollbar, .void-scrollable-element *::-webkit-scrollbar { width: 14px !important; - height: 14px !important; + height: 4px !important; } .void-scrollable-element::-webkit-scrollbar-track, diff --git a/src/vs/workbench/contrib/void/browser/metricsPollService.ts b/src/vs/workbench/contrib/void/browser/metricsPollService.ts new file mode 100644 index 00000000..ab4d92d8 --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/metricsPollService.ts @@ -0,0 +1,55 @@ +/*-------------------------------------------------------------------------------------- + * 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 { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; + +import * as dom from '../../../../base/browser/dom.js'; +import { IMetricsService } from '../common/metricsService.js'; + + +export interface IMetricsPollService { + readonly _serviceBrand: undefined; +} + + +const PING_EVERY_MS = 15 * 1000 * 60 // 15 minutes + +export const IMetricsPollService = createDecorator('voidMetricsPollService'); +class MetricsPollService extends Disposable implements IMetricsPollService { + _serviceBrand: undefined; + + static readonly ID = 'voidMetricsPollService'; + + + private readonly intervalID: number + constructor( + @IMetricsService private readonly metricsService: IMetricsService, + ) { + super() + + // initial state + const { window } = dom.getActiveWindow() + let i = 1 + + this.intervalID = window.setInterval(() => { + this.metricsService.capture('Alive', { iv1: i }) + i += 1 + }, PING_EVERY_MS) + + + } + + override dispose() { + super.dispose() + const { window } = dom.getActiveWindow() + window.clearInterval(this.intervalID) + } + + +} + +registerWorkbenchContribution2(MetricsPollService.ID, MetricsPollService, WorkbenchPhase.BlockRestore); diff --git a/src/vs/workbench/contrib/void/browser/quickEditStateService.ts b/src/vs/workbench/contrib/void/browser/quickEditStateService.ts deleted file mode 100644 index 62f3823b..00000000 --- a/src/vs/workbench/contrib/void/browser/quickEditStateService.ts +++ /dev/null @@ -1,77 +0,0 @@ -/*-------------------------------------------------------------------------------------- - * Copyright 2025 Glass Devtools, Inc. All rights reserved. - * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. - *--------------------------------------------------------------------------------------*/ - -import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { QuickEdit } from './quickEditActions.js'; - - - -// service that manages state -export type VoidQuickEditState = { - quickEditsOfDocument: { [uri: string]: QuickEdit } -} - -export interface IQuickEditStateService { - readonly _serviceBrand: undefined; - - readonly state: VoidQuickEditState; // readonly to the user - setState(newState: Partial): void; - onDidChangeState: Event; - - onDidFocusChat: Event; - onDidBlurChat: Event; - fireFocusChat(): void; - fireBlurChat(): void; - -} - -export const IQuickEditStateService = createDecorator('voidQuickEditStateService'); -class VoidQuickEditStateService extends Disposable implements IQuickEditStateService { - _serviceBrand: undefined; - - static readonly ID = 'voidQuickEditStateService'; - - private readonly _onDidChangeState = new Emitter(); - readonly onDidChangeState: Event = this._onDidChangeState.event; - - private readonly _onFocusChat = new Emitter(); - readonly onDidFocusChat: Event = this._onFocusChat.event; - - private readonly _onBlurChat = new Emitter(); - readonly onDidBlurChat: Event = this._onBlurChat.event; - - - // state - state: VoidQuickEditState - - constructor( - ) { - super() - - // initial state - this.state = { quickEditsOfDocument: {} } - } - - - setState(newState: Partial) { - - this.state = { ...this.state, ...newState } - this._onDidChangeState.fire() - } - - fireFocusChat() { - this._onFocusChat.fire() - } - - fireBlurChat() { - this._onBlurChat.fire() - } - -} - -registerSingleton(IQuickEditStateService, VoidQuickEditStateService, InstantiationType.Eager); diff --git a/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx b/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx index 832d7eb6..b09a22ba 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx @@ -1,8 +1,12 @@ import { useState, useEffect, useCallback } from 'react' -import { useAccessor, useURIStreamState, useSettingsState } from '../util/services.js' -import { useRefState } from '../util/helpers.js' +import { useAccessor, useCommandBarState, useCommandBarURIListener, useSettingsState } from '../util/services.js' +import { usePromise, useRefState } from '../util/helpers.js' import { isFeatureNameDisabled } from '../../../../common/voidSettingsTypes.js' import { URI } from '../../../../../../../base/common/uri.js' +import { FileSymlink, LucideIcon, RotateCw } from 'lucide-react' +import { Check, X, Square, Copy, Play, } from 'lucide-react' +import { getBasename, ListableToolItem, ToolChildrenWrapper } from '../sidebar-tsx/SidebarChat.js' +import { ChatMarkdownRender } from './ChatMarkdownRender.js' enum CopyButtonText { Idle = 'Copy', @@ -10,6 +14,56 @@ enum CopyButtonText { Error = 'Could not copy', } + +type IconButtonProps = { + onClick: () => void; + Icon: LucideIcon + disabled?: boolean + className?: string +} + +export const IconShell1 = ({ onClick, Icon, disabled, className }: IconButtonProps) => ( + +) + + +// export const IconShell2 = ({ onClick, title, Icon, disabled, className }: IconButtonProps) => ( +// +// ) + const COPY_FEEDBACK_TIMEOUT = 1000 // amount of time to say 'Copied!' const CopyButton = ({ codeStr }: { codeStr: string }) => { @@ -26,7 +80,6 @@ const CopyButton = ({ codeStr }: { codeStr: string }) => { }, COPY_FEEDBACK_TIMEOUT) }, [copyButtonText]) - const onCopy = useCallback(() => { clipboardService.writeText(codeStr) .then(() => { setCopyButtonText(CopyButtonText.Copied) }) @@ -34,115 +87,257 @@ const CopyButton = ({ codeStr }: { codeStr: string }) => { metricsService.capture('Copy Code', { length: codeStr.length }) // capture the length only }, [metricsService, clipboardService, codeStr, setCopyButtonText]) - const isSingleLine = !codeStr.includes('\n') - - return + /> } - - - // state persisted for duration of react only +// TODO change this to use type `ChatThreads.applyBoxState[applyBoxId]` const applyingURIOfApplyBoxIdRef: { current: { [applyBoxId: string]: URI | undefined } } = { current: {} } -export const ApplyBlockHoverButtons = ({ codeStr, applyBoxId }: { codeStr: string, applyBoxId: string }) => { + +export const JumpToFileButton = ({ uri }: { uri: URI | 'current' }) => { + const accessor = useAccessor() + const commandService = accessor.get('ICommandService') + + const jumpToFileButton = uri !== 'current' && ( + { + commandService.executeCommand('vscode.open', uri, { preview: true }) + }} + /> + ) + + return jumpToFileButton +} + +export const useApplyButtonHTML = ({ codeStr, applyBoxId, uri }: { codeStr: string, applyBoxId: string, uri: URI | 'current' }) => { const settingsState = useSettingsState() const isDisabled = !!isFeatureNameDisabled('Apply', settingsState) || !applyBoxId const accessor = useAccessor() const editCodeService = accessor.get('IEditCodeService') + const voidCommandBarService = accessor.get('IVoidCommandBarService') const metricsService = accessor.get('IMetricsService') const [_, rerender] = useState(0) - const applyingUri = useCallback(() => applyingURIOfApplyBoxIdRef.current[applyBoxId] ?? null, [applyBoxId]) - const streamState = useCallback(() => editCodeService.getURIStreamState({ uri: applyingUri() }), [editCodeService, applyingUri]) + const getUriBeingApplied = useCallback(() => { + return applyingURIOfApplyBoxIdRef.current[applyBoxId] ?? null + }, [applyBoxId]) - // listen for stream updates - useURIStreamState( - useCallback((uri, newStreamState) => { - const shouldUpdate = applyingUri()?.fsPath !== uri.fsPath - if (shouldUpdate) return - rerender(c => c + 1) - }, [applyBoxId, editCodeService, applyingUri]) + const getStreamState = useCallback(() => { + const uri = getUriBeingApplied() + if (!uri) return 'idle-no-changes' + return voidCommandBarService.getStreamState(uri) + }, [voidCommandBarService, getUriBeingApplied]) + + // listen for stream updates on this box + + + useCommandBarURIListener(useCallback((uri_) => { + const shouldUpdate = ( + getUriBeingApplied()?.fsPath === uri_.fsPath + || (uri !== 'current' && uri.fsPath === uri_.fsPath) + ) + if (!shouldUpdate) return + rerender(c => c + 1) + }, [applyBoxId, editCodeService, getUriBeingApplied, uri]) ) - const onSubmit = useCallback(() => { + const onClickSubmit = useCallback(async () => { if (isDisabled) return - if (streamState() === 'streaming') return - const [newApplyingUri, _] = editCodeService.startApplying({ + if (getStreamState() === 'streaming') return + const [newApplyingUri, applyDonePromise] = await editCodeService.startApplying({ from: 'ClickApply', - type: 'searchReplace', applyStr: codeStr, - uri: 'current', + uri: uri, + startBehavior: 'keep-conflicts', }) ?? [] + // catch any errors by interrupting the stream + applyDonePromise?.catch(e => { if (newApplyingUri) editCodeService.interruptURIStreaming({ uri: newApplyingUri }) }) + applyingURIOfApplyBoxIdRef.current[applyBoxId] = newApplyingUri ?? undefined + rerender(c => c + 1) metricsService.capture('Apply Code', { length: codeStr.length }) // capture the length only - }, [isDisabled, streamState, editCodeService, codeStr, applyBoxId, metricsService]) + }, [isDisabled, getStreamState, editCodeService, codeStr, uri, applyBoxId, metricsService]) const onInterrupt = useCallback(() => { - if (streamState() !== 'streaming') return - const uri = applyingUri() + if (getStreamState() !== 'streaming') return + const uri = getUriBeingApplied() if (!uri) return editCodeService.interruptURIStreaming({ uri }) metricsService.capture('Stop Apply', {}) - }, [streamState, applyingUri, editCodeService, metricsService]) + }, [getStreamState, getUriBeingApplied, editCodeService, metricsService]) + + const onAccept = useCallback(() => { + const uri = getUriBeingApplied() + if (uri) editCodeService.acceptOrRejectAllDiffAreas({ uri, behavior: 'accept', removeCtrlKs: false }) + }, [getUriBeingApplied, editCodeService]) + + const onReject = useCallback(() => { + const uri = getUriBeingApplied() + if (uri) editCodeService.acceptOrRejectAllDiffAreas({ uri, behavior: 'reject', removeCtrlKs: false }) + }, [getUriBeingApplied, editCodeService]) + + const onReapply = useCallback(() => { + onReject() + onClickSubmit() + }, [onReject, onClickSubmit]) + + const currStreamState = getStreamState() + + const copyButton = ( + + ) + + const playButton = ( + + ) + + const stopButton = ( + + ) + + const reapplyButton = ( + + ) + + const acceptButton = ( + + ) + + const rejectButton = ( + + ) - const isSingleLine = !codeStr.includes('\n') - const applyButton = + let buttonsHTML = <> - const stopButton = + if (currStreamState === 'streaming') { + buttonsHTML = <> + + {copyButton} + {stopButton} + + } - const acceptRejectButtons = <> - - - + if (currStreamState === 'idle-no-changes') { + buttonsHTML = <> + + {copyButton} + {playButton} + + } + + if (currStreamState === 'idle-has-changes') { + buttonsHTML = <> + + {reapplyButton} + {rejectButton} + {acceptButton} + + } + + const statusIndicatorHTML =
+
+
+ + return { + statusIndicatorHTML, + buttonsHTML, + } + +} + + + + + +export const BlockCodeApplyWrapper = ({ + children, + initValue, + applyBoxId, + language, + canApply, + uri, +}: { + initValue: string; + children: React.ReactNode; + applyBoxId: string; + canApply: boolean; + language: string; + uri: URI | 'current', +}) => { + + const { statusIndicatorHTML, buttonsHTML } = useApplyButtonHTML({ codeStr: initValue, applyBoxId, uri }) + const accessor = useAccessor() + const commandService = accessor.get('ICommandService') + + const name = uri !== 'current' ? + {getBasename(uri.fsPath)}} + isSmall={true} + showDot={false} + onClick={() => { commandService.executeCommand('vscode.open', uri, { preview: true }) }} + /> + : {language} + + + return
+ {/* header */} +
+
+ {statusIndicatorHTML} + + {name} + +
+
+ {buttonsHTML} +
+
+ + {/* contents */} + + {children} + +
- const currStreamState = streamState() - return <> - {currStreamState !== 'streaming' && } - {currStreamState === 'idle' && !isDisabled && applyButton} - {currStreamState === 'streaming' && stopButton} - {currStreamState === 'acceptRejectAll' && acceptRejectButtons} - } 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 deleted file mode 100644 index f7954b82..00000000 --- a/src/vs/workbench/contrib/void/browser/react/src/markdown/BlockCode.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/*-------------------------------------------------------------------------------------- - * Copyright 2025 Glass Devtools, Inc. All rights reserved. - * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. - *--------------------------------------------------------------------------------------*/ - -import React from 'react'; - -import { VoidCodeEditor, VoidCodeEditorProps } from '../util/inputs.js'; - - -export const BlockCode = ({ buttonsOnHover, ...codeEditorProps }: { buttonsOnHover?: React.ReactNode } & VoidCodeEditorProps) => { - const isSingleLine = !codeEditorProps.initValue.includes('\n') - - return ( - <> -
- {buttonsOnHover === null ? null : ( -
-
- {buttonsOnHover} -
-
- )} - - -
- - ) -} diff --git a/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx b/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx index 50b8378e..2507f796 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx @@ -3,11 +3,18 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import React, { JSX } from 'react' +import React, { JSX, useMemo, useState } from 'react' import { marked, MarkedToken, Token } from 'marked' -import { BlockCode } from './BlockCode.js' -import { nameToVscodeLanguage } from '../../../../common/helpers/detectLanguage.js' -import { ApplyBlockHoverButtons } from './ApplyBlockHoverButtons.js' + +import { convertToVscodeLang, detectLanguage } from '../../../../common/helpers/languageHelpers.js' +import { BlockCodeApplyWrapper } from './ApplyBlockHoverButtons.js' +import { useAccessor } from '../util/services.js' +import { ScrollType } from '../../../../../../../editor/common/editorCommon.js' +import { URI } from '../../../../../../../base/common/uri.js' +import { isAbsolute } from '../../../../../../../base/common/path.js' +import { separateOutFirstLine } from '../../../../common/helpers/util.js' +import { BlockCode } from '../util/inputs.js' + export type ChatMessageLocation = { threadId: string; @@ -16,17 +23,101 @@ export type ChatMessageLocation = { type ApplyBoxLocation = ChatMessageLocation & { tokenIdx: string } -const getApplyBoxId = ({ threadId, messageIdx, tokenIdx }: ApplyBoxLocation) => { +export const getApplyBoxId = ({ threadId, messageIdx, tokenIdx }: ApplyBoxLocation) => { return `${threadId}-${messageIdx}-${tokenIdx}` } -const RenderToken = ({ token, nested, chatMessageLocationForApply, tokenIdx }: { token: Token | string, nested?: boolean, chatMessageLocationForApply?: ChatMessageLocation, tokenIdx: string }): JSX.Element => { +function isValidUri(s: string): boolean { + return s.length > 5 && isAbsolute(s) && !s.includes('//') && !s.includes('/*') // common case that is a false positive is comments like // +} + +const Codespan = ({ text, className, onClick }: { text: string, className?: string, onClick?: () => void }) => { + + // TODO compute this once for efficiency. we should use `labels.ts/shorten` to display duplicates properly + + return + {text} + + +} + +const CodespanWithLink = ({ text, rawText, chatMessageLocation }: { text: string, rawText: string, chatMessageLocation: ChatMessageLocation }) => { + + const accessor = useAccessor() + + const chatThreadService = accessor.get('IChatThreadService') + const commandService = accessor.get('ICommandService') + const editorService = accessor.get('ICodeEditorService') + + const { messageIdx, threadId } = chatMessageLocation + + const [didComputeCodespanLink, setDidComputeCodespanLink] = useState(false) + + let link = undefined + if (rawText.endsWith("`")) { // if codespan was completed + + // get link from cache + link = chatThreadService.getCodespanLink({ codespanStr: text, messageIdx, threadId }) + + if (link === undefined) { + // if no link, generate link and add to cache + (chatThreadService.generateCodespanLink({ codespanStr: text, threadId }) + .then(link => { + chatThreadService.addCodespanLink({ newLinkText: text, newLinkLocation: link, messageIdx, threadId }) + setDidComputeCodespanLink(true) // rerender + }) + ) + } + + } + + + const onClick = () => { + + if (!link) return; + const selection = link.selection + + // open the file + commandService.executeCommand('vscode.open', link.uri).then(() => { + + // select the text + setTimeout(() => { + if (!selection) return; + + const editor = editorService.getActiveCodeEditor() + if (!editor) return; + + editor.setSelection(selection) + editor.revealRange(selection, ScrollType.Immediate) + + }, 50) // needed when document was just opened and needs to initialize + + }) + + } + + return +} + + +export type RenderTokenOptions = { isApplyEnabled?: boolean, isLinkDetectionEnabled?: boolean } +const RenderToken = ({ token, inPTag, codeURI, chatMessageLocation, tokenIdx, ...options }: { token: Token | string, inPTag?: boolean, codeURI?: URI, chatMessageLocation?: ChatMessageLocation, tokenIdx: string, } & RenderTokenOptions): React.ReactNode => { + const accessor = useAccessor() + const languageService = accessor.get('ILanguageService') // deal with built-in tokens first (assume marked token) const t = token as MarkedToken if (t.raw.trim() === '') { - return <>; + return null; } if (t.type === "space") { @@ -34,27 +125,67 @@ const RenderToken = ({ token, nested, chatMessageLocationForApply, tokenIdx }: { } if (t.type === "code") { + const [firstLine, remainingContents] = separateOutFirstLine(t.text) + const firstLineIsURI = isValidUri(firstLine) && !codeURI + const contents = firstLineIsURI ? (remainingContents?.trimStart() || '') : t.text // exclude first-line URI from contents - const applyBoxId = chatMessageLocationForApply ? getApplyBoxId({ - threadId: chatMessageLocationForApply.threadId, - messageIdx: chatMessageLocationForApply.messageIdx, - tokenIdx: tokenIdx, - }) : null + if (!contents) return null - return
- } - /> -
+ // figure out langauge and URI + let uri: URI | null + let language: string + if (codeURI) { + uri = codeURI + } + else if (firstLineIsURI) { // get lang from the uri in the first line of the markdown + uri = URI.file(firstLine) + } + else { + uri = null + } + + if (t.lang) { // a language was provided. empty string is common so check truthy, not just undefined + language = convertToVscodeLang(languageService, t.lang) // convert markdown language to language that vscode recognizes (eg markdown doesn't know bash but it does know shell) + } + else { // no language provided - fallback - get lang from the uri and contents + language = detectLanguage(languageService, { uri, fileContents: contents }) + } + + if (options.isApplyEnabled && chatMessageLocation) { + const isCodeblockClosed = t.raw.trimEnd().endsWith('```') // user should only be able to Apply when the code has been closed (t.raw ends with "```") + + const applyBoxId = getApplyBoxId({ + threadId: chatMessageLocation.threadId, + messageIdx: chatMessageLocation.messageIdx, + tokenIdx: tokenIdx, + }) + return + + + } + + return } if (t.type === "heading") { const HeadingTag = `h${t.depth}` as keyof JSX.IntrinsicElements - return {t.text} + return + + } if (t.type === "table") { @@ -132,7 +263,7 @@ const RenderToken = ({ token, nested, chatMessageLocationForApply, tokenIdx }: { return
  • - +
  • } @@ -148,7 +279,7 @@ const RenderToken = ({ token, nested, chatMessageLocationForApply, tokenIdx }: { )} - + ))} @@ -161,24 +292,22 @@ const RenderToken = ({ token, nested, chatMessageLocationForApply, tokenIdx }: { {t.tokens.map((token, index) => ( ))} - if (nested) return contents - - return

    - {contents} -

    + if (inPTag) return {contents} + return

    {contents}

    } if (t.type === "html") { - return ( -

    - {t.raw} -

    - ) + const contents = t.raw + if (inPTag) return {contents} + return

    {contents}

    } if (t.type === "text" || t.type === "escape") { @@ -221,11 +350,17 @@ const RenderToken = ({ token, nested, chatMessageLocationForApply, tokenIdx }: { // inline code if (t.type === "codespan") { - return ( - - {t.text} - - ) + + if (options.isLinkDetectionEnabled && chatMessageLocation) { + return + + } + + return } if (t.type === "br") { @@ -244,12 +379,13 @@ const RenderToken = ({ token, nested, chatMessageLocationForApply, tokenIdx }: { ) } -export const ChatMarkdownRender = ({ string, nested = false, chatMessageLocationForApply }: { string: string, nested?: boolean, chatMessageLocationForApply?: ChatMessageLocation }) => { + +export const ChatMarkdownRender = ({ string, inPTag = false, chatMessageLocation, ...options }: { string: string, inPTag?: boolean, codeURI?: URI, chatMessageLocation: ChatMessageLocation | undefined } & RenderTokenOptions) => { const tokens = marked.lexer(string); // https://marked.js.org/using_pro#renderer return ( <> {tokens.map((token, index) => ( - + ))} ) diff --git a/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx index fe70caa3..42e9b81b 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx @@ -3,12 +3,11 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import React, { FormEvent, useCallback, useEffect, useRef, useState } from 'react'; -import { useSettingsState, useSidebarState, useChatThreadsState, useQuickEditState, useAccessor, useCtrlKZoneStreamingState } from '../util/services.js'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useSettingsState, useAccessor, useCtrlKZoneStreamingState } from '../util/services.js'; import { TextAreaFns, VoidInputBox2 } from '../util/inputs.js'; import { QuickEditPropsType } from '../../../quickEditActions.js'; import { ButtonStop, ButtonSubmit, IconX, VoidChatArea } from '../sidebar-tsx/SidebarChat.js'; -import { ModelDropdown } from '../void-settings-tsx/ModelDropdown.js'; import { VOID_CTRL_K_ACTION_ID } from '../../../actionIDs.js'; import { useRefState } from '../util/helpers.js'; import { useScrollbarStyles } from '../util/useScrollbarStyles.js'; @@ -55,17 +54,24 @@ export const QuickEditChat = ({ setIsStreamingRef(isStreaming) }, [diffareaid, setIsStreamingRef])) + const loadingIcon =
    - const onSubmit = useCallback(() => { + const onSubmit = useCallback(async () => { if (isDisabled) return if (isStreamingRef.current) return textAreaFnsRef.current?.disable() - editCodeService.startApplying({ + const [newApplyingUri, applyDonePromise] = await editCodeService.startApplying({ from: 'QuickEdit', - type: 'rewrite', diffareaid, - }) + startBehavior: 'keep-conflicts', + }) ?? [] + // catch any errors by interrupting the stream + applyDonePromise?.catch(e => { if (newApplyingUri) editCodeService.interruptCtrlKStreaming({ diffareaid }) }) + + }, [isStreamingRef, isDisabled, editCodeService, diffareaid]) const onInterrupt = useCallback(() => { @@ -87,14 +93,14 @@ export const QuickEditChat = ({ const chatAreaRef = useRef(null) return
    { textAreaRef.current?.focus() }} > `${threadId}-$ - -// const ReasoningOptionDropdown = () => { -// const accessor = useAccessor() - -// const voidSettingsService = accessor.get('IVoidSettingsService') -// const voidSettingsState = useSettingsState() - -// const modelSelection = voidSettingsState.modelSelectionOfFeature['Chat'] -// if (!modelSelection) return null - -// const { modelName, providerName } = modelSelection -// const { canToggleReasoning, reasoningBudgetSlider } = getModelCapabilities(providerName, modelName).supportsReasoningOutput || {} - -// const defaultEnabledVal = canToggleReasoning ? true : false -// const isEnabled = voidSettingsState.optionsOfModelSelection[modelSelection.providerName]?.[modelSelection.modelName]?.reasoningEnabled ?? defaultEnabledVal - -// let toggleButton: React.ReactNode = null -// if (canToggleReasoning) { -// toggleButton =
    -// {isEnabled ? 'Thinking' : 'Thinking'} -// { voidSettingsService.setOptionsOfModelSelection(modelSelection.providerName, modelSelection.modelName, { reasoningEnabled: newVal }) }} -// /> -//
    -// } - -// let slider: React.ReactNode = null -// if (isEnabled && reasoningBudgetSlider?.type === 'slider') { -// const { min, max, default: defaultVal } = reasoningBudgetSlider -// const value = voidSettingsState.optionsOfModelSelection[modelSelection.providerName]?.[modelSelection.modelName]?.reasoningBudget ?? defaultVal -// slider =
    -// Budget -// { voidSettingsService.setOptionsOfModelSelection(modelSelection.providerName, modelSelection.modelName, { reasoningBudget: newVal }) }} -// /> -// {`${value} tokens`} -//
    - -// } - -// return <> -// {toggleButton} -// {slider} -// -// } - - - // SLIDER ONLY: -const ReasoningOptionDropdown = () => { +const ReasoningOptionSlider = ({ featureName }: { featureName: FeatureName }) => { const accessor = useAccessor() const voidSettingsService = accessor.get('IVoidSettingsService') const voidSettingsState = useSettingsState() - const modelSelection = voidSettingsState.modelSelectionOfFeature['Chat'] + const modelSelection = voidSettingsState.modelSelectionOfFeature[featureName] if (!modelSelection) return null const { modelName, providerName } = modelSelection - const { canToggleReasoning, reasoningBudgetSlider } = getModelCapabilities(providerName, modelName).supportsReasoning || {} + const { reasoningCapabilities } = getModelCapabilities(providerName, modelName) + const { canTurnOffReasoning, reasoningBudgetSlider } = reasoningCapabilities || {} - const { isReasoningEnabled } = getModelSelectionState(providerName, modelName, voidSettingsState.optionsOfModelSelection[providerName]?.[modelName]) - - if (canToggleReasoning && !reasoningBudgetSlider) { // if it's just a on/off toggle without a power slider (no models right now) + const modelSelectionOptions = voidSettingsState.optionsOfModelSelection[providerName]?.[modelName] + const isReasoningEnabled = getIsResoningEnabledState(providerName, modelName, modelSelectionOptions) + if (canTurnOffReasoning && !reasoningBudgetSlider) { // if it's just a on/off toggle without a power slider (no models right now) return null // unused right now // return
    // {isReasoningEnabled ? 'Thinking' : 'Thinking'} @@ -236,8 +181,8 @@ const ReasoningOptionDropdown = () => { const value = voidSettingsState.optionsOfModelSelection[modelSelection.providerName]?.[modelSelection.modelName]?.reasoningBudget ?? defaultVal const nSteps = 8 // only used in calculating stepSize, stepSize is what actually matters - const stepSize = Math.round((max - min_) / 8) - const min = canToggleReasoning ? min_ - stepSize : min_ + const stepSize = Math.round((max - min_) / nSteps) + const min = canTurnOffReasoning ? min_ - stepSize : min_ return
    Thinking @@ -249,8 +194,7 @@ const ReasoningOptionDropdown = () => { step={stepSize} value={value} onChange={(newVal) => { - console.log('NEWVAL', newVal) - const disabled = newVal === min && canToggleReasoning + const disabled = newVal === min && canTurnOffReasoning voidSettingsService.setOptionsOfModelSelection(modelSelection.providerName, modelSelection.modelName, { reasoningEnabled: !disabled, reasoningBudget: newVal }) }} /> @@ -263,6 +207,46 @@ const ReasoningOptionDropdown = () => { +const nameOfChatMode = { + 'normal': 'Chat', + 'gather': 'Gather', + 'agent': 'Agent', +} + +const detailOfChatMode = { + 'normal': 'Normal chat', + 'gather': 'Discover relevant files', + 'agent': 'Edit files and use tools', +} + + +const ChatModeDropdown = ({ className }: { className: string }) => { + const accessor = useAccessor() + + const voidSettingsService = accessor.get('IVoidSettingsService') + const settingsState = useSettingsState() + + const options: ChatMode[] = useMemo(() => ['normal', 'gather', 'agent'], []) + + const onChangeOption = useCallback((newVal: ChatMode) => { + voidSettingsService.setGlobalSetting('chatMode', newVal) + }, [voidSettingsService]) + + return nameOfChatMode[val]} + getOptionDropdownName={(val) => nameOfChatMode[val]} + getOptionDropdownDetail={(val) => detailOfChatMode[val]} + getOptionsEqual={(a, b) => a === b} + /> + +} + + + interface VoidChatAreaProps { @@ -274,13 +258,14 @@ interface VoidChatAreaProps { onAbort: () => void; isStreaming: boolean; isDisabled?: boolean; - divRef?: React.RefObject; + divRef?: React.RefObject; // UI customization className?: string; showModelDropdown?: boolean; showSelections?: boolean; showProspectiveSelections?: boolean; + loadingIcon?: React.ReactNode; selections?: StagingSelectionItem[] setSelections?: (s: StagingSelectionItem[]) => void @@ -290,6 +275,8 @@ interface VoidChatAreaProps { onClickAnywhere?: () => void; // Optional close button onClose?: () => void; + + featureName: FeatureName; } export const VoidChatArea: React.FC = ({ @@ -307,16 +294,18 @@ export const VoidChatArea: React.FC = ({ showProspectiveSelections = true, selections, setSelections, + featureName, + loadingIcon, }) => { return (
    = ({ {/* Bottom row */}
    {showModelDropdown && ( -
    - - +
    + + +
    + {featureName === 'Chat' && } + +
    )} - {isStreaming ? ( - - ) : ( - - )} + +
    + + {isStreaming && loadingIcon} + + {isStreaming ? ( + + ) : ( + + )} +
    +
    ); }; -const useResizeObserver = () => { - const ref = useRef(null); - const [dimensions, setDimensions] = useState({ height: 0, width: 0 }); - - useEffect(() => { - if (ref.current) { - const resizeObserver = new ResizeObserver((entries) => { - if (entries.length > 0) { - const entry = entries[0]; - setDimensions({ - height: entry.contentRect.height, - width: entry.contentRect.width - }); - } - }); - - resizeObserver.observe(ref.current); - - return () => { - if (ref.current) - resizeObserver.unobserve(ref.current); - }; - } - }, []); - - return [ref, dimensions] as const; -}; @@ -475,7 +448,6 @@ const ScrollToBottomContainer = ({ children, className, style, scrollContainerRe return (
    ); }; - - - -const getBasename = (pathStr: string) => { +export const getFolderName = (pathStr: string) => { // 'unixify' path pathStr = pathStr.replace(/[/\\]+/g, '/') // replace any / or \ or \\ with / const parts = pathStr.split('/') // split on / + // Filter out empty parts (the last element will be empty if path ends with /) + const nonEmptyParts = parts.filter(part => part.length > 0) + if (nonEmptyParts.length === 0) return '/' // Root directory + if (nonEmptyParts.length === 1) return nonEmptyParts[0] + '/' // Only one folder + // Get the last two parts + const lastTwo = nonEmptyParts.slice(-2) + return lastTwo.join('/') + '/' +} + +export const getBasename = (pathStr: string) => { + // 'unixify' path + pathStr = pathStr.replace(/[/\\]+/g, '/') // replace any / or \ or \\ with / + const parts = pathStr.split('/') // split on / + if (parts.length === 0) return pathStr return parts[parts.length - 1] } export const SelectedFiles = ( - { type, selections, setSelections, showProspectiveSelections }: - | { type: 'past', selections: StagingSelectionItem[]; setSelections?: undefined, showProspectiveSelections?: undefined } - | { type: 'staging', selections: StagingSelectionItem[]; setSelections: ((newSelections: StagingSelectionItem[]) => void), showProspectiveSelections?: boolean } + { type, selections, setSelections, showProspectiveSelections, messageIdx, }: + | { type: 'past', selections: StagingSelectionItem[]; setSelections?: undefined, showProspectiveSelections?: undefined, messageIdx: number, } + | { type: 'staging', selections: StagingSelectionItem[]; setSelections: ((newSelections: StagingSelectionItem[]) => void), showProspectiveSelections?: boolean, messageIdx?: number } ) => { const accessor = useAccessor() const commandService = accessor.get('ICommandService') + const modelReferenceService = accessor.get('IVoidModelService') // state for tracking prospective files - const { currentUri } = useUriState() + const { uri: currentURI } = useActiveURI() const [recentUris, setRecentUris] = useState([]) const maxRecentUris = 10 const maxProspectiveFiles = 3 useEffect(() => { // handle recent files - if (!currentUri) return + if (!currentURI) return setRecentUris(prev => { - const withoutCurrent = prev.filter(uri => uri.fsPath !== currentUri.fsPath) // remove duplicates - const withCurrent = [currentUri, ...withoutCurrent] + const withoutCurrent = prev.filter(uri => uri.fsPath !== currentURI.fsPath) // remove duplicates + const withCurrent = [currentURI, ...withoutCurrent] return withCurrent.slice(0, maxRecentUris) }) - }, [currentUri]) - let prospectiveSelections: StagingSelectionItem[] = [] - if (type === 'staging' && showProspectiveSelections) { // handle prospective files + }, [currentURI]) + const [prospectiveSelections, setProspectiveSelections] = useState([]) + + + // handle prospective files + useEffect(() => { + const computeRecents = async () => { + const prospectiveURIs = recentUris + .filter(uri => !selections.find(s => s.type === 'File' && s.fileURI.fsPath === uri.fsPath)) + .slice(0, maxProspectiveFiles) + + const answer: StagingSelectionItem[] = [] + for (const uri of prospectiveURIs) { + answer.push({ + type: 'File', + fileURI: uri, + language: (await modelReferenceService.getModelSafe(uri)).model?.getLanguageId() || 'plaintext', + selectionStr: null, + range: null, + state: { isOpened: false, wasAddedAsCurrentFile: false }, + }) + } + return answer + } + // add a prospective file if type === 'staging' and if the user is in a file, and if the file is not selected yet - prospectiveSelections = recentUris - .filter(uri => !selections.find(s => s.type === 'File' && s.fileURI.fsPath === uri.fsPath)) - .slice(0, maxProspectiveFiles) - .map(uri => ({ - type: 'File', - fileURI: uri, - selectionStr: null, - range: null, - state: { isOpened: false }, - })) - } + if (type === 'staging' && showProspectiveSelections) { + computeRecents().then((a) => setProspectiveSelections(a)) + } + else { + setProspectiveSelections([]) + } + }, [recentUris, selections, type, showProspectiveSelections]) + const allSelections = [...selections, ...prospectiveSelections] @@ -546,6 +549,7 @@ export const SelectedFiles = ( const isThisSelectionOpened = (!!selection.selectionStr && selection.state.isOpened && type === 'staging') const isThisSelectionAFile = selection.selectionStr === null const isThisSelectionProspective = i > selections.length - 1 + const isThisSelectionAddedAsCurrentFile = selection.state.wasAddedAsCurrentFile const thisKey = `${isThisSelectionProspective}-${i}-${selections.length}` @@ -563,9 +567,10 @@ export const SelectedFiles = ( px-1 w-fit h-fit select-none - ${isThisSelectionProspective ? 'bg-void-bg-1 text-void-fg-3 opacity-80' : 'bg-void-bg-3 hover:brightness-95 text-void-fg-1'} text-xs text-nowrap - border rounded-sm ${isThisSelectionProspective + border rounded-sm + ${isThisSelectionProspective ? 'bg-void-bg-1 text-void-fg-3 opacity-80' : 'bg-void-bg-3 hover:brightness-95 text-void-fg-1'} + ${isThisSelectionProspective ? 'border-void-border-2' : isThisSelectionOpened ? 'border-void-border-1 ring-1 ring-void-blue' @@ -579,14 +584,25 @@ export const SelectedFiles = ( if (isThisSelectionProspective) { // add prospective selection to selections setSelections([...selections, selection]) } else if (isThisSelectionAFile) { // open files + commandService.executeCommand('vscode.open', selection.fileURI, { preview: true, // preserveFocus: false, }); + + if (isThisSelectionAddedAsCurrentFile) { + // make it so the file is added permanently, not just as the current file + const newSelection: StagingSelectionItem = { ...selection, state: { ...selection.state, wasAddedAsCurrentFile: false } } + setSelections([ + ...selections.slice(0, i), + newSelection, + ...selections.slice(i + 1) + ]) + } } else { // show text const selection = selections[i] - const newSelection = { ...selection, state: { isOpened: !selection.state.isOpened } } + const newSelection = { ...selection, state: { ...selection.state, isOpened: !selection.state.isOpened } } const newSelections = [ ...selections.slice(0, i), newSelection, @@ -608,6 +624,12 @@ export const SelectedFiles = ( + (isThisSelectionAFile ? '' : ` (${selection.range.startLineNumber}-${selection.range.endLineNumber})`) } + {isThisSelectionAddedAsCurrentFile && messageIdx === undefined && currentURI?.fsPath === selection.fileURI.fsPath && + + {`(Current File)`} + + } + {type === 'staging' && !isThisSelectionProspective ? // X button @@ -655,78 +677,140 @@ export const SelectedFiles = ( - -interface DropdownComponentProps { - title: string; - desc1: string; - desc2?: string; +type ToolHeaderParams = { + icon?: React.ReactNode; + title: React.ReactNode; + desc1: React.ReactNode; + desc2?: React.ReactNode; + isError?: boolean; + isRejected?: boolean; numResults?: number; + hasNextPage?: boolean; children?: React.ReactNode; onClick?: () => void; - icon?: React.ReactNode; + isOpen?: boolean, } -const DropdownComponent = ({ +const ToolHeaderWrapper = ({ + icon, title, desc1, desc2, numResults, + hasNextPage, children, + isError, onClick, - icon, -}: DropdownComponentProps) => { - const [isExpanded, setIsExpanded] = useState(false); + isOpen, + isRejected, +}: ToolHeaderParams) => { - const isDropdown = !!children - const isClickable = !!isDropdown || !!onClick + const [isOpen_, setIsOpen] = useState(false); + const isExpanded = isOpen !== undefined ? isOpen : isOpen_ - return ( -
    -
    -
    { - if (children) { setIsExpanded(v => !v); } - if (onClick) { onClick(); } - }} - > - {isDropdown && ( - - )} -
    - {icon} - {title} - {desc1} + const isDropdown = children !== undefined // null ALLOWS dropdown + const isClickable = !!(isDropdown || onClick) + + return (
    +
    + {/* header */} +
    { + if (isDropdown) { setIsOpen(v => !v); } + if (onClick) { onClick(); } + }} + > + {isDropdown && ( + + )} +
    + {/* left */} +
    + {title} + {desc1} +
    + + {/* right */} +
    {desc2 && {desc2} } {numResults !== undefined && ( - - {`(`}{numResults}{` result`}{numResults !== 1 ? 's' : ''}{`)`} + + {`(${numResults}${hasNextPage ? '+' : ''} result${numResults !== 1 ? 's' : ''})`} )} + {isError && } + {isRejected && }
    +
    + {/* children */} + {
    + {children} +
    } +
    +
    ); +}; + + + + +const SimplifiedToolHeader = ({ + title, + children, +}: { + title: string; + children?: React.ReactNode; +}) => { + const [isOpen, setIsOpen] = useState(false); + const isDropdown = children !== undefined; + return ( +
    +
    + {/* header */}
    { + if (isDropdown) { setIsOpen(v => !v); } + }} > -
    - {children} + {isDropdown && ( + + )} +
    + {title}
    + {/* children */} + {
    + {children} +
    }
    ); }; -const UserMessageComponent = ({ chatMessage, messageIdx, isLoading }: ChatBubbleProps & { chatMessage: ChatMessage & { role: 'user' } }) => { + + +const UserMessageComponent = ({ chatMessage, messageIdx, isCommitted }: { chatMessage: ChatMessage & { role: 'user' }, messageIdx: number, isCommitted: boolean, }) => { const accessor = useAccessor() const chatThreadsService = accessor.get('IChatThreadService') + const sidebarStateService = accessor.get('ISidebarStateService') // global state let isBeingEdited = false @@ -757,7 +841,13 @@ const UserMessageComponent = ({ chatMessage, messageIdx, isLoading }: ChatBubble const canInitialize = mode === 'edit' && textAreaRefState const shouldInitialize = _justEnabledEdit.current || _mustInitialize.current if (canInitialize && shouldInitialize) { - setStagingSelections(chatMessage.selections || []) + setStagingSelections((chatMessage.selections || []) + .map(s => { // quick hack so we dont have to do anything more + const sNew = s + sNew.state.wasAddedAsCurrentFile = false // wipe all "current file" info when the user first edits a message + return sNew + }) + ) if (textAreaFnsRef.current) textAreaFnsRef.current.setValue(chatMessage.displayContent || '') @@ -771,14 +861,14 @@ const UserMessageComponent = ({ chatMessage, messageIdx, isLoading }: ChatBubble const onOpenEdit = () => { setIsBeingEdited(true) - chatThreadsService.setFocusedMessageIdx(messageIdx) + chatThreadsService.setCurrentlyFocusedMessageIdx(messageIdx) _justEnabledEdit.current = true } const onCloseEdit = () => { setIsFocused(false) setIsHovered(false) setIsBeingEdited(false) - chatThreadsService.setFocusedMessageIdx(undefined) + chatThreadsService.setCurrentlyFocusedMessageIdx(undefined) } @@ -788,7 +878,7 @@ const UserMessageComponent = ({ chatMessage, messageIdx, isLoading }: ChatBubble let chatbubbleContents: React.ReactNode if (mode === 'display') { chatbubbleContents = <> - + {chatMessage.displayContent} } @@ -801,22 +891,27 @@ const UserMessageComponent = ({ chatMessage, messageIdx, isLoading }: ChatBubble if (messageIdx === undefined) return; // cancel any streams on this thread - const thread = chatThreadsService.getCurrentThread() - chatThreadsService.cancelStreaming(thread.id) + const threadId = chatThreadsService.state.currentThreadId + chatThreadsService.stopRunning(threadId) // update state setIsBeingEdited(false) - chatThreadsService.setFocusedMessageIdx(undefined) - chatThreadsService.closeStagingSelectionsInMessage(messageIdx) + chatThreadsService.setCurrentlyFocusedMessageIdx(undefined) + chatThreadsService.closeCurrentStagingSelectionsInMessage({ messageIdx }) // stream the edit const userMessage = textAreaRefState.value; - await chatThreadsService.editUserMessageAndStreamResponse({ userMessage, chatMode: 'agent', messageIdx, }) + try { + await chatThreadsService.editUserMessageAndStreamResponse({ userMessage, messageIdx, threadId }) + } catch (e) { + console.error('Error while editing message:', e) + } + sidebarStateService.fireFocusChat() } const onAbort = () => { const threadId = chatThreadsService.state.currentThreadId - chatThreadsService.cancelStreaming(threadId) + chatThreadsService.stopRunning(threadId) } const onKeyDown = (e: KeyboardEvent) => { @@ -828,11 +923,12 @@ const UserMessageComponent = ({ chatMessage, messageIdx, isLoading }: ChatBubble } } - if (!chatMessage.content && !isLoading) { // don't show if empty and not loading (if loading, want to show). + if (!chatMessage.content && isCommitted) { // don't show if empty and not loading (if loading, want to show). return null } chatbubbleContents = setIsDisabled(!text)} onFocus={() => { setIsFocused(true) - chatThreadsService.setFocusedMessageIdx(messageIdx); + chatThreadsService.setCurrentlyFocusedMessageIdx(messageIdx); }} onBlur={() => { setIsFocused(false) @@ -879,7 +975,7 @@ const UserMessageComponent = ({ chatMessage, messageIdx, isLoading }: ChatBubble className={` text-left rounded-lg max-w-full ${mode === 'edit' ? '' - : mode === 'display' ? 'p-2 flex flex-col gap-1 bg-void-bg-1 text-void-fg-1 overflow-x-auto cursor-pointer' : '' + : mode === 'display' ? 'p-2 flex flex-col bg-void-bg-1 text-void-fg-1 overflow-x-auto cursor-pointer' : '' } `} onClick={() => { if (mode === 'display') { onOpenEdit() } }} @@ -908,15 +1004,99 @@ const UserMessageComponent = ({ chatMessage, messageIdx, isLoading }: ChatBubble }} />} -
    - - } +const SmallProseWrapper = ({ children }: { children: React.ReactNode }) => { + return
    { + prose-h1:text-[14px] + prose-h1:my-4 + + prose-h2:text-[13px] + prose-h2:my-4 + + prose-h3:text-[13px] + prose-h3:my-3 + + prose-h4:text-[13px] + prose-h4:my-2 + + prose-p:my-2 + prose-p:leading-snug + prose-hr:my-2 + + prose-ul:my-2 + prose-ul:pl-4 + prose-ul:list-outside + prose-ul:list-disc + prose-ul:leading-snug + + + prose-ol:my-2 + prose-ol:pl-4 + prose-ol:list-outside + prose-ol:list-decimal + prose-ol:leading-snug + + marker:text-inherit + + prose-blockquote:pl-2 + prose-blockquote:my-2 + + prose-code:text-[12px] + prose-code:before:content-none + prose-code:after:content-none + + prose-pre:text-[12px] + prose-pre:p-2 + prose-pre:my-2 + + prose-table:text-[13px] + '> + {children} +
    +} + +const ProseWrapper = ({ children }: { children: React.ReactNode }) => { + return
    + {children} +
    +} +const AssistantMessageComponent = ({ chatMessage, isCommitted, messageIdx, isLast, chatIsRunning, isToolBeingWritten }: { chatMessage: ChatMessage & { role: 'assistant' }, messageIdx: number, isCommitted: boolean, isLast: boolean, chatIsRunning: IsRunningType, isToolBeingWritten: boolean }) => { const accessor = useAccessor() const chatThreadsService = accessor.get('IChatThreadService') @@ -924,6 +1104,7 @@ const AssistantMessageComponent = ({ chatMessage, isLoading, messageIdx }: ChatB const reasoningStr = chatMessage.reasoning?.trim() || null const hasReasoning = !!reasoningStr + const isDoneReasoning = !!chatMessage.content const thread = chatThreadsService.getCurrentThread() @@ -933,408 +1114,783 @@ const AssistantMessageComponent = ({ chatMessage, isLoading, messageIdx }: ChatB } const isEmpty = !chatMessage.content && !chatMessage.reasoning - if (isEmpty) return null + const isLoading = !isCommitted && !isToolBeingWritten && (chatIsRunning === 'message' || chatIsRunning === 'awaiting_user') + const isLastAndLoading = isLast && isLoading + if (isEmpty && !isLastAndLoading) return null return <> - {/* reasoning token */} - {hasReasoning && } - > - - } + {hasReasoning && + + + + } -
    - - {/* assistant message */} + {/* assistant message */} + - - {isLoading && } - -
    + {/* loading indicator */} + {isLoading && } + } - - -const ToolError = ({ title, errorMessage }: { title: string, errorMessage: string }) => { - return ( -
    - -
    - {title} -
    {'Error: ' + errorMessage}
    +const ReasoningWrapper = ({ isDoneReasoning, isStreaming, children }: { isDoneReasoning: boolean, isStreaming: boolean, children: React.ReactNode }) => { + const isDone = isDoneReasoning || !isStreaming + const isWriting = !isDone + const [isOpen, setIsOpen] = useState(isWriting) + useEffect(() => { + if (!isWriting) setIsOpen(false) // if just finished reasoning, close + }, [isWriting]) + return : ''} isOpen={isOpen} onClick={() => setIsOpen(v => !v)}> + +
    + {children}
    -
    - ) -} - - -const toolNameToTitle: Record = { - 'read_file': 'Read file', - 'list_dir': 'Inspect folder', - 'pathname_search': 'Search (path only)', - 'search': 'Search (file contents)', - 'create_uri': 'Create file', - 'delete_uri': 'Delete file', - 'edit': 'Edit file', - 'terminal_command': 'Ran terminal command' + + } -const ToolRequestAcceptRejectButtons = ({ toolRequest }: { toolRequest: ToolRequestApproval }) => { + +// should either be past or "-ing" tense, not present tense. Eg. when the LLM searches for something, the user expects it to say "I searched for X" or "I am searching for X". Not "I search X". + +const loadingTitleWrapper = (item: React.ReactNode) => { + return + {item} + + +} +const folderFileStr = (isFolder: boolean) => isFolder ? 'folder' : 'file' +const titleOfToolName = { + 'read_file': { done: 'Read file', proposed: 'Read file', running: loadingTitleWrapper('Reading file') }, + 'list_dir': { done: 'Inspected folder', proposed: 'Inspect folder', running: loadingTitleWrapper('Inspecting folder') }, + 'pathname_search': { done: 'Searched by file name', proposed: 'Search by file name', running: loadingTitleWrapper('Searching by file name') }, + 'text_search': { done: 'Searched', proposed: 'Search text', running: loadingTitleWrapper('Searching') }, + 'create_uri': { + done: (isFolder: boolean) => `Created ${folderFileStr(isFolder)}`, + proposed: (isFolder: boolean | null) => isFolder === null ? 'Create URI' : `Create ${folderFileStr(isFolder)}`, + running: (isFolder: boolean) => loadingTitleWrapper(`Creating ${folderFileStr(isFolder)}`) + }, + 'delete_uri': { + done: (isFolder: boolean) => `Deleted ${folderFileStr(isFolder)}`, + proposed: (isFolder: boolean | null) => isFolder === null ? 'Delete URI' : `Delete ${folderFileStr(isFolder)}`, + running: (isFolder: boolean) => loadingTitleWrapper(`Deleting ${folderFileStr(isFolder)}`) + }, + 'edit': { done: `Edited file`, proposed: 'Edit file', running: loadingTitleWrapper('Editing file') }, + 'terminal_command': { done: `Ran terminal command`, proposed: 'Run terminal command', running: loadingTitleWrapper('Running terminal command') } +} as const satisfies Record + + + +const toolNameToDesc = (toolName: ToolName, _toolParams: ToolCallParams[ToolName] | undefined): string => { + + if (!_toolParams) { + return ''; + } + + if (toolName === 'read_file') { + const toolParams = _toolParams as ToolCallParams['read_file'] + return getBasename(toolParams.uri.fsPath); + } else if (toolName === 'list_dir') { + const toolParams = _toolParams as ToolCallParams['list_dir'] + return `${getFolderName(toolParams.rootURI.fsPath)}`; + } else if (toolName === 'pathname_search') { + const toolParams = _toolParams as ToolCallParams['pathname_search'] + return `"${toolParams.queryStr}"`; + } else if (toolName === 'text_search') { + const toolParams = _toolParams as ToolCallParams['text_search'] + return `"${toolParams.queryStr}"`; + } else if (toolName === 'create_uri') { + const toolParams = _toolParams as ToolCallParams['create_uri'] + return toolParams.isFolder ? getFolderName(toolParams.uri.fsPath) : getBasename(toolParams.uri.fsPath); + } else if (toolName === 'delete_uri') { + const toolParams = _toolParams as ToolCallParams['delete_uri'] + return toolParams.isFolder ? getFolderName(toolParams.uri.fsPath) : getBasename(toolParams.uri.fsPath); + } else if (toolName === 'edit') { + const toolParams = _toolParams as ToolCallParams['edit'] + return getBasename(toolParams.uri.fsPath); + } else if (toolName === 'terminal_command') { + const toolParams = _toolParams as ToolCallParams['terminal_command'] + return `"${toolParams.command}"`; + } else { + return '' + } +} + + +const ToolRequestAcceptRejectButtons = () => { const accessor = useAccessor() const chatThreadsService = accessor.get('IChatThreadService') - return <> -
    { chatThreadsService.approveTool(toolRequest.voidToolId) }}>Accept
    -
    { chatThreadsService.rejectTool(toolRequest.voidToolId) }}>Reject
    - + const metricsService = accessor.get('IMetricsService') + const voidSettingsService = accessor.get('IVoidSettingsService') + const voidSettingsState = useSettingsState() + + const onAccept = useCallback(() => { + try { // this doesn't need to be wrapped in try/catch anymore + const threadId = chatThreadsService.state.currentThreadId + chatThreadsService.approveTool(threadId) + metricsService.capture('Tool Request Accepted', {}) + } catch (e) { console.error('Error while approving message in chat:', e) } + }, [chatThreadsService, metricsService]) + + const onReject = useCallback(() => { + try { + const threadId = chatThreadsService.state.currentThreadId + chatThreadsService.rejectTool(threadId) + } catch (e) { console.error('Error while approving message in chat:', e) } + metricsService.capture('Tool Request Rejected', {}) + }, [chatThreadsService, metricsService]) + + const onToggleAutoApprove = useCallback((newValue: boolean) => { + voidSettingsService.setGlobalSetting('autoApprove', newValue) + metricsService.capture('Tool Auto-Accept Toggle', { enabled: newValue }) + }, [voidSettingsService, metricsService]) + + const approveButton = ( + + ) + + const cancelButton = ( + + ) + + const autoApproveToggle = ( +
    + + Auto-approve +
    + ) + + return
    + {approveButton} + {cancelButton} + {autoApproveToggle} +
    } -const toolNameToComponent: { [T in ToolName]: { - requestWrapper: (props: { toolRequest: ToolRequestApproval }) => React.ReactNode, - resultWrapper: (props: { toolMessage: ToolMessage & { result: { type: 'success' } } }) => React.ReactNode, -} } = { +export const ToolChildrenWrapper = ({ children, className }: { children: React.ReactNode, className?: string }) => { + return
    +
    + {children} +
    +
    +} +export const CodeChildren = ({ children }: { children: React.ReactNode }) => { + return
    +
    + {children} +
    +
    +} + +export const ListableToolItem = ({ name, onClick, isSmall, className, showDot }: { name: React.ReactNode, onClick?: () => void, isSmall?: boolean, className?: string, showDot?: boolean }) => { + return
    + {showDot === false ? null :
    } +
    {name}
    +
    +} + +const EditToolApplyButton = ({ changeDescription, applyBoxId, uri }: { changeDescription: string, applyBoxId: string, uri: URI }) => { + const { statusIndicatorHTML, buttonsHTML } = useApplyButtonHTML({ codeStr: changeDescription, applyBoxId, uri }) + return
    + {statusIndicatorHTML} + {buttonsHTML} +
    +} + + +const EditToolChildren = ({ uri, changeDescription }: { uri: URI, changeDescription: string }) => { + return
    + + + +
    +} + + +type ToolRequestState = 'awaiting_user' | 'running' + +type RequestWrapper = (props: { toolRequest: ToolRequestApproval, messageIdx: number, toolRequestState: ToolRequestState, threadId: string }) => React.ReactNode +type ResultWrapper = (props: { toolMessage: ToolMessage, messageIdx: number, threadId: string }) => React.ReactNode + +type ToolComponent = { + requestWrapper: T extends ToolNameWithApproval ? RequestWrapper : null, + resultWrapper: ResultWrapper, +} + +const toolNameToComponent: { [T in ToolName]: ToolComponent } = { 'read_file': { - requestWrapper: ({ toolRequest }) => { - const accessor = useAccessor() - const commandService = accessor.get('ICommandService') - const title = toolNameToTitle[toolRequest.name] - const { params } = toolRequest - return } - onClick={() => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) }} - /> - }, + requestWrapper: null, resultWrapper: ({ toolMessage }) => { const accessor = useAccessor() const commandService = accessor.get('ICommandService') - const title = toolNameToTitle[toolMessage.name] - const { value, params } = toolMessage.result - return }> -
    { commandService.executeCommand('vscode.open', params.uri, { preview: true }) }} - > -
    - {params.uri.fsPath} -
    - {value.hasNextPage && (
    AI can scroll for more content...
    )} + const title = toolMessage.result.type === 'success' ? titleOfToolName[toolMessage.name].done : titleOfToolName[toolMessage.name].proposed + const { uri } = toolMessage.result.params ?? {} + const desc1 = uri ? getBasename(uri.fsPath) : ''; + const icon = null -
    + if (toolMessage.result.type === 'rejected') return null // will never happen, not rejectable + + const isError = toolMessage.result.type === 'error' + const componentParams: ToolHeaderParams = { title, desc1, isError, icon } + + if (toolMessage.result.type === 'success') { + const { value, params } = toolMessage.result + componentParams.onClick = () => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) } + if (value.hasNextPage) componentParams.desc2 = `(AI can scroll for more)` + } + else { + const { value, params } = toolMessage.result + if (params) componentParams.desc2 = + componentParams.children = + + {value} + + + } + + return }, }, 'list_dir': { - requestWrapper: ({ toolRequest }) => { - const title = toolNameToTitle[toolRequest.name] - const { params } = toolRequest - return } /> - }, + requestWrapper: null, resultWrapper: ({ toolMessage }) => { const accessor = useAccessor() const commandService = accessor.get('ICommandService') const explorerService = accessor.get('IExplorerService') - const title = toolNameToTitle[toolMessage.name] - // message.result.hasNextPage = true - // message.result.itemsRemaining = 400 + const title = toolMessage.result.type === 'success' ? titleOfToolName[toolMessage.name].done : titleOfToolName[toolMessage.name].proposed + const desc1 = toolNameToDesc(toolMessage.name, toolMessage.result.params) + const icon = null - const { value, params } = toolMessage.result - return } - > - {value.children?.map((child, i) => ( -
    { - commandService.executeCommand('workbench.view.explorer'); - explorerService.select(child.uri, true); - }} - > -
    - {`${child.name}${child.isDirectory ? '/' : ''}`} -
    - ))} - {value.hasNextPage && ( -
    - {value.itemsRemaining} more items... -
    - )} -
    + if (toolMessage.result.type === 'rejected') return null // will never happen, not rejectable + const isError = toolMessage.result.type === 'error' + const componentParams: ToolHeaderParams = { title, desc1, isError, icon } + + if (toolMessage.result.type === 'success') { + const { value, params } = toolMessage.result + componentParams.numResults = value.children?.length + componentParams.hasNextPage = value.hasNextPage + componentParams.children = !value.children || (value.children.length ?? 0) === 0 ? undefined + : + {value.children.map((child, i) => ( { + commandService.executeCommand('vscode.open', child.uri, { preview: true }) + // commandService.executeCommand('workbench.view.explorer'); // open in explorer folders view instead + // explorerService.select(child.uri, true); + }} + />))} + {value.hasNextPage && + + } + + } + else { + const { value, params } = toolMessage.result + componentParams.children = + + {value} + + + } + + return } }, 'pathname_search': { - requestWrapper: ({ toolRequest }) => { - const title = toolNameToTitle[toolRequest.name] - const { params } = toolRequest - return } /> - }, - resultWrapper: ({ toolMessage }) => { - - const accessor = useAccessor() - const commandService = accessor.get('ICommandService') - const title = toolNameToTitle[toolMessage.name] - - const { value, params } = toolMessage.result - return ( - } - > - {value.uris.map((uri, i) => ( -
    { - commandService.executeCommand('vscode.open', uri, { preview: true }) - }} - > -
    - {uri.fsPath.split('/').pop()} -
    - )) - } - {value.hasNextPage && ( -
    - More results available... -
    - )} -
    - ) - } - }, - 'search': { - requestWrapper: ({ toolRequest }) => { - const title = toolNameToTitle[toolRequest.name] - const { params } = toolRequest - return } /> - }, + requestWrapper: null, resultWrapper: ({ toolMessage }) => { const accessor = useAccessor() const commandService = accessor.get('ICommandService') - const title = toolNameToTitle[toolMessage.name] + const isError = toolMessage.result.type === 'error' + const title = toolMessage.result.type === 'success' ? titleOfToolName[toolMessage.name].done : titleOfToolName[toolMessage.name].proposed + const desc1 = toolNameToDesc(toolMessage.name, toolMessage.result.params) + const icon = null - const { value, params } = toolMessage.result - return ( - } - > - {value.uris.map((uri, i) => ( -
    + {value.uris.map((uri, i) => ( { commandService.executeCommand('vscode.open', uri, { preview: true }) }} - > -
    - {uri.fsPath.split('/').pop()} -
    - ))} - {value.hasNextPage && (
    More results available...
    )} -
    - ) + />))} + {value.hasNextPage && + + } + + + } + else { + const { value, params } = toolMessage.result + componentParams.children = + + {value} + + + } + + return } }, + 'text_search': { + requestWrapper: null, + resultWrapper: ({ toolMessage }) => { + const accessor = useAccessor() + const commandService = accessor.get('ICommandService') + const isError = toolMessage.result.type === 'error' + const title = toolMessage.result.type === 'success' ? titleOfToolName[toolMessage.name].done : titleOfToolName[toolMessage.name].proposed + const desc1 = toolNameToDesc(toolMessage.name, toolMessage.result.params) + const icon = null + + if (toolMessage.result.type === 'rejected') return null // will never happen, not rejectable + + const componentParams: ToolHeaderParams = { title, desc1, isError, icon } + + if (toolMessage.result.type === 'success') { + const { value, params } = toolMessage.result + componentParams.numResults = value.uris.length + componentParams.hasNextPage = value.hasNextPage + componentParams.children = value.uris.length === 0 ? undefined + : + {value.uris.map((uri, i) => ( { commandService.executeCommand('vscode.open', uri, { preview: true }) }} + />))} + {value.hasNextPage && + + } + + + } + else { + const { value, params } = toolMessage.result + componentParams.children = + + {value} + + + } + return + } + }, + + // --- 'create_uri': { - requestWrapper: ({ toolRequest }) => { - const title = toolNameToTitle[toolRequest.name] - const { params } = toolRequest - return } /> + requestWrapper: ({ toolRequest, toolRequestState }) => { + const accessor = useAccessor() + const commandService = accessor.get('ICommandService') + const explorerService = accessor.get('IExplorerService') + const isError = false + const isFolder = toolRequest.params.isFolder + const title = toolRequestState === 'awaiting_user' ? titleOfToolName[toolRequest.name].proposed(isFolder) : titleOfToolName[toolRequest.name].running(isFolder) + const desc1 = toolNameToDesc(toolRequest.name, toolRequest.params) + const icon = null + + const componentParams: ToolHeaderParams = { title, desc1, isError, icon, } + + return }, resultWrapper: ({ toolMessage }) => { const accessor = useAccessor() const commandService = accessor.get('ICommandService') - const title = toolNameToTitle[toolMessage.name] - const { params } = toolMessage.result - return ( - { commandService.executeCommand('vscode.open', params.uri, { preview: true }) }} - icon={} - /> - ) + const isError = toolMessage.result.type === 'error' + const isRejected = toolMessage.result.type === 'rejected' + const isFolder = toolMessage.result.params?.isFolder ?? false + const title = toolMessage.result.type === 'success' ? titleOfToolName[toolMessage.name].done(isFolder) : titleOfToolName[toolMessage.name].proposed(isFolder) + const desc1 = toolNameToDesc(toolMessage.name, toolMessage.result.params) + const icon = null + + const componentParams: ToolHeaderParams = { title, desc1, isError, icon, isRejected } + + if (toolMessage.result.type === 'success') { + const { params, value } = toolMessage.result + componentParams.onClick = () => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) } + } + else if (toolMessage.result.type === 'rejected') { + const { params } = toolMessage.result + componentParams.onClick = () => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) } + } + else if (toolMessage.result.type === 'error') { + const { params, value } = toolMessage.result + if (params) { componentParams.onClick = () => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) } } + componentParams.children = componentParams.children = + + {value} + + + } + + return } }, 'delete_uri': { - requestWrapper: ({ toolRequest }) => { + requestWrapper: ({ toolRequest, toolRequestState }) => { const accessor = useAccessor() const commandService = accessor.get('ICommandService') - const title = toolNameToTitle[toolRequest.name] + const isError = false + const isFolder = toolRequest.params.isFolder + const title = toolRequestState === 'awaiting_user' ? titleOfToolName[toolRequest.name].proposed(isFolder) : titleOfToolName[toolRequest.name].running(isFolder) + const desc1 = toolNameToDesc(toolRequest.name, toolRequest.params) + const icon = null + + const componentParams: ToolHeaderParams = { title, desc1, isError, icon, } + const { params } = toolRequest - return { commandService.executeCommand('vscode.open', params.uri, { preview: true }) }} - /> + componentParams.onClick = () => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) } + + return }, resultWrapper: ({ toolMessage }) => { const accessor = useAccessor() const commandService = accessor.get('ICommandService') - const title = toolNameToTitle[toolMessage.name] - const { params } = toolMessage.result - return ( - { commandService.executeCommand('vscode.open', params.uri, { preview: true }) }} - /> - ) + const isFolder = toolMessage.result.params?.isFolder ?? false + const isError = toolMessage.result.type === 'error' + const isRejected = toolMessage.result.type === 'rejected' + const title = toolMessage.result.type === 'success' ? titleOfToolName[toolMessage.name].done(isFolder) : titleOfToolName[toolMessage.name].proposed(isFolder) + const desc1 = toolNameToDesc(toolMessage.name, toolMessage.result.params) + const icon = null + + const componentParams: ToolHeaderParams = { title, desc1, isError, icon, isRejected } + + if (toolMessage.result.type === 'success') { + const { params, value } = toolMessage.result + componentParams.onClick = () => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) } + } + else if (toolMessage.result.type === 'rejected') { + const { params } = toolMessage.result + componentParams.onClick = () => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) } + } + else if (toolMessage.result.type === 'error') { + const { params, value } = toolMessage.result + if (params) { componentParams.onClick = () => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) } } + componentParams.children = componentParams.children = + + {value} + + + } + + return } }, 'edit': { - requestWrapper: ({ toolRequest }) => { + requestWrapper: ({ toolRequest, messageIdx, toolRequestState, threadId }) => { const accessor = useAccessor() - const commandService = accessor.get('ICommandService') - const title = toolNameToTitle[toolRequest.name] - const { params } = toolRequest - return } - onClick={() => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) }} - > - - - }, - resultWrapper: ({ toolMessage }) => { - const accessor = useAccessor() - const commandService = accessor.get('ICommandService') - const title = toolNameToTitle[toolMessage.name] + const isError = false + const title = toolRequestState === 'awaiting_user' ? titleOfToolName[toolRequest.name].proposed : titleOfToolName[toolRequest.name].running + const desc1 = toolNameToDesc(toolRequest.name, toolRequest.params) + const icon = null - const { params } = toolMessage.result - return ( - { commandService.executeCommand('vscode.open', params.uri, { preview: true }) }} - icon={} + const componentParams: ToolHeaderParams = { title, desc1, isError, icon, } + + const { params } = toolRequest + componentParams.children = + - ) + + + componentParams.desc2 = + + return + }, + resultWrapper: ({ toolMessage, messageIdx, threadId }) => { + const accessor = useAccessor() + const isError = toolMessage.result.type === 'error' + const isRejected = toolMessage.result.type === 'rejected' + const title = toolMessage.result.type === 'success' ? titleOfToolName[toolMessage.name].done : titleOfToolName[toolMessage.name].proposed + const desc1 = toolNameToDesc(toolMessage.name, toolMessage.result.params) + const icon = null + + const componentParams: ToolHeaderParams = { title, desc1, isError, icon, isRejected } + + if (toolMessage.result.type === 'success' || toolMessage.result.type === 'rejected' || toolMessage.result.type === 'error') { + const { params } = toolMessage.result + + // add apply box + if (params) { + const applyBoxId = getApplyBoxId({ + threadId: threadId, + messageIdx: messageIdx, + tokenIdx: 'N/A', + }) + componentParams.desc2 = + } + + // add children + if (toolMessage.result.type !== 'error') { + const { params } = toolMessage.result + componentParams.children = + + + } + else { + // error + const { params, value } = toolMessage.result + if (params) { + componentParams.children = + {/* error */} + + {value} + + + {/* content */} + + + } + else { + componentParams.children = + {value} + + } + } + } + + return } }, 'terminal_command': { - requestWrapper: ({ toolRequest }) => { + requestWrapper: ({ toolRequest, toolRequestState }) => { const accessor = useAccessor() const commandService = accessor.get('ICommandService') - const title = toolNameToTitle[toolRequest.name] - const { params } = toolRequest - return } - // TODO!!! open the terminal with that ID - /> + const terminalToolsService = accessor.get('ITerminalToolService') + const isError = false + const title = toolRequestState === 'awaiting_user' ? titleOfToolName[toolRequest.name].proposed : titleOfToolName[toolRequest.name].running + const desc1 = toolNameToDesc(toolRequest.name, toolRequest.params) + const icon = null + + const componentParams: ToolHeaderParams = { title, desc1, isError, icon, } + + const { proposedTerminalId, waitForCompletion } = toolRequest.params + if (terminalToolsService.terminalExists(proposedTerminalId)) + componentParams.onClick = () => terminalToolsService.openTerminal(proposedTerminalId) + if (!waitForCompletion) + componentParams.desc2 = '(background task)' + + return }, resultWrapper: ({ toolMessage }) => { const accessor = useAccessor() const commandService = accessor.get('ICommandService') - const title = toolNameToTitle[toolMessage.name] + const terminalToolsService = accessor.get('ITerminalToolService') + const isError = toolMessage.result.type === 'error' + const title = toolMessage.result.type === 'success' ? titleOfToolName[toolMessage.name].done : titleOfToolName[toolMessage.name].proposed + const desc1 = toolNameToDesc(toolMessage.name, toolMessage.result.params) + const icon = null - const { params } = toolMessage.result - return ( - } - > -
    -
    - + const isRejected = toolMessage.result.type === 'rejected' + const componentParams: ToolHeaderParams = { title, desc1, isError, icon, isRejected } + + if (toolMessage.result.type === 'success') { + const { value, params } = toolMessage.result + const { command } = params + const { terminalId, resolveReason, result } = value + + const resultStr = resolveReason.type === 'done' ? (resolveReason.exitCode !== 0 ? `\nError: exit code ${resolveReason.exitCode}` : null) + : resolveReason.type === 'bgtask' ? null : + resolveReason.type === 'timeout' ? `\n(partial results; request timed out)` : + resolveReason.type === 'toofull' ? `\n(truncated)` + : null + + componentParams.children = + terminalToolsService.openTerminal(terminalId)} + /> +
    + {resolveReason.type === 'bgtask' ? 'Result so far:\n' : null} + {result} + {resultStr}
    - - ) +
    + + + if (resolveReason.type === 'bgtask') + componentParams.desc2 = '(background task)' + } + else if (toolMessage.result.type === 'rejected' || toolMessage.result.type === 'error') { + const { params } = toolMessage.result + if (params) { + const { proposedTerminalId, waitForCompletion } = params + if (terminalToolsService.terminalExists(proposedTerminalId)) + componentParams.onClick = () => terminalToolsService.openTerminal(proposedTerminalId) + if (!waitForCompletion) + componentParams.desc2 = '(background task)' + } + if (toolMessage.result.type === 'error') { + const { value } = toolMessage.result + componentParams.children = {value} + } + } + + return } } - }; type ChatBubbleMode = 'display' | 'edit' -type ChatBubbleProps = { chatMessage: ChatMessage, messageIdx: number, isLoading?: boolean, } -const ChatBubble = ({ chatMessage, isLoading, messageIdx }: ChatBubbleProps) => { +type ChatBubbleProps = { + chatMessage: ChatMessage, + messageIdx: number, + isCommitted: boolean, + isLast: boolean, // includes the streaming message (if streaming, isLast is false except for the streaming message) + chatIsRunning: IsRunningType, + threadId: string, + isToolBeingWritten: boolean, +} +const ChatBubble = ({ chatMessage, isCommitted, messageIdx, isLast, chatIsRunning, threadId, isToolBeingWritten }: ChatBubbleProps) => { const role = chatMessage.role if (role === 'user') { return } else if (role === 'assistant') { return } else if (role === 'tool_request') { - const isLastMessage = true // TODO!!! fix this - if (!isLastMessage) return null - const ToolRequestComponent = toolNameToComponent[chatMessage.name].requestWrapper as React.FC<{ toolRequest: any }> // ts isnt smart enough... - return <> - - - + const ToolRequestWrapper = toolNameToComponent[chatMessage.name]?.requestWrapper as RequestWrapper + const toolRequestType = ( + chatIsRunning === 'awaiting_user' ? 'awaiting_user' + : chatIsRunning === 'tool' ? 'running' + : null + ) + if (ToolRequestWrapper && isLast) { // if it's the last message + return <> + {toolRequestType !== null && } + {chatIsRunning === 'awaiting_user' && } + + } + return null } else if (role === 'tool') { - const title = toolNameToTitle[chatMessage.name] - if (chatMessage.result.type === 'error') return - const ToolResultComponent = toolNameToComponent[chatMessage.name].resultWrapper as React.FC<{ toolMessage: any }> // ts isnt smart enough... - return + const ToolResultWrapper = toolNameToComponent[chatMessage.name]?.resultWrapper as ResultWrapper + if (ToolResultWrapper) + return + return null } - } -export const SidebarChat = () => { + +const CommandBarInChat = () => { + const { state: commandBarState, sortedURIs: sortedCommandBarURIs } = useCommandBarState() + const [isExpanded, setIsExpanded] = useState(false) + + const accessor = useAccessor() + const commandService = accessor.get('ICommandService') + + if (!sortedCommandBarURIs || sortedCommandBarURIs.length === 0) { + return null + } + + return ( + + {sortedCommandBarURIs.map((uri, i) => ( + { commandService.executeCommand('vscode.open', uri, { preview: true }) }} + /> + ))} + + + ) +} + + +export const SidebarChat = () => { const textAreaRef = useRef(null) const textAreaFnsRef = useRef(null) const accessor = useAccessor() - // const modelService = accessor.get('IModelService') const commandService = accessor.get('ICommandService') const chatThreadsService = accessor.get('IChatThreadService') @@ -1345,8 +1901,8 @@ export const SidebarChat = () => { useEffect(() => { const disposables: IDisposable[] = [] disposables.push( - sidebarStateService.onDidFocusChat(() => { !chatThreadsService.isFocusingMessage() && textAreaRef.current?.focus() }), - sidebarStateService.onDidBlurChat(() => { !chatThreadsService.isFocusingMessage() && textAreaRef.current?.blur() }) + sidebarStateService.onDidFocusChat(() => { !chatThreadsService.isCurrentlyFocusingMessage() && textAreaRef.current?.focus() }), + sidebarStateService.onDidBlurChat(() => { !chatThreadsService.isCurrentlyFocusingMessage() && textAreaRef.current?.blur() }) ) return () => disposables.forEach(d => d.dispose()) }, [sidebarStateService, textAreaRef]) @@ -1364,11 +1920,15 @@ export const SidebarChat = () => { // stream state const currThreadStreamState = useChatThreadsStreamState(chatThreadsState.currentThreadId) - const isStreaming = !!currThreadStreamState?.streamingToken + const isRunning = currThreadStreamState?.isRunning const latestError = currThreadStreamState?.error const messageSoFar = currThreadStreamState?.messageSoFar const reasoningSoFar = currThreadStreamState?.reasoningSoFar + const toolNameSoFar = currThreadStreamState?.toolNameSoFar + const toolParamsSoFar = currThreadStreamState?.toolParamsSoFar + const toolIsLoading = !!toolNameSoFar && toolNameSoFar === 'edit' // show loading for slow tools (right now just edit) + // ----- SIDEBAR CHAT state (local) ----- // state of current message @@ -1377,94 +1937,111 @@ export const SidebarChat = () => { const isDisabled = instructionsAreEmpty || !!isFeatureNameDisabled('Chat', settingsState) - const [sidebarRef, sidebarDimensions] = useResizeObserver() - const [chatAreaRef, chatAreaDimensions] = useResizeObserver() - const [historyRef, historyDimensions] = useResizeObserver() + const sidebarRef = useRef(null) + const scrollContainerRef = useRef(null) useScrollbarStyles(sidebarRef) - const onSubmit = useCallback(async () => { if (isDisabled) return - if (isStreaming) return + if (isRunning) return + + const threadId = chatThreadsService.state.currentThreadId // update state - chatThreadsService.closeStagingSelectionsInCurrentThread() // close all selections + chatThreadsService.closeCurrentStagingSelectionsInThread() // close all selections // send message to LLM const userMessage = textAreaRef.current?.value ?? '' - // getModelCapabilities() // TODO!!! check if can go into agent mode - - await chatThreadsService.addUserMessageAndStreamResponse({ userMessage, chatMode: 'agent' }) + try { + await chatThreadsService.addUserMessageAndStreamResponse({ userMessage, threadId }) + } catch (e) { + console.error('Error while sending message in chat:', e) + } setSelections([]) // clear staging textAreaFnsRef.current?.setValue('') textAreaRef.current?.focus() // focus input after submit - }, [chatThreadsService, isDisabled, isStreaming, textAreaRef, textAreaFnsRef, setSelections]) + }, [chatThreadsService, isDisabled, isRunning, textAreaRef, textAreaFnsRef, setSelections, settingsState]) const onAbort = () => { const threadId = currentThread.id - chatThreadsService.cancelStreaming(threadId) + chatThreadsService.stopRunning(threadId) } - // const [_test_messages, _set_test_messages] = useState([]) - const keybindingString = accessor.get('IKeybindingService').lookupKeybinding(VOID_CTRL_L_ACTION_ID)?.getLabel() // scroll to top on thread switch - const scrollContainerRef = useRef(null) useEffect(() => { if (isHistoryOpen) scrollContainerRef.current?.scrollTo({ top: 0, left: 0 }) }, [isHistoryOpen, currentThread.id]) + const numMessages = previousMessages.length - const pastMessagesHTML = useMemo(() => { - return previousMessages.map((message, i) => - - ) - }, [previousMessages, currentThread]) + const previousMessagesHTML = useMemo(() => { + const threadId = currentThread.id + return previousMessages.map((message, i) => { + const isLast = i === numMessages - 1 && (isRunning === 'tool' || isRunning === 'awaiting_user') + return + }) + }, [previousMessages, isRunning, currentThread, numMessages]) - - - const streamingChatIdx = pastMessagesHTML.length - const currStreamingMessageHTML = !!(reasoningSoFar || messageSoFar || isStreaming) ? + const threadId = currentThread.id + const streamingChatIdx = previousMessagesHTML.length + const currStreamingMessageHTML = !!(reasoningSoFar || messageSoFar || isRunning) ? : null - const allMessagesHTML = [...pastMessagesHTML, currStreamingMessageHTML] + const proposed = toolNameSoFar && toolNames.includes(toolNameSoFar as ToolName) ? titleOfToolName[toolNameSoFar as ToolName]?.proposed : toolNameSoFar + const toolTitle = typeof proposed === 'function' ? proposed(null) : proposed + const currStreamingToolHTML = toolIsLoading ? + Getting parameters} /> + : null - const threadSelector =
    - - const messagesHTML = {/* previous messages */} {allMessagesHTML} @@ -1492,42 +2069,49 @@ export const SidebarChat = () => { const onKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { onSubmit() - } else if (e.key === 'Escape' && isStreaming) { + } else if (e.key === 'Escape' && isRunning) { onAbort() } - }, [onSubmit, onAbort, isStreaming]) - const inputForm =
    0 ? 'absolute bottom-0' : ''}`}> + }, [onSubmit, onAbort, isRunning]) + + const inputForm =
    { textAreaRef.current?.focus() }} > { chatThreadsService.setFocusedMessageIdx(undefined) }} + onFocus={() => { chatThreadsService.setCurrentlyFocusedMessageIdx(undefined) }} ref={textAreaRef} fnsRef={textAreaFnsRef} multiline={true} /> +
    - return
    - {threadSelector} - - {messagesHTML} - - {inputForm} - -
    + return ( +
    + {threadSelector} +
    +
    + {messagesHTML} +
    + {inputForm} +
    +
    + ) } diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx index bdf2ae0d..2e0b4bd2 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx @@ -29,11 +29,11 @@ export const SidebarThreadSelector = () => { // sorted by most recent to least recent const sortedThreadIds = Object.keys(allThreads ?? {}) - .sort((threadId1, threadId2) => allThreads![threadId1].lastModified > allThreads![threadId2].lastModified ? -1 : 1) - .filter(threadId => allThreads![threadId].messages.length !== 0) + .sort((threadId1, threadId2) => (allThreads[threadId1]?.lastModified ?? 0) > (allThreads[threadId2]?.lastModified ?? 0) ? -1 : 1) + .filter(threadId => (allThreads![threadId]?.messages.length ?? 0) !== 0) return ( -
    +
    {/* title */} @@ -63,12 +63,16 @@ export const SidebarThreadSelector = () => { if (!allThreads) { return
  • {`Error accessing chat history.`}
  • ; } - const pastThread = allThreads[threadId]; + if (!pastThread) { + return
  • {`Error accessing chat history.`}
  • ; + } + + let firstMsg = null; // let secondMsg = null; - const firstUserMsgIdx = pastThread.messages.findIndex((msg) => msg.role !== 'tool' && msg.role !== 'tool_request'); + const firstUserMsgIdx = pastThread.messages.findIndex((msg) => msg.role !== 'tool' && msg.role !== 'tool_request'); if (firstUserMsgIdx !== -1) { // firstMsg = truncate(pastThread.messages[firstMsgIdx].displayContent ?? ''); @@ -102,7 +106,7 @@ export const SidebarThreadSelector = () => { `} onClick={() => chatThreadsService.switchToThread(pastThread.id)} onDoubleClick={() => sidebarStateService.setState({ isHistoryOpen: false })} - title={new Date(pastThread.createdAt).toLocaleString()} + title={new Date(pastThread.lastModified).toLocaleString()} >
    {`${firstMsg}`}
    {`\u00A0(${numMessages})`}
    diff --git a/src/vs/workbench/contrib/void/browser/react/src/util/helpers.tsx b/src/vs/workbench/contrib/void/browser/react/src/util/helpers.tsx index 32ab1902..d76f131a 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/util/helpers.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/util/helpers.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' @@ -17,3 +17,12 @@ export const useRefState = (initVal: T): ReturnType => { }, []) return [ref, setState] } + + +export const usePromise = (promise: Promise): T | undefined => { + const [val, setVal] = useState(undefined) + useEffect(() => { + promise.then((v) => setVal(v)) + }, [promise]) + return val +} 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 f4042a2e..ec2b2e94 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 @@ -316,18 +316,18 @@ export const VoidSlider = ({ {/* Track */}
    {/* Filled part of track */}
    @@ -460,7 +460,7 @@ export const VoidCheckBox = ({ label, value, onClick, className }: { label: stri -export const VoidCustomDropdownBox = ({ +export const VoidCustomDropdownBox = >({ options, selectedOption, onChangeOption, @@ -471,7 +471,8 @@ export const VoidCustomDropdownBox = ({ className, arrowTouchesText = true, matchInputWidth = false, - gap = 0, + gapPx = 0, + offsetPx = -6, }: { options: T[]; selectedOption: T | undefined; @@ -483,7 +484,8 @@ export const VoidCustomDropdownBox = ({ className?: string; arrowTouchesText?: boolean; matchInputWidth?: boolean; - gap?: number; + gapPx?: number; + offsetPx?: number; }) => { const [isOpen, setIsOpen] = useState(false); const measureRef = useRef(null); @@ -502,7 +504,7 @@ export const VoidCustomDropdownBox = ({ placement: 'bottom-start', middleware: [ - offset(gap), + offset({ mainAxis: gapPx, crossAxis: offsetPx }), flip({ boundary: document.body, padding: 8 @@ -537,7 +539,7 @@ export const VoidCustomDropdownBox = ({ // if the selected option is null, set the selection to the 0th option useEffect(() => { if (options.length === 0) return - if (selectedOption) return + if (selectedOption !== undefined) return onChangeOption(options[0]) }, [selectedOption, onChangeOption, options]) @@ -566,7 +568,7 @@ export const VoidCustomDropdownBox = ({ return () => document.removeEventListener('mousedown', handleClickOutside); }, [isOpen, refs.floating, refs.reference]); - if (!selectedOption) + if (selectedOption === undefined) return null return ( @@ -785,8 +787,8 @@ const normalizeIndentation = (code: string): string => { const modelOfEditorId: { [id: string]: ITextModel | undefined } = {} -export type VoidCodeEditorProps = { initValue: string, language?: string, maxHeight?: number, showScrollbars?: boolean } -export const VoidCodeEditor = ({ initValue, language, maxHeight, showScrollbars }: VoidCodeEditorProps) => { +export type BlockCodeProps = { initValue: string, language?: string, maxHeight?: number, showScrollbars?: boolean } +export const BlockCode = ({ initValue, language, maxHeight, showScrollbars }: BlockCodeProps) => { initValue = normalizeIndentation(initValue) @@ -801,7 +803,6 @@ export const VoidCodeEditor = ({ initValue, language, maxHeight, showScrollbars // const languageDetectionService = accessor.get('ILanguageDetectionService') const modelService = accessor.get('IModelService') - const id = useId() // these are used to pass to the model creation of modelRef @@ -882,9 +883,11 @@ export const VoidCodeEditor = ({ initValue, language, maxHeight, showScrollbars }, [instantiationService])} onCreateInstance={useCallback((editor: CodeEditorWidget) => { + const languageId = languageRef.current ? languageRef.current : 'plaintext' + const model = modelOfEditorId[id] ?? modelService.createModel( - initValueRef.current + '\n', { - languageId: languageRef.current ? languageRef.current : 'typescript', + initValueRef.current, { + languageId: languageId, onDidChange: (e) => { return { dispose: () => { } } } // no idea why they'd require this }) modelRef.current = model @@ -921,7 +924,7 @@ export const VoidCodeEditor = ({ initValue, language, maxHeight, showScrollbars export const VoidButton = ({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick: () => void }) => { return } diff --git a/src/vs/workbench/contrib/void/browser/react/src/util/mountFnGenerator.tsx b/src/vs/workbench/contrib/void/browser/react/src/util/mountFnGenerator.tsx index 9839201d..6114c8ff 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/util/mountFnGenerator.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/util/mountFnGenerator.tsx @@ -18,9 +18,21 @@ export const mountFnGenerator = (Component: (params: any) => React.ReactNode) => const disposables = _registerServices(accessor) - const root = ReactDOM.createRoot(rootElement) - root.render(); // tailwind dark theme indicator - return disposables + const rerender = (props?: any) => { + root.render(); // tailwind dark theme indicator + } + const dispose = () => { + root.unmount(); + disposables.forEach(d => d.dispose()); + } + + rerender(props) + + const returnVal = { + rerender, + dispose, + } + return returnVal } diff --git a/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx b/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx index 447854aa..95060cb2 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx @@ -9,8 +9,6 @@ import { IDisposable } from '../../../../../../../base/common/lifecycle.js' import { VoidSidebarState } from '../../../sidebarStateService.js' import { VoidSettingsState } from '../../../../../../../workbench/contrib/void/common/voidSettingsService.js' import { ColorScheme } from '../../../../../../../platform/theme/common/theme.js' -import { VoidUriState } from '../../../voidUriStateService.js'; -import { VoidQuickEditState } from '../../../quickEditStateService.js' import { RefreshModelStateOfProvider } from '../../../../../../../workbench/contrib/void/common/refreshModelService.js' import { ServicesAccessor } from '../../../../../../../editor/browser/editorExtensions.js'; @@ -24,10 +22,8 @@ import { IThemeService } from '../../../../../../../platform/theme/common/themeS import { ILLMMessageService } from '../../../../common/sendLLMMessageService.js'; import { IRefreshModelService } from '../../../../../../../workbench/contrib/void/common/refreshModelService.js'; import { IVoidSettingsService } from '../../../../../../../workbench/contrib/void/common/voidSettingsService.js'; -import { IEditCodeService, URIStreamState } from '../../../editCodeServiceInterface.js' +import { IEditCodeService } from '../../../editCodeServiceInterface.js' -import { IVoidUriStateService } from '../../../voidUriStateService.js'; -import { IQuickEditStateService } from '../../../quickEditStateService.js'; import { ISidebarStateService } from '../../../sidebarStateService.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js' import { ICodeEditorService } from '../../../../../../../editor/browser/services/codeEditorService.js' @@ -45,18 +41,17 @@ import { IPathService } from '../../../../../../../workbench/services/path/commo import { IMetricsService } from '../../../../../../../workbench/contrib/void/common/metricsService.js' import { URI } from '../../../../../../../base/common/uri.js' import { IChatThreadService, ThreadsState, ThreadStreamState } from '../../../chatThreadService.js' - +import { ITerminalToolService } from '../../../terminalToolService.js' +import { ILanguageService } from '../../../../../../../editor/common/languages/language.js' +import { IVoidModelService } from '../../../../common/voidModelService.js' +import { IWorkspaceContextService } from '../../../../../../../platform/workspace/common/workspace.js' +import { IVoidCommandBarService } from '../../../voidCommandBarService.js' // normally to do this you'd use a useEffect that calls .onDidChangeState(), but useEffect mounts too late and misses initial state changes // even if React hasn't mounted yet, the variables are always updated to the latest state. // React listens by adding a setState function to these listeners. -let uriState: VoidUriState -const uriStateListeners: Set<(s: VoidUriState) => void> = new Set() - -let quickEditState: VoidQuickEditState -const quickEditStateListeners: Set<(s: VoidQuickEditState) => void> = new Set() let sidebarState: VoidSidebarState const sidebarStateListeners: Set<(s: VoidSidebarState) => void> = new Set() @@ -78,54 +73,30 @@ let colorThemeState: ColorScheme const colorThemeStateListeners: Set<(s: ColorScheme) => void> = new Set() const ctrlKZoneStreamingStateListeners: Set<(diffareaid: number, s: boolean) => void> = new Set() -const uriStreamingStateListeners: Set<(uri: URI, s: URIStreamState) => void> = new Set() - +const commandBarURIStateListeners: Set<(uri: URI) => void> = new Set(); +const activeURIListeners: Set<(uri: URI | null) => void> = new Set(); // must call this before you can use any of the hooks below // this should only be called ONCE! this is the only place you don't need to dispose onDidChange. If you use state.onDidChange anywhere else, make sure to dispose it! -let wasCalled = false export const _registerServices = (accessor: ServicesAccessor) => { const disposables: IDisposable[] = [] - // don't register services twice - if (wasCalled) { - return - // console.error(`⚠️ Void _registerServices was called again! It should only be called once.`) - } - wasCalled = true - _registerAccessor(accessor) const stateServices = { - uriStateService: accessor.get(IVoidUriStateService), - quickEditStateService: accessor.get(IQuickEditStateService), sidebarStateService: accessor.get(ISidebarStateService), chatThreadsStateService: accessor.get(IChatThreadService), settingsStateService: accessor.get(IVoidSettingsService), refreshModelService: accessor.get(IRefreshModelService), themeService: accessor.get(IThemeService), editCodeService: accessor.get(IEditCodeService), + voidCommandBarService: accessor.get(IVoidCommandBarService), + modelService: accessor.get(IModelService), } - const { uriStateService, sidebarStateService, quickEditStateService, settingsStateService, chatThreadsStateService, refreshModelService, themeService, editCodeService } = stateServices - - uriState = uriStateService.state - disposables.push( - uriStateService.onDidChangeState(() => { - uriState = uriStateService.state - uriStateListeners.forEach(l => l(uriState)) - }) - ) - - quickEditState = quickEditStateService.state - disposables.push( - quickEditStateService.onDidChangeState(() => { - quickEditState = quickEditStateService.state - quickEditStateListeners.forEach(l => l(quickEditState)) - }) - ) + const { sidebarStateService, settingsStateService, chatThreadsStateService, refreshModelService, themeService, editCodeService, voidCommandBarService, modelService } = stateServices sidebarState = sidebarStateService.state disposables.push( @@ -179,15 +150,21 @@ export const _registerServices = (accessor: ServicesAccessor) => { // no state disposables.push( - editCodeService.onDidChangeCtrlKZoneStreaming(({ diffareaid }) => { + editCodeService.onDidChangeStreamingInCtrlKZone(({ diffareaid }) => { const isStreaming = editCodeService.isCtrlKZoneStreaming({ diffareaid }) ctrlKZoneStreamingStateListeners.forEach(l => l(diffareaid, isStreaming)) }) ) + disposables.push( - editCodeService.onDidChangeURIStreamState(({ uri }) => { - const isStreaming = editCodeService.getURIStreamState({ uri }) - uriStreamingStateListeners.forEach(l => l(uri, isStreaming)) + voidCommandBarService.onDidChangeState(({ uri }) => { + commandBarURIStateListeners.forEach(l => l(uri)); + }) + ) + + disposables.push( + voidCommandBarService.onDidChangeActiveURI(({ uri }) => { + activeURIListeners.forEach(l => l(uri)); }) ) @@ -211,8 +188,6 @@ const getReactAccessor = (accessor: ServicesAccessor) => { IRefreshModelService: accessor.get(IRefreshModelService), IVoidSettingsService: accessor.get(IVoidSettingsService), IEditCodeService: accessor.get(IEditCodeService), - IVoidUriStateService: accessor.get(IVoidUriStateService), - IQuickEditStateService: accessor.get(IQuickEditStateService), ISidebarStateService: accessor.get(ISidebarStateService), IChatThreadService: accessor.get(IChatThreadService), @@ -232,6 +207,12 @@ const getReactAccessor = (accessor: ServicesAccessor) => { IConfigurationService: accessor.get(IConfigurationService), IPathService: accessor.get(IPathService), IMetricsService: accessor.get(IMetricsService), + ITerminalToolService: accessor.get(ITerminalToolService), + ILanguageService: accessor.get(ILanguageService), + IVoidModelService: accessor.get(IVoidModelService), + IWorkspaceContextService: accessor.get(IWorkspaceContextService), + + IVoidCommandBarService: accessor.get(IVoidCommandBarService), } as const return reactAccessor @@ -259,26 +240,6 @@ export const useAccessor = () => { // -- state of services -- -export const useUriState = () => { - const [s, ss] = useState(uriState) - useEffect(() => { - ss(uriState) - uriStateListeners.add(ss) - return () => { uriStateListeners.delete(ss) } - }, [ss]) - return s -} - -export const useQuickEditState = () => { - const [s, ss] = useState(quickEditState) - useEffect(() => { - ss(quickEditState) - quickEditStateListeners.add(ss) - return () => { quickEditStateListeners.delete(ss) } - }, [ss]) - return s -} - export const useSidebarState = () => { const [s, ss] = useState(sidebarState) useEffect(() => { @@ -365,14 +326,6 @@ export const useCtrlKZoneStreamingState = (listener: (diffareaid: number, s: boo }, [listener, ctrlKZoneStreamingStateListeners]) } -export const useURIStreamState = (listener: (uri: URI, s: URIStreamState) => void) => { - useEffect(() => { - uriStreamingStateListeners.add(listener) - return () => { uriStreamingStateListeners.delete(listener) } - }, [listener, uriStreamingStateListeners]) -} - - export const useIsDark = () => { const [s, ss] = useState(colorThemeState) useEffect(() => { @@ -384,6 +337,40 @@ export const useIsDark = () => { // s is the theme, return isDark instead of s const isDark = s === ColorScheme.DARK || s === ColorScheme.HIGH_CONTRAST_DARK return isDark - } +export const useCommandBarURIListener = (listener: (uri: URI) => void) => { + useEffect(() => { + commandBarURIStateListeners.add(listener); + return () => { commandBarURIStateListeners.delete(listener) }; + }, [listener]); +}; +export const useCommandBarState = () => { + const accessor = useAccessor() + const commandBarService = accessor.get('IVoidCommandBarService') + const [s, ss] = useState({ state: commandBarService.stateOfURI, sortedURIs: commandBarService.sortedURIs }); + const listener = useCallback(() => { + ss({ state: commandBarService.stateOfURI, sortedURIs: commandBarService.sortedURIs }); + }, [commandBarService]) + useCommandBarURIListener(listener) + + return s; +} + + + +// roughly gets the active URI - this is used to get the history of recent URIs +export const useActiveURI = () => { + const accessor = useAccessor() + const commandBarService = accessor.get('IVoidCommandBarService') + const [s, ss] = useState(commandBarService.activeURI) + useEffect(() => { + const listener = () => { ss(commandBarService.activeURI) } + activeURIListeners.add(listener); + return () => { activeURIListeners.delete(listener) }; + }, []) + return { uri: s } +} + + + diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-command-bar-tsx/VoidCommandBar.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-command-bar-tsx/VoidCommandBar.tsx new file mode 100644 index 00000000..25ffbaec --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/react/src/void-command-bar-tsx/VoidCommandBar.tsx @@ -0,0 +1,307 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + + +import { useAccessor, useCommandBarState, useIsDark } from '../util/services.js'; + +import '../styles.css' +import { useCallback, useEffect, useState, useRef } from 'react'; +import { ScrollType } from '../../../../../../../editor/common/editorCommon.js'; +import { acceptAllBg, acceptBorder, buttonFontSize, buttonTextColor, rejectAllBg, rejectBorder } from '../../../../common/helpers/colors.js'; +import { VoidCommandBarProps } from '../../../voidCommandBarService.js'; + +export const VoidCommandBarMain = ({ uri, editor }: VoidCommandBarProps) => { + const isDark = useIsDark() + + return
    + +
    +} + + + +const stepIdx = (currIdx: number | null, len: number, step: -1 | 1) => { + if (len === 0) return null + return ((currIdx ?? 0) + step + len) % len // for some reason, small negatives are kept negative. just add len to offset +} + + + +const VoidCommandBar = ({ uri, editor }: VoidCommandBarProps) => { + const accessor = useAccessor() + const editCodeService = accessor.get('IEditCodeService') + const editorService = accessor.get('ICodeEditorService') + const metricsService = accessor.get('IMetricsService') + const commandService = accessor.get('ICommandService') + const commandBarService = accessor.get('IVoidCommandBarService') + const voidModelService = accessor.get('IVoidModelService') + const { state: commandBarState, sortedURIs: sortedCommandBarURIs } = useCommandBarState() + + + // useEffect(() => { + // console.log('MOUNTING!!!') + // }, []) + + // latestUriIdx is used to remember place in leftRight + const _latestValidUriIdxRef = useRef(null) + + // i is the current index of the URI in sortedCommandBarURIs + const i_ = sortedCommandBarURIs.findIndex(e => e.fsPath === uri?.fsPath) + const currFileIdx = i_ === -1 ? null : i_ + useEffect(() => { + if (currFileIdx !== null) _latestValidUriIdxRef.current = currFileIdx + }, [currFileIdx]) + + const uriIdxInStepper = currFileIdx !== null ? currFileIdx // use currFileIdx if it exists, else use latestNotNullUriIdxRef + : _latestValidUriIdxRef.current === null ? null + : _latestValidUriIdxRef.current < sortedCommandBarURIs.length ? _latestValidUriIdxRef.current + : null + + // when change URI, scroll to the proper spot + useEffect(() => { + setTimeout(() => { + // check undefined + if (!uri) return + const s = commandBarService.stateOfURI[uri.fsPath] + if (!s) return + const { diffIdx } = s + goToDiffIdx(diffIdx ?? 0) + }, 50) + }, [uri, commandBarService]) + + if (uri?.scheme !== 'file') return null // don't show in editors that we made, they must be files + + const getNextDiffIdx = (step: 1 | -1) => { + // check undefined + if (!uri) return null + const s = commandBarState[uri.fsPath] + if (!s) return null + const { diffIdx, sortedDiffIds } = s + // get next idx + const nextDiffIdx = stepIdx(diffIdx, sortedDiffIds.length, step) + return nextDiffIdx + } + const goToDiffIdx = (idx: number | null) => { + if (idx === null) return + // check undefined + if (!uri) return + const s = commandBarState[uri.fsPath] + if (!s) return + const { sortedDiffIds } = s + // reveal + const diffid = sortedDiffIds[idx] + if (diffid === undefined) return + const diff = editCodeService.diffOfId[diffid] + if (!diff) return + editor.revealLineNearTop(diff.startLine, ScrollType.Immediate) + commandBarService.setDiffIdx(uri, idx) + } + const getNextUriIdx = (step: 1 | -1) => { + return stepIdx(uriIdxInStepper, sortedCommandBarURIs.length, step) + } + const goToURIIdx = async (idx: number | null) => { + if (idx === null) return + const nextURI = sortedCommandBarURIs[idx] + editCodeService.diffAreasOfURI + const { model } = await voidModelService.getModelSafe(nextURI) + if (model) { + // switch to the URI + editorService.openCodeEditor({ resource: nextURI, options: { revealIfVisible: true } }, editor) + } + } + + const currDiffIdx = uri ? commandBarState[uri.fsPath]?.diffIdx ?? null : null + const sortedDiffIds = uri ? commandBarState[uri.fsPath]?.sortedDiffIds ?? [] : [] + const sortedDiffZoneIds = uri ? commandBarState[uri.fsPath]?.sortedDiffZoneIds ?? [] : [] + + + const isADiffInThisFile = sortedDiffIds.length !== 0 + const isADiffZoneInThisFile = sortedDiffZoneIds.length !== 0 + const isADiffZoneInAnyFile = sortedCommandBarURIs.length !== 0 + + const streamState = uri ? commandBarService.getStreamState(uri) : null + const showAcceptRejectAll = streamState === 'idle-has-changes' + + const nextDiffIdx = getNextDiffIdx(1) + const prevDiffIdx = getNextDiffIdx(-1) + const nextURIIdx = getNextUriIdx(1) + const prevURIIdx = getNextUriIdx(-1) + + const upDownDisabled = prevDiffIdx === null || nextDiffIdx === null + const leftRightDisabled = prevURIIdx === null || nextURIIdx === null // || (sortedCommandBarURIs.length === 1 && isADiffZoneInThisFile) + + const upButton = + + const downButton = + + const leftButton = + + const rightButton = + + + + // accept/reject if current URI has changes + const onAcceptAll = () => { + if (!uri) return + editCodeService.acceptOrRejectAllDiffAreas({ uri, behavior: 'accept', removeCtrlKs: false, _addToHistory: true }) + metricsService.capture('Accept All', {}) + } + const onRejectAll = () => { + if (!uri) return + editCodeService.acceptOrRejectAllDiffAreas({ uri, behavior: 'reject', removeCtrlKs: false, _addToHistory: true }) + metricsService.capture('Reject All', {}) + } + + + if (!isADiffZoneInAnyFile) return null + + const acceptAllButton = + + + const rejectAllButton = + + const acceptRejectAllButtons =
    + {acceptAllButton} + {rejectAllButton} +
    + + // const closeCommandBar = useCallback(() => { + // commandService.executeCommand('void.hideCommandBar'); + // }, [commandService]); + + // const hideButton = + + const leftRightUpDownButtons =
    +
    + {/* Changes in file */} +
    + {upButton} + {downButton} + + {isADiffInThisFile ? + `Diff ${(currDiffIdx ?? 0) + 1} of ${sortedDiffIds.length}` + : streamState === 'streaming' ? + 'No changes yet' + : `No changes` + } + +
    + + {/* Files */} +
    + {leftButton} + {/*
    */} + {rightButton} + {/*
    */} + + {currFileIdx !== null ? + `File ${currFileIdx + 1} of ${sortedCommandBarURIs.length}` + : `${sortedCommandBarURIs.length} file${sortedCommandBarURIs.length === 1 ? '' : 's'} changed` + } + +
    +
    +
    + + return
    + {showAcceptRejectAll && acceptRejectAllButtons} + {leftRightUpDownButtons} + +
    +} diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-command-bar-tsx/index.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-command-bar-tsx/index.tsx new file mode 100644 index 00000000..5b185788 --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/react/src/void-command-bar-tsx/index.tsx @@ -0,0 +1,9 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { mountFnGenerator } from '../util/mountFnGenerator.js' +import { VoidCommandBarMain } from './VoidCommandBar.js' + +export const mountVoidCommandBar = mountFnGenerator(VoidCommandBarMain) 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 f4943936..813968a4 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 @@ -21,7 +21,7 @@ const optionsEqual = (m1: ModelOption[], m2: ModelOption[]) => { return true } -const ModelSelectBox = ({ options, featureName }: { options: ModelOption[], featureName: FeatureName }) => { +const ModelSelectBox = ({ options, featureName, className }: { options: ModelOption[], featureName: FeatureName, className: string }) => { const accessor = useAccessor() const voidSettingsService = accessor.get('IVoidSettingsService') @@ -40,7 +40,7 @@ const ModelSelectBox = ({ options, featureName }: { options: ModelOption[], feat getOptionDropdownName={(option) => option.selection.modelName} getOptionDropdownDetail={(option) => option.selection.providerName} getOptionsEqual={(a, b) => optionsEqual([a], [b])} - className='text-xs text-void-fg-3' + className={className} matchInputWidth={false} /> } @@ -77,7 +77,7 @@ const ModelSelectBox = ({ options, featureName }: { options: ModelOption[], feat -const MemoizedModelDropdown = ({ featureName }: { featureName: FeatureName }) => { +const MemoizedModelDropdown = ({ featureName, className }: { featureName: FeatureName, className: string }) => { const settingsState = useSettingsState() const oldOptionsRef = useRef([]) const [memoizedOptions, setMemoizedOptions] = useState(oldOptionsRef.current) @@ -86,7 +86,7 @@ const MemoizedModelDropdown = ({ featureName }: { featureName: FeatureName }) => useEffect(() => { const oldOptions = oldOptionsRef.current - const newOptions = settingsState._modelOptions.filter((o) => filter(o.selection)) + const newOptions = settingsState._modelOptions.filter((o) => filter(o.selection, { chatMode: settingsState.globalSettings.chatMode })) if (!optionsEqual(oldOptions, newOptions)) { setMemoizedOptions(newOptions) @@ -95,14 +95,14 @@ const MemoizedModelDropdown = ({ featureName }: { featureName: FeatureName }) => }, [settingsState._modelOptions, filter]) if (memoizedOptions.length === 0) { // Pretty sure this will never be reached unless filter is enabled - return + return } - return + return } -export const ModelDropdown = ({ featureName }: { featureName: FeatureName }) => { +export const ModelDropdown = ({ featureName, className }: { featureName: FeatureName, className: string }) => { const settingsState = useSettingsState() const accessor = useAccessor() @@ -116,12 +116,12 @@ export const ModelDropdown = ({ featureName }: { featureName: FeatureName }) => const isDisabled = isFeatureNameDisabled(featureName, settingsState) if (isDisabled) return - return + return } 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 10068af2..375e5086 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 @@ -18,13 +18,12 @@ import { ModelDropdown } from './ModelDropdown.js' import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js' import { WarningBox } from './WarningBox.js' import { os } from '../../../../common/helpers/systemInfo.js' +import { IconX } from '../sidebar-tsx/SidebarChat.js' -const SubtleButton = ({ onClick, text, icon, disabled }: { onClick: () => void, text: string, icon: React.ReactNode, disabled: boolean }) => { +const ButtonLeftTextRightOption = ({ text, leftButton }: { text: string, leftButton?: React.ReactNode }) => { return
    - + {leftButton ? leftButton : null} {text} @@ -57,22 +56,28 @@ const RefreshModelButton = ({ providerName }: { providerName: RefreshableProvide const { state } = refreshModelState[providerName] const { title: providerTitle } = displayInfoOfProviderName(providerName) - return { - refreshModelService.startRefreshingModels(providerName, { enableProviderOnSuccess: false, doNotFire: false }) - metricsService.capture('Click', { providerName, action: 'Refresh Models' }) - }} - text={justFinished === 'finished' ? `${providerTitle} Models are up-to-date!` - : justFinished === 'error' ? `${providerTitle} not found!` - : `Manually refresh ${providerTitle} models.` - } - icon={justFinished === 'finished' ? - : justFinished === 'error' ? - : state === 'refreshing' ? - : + + return { + refreshModelService.startRefreshingModels(providerName, { enableProviderOnSuccess: false, doNotFire: false }) + metricsService.capture('Click', { providerName, action: 'Refresh Models' }) + }} + > + {justFinished === 'finished' ? + : justFinished === 'error' ? + : state === 'refreshing' ? + : } + } - disabled={state === 'refreshing' || justFinished !== null} + text={justFinished === 'finished' ? `${providerTitle} Models are up-to-date!` + : justFinished === 'error' ? `${providerTitle} not found!` + : `Manually refresh ${providerTitle} models.`} /> } @@ -93,7 +98,7 @@ const RefreshableModels = () => { -const AddModelMenu = ({ onSubmit }: { onSubmit: () => void }) => { +const AddModelMenu = ({ onSubmit, onClose }: { onSubmit: () => void, onClose: () => void }) => { const accessor = useAccessor() const settingsStateService = accessor.get('IVoidSettingsService') @@ -116,8 +121,8 @@ const AddModelMenu = ({ onSubmit }: { onSubmit: () => void }) => { options={providerNames} selectedOption={providerName} onChangeOption={(pn) => setProviderName(pn)} - getOptionDisplayName={(pn) => pn ? displayInfoOfProviderName(pn).title : '(null)'} - getOptionDropdownName={(pn) => pn ? displayInfoOfProviderName(pn).title : '(null)'} + getOptionDisplayName={(pn) => pn ? displayInfoOfProviderName(pn).title : 'Provider Name'} + getOptionDropdownName={(pn) => pn ? displayInfoOfProviderName(pn).title : 'Provider Name'} getOptionsEqual={(a, b) => a === b} className={`max-w-44 w-full border border-void-border-2 bg-void-bg-1 text-void-fg-3 text-root py-[4px] px-[6px] @@ -141,8 +146,8 @@ const AddModelMenu = ({ onSubmit }: { onSubmit: () => void }) => {
    {/* button */} -
    - { + { const modelName = modelNameRef.current?.value if (providerName === null) { @@ -161,16 +166,16 @@ const AddModelMenu = ({ onSubmit }: { onSubmit: () => void }) => { settingsStateService.addModel(providerName, modelName) onSubmit() - }} - >Add model -
    + >Add model - {!errorString ? null :
    - {errorString} -
    } +
    + {!errorString ? null :
    + {errorString} +
    } + } @@ -178,9 +183,9 @@ const AddModelMenu = ({ onSubmit }: { onSubmit: () => void }) => { const AddModelMenuFull = () => { const [open, setOpen] = useState(false) - return
    + return
    {open ? - { setOpen(false) }} /> + setOpen(false)} onClose={() => setOpen(false)} /> : setOpen(true)}>Add Model }
    @@ -291,7 +296,7 @@ const ProviderSetting = ({ providerName, settingName }: { providerName: Provider isPasswordField={isPasswordField} /> {subTextMd === undefined ? null :
    - +
    }
    @@ -354,7 +359,7 @@ export const VoidProviderSettings = ({ providerNames }: { providerNames: Provide type TabName = 'models' | 'general' -export const AutoRefreshToggle = () => { +export const AutoDetectLocalModelsToggle = () => { const settingName: GlobalSettingName = 'autoRefreshModels' const accessor = useAccessor() @@ -366,19 +371,17 @@ export const AutoRefreshToggle = () => { // right now this is just `enabled_autoRefreshModels` const enabled = voidSettingsState.globalSettings[settingName] - return
    - { voidSettingsService.setGlobalSetting(settingName, newVal) metricsService.capture('Click', { action: 'Autorefresh Toggle', settingName, enabled: newVal }) - }} /> - - - {`Automatically detect local providers and models (${refreshableProviderNames.map(providerName => displayInfoOfProviderName(providerName).title).join(', ')}).`} - -
    + }} + />} + text={`Automatically detect local providers and models (${refreshableProviderNames.map(providerName => displayInfoOfProviderName(providerName).title).join(', ')}).`} + /> } @@ -398,6 +401,30 @@ export const AIInstructionsBox = () => { /> } +const FastApplyMethodDropdown = () => { + const accessor = useAccessor() + const voidSettingsService = accessor.get('IVoidSettingsService') + + const options = useMemo(() => [true, false], []) + + const onChangeOption = useCallback((newVal: boolean) => { + voidSettingsService.setGlobalSetting('enableFastApply', newVal) + }, [voidSettingsService]) + + return val ? 'Fast Apply' : 'Slow Apply'} + getOptionDropdownName={(val) => val ? 'Fast Apply' : 'Slow Apply'} + getOptionDropdownDetail={(val) => val ? 'Output Search/Replace blocks' : 'Rewrite whole files'} + getOptionsEqual={(a, b) => a === b} + /> + +} + + export const FeaturesTab = () => { const voidSettingsState = useSettingsState() const accessor = useAccessor() @@ -407,11 +434,10 @@ export const FeaturesTab = () => { return <>

    Models

    - - -
    + + @@ -421,11 +447,11 @@ export const FeaturesTab = () => { {/*

    {`Void can access any model that you host locally. We automatically detect your local models by default.`}

    */}

    {`Void can access any model that you host locally. We automatically detect your local models by default.`}

    - - - - - + + + + + {/* TODO we should create UI for downloading models without user going into terminal */}
    @@ -444,31 +470,94 @@ export const FeaturesTab = () => {

    Feature Options

    -
    + {/* L1 */} +
    + {/* FIM */}

    {displayInfoOfFeatureName('Autocomplete')}

    -
    Experimental. Only works with models that support FIM.
    -
    - voidSettingsService.setGlobalSetting('enableAutocomplete', newVal)} - /> - {voidSettingsState.globalSettings.enableAutocomplete ? 'Enabled' : 'Disabled'} +
    Experimental. Only works with models that support FIM.
    + +
    + {/* Enable Switch */} +
    + voidSettingsService.setGlobalSetting('enableAutocomplete', newVal)} + /> + {voidSettingsState.globalSettings.enableAutocomplete ? 'Enabled' : 'Disabled'} +
    + {/* Model Dropdown */} +
    + +
    -
    - +
    + + {/* Apply */} +
    +

    {displayInfoOfFeatureName('Apply')}

    +
    Settings that control the behavior of the Apply button and the Edit tool.
    + +
    + {/* Sync to Chat Switch */} +
    + voidSettingsService.setGlobalSetting('syncApplyToChat', newVal)} + /> + {voidSettingsState.globalSettings.syncApplyToChat ? 'Same as Chat model' : 'Different model'} +
    + + {/* Model Dropdown */} +
    + +
    +
    + + +
    + {/* Fast Apply Method Dropdown */} +
    + +
    +
    + +
    + +
    + + {/* L2 */} +
    + + {/* Tools Section */} +
    +

    Tools

    +
    {`Tools are functions that LLMs can call. Some tools require user approval.`}
    + +
    + {/* Auto Accept Switch */} +
    + voidSettingsService.setGlobalSetting('autoApprove', newVal)} + /> + {voidSettingsState.globalSettings.autoApprove ? 'Auto-approve' : 'Auto-approve'} +
    -

    {displayInfoOfFeatureName('Apply')}

    -
    We recommend using Claude 3.7 or GPT 4o.
    -
    +
    + +
    + diff --git a/src/vs/workbench/contrib/void/browser/react/tailwind.config.js b/src/vs/workbench/contrib/void/browser/react/tailwind.config.js index be4f9a13..95586bb3 100644 --- a/src/vs/workbench/contrib/void/browser/react/tailwind.config.js +++ b/src/vs/workbench/contrib/void/browser/react/tailwind.config.js @@ -9,7 +9,7 @@ module.exports = { content: ['./src2/**/*.{jsx,tsx}'], // uses these files to decide how to transform the css file theme: { extend: { - typography: { + typography: theme => ({ DEFAULT: { css: { '--tw-prose-body': 'var(--void-fg-1)', @@ -30,8 +30,7 @@ module.exports = { '--tw-prose-td-borders': 'var(--void-border-4)', }, }, - - }, + }), fontSize: { xs: '10px', sm: '11px', diff --git a/src/vs/workbench/contrib/void/browser/react/tsup.config.js b/src/vs/workbench/contrib/void/browser/react/tsup.config.js index e51218e5..ab3bd525 100644 --- a/src/vs/workbench/contrib/void/browser/react/tsup.config.js +++ b/src/vs/workbench/contrib/void/browser/react/tsup.config.js @@ -7,6 +7,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ entry: [ + './src2/void-command-bar-tsx/index.tsx', './src2/sidebar-tsx/index.tsx', './src2/void-settings-tsx/index.tsx', './src2/quick-edit-tsx/index.tsx', diff --git a/src/vs/workbench/contrib/void/browser/searchReplaceCacheService.ts b/src/vs/workbench/contrib/void/browser/searchReplaceCacheService.ts index e50a6d12..4fbf283e 100644 --- a/src/vs/workbench/contrib/void/browser/searchReplaceCacheService.ts +++ b/src/vs/workbench/contrib/void/browser/searchReplaceCacheService.ts @@ -7,8 +7,6 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -// import { ILLMMessageService } from '../common/llmMessageService.js'; -// import { ServiceSendLLMMessageParams } from '../common/llmMessageTypes.js'; @@ -17,7 +15,7 @@ export interface ISearchReplaceService { } export const ISearchReplaceService = createDecorator('SearchReplaceCacheService'); -class SearchReplaceService extends Disposable implements ISearchReplaceService { +export class SearchReplaceService extends Disposable implements ISearchReplaceService { _serviceBrand: undefined; private readonly _onDidChangeState = new Emitter(); diff --git a/src/vs/workbench/contrib/void/browser/sidebarActions.ts b/src/vs/workbench/contrib/void/browser/sidebarActions.ts index 23974f7e..503981c6 100644 --- a/src/vs/workbench/contrib/void/browser/sidebarActions.ts +++ b/src/vs/workbench/contrib/void/browser/sidebarActions.ts @@ -11,24 +11,21 @@ import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; -import { StagingSelectionItem, IChatThreadService } from './chatThreadService.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; import { IRange } from '../../../../editor/common/core/range.js'; import { ITextModel } from '../../../../editor/common/model.js'; -import { VOID_VIEW_CONTAINER_ID, VOID_VIEW_ID } from './sidebarPane.js'; +import { VOID_VIEW_ID } from './sidebarPane.js'; import { IMetricsService } from '../common/metricsService.js'; import { ISidebarStateService } from './sidebarStateService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { VOID_TOGGLE_SETTINGS_ACTION_ID } from './voidSettingsPane.js'; import { VOID_CTRL_L_ACTION_ID } from './actionIDs.js'; -import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { localize2 } from '../../../../nls.js'; -import { IViewsService } from '../../../services/views/common/viewsService.js'; -import { IVoidUriStateService } from './voidUriStateService.js'; +import { StagingSelectionItem } from '../common/chatThreadServiceTypes.js'; +import { IChatThreadService } from './chatThreadService.js'; // ---------- Register commands and keybindings ---------- @@ -122,21 +119,23 @@ registerAction2(class extends Action2 { const selection: StagingSelectionItem = !selectionRange || !selectionStr || (selectionRange.startLineNumber > selectionRange.endLineNumber) ? { type: 'File', fileURI: model.uri, + language: model.getLanguageId(), selectionStr: null, range: null, - state: { isOpened: false, } + state: { isOpened: false, wasAddedAsCurrentFile: false } } : { type: 'Selection', fileURI: model.uri, + language: model.getLanguageId(), selectionStr: selectionStr, range: selectionRange, - state: { isOpened: true, } + state: { isOpened: true, wasAddedAsCurrentFile: false } } // update the staging selections const chatThreadService = accessor.get(IChatThreadService) - const focusedMessageIdx = chatThreadService.getFocusedMessageIdx() + const focusedMessageIdx = chatThreadService.getCurrentFocusedMessageIdx() // set the selections to the proper value let selections: StagingSelectionItem[] = [] @@ -283,43 +282,3 @@ export class TabSwitchListener extends Disposable { this._register(this._editorService.onCodeEditorAdd(editor => { initializeEditor(editor) })) } } - - -class TabSwitchContribution extends Disposable implements IWorkbenchContribution { - static readonly ID = 'workbench.contrib.void.tabswitch' - - constructor( - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IViewsService private readonly viewsService: IViewsService, - @IVoidUriStateService private readonly uriStateService: IVoidUriStateService, - @ICodeEditorService private readonly codeEditorService: ICodeEditorService, - // @ICommandService private readonly commandService: ICommandService, - ) { - super() - - // sidebarIsVisible state - let sidebarIsVisible = this.viewsService.isViewContainerVisible(VOID_VIEW_CONTAINER_ID) - this._register(this.viewsService.onDidChangeViewVisibility(e => { - sidebarIsVisible = e.visible - })) - - const onSwitchTab = () => { // update state - if (sidebarIsVisible) { - const currentUri = this.codeEditorService.getActiveCodeEditor()?.getModel()?.uri - if (!currentUri) return; - this.uriStateService.setState({ currentUri }) - // this.commandService.executeCommand(VOID_ADD_SELECTION_TO_SIDEBAR_ACTION_ID) - } - } - - // when sidebar becomes visible, add current file - this._register(this.viewsService.onDidChangeViewVisibility(e => { sidebarIsVisible = e.visible })) - - // run on current tab if it exists, and listen for tab switches and visibility changes - onSwitchTab() - this._register(this.viewsService.onDidChangeViewVisibility(() => { onSwitchTab() })) - this._register(this.instantiationService.createInstance(TabSwitchListener, () => { onSwitchTab() })) - } -} - -registerWorkbenchContribution2(TabSwitchContribution.ID, TabSwitchContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/workbench/contrib/void/browser/sidebarPane.ts b/src/vs/workbench/contrib/void/browser/sidebarPane.ts index 9b5fff8e..1fe2e88a 100644 --- a/src/vs/workbench/contrib/void/browser/sidebarPane.ts +++ b/src/vs/workbench/contrib/void/browser/sidebarPane.ts @@ -38,7 +38,7 @@ import { mountSidebar } from './react/out/sidebar-tsx/index.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Orientation } from '../../../../base/browser/ui/sash/sash.js'; // import { IDisposable } from '../../../../base/common/lifecycle.js'; -import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { toDisposable } from '../../../../base/common/lifecycle.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; @@ -80,8 +80,8 @@ class SidebarViewPane extends ViewPane { // gets set immediately this.instantiationService.invokeFunction(accessor => { // mount react - const disposables: IDisposable[] | undefined = mountSidebar(parent, accessor); - disposables?.forEach(d => this._register(d)) + const disposeFn: (() => void) | undefined = mountSidebar(parent, accessor)?.dispose; + this._register(toDisposable(() => disposeFn?.())) }); } diff --git a/src/vs/workbench/contrib/void/browser/terminalToolService.ts b/src/vs/workbench/contrib/void/browser/terminalToolService.ts index 2fe64d11..5876e54b 100644 --- a/src/vs/workbench/contrib/void/browser/terminalToolService.ts +++ b/src/vs/workbench/contrib/void/browser/terminalToolService.ts @@ -3,69 +3,229 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { removeAnsiEscapeCodes } from '../../../../base/common/strings.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { ITerminalService, ITerminalInstance } from '../../../../workbench/contrib/terminal/browser/terminal.js'; import { TerminalLocation } from '../../../../platform/terminal/common/terminal.js'; -import { generateUuid } from '../../../../base/common/uuid.js'; +import { ITerminalService, ITerminalInstance } from '../../../../workbench/contrib/terminal/browser/terminal.js'; +import { TerminalResolveReason } from '../common/toolsServiceTypes.js'; +import { MAX_TERMINAL_CHARS_PAGE, TERMINAL_BG_WAIT_TIME, TERMINAL_TIMEOUT_TIME } from './toolsService.js'; + + export interface ITerminalToolService { readonly _serviceBrand: undefined; - createNewTerminal(terminalId: string): Promise; - runCommand(command: string, terminalId?: string): Promise; - focus(terminalId: string): Promise; + listTerminalIds(): string[]; + runCommand(command: string, proposedTerminalId: string, waitForCompletion: boolean): Promise<{ terminalId: string, didCreateTerminal: boolean, result: string, resolveReason: TerminalResolveReason }>; + openTerminal(terminalId: string): Promise + terminalExists(terminalId: string): boolean } export const ITerminalToolService = createDecorator('TerminalToolService'); + + +function isCommandComplete(output: string) { + // https://code.visualstudio.com/docs/terminal/shell-integration#_vs-code-custom-sequences-osc-633-st + const completionMatch = output.match(/\]633;D(?:;(\d+))?/) + if (!completionMatch) { return false } + if (completionMatch[1] !== undefined) return { exitCode: parseInt(completionMatch[1]) } + return { exitCode: 0 } +} + + +const nameOfId = (id: string) => { + if (id === '1') return 'Void Agent' + return `Void Agent (${id})` +} +const idOfName = (name: string) => { + if (name === 'Void Agent') return '1' + + const match = name.match(/Void Agent \((\d+)\)/) + if (!match) return null + if (Number.isInteger(match[1]) && Number(match[1]) >= 1) return match[1] + return null +} + export class TerminalToolService extends Disposable implements ITerminalToolService { readonly _serviceBrand: undefined; - private terminalInstances: Record = {} + private terminalInstanceOfId: Record = {} constructor( - @ITerminalService private readonly terminalService: ITerminalService + @ITerminalService private readonly terminalService: ITerminalService, ) { super(); - } - async createNewTerminal() { - const terminalId = generateUuid(); - - this.terminalService.createTerminal({}); - const terminal = await this.terminalService.createTerminal({ - location: TerminalLocation.Editor, - config: { name: `Void Agent (${terminalId})`, } - }); - - this.terminalInstances[terminalId] = terminal - return terminalId; - } - - async runCommand(command: string, terminalId?: string) { - - if (!terminalId) { - terminalId = await this.createNewTerminal(); + // runs on ALL terminals for simplicity + const initializeTerminal = (terminal: ITerminalInstance) => { + // when exit, remove + const d = terminal.onExit(() => { + const terminalId = idOfName(terminal.title) + if (terminalId !== null && (terminalId in this.terminalInstanceOfId)) delete this.terminalInstanceOfId[terminalId] + d.dispose() + }) } - const terminal = this.terminalInstances[terminalId]; - if (!terminal) throw new Error(`Terminal with ID ${terminalId} does not exist`); - terminal.sendText(command, true); - return; + // initialize any terminals that are already open + for (const terminal of terminalService.instances) { + const proposedTerminalId = idOfName(terminal.title) + if (proposedTerminalId) this.terminalInstanceOfId[proposedTerminalId] = terminal + + initializeTerminal(terminal) + } + + this._register( + terminalService.onDidCreateInstance(terminal => { initializeTerminal(terminal) }) + ) + } - async focus(terminalId: string) { - const terminal = this.terminalInstances[terminalId]; - if (!terminal) throw new Error(`That terminal was closed.`); - - terminal.focus(true); - return; + listTerminalIds() { + return Object.keys(this.terminalInstanceOfId) } + getValidNewTerminalId(): string { + // {1 2 3} # size 3, new=4 + // {1 3 4} # size 3, new=2 + // 1 <= newTerminalId <= n + 1 + const n = Object.keys(this.terminalInstanceOfId).length; + if (n === 0) return '1' + + for (let i = 1; i <= n + 1; i++) { + const potentialId = i + ''; + if (!(potentialId in this.terminalInstanceOfId)) return potentialId; + } + throw new Error('This should never be reached by pigeonhole principle'); + } + + + + private async _getOrCreateTerminal(proposedTerminalId: string) { + // if terminal ID exists, return it + if (proposedTerminalId in this.terminalInstanceOfId) return { terminalId: proposedTerminalId, didCreateTerminal: false } + + // create new terminal and return its ID + const terminalId = this.getValidNewTerminalId(); + const terminal = await this.terminalService.createTerminal({ + location: TerminalLocation.Panel, + config: { name: nameOfId(terminalId), title: nameOfId(terminalId) }, + }) + + + // when a new terminal is created, there is an initial command that gets run which is empty, wait for it to end before returning + const disposables: IDisposable[] = [] + const waitForMount = new Promise(res => { + let data = '' + const d = terminal.onData(newData => { + data += newData + if (isCommandComplete(data)) { res() } + }) + disposables.push(d) + }) + const waitForTimeout = new Promise(res => { setTimeout(() => { res() }, 1000) }) + + await Promise.any([waitForMount, waitForTimeout,]) + disposables.forEach(d => d.dispose()) + + this.terminalInstanceOfId[terminalId] = terminal + return { terminalId, didCreateTerminal: true } + } + + terminalExists(terminalId: string): boolean { + return terminalId in this.terminalInstanceOfId + } + + + openTerminal: ITerminalToolService['openTerminal'] = async (terminalId) => { + if (!terminalId) return + const terminal = this.terminalInstanceOfId[terminalId] + if (!terminal) return // should never happen + this.terminalService.setActiveInstance(terminal) + await this.terminalService.focusActiveInstance() + } + + + + runCommand: ITerminalToolService['runCommand'] = async (command, proposedTerminalId, waitForCompletion) => { + await this.terminalService.whenConnected; + const { terminalId, didCreateTerminal } = await this._getOrCreateTerminal(proposedTerminalId) + const terminal = this.terminalInstanceOfId[terminalId]; + if (!terminal) throw new Error(`Unexpected internal error: Terminal with ID ${terminalId} did not exist.`); + + // focus the terminal about to run + this.terminalService.setActiveInstance(terminal) + await this.terminalService.focusActiveInstance() + + let result: string = '' + let resolveReason: TerminalResolveReason | undefined = undefined + + const disposables: IDisposable[] = [] + + const waitUntilDone = new Promise((res, rej) => { + const d2 = terminal.onData(async newData => { + if (resolveReason) return + + result += newData + + // onPageFull + if (result.length > MAX_TERMINAL_CHARS_PAGE) { + result = result.substring(0, MAX_TERMINAL_CHARS_PAGE) + await terminal.sendText('\x03', true) // interrupt the terminal with Ctrl+C + resolveReason = { type: 'toofull' } + res() + return + } + + // onDone + const isDone = isCommandComplete(result) + if (isDone) { + resolveReason = { type: 'done', exitCode: isDone.exitCode } + res() + return + } + }) + disposables.push(d2) + }) + + + // send the command here + await terminal.sendText(command, true) + + // timeout promise + const waitUntilTimeout = new Promise((res, rej) => { + setTimeout(async () => { + if (resolveReason) return + await terminal.sendText('\x03', true) // interrupt the terminal with Ctrl+C + resolveReason = { type: waitForCompletion ? 'timeout' : 'bgtask' } + res() + return + }, (waitForCompletion ? TERMINAL_TIMEOUT_TIME : TERMINAL_BG_WAIT_TIME) * 1000) + }) + + await Promise.any([ + waitUntilDone, + waitUntilTimeout, + ]) + + disposables.forEach(d => d.dispose()) + + if (!resolveReason) throw new Error('Unexpected internal error: Promise.any should have resolved with a reason.') + + + result = removeAnsiEscapeCodes(result) + .split('\n').slice(1, -1) // remove first and last line (first = command, last = andrewpareles/void %) + .join('\n') + + return { terminalId, didCreateTerminal, result, resolveReason } + } + + + } -registerSingleton(ITerminalToolService, TerminalToolService, InstantiationType.Eager); +registerSingleton(ITerminalToolService, TerminalToolService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/void/browser/toolsService.ts b/src/vs/workbench/contrib/void/browser/toolsService.ts index d43e1f9f..45048458 100644 --- a/src/vs/workbench/contrib/void/browser/toolsService.ts +++ b/src/vs/workbench/contrib/void/browser/toolsService.ts @@ -7,167 +7,22 @@ import { IWorkspaceContextService } from '../../../../platform/workspace/common/ import { QueryBuilder } from '../../../services/search/common/queryBuilder.js' import { ISearchService } from '../../../services/search/common/search.js' import { IEditCodeService } from './editCodeServiceInterface.js' -import { editToolDesc_toolDescription } from './prompt/prompts.js' -import { IVoidFileService } from '../common/voidFileService.js' +import { ITerminalToolService } from './terminalToolService.js' +import { ToolCallParams, ToolDirectoryItem, ToolName, ToolResultType } from '../common/toolsServiceTypes.js' +import { IVoidModelService } from '../common/voidModelService.js' +import { EndOfLinePreference } from '../../../../editor/common/model.js' +import { basename } from '../../../../base/common/path.js' +import { IVoidCommandBarService } from './voidCommandBarService.js' // tool use for AI -// we do this using Anthropic's style and convert to OpenAI style later -export type InternalToolInfo = { - name: string, - description: string, - params: { - [paramName: string]: { type: string, description: string | undefined } // name -> type - }, - required: string[], // required paramNames -} -const paginationHelper = { - desc: `Very large results may be paginated (indicated in the result). Pagination fails gracefully if out of bounds or invalid page number.`, - param: { pageNumber: { type: 'number', description: 'The page number (optional, default is 1).' }, } -} as const - -export const voidTools = { - // --- context-gathering (read/search/list) --- - - read_file: { - name: 'read_file', - description: `Returns file contents of a given URI. ${paginationHelper.desc}`, - params: { - uri: { type: 'string', description: undefined }, - }, - required: ['uri'], - }, - - list_dir: { - name: 'list_dir', - description: `Returns all file names and folder names in a given URI. ${paginationHelper.desc}`, - params: { - uri: { type: 'string', description: undefined }, - ...paginationHelper.param - }, - required: ['uri'], - }, - - pathname_search: { - name: 'pathname_search', - description: `Returns all pathnames that match a given grep query. You should use this when looking for a file with a specific name or path. This does NOT search file content. ${paginationHelper.desc}`, - params: { - query: { type: 'string', description: undefined }, - ...paginationHelper.param, - }, - required: ['query'], - }, - - search: { - name: 'search', - description: `Returns all code excerpts containing the given string or grep query. This does NOT search pathname. As a follow-up, you may want to use read_file to view the full file contents of the results. ${paginationHelper.desc}`, - params: { - query: { type: 'string', description: undefined }, - ...paginationHelper.param, - }, - required: ['query'], - }, - - // --- editing (create/delete) --- - - create_uri: { - name: 'create_uri', - description: `Creates a file or folder at the given path. To create a folder, ensure the path ends with a trailing slash. Fails gracefully if the file already exists. Missing ancestors in the path will be recursively created automatically.`, - params: { - uri: { type: 'string', description: undefined }, - }, - required: ['uri'], - }, - - delete_uri: { - name: 'delete_uri', - description: `Deletes the file or folder at the given path. Fails gracefully if the file or folder does not exist.`, - params: { - uri: { type: 'string', description: undefined }, - params: { type: 'string', description: 'Return -r here to delete this URI and all descendants (if applicable). Default is the empty string.' } - }, - required: ['uri', 'params'], - }, - - edit: { // APPLY TOOL - name: 'edit', - description: `Edits the contents of a file at the given URI. Fails gracefully if the file does not exist.`, - params: { - uri: { type: 'string', description: undefined }, - changeDescription: { type: 'string', description: editToolDesc_toolDescription } - }, - required: ['uri', 'changeDescription'], - }, - - terminal_command: { - name: 'terminal_command', - description: `Executes a terminal command.`, - params: { - command: { type: 'string', description: 'The terminal command to execute.' } - }, - required: ['command'], - }, - - - // go_to_definition - // go_to_usages - -} satisfies { [name: string]: InternalToolInfo } - -export type ToolName = keyof typeof voidTools -export const toolNames = Object.keys(voidTools) as ToolName[] - -const toolNamesSet = new Set(toolNames) -export const isAToolName = (toolName: string): toolName is ToolName => { - const isAToolName = toolNamesSet.has(toolName) - return isAToolName -} - - -export const toolNamesThatRequireApproval = new Set(['create_uri', 'delete_uri', 'edit', 'terminal_command'] satisfies ToolName[]) - -type DirectoryItem = { - uri: URI; - name: string; - isDirectory: boolean; - isSymbolicLink: boolean; -} - - -export type ToolCallParams = { - 'read_file': { uri: URI, pageNumber: number }, - 'list_dir': { rootURI: URI, pageNumber: number }, - 'pathname_search': { queryStr: string, pageNumber: number }, - 'search': { queryStr: string, pageNumber: number }, - // --- - 'edit': { uri: URI, changeDescription: string }, - 'create_uri': { uri: URI }, - 'delete_uri': { uri: URI, isRecursive: boolean }, - 'terminal_command': { command: string }, -} - - -export type ToolResultType = { - 'read_file': { fileContents: string, hasNextPage: boolean }, - 'list_dir': { children: DirectoryItem[] | null, hasNextPage: boolean, hasPrevPage: boolean, itemsRemaining: number }, - 'pathname_search': { uris: URI[], hasNextPage: boolean }, - 'search': { uris: URI[], hasNextPage: boolean }, - // --- - 'edit': {}, - 'create_uri': {}, - 'delete_uri': {}, - 'terminal_command': {}, -} - - - -export type ValidateParams = { [T in ToolName]: (p: string) => Promise } -export type CallTool = { [T in ToolName]: (p: ToolCallParams[T]) => Promise } -export type ToolResultToString = { [T in ToolName]: (p: ToolCallParams[T], result: ToolResultType[T]) => string } +type ValidateParams = { [T in ToolName]: (p: string) => Promise } +type CallTool = { [T in ToolName]: (p: ToolCallParams[T]) => Promise<{ result: ToolResultType[T], interruptTool?: () => void }> } +type ToolResultToString = { [T in ToolName]: (p: ToolCallParams[T], result: ToolResultType[T]) => string } @@ -175,6 +30,9 @@ export type ToolResultToString = { [T in ToolName]: (p: ToolCallParams[T], resul // pagination info const MAX_FILE_CHARS_PAGE = 50_000 const MAX_CHILDREN_URIs_PAGE = 500 +export const MAX_TERMINAL_CHARS_PAGE = 20_000 +export const TERMINAL_TIMEOUT_TIME = 15 +export const TERMINAL_BG_WAIT_TIME = 1 @@ -193,7 +51,7 @@ const computeDirectoryResult = async ( const toChildIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1; // INCLUSIVE const listChildren = stat.children?.slice(fromChildIdx, toChildIdx + 1) ?? []; - const children: DirectoryItem[] = listChildren.map(child => ({ + const children: ToolDirectoryItem[] = listChildren.map(child => ({ name: child.name, uri: child.resource, isDirectory: child.isDirectory, @@ -220,8 +78,8 @@ const directoryResultToString = (params: ToolCallParams['list_dir'], result: Too let output = ''; const entries = result.children; - if (!result.hasPrevPage) { - output += `${params.rootURI}\n`; + if (!result.hasPrevPage) { // is first page + output += `${params.rootURI.fsPath}\n`; } for (let i = 0; i < entries.length; i++) { @@ -246,24 +104,30 @@ const directoryResultToString = (params: ToolCallParams['list_dir'], result: Too const validateJSON = (s: string): { [s: string]: unknown } => { try { const o = JSON.parse(s) + if (typeof o !== 'object') throw new Error() + + if ('result' in o) { // openrouter sometimes wraps the result with { 'result': ... } + return o.result + } + return o } catch (e) { - throw new Error(`Tool parameter was not a string of a valid JSON: "${s}".`) + throw new Error(`Invalid LLM output format: Tool parameter was not a string of a valid JSON: "${s}".`) } } const validateStr = (argName: string, value: unknown) => { - if (typeof value !== 'string') throw new Error(`Error: ${argName} must be a string.`) + if (typeof value !== 'string') throw new Error(`Invalid LLM output format: ${argName} must be a string.`) return value } -// TODO!!!! check to make sure in workspace +// We are NOT checking to make sure in workspace const validateURI = (uriStr: unknown) => { - if (typeof uriStr !== 'string') throw new Error('Error: provided uri must be a string.') + if (typeof uriStr !== 'string') throw new Error('Invalid LLM output format: Provided uri must be a string.') const uri = URI.file(uriStr) return uri @@ -273,17 +137,38 @@ const validatePageNum = (pageNumberUnknown: unknown) => { if (!pageNumberUnknown) return 1 const parsedInt = Number.parseInt(pageNumberUnknown + '') if (!Number.isInteger(parsedInt)) throw new Error(`Page number was not an integer: "${pageNumberUnknown}".`) - if (parsedInt < 1) throw new Error(`Specified page number must be 1 or greater: "${pageNumberUnknown}".`) + if (parsedInt < 1) throw new Error(`Invalid LLM output format: Specified page number must be 1 or greater: "${pageNumberUnknown}".`) return parsedInt } const validateRecursiveParamStr = (paramsUnknown: unknown) => { - if (typeof paramsUnknown !== 'string') throw new Error('Error calling tool: provided params must be a string.') + if (typeof paramsUnknown !== 'string') throw new Error('Invalid LLM output format: Error calling tool: provided params must be a string.') const params = paramsUnknown const isRecursive = params.includes('r') return isRecursive } +const validateProposedTerminalId = (terminalIdUnknown: unknown) => { + if (!terminalIdUnknown) return '1' + const terminalId = terminalIdUnknown + '' + return terminalId +} + +const validateWaitForCompletion = (b: unknown) => { + if (typeof b === 'string') { + if (b === 'true') return true + if (b === 'false') return false + } + return true // default is true +} + + +const checkIfIsFolder = (uriStr: string) => { + uriStr = uriStr.trim() + if (uriStr.endsWith('/') || uriStr.endsWith('\\')) return true + return false +} + export interface IToolsService { readonly _serviceBrand: undefined; validateParams: ValidateParams; @@ -301,15 +186,15 @@ export class ToolsService implements IToolsService { public callTool: CallTool; public stringOfResult: ToolResultToString; - constructor( @IFileService fileService: IFileService, @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, @ISearchService searchService: ISearchService, @IInstantiationService instantiationService: IInstantiationService, - @IVoidFileService voidFileService: IVoidFileService, + @IVoidModelService voidModelService: IVoidModelService, @IEditCodeService editCodeService: IEditCodeService, - // @ITerminalToolService private readonly terminalToolService: ITerminalToolService, + @ITerminalToolService private readonly terminalToolService: ITerminalToolService, + @IVoidCommandBarService private readonly commandBarService: IVoidCommandBarService, ) { const queryBuilder = instantiationService.createInstance(QueryBuilder); @@ -342,7 +227,7 @@ export class ToolsService implements IToolsService { return { queryStr, pageNumber } }, - search: async (params: string) => { + text_search: async (params: string) => { const o = validateJSON(params) const { query: queryUnknown, pageNumber: pageNumberUnknown } = o @@ -356,17 +241,21 @@ export class ToolsService implements IToolsService { create_uri: async (params: string) => { const o = validateJSON(params) - const { uri: uriStr } = o - const uri = validateURI(uriStr) - return { uri } + const { uri: uriUnknown } = o + const uri = validateURI(uriUnknown) + const uriStr = validateStr('uri', uriUnknown) + const isFolder = checkIfIsFolder(uriStr) + return { uri, isFolder } }, delete_uri: async (params: string) => { const o = validateJSON(params) - const { uri: uriStr, params: paramsStr } = o - const uri = validateURI(uriStr) + const { uri: uriUnknown, params: paramsStr } = o + const uri = validateURI(uriUnknown) const isRecursive = validateRecursiveParamStr(paramsStr) - return { uri, isRecursive } + const uriStr = validateStr('uri', uriUnknown) + const isFolder = checkIfIsFolder(uriStr) + return { uri, isRecursive, isFolder } }, edit: async (params: string) => { @@ -374,15 +263,16 @@ export class ToolsService implements IToolsService { const { uri: uriStr, changeDescription: changeDescriptionUnknown } = o const uri = validateURI(uriStr) const changeDescription = validateStr('changeDescription', changeDescriptionUnknown) - return { uri, changeDescription } }, terminal_command: async (s: string) => { const o = validateJSON(s) - const { command: commandUnknown } = o + const { command: commandUnknown, terminalId: terminalIdUnknown, waitForCompletion: waitForCompletionUnknown } = o const command = validateStr('command', commandUnknown) - return { command } + const proposedTerminalId = validateProposedTerminalId(terminalIdUnknown) + const waitForCompletion = validateWaitForCompletion(waitForCompletionUnknown) + return { command, proposedTerminalId, waitForCompletion } }, } @@ -390,22 +280,28 @@ export class ToolsService implements IToolsService { this.callTool = { read_file: async ({ uri, pageNumber }) => { - const readFileContents = await voidFileService.readFile(uri) + await voidModelService.initializeModel(uri) + const { model } = await voidModelService.getModelSafe(uri) + if (model === null) { throw new Error(`Contents were empty. There may have been an error, or the file may not exist.`) } + const readFileContents = model.getValue(EndOfLinePreference.LF) const fromIdx = MAX_FILE_CHARS_PAGE * (pageNumber - 1) const toIdx = MAX_FILE_CHARS_PAGE * pageNumber - 1 const fileContents = readFileContents.slice(fromIdx, toIdx + 1) // paginate const hasNextPage = (readFileContents.length - 1) - toIdx >= 1 - return { fileContents, hasNextPage } + + return { result: { fileContents, hasNextPage } } }, list_dir: async ({ rootURI, pageNumber }) => { const dirResult = await computeDirectoryResult(fileService, rootURI, pageNumber) - return dirResult + return { result: dirResult } }, pathname_search: async ({ queryStr, pageNumber }) => { - const query = queryBuilder.file(workspaceContextService.getWorkspace().folders.map(f => f.uri), { filePattern: queryStr, }) + const query = queryBuilder.file(workspaceContextService.getWorkspace().folders.map(f => f.uri), { + filePattern: queryStr, + }) const data = await searchService.fileSearch(query, CancellationToken.None) const fromIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1) @@ -415,11 +311,15 @@ export class ToolsService implements IToolsService { .map(({ resource, results }) => resource) const hasNextPage = (data.results.length - 1) - toIdx >= 1 - return { uris, hasNextPage } + return { result: { uris, hasNextPage } } }, - search: async ({ queryStr, pageNumber }) => { - const query = queryBuilder.text({ pattern: queryStr, }, workspaceContextService.getWorkspace().folders.map(f => f.uri)) + text_search: async ({ queryStr, pageNumber }) => { + const query = queryBuilder.text({ + pattern: queryStr, + isRegExp: true, + }, workspaceContextService.getWorkspace().folders.map(f => f.uri)) + const data = await searchService.textSearch(query, CancellationToken.None) const fromIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1) @@ -429,35 +329,47 @@ export class ToolsService implements IToolsService { .map(({ resource, results }) => resource) const hasNextPage = (data.results.length - 1) - toIdx >= 1 - return { queryStr, uris, hasNextPage } + return { result: { queryStr, uris, hasNextPage } } }, // --- - create_uri: async ({ uri }) => { - await fileService.createFile(uri) - return {} + create_uri: async ({ uri, isFolder }) => { + if (isFolder) + await fileService.createFolder(uri) + else { + await fileService.createFile(uri) + } + return { result: {} } }, delete_uri: async ({ uri, isRecursive }) => { await fileService.del(uri, { recursive: isRecursive }) - return {} + return { result: {} } }, edit: async ({ uri, changeDescription }) => { - const [_, applyDonePromise] = editCodeService.startApplying({ + await voidModelService.initializeModel(uri) + if (this.commandBarService.getStreamState(uri) === 'streaming') { + throw new Error(`The Apply model was already running. This can happen if two agents try editing the same file at the same time. Please try again in a moment.`) + } + const res = await editCodeService.startApplying({ uri, applyStr: changeDescription, from: 'ClickApply', - type: 'searchReplace', - }) ?? [] - await applyDonePromise - return {} + startBehavior: 'keep-conflicts', + }) + if (!res) throw new Error(`The Apply model did not start running on ${basename(uri.fsPath)}. Please try again.`) + const [diffZoneURI, applyDonePromise] = res + + const interruptTool = () => { // must reject the applyPromiseDone promise + editCodeService.interruptURIStreaming({ uri: diffZoneURI }) + } + return { result: applyDonePromise, interruptTool } }, - terminal_command: async ({ command }) => { - // TODO!!!! - // await // Await user confirmation and then command execution before resolving - return {} + terminal_command: async ({ command, proposedTerminalId, waitForCompletion }) => { + const { terminalId, didCreateTerminal, result, resolveReason } = await this.terminalToolService.runCommand(command, proposedTerminalId, waitForCompletion) + return { result: { terminalId, didCreateTerminal, result, resolveReason } } }, } @@ -471,12 +383,12 @@ export class ToolsService implements IToolsService { }, list_dir: (params, result) => { const dirTreeStr = directoryResultToString(params, result) - return dirTreeStr + nextPageStr(result.hasNextPage) + return dirTreeStr // + nextPageStr(result.hasNextPage) // already handles num results remaining }, pathname_search: (params, result) => { return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage) }, - search: (params, result) => { + text_search: (params, result) => { return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage) }, // --- @@ -487,10 +399,33 @@ export class ToolsService implements IToolsService { return `URI ${params.uri.fsPath} successfully deleted.` }, edit: (params, result) => { - return `Change successfully made ${params.uri.fsPath} successfully deleted.` + console.log('STR OF RESULT', params) + return `Change successfully made to ${params.uri.fsPath}.` }, terminal_command: (params, result) => { - return `Terminal command "${params.command}" successfully executed.` + + const { + terminalId, + didCreateTerminal, + resolveReason, + result: result_, + } = result + + const terminalDesc = `terminal ${terminalId}${didCreateTerminal ? ` (a newly-created terminal)` : ''}` + + if (resolveReason.type === 'timeout') { + return `Terminal command ran in ${terminalDesc}, but timed out after ${TERMINAL_TIMEOUT_TIME} seconds. Result:\n${result_}` + } + else if (resolveReason.type === 'bgtask') { + return `Terminal command is running in the background in ${terminalDesc}. Here were the outputs after ${TERMINAL_BG_WAIT_TIME} seconds:\n${result_}` + } + else if (resolveReason.type === 'toofull') { + return `Terminal command executed in terminal ${terminalDesc}. Command was interrupted because output was too long. Result:\n${result_}` + } + else if (resolveReason.type === 'done') { + return `Terminal command executed in terminal ${terminalDesc}. Result (exit code ${resolveReason.exitCode}):\n${result_}` + } + throw new Error(`Unexpected internal error: Terminal command did not resolve with a valid reason.`) }, } diff --git a/src/vs/workbench/contrib/void/browser/void.contribution.ts b/src/vs/workbench/contrib/void/browser/void.contribution.ts index f5570488..9054450b 100644 --- a/src/vs/workbench/contrib/void/browser/void.contribution.ts +++ b/src/vs/workbench/contrib/void/browser/void.contribution.ts @@ -33,7 +33,18 @@ import './media/void.css' import './voidUpdateActions.js' +// tools +import './toolsService.js' +import './terminalToolService.js' +// register Thread History +import './chatThreadService.js' + +// ping +import './metricsPollService.js' + +// helper services +import './helperServices/consistentItemService.js' // ---------- common (unclear if these actually need to be imported, because they're already imported wherever they're used) ---------- @@ -52,9 +63,5 @@ import '../common/metricsService.js' // updates import '../common/voidUpdateService.js' -// tools -import './toolsService.js' - -// register Thread History -import './chatThreadService.js' - +// model service +import '../common/voidModelService.js' diff --git a/src/vs/workbench/contrib/void/browser/voidCommandBarService.ts b/src/vs/workbench/contrib/void/browser/voidCommandBarService.ts new file mode 100644 index 00000000..7711068d --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/voidCommandBarService.ts @@ -0,0 +1,443 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { URI } from '../../../../base/common/uri.js'; +import * as dom from '../../../../base/browser/dom.js'; +import { Widget } from '../../../../base/browser/ui/widget.js'; +import { IOverlayWidget, ICodeEditor, OverlayWidgetPositionPreference } from '../../../../editor/browser/editorBrowser.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; +import { mountVoidCommandBar } from './react/out/void-command-bar-tsx/index.js' +import { deepClone } from '../../../../base/common/objects.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IEditCodeService } from './editCodeServiceInterface.js'; +import { ITextModel } from '../../../../editor/common/model.js'; +import { IModelService } from '../../../../editor/common/services/model.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; + + + +export interface IVoidCommandBarService { + readonly _serviceBrand: undefined; + stateOfURI: { [uri: string]: CommandBarStateType }; + sortedURIs: URI[]; + activeURI: URI | null; + + onDidChangeState: Event<{ uri: URI }>; + onDidChangeActiveURI: Event<{ uri: URI | null }>; + + getStreamState: (uri: URI) => 'streaming' | 'idle-has-changes' | 'idle-no-changes'; + setDiffIdx(uri: URI, newIdx: number | null): void; + + acceptOrRejectAllFiles(opts: { behavior: 'reject' | 'accept' }): void; + anyFileIsStreaming(): boolean; + +} + + +export const IVoidCommandBarService = createDecorator('VoidCommandBarService'); + + +export type CommandBarStateType = undefined | { + sortedDiffZoneIds: string[]; // sorted by line number + sortedDiffIds: string[]; // sorted by line number (computed) + isStreaming: boolean; // is any diffZone streaming in this URI + + diffIdx: number | null; // must refresh whenever sortedDiffIds does so it's valid +} + + + +const defaultState: NonNullable = { + sortedDiffZoneIds: [], + sortedDiffIds: [], + isStreaming: false, + diffIdx: null, +} + + +export class VoidCommandBarService extends Disposable implements IVoidCommandBarService { + _serviceBrand: undefined; + + static readonly ID: 'void.VoidCommandBarService' + + // depends on uri -> diffZone -> {streaming, diffs} + public stateOfURI: { [uri: string]: CommandBarStateType } = {} + public sortedURIs: URI[] = [] // keys of state (depends on diffZones in the uri) + private readonly _listenToTheseURIs = new Set() // uriFsPaths + + // Emits when a URI's stream state changes between idle, streaming, and acceptRejectAll + private readonly _onDidChangeState = new Emitter<{ uri: URI }>(); + readonly onDidChangeState = this._onDidChangeState.event; + + + // active URI + activeURI: URI | null = null; + private readonly _onDidChangeActiveURI = new Emitter<{ uri: URI | null }>(); + readonly onDidChangeActiveURI = this._onDidChangeActiveURI.event; + + constructor( + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ICodeEditorService private readonly _codeEditorService: ICodeEditorService, + @IModelService private readonly _modelService: IModelService, + @IEditCodeService private readonly _editCodeService: IEditCodeService, + ) { + super(); + + + const registeredModelURIs = new Set() + const initializeModel = async (model: ITextModel) => { + // do not add listeners to the same model twice - important, or will see duplicates + if (registeredModelURIs.has(model.uri.fsPath)) return + registeredModelURIs.add(model.uri.fsPath) + this._listenToTheseURIs.add(model.uri) + } + // initialize all existing models + initialize when a new model mounts + this._modelService.getModels().forEach(model => { initializeModel(model) }) + this._register(this._modelService.onModelAdded(model => { initializeModel(model) })); + + + + + + + // for every new editor, add the floating widget and update active URI + const disposablesOfEditorId: { [editorId: string]: IDisposable[] } = {}; + const onCodeEditorAdd = (editor: ICodeEditor) => { + const id = editor.getId(); + disposablesOfEditorId[id] = []; + + // mount the command bar + const d1 = this._instantiationService.createInstance(AcceptRejectAllFloatingWidget, { editor }); + disposablesOfEditorId[id].push(d1); + const d2 = editor.onDidChangeModel((e) => { + if (e.newModelUrl?.scheme !== 'file') return + this.activeURI = e.newModelUrl; + this._onDidChangeActiveURI.fire({ uri: e.newModelUrl }) + }) + disposablesOfEditorId[id].push(d2); + } + const onCodeEditorRemove = (editor: ICodeEditor) => { + const id = editor.getId(); + if (disposablesOfEditorId[id]) { + disposablesOfEditorId[id].forEach(d => d.dispose()); + delete disposablesOfEditorId[id]; + } + } + this._register(this._codeEditorService.onCodeEditorAdd((editor) => { onCodeEditorAdd(editor) })) + this._register(this._codeEditorService.onCodeEditorRemove((editor) => { onCodeEditorRemove(editor) })) + this._codeEditorService.listCodeEditors().forEach(editor => { onCodeEditorAdd(editor) }) + + // state updaters + this._register(this._editCodeService.onDidAddOrDeleteDiffZones(e => { + for (const uri of this._listenToTheseURIs) { + if (e.uri.fsPath !== uri.fsPath) continue + // --- sortedURIs: delete if empty, add if not in state yet + const diffZones = this._getDiffZonesOnURI(uri) + if (diffZones.length === 0) { + this._deleteURIEntryFromState(uri) + this._onDidChangeState.fire({ uri }) + continue // deleted, so done + } + if (!this.sortedURIs.find(uri2 => uri2.fsPath === uri.fsPath)) { + this._addURIEntryToState(uri) + } + + const currState = this.stateOfURI[uri.fsPath] + if (!currState) continue // should never happen + // update state of the diffZones on this URI + const oldDiffZones = currState.sortedDiffZoneIds + const currentDiffZones = this._editCodeService.diffAreasOfURI[uri.fsPath] || [] // a Set + const { addedDiffZones, deletedDiffZones } = this._getDiffZoneChanges(oldDiffZones, currentDiffZones || []) + + const diffZonesWithoutDeleted = oldDiffZones.filter(olddiffareaid => !deletedDiffZones.has(olddiffareaid)) + + // --- new state: + const newSortedDiffZoneIds = [ + ...diffZonesWithoutDeleted, + ...addedDiffZones, + ] + const newSortedDiffIds = this._computeSortedDiffs(newSortedDiffZoneIds) + const isStreaming = this._isAnyDiffZoneStreaming(currentDiffZones) + + this._setState(uri, { + sortedDiffZoneIds: newSortedDiffZoneIds, + sortedDiffIds: newSortedDiffIds, + isStreaming: isStreaming + }) + this._onDidChangeState.fire({ uri }) + } + + })) + this._register(this._editCodeService.onDidChangeDiffsInDiffZone(e => { + for (const uri of this._listenToTheseURIs) { + if (e.uri.fsPath !== uri.fsPath) continue + // --- sortedURIs: no change + // --- state: + // sortedDiffIds gets a change to it, so gets recomputed + const currState = this.stateOfURI[uri.fsPath] + if (!currState) continue // should never happen + const { sortedDiffZoneIds } = currState + const newSortedDiffIds = this._computeSortedDiffs(sortedDiffZoneIds) + this._setState(uri, { + sortedDiffIds: newSortedDiffIds, + // sortedDiffZoneIds, // no change + // isStreaming, // no change + }) + this._onDidChangeState.fire({ uri }) + } + })) + this._register(this._editCodeService.onDidChangeStreamingInDiffZone(e => { + for (const uri of this._listenToTheseURIs) { + if (e.uri.fsPath !== uri.fsPath) continue + // --- sortedURIs: no change + // --- state: + const currState = this.stateOfURI[uri.fsPath] + if (!currState) continue // should never happen + const { sortedDiffZoneIds } = currState + this._setState(uri, { + isStreaming: this._isAnyDiffZoneStreaming(sortedDiffZoneIds), + // sortedDiffIds, // no change + // sortedDiffZoneIds, // no change + }) + this._onDidChangeState.fire({ uri }) + } + })) + + } + + + setDiffIdx(uri: URI, newIdx: number | null): void { + this._setState(uri, { diffIdx: newIdx }); + this._onDidChangeState.fire({ uri }); + } + + + getStreamState(uri: URI) { + const { isStreaming, sortedDiffZoneIds } = this.stateOfURI[uri.fsPath] ?? {} + if (isStreaming) { + return 'streaming' + } + if ((sortedDiffZoneIds?.length ?? 0) > 0) { + return 'idle-has-changes' + } + return 'idle-no-changes' + } + + + _computeSortedDiffs(diffareaids: string[]) { + const sortedDiffIds = []; + for (const diffareaid of diffareaids) { + const diffZone = this._editCodeService.diffAreaOfId[diffareaid]; + if (!diffZone || diffZone.type !== 'DiffZone') { + continue; + } + + // Add all diff ids from this diffzone + const diffIds = Object.keys(diffZone._diffOfId); + sortedDiffIds.push(...diffIds); + } + + return sortedDiffIds; + } + + _getDiffZoneChanges(oldDiffZones: Iterable, currentDiffZones: Iterable) { + // Find the added or deleted diffZones by comparing diffareaids + const addedDiffZoneIds = new Set(); + const deletedDiffZoneIds = new Set(); + + // Convert the current diffZones to a set of ids for easy lookup + const currentDiffZoneIdSet = new Set(currentDiffZones); + + // Find deleted diffZones (in old but not in current) + for (const oldDiffZoneId of oldDiffZones) { + if (!currentDiffZoneIdSet.has(oldDiffZoneId)) { + const diffZone = this._editCodeService.diffAreaOfId[oldDiffZoneId]; + if (diffZone && diffZone.type === 'DiffZone') { + deletedDiffZoneIds.add(oldDiffZoneId); + } + } + } + + // Find added diffZones (in current but not in old) + const oldDiffZoneIdSet = new Set(oldDiffZones); + for (const currentDiffZoneId of currentDiffZones) { + if (!oldDiffZoneIdSet.has(currentDiffZoneId)) { + const diffZone = this._editCodeService.diffAreaOfId[currentDiffZoneId]; + if (diffZone && diffZone.type === 'DiffZone') { + addedDiffZoneIds.add(currentDiffZoneId); + } + } + } + + return { addedDiffZones: addedDiffZoneIds, deletedDiffZones: deletedDiffZoneIds } + } + + _isAnyDiffZoneStreaming(diffareaids: Iterable) { + for (const diffareaid of diffareaids) { + const diffZone = this._editCodeService.diffAreaOfId[diffareaid]; + if (!diffZone || diffZone.type !== 'DiffZone') { + continue; + } + if (diffZone._streamState.isStreaming) { + return true; + } + } + return false + } + + + _setState(uri: URI, opts: Partial) { + const newState = { + ...this.stateOfURI[uri.fsPath] ?? deepClone(defaultState), + ...opts + } + + // make sure diffIdx is always correct + if (newState.diffIdx && newState.diffIdx > newState.sortedDiffIds.length) { + newState.diffIdx = newState.sortedDiffIds.length + if (newState.diffIdx < 0) newState.diffIdx = null + } + + this.stateOfURI = { + ...this.stateOfURI, + [uri.fsPath]: newState + } + } + + + _addURIEntryToState(uri: URI) { + // add to sortedURIs + this.sortedURIs = [ + ...this.sortedURIs, + uri + ] + + // add to state + this.stateOfURI[uri.fsPath] = deepClone(defaultState) + } + + _deleteURIEntryFromState(uri: URI) { + // delete this from sortedURIs + const i = this.sortedURIs.findIndex(uri2 => uri2.fsPath === uri.fsPath) + if (i === -1) return + this.sortedURIs = [ + ...this.sortedURIs.slice(0, i), + ...this.sortedURIs.slice(i + 1, Infinity), + ] + // delete from state + delete this.stateOfURI[uri.fsPath] + } + + + + private _getDiffZonesOnURI(uri: URI) { + const diffZones = [...this._editCodeService.diffAreasOfURI[uri.fsPath]?.values() ?? []] + .map(diffareaid => this._editCodeService.diffAreaOfId[diffareaid]) + .filter(diffArea => !!diffArea && diffArea.type === 'DiffZone') + return diffZones + } + + + anyFileIsStreaming() { + return this.sortedURIs.some(uri => this.getStreamState(uri) === 'streaming') + } + + acceptOrRejectAllFiles(opts: { behavior: 'reject' | 'accept' }) { + const { behavior } = opts + // if anything is streaming, do nothing + const anyIsStreaming = this.anyFileIsStreaming() + if (anyIsStreaming) return + for (const uri of this.sortedURIs) { + this._editCodeService.acceptOrRejectAllDiffAreas({ uri, behavior, removeCtrlKs: false }) + } + } + + +} + +registerSingleton(IVoidCommandBarService, VoidCommandBarService, InstantiationType.Delayed); // delayed is needed here :( + +// registerWorkbenchContribution2(VoidCommandBarService.ID, VoidCommandBarService, WorkbenchPhase.BlockRestore); + + +export type VoidCommandBarProps = { + uri: URI | null; + editor: ICodeEditor; +} + + + + +class AcceptRejectAllFloatingWidget extends Widget implements IOverlayWidget { + private readonly _domNode: HTMLElement; + private readonly editor: ICodeEditor; + private readonly ID: string; + + _height = 0 + + constructor({ editor }: { editor: ICodeEditor, }, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + + this.ID = generateUuid(); + this.editor = editor; + // Create container div + const { root } = dom.h('div@root'); + + // Style the container + // root.style.backgroundColor = 'rgb(248 113 113)'; + root.style.height = '16rem'; // make a fixed size, and all contents go on the bottom right. this fixes annoying VS Code mounting issues + root.style.width = '16rem'; + root.style.flexDirection = 'column'; + root.style.justifyContent = 'flex-end'; + root.style.alignItems = 'flex-end'; + root.style.zIndex = '2'; + root.style.padding = '4px'; + root.style.pointerEvents = 'none'; + root.style.display = 'flex'; + root.style.overflow = 'hidden'; + + + this._domNode = root; + editor.addOverlayWidget(this); + + this.instantiationService.invokeFunction(accessor => { + const uri = editor.getModel()?.uri || null + const res = mountVoidCommandBar(root, accessor, { uri, editor } satisfies VoidCommandBarProps) + if (!res) return + this._register(toDisposable(() => res.dispose?.())) + this._register(editor.onWillChangeModel((model) => { + const uri = model.newModelUrl + res.rerender({ uri, editor } satisfies VoidCommandBarProps) + })) + }) + } + + + public getId(): string { + return this.ID; + } + + public getDomNode(): HTMLElement { + return this._domNode; + } + + public getPosition() { + return { + preference: OverlayWidgetPositionPreference.BOTTOM_RIGHT_CORNER + } + } + + public override dispose(): void { + this.editor.removeOverlayWidget(this); + super.dispose(); + } +} + + diff --git a/src/vs/workbench/contrib/void/browser/voidSettingsPane.ts b/src/vs/workbench/contrib/void/browser/voidSettingsPane.ts index 71b37461..b70aef91 100644 --- a/src/vs/workbench/contrib/void/browser/voidSettingsPane.ts +++ b/src/vs/workbench/contrib/void/browser/voidSettingsPane.ts @@ -25,7 +25,7 @@ import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextke import { mountVoidSettings } from './react/out/void-settings-tsx/index.js' import { Codicon } from '../../../../base/common/codicons.js'; -import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { toDisposable } from '../../../../base/common/lifecycle.js'; // refer to preferences.contribution.ts keybindings editor @@ -90,12 +90,12 @@ class VoidSettingsPane extends EditorPane { // Mount React into the scrollable content this.instantiationService.invokeFunction(accessor => { - const disposables: IDisposable[] | undefined = mountVoidSettings(settingsElt, accessor); + const disposeFn = mountVoidSettings(settingsElt, accessor)?.dispose; + this._register(toDisposable(() => disposeFn?.())) // setTimeout(() => { // this is a complete hack and I don't really understand how scrollbar works here // this._scrollbar?.scanDomNode(); // }, 1000) - disposables?.forEach(d => this._register(d)); }); } diff --git a/src/vs/workbench/contrib/void/browser/voidUpdateActions.ts b/src/vs/workbench/contrib/void/browser/voidUpdateActions.ts index d3e92f03..da0e5fc7 100644 --- a/src/vs/workbench/contrib/void/browser/voidUpdateActions.ts +++ b/src/vs/workbench/contrib/void/browser/voidUpdateActions.ts @@ -17,11 +17,41 @@ import * as dom from '../../../../base/browser/dom.js'; -const notifyYesUpdate = (notifService: INotificationService, msg?: string) => { - const message = msg || 'This is a very old version of void, please download the latest version! [Void Editor](https://voideditor.com/download-beta)!' - notifService.notify({ +const notifyYesUpdate = (notifService: INotificationService, res: { message?: string } = {}) => { + const message = res?.message || 'This is a very old version of Void, please download the latest version! [Void Editor](https://voideditor.com/download-beta)!' + const notifController = notifService.notify({ severity: Severity.Info, message: message, + sticky: true, + progress: { worked: 0, total: 100 }, + actions: { + primary: [{ + id: 'void.updater.update', + enabled: true, + label: `Reinstall`, + tooltip: '', + class: undefined, + run: () => { + const { window } = dom.getActiveWindow() + window.open('https://voideditor.com/download-beta') + } + }, + { + id: 'void.updater.site', + enabled: true, + label: `Void Site`, + tooltip: '', + class: undefined, + run: () => { + const { window } = dom.getActiveWindow() + window.open('https://voideditor.com/') + } + }] + }, + }) + const d = notifController.onDidClose(() => { + notifyYesUpdate(notifService, res) + d.dispose() }) } const notifyNoUpdate = (notifService: INotificationService) => { @@ -35,6 +65,7 @@ const notifyErrChecking = (notifService: INotificationService) => { notifService.notify({ severity: Severity.Info, message: message, + sticky: true, }) } @@ -57,7 +88,7 @@ registerAction2(class extends Action2 { metricsService.capture('Void Update Manual: Checking...', {}) const res = await voidUpdateService.check() if (!res) { notifyErrChecking(notifService); metricsService.capture('Void Update Manual: Error', { res }) } - else if (res.hasUpdate) { notifyYesUpdate(notifService, res.message); metricsService.capture('Void Update Manual: Yes', { res }) } + else if (res.hasUpdate) { notifyYesUpdate(notifService, res); metricsService.capture('Void Update Manual: Yes', { res }) } else if (!res.hasUpdate) { notifyNoUpdate(notifService); metricsService.capture('Void Update Manual: No', { res }) } } }) @@ -75,19 +106,19 @@ class VoidUpdateWorkbenchContribution extends Disposable implements IWorkbenchCo this.metricsService.capture('Void Update Startup: Checking...', {}) const res = await this.voidUpdateService.check() if (!res) { notifyErrChecking(this.notifService); this.metricsService.capture('Void Update Startup: Error', { res }) } - else if (res.hasUpdate) { notifyYesUpdate(this.notifService, res.message); this.metricsService.capture('Void Update Startup: Yes', { res }) } + else if (res.hasUpdate) { notifyYesUpdate(this.notifService, res); this.metricsService.capture('Void Update Startup: Yes', { res }) } else if (!res.hasUpdate) { this.metricsService.capture('Void Update Startup: No', { res }) } // display nothing if up to date } // check once 5 seconds after mount - - const initId = setTimeout(() => autoCheck(), 5 * 1000) - this._register({ dispose: () => clearTimeout(initId) }) - // check every 3 hours const { window } = dom.getActiveWindow() - const intervalId = window.setInterval(() => autoCheck(), 3 * 60 * 60 * 1000) + const initId = window.setTimeout(() => autoCheck(), 5 * 1000) + this._register({ dispose: () => window.clearTimeout(initId) }) + + + const intervalId = window.setInterval(() => autoCheck(), 3 * 60 * 60 * 1000) // every 3 hrs this._register({ dispose: () => window.clearInterval(intervalId) }) } diff --git a/src/vs/workbench/contrib/void/browser/voidUriStateService.ts b/src/vs/workbench/contrib/void/browser/voidUriStateService.ts deleted file mode 100644 index 1a89c29b..00000000 --- a/src/vs/workbench/contrib/void/browser/voidUriStateService.ts +++ /dev/null @@ -1,57 +0,0 @@ -/*-------------------------------------------------------------------------------------- - * Copyright 2025 Glass Devtools, Inc. All rights reserved. - * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. - *--------------------------------------------------------------------------------------*/ - -import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { URI } from '../../../../base/common/uri.js'; -import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; - - - -// service that manages state -export type VoidUriState = { - currentUri?: URI -} - -export interface IVoidUriStateService { - readonly _serviceBrand: undefined; - - readonly state: VoidUriState; // readonly to the user - setState(newState: Partial): void; - onDidChangeState: Event; -} - -export const IVoidUriStateService = createDecorator('voidUriStateService'); -class VoidUriStateService extends Disposable implements IVoidUriStateService { - _serviceBrand: undefined; - - static readonly ID = 'voidUriStateService'; - - private readonly _onDidChangeState = new Emitter(); - readonly onDidChangeState: Event = this._onDidChangeState.event; - - - // state - state: VoidUriState - - constructor( - ) { - super() - - // initial state - this.state = { currentUri: undefined } - } - - setState(newState: Partial) { - - this.state = { ...this.state, ...newState } - this._onDidChangeState.fire() - } - - -} - -registerSingleton(IVoidUriStateService, VoidUriStateService, InstantiationType.Eager); diff --git a/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts b/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts new file mode 100644 index 00000000..c4893d6c --- /dev/null +++ b/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts @@ -0,0 +1,87 @@ +import { URI } from '../../../../base/common/uri.js'; +import { IRange } from '../../../../editor/common/core/range.js'; +import { AnthropicReasoning } from './sendLLMMessageTypes.js'; +import { ToolName, ToolCallParams, ToolResultType } from './toolsServiceTypes.js'; + +export type ToolMessage = { + role: 'tool'; + name: T; // internal use + paramsStr: string; // internal use + id: string; // apis require this tool use id + content: string; // give this result to LLM + + // if rejected, don't show in chat + result: + | { type: 'success'; params: ToolCallParams[T]; value: ToolResultType[T], } + | { type: 'error'; params: ToolCallParams[T] | undefined; value: string } + | { type: 'rejected'; params: ToolCallParams[T] } +} +export type ToolRequestApproval = { + role: 'tool_request'; + name: T; // internal use + params: ToolCallParams[T]; // internal use + paramsStr: string; // internal use - this is what the LLM outputted, not necessarily JSON.stringify(params) + id: string; // proposed tool's id +} + +// WARNING: changing this format is a big deal!!!!!! need to migrate old format to new format on users' computers so people don't get errors. +export type ChatMessage = + | { + role: 'user'; + content: string; // content displayed to the LLM on future calls - allowed to be '', will be replaced with (empty) + displayContent: string; // content displayed to user - allowed to be '', will be ignored + selections: StagingSelectionItem[] | null; // the user's selection + state: { + stagingSelections: StagingSelectionItem[]; + isBeingEdited: boolean; + } + } | { + role: 'assistant'; + content: string; // content received from LLM - allowed to be '', will be replaced with (empty) + reasoning: string; // reasoning from the LLM, used for step-by-step thinking + + anthropicReasoning: AnthropicReasoning[] | null; // anthropic reasoning + } + | ToolMessage + | ToolRequestApproval + + +// one of the square items that indicates a selection in a chat bubble (NOT a file, a Selection of text) +export type CodeSelection = { + type: 'Selection'; + fileURI: URI; + language: string; + selectionStr: string; + range: IRange; + state: { + isOpened: boolean; + wasAddedAsCurrentFile: boolean; + }; +} + +export type FileSelection = { + type: 'File'; + fileURI: URI; + language: string; + selectionStr: null; + range: null; + state: { + isOpened: boolean; + wasAddedAsCurrentFile: boolean; + }; +} + +export type StagingSelectionItem = CodeSelection | FileSelection + + + +export type CodespanLocationLink = { + uri: URI, // we handle serialization for this + displayText: string, + selection?: { // store as JSON so dont have to worry about serialization + startLineNumber: number + startColumn: number, + endLineNumber: number + endColumn: number, + } | undefined +} | null diff --git a/src/vs/workbench/contrib/void/common/helpers/colors.ts b/src/vs/workbench/contrib/void/common/helpers/colors.ts new file mode 100644 index 00000000..1a20ea9b --- /dev/null +++ b/src/vs/workbench/contrib/void/common/helpers/colors.ts @@ -0,0 +1,8 @@ +export const acceptBg = '#1a7431' +export const acceptAllBg = '#1e8538' +export const acceptBorder = '1px solid #145626' +export const rejectBg = '#b42331' +export const rejectAllBg = '#cf2838' +export const rejectBorder = '1px solid #8e1c27' +export const buttonFontSize = '11px' +export const buttonTextColor = 'white' diff --git a/src/vs/workbench/contrib/void/common/helpers/detectLanguage.ts b/src/vs/workbench/contrib/void/common/helpers/detectLanguage.ts deleted file mode 100644 index 9bc3c9bd..00000000 --- a/src/vs/workbench/contrib/void/common/helpers/detectLanguage.ts +++ /dev/null @@ -1,174 +0,0 @@ -/*-------------------------------------------------------------------------------------- - * Copyright 2025 Glass Devtools, Inc. All rights reserved. - * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. - *--------------------------------------------------------------------------------------*/ - -// eg "bash" -> "shell" -export const nameToVscodeLanguage: { [key: string]: string } = { - // Web Technologies - 'html': 'html', - 'css': 'css', - 'scss': 'scss', - 'sass': 'scss', - 'less': 'less', - 'javascript': 'typescript', - 'js': 'typescript', // use more general renderer - 'jsx': 'typescript', - 'typescript': 'typescript', - 'ts': 'typescript', - 'tsx': 'typescript', - 'json': 'json', - 'jsonc': 'json', - - // Programming Languages - 'python': 'python', - 'py': 'python', - 'java': 'java', - 'cpp': 'cpp', - 'c++': 'cpp', - 'c': 'c', - 'csharp': 'csharp', - 'cs': 'csharp', - 'c#': 'csharp', - 'go': 'go', - 'golang': 'go', - 'rust': 'rust', - 'rs': 'rust', - 'ruby': 'ruby', - 'rb': 'ruby', - 'php': 'php', - 'shell': 'shell', - 'bash': 'shell', - 'sh': 'shell', - 'zsh': 'shell', - - // Markup and Config - 'markdown': 'markdown', - 'md': 'markdown', - 'xml': 'xml', - 'svg': 'xml', - 'yaml': 'yaml', - 'yml': 'yaml', - 'ini': 'ini', - 'toml': 'ini', - - // Database and Query Languages - 'sql': 'sql', - 'mysql': 'sql', - 'postgresql': 'sql', - 'graphql': 'graphql', - 'gql': 'graphql', - - // Others - 'dockerfile': 'dockerfile', - 'docker': 'dockerfile', - 'makefile': 'makefile', - 'plaintext': 'plaintext', - 'text': 'plaintext' -}; - - - -// eg ".ts" -> "typescript" -const fileExtensionToVscodeLanguage: { [key: string]: string } = { - // Web - 'html': 'html', - 'htm': 'html', - 'css': 'css', - 'scss': 'scss', - 'less': 'less', - 'js': 'javascript', - 'jsx': 'javascript', - 'ts': 'typescript', - 'tsx': 'typescript', - 'json': 'json', - 'jsonc': 'json', - - // Programming Languages - 'py': 'python', - 'java': 'java', - 'cpp': 'cpp', - 'cc': 'cpp', - 'c': 'c', - 'h': 'cpp', - 'hpp': 'cpp', - 'cs': 'csharp', - 'go': 'go', - 'rs': 'rust', - 'rb': 'ruby', - 'php': 'php', - 'sh': 'shell', - 'bash': 'shell', - 'zsh': 'shell', - - // Markup/Config - 'md': 'markdown', - 'markdown': 'markdown', - 'xml': 'xml', - 'svg': 'xml', - 'yaml': 'yaml', - 'yml': 'yaml', - 'ini': 'ini', - 'toml': 'ini', - - // Other - 'sql': 'sql', - 'graphql': 'graphql', - 'gql': 'graphql', - 'dockerfile': 'dockerfile', - 'docker': 'dockerfile', - 'mk': 'makefile', - - // Config Files and Dot Files - 'npmrc': 'ini', - 'env': 'ini', - 'gitignore': 'ignore', - 'dockerignore': 'ignore', - 'eslintrc': 'json', - 'babelrc': 'json', - 'prettierrc': 'json', - 'stylelintrc': 'json', - 'editorconfig': 'ini', - 'htaccess': 'apacheconf', - 'conf': 'ini', - 'config': 'ini', - - // Package Files - 'package': 'json', - 'package-lock': 'json', - 'gemfile': 'ruby', - 'podfile': 'ruby', - 'rakefile': 'ruby', - - // Build Systems - 'cmake': 'cmake', - 'makefile': 'makefile', - 'gradle': 'groovy', - - // Shell Scripts - 'bashrc': 'shell', - 'zshrc': 'shell', - 'fish': 'shell', - - // Version Control - 'gitconfig': 'ini', - 'hgrc': 'ini', - 'svnconfig': 'ini', - - // Web Server - 'nginx': 'nginx', - - // Misc Config - 'properties': 'properties', - 'cfg': 'ini', - 'reg': 'ini' -}; - - -export function filenameToVscodeLanguage(filename: string): string | undefined { - - const ext = filename.toLowerCase().split('.').pop(); - if (!ext) return undefined; - - return fileExtensionToVscodeLanguage[ext]; -} diff --git a/src/vs/workbench/contrib/void/browser/helpers/extractCodeFromResult.ts b/src/vs/workbench/contrib/void/common/helpers/extractCodeFromResult.ts similarity index 96% rename from src/vs/workbench/contrib/void/browser/helpers/extractCodeFromResult.ts rename to src/vs/workbench/contrib/void/common/helpers/extractCodeFromResult.ts index ee138358..f09ee3ae 100644 --- a/src/vs/workbench/contrib/void/browser/helpers/extractCodeFromResult.ts +++ b/src/vs/workbench/contrib/void/common/helpers/extractCodeFromResult.ts @@ -3,7 +3,7 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { OnText } from '../../common/sendLLMMessageTypes.js' +import { OnText } from '../sendLLMMessageTypes.js' import { DIVIDER, FINAL, ORIGINAL } from '../prompt/prompts.js' class SurroundingsRemover { @@ -264,7 +264,7 @@ export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string onText_(params) } - const newOnText: OnText = ({ fullText: fullText_ }) => { + const newOnText: OnText = ({ fullText: fullText_, ...p }) => { // until found the first think tag, keep adding to fullText if (!foundTag1) { const endsWithTag1 = endsWithAnyPrefixOf(fullText_, thinkTags[0]) @@ -282,7 +282,7 @@ export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string fullTextSoFar += fullText_.substring(0, tag1Index) // Update latestAddIdx to after the first tag latestAddIdx = tag1Index + thinkTags[0].length - onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) + onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) return } @@ -290,7 +290,7 @@ export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string // add the text to fullText fullTextSoFar = fullText_ latestAddIdx = fullText_.length - onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) + onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) return } @@ -314,7 +314,7 @@ export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string fullReasoningSoFar += fullText_.substring(latestAddIdx, tag2Index) // Update latestAddIdx to after the second tag latestAddIdx = tag2Index + thinkTags[1].length - onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) + onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) return } @@ -327,7 +327,7 @@ export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string latestAddIdx = fullText_.length } - onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) + onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) return } @@ -340,7 +340,7 @@ export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string latestAddIdx = fullText_.length } - onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) + onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) } return newOnText diff --git a/src/vs/workbench/contrib/void/common/helpers/languageHelpers.ts b/src/vs/workbench/contrib/void/common/helpers/languageHelpers.ts new file mode 100644 index 00000000..910b175d --- /dev/null +++ b/src/vs/workbench/contrib/void/common/helpers/languageHelpers.ts @@ -0,0 +1,197 @@ +// /*-------------------------------------------------------------------------------------- +// * Copyright 2025 Glass Devtools, Inc. All rights reserved. +// * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. +// *--------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { separateOutFirstLine } from './util.js'; + + +// this works better than model.getLanguageId() +export function detectLanguage(languageService: ILanguageService, opts: { uri: URI | null, fileContents: string | undefined }) { + const firstLine = opts.fileContents ? separateOutFirstLine(opts.fileContents)?.[0] : undefined + const fullLang = languageService.createByFilepathOrFirstLine(opts.uri, firstLine) + return fullLang.languageId || 'plaintext' +} + +// --- conversions +export const convertToVscodeLang = (languageService: ILanguageService, markdownLang: string) => { + if (markdownLang in markdownLangToVscodeLang) + return markdownLangToVscodeLang[markdownLang] + + const { languageId } = languageService.createById(markdownLang) + return languageId +} + + +// // eg "bash" -> "shell" +const markdownLangToVscodeLang: { [key: string]: string } = { + // Web Technologies + 'html': 'html', + 'css': 'css', + 'scss': 'scss', + 'sass': 'scss', + 'less': 'less', + 'javascript': 'typescript', + 'js': 'typescript', // use more general renderer + 'jsx': 'typescriptreact', + 'typescript': 'typescript', + 'ts': 'typescript', + 'tsx': 'typescriptreact', + 'json': 'json', + 'jsonc': 'json', + + // Programming Languages + 'python': 'python', + 'py': 'python', + 'java': 'java', + 'cpp': 'cpp', + 'c++': 'cpp', + 'c': 'c', + 'csharp': 'csharp', + 'cs': 'csharp', + 'c#': 'csharp', + 'go': 'go', + 'golang': 'go', + 'rust': 'rust', + 'rs': 'rust', + 'ruby': 'ruby', + 'rb': 'ruby', + 'php': 'php', + 'shell': 'shellscript', // this is important + 'bash': 'shellscript', + 'sh': 'shellscript', + 'zsh': 'shellscript', + + // Markup and Config + 'markdown': 'markdown', + 'md': 'markdown', + 'xml': 'xml', + 'svg': 'xml', + 'yaml': 'yaml', + 'yml': 'yaml', + 'ini': 'ini', + 'toml': 'ini', + + // Database and Query Languages + 'sql': 'sql', + 'mysql': 'sql', + 'postgresql': 'sql', + 'graphql': 'graphql', + 'gql': 'graphql', + + // Others + 'dockerfile': 'dockerfile', + 'docker': 'dockerfile', + 'makefile': 'makefile', + 'plaintext': 'plaintext', + 'text': 'plaintext' +}; + +// // eg ".ts" -> "typescript" +// const fileExtensionToVscodeLanguage: { [key: string]: string } = { +// // Web +// 'html': 'html', +// 'htm': 'html', +// 'css': 'css', +// 'scss': 'scss', +// 'less': 'less', +// 'js': 'javascript', +// 'jsx': 'javascript', +// 'ts': 'typescript', +// 'tsx': 'typescript', +// 'json': 'json', +// 'jsonc': 'json', + +// // Programming Languages +// 'py': 'python', +// 'java': 'java', +// 'cpp': 'cpp', +// 'cc': 'cpp', +// 'c': 'c', +// 'h': 'cpp', +// 'hpp': 'cpp', +// 'cs': 'csharp', +// 'go': 'go', +// 'rs': 'rust', +// 'rb': 'ruby', +// 'php': 'php', +// 'sh': 'shell', +// 'bash': 'shell', +// 'zsh': 'shell', + +// // Markup/Config +// 'md': 'markdown', +// 'markdown': 'markdown', +// 'xml': 'xml', +// 'svg': 'xml', +// 'yaml': 'yaml', +// 'yml': 'yaml', +// 'ini': 'ini', +// 'toml': 'ini', + +// // Other +// 'sql': 'sql', +// 'graphql': 'graphql', +// 'gql': 'graphql', +// 'dockerfile': 'dockerfile', +// 'docker': 'dockerfile', +// 'mk': 'makefile', + +// // Config Files and Dot Files +// 'npmrc': 'ini', +// 'env': 'ini', +// 'gitignore': 'ignore', +// 'dockerignore': 'ignore', +// 'eslintrc': 'json', +// 'babelrc': 'json', +// 'prettierrc': 'json', +// 'stylelintrc': 'json', +// 'editorconfig': 'ini', +// 'htaccess': 'apacheconf', +// 'conf': 'ini', +// 'config': 'ini', + +// // Package Files +// 'package': 'json', +// 'package-lock': 'json', +// 'gemfile': 'ruby', +// 'podfile': 'ruby', +// 'rakefile': 'ruby', + +// // Build Systems +// 'cmake': 'cmake', +// 'makefile': 'makefile', +// 'gradle': 'groovy', + +// // Shell Scripts +// 'bashrc': 'shell', +// 'zshrc': 'shell', +// 'fish': 'shell', + +// // Version Control +// 'gitconfig': 'ini', +// 'hgrc': 'ini', +// 'svnconfig': 'ini', + +// // Web Server +// 'nginx': 'nginx', + +// // Misc Config +// 'properties': 'properties', +// 'cfg': 'ini', +// 'reg': 'ini' +// }; + + +// export function filenameToVscodeLanguage(filename: string): string | undefined { + + + + +// const ext = filename.toLowerCase().split('.').pop(); +// if (!ext) return undefined; + +// return fileExtensionToVscodeLanguage[ext]; +// } diff --git a/src/vs/workbench/contrib/void/common/helpers/util.ts b/src/vs/workbench/contrib/void/common/helpers/util.ts new file mode 100644 index 00000000..b2309a3f --- /dev/null +++ b/src/vs/workbench/contrib/void/common/helpers/util.ts @@ -0,0 +1,18 @@ + +export const separateOutFirstLine = (content: string): [string, string] | [string, undefined] => { + const newLineIdx = content.indexOf('\r\n') + if (newLineIdx !== -1) { + const A = content.substring(0, newLineIdx) + const B = content.substring(newLineIdx + 2, Infinity); + return [A, B] + } + + const newLineIdx2 = content.indexOf('\n') + if (newLineIdx2 !== -1) { + const A = content.substring(0, newLineIdx2) + const B = content.substring(newLineIdx2 + 1, Infinity); + return [A, B] + } + + return [content, undefined] +} diff --git a/src/vs/workbench/contrib/void/common/modelCapabilities.ts b/src/vs/workbench/contrib/void/common/modelCapabilities.ts index 95de9604..88a45939 100644 --- a/src/vs/workbench/contrib/void/common/modelCapabilities.ts +++ b/src/vs/workbench/contrib/void/common/modelCapabilities.ts @@ -40,6 +40,8 @@ export const defaultModelsOfProvider = { vLLM: [ // autodetected ], openRouter: [ // https://openrouter.ai/models + 'anthropic/claude-3.7-sonnet:thinking', + 'anthropic/claude-3.7-sonnet', 'anthropic/claude-3.5-sonnet', 'deepseek/deepseek-r1', 'mistralai/codestral-2501', @@ -79,10 +81,11 @@ type ModelOptions = { supportsTools: false | 'anthropic-style' | 'openai-style'; supportsFIM: boolean; - supportsReasoning: false | { + reasoningCapabilities: false | { + readonly supportsReasoning: true; // reasoning options if supports reasoning - readonly canToggleReasoning: boolean; // whether or not the user can disable reasoning mode (false if the model only supports reasoning) - readonly canIOReasoning: boolean; // whether or not the model actually outputs reasoning + readonly canTurnOffReasoning: boolean; // whether or not the user can disable reasoning mode (false if the model only supports reasoning) + readonly canIOReasoning: boolean; // whether or not the model actually outputs reasoning (eg o1 lets us control reasoning but not output it) readonly reasoningMaxOutputTokens?: number; // overrides normal maxOutputTokens // <-- UNUSED (except anthropic) readonly reasoningBudgetSlider?: { type: 'slider'; min: number; max: number; default: number }; @@ -95,7 +98,7 @@ type ModelOptions = { type ProviderReasoningIOSettings = { // include this in payload to get reasoning - input?: { includeInPayload?: { [key: string]: any }, }; + input?: { includeInPayload?: (reasoningState: SendableReasoningInfo) => null | { [key: string]: any }, }; // nameOfFieldInDelta: reasoning output is in response.choices[0].delta[deltaReasoningField] // needsManualParse: whether we must manually parse out the tags output?: @@ -118,7 +121,7 @@ const modelOptionsDefaults: ModelOptions = { supportsSystemMessage: false, supportsTools: false, supportsFIM: false, - supportsReasoning: false, + reasoningCapabilities: false, } @@ -127,70 +130,70 @@ const openSourceModelOptions_assumingOAICompat = { supportsFIM: false, supportsSystemMessage: false, supportsTools: false, - supportsReasoning: { canToggleReasoning: false, canIOReasoning: true, openSourceThinkTags: ['', ''] }, + reasoningCapabilities: { supportsReasoning: true, canTurnOffReasoning: false, canIOReasoning: true, openSourceThinkTags: ['', ''] }, }, 'deepseekCoderV2': { supportsFIM: false, supportsSystemMessage: false, // unstable supportsTools: false, - supportsReasoning: false, + reasoningCapabilities: false, }, 'codestral': { supportsFIM: true, supportsSystemMessage: 'system-role', supportsTools: 'openai-style', - supportsReasoning: false, + reasoningCapabilities: false, }, // llama 'llama3': { supportsFIM: false, supportsSystemMessage: 'system-role', supportsTools: 'openai-style', - supportsReasoning: false, + reasoningCapabilities: false, }, 'llama3.1': { supportsFIM: false, supportsSystemMessage: 'system-role', supportsTools: 'openai-style', - supportsReasoning: false, + reasoningCapabilities: false, }, 'llama3.2': { supportsFIM: false, supportsSystemMessage: 'system-role', supportsTools: 'openai-style', - supportsReasoning: false, + reasoningCapabilities: false, }, 'llama3.3': { supportsFIM: false, supportsSystemMessage: 'system-role', supportsTools: 'openai-style', - supportsReasoning: false, + reasoningCapabilities: false, }, // qwen 'qwen2.5coder': { supportsFIM: true, supportsSystemMessage: 'system-role', supportsTools: 'openai-style', - supportsReasoning: false, + reasoningCapabilities: false, }, 'qwq': { supportsFIM: false, // no FIM, yes reasoning supportsSystemMessage: 'system-role', supportsTools: 'openai-style', - supportsReasoning: { canToggleReasoning: false, canIOReasoning: true, openSourceThinkTags: ['', ''] }, + reasoningCapabilities: { supportsReasoning: true, canTurnOffReasoning: false, canIOReasoning: true, openSourceThinkTags: ['', ''] }, }, // FIM only 'starcoder2': { supportsFIM: true, supportsSystemMessage: false, supportsTools: false, - supportsReasoning: false, + reasoningCapabilities: false, }, 'codegemma:2b': { supportsFIM: true, supportsSystemMessage: false, supportsTools: false, - supportsReasoning: false, + reasoningCapabilities: false, }, } as const satisfies { [s: string]: Partial } @@ -233,8 +236,9 @@ const anthropicModelOptions = { supportsFIM: false, supportsSystemMessage: 'separated', supportsTools: 'anthropic-style', - supportsReasoning: { - canToggleReasoning: true, + reasoningCapabilities: { + supportsReasoning: true, + canTurnOffReasoning: true, canIOReasoning: true, reasoningMaxOutputTokens: 64_000, // can bump it to 128_000 with beta mode output-128k-2025-02-19 reasoningBudgetSlider: { type: 'slider', min: 1024, max: 32_000, default: 1024 }, // they recommend batching if max > 32_000 @@ -247,7 +251,7 @@ const anthropicModelOptions = { supportsFIM: false, supportsSystemMessage: 'separated', supportsTools: 'anthropic-style', - supportsReasoning: false, + reasoningCapabilities: false, }, 'claude-3-5-haiku-20241022': { contextWindow: 200_000, @@ -256,7 +260,7 @@ const anthropicModelOptions = { supportsFIM: false, supportsSystemMessage: 'separated', supportsTools: 'anthropic-style', - supportsReasoning: false, + reasoningCapabilities: false, }, 'claude-3-opus-20240229': { contextWindow: 200_000, @@ -265,7 +269,7 @@ const anthropicModelOptions = { supportsFIM: false, supportsSystemMessage: 'separated', supportsTools: 'anthropic-style', - supportsReasoning: false, + reasoningCapabilities: false, }, 'claude-3-sonnet-20240229': { // no point of using this, but including this for people who put it in contextWindow: 200_000, cost: { input: 3.00, output: 15.00 }, @@ -273,11 +277,21 @@ const anthropicModelOptions = { supportsFIM: false, supportsSystemMessage: 'separated', supportsTools: 'anthropic-style', - supportsReasoning: false, + reasoningCapabilities: false, } } as const satisfies { [s: string]: ModelOptions } const anthropicSettings: ProviderSettings = { + providerReasoningIOSettings: { + input: { + includeInPayload: (reasoningInfo) => { + if (reasoningInfo?.type === 'budgetEnabled') { + return { thinking: { type: 'enabled', budget_tokens: reasoningInfo.reasoningBudget } } + } + return null + } + }, + }, modelOptions: anthropicModelOptions, modelOptionsFallback: (modelName) => { let fallbackName: keyof typeof anthropicModelOptions | null = null @@ -288,7 +302,7 @@ const anthropicSettings: ProviderSettings = { if (modelName.includes('claude-3-sonnet')) fallbackName = 'claude-3-sonnet-20240229' if (fallbackName) return { modelName: fallbackName, ...anthropicModelOptions[fallbackName] } return { modelName, ...modelOptionsDefaults, maxOutputTokens: 4_096 } - } + }, } @@ -301,7 +315,7 @@ const openAIModelOptions = { // https://platform.openai.com/docs/pricing supportsFIM: false, supportsTools: false, supportsSystemMessage: 'developer-role', - supportsReasoning: { canIOReasoning: false, canToggleReasoning: false }, // it doesn't actually output reasoning, but our logic is fine with it + reasoningCapabilities: { supportsReasoning: true, canIOReasoning: false, canTurnOffReasoning: false }, // it doesn't actually output reasoning, but our logic is fine with it }, 'o3-mini': { contextWindow: 200_000, @@ -310,7 +324,7 @@ const openAIModelOptions = { // https://platform.openai.com/docs/pricing supportsFIM: false, supportsTools: false, supportsSystemMessage: 'developer-role', - supportsReasoning: { canIOReasoning: false, canToggleReasoning: false }, + reasoningCapabilities: { supportsReasoning: true, canIOReasoning: false, canTurnOffReasoning: false }, }, 'gpt-4o': { contextWindow: 128_000, @@ -319,7 +333,7 @@ const openAIModelOptions = { // https://platform.openai.com/docs/pricing supportsFIM: false, supportsTools: 'openai-style', supportsSystemMessage: 'system-role', - supportsReasoning: false, + reasoningCapabilities: false, }, 'o1-mini': { contextWindow: 128_000, @@ -328,7 +342,7 @@ const openAIModelOptions = { // https://platform.openai.com/docs/pricing supportsFIM: false, supportsTools: false, supportsSystemMessage: false, // does not support any system - supportsReasoning: { canIOReasoning: false, canToggleReasoning: false }, + reasoningCapabilities: { supportsReasoning: true, canIOReasoning: false, canTurnOffReasoning: false }, }, 'gpt-4o-mini': { contextWindow: 128_000, @@ -337,7 +351,7 @@ const openAIModelOptions = { // https://platform.openai.com/docs/pricing supportsFIM: false, supportsTools: 'openai-style', supportsSystemMessage: 'system-role', // ?? - supportsReasoning: false, + reasoningCapabilities: false, }, } as const satisfies { [s: string]: ModelOptions } @@ -363,7 +377,7 @@ const xAIModelOptions = { supportsFIM: false, supportsSystemMessage: 'system-role', supportsTools: 'openai-style', - supportsReasoning: false, + reasoningCapabilities: false, }, } as const satisfies { [s: string]: ModelOptions } @@ -387,7 +401,7 @@ const geminiModelOptions = { // https://ai.google.dev/gemini-api/docs/pricing supportsFIM: false, supportsSystemMessage: 'system-role', supportsTools: 'openai-style', // we are assuming OpenAI SDK when calling gemini - supportsReasoning: false, + reasoningCapabilities: false, }, 'gemini-2.0-flash-lite-preview-02-05': { contextWindow: 1_048_576, @@ -396,7 +410,7 @@ const geminiModelOptions = { // https://ai.google.dev/gemini-api/docs/pricing supportsFIM: false, supportsSystemMessage: 'system-role', supportsTools: 'openai-style', - supportsReasoning: false, + reasoningCapabilities: false, }, 'gemini-1.5-flash': { contextWindow: 1_048_576, @@ -405,7 +419,7 @@ const geminiModelOptions = { // https://ai.google.dev/gemini-api/docs/pricing supportsFIM: false, supportsSystemMessage: 'system-role', supportsTools: 'openai-style', - supportsReasoning: false, + reasoningCapabilities: false, }, 'gemini-1.5-pro': { contextWindow: 2_097_152, @@ -414,7 +428,7 @@ const geminiModelOptions = { // https://ai.google.dev/gemini-api/docs/pricing supportsFIM: false, supportsSystemMessage: 'system-role', supportsTools: 'openai-style', - supportsReasoning: false, + reasoningCapabilities: false, }, 'gemini-1.5-flash-8b': { contextWindow: 1_048_576, @@ -423,7 +437,7 @@ const geminiModelOptions = { // https://ai.google.dev/gemini-api/docs/pricing supportsFIM: false, supportsSystemMessage: 'system-role', supportsTools: 'openai-style', - supportsReasoning: false, + reasoningCapabilities: false, }, } as const satisfies { [s: string]: ModelOptions } @@ -469,7 +483,7 @@ const groqModelOptions = { // https://console.groq.com/docs/models, https://groq supportsFIM: false, supportsSystemMessage: 'system-role', supportsTools: 'openai-style', - supportsReasoning: false, + reasoningCapabilities: false, }, 'llama-3.1-8b-instant': { contextWindow: 128_000, @@ -478,7 +492,7 @@ const groqModelOptions = { // https://console.groq.com/docs/models, https://groq supportsFIM: false, supportsSystemMessage: 'system-role', supportsTools: 'openai-style', - supportsReasoning: false, + reasoningCapabilities: false, }, 'qwen-2.5-coder-32b': { contextWindow: 128_000, @@ -487,7 +501,7 @@ const groqModelOptions = { // https://console.groq.com/docs/models, https://groq supportsFIM: false, // unfortunately looks like no FIM support on groq supportsSystemMessage: 'system-role', supportsTools: 'openai-style', - supportsReasoning: false, + reasoningCapabilities: false, }, 'qwen-qwq-32b': { // https://huggingface.co/Qwen/QwQ-32B contextWindow: 128_000, @@ -496,11 +510,21 @@ const groqModelOptions = { // https://console.groq.com/docs/models, https://groq supportsFIM: false, supportsSystemMessage: 'system-role', supportsTools: 'openai-style', - supportsReasoning: { canIOReasoning: true, canToggleReasoning: false, openSourceThinkTags: ['', ''] }, // we're using reasoning_format:parsed so really don't need to know openSourceThinkTags + reasoningCapabilities: { supportsReasoning: true, canIOReasoning: true, canTurnOffReasoning: false, openSourceThinkTags: ['', ''] }, // we're using reasoning_format:parsed so really don't need to know openSourceThinkTags }, } as const satisfies { [s: string]: ModelOptions } const groqSettings: ProviderSettings = { - providerReasoningIOSettings: { input: { includeInPayload: { reasoning_format: 'parsed' } }, output: { nameOfFieldInDelta: 'reasoning' }, }, // Must be set to either parsed or hidden when using tool calling https://console.groq.com/docs/reasoning + providerReasoningIOSettings: { + input: { + includeInPayload: (reasoningInfo) => { + if (reasoningInfo?.type === 'budgetEnabled') { + return { reasoning_format: 'parsed' } + } + return null + } + }, + output: { nameOfFieldInDelta: 'reasoning' }, + }, // Must be set to either parsed or hidden when using tool calling https://console.groq.com/docs/reasoning modelOptions: groqModelOptions, modelOptionsFallback: (modelName) => { return null } } @@ -536,6 +560,21 @@ const openRouterModelOptions_assumingOpenAICompat = { maxOutputTokens: null, cost: { input: 0.8, output: 2.4 }, }, + 'anthropic/claude-3.7-sonnet:thinking': { + contextWindow: 200_000, + maxOutputTokens: null, + cost: { input: 3.00, output: 15.00 }, + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + reasoningCapabilities: { // same as anthropic, see above + supportsReasoning: true, + canTurnOffReasoning: false, + canIOReasoning: true, + reasoningMaxOutputTokens: 64_000, + reasoningBudgetSlider: { type: 'slider', min: 1024, max: 32_000, default: 1024 }, // they recommend batching if max > 32_000 + }, + }, 'anthropic/claude-3.7-sonnet': { contextWindow: 200_000, maxOutputTokens: null, @@ -543,7 +582,7 @@ const openRouterModelOptions_assumingOpenAICompat = { supportsFIM: false, supportsSystemMessage: 'system-role', supportsTools: 'openai-style', - supportsReasoning: { canIOReasoning: true, canToggleReasoning: false }, // TODO!!! false for now + reasoningCapabilities: false, // stupidly, openrouter separates thinking from non-thinking }, 'anthropic/claude-3.5-sonnet': { contextWindow: 200_000, @@ -552,7 +591,7 @@ const openRouterModelOptions_assumingOpenAICompat = { supportsFIM: false, supportsSystemMessage: 'system-role', supportsTools: 'openai-style', - supportsReasoning: false, + reasoningCapabilities: false, }, 'mistralai/codestral-2501': { ...openSourceModelOptions_assumingOAICompat.codestral, @@ -560,7 +599,7 @@ const openRouterModelOptions_assumingOpenAICompat = { maxOutputTokens: null, cost: { input: 0.3, output: 0.9 }, supportsTools: 'openai-style', - supportsReasoning: false, + reasoningCapabilities: false, }, 'qwen/qwen-2.5-coder-32b-instruct': { ...openSourceModelOptions_assumingOAICompat['qwen2.5coder'], @@ -581,7 +620,18 @@ const openRouterModelOptions_assumingOpenAICompat = { const openRouterSettings: ProviderSettings = { // reasoning: OAICompat + response.choices[0].delta.reasoning : payload should have {include_reasoning: true} https://openrouter.ai/announcements/reasoning-tokens-for-thinking-models providerReasoningIOSettings: { - input: { includeInPayload: { include_reasoning: true } }, + input: { + includeInPayload: (reasoningInfo) => { + if (reasoningInfo?.type === 'budgetEnabled') { + return { + reasoning: { + max_tokens: reasoningInfo.reasoningBudget + } + } + } + return null + } + }, output: { nameOfFieldInDelta: 'reasoning' }, }, modelOptions: openRouterModelOptions_assumingOpenAICompat, @@ -632,12 +682,45 @@ export const getProviderCapabilities = (providerName: ProviderName) => { return { providerReasoningIOSettings } } -// state from optionsOfModelSelection -export const getModelSelectionState = (providerName: ProviderName, modelName: string, modelSelectionOptions: ModelSelectionOptions | undefined): { isReasoningEnabled: boolean, reasoningBudget: number | undefined } => { - const { canToggleReasoning, reasoningBudgetSlider } = getModelCapabilities(providerName, modelName).supportsReasoning || {} - const defaultEnabledVal = canToggleReasoning ? true : false +export type SendableReasoningInfo = { + type: 'budgetEnabled', + isReasoningEnabled: true, + reasoningBudget: number, +} | null + + + +export const getIsResoningEnabledState = ( + providerName: ProviderName, + modelName: string, + modelSelectionOptions: ModelSelectionOptions | undefined, +) => { + const { supportsReasoning } = getModelCapabilities(providerName, modelName).reasoningCapabilities || {} + if (!supportsReasoning) return false + + const defaultEnabledVal = true // if can't toggle reasoning, then this must be true. just true as default const isReasoningEnabled = modelSelectionOptions?.reasoningEnabled ?? defaultEnabledVal - const reasoningBudget = reasoningBudgetSlider?.type === 'slider' ? modelSelectionOptions?.reasoningBudget ?? reasoningBudgetSlider?.default : undefined - return { isReasoningEnabled, reasoningBudget } + return isReasoningEnabled +} + + +// used to force reasoning state (complex) into something simple we can just read from when sending a message +export const getSendableReasoningInfo = ( + providerName: ProviderName, + modelName: string, + modelSelectionOptions: ModelSelectionOptions | undefined, +): SendableReasoningInfo => { + + const { canIOReasoning, reasoningBudgetSlider } = getModelCapabilities(providerName, modelName).reasoningCapabilities || {} + if (!canIOReasoning) return null + const isReasoningEnabled = getIsResoningEnabledState(providerName, modelName, modelSelectionOptions) + if (!isReasoningEnabled) return null + + // check for reasoning budget + const reasoningBudget = reasoningBudgetSlider?.type === 'slider' ? modelSelectionOptions?.reasoningBudget ?? reasoningBudgetSlider?.default : undefined + if (reasoningBudget) { + return { type: 'budgetEnabled', isReasoningEnabled: isReasoningEnabled, reasoningBudget: reasoningBudget } + } + return null } diff --git a/src/vs/workbench/contrib/void/browser/prompt/prompts.ts b/src/vs/workbench/contrib/void/common/prompt/prompts.ts similarity index 55% rename from src/vs/workbench/contrib/void/browser/prompt/prompts.ts rename to src/vs/workbench/contrib/void/common/prompt/prompts.ts index 3199364f..b6e27ce6 100644 --- a/src/vs/workbench/contrib/void/browser/prompt/prompts.ts +++ b/src/vs/workbench/contrib/void/common/prompt/prompts.ts @@ -5,97 +5,243 @@ import { URI } from '../../../../../base/common/uri.js'; -import { filenameToVscodeLanguage } from '../../common/helpers/detectLanguage.js'; -import { CodeSelection, StagingSelectionItem, FileSelection } from '../chatThreadService.js'; -import { IModelService } from '../../../../../editor/common/services/model.js'; -import { os } from '../../common/helpers/systemInfo.js'; -import { IVoidFileService } from '../../common/voidFileService.js'; +import { os } from '../helpers/systemInfo.js'; +import { CodeSelection, FileSelection, StagingSelectionItem } from '../chatThreadServiceTypes.js'; +import { ChatMode } from '../voidSettingsTypes.js'; +import { IVoidModelService } from '../voidModelService.js'; +import { EndOfLinePreference } from '../../../../../editor/common/model.js'; +import { InternalToolInfo } from '../toolsServiceTypes.js'; // this is just for ease of readability export const tripleTick = ['```', '```'] -export const editToolDesc_toolDescription = `\ -A high level description of the change you'd like to make in the file. This description will be handed to a dumber, faster model that will quickly apply the change. \ -Typically the best description you can give here is a high level view of the final code you'd like to see. For example, you can write code excerpt(s) with "// ... existing code ..." comments to help you write less. \ -However, you are allowed to describe the change using whatever text/language you like, especially if the change is better described without code. \ -Do NOT output the whole file if possible, and try to write as LITTLE as needed to describe the change.` +const changesExampleContent = `\ +// ... existing code ... +// {{change 1}} +// ... existing code ... +// {{change 2}} +// ... existing code ... +// {{change 3}} +// ... existing code ...` + +const editToolDescription = `\ +${tripleTick[0]} +${changesExampleContent} +${tripleTick[1]}` + +const fileNameEdit = `${tripleTick[0]}typescript +/Users/username/Dekstop/my_project/app.ts +${changesExampleContent} +${tripleTick[1]}` -export const chat_systemMessage = (workspaces: string[], mode: 'agent' | 'gather' | 'chat') => `\ -You are a coding ${mode === 'agent' ? 'agent' : 'assistant'}. Your job is to help the user ${mode === 'agent' ? 'make changes to their codebase' : 'search and understand their codebase'}. + + +// ======================================================== tools ======================================================== + +const paginationHelper = { + desc: `Very large results may be paginated (indicated in the result). Pagination fails gracefully if out of bounds or invalid page number.`, + param: { pageNumber: { type: 'number', description: 'The page number (default is the first page = 1).' }, } +} as const + +export const voidTools = { + // --- context-gathering (read/search/list) --- + + read_file: { + name: 'read_file', + description: `Returns file contents of a given URI. ${paginationHelper.desc}`, + params: { + uri: { type: 'string', description: undefined }, + ...paginationHelper.param, + }, + }, + + list_dir: { + name: 'list_dir', + description: `Returns all file names and folder names in a given URI. ${paginationHelper.desc}`, + params: { + uri: { type: 'string', description: undefined }, + ...paginationHelper.param, + }, + }, + + pathname_search: { + name: 'pathname_search', + description: `Returns all pathnames that match a given grep query. You should use this when looking for a file with a specific name or path. This does NOT search file content. ${paginationHelper.desc}`, + params: { + query: { type: 'string', description: undefined }, + ...paginationHelper.param, + }, + }, + + text_search: { + name: 'text_search', + description: `Returns pathnames of files with an exact match of the query. The query can be any regex. This does NOT search pathname. As a follow-up, you may want to use read_file to view the full file contents of the results. ${paginationHelper.desc}`, + params: { + query: { type: 'string', description: undefined }, + ...paginationHelper.param, + }, + }, + + // --- editing (create/delete) --- + + create_uri: { + name: 'create_uri', + description: `Create a file or folder at the given path. To create a folder, ensure the path ends with a trailing slash. Fails gracefully if the file already exists. Missing ancestors in the path will be recursively created automatically.`, + params: { + uri: { type: 'string', description: undefined }, + }, + }, + + delete_uri: { + name: 'delete_uri', + description: `Delete a file or folder at the given path. Fails gracefully if the file or folder does not exist.`, + params: { + uri: { type: 'string', description: undefined }, + params: { type: 'string', description: 'Return -r here to delete this URI and all descendants (if applicable). Default is the empty string.' } + }, + }, + + edit: { // APPLY TOOL + name: 'edit', + description: `Edits the contents of a file, given the file's URI and a description. Fails gracefully if the file does not exist.`, + params: { + uri: { type: 'string', description: undefined }, + changeDescription: { + type: 'string', description: `\ +- Your changeDescription should be a brief code description of the change you want to make, with comments like "// ... existing code ..." to condense your writing. +- NEVER re-write the whole file, and ALWAYS use comments like "// ... existing code ...". Bias towards writing as little as possible. +- Your description will be handed to a dumber, faster model that will quickly apply the change, so it should be clear and concise. +- You must output your description in triple backticks. +Here's an example of a good description:\n${editToolDescription}.` + } + }, + }, + + terminal_command: { + name: 'terminal_command', + description: `Executes a terminal command.`, + params: { + command: { type: 'string', description: 'The terminal command to execute.' }, + waitForCompletion: { type: 'string', description: `Whether or not to await the command to complete and get the final result. Default is true. Make this value false when you want a command to run indefinitely without waiting for it.` }, + terminalId: { type: 'string', description: 'Optional (value must be an integer >= 1, or empty which will go with the default). This is the ID of the terminal instance to execute the command in. The primary purpose of this is to start a new terminal for background processes or tasks that run indefinitely (e.g. if you want to run a server locally). Fails gracefully if a terminal ID does not exist, by creating a new terminal instance. Defaults to the preferred terminal ID.' }, + }, + }, + + + // go_to_definition + // go_to_usages + +} satisfies { [name: string]: InternalToolInfo } + + + + + +// ======================================================== chat (normal, gather, agent) ======================================================== + + + +export const chat_systemMessage = (workspaces: string[], runningTerminalIds: string[], mode: ChatMode) => `\ +You are an expert coding ${mode === 'agent' ? 'agent' : 'assistant'} that runs in the Void code editor. Your job is \ +${mode === 'agent' ? `to help the user develop, run, deploy, and make changes to their codebase. You should ALWAYS bring user's task to completion to the fullest extent possible, calling tools to make all necessary changes. Do not be lazy.` + : mode === 'gather' ? `to search and understand the user's codebase. You MUST use tools to read files and help the user understand the codebase, even if you were initially given files.` + : mode === 'normal' ? `to assist the user with their coding tasks.` + : ''} You will be given instructions to follow from the user, \`INSTRUCTIONS\`. You may also be given a list of files that the user has specifically selected, \`SELECTIONS\`. Please assist the user with their query. The user's query is never invalid. - +${/* system info */''} The user's system information is as follows: - ${os} -- Open workspaces: ${workspaces.join(', ')} - -${mode === 'agent' || mode === 'gather' /* tool use */ ? `\ +- Open workspace(s): ${workspaces.join(', ') || 'NO WORKSPACE OPEN'} +${(mode === 'agent') && runningTerminalIds.length !== 0 ? `\ +- Existing terminal IDs: ${runningTerminalIds.join(', ')} +`: '\n'} +${/* tool use */ mode === 'agent' || mode === 'gather' ? `\ You will be given tools you can call. -- Only use tools if they help you accomplish the user's goal. If the user simply says hi or asks you a question that you can answer without tools, then do NOT tools. -- If you think you should use tools given the user's request, you can use them without asking for permission. Feel free to use tools to gather context, understand the codebase, ${mode === 'agent' ? 'edit files, ' : ''}etc. -- NEVER refer to a tool by name when speaking with the user. For example, do NOT say to the user "I'm going to use \`list_dir\`". Instead, say "I'm going to list all files in ___ directory", etc. Do not refer to "pages" of results, just say you're getting more results. -- Some tools only work if the user has a workspace open. +${mode === 'agent' ? `\ +- Only use tools if they help you accomplish the user's goal. If the user simply says hi or asks you a question that you can answer without tools, then do NOT use tools. +- ALWAYS use tools to take actions. For example, if you would like to edit a file, you MUST use a tool.` + : mode === 'gather' ? `\ +- Your primary use of tools should be to gather information to help the user understand the codebase and answer their query. +- You should extensively read files, types, etc and gather relevant context.` + : ''} +- If you think you should use tools, you do not need to ask for permission. Feel free to call tools whenever you'd like. You can use them to understand the codebase, ${mode === 'agent' ? 'run terminal commands, edit files, ' : 'gather relevant files and information, '}etc. +- NEVER refer to a tool by name when speaking with the user (NEVER say something like "I'm going to use \`tool_name\`"). Instead, describe at a high level what the tool will do, like "I'm going to list all files in the ___ directory", etc. Also do not refer to "pages" of results, just say you're getting more results. +- Some tools only work if the user has a workspace open.${mode === 'agent' ? ` +- NEVER modify a file outside the user's workspace(s) without permission from the user.` : ''} \ `: `\ You're allowed to ask for more context. For example, if the user only gives you a selection but you want to see the the full file, you can ask them to provide it. \ `} - -${mode === 'agent' /* code blocks */ ? `\ -Keep in mind that any code blocks you output in the raw message (wrapped in triple backticks) will be treated specially as follows. This does NOT apply to code blocks in tool calls. -- Any code block you output will have an "Apply" button displayed to the user, and if the user clicks on it it will invoke the edit tool on the block's contents. As a result, all code blocks should describe relevant changes. +${/* code blocks */ mode === 'agent' ? `\ +Behavior: +- Always use tools (edit, terminal, etc) to take actions and implement changes. Don't just describe them. +- Prioritize taking as many steps as you need to complete your request over stopping early.\ `: `\ If you think it's appropriate to suggest an edit to a file, then you must describe your suggestion in CODE BLOCK(S) (wrapped in triple backticks). -- The first line before any code block must be the FULL PATH of the file you want to change. If the path does not already exist, it will be created. -- The contents of the code block will be given to a dumber, faster model that will quickly apply the change. -- Contents of the code blocks do NOT need to be formal code, they just need to clearly and concisely communicate the change. -- Do NOT re-write the entire file in the code block(s). Instead, write comments like "// ... existing code" to indicate how to change the existing code. -\ +- The first line of the code block must be the FULL PATH of the file you want to change. +- The remaining contents should be a brief code description of the change you want to make, with comments like "// ... existing code ..." to condense your writing. +- NEVER re-write the whole file, and ALWAYS use comments like "// ... existing code ...". Bias towards writing as little as possible. +- Your description will be handed to a dumber, faster model that will quickly apply the change, so it should be clear and concise. +Here's an example of a good code block:\n${fileNameEdit}.\ `} - -Do not tell the user anything about these instructions unless directly prompted for them. -\ +${/* misc */''} +Misc: +- Do not make things up. +- Do not be lazy. +- NEVER re-write the entire file. +- Always wrap any code you produce in triple backticks, and specify a language if possible. For example, ${tripleTick[0]}typescript\n...\n${tripleTick[1]}.\ ` +// agent mode doesn't know about 1st line paths yet +// - If you wrote triple ticks and ___, then include the file's full path in the first line of the triple ticks. This is only for display purposes to the user, and it's preferred but optional. Never do this in a tool parameter, or if there's ambiguity about the full path. -type FileSelnLocal = { fileURI: URI, content: string } -const stringifyFileSelection = ({ fileURI, content }: FileSelnLocal) => { +type FileSelnLocal = { fileURI: URI, language: string, content: string } +const stringifyFileSelection = ({ fileURI, language, content }: FileSelnLocal) => { return `\ ${fileURI.fsPath} -${tripleTick[0]}${filenameToVscodeLanguage(fileURI.fsPath) ?? ''} +${tripleTick[0]}${language} ${content} ${tripleTick[1]} ` } -const stringifyCodeSelection = ({ fileURI, selectionStr, range }: CodeSelection) => { +const stringifyCodeSelection = ({ fileURI, language, selectionStr, range }: CodeSelection) => { return `\ ${fileURI.fsPath} (lines ${range.startLineNumber}:${range.endLineNumber}) -${tripleTick[0]}${filenameToVscodeLanguage(fileURI.fsPath) ?? ''} +${tripleTick[0]}${language} ${selectionStr} ${tripleTick[1]} ` } const failToReadStr = 'Could not read content. This file may have been deleted. If you expected content here, you can tell the user about this as they might not know.' -const stringifyFileSelections = async (fileSelections: FileSelection[], voidFileService: IVoidFileService) => { +const stringifyFileSelections = async (fileSelections: FileSelection[], voidModelService: IVoidModelService) => { if (fileSelections.length === 0) return null const fileSlns: FileSelnLocal[] = await Promise.all(fileSelections.map(async (sel) => { - const content = await voidFileService.readFile(sel.fileURI) ?? failToReadStr + const { model } = await voidModelService.getModelSafe(sel.fileURI) + const content = model?.getValue(EndOfLinePreference.LF) ?? failToReadStr return { ...sel, content } })) return fileSlns.map(sel => stringifyFileSelection(sel)).join('\n') } + + const stringifyCodeSelections = (codeSelections: CodeSelection[]) => { - return codeSelections.map(sel => stringifyCodeSelection(sel)).join('\n') || null + return codeSelections.map(sel => { + stringifyCodeSelection(sel) + }).join('\n') || null } + const stringifySelectionNames = (currSelns: StagingSelectionItem[] | null): string => { if (!currSelns) return '' return currSelns.map(s => `${s.fileURI.fsPath}${s.range ? ` (lines ${s.range.startLineNumber}:${s.range.endLineNumber})` : ''}`).join('\n') } + export const chat_userMessageContent = async (instructions: string, currSelns: StagingSelectionItem[] | null) => { const selnsStr = stringifySelectionNames(currSelns) @@ -106,7 +252,10 @@ export const chat_userMessageContent = async (instructions: string, currSelns: S return str; }; -export const chat_selectionsString = async (prevSelns: StagingSelectionItem[] | null, currSelns: StagingSelectionItem[] | null, voidFileService: IVoidFileService) => { +export const chat_selectionsString = async ( + prevSelns: StagingSelectionItem[] | null, currSelns: StagingSelectionItem[] | null, + voidModelService: IVoidModelService, +) => { // ADD IN FILES AT TOP const allSelections = [...currSelns || [], ...prevSelns || []] @@ -131,16 +280,11 @@ export const chat_selectionsString = async (prevSelns: StagingSelectionItem[] | } } - const filesStr = await stringifyFileSelections(fileSelections, voidFileService) + const filesStr = await stringifyFileSelections(fileSelections, voidModelService) const selnsStr = stringifyCodeSelections(codeSelections) - - if (filesStr || selnsStr) return `\ -ALL FILE CONTENTS -${filesStr} -${selnsStr}` - - return null + const fileContents = [filesStr, selnsStr].filter(Boolean).join('\n') + return fileContents || null } export const chat_lastUserMessageWithFilesAdded = (userMessage: string, selectionsString: string | null) => { @@ -160,10 +304,9 @@ Directions: +// ======================================================== apply (writeover) ======================================================== -export const rewriteCode_userMessage = ({ originalCode, applyStr, uri }: { originalCode: string, applyStr: string, uri: URI }) => { - - const language = filenameToVscodeLanguage(uri.fsPath) ?? '' +export const rewriteCode_userMessage = ({ originalCode, applyStr, language }: { originalCode: string, applyStr: string, language: string }) => { return `\ ORIGINAL_FILE @@ -183,69 +326,7 @@ Please finish writing the new file by applying the change to the original file. - - - -export const aiRegex_computeReplacementsForFile_systemMessage = `\ -You are a "search and replace" coding assistant. - -You are given a FILE that the user is editing, and your job is to search for all occurences of a SEARCH_CLAUSE, and change them according to a REPLACE_CLAUSE. - -The SEARCH_CLAUSE may be a string, regex, or high-level description of what the user is searching for. - -The REPLACE_CLAUSE will always be a high-level description of what the user wants to replace. - -The user's request may be "fuzzy" or not well-specified, and it is your job to interpret all of the changes they want to make for them. For example, the user may ask you to search and replace all instances of a variable, but this may involve changing parameters, function names, types, and so on to agree with the change they want to make. Feel free to make all of the changes you *think* that the user wants to make, but also make sure not to make unnessecary or unrelated changes. - -## Instructions - -1. If you do not want to make any changes, you should respond with the word "no". - -2. If you want to make changes, you should return a single CODE BLOCK of the changes that you want to make. -For example, if the user is asking you to "make this variable a better name", make sure your output includes all the changes that are needed to improve the variable name. -- Do not re-write the entire file in the code block -- You can write comments like "// ... existing code" to indicate existing code -- Make sure you give enough context in the code block to apply the changes to the correct location in the code` - - -export const aiRegex_computeReplacementsForFile_userMessage = async ({ searchClause, replaceClause, fileURI, voidFileService }: { searchClause: string, replaceClause: string, fileURI: URI, modelService: IModelService, voidFileService: IVoidFileService }) => { - - // we may want to do this in batches - const fileSelection: FileSelection = { type: 'File', fileURI, selectionStr: null, range: null, state: { isOpened: false } } - - const file = await stringifyFileSelections([fileSelection], voidFileService) - - return `\ -## FILE -${file} - -## SEARCH_CLAUSE -Here is what the user is searching for: -${searchClause} - -## REPLACE_CLAUSE -Here is what the user wants to replace it with: -${replaceClause} - -## INSTRUCTIONS -Please return the changes you want to make to the file in a codeblock, or return "no" if you do not want to make changes.` -} - - - - -// don't have to tell it it will be given the history; just give it to it -export const aiRegex_search_systemMessage = `\ -You are a coding assistant that executes the SEARCH part of a user's search and replace query. - -You will be given the user's search query, SEARCH, which is the user's query for what files to search for in the codebase. You may also be given the user's REPLACE query for additional context. - -Output -- Regex query -- Files to Include (optional) -- Files to Exclude? (optional) - -` +// ======================================================== apply (fast apply - search/replace) ======================================================== @@ -368,6 +449,8 @@ export const voidPrefixAndSuffix = ({ fullFileStr, startLine, endLine }: { fullF } +// ======================================================== quick edit (ctrl+K) ======================================================== + export type QuickEditFimTagsType = { preTag: string, sufTag: string, @@ -425,10 +508,82 @@ ${tripleTick[1]}).` + + + /* +// ======================================================== ai search/replace ======================================================== -OLD CHAT EXAMPLES: +export const aiRegex_computeReplacementsForFile_systemMessage = `\ +You are a "search and replace" coding assistant. + +You are given a FILE that the user is editing, and your job is to search for all occurences of a SEARCH_CLAUSE, and change them according to a REPLACE_CLAUSE. + +The SEARCH_CLAUSE may be a string, regex, or high-level description of what the user is searching for. + +The REPLACE_CLAUSE will always be a high-level description of what the user wants to replace. + +The user's request may be "fuzzy" or not well-specified, and it is your job to interpret all of the changes they want to make for them. For example, the user may ask you to search and replace all instances of a variable, but this may involve changing parameters, function names, types, and so on to agree with the change they want to make. Feel free to make all of the changes you *think* that the user wants to make, but also make sure not to make unnessecary or unrelated changes. + +## Instructions + +1. If you do not want to make any changes, you should respond with the word "no". + +2. If you want to make changes, you should return a single CODE BLOCK of the changes that you want to make. +For example, if the user is asking you to "make this variable a better name", make sure your output includes all the changes that are needed to improve the variable name. +- Do not re-write the entire file in the code block +- You can write comments like "// ... existing code" to indicate existing code +- Make sure you give enough context in the code block to apply the changes to the correct location in the code` + + + + +// export const aiRegex_computeReplacementsForFile_userMessage = async ({ searchClause, replaceClause, fileURI, voidFileService }: { searchClause: string, replaceClause: string, fileURI: URI, voidFileService: IVoidFileService }) => { + +// // we may want to do this in batches +// const fileSelection: FileSelection = { type: 'File', fileURI, selectionStr: null, range: null, state: { isOpened: false } } + +// const file = await stringifyFileSelections([fileSelection], voidFileService) + +// return `\ +// ## FILE +// ${file} + +// ## SEARCH_CLAUSE +// Here is what the user is searching for: +// ${searchClause} + +// ## REPLACE_CLAUSE +// Here is what the user wants to replace it with: +// ${replaceClause} + +// ## INSTRUCTIONS +// Please return the changes you want to make to the file in a codeblock, or return "no" if you do not want to make changes.` +// } + + + + +// // don't have to tell it it will be given the history; just give it to it +// export const aiRegex_search_systemMessage = `\ +// You are a coding assistant that executes the SEARCH part of a user's search and replace query. + +// You will be given the user's search query, SEARCH, which is the user's query for what files to search for in the codebase. You may also be given the user's REPLACE query for additional context. + +// Output +// - Regex query +// - Files to Include (optional) +// - Files to Exclude? (optional) + +// ` + + + + + + +// ======================================================== old examples ======================================================== Do not tell the user anything about the examples below. Do not assume the user is talking about any of the examples below. diff --git a/src/vs/workbench/contrib/void/common/sendLLMMessageService.ts b/src/vs/workbench/contrib/void/common/sendLLMMessageService.ts index 1213b256..5b3023ce 100644 --- a/src/vs/workbench/contrib/void/common/sendLLMMessageService.ts +++ b/src/vs/workbench/contrib/void/common/sendLLMMessageService.ts @@ -38,6 +38,7 @@ export class LLMMessageService extends Disposable implements ILLMMessageService onText: {} as { [eventId: string]: ((params: EventLLMMessageOnTextParams) => void) }, onFinalMessage: {} as { [eventId: string]: ((params: EventLLMMessageOnFinalMessageParams) => void) }, onError: {} as { [eventId: string]: ((params: EventLLMMessageOnErrorParams) => void) }, + onAbort: {} as { [eventId: string]: (() => void) }, // NOT sent over the channel, result is instant when we call .abort() } // list hooks @@ -71,8 +72,8 @@ export class LLMMessageService extends Disposable implements ILLMMessageService // .listen sets up an IPC channel and takes a few ms, so we set up listeners immediately and add hooks to them instead // llm this._register((this.channel.listen('onText_sendLLMMessage') satisfies Event)(e => { this.llmMessageHooks.onText[e.requestId]?.(e) })) - this._register((this.channel.listen('onFinalMessage_sendLLMMessage') satisfies Event)(e => { this.llmMessageHooks.onFinalMessage[e.requestId]?.(e); this._onRequestIdDone(e.requestId) })) - this._register((this.channel.listen('onError_sendLLMMessage') satisfies Event)(e => { this.llmMessageHooks.onError[e.requestId]?.(e); this._onRequestIdDone(e.requestId); console.error('Error in LLMMessageService:', JSON.stringify(e)) })) + this._register((this.channel.listen('onFinalMessage_sendLLMMessage') satisfies Event)(e => { this.llmMessageHooks.onFinalMessage[e.requestId]?.(e); this._clearChannelHooks(e.requestId) })) + this._register((this.channel.listen('onError_sendLLMMessage') satisfies Event)(e => { this.llmMessageHooks.onError[e.requestId]?.(e); this._clearChannelHooks(e.requestId); console.error('Error in LLMMessageService:', JSON.stringify(e)) })) // ollama .list() this._register((this.channel.listen('onSuccess_list_ollama') satisfies Event>)(e => { this.listHooks.ollama.success[e.requestId]?.(e) })) this._register((this.channel.listen('onError_list_ollama') satisfies Event>)(e => { this.listHooks.ollama.error[e.requestId]?.(e) })) @@ -82,7 +83,7 @@ export class LLMMessageService extends Disposable implements ILLMMessageService } sendLLMMessage(params: ServiceSendLLMMessageParams) { - const { onText, onFinalMessage, onError, modelSelection, ...proxyParams } = params; + const { onText, onFinalMessage, onError, onAbort, modelSelection, ...proxyParams } = params; // throw an error if no model/provider selected (this should usually never be reached, the UI should check this first, but might happen in cases like Apply where we haven't built much UI/checks yet, good practice to have check logic on backend) if (modelSelection === null) { @@ -91,11 +92,19 @@ export class LLMMessageService extends Disposable implements ILLMMessageService return null } + if (params.messagesType === 'chatMessages' && (params.messages?.length ?? 0) === 0) { + const message = `No messages detected.` + onError({ message, fullError: null }) + return null + } + + // add state for request id const requestId = generateUuid(); this.llmMessageHooks.onText[requestId] = onText this.llmMessageHooks.onFinalMessage[requestId] = onFinalMessage this.llmMessageHooks.onError[requestId] = onError + this.llmMessageHooks.onAbort[requestId] = onAbort // used internally only const { aiInstructions } = this.voidSettingsService.state.globalSettings const { settingsOfProvider, } = this.voidSettingsService.state @@ -112,10 +121,10 @@ export class LLMMessageService extends Disposable implements ILLMMessageService return requestId } - abort(requestId: string) { + this.llmMessageHooks.onAbort[requestId]?.() // calling the abort hook here is instant (doesn't go over a channel) this.channel.call('abort', { requestId } satisfies MainLLMMessageAbortParams); - this._onRequestIdDone(requestId) + this._clearChannelHooks(requestId) } @@ -156,7 +165,7 @@ export class LLMMessageService extends Disposable implements ILLMMessageService } satisfies MainModelListParams) } - _onRequestIdDone(requestId: string) { + _clearChannelHooks(requestId: string) { delete this.llmMessageHooks.onText[requestId] delete this.llmMessageHooks.onFinalMessage[requestId] delete this.llmMessageHooks.onError[requestId] diff --git a/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts b/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts index 76d62af9..82df3d26 100644 --- a/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts +++ b/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts @@ -3,7 +3,7 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import type { InternalToolInfo, ToolName } from '../browser/toolsService.js' +import { ToolName, InternalToolInfo } from './toolsServiceTypes.js' import { ModelSelection, ModelSelectionOptions, ProviderName, SettingsOfProvider } from './voidSettingsTypes.js' @@ -54,9 +54,10 @@ export type ToolCallType = { export type AnthropicReasoning = ({ type: 'thinking'; thinking: any; signature: string; } | { type: 'redacted_thinking', data: any }) -export type OnText = (p: { fullText: string; fullReasoning: string }) => void +export type OnText = (p: { fullText: string; fullReasoning: string; fullToolName: string; fullToolParams: string; }) => void export type OnFinalMessage = (p: { fullText: string; fullReasoning: string; toolCalls?: ToolCallType[]; anthropicReasoning: AnthropicReasoning[] | null }) => void // id is tool_use_id export type OnError = (p: { message: string; fullError: Error | null }) => void +export type OnAbort = () => void export type AbortRef = { current: (() => void) | null } @@ -81,9 +82,10 @@ export type ServiceSendLLMMessageParams = { onText: OnText; onFinalMessage: OnFinalMessage; onError: OnError; - logging: { loggingName: string, }; + logging: { loggingName: string, loggingExtras?: { [k: string]: any } }; modelSelection: ModelSelection | null; modelSelectionOptions: ModelSelectionOptions | undefined; + onAbort: OnAbort; } & SendLLMType; // params to the true sendLLMMessage function @@ -91,7 +93,7 @@ export type SendLLMMessageParams = { onText: OnText; onFinalMessage: OnFinalMessage; onError: OnError; - logging: { loggingName: string, }; + logging: { loggingName: string, loggingExtras?: { [k: string]: any } }; abortRef: AbortRef; aiInstructions: string; @@ -114,7 +116,6 @@ export type EventLLMMessageOnTextParams = Parameters[0] & { requestId: s export type EventLLMMessageOnFinalMessageParams = Parameters[0] & { requestId: string } export type EventLLMMessageOnErrorParams = Parameters[0] & { requestId: string } - // service -> main -> internal -> event (back to main) // (browser) diff --git a/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts b/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts new file mode 100644 index 00000000..adefa286 --- /dev/null +++ b/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts @@ -0,0 +1,67 @@ +import { URI } from '../../../../base/common/uri.js' +import { voidTools } from './prompt/prompts.js'; + + +export type ToolDirectoryItem = { + uri: URI; + name: string; + isDirectory: boolean; + isSymbolicLink: boolean; +} + + +export type TerminalResolveReason = { type: 'toofull' | 'timeout' | 'bgtask' } | { type: 'done', exitCode: number } + + + +// we do this using Anthropic's style and convert to OpenAI style later +export type InternalToolInfo = { + name: string, + description: string, + params: { + [paramName: string]: { type: string, description: string | undefined } // name -> type + }, +} + + + + +export type ToolName = keyof typeof voidTools +export const toolNames = Object.keys(voidTools) as ToolName[] + +const toolNamesSet = new Set(toolNames) +export const isAToolName = (toolName: string): toolName is ToolName => { + const isAToolName = toolNamesSet.has(toolName) + return isAToolName +} + + +const toolNamesWithApproval = ['create_uri', 'delete_uri', 'edit', 'terminal_command'] as const satisfies readonly ToolName[] +export type ToolNameWithApproval = typeof toolNamesWithApproval[number] +export const toolNamesThatRequireApproval = new Set(toolNamesWithApproval) + +export type ToolCallParams = { + 'read_file': { uri: URI, pageNumber: number }, + 'list_dir': { rootURI: URI, pageNumber: number }, + 'pathname_search': { queryStr: string, pageNumber: number }, + 'text_search': { queryStr: string, pageNumber: number }, + // --- + 'edit': { uri: URI, changeDescription: string }, + 'create_uri': { uri: URI, isFolder: boolean }, + 'delete_uri': { uri: URI, isRecursive: boolean, isFolder: boolean }, + 'terminal_command': { command: string, proposedTerminalId: string, waitForCompletion: boolean }, +} + + +export type ToolResultType = { + 'read_file': { fileContents: string, hasNextPage: boolean }, + 'list_dir': { children: ToolDirectoryItem[] | null, hasNextPage: boolean, hasPrevPage: boolean, itemsRemaining: number }, + 'pathname_search': { uris: URI[], hasNextPage: boolean }, + 'text_search': { uris: URI[], hasNextPage: boolean }, + // --- + 'edit': Promise, + 'create_uri': {}, + 'delete_uri': {}, + 'terminal_command': { terminalId: string, didCreateTerminal: boolean, result: string; resolveReason: TerminalResolveReason; }, +} + diff --git a/src/vs/workbench/contrib/void/common/voidFileService.ts b/src/vs/workbench/contrib/void/common/voidFileService.ts deleted file mode 100644 index cebad454..00000000 --- a/src/vs/workbench/contrib/void/common/voidFileService.ts +++ /dev/null @@ -1,94 +0,0 @@ -/*-------------------------------------------------------------------------------------- - * Copyright 2025 Glass Devtools, Inc. All rights reserved. - * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. - *--------------------------------------------------------------------------------------*/ - -import { URI } from '../../../../base/common/uri.js'; -import { EndOfLinePreference } from '../../../../editor/common/model.js'; -import { IModelService } from '../../../../editor/common/services/model.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; -import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; - -export interface IVoidFileService { - readonly _serviceBrand: undefined; - - readFile(uri: URI, range?: { startLineNumber: number, endLineNumber: number }): Promise; - readModel(uri: URI, range?: { startLineNumber: number, endLineNumber: number }): string | null; -} - -export const IVoidFileService = createDecorator('VoidFileService'); - -// implemented by calling channel -export class VoidFileService implements IVoidFileService { - readonly _serviceBrand: undefined; - - constructor( - @IModelService private readonly modelService: IModelService, - @IFileService private readonly fileService: IFileService, - ) { - - } - - readFile = async (uri: URI, range?: { startLineNumber: number, endLineNumber: number }): Promise => { - - // attempt to read the model - const modelResult = this.readModel(uri, range); - if (modelResult) return modelResult; - - // if no model, read the raw file - const fileResult = await this._readFileRaw(uri, range); - if (fileResult) return fileResult; - - return ''; - } - - _readFileRaw = async (uri: URI, range?: { startLineNumber: number, endLineNumber: number }): Promise => { - - try { // this throws an error if no file exists (eg it was deleted) - const res = await this.fileService.readFile(uri); - const str = res.value.toString().replace(/\r\n/g, '\n'); // even if not on Windows, might read a file with \r\n - if (range) return str.split('\n').slice(range.startLineNumber - 1, range.endLineNumber).join('\n') - return str; - } catch (e) { - return null; - } - } - - - readModel = (uri: URI, range?: { startLineNumber: number, endLineNumber: number }): string | null => { - - // read saved model (sometimes null if the user reloads application) - let model = this.modelService.getModel(uri); - - // check all opened models for the same `fsPath` - if (!model) { - const models = this.modelService.getModels(); - for (const m of models) { - if (m.uri.fsPath === uri.fsPath) { - model = m - break; - } - } - } - - // if still not found, return - if (!model) { return null } - - // if range, read it - if (range) { - return model.getValueInRange({ - startLineNumber: range.startLineNumber, - endLineNumber: range.endLineNumber, - startColumn: 1, - endColumn: Number.MAX_VALUE - }, EndOfLinePreference.LF); - } else { - return model.getValue(EndOfLinePreference.LF) - } - - } - -} - -registerSingleton(IVoidFileService, VoidFileService, InstantiationType.Eager); diff --git a/src/vs/workbench/contrib/void/common/voidModelService.ts b/src/vs/workbench/contrib/void/common/voidModelService.ts new file mode 100644 index 00000000..8cbf4ac9 --- /dev/null +++ b/src/vs/workbench/contrib/void/common/voidModelService.ts @@ -0,0 +1,69 @@ +import { Disposable, IReference } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ITextModel } from '../../../../editor/common/model.js'; +import { IResolvedTextEditorModel, ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +type VoidModelType = { + model: ITextModel | null; + editorModel: IResolvedTextEditorModel | null; +}; + +export interface IVoidModelService { + readonly _serviceBrand: undefined; + initializeModel(uri: URI): Promise; + getModel(uri: URI): VoidModelType; + getModelSafe(uri: URI): Promise; +} + +export const IVoidModelService = createDecorator('voidVoidModelService'); + +class VoidModelService extends Disposable implements IVoidModelService { + _serviceBrand: undefined; + static readonly ID = 'voidVoidModelService'; + private readonly _modelRefOfURI: Record> = {}; + + constructor( + @ITextModelService private readonly _textModelService: ITextModelService, + ) { + super(); + } + + initializeModel = async (uri: URI) => { + if (uri.fsPath in this._modelRefOfURI) return; + const editorModelRef = await this._textModelService.createModelReference(uri); + // Keep a strong reference to prevent disposal + this._modelRefOfURI[uri.fsPath] = editorModelRef; + }; + + getModel = (uri: URI): VoidModelType => { + const editorModelRef = this._modelRefOfURI[uri.fsPath]; + if (!editorModelRef) { + return { model: null, editorModel: null }; + } + + const model = editorModelRef.object.textEditorModel; + + if (!model) { + return { model: null, editorModel: editorModelRef.object }; + } + + return { model, editorModel: editorModelRef.object }; + }; + + getModelSafe = async (uri: URI): Promise => { + if (!(uri.fsPath in this._modelRefOfURI)) await this.initializeModel(uri); + return this.getModel(uri); + + }; + + override dispose() { + super.dispose(); + for (const ref of Object.values(this._modelRefOfURI)) { + ref.dispose(); // release reference to allow disposal + } + } +} + +registerSingleton(IVoidModelService, VoidModelService, InstantiationType.Eager); diff --git a/src/vs/workbench/contrib/void/common/voidSettingsService.ts b/src/vs/workbench/contrib/void/common/voidSettingsService.ts index 278d63da..e631a8aa 100644 --- a/src/vs/workbench/contrib/void/common/voidSettingsService.ts +++ b/src/vs/workbench/contrib/void/common/voidSettingsService.ts @@ -12,7 +12,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IMetricsService } from './metricsService.js'; import { getModelCapabilities } from './modelCapabilities.js'; -import { defaultSettingsOfProvider, FeatureName, ProviderName, ModelSelectionOfFeature, SettingsOfProvider, SettingName, providerNames, ModelSelection, modelSelectionsEqual, featureNames, VoidModelInfo, GlobalSettings, GlobalSettingName, defaultGlobalSettings, defaultProviderSettings, ModelSelectionOptions, OptionsOfModelSelection } from './voidSettingsTypes.js'; +import { defaultSettingsOfProvider, FeatureName, ProviderName, ModelSelectionOfFeature, SettingsOfProvider, SettingName, providerNames, ModelSelection, modelSelectionsEqual, featureNames, VoidModelInfo, GlobalSettings, GlobalSettingName, defaultGlobalSettings, defaultProviderSettings, ModelSelectionOptions, OptionsOfModelSelection, ChatMode } from './voidSettingsTypes.js'; // past values: // 'void.settingsServiceStorage' @@ -97,15 +97,15 @@ const _updatedModelsAfterDefaultModelsChange = (defaultModelNames: string[], opt } -export const modelFilterOfFeatureName: { [featureName in FeatureName]: { filter: (o: ModelSelection) => boolean; emptyMessage: string | null } } = { - 'Autocomplete': { filter: o => getModelCapabilities(o.providerName, o.modelName).supportsFIM, emptyMessage: 'No models support FIM' }, - 'Chat': { filter: o => true, emptyMessage: null }, - 'Ctrl+K': { filter: o => true, emptyMessage: null }, - 'Apply': { filter: o => true, emptyMessage: null }, +export const modelFilterOfFeatureName: { [featureName in FeatureName]: { filter: (o: ModelSelection, opts: { chatMode: ChatMode }) => boolean; emptyMessage: null | { message: string, priority: 'always' | 'fallback' } } } = { + 'Autocomplete': { filter: (o) => getModelCapabilities(o.providerName, o.modelName).supportsFIM, emptyMessage: { message: 'No models support FIM', priority: 'always' } }, + 'Chat': { filter: (o, { chatMode }) => chatMode === 'normal' ? true : !!getModelCapabilities(o.providerName, o.modelName).supportsTools, emptyMessage: { message: 'No models support tool use', priority: 'fallback' } }, + 'Ctrl+K': { filter: o => true, emptyMessage: null, }, + 'Apply': { filter: o => true, emptyMessage: null, }, } -const _validatedState = (state: Omit) => { +const _validatedModelState = (state: Omit) => { let newSettingsOfProvider = state.settingsOfProvider @@ -143,7 +143,8 @@ const _validatedState = (state: Omit) => { for (const featureName of featureNames) { const { filter } = modelFilterOfFeatureName[featureName] - const modelOptionsForThisFeature = newModelOptions.filter((o) => filter(o.selection)) + const filterOpts = { chatMode: state.globalSettings.chatMode } + const modelOptionsForThisFeature = newModelOptions.filter((o) => filter(o.selection, filterOpts)) const modelSelectionAtFeature = newModelSelectionOfFeature[featureName] const selnIdx = modelSelectionAtFeature === null ? -1 : modelOptionsForThisFeature.findIndex(m => modelSelectionsEqual(m.selection, modelSelectionAtFeature)) @@ -218,7 +219,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { // the stored data structure might be outdated, so we need to update it here const finalState = readS - this.state = _validatedState(finalState); + this.state = _validatedModelState(finalState); this._resolver(); this._onDidChangeState.fire(); @@ -265,7 +266,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { globalSettings: newGlobalSettings, } - this.state = _validatedState(newState) + this.state = _validatedModelState(newState) await this._storeState() this._onDidChangeState.fire() @@ -273,6 +274,12 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { } + private _onUpdate_syncApplyToChat() { + // if sync is turned on, sync (call this whenever Chat model or !!sync changes) + this.setModelSelectionOfFeature('Apply', deepClone(this.state.modelSelectionOfFeature['Chat'])) + + } + setGlobalSetting: SetGlobalSettingFn = async (settingName, newVal) => { const newState: VoidSettingsState = { ...this.state, @@ -281,10 +288,12 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { [settingName]: newVal } } - this.state = newState + this.state = _validatedModelState(newState) await this._storeState() this._onDidChangeState.fire() + // hooks + if (this.state.globalSettings.syncApplyToChat) this._onUpdate_syncApplyToChat() } @@ -297,10 +306,15 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { } } - this.state = newState + this.state = _validatedModelState(newState) await this._storeState() this._onDidChangeState.fire() + + // hooks + if (featureName === 'Chat') { + if (this.state.globalSettings.syncApplyToChat) this._onUpdate_syncApplyToChat() + } } @@ -318,7 +332,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { } } } - this.state = newState + this.state = _validatedModelState(newState) await this._storeState() this._onDidChangeState.fire() diff --git a/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts b/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts index 5636790a..acfe57f0 100644 --- a/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts +++ b/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts @@ -324,7 +324,7 @@ export const displayInfoOfFeatureName = (featureName: FeatureName) => { else if (featureName === 'Chat') return 'Chat' else if (featureName === 'Apply') - return 'Fast Apply' + return 'Apply' else throw new Error(`Feature Name ${featureName} not allowed`) } @@ -378,21 +378,27 @@ export const isFeatureNameDisabled = (featureName: FeatureName, settingsState: V -export type ChatMode = 'agent' | 'gather' | 'chat' +export type ChatMode = 'agent' | 'gather' | 'normal' export type GlobalSettings = { autoRefreshModels: boolean; aiInstructions: string; enableAutocomplete: boolean; + syncApplyToChat: boolean; + enableFastApply: boolean; chatMode: ChatMode; + autoApprove: boolean; } export const defaultGlobalSettings: GlobalSettings = { autoRefreshModels: true, aiInstructions: '', enableAutocomplete: false, + syncApplyToChat: true, + enableFastApply: true, chatMode: 'agent', + autoApprove: false, } export type GlobalSettingName = keyof GlobalSettings diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts index 6ff7906e..ab9991c1 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts @@ -89,12 +89,6 @@ const prepareMessages_systemMessage = ({ const newMessages: (InternalLLMChatMessage | { role: 'developer', content: string })[] = messages.filter(msg => msg.role !== 'system') - // if (!supportsTools) { - // if (!systemMessageStr) systemMessageStr = '' - // systemMessageStr += '' // TODO!!! add tool use system message here - // } - - // if it has a system message (if doesn't, we obviously don't care about whether it supports system message or not...) if (systemMessageStr) { // if supports system message @@ -285,7 +279,6 @@ const prepareMessages_tools_anthropic = ({ messages }: { messages: InternalLLMCh role: 'user', content: [ ...[{ type: 'tool_result', tool_use_id: currMsg.id, content: currMsg.content || EMPTY_TOOL_CONTENT }] as const, - ...currMsg.content ? [{ type: 'text', text: currMsg.content }] as const : [], ] } } @@ -368,6 +361,7 @@ const prepareMessages_noEmptyMessage = ({ messages }: { messages: PrepareMessage else if (c.type === 'tool_use') { } else if (c.type === 'tool_result') { } } + if (currMsg.content.length === 0) currMsg.content = [{ type: 'text', text: EMPTY_MESSAGE }] } } @@ -396,6 +390,7 @@ export const prepareMessages = ({ const { messages: messages3, separateSystemMessageStr } = prepareMessages_systemMessage({ messages: messages2, aiInstructions, supportsSystemMessage }) const { messages: messages4 } = prepareMessages_tools({ messages: messages3, supportsTools }) const { messages: messages5 } = prepareMessages_noEmptyMessage({ messages: messages4 }) + return { messages: messages5 as any, separateSystemMessageStr diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts index dd75882e..1d61af41 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts @@ -7,13 +7,12 @@ import Anthropic from '@anthropic-ai/sdk'; import { Ollama } from 'ollama'; import OpenAI, { ClientOptions } from 'openai'; -import { Model as OpenAIModel } from 'openai/resources/models.js'; -import { extractReasoningOnFinalMessage, extractReasoningOnTextWrapper } from '../../browser/helpers/extractCodeFromResult.js'; +import { extractReasoningOnFinalMessage, extractReasoningOnTextWrapper } from '../../common/helpers/extractCodeFromResult.js'; import { LLMChatMessage, LLMFIMMessage, ModelListParams, OllamaModelResponse, OnError, OnFinalMessage, OnText } from '../../common/sendLLMMessageTypes.js'; -import { InternalToolInfo, isAToolName, ToolName } from '../../browser/toolsService.js'; import { defaultProviderSettings, displayInfoOfProviderName, ModelSelectionOptions, ProviderName, SettingsOfProvider } from '../../common/voidSettingsTypes.js'; import { prepareFIMMessage, prepareMessages } from './preprocessLLMMessages.js'; -import { getModelSelectionState, getModelCapabilities, getProviderCapabilities } from '../../common/modelCapabilities.js'; +import { getSendableReasoningInfo, getModelCapabilities, getProviderCapabilities } from '../../common/modelCapabilities.js'; +import { InternalToolInfo, ToolName, isAToolName } from '../../common/toolsServiceTypes.js'; type InternalCommonMessageParams = { @@ -37,17 +36,19 @@ const invalidApiKeyMessage = (providerName: ProviderName) => `Invalid ${displayI // ------------ OPENAI-COMPATIBLE (HELPERS) ------------ const toOpenAICompatibleTool = (toolInfo: InternalToolInfo) => { - const { name, description, params, required } = toolInfo + const { name, description, params } = toolInfo return { type: 'function', function: { name: name, + strict: true, // strict mode - https://platform.openai.com/docs/guides/function-calling?api-mode=chat description: description, parameters: { type: 'object', properties: params, - required: required, - } + required: Object.keys(params), // in strict mode, all params are required and additionalProperties is false + additionalProperties: false, + }, } } satisfies OpenAI.Chat.Completions.ChatCompletionTool } @@ -151,32 +152,41 @@ const _sendOpenAICompatibleFIM = ({ messages: messages_, onFinalMessage, onError -const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName: modelName_, _setAborter, providerName, aiInstructions, tools: tools_ }: SendChatParams_Internal) => { +const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName: modelName_, _setAborter, providerName, aiInstructions, tools: tools_ }: SendChatParams_Internal) => { const { modelName, - supportsReasoning, supportsSystemMessage, supportsTools, - // maxOutputTokens, right now we are ignoring this + // maxOutputTokens, + reasoningCapabilities, } = getModelCapabilities(providerName, modelName_) - const { - canIOReasoning, - openSourceThinkTags, - } = supportsReasoning || {} - - const { providerReasoningIOSettings } = getProviderCapabilities(providerName) - const { messages } = prepareMessages({ messages: messages_, aiInstructions, supportsSystemMessage, supportsTools, supportsAnthropicReasoningSignature: false }) + // reasoning + const { canIOReasoning, openSourceThinkTags, } = reasoningCapabilities || {} + const reasoningInfo = getSendableReasoningInfo(providerName, modelName_, modelSelectionOptions) // user's modelName_ here + const includeInPayload = providerReasoningIOSettings?.input?.includeInPayload?.(reasoningInfo) || {} + + // tools const tools = (supportsTools && ((tools_?.length ?? 0) !== 0)) ? tools_?.map(tool => toOpenAICompatibleTool(tool)) : undefined - - const includeInPayload = canIOReasoning ? providerReasoningIOSettings?.input?.includeInPayload || {} : {} - const toolsObj = tools ? { tools: tools, tool_choice: 'auto', parallel_tool_calls: false, } as const : {} - const openai: OpenAI = newOpenAICompatibleSDK({ providerName, settingsOfProvider, includeInPayload }) - const options: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model: modelName, messages: messages, stream: true, ...toolsObj, } + // max tokens + // const maxTokens = reasoningInfo?.isReasoningEnabled && reasoningCapabilities ? reasoningCapabilities.reasoningMaxOutputTokens : maxOutputTokens + + // instance + const { messages } = prepareMessages({ messages: messages_, aiInstructions, supportsSystemMessage, supportsTools, supportsAnthropicReasoningSignature: false }) + const openai: OpenAI = newOpenAICompatibleSDK({ providerName, settingsOfProvider, includeInPayload }) + const options: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { + model: modelName, + messages: messages, + stream: true, + // max_completion_tokens: maxTokens, + ...toolsObj, + } + + // open source models - manually parse think tokens const { needsManualParse: needsManualReasoningParse, nameOfFieldInDelta: nameOfReasoningFieldInDelta } = providerReasoningIOSettings?.output ?? {} const manuallyParseReasoning = needsManualReasoningParse && canIOReasoning && openSourceThinkTags if (manuallyParseReasoning) { @@ -185,6 +195,10 @@ const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage let fullReasoningSoFar = '' let fullTextSoFar = '' + + let fullToolName = '' + let fullToolParams = '' + const toolCallOfIndex: ToolCallOfIndex = {} openai.chat.completions .create(options) @@ -198,7 +212,10 @@ const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage if (!toolCallOfIndex[index]) toolCallOfIndex[index] = { name: '', paramsStr: '', id: '' } toolCallOfIndex[index].name += tool.function?.name ?? '' toolCallOfIndex[index].paramsStr += tool.function?.arguments ?? ''; - toolCallOfIndex[index].id = tool.id ?? '' + toolCallOfIndex[index].id += tool.id ?? '' + + fullToolName += tool.function?.name ?? '' + fullToolParams += tool.function?.arguments ?? '' } // message const newText = chunk.choices[0]?.delta?.content ?? '' @@ -212,7 +229,7 @@ const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage fullReasoningSoFar += newReasoning } - onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) + onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar, fullToolName, fullToolParams }) } // on final const toolCalls = toolCallsFrom_OpenAICompat(toolCallOfIndex) @@ -236,6 +253,13 @@ const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage } + +type OpenAIModel = { + id: string; + created: number; + object: 'model'; + owned_by: string; +} const _openaiCompatibleList = async ({ onSuccess: onSuccess_, onError: onError_, settingsOfProvider, providerName }: ListParams_Internal) => { const onSuccess = ({ models }: { models: OpenAIModel[] }) => { onSuccess_({ models }) @@ -268,15 +292,15 @@ const _openaiCompatibleList = async ({ onSuccess: onSuccess_, onError: onError_, // ------------ ANTHROPIC ------------ const toAnthropicTool = (toolInfo: InternalToolInfo) => { - const { name, description, params, required } = toolInfo + const { name, description, params } = toolInfo return { name: name, description: description, input_schema: { type: 'object', properties: params, - required: required, - } + required: Object.keys(params), + }, } satisfies Anthropic.Messages.Tool } @@ -294,31 +318,32 @@ const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalM supportsSystemMessage, supportsTools, maxOutputTokens, - supportsReasoning, + reasoningCapabilities, } = getModelCapabilities(providerName, modelName_) - const { - isReasoningEnabled, - reasoningBudget, - } = getModelSelectionState(providerName, modelName_, modelSelectionOptions) // user's modelName_ here - - const { messages, separateSystemMessageStr } = prepareMessages({ messages: messages_, aiInstructions, supportsSystemMessage, supportsTools, supportsAnthropicReasoningSignature: true }) const thisConfig = settingsOfProvider.anthropic - const anthropic = new Anthropic({ apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true }); + const { providerReasoningIOSettings } = getProviderCapabilities(providerName) + + // reasoning + const reasoningInfo = getSendableReasoningInfo(providerName, modelName_, modelSelectionOptions) // user's modelName_ here + const includeInPayload = providerReasoningIOSettings?.input?.includeInPayload?.(reasoningInfo) || {} + + // tools const tools = ((tools_?.length ?? 0) !== 0) ? tools_?.map(tool => toAnthropicTool(tool)) : undefined - - const toolsObj: Partial = tools ? { tools: tools, tool_choice: { type: 'auto', disable_parallel_tool_use: true } // one tool at a time } : {} + // anthropic-specific - max tokens + const maxTokens = reasoningInfo?.isReasoningEnabled && reasoningCapabilities ? reasoningCapabilities.reasoningMaxOutputTokens : maxOutputTokens - const enableThinking = supportsReasoning && isReasoningEnabled && reasoningBudget - const maxTokens = enableThinking ? supportsReasoning.reasoningMaxOutputTokens : maxOutputTokens - const thinkingObj: Partial = enableThinking ? { - thinking: { type: 'enabled', budget_tokens: reasoningBudget } // thinking enabled - } : {} + // instance + const { messages, separateSystemMessageStr } = prepareMessages({ messages: messages_, aiInstructions, supportsSystemMessage, supportsTools, supportsAnthropicReasoningSignature: true }) + const anthropic = new Anthropic({ + apiKey: thisConfig.apiKey, + dangerouslyAllowBrowser: true + }); const stream = anthropic.messages.stream({ system: separateSystemMessageStr, @@ -326,13 +351,16 @@ const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalM model: modelName, max_tokens: maxTokens ?? 4_096, // anthropic requires this ...toolsObj, - ...thinkingObj, + ...includeInPayload, }) // when receive text let fullText = '' let fullReasoning = '' + let fullToolName = '' + let fullToolParams = '' + // there are no events for tool_use, it comes in at the end stream.on('streamEvent', e => { // start block @@ -340,18 +368,22 @@ const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalM if (e.content_block.type === 'text') { if (fullText) fullText += '\n\n' // starting a 2nd text block fullText += e.content_block.text - onText({ fullText, fullReasoning }) + onText({ fullText, fullReasoning, fullToolName, fullToolParams }) } else if (e.content_block.type === 'thinking') { if (fullReasoning) fullReasoning += '\n\n' // starting a 2nd reasoning block fullReasoning += e.content_block.thinking - onText({ fullText, fullReasoning }) + onText({ fullText, fullReasoning, fullToolName, fullToolParams }) } else if (e.content_block.type === 'redacted_thinking') { console.log('delta', e.content_block.type) if (fullReasoning) fullReasoning += '\n\n' // starting a 2nd reasoning block fullReasoning += '[redacted_thinking]' - onText({ fullText, fullReasoning }) + onText({ fullText, fullReasoning, fullToolName, fullToolParams }) + } + else if (e.content_block.type === 'tool_use') { + fullToolName += e.content_block.name ?? '' // anthropic gives us the tool name in the start block + onText({ fullText, fullReasoning, fullToolName, fullToolParams }) } } @@ -359,11 +391,15 @@ const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalM else if (e.type === 'content_block_delta') { if (e.delta.type === 'text_delta') { fullText += e.delta.text - onText({ fullText, fullReasoning }) + onText({ fullText, fullReasoning, fullToolName, fullToolParams }) } else if (e.delta.type === 'thinking_delta') { fullReasoning += e.delta.thinking - onText({ fullText, fullReasoning }) + onText({ fullText, fullReasoning, fullToolName, fullToolParams }) + } + else if (e.delta.type === 'input_json_delta') { // tool use + fullToolParams += e.delta.partial_json ?? '' // anthropic gives us the partial delta (string) here - https://docs.anthropic.com/en/api/messages-streaming + onText({ fullText, fullReasoning, fullToolName, fullToolParams }) } } }) diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts index 1e17d97b..87938d62 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts @@ -17,7 +17,7 @@ export const sendLLMMessage = ({ onFinalMessage: onFinalMessage_, onError: onError_, abortRef: abortRef_, - logging: { loggingName }, + logging: { loggingName, loggingExtras }, settingsOfProvider, modelSelection, modelSelectionOptions, @@ -48,6 +48,7 @@ export const sendLLMMessage = ({ suffixLength: messages_.suffix.length, } : {}, + ...loggingExtras, ...extras, }) } @@ -84,6 +85,7 @@ export const sendLLMMessage = ({ onError_({ message: errorMessage, fullError }) } + // we should NEVER call onAbort internally, only from the outside const onAbort = () => { captureLLMEvent(`${loggingName} - Abort`, { messageLengthSoFar: _fullTextSoFar.length }) try { _aborter?.() } // aborter sometimes automatically throws an error @@ -93,9 +95,9 @@ export const sendLLMMessage = ({ abortRef_.current = onAbort if (messagesType === 'chatMessages') - captureLLMEvent(`${loggingName} - Sending Message`, { messageLength: messages_[messages_.length - 1]?.content.length }) + captureLLMEvent(`${loggingName} - Sending Message`, { messageLength: messages_?.[messages_.length - 1]?.content.length }) else if (messagesType === 'FIMMessage') - captureLLMEvent(`${loggingName} - Sending FIM`, {}) // TODO!!! add more metrics + captureLLMEvent(`${loggingName} - Sending FIM`, { prefixLen: messages_?.prefix?.length, suffixLen: messages_?.suffix?.length }) // TODO!!! add more metrics for FIM try { diff --git a/src/vs/workbench/contrib/void/electron-main/metricsMainService.ts b/src/vs/workbench/contrib/void/electron-main/metricsMainService.ts index 592f79c4..bfc0a8c9 100644 --- a/src/vs/workbench/contrib/void/electron-main/metricsMainService.ts +++ b/src/vs/workbench/contrib/void/electron-main/metricsMainService.ts @@ -96,7 +96,7 @@ export class MetricsMainService extends Disposable implements IMetricsService { // very important to await whenReady! await this._appStorage.whenReady - const { commit, version, quality } = this._productService + const { commit, version, voidVersion, quality } = this._productService const isDevMode = !this._envMainService.isBuilt // found in abstractUpdateService.ts @@ -104,6 +104,7 @@ export class MetricsMainService extends Disposable implements IMetricsService { this._initProperties = { commit, vscodeVersion: version, + voidVersion, os, quality, distinctId: this.distinctId, diff --git a/src/vs/workbench/contrib/void/electron-main/sendLLMMessageChannel.ts b/src/vs/workbench/contrib/void/electron-main/sendLLMMessageChannel.ts index 12b0d984..182ce580 100644 --- a/src/vs/workbench/contrib/void/electron-main/sendLLMMessageChannel.ts +++ b/src/vs/workbench/contrib/void/electron-main/sendLLMMessageChannel.ts @@ -106,6 +106,17 @@ export class LLMMessageChannel implements IServerChannel { sendLLMMessage(mainThreadParams, this.metricsService); } + private _callAbort(params: MainLLMMessageAbortParams) { + const { requestId } = params; + if (!(requestId in this.abortRefOfRequestId)) return + this.abortRefOfRequestId[requestId].current?.() + delete this.abortRefOfRequestId[requestId] + } + + + + + _callOllamaList = (params: MainModelListParams) => { const { requestId } = params const emitters = this.listEmitters.ollama @@ -132,11 +143,4 @@ export class LLMMessageChannel implements IServerChannel { - private _callAbort(params: MainLLMMessageAbortParams) { - const { requestId } = params; - if (!(requestId in this.abortRefOfRequestId)) return - this.abortRefOfRequestId[requestId].current?.() - delete this.abortRefOfRequestId[requestId] - } - } diff --git a/src/vs/workbench/electron-sandbox/parts/dialogs/dialogHandler.ts b/src/vs/workbench/electron-sandbox/parts/dialogs/dialogHandler.ts index 6c61a590..429862fc 100644 --- a/src/vs/workbench/electron-sandbox/parts/dialogs/dialogHandler.ts +++ b/src/vs/workbench/electron-sandbox/parts/dialogs/dialogHandler.ts @@ -71,6 +71,7 @@ export class NativeDialogHandler extends AbstractDialogHandler { async about(): Promise { let version = this.productService.version; + let voidVersion = this.productService.voidVersion; // Void added this if (this.productService.target) { version = `${version} (${this.productService.target} setup)`; } else if (this.productService.darwinUniversalAssetId) { @@ -81,7 +82,8 @@ export class NativeDialogHandler extends AbstractDialogHandler { const detailString = (useAgo: boolean): string => { return localize({ key: 'aboutDetail', comment: ['Electron, Chromium, Node.js and V8 are product names that need no translation'] }, - "Version: {0}\nCommit: {1}\nDate: {2}\nElectron: {3}\nElectronBuildId: {4}\nChromium: {5}\nNode.js: {6}\nV8: {7}\nOS: {8}", + "Void Version: {0}\nVSCode Version: {1}\nCommit: {2}\nDate: {3}\nElectron: {4}\nElectronBuildId: {5}\nChromium: {6}\nNode.js: {7}\nV8: {8}\nOS: {9}", + voidVersion || 'Unknown', version, this.productService.commit || 'Unknown', this.productService.date ? `${this.productService.date}${useAgo ? ' (' + fromNow(new Date(this.productService.date), true) + ')' : ''}` : 'Unknown', diff --git a/src/vs/workbench/services/themes/common/workbenchThemeService.ts b/src/vs/workbench/services/themes/common/workbenchThemeService.ts index 505dd21c..52ec138d 100644 --- a/src/vs/workbench/services/themes/common/workbenchThemeService.ts +++ b/src/vs/workbench/services/themes/common/workbenchThemeService.ts @@ -39,12 +39,12 @@ export enum ThemeSettings { } export enum ThemeSettingDefaults { - COLOR_THEME_DARK = 'Default Dark+', + COLOR_THEME_DARK = 'Default Dark+', // Void changed this from 'Default Dark Modern' COLOR_THEME_LIGHT = 'Default Light Modern', COLOR_THEME_HC_DARK = 'Default High Contrast', COLOR_THEME_HC_LIGHT = 'Default High Contrast Light', - COLOR_THEME_DARK_OLD = 'Default Dark Modern', + COLOR_THEME_DARK_OLD = 'Default Dark Modern', // Void changed this from 'Default Dark+' COLOR_THEME_LIGHT_OLD = 'Default Light+', FILE_ICON_THEME = 'vs-seti',