Merge pull request #310 from voideditor/model-selection

Tool use progress + UI
This commit is contained in:
Andrew Pareles 2025-03-07 03:08:22 -08:00 committed by GitHub
commit 5eb2bcfef6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 4154 additions and 2707 deletions

View file

@ -12,7 +12,7 @@ There are a few ways to contribute:
### Codebase Guide
We highly recommend reading [this](https://github.com/microsoft/vscode/wiki/Source-Code-Organization) article on VSCode's sourcecode organization.
We [highly recommend reading this](https://github.com/microsoft/vscode/wiki/Source-Code-Organization) article on VSCode's sourcecode organization too. Void's codebase is pretty simple when you know what a service is and what `browser/` and `common/` mean, and the article covers all the jargon.
<!-- ADD BLOG HERE
We wrote a [guide to working in VSCode].
@ -61,13 +61,14 @@ To build Void, open `void/` inside VSCode. Then open your terminal and run:
3. Build Void.
- Press <kbd>Cmd+Shift+B</kbd> (Mac).
- Press <kbd>Ctrl+Shift+B</kbd> (Windows/Linux).
- This step can take ~5 min. The build is done when you see two check marks.
- This step can take ~5 min. The build is done when you see two check marks (one of the items will continue spinning indefinitely - it compiles our React code).
4. Run Void.
- Run `./scripts/code.sh` (Mac/Linux).
- Run `./scripts/code.bat` (Windows).
6. Nice-to-knows.
- You can always press <kbd>Ctrl+R</kbd> (<kbd>Cmd+R</kbd>) inside the new window to reload and see your new changes. It's faster than <kbd>Ctrl+Shift+P</kbd> and `Reload Window`.
- You might want to add the flags `--user-data-dir ./.tmp/user-data --extensions-dir ./.tmp/extensions` to the above run command, which lets you delete the `.tmp` folder to reset any IDE changes you made when testing.
- You can kill any of the build scripts by pressing `Ctrl+D` in VSCode terminal. If you press `Ctrl+C` the script will close but will keep running in the background (to open all background scripts, just re-build).
#### Building Void from Terminal

53
package-lock.json generated
View file

@ -75,6 +75,7 @@
"devDependencies": {
"@playwright/test": "^1.50.0",
"@stylistic/eslint-plugin-ts": "^2.8.0",
"@tailwindcss/typography": "^0.5.16",
"@types/cookie": "^0.3.3",
"@types/debug": "^4.1.5",
"@types/diff": "^7.0.1",
@ -168,7 +169,7 @@
"pump": "^1.0.1",
"rcedit": "^1.1.0",
"rimraf": "^2.7.1",
"scope-tailwind": "^1.0.6",
"scope-tailwind": "^1.0.9",
"sinon": "^12.0.1",
"sinon-test": "^3.1.3",
"source-map": "0.6.1",
@ -3598,6 +3599,36 @@
"node": ">=10"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
"integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"lodash.castarray": "^4.4.0",
"lodash.isplainobject": "^4.0.6",
"lodash.merge": "^4.6.2",
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@thisismanta/pessimist": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@thisismanta/pessimist/-/pessimist-1.2.0.tgz",
@ -14039,6 +14070,13 @@
"integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY= sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
"dev": true
},
"node_modules/lodash.castarray": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
"integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.clone": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clone/-/lodash.clone-4.5.0.tgz",
@ -14057,6 +14095,13 @@
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
"dev": true
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@ -18820,9 +18865,9 @@
"dev": true
},
"node_modules/scope-tailwind": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/scope-tailwind/-/scope-tailwind-1.0.6.tgz",
"integrity": "sha512-tkISLsaesYKKXL9YrLsRWFOD/FhrRVGKeinjgTuFtEidryLzwlBB3G17ArmHWHYcfdMp00XwnRMcGFkF8wwG6w==",
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/scope-tailwind/-/scope-tailwind-1.0.9.tgz",
"integrity": "sha512-sxtAKxJq143lYK/RCE36YGq13ficBZ9/9Z0TZa78k0AEiKNT5nH4kfhD8YAfEXR/qPR+G7tl9KL4UoHh+Cs93g==",
"dev": true,
"license": "AGPL-3.0-only",
"dependencies": {

View file

@ -136,6 +136,7 @@
"devDependencies": {
"@playwright/test": "^1.50.0",
"@stylistic/eslint-plugin-ts": "^2.8.0",
"@tailwindcss/typography": "^0.5.16",
"@types/cookie": "^0.3.3",
"@types/debug": "^4.1.5",
"@types/diff": "^7.0.1",
@ -229,7 +230,7 @@
"pump": "^1.0.1",
"rcedit": "^1.1.0",
"rimraf": "^2.7.1",
"scope-tailwind": "^1.0.6",
"scope-tailwind": "^1.0.9",
"sinon": "^12.0.1",
"sinon-test": "^3.1.3",
"source-map": "0.6.1",

View file

@ -4,23 +4,20 @@
*--------------------------------------------------------------------------------------------*/
import { coalesce, isNonEmptyArray } from '../../../../base/common/arrays.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { toErrorMessage } from '../../../../base/common/errorMessage.js';
import { Event } from '../../../../base/common/event.js';
import { MarkdownString } from '../../../../base/common/htmlContent.js';
import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js';
import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js';
import * as strings from '../../../../base/common/strings.js';
import { localize, localize2 } from '../../../../nls.js';
import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
import { localize } from '../../../../nls.js';
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
import { ExtensionIdentifier, IExtensionManifest } from '../../../../platform/extensions/common/extensions.js';
import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { IProductService } from '../../../../platform/product/common/productService.js';
import { Registry } from '../../../../platform/registry/common/platform.js';
import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js';
import { IWorkbenchContribution } from '../../../common/contributions.js';
import { IViewContainersRegistry, IViewDescriptor, IViewsRegistry, ViewContainer, ViewContainerLocation, Extensions as ViewExtensions } from '../../../common/views.js';
import { IViewsRegistry, Extensions as ViewExtensions } from '../../../common/views.js';
import { IExtensionFeatureTableRenderer, IRenderedData, ITableData, IRowData, IExtensionFeaturesRegistry, Extensions } from '../../../services/extensionManagement/common/extensionFeatures.js';
import { isProposedApiEnabled } from '../../../services/extensions/common/extensions.js';
import * as extensionsRegistry from '../../../services/extensions/common/extensionsRegistry.js';
@ -30,90 +27,91 @@ import { ChatAgentLocation, IChatAgentData, IChatAgentService } from '../common/
import { ChatContextKeys } from '../common/chatContextKeys.js';
import { IRawChatParticipantContribution } from '../common/chatParticipantContribTypes.js';
import { ChatViewId } from './chat.js';
import { CHAT_EDITING_SIDEBAR_PANEL_ID, CHAT_SIDEBAR_PANEL_ID, ChatViewPane } from './chatViewPane.js';
// --- Chat Container & View Registration
const chatViewContainer: ViewContainer = Registry.as<IViewContainersRegistry>(ViewExtensions.ViewContainersRegistry).registerViewContainer({
id: CHAT_SIDEBAR_PANEL_ID,
title: localize2('chat.viewContainer.label', "Chat"),
icon: Codicon.commentDiscussion,
ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [CHAT_SIDEBAR_PANEL_ID, { mergeViewWithContainerWhenSingleView: true }]),
storageId: CHAT_SIDEBAR_PANEL_ID,
hideIfEmpty: true,
order: 100,
}, ViewContainerLocation.AuxiliaryBar, { isDefault: true, doNotRegisterOpenCommand: true });
// Void commented this out
// const chatViewContainer: ViewContainer = Registry.as<IViewContainersRegistry>(ViewExtensions.ViewContainersRegistry).registerViewContainer({
// id: CHAT_SIDEBAR_PANEL_ID,
// title: localize2('chat.viewContainer.label', "Chat"),
// icon: Codicon.commentDiscussion,
// ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [CHAT_SIDEBAR_PANEL_ID, { mergeViewWithContainerWhenSingleView: true }]),
// storageId: CHAT_SIDEBAR_PANEL_ID,
// hideIfEmpty: true,
// order: 100,
// }, ViewContainerLocation.AuxiliaryBar, { isDefault: true, doNotRegisterOpenCommand: true });
const chatViewDescriptor: IViewDescriptor[] = [{
id: ChatViewId,
containerIcon: chatViewContainer.icon,
containerTitle: chatViewContainer.title.value,
singleViewPaneContainerTitle: chatViewContainer.title.value,
name: localize2('chat.viewContainer.label', "Chat"),
canToggleVisibility: false,
canMoveView: true,
openCommandActionDescriptor: {
id: CHAT_SIDEBAR_PANEL_ID,
title: chatViewContainer.title,
mnemonicTitle: localize({ key: 'miToggleChat', comment: ['&& denotes a mnemonic'] }, "&&Chat"),
keybindings: {
primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyI,
mac: {
primary: KeyMod.CtrlCmd | KeyMod.WinCtrl | KeyCode.KeyI
}
},
order: 1
},
ctorDescriptor: new SyncDescriptor(ChatViewPane, [{ location: ChatAgentLocation.Panel }]),
when: ContextKeyExpr.or(
ChatContextKeys.Setup.hidden.negate(),
ChatContextKeys.Setup.installed,
ChatContextKeys.panelParticipantRegistered,
ChatContextKeys.extensionInvalid
)
}];
Registry.as<IViewsRegistry>(ViewExtensions.ViewsRegistry).registerViews(chatViewDescriptor, chatViewContainer);
// const chatViewDescriptor: IViewDescriptor[] = [{
// id: ChatViewId,
// containerIcon: chatViewContainer.icon,
// containerTitle: chatViewContainer.title.value,
// singleViewPaneContainerTitle: chatViewContainer.title.value,
// name: localize2('chat.viewContainer.label', "Chat"),
// canToggleVisibility: false,
// canMoveView: true,
// openCommandActionDescriptor: {
// id: CHAT_SIDEBAR_PANEL_ID,
// title: chatViewContainer.title,
// mnemonicTitle: localize({ key: 'miToggleChat', comment: ['&& denotes a mnemonic'] }, "&&Chat"),
// keybindings: {
// primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyI,
// mac: {
// primary: KeyMod.CtrlCmd | KeyMod.WinCtrl | KeyCode.KeyI
// }
// },
// order: 1
// },
// ctorDescriptor: new SyncDescriptor(ChatViewPane, [{ location: ChatAgentLocation.Panel }]),
// when: ContextKeyExpr.or(
// ChatContextKeys.Setup.hidden.negate(),
// ChatContextKeys.Setup.installed,
// ChatContextKeys.panelParticipantRegistered,
// ChatContextKeys.extensionInvalid
// )
// }];
// Registry.as<IViewsRegistry>(ViewExtensions.ViewsRegistry).registerViews(chatViewDescriptor, chatViewContainer);
// --- Edits Container & View Registration
const editsViewContainer: ViewContainer = Registry.as<IViewContainersRegistry>(ViewExtensions.ViewContainersRegistry).registerViewContainer({
id: CHAT_EDITING_SIDEBAR_PANEL_ID,
title: localize2('chatEditing.viewContainer.label', "Copilot Edits"),
icon: Codicon.editSession,
ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [CHAT_EDITING_SIDEBAR_PANEL_ID, { mergeViewWithContainerWhenSingleView: true }]),
storageId: CHAT_EDITING_SIDEBAR_PANEL_ID,
hideIfEmpty: true,
order: 101,
}, ViewContainerLocation.AuxiliaryBar, { doNotRegisterOpenCommand: true });
// Void commented this out
// const editsViewContainer: ViewContainer = Registry.as<IViewContainersRegistry>(ViewExtensions.ViewContainersRegistry).registerViewContainer({
// id: CHAT_EDITING_SIDEBAR_PANEL_ID,
// title: localize2('chatEditing.viewContainer.label', "Copilot Edits"),
// icon: Codicon.editSession,
// ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [CHAT_EDITING_SIDEBAR_PANEL_ID, { mergeViewWithContainerWhenSingleView: true }]),
// storageId: CHAT_EDITING_SIDEBAR_PANEL_ID,
// hideIfEmpty: true,
// order: 101,
// }, ViewContainerLocation.AuxiliaryBar, { doNotRegisterOpenCommand: true });
const editsViewDescriptor: IViewDescriptor[] = [{
id: 'workbench.panel.chat.view.edits',
containerIcon: editsViewContainer.icon,
containerTitle: editsViewContainer.title.value,
singleViewPaneContainerTitle: editsViewContainer.title.value,
name: editsViewContainer.title,
canToggleVisibility: false,
canMoveView: true,
openCommandActionDescriptor: {
id: CHAT_EDITING_SIDEBAR_PANEL_ID,
title: editsViewContainer.title,
mnemonicTitle: localize({ key: 'miToggleEdits', comment: ['&& denotes a mnemonic'] }, "Copilot Ed&&its"),
keybindings: {
primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyI,
linux: {
primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyMod.Shift | KeyCode.KeyI
}
},
order: 2
},
ctorDescriptor: new SyncDescriptor(ChatViewPane, [{ location: ChatAgentLocation.EditingSession }]),
when: ContextKeyExpr.or(
ChatContextKeys.Setup.hidden.negate(),
ChatContextKeys.Setup.installed,
ChatContextKeys.editingParticipantRegistered
)
}];
Registry.as<IViewsRegistry>(ViewExtensions.ViewsRegistry).registerViews(editsViewDescriptor, editsViewContainer);
// const editsViewDescriptor: IViewDescriptor[] = [{
// id: 'workbench.panel.chat.view.edits',
// containerIcon: editsViewContainer.icon,
// containerTitle: editsViewContainer.title.value,
// singleViewPaneContainerTitle: editsViewContainer.title.value,
// name: editsViewContainer.title,
// canToggleVisibility: false,
// canMoveView: true,
// openCommandActionDescriptor: {
// id: CHAT_EDITING_SIDEBAR_PANEL_ID,
// title: editsViewContainer.title,
// mnemonicTitle: localize({ key: 'miToggleEdits', comment: ['&& denotes a mnemonic'] }, "Copilot Ed&&its"),
// keybindings: {
// primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyI,
// linux: {
// primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyMod.Shift | KeyCode.KeyI
// }
// },
// order: 2
// },
// ctorDescriptor: new SyncDescriptor(ChatViewPane, [{ location: ChatAgentLocation.EditingSession }]),
// when: ContextKeyExpr.or(
// ChatContextKeys.Setup.hidden.negate(),
// ChatContextKeys.Setup.installed,
// ChatContextKeys.editingParticipantRegistered
// )
// }];
// Registry.as<IViewsRegistry>(ViewExtensions.ViewsRegistry).registerViews(editsViewDescriptor, editsViewContainer);
const chatParticipantExtensionPoint = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint<IRawChatParticipantContribution[]>({
extensionPoint: 'chatParticipants',

View file

@ -18,9 +18,14 @@ import { IModelService } from '../../../../editor/common/services/model.js';
import { extractCodeFromRegular } from './helpers/extractCodeFromResult.js';
import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js';
import { ILLMMessageService } from '../common/llmMessageService.js';
import { _ln, allLinebreakSymbols } from '../common/voidFileService.js';
import { isWindows } from '../../../../base/common/platform.js';
// import { IContextGatheringService } from './contextGatheringService.js';
const allLinebreakSymbols = ['\r\n', '\n']
const _ln = isWindows ? allLinebreakSymbols[0] : allLinebreakSymbols[1]
// The extension this was called from is here - https://github.com/voideditor/void/blob/autocomplete/extensions/void/src/extension/extension.ts

View file

@ -11,13 +11,15 @@ 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 './llmMessageService.js';
import { chat_userMessageContent, chat_systemMessage, chat_userMessageContentWithAllFilesToo, chat_selectionsString } from '../browser/prompt/prompts.js';
import { InternalToolInfo, IToolsService, ToolCallReturnType, ToolFns, ToolName, voidTools } from './toolsService.js';
import { toLLMChatMessage } from './llmMessageTypes.js';
import { ILLMMessageService } from '../common/llmMessageService.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 { LLMChatMessage, toLLMChatMessage, ToolCallType } from '../common/llmMessageTypes.js';
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
import { IVoidFileService } from './voidFileService.js';
import { IVoidFileService } from '../common/voidFileService.js';
import { generateUuid } from '../../../../base/common/uuid.js';
import { getErrorMessage } from '../../../../base/common/errors.js';
import { ChatMode } from '../common/voidSettingsTypes.js';
const findLastIndex = <T>(arr: T[], condition: (t: T) => boolean): number => {
@ -57,12 +59,17 @@ export type StagingSelectionItem = CodeSelection | FileSelection
export type ToolMessage<T extends ToolName> = {
role: 'tool';
name: T; // internal use
params: string; // internal use
paramsStr: string; // internal use
id: string; // apis require this tool use id
content: string; // result
result: ToolCallReturnType[T]; // text message of result
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<T extends ToolName> = {
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 =
@ -81,6 +88,7 @@ export type ChatMessage =
reasoning: string | null; // reasoning from the LLM, used for step-by-step thinking
}
| ToolMessage<ToolName>
| ToolRequestApproval<ToolName>
type UserMessageType = ChatMessage & { role: 'user' }
type UserMessageState = UserMessageType['state']
@ -143,7 +151,6 @@ const newThreadObject = () => {
export const THREAD_STORAGE_KEY = 'void.chatThreadStorage'
type ChatMode = 'agent' | 'chat'
export interface IChatThreadService {
readonly _serviceBrand: undefined;
@ -169,6 +176,9 @@ export interface IChatThreadService {
getCurrentThreadState: () => ThreadType['state']
setCurrentThreadState: (newState: Partial<ThreadType['state']>) => void
closeStagingSelectionsInCurrentThread(): void;
closeStagingSelectionsInMessage(messageIdx: number): void;
// call to edit a message
editUserMessageAndStreamResponse({ userMessage, chatMode, messageIdx }: { userMessage: string, chatMode: ChatMode, messageIdx: number }): Promise<void>;
@ -179,6 +189,8 @@ export interface IChatThreadService {
cancelStreaming(threadId: string): void;
dismissStreamError(threadId: string): void;
approveTool(toolId: string): void;
rejectTool(toolId: string): void;
}
export const IChatThreadService = createDecorator<IChatThreadService>('voidChatThreadService');
@ -317,6 +329,18 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
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]
}
async addUserMessageAndStreamResponse({ userMessage, chatMode, chatSelections }: { userMessage: string, chatMode: ChatMode, chatSelections?: { prevSelns?: StagingSelectionItem[], currSelns?: StagingSelectionItem[] } }) {
@ -329,10 +353,8 @@ class ChatThreadService extends Disposable implements IChatThreadService {
// add user's message to chat history
const instructions = userMessage
const userMessageContent = await chat_userMessageContent(instructions, currSelns)
const selectionsStr = await chat_selectionsString(prevSelns, currSelns, this._voidFileService)
const userMessageFullContent = chat_userMessageContentWithAllFilesToo(userMessageContent, selectionsStr)
const userMessageContent = await chat_userMessageContent(instructions, currSelns) // user message + names of files (NOT content)
const userHistoryElt: ChatMessage = { role: 'user', content: userMessageContent, displayContent: instructions, selections: currSelns, state: defaultMessageState }
this._addMessageToThread(threadId, userHistoryElt)
@ -351,31 +373,34 @@ class ChatThreadService extends Disposable implements IChatThreadService {
let nMessagesSent = 0
while (shouldSendAnotherMessage) {
shouldSendAnotherMessage = false
// 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
let res_: () => void // resolves when user approves this tool use (or if tool doesn't require approval)
const awaitable = new Promise<void>((res, rej) => { res_ = res })
// replace last userMessage with userMessageFullContent (which contains all the files too)
const messages_ = this.getCurrentThread().messages.map(m => (toLLMChatMessage(m)))
const messages_ = this.getCurrentThread().messages.map(m => (toLLMChatMessage(m))).filter(m => !!m)
const lastUserMsgIdx = findLastIndex(messages_, m => m.role === 'user')
let messages = messages_
if (lastUserMsgIdx !== -1) { // should never be -1
messages = [
...messages.slice(0, lastUserMsgIdx),
{ role: 'user', content: userMessageFullContent },
...messages.slice(lastUserMsgIdx + 1, Infinity)]
}
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',
useProviderFor: 'Ctrl+L',
logging: { loggingName: `Agent` },
messages: [
{ role: 'system', content: chat_systemMessage(this._workspaceContextService.getWorkspace().folders.map(f => f.uri.fsPath)) },
...messages,
],
messages,
tools: tools,
@ -390,37 +415,93 @@ class ChatThreadService extends Disposable implements IChatThreadService {
else {
this._addMessageToThread(threadId, { role: 'assistant', content: fullText, reasoning: fullReasoning || null })
this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined }) // clear streaming message
for (const tool of toolCalls ?? []) {
const toolName = tool.name as ToolName
// 1.
let toolResult: Awaited<ReturnType<ToolFns[ToolName]>>
let toolResultVal: ToolCallReturnType[ToolName]
try {
toolResult = await this._toolsService.toolFns[toolName](tool.params)
toolResultVal = toolResult
} catch (error) {
this._setStreamState(threadId, { error })
shouldSendAnotherMessage = false
break
}
// deal with the tool
const tool: ToolCallType | undefined = toolCalls?.[0]
if (!tool) {
res_()
return
}
const toolName = tool.name
shouldSendAnotherMessage = true
// 2.
let toolResultStr: string
try {
toolResultStr = this._toolsService.toolResultToString[toolName](toolResult 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) {
this._setStreamState(threadId, { error })
shouldSendAnotherMessage = false
break
}
// 1. validate tool params
let toolParams: ToolCallParams[typeof toolName]
try {
console.log('A')
this._addMessageToThread(threadId, { role: 'tool', name: toolName, params: tool.params, id: tool.id, content: toolResultStr, result: toolResultVal, })
shouldSendAnotherMessage = true
const params = await this._toolsService.validateParams[toolName](tool.paramsStr)
console.log('B')
toolParams = params
} catch (error) {
console.log('ERR1')
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)) {
console.log('C')
const voidToolId = generateUuid()
console.log('D')
const toolApprovalPromise = new Promise<void>((res, rej) => { this.resRejOfToolAwaitingApproval[voidToolId] = { res, rej } })
console.log('E')
this._addMessageToThread(threadId, { role: 'tool_request', name: toolName, params: toolParams, voidToolId: voidToolId })
try {
console.log('F')
await toolApprovalPromise
// accepted tool
}
catch (e) {
console.log('ERR2')
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 {
console.log('G')
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) {
console.log('ERR3')
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 {
console.log('H')
toolResultStr = this._toolsService.stringOfResult[toolName](toolParams as any, toolResult as any)
// if (Math.random() > 0) throw new Error('This is not an allowed repo.')
} 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
}
console.log('I')
// 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_()
}
res_()
},
onError: (error) => {
const messageSoFar = this.streamState[threadId]?.messageSoFar ?? ''
@ -432,11 +513,13 @@ class ChatThreadService extends Disposable implements IChatThreadService {
if (llmCancelToken === null) break
this._setStreamState(threadId, { streamingToken: llmCancelToken })
console.log('awaiting agentloop')
await awaitable
console.log('done')
}
}
agentLoop() // DO NOT AWAIT THIS, this fn should resolve when ready to clear inputs
agentLoop() // DO NOT AWAIT THIS, add fn should resolve when we've added message (this lets us interrupt the agent loop correctly instead of waiting for it to resolve)
}
@ -596,6 +679,35 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
closeStagingSelectionsInCurrentThread = () => {
const currThread = this.getCurrentThreadState()
// close all stagingSelections
const closedStagingSelections = currThread.stagingSelections.map(s => ({ ...s, state: { ...s.state, isOpened: false } }))
const newThread = currThread
newThread.stagingSelections = closedStagingSelections
this.setCurrentThreadState(newThread)
}
closeStagingSelectionsInMessage = (messageIdx: number) => {
const currMessage = this.getCurrentMessageState(messageIdx)
// close all stagingSelections
const closedStagingSelections = currMessage.stagingSelections.map(s => ({ ...s, state: { ...s.state, isOpened: false } }))
const newMessage = currMessage
newMessage.stagingSelections = closedStagingSelections
this.setCurrentMessageState(messageIdx, newMessage)
}
getCurrentThreadState = () => {
const currentThread = this.getCurrentThread()
return currentThread.state

View file

@ -5,7 +5,7 @@
import { Disposable } from '../../../../base/common/lifecycle.js';
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { ICodeEditor, IOverlayWidget, IViewZone, OverlayWidgetPositionPreference } from '../../../../editor/browser/editorBrowser.js';
// import { IUndoRedoService } from '../../../../platform/undoRedo/common/undoRedo.js';
@ -31,17 +31,18 @@ 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 './helpers/detectLanguage.js';
import { filenameToVscodeLanguage } from '../common/helpers/detectLanguage.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';
import { Emitter, Event } from '../../../../base/common/event.js';
import { Emitter } from '../../../../base/common/event.js';
import { VOID_OPEN_SETTINGS_ACTION_ID } from './voidSettingsPane.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { ILLMMessageService } from '../common/llmMessageService.js';
import { LLMChatMessage, OnError, errorDetails } from '../common/llmMessageTypes.js';
import { IMetricsService } from '../common/metricsService.js';
import { IVoidFileService } from '../common/voidFileService.js';
import { IEditCodeService, URIStreamState, AddCtrlKOpts, StartApplyingOpts } from './editCodeServiceInterface.js';
const configOfBG = (color: Color) => {
return { dark: color, light: color, hcDark: color, hcLight: color, }
@ -121,27 +122,8 @@ const findTextInCode = (text: string, fileContents: string, startingAtLine?: num
}
export type URIStreamState = 'idle' | 'acceptRejectAll' | 'streaming'
export type StartApplyingOpts = {
from: 'QuickEdit';
type: 'rewrite';
diffareaid: number; // id of the CtrlK area (contains text selection)
} | {
from: 'ClickApply';
type: 'searchReplace' | 'rewrite';
applyStr: string;
}
export type AddCtrlKOpts = {
startLine: number,
endLine: number,
editor: ICodeEditor,
}
// // TODO diffArea should be removed if we just discovered it has no more diffs in it
// for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) {
// const diffArea = this.diffAreaOfId[diffareaid]
@ -247,28 +229,6 @@ type HistorySnapshot = {
type StreamLocationMutable = { line: number, col: number, addedSplitYet: boolean, originalCodeStartLine: number }
export interface IEditCodeService {
readonly _serviceBrand: undefined;
startApplying(opts: StartApplyingOpts): URI | null;
addCtrlKZone(opts: AddCtrlKOpts): number | undefined;
removeCtrlKZone(opts: { diffareaid: number }): void;
removeDiffAreas(opts: { uri: URI, removeCtrlKs: boolean, behavior: 'reject' | 'accept' }): void;
// 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;
}
export const IEditCodeService = createDecorator<IEditCodeService>('editCodeService');
class EditCodeService extends Disposable implements IEditCodeService {
_serviceBrand: undefined;
@ -813,7 +773,7 @@ class EditCodeService extends Disposable implements IEditCodeService {
private _addToHistory(uri: URI) {
private _addToHistory(uri: URI, opts?: { onUndo?: () => void }) {
const getCurrentSnapshot = (): HistorySnapshot => {
const snapshottedDiffAreaOfId: Record<string, DiffAreaSnapshot> = {}
@ -895,7 +855,7 @@ class EditCodeService extends Disposable implements IEditCodeService {
resource: uri,
label: 'Void Changes',
code: 'undoredo.editCode',
undo: () => { restoreDiffAreas(beforeSnapshot) },
undo: () => { restoreDiffAreas(beforeSnapshot); opts?.onUndo?.() },
redo: () => { if (afterSnapshot) restoreDiffAreas(afterSnapshot) }
}
this._undoRedoService.pushElement(elt)
@ -1226,14 +1186,20 @@ class EditCodeService extends Disposable implements IEditCodeService {
public startApplying(opts: StartApplyingOpts) {
// throws if there's an error
public startApplying(opts: StartApplyingOpts): [URI, Promise<void>] | null {
if (opts.type === 'rewrite') {
const addedDiffArea = this._initializeWriteoverStream(opts)
return addedDiffArea?._URI ?? null
const added = this._initializeWriteoverStream(opts)
if (!added) return null
const [diffZone, promise] = added
return [diffZone._URI, promise]
}
else if (opts.type === 'searchReplace') {
const addedDiffArea = this._initializeSearchAndReplaceStream(opts)
return addedDiffArea?._URI ?? null
const added = this._initializeSearchAndReplaceStream(opts)
if (!added) return null
if (!added) return null
const [diffZone, promise] = added
return [diffZone._URI, promise]
}
return null
}
@ -1260,9 +1226,9 @@ class EditCodeService extends Disposable implements IEditCodeService {
private _initializeWriteoverStream(opts: StartApplyingOpts): DiffZone | undefined {
private _initializeWriteoverStream(opts: StartApplyingOpts): [DiffZone, Promise<void>] | undefined {
const { from } = opts
const { from, } = opts
let startLine: number
let endLine: number
@ -1306,8 +1272,16 @@ class EditCodeService extends Disposable implements IEditCodeService {
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<void>((res_, rej_) => { resApplyPromise = res_; rejApplyPromise = rej_ })
// add to history
const { onFinishEdit } = this._addToHistory(uri)
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
@ -1403,56 +1377,75 @@ class EditCodeService extends Disposable implements IEditCodeService {
let fullTextSoFar = '' // so far (INCLUDING ignored suffix)
let prevIgnoredSuffix = ''
streamRequestIdRef.current = this._llmMessageService.sendLLMMessage({
messagesType: 'chatMessages',
useProviderFor: opts.from === 'ClickApply' ? 'Apply' : 'Ctrl+K',
logging: { loggingName: `startApplying - ${from}` },
messages,
onText: ({ fullText: fullText_ }) => {
const newText_ = fullText_.substring(fullTextSoFar.length, Infinity)
const writeover = async () => {
const newText = prevIgnoredSuffix + newText_ // add the previously ignored suffix because it's no longer the suffix!
fullTextSoFar += newText // full text, including ```, etc
let resMessageDonePromise: () => void = () => { }
const messageDonePromise = new Promise<void>((res_) => { resMessageDonePromise = res_ })
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',
useProviderFor: opts.from === 'ClickApply' ? 'Apply' : 'Ctrl+K',
logging: { loggingName: `Edit (Writeover) - ${from}` },
messages,
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: ({ fullText }) => {
// 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()
},
onError: (e) => {
this._notifyError(e)
onDone()
this._undoHistory(uri)
},
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
})
this._refreshStylesAndDiffsInURI(uri)
return diffZone
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()
},
})
await messageDonePromise
}
writeover().then(() => resApplyPromise()).catch((e) => rejApplyPromise(e))
return [diffZone, applyPromise]
}
private _initializeSearchAndReplaceStream(opts: StartApplyingOpts & { from: 'ClickApply' }) {
const { applyStr } = opts
private _initializeSearchAndReplaceStream(opts: StartApplyingOpts & { from: 'ClickApply' }): [DiffZone, Promise<void>] | undefined {
const { from, applyStr, uri: givenURI, } = opts
let uri: URI
if (givenURI === 'current') {
const uri_ = this._getActiveEditorURI()
if (!uri_) return
uri = uri_
}
else {
uri = givenURI
}
const uri_ = this._getActiveEditorURI()
if (!uri_) return
const uri = uri_
// generate search/replace block text
const originalFileCode = this._voidFileService.readModel(uri)
@ -1476,16 +1469,24 @@ class EditCodeService extends Disposable implements IEditCodeService {
// can use this as a proxy to set the diffArea's stream state requestId
let streamRequestIdRef: { current: string | null } = { current: null }
let { onFinishEdit } = this._addToHistory(uri)
// TODO replace these with whatever block we're on initially if already started
// promise that resolves when the apply is done
let resApplyPromise: () => void
let rejApplyPromise: (e: any) => void
const applyPromise = new Promise<void>((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 replace these with whatever block we're on initially if already started (caching apply)
type SearchReplaceDiffAreaMetadata = {
originalBounds: [number, number], // 1-indexed
originalCode: string,
}
const addedTrackingZoneOfBlockNum: TrackingZone<SearchReplaceDiffAreaMetadata>[] = []
const adding: Omit<DiffZone, 'diffareaid'> = {
type: 'DiffZone',
@ -1506,12 +1507,6 @@ class EditCodeService extends Disposable implements IEditCodeService {
this._onDidAddOrDeleteDiffZones.fire({ uri })
const revertAndContinueHistory = () => {
this._undoHistory(uri)
const { onFinishEdit: onFinishEdit_ } = this._addToHistory(uri)
onFinishEdit = onFinishEdit_
}
const convertOriginalRangeToFinalRange = (originalRange: readonly [number, number]): [number, number] => {
// adjust based on the changes by computing line offset
@ -1531,12 +1526,12 @@ class EditCodeService extends Disposable implements IEditCodeService {
}
const errMsgOfInvalidStr = (str: string & ReturnType<typeof findTextInCode>) => {
return str === 'Not found' ?
'I interrupted you because the latest ORIGINAL code 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.'
: str === 'Not unique' ?
'I interrupted you because the latest ORIGINAL code 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.'
: ''
const errMsgOfInvalidStr = (str: string & ReturnType<typeof findTextInCode>, 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)}`
: ``
}
@ -1555,169 +1550,209 @@ class EditCodeService extends Disposable implements IEditCodeService {
// refresh now in case onText takes a while to get 1st message
this._refreshStylesAndDiffsInURI(uri)
// stateful
const addedTrackingZoneOfBlockNum: TrackingZone<SearchReplaceDiffAreaMetadata>[] = []
// stream style related
let latestStreamLocationMutable: StreamLocationMutable | null = null
let shouldUpdateOrigStreamStyle = true
let oldBlocks: ExtractedSearchReplaceBlock[] = []
// this generates >>>>>>> ORIGINAL <<<<<<< REPLACE blocks and and simultaneously applies it
let shouldSendAnotherMessage = true
let nMessagesSent = 0
let currStreamingBlockNum = 0
while (shouldSendAnotherMessage) {
shouldSendAnotherMessage = false
nMessagesSent += 1
const retryLoop = async () => {
// this generates >>>>>>> ORIGINAL <<<<<<< REPLACE blocks and and simultaneously applies it
let shouldSendAnotherMessage = true
let nMessagesSent = 0
let currStreamingBlockNum = 0
while (shouldSendAnotherMessage) {
shouldSendAnotherMessage = false
nMessagesSent += 1
streamRequestIdRef.current = this._llmMessageService.sendLLMMessage({
messagesType: 'chatMessages',
useProviderFor: 'Apply',
logging: { loggingName: `generateSearchAndReplace` },
messages,
onText: ({ fullText }) => {
// blocks are [done done done ... {writingFinal|writingOriginal}]
// ^
// currStreamingBlockNum
let resMessageDonePromise: () => void = () => { }
const messageDonePromise = new Promise<void>((res_) => { resMessageDonePromise = res_ })
const blocks = extractSearchReplaceBlocks(fullText)
streamRequestIdRef.current = this._llmMessageService.sendLLMMessage({
messagesType: 'chatMessages',
useProviderFor: 'Apply',
logging: { loggingName: `Edit (Search/Replace) - ${from}` },
messages,
onText: (params) => {
const { fullText } = params
// blocks are [done done done ... {writingFinal|writingOriginal}]
// ^
// currStreamingBlockNum
for (let blockNum = currStreamingBlockNum; blockNum < blocks.length; blockNum += 1) {
const block = blocks[blockNum]
const blocks = extractSearchReplaceBlocks(fullText)
if (block.state === 'writingOriginal') {
// update stream state to the first line of original if some portion of original has been written
if (shouldUpdateOrigStreamStyle && block.orig.trim().length >= 20) {
const startingAtLine = diffZone._streamState.line ?? 1 // dont go backwards if already have a stream line
const originalRange = findTextInCode(block.orig, originalFileCode, startingAtLine)
if (typeof originalRange !== 'string') {
const [startLine, _] = convertOriginalRangeToFinalRange(originalRange)
diffZone._streamState.line = startLine
shouldUpdateOrigStreamStyle = false
for (let blockNum = currStreamingBlockNum; blockNum < blocks.length; blockNum += 1) {
const block = blocks[blockNum]
if (block.state === 'writingOriginal') {
// update stream state to the first line of original if some portion of original has been written
if (shouldUpdateOrigStreamStyle && block.orig.trim().length >= 20) {
const startingAtLine = diffZone._streamState.line ?? 1 // dont go backwards if already have a stream line
const originalRange = findTextInCode(block.orig, originalFileCode, startingAtLine)
if (typeof originalRange !== 'string') {
const [startLine, _] = convertOriginalRangeToFinalRange(originalRange)
diffZone._streamState.line = startLine
shouldUpdateOrigStreamStyle = false
}
}
// must be done writing original to move on to writing streamed content
continue
}
// must be done writing original to move on to writing streamed content
continue
}
shouldUpdateOrigStreamStyle = true
shouldUpdateOrigStreamStyle = true
// if this is the first time we're seeing this block, add it as a diffarea so we can start streaming
if (!(blockNum in addedTrackingZoneOfBlockNum)) {
const originalBounds = findTextInCode(block.orig, originalFileCode)
// if this is the first time we're seeing this block, add it as a diffarea so we can start streaming
if (!(blockNum in addedTrackingZoneOfBlockNum)) {
console.log('finding text in code...', { orig: block.orig })
const originalBounds = findTextInCode(block.orig, originalFileCode)
// if error
if (typeof originalBounds === 'string') {
messages.push(
{ role: 'assistant', content: fullText }, // latest output
{ role: 'user', content: errMsgOfInvalidStr(originalBounds) } // user explanation of what's wrong
// if error
if (typeof originalBounds === 'string') {
const content = errMsgOfInvalidStr(originalBounds, block.orig)
messages.push(
{ role: 'assistant', content: fullText }, // latest output
{ role: 'user', content: content } // user explanation of what's wrong
)
if (streamRequestIdRef.current) this._llmMessageService.abort(streamRequestIdRef.current)
// 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
}
currStreamingBlockNum = 0
latestStreamLocationMutable = null
shouldUpdateOrigStreamStyle = true
oldBlocks = []
addedTrackingZoneOfBlockNum.splice(0, Infinity) // clear the array
shouldSendAnotherMessage = true
this._refreshStylesAndDiffsInURI(uri)
resMessageDonePromise()
return
}
const [startLine, endLine] = convertOriginalRangeToFinalRange(originalBounds)
// otherwise if no error, add the position as a diffarea
const adding: Omit<TrackingZone<SearchReplaceDiffAreaMetadata>, 'diffareaid'> = {
type: 'TrackingZone',
startLine: startLine,
endLine: endLine,
_URI: uri,
metadata: {
originalBounds: [...originalBounds],
originalCode: block.orig,
},
}
const trackingZone = this._addDiffArea(adding)
addedTrackingZoneOfBlockNum.push(trackingZone)
latestStreamLocationMutable = { line: startLine, addedSplitYet: false, col: 1, originalCodeStartLine: 1 }
} // <-- done adding diffarea
// should always be in streaming state here
if (!diffZone._streamState.isStreaming) {
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,
{ startLineNumber: finalStartLine, startColumn: 1, endLineNumber: finalEndLine, endColumn: Number.MAX_SAFE_INTEGER }, // 1-indexed
{ shouldRealignDiffAreas: true }
)
if (streamRequestIdRef.current) this._llmMessageService.abort(streamRequestIdRef.current)
shouldSendAnotherMessage = true
revertAndContinueHistory()
diffZone._streamState.line = finalEndLine + 1
currStreamingBlockNum = blockNum + 1
continue
}
const [startLine, endLine] = convertOriginalRangeToFinalRange(originalBounds)
// write the added text to the file
const deltaFinalText = block.final.substring((oldBlocks[blockNum]?.final ?? '').length, Infinity)
this._writeStreamedDiffZoneLLMText(uri, block.orig, block.final, deltaFinalText, latestStreamLocationMutable)
oldBlocks = blocks // oldblocks is only used if writingFinal
// otherwise if no error, add the position as a diffarea
const adding: Omit<TrackingZone<SearchReplaceDiffAreaMetadata>, 'diffareaid'> = {
type: 'TrackingZone',
startLine: startLine,
endLine: endLine,
_URI: uri,
metadata: {
originalBounds: [...originalBounds],
originalCode: block.orig,
},
}
const trackingZone = this._addDiffArea(adding)
addedTrackingZoneOfBlockNum.push(trackingZone)
latestStreamLocationMutable = { line: startLine, addedSplitYet: false, col: 1, originalCodeStartLine: 1 }
} // <-- done adding diffarea
// const { endLine: currentEndLine } = addedTrackingZoneOfBlockNum[blockNum] // would be bad to do this because a lot of the bottom lines might be the same. more accurate to go with latestStreamLocationMutable
// diffZone._streamState.line = currentEndLine
diffZone._streamState.line = latestStreamLocationMutable.line
// should always be in streaming state here
if (!diffZone._streamState.isStreaming) {
console.error('DiffZone was not in streaming state in _initializeSearchAndReplaceStream')
continue
} // end for
this._refreshStylesAndDiffsInURI(uri)
},
onFinalMessage: async (params) => {
const { fullText } = params
console.log('final message!!', fullText)
// 1. wait 500ms and fix lint errors - call lint error workflow
// (update react state to say "Fixing errors")
const blocks = extractSearchReplaceBlocks(fullText)
if (blocks.length === 0) {
this._notificationService.info(`Void: We ran Apply, but the LLM didn't output any changes.`)
}
if (!latestStreamLocationMutable) continue
// writeover the whole file
let newCode = originalFileCode
for (let blockNum = addedTrackingZoneOfBlockNum.length - 1; blockNum >= 0; blockNum -= 1) {
const { originalBounds } = addedTrackingZoneOfBlockNum[blockNum].metadata
const finalCode = blocks[blockNum].final
// 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,
{ startLineNumber: finalStartLine, startColumn: 1, endLineNumber: finalEndLine, endColumn: Number.MAX_SAFE_INTEGER }, // 1-indexed
if (finalCode === null) continue
const [originalStart, originalEnd] = originalBounds
const lines = newCode.split('\n')
newCode = [
...lines.slice(0, (originalStart - 1)),
...finalCode.split('\n'),
...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 }
)
diffZone._streamState.line = finalEndLine + 1
currStreamingBlockNum = blockNum + 1
continue
}
// write the added text to the file
const deltaFinalText = block.final.substring((oldBlocks[blockNum]?.final ?? '').length, Infinity)
this._writeStreamedDiffZoneLLMText(uri, block.orig, block.final, deltaFinalText, latestStreamLocationMutable)
oldBlocks = blocks // oldblocks is only used if writingFinal
onDone()
resMessageDonePromise()
},
onError: (e) => {
this._notifyError(e)
onDone()
this._undoHistory(uri)
resMessageDonePromise()
},
// const { endLine: currentEndLine } = addedTrackingZoneOfBlockNum[blockNum] // would be bad to do this because a lot of the bottom lines might be the same. more accurate to go with latestStreamLocationMutable
// diffZone._streamState.line = currentEndLine
diffZone._streamState.line = latestStreamLocationMutable.line
})
await messageDonePromise
} // end while
} // end for
} // end retryLoop
this._refreshStylesAndDiffsInURI(uri)
},
onFinalMessage: async ({ fullText }) => {
console.log('final message!!', fullText)
retryLoop().then(() => resApplyPromise()).catch((e) => rejApplyPromise(e))
// 1. wait 500ms and fix lint errors - call lint error workflow
// (update react state to say "Fixing errors")
const blocks = extractSearchReplaceBlocks(fullText)
if (blocks.length === 0) {
this._notificationService.info(`Void: We ran Apply, but the LLM didn't output any changes.`)
}
// writeover the whole file
let newCode = originalFileCode
for (let blockNum = addedTrackingZoneOfBlockNum.length - 1; blockNum >= 0; blockNum -= 1) {
const { originalBounds } = addedTrackingZoneOfBlockNum[blockNum].metadata
const finalCode = blocks[blockNum].final
if (finalCode === null) continue
const [originalStart, originalEnd] = originalBounds
const lines = newCode.split('\n')
newCode = [
...lines.slice(0, (originalStart - 1)),
...finalCode.split('\n'),
...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 }
)
}
onDone()
},
onError: (e) => {
this._notifyError(e)
onDone()
this._undoHistory(uri)
},
})
}
return diffZone
return [diffZone, applyPromise]
}

View file

@ -0,0 +1,57 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
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';
export type StartApplyingOpts = ({
from: 'QuickEdit';
type: 'rewrite';
diffareaid: number; // id of the CtrlK area (contains text selection)
} | {
from: 'ClickApply';
type: 'searchReplace' | 'rewrite';
applyStr: string;
uri: 'current' | URI;
})
export type AddCtrlKOpts = {
startLine: number,
endLine: number,
editor: ICodeEditor,
}
export type URIStreamState = 'idle' | 'acceptRejectAll' | 'streaming'
export const IEditCodeService = createDecorator<IEditCodeService>('editCodeService');
export interface IEditCodeService {
readonly _serviceBrand: undefined;
startApplying(opts: StartApplyingOpts): [URI, Promise<void>] | null;
addCtrlKZone(opts: AddCtrlKOpts): number | undefined;
removeCtrlKZone(opts: { diffareaid: number }): void;
removeDiffAreas(opts: { uri: URI, removeCtrlKs: boolean, behavior: 'reject' | 'accept' }): void;
// 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;
}

View file

@ -171,6 +171,9 @@ export type ExtractedSearchReplaceBlock = {
}
// JS substring swaps indices, so "ab".substr(1,0) will NOT be '', it will be 'a'!
const voidSubstr = (str: string, start: number, end: number) => end < start ? '' : str.substring(start, end)
const endsWithAnyPrefixOf = (str: string, anyPrefix: string) => {
// for each prefix
for (let i = anyPrefix.length; i >= 1; i--) { // i >= 1 because must not be empty string
@ -187,7 +190,6 @@ export const extractSearchReplaceBlocks = (str: string) => {
const DIVIDER_ = '\n' + DIVIDER + `\n`
// logic for FINAL_ is slightly more complicated - should be '\n' + FINAL, but that ignores if the final output is empty
const blocks: ExtractedSearchReplaceBlock[] = []
let i = 0 // search i and beyond (this is done by plain index, not by line number. much simpler this way)
@ -196,41 +198,42 @@ export const extractSearchReplaceBlocks = (str: string) => {
if (origStart === -1) { return blocks }
origStart += ORIGINAL_.length
i = origStart
// wrote <<<< ORIGINAL
// wrote <<<< ORIGINAL\n
let dividerStart = str.indexOf(DIVIDER_, i)
if (dividerStart === -1) { // if didnt find DIVIDER_, either writing originalStr or DIVIDER_ right now
const isWritingDIVIDER = endsWithAnyPrefixOf(str, DIVIDER_)
const writingDIVIDERlen = endsWithAnyPrefixOf(str, DIVIDER_)?.length ?? 0
blocks.push({
orig: str.substring(origStart, str.length - (isWritingDIVIDER?.length ?? 0)),
orig: voidSubstr(str, origStart, str.length - writingDIVIDERlen),
final: '',
state: 'writingOriginal'
})
return blocks
}
const origStrDone = str.substring(origStart, dividerStart)
const origStrDone = voidSubstr(str, origStart, dividerStart)
dividerStart += DIVIDER_.length
i = dividerStart
// wrote =====
// wrote \n=====\n
const fullFINALStart = str.indexOf(FINAL, i)
const fullFINALStart_ = str.indexOf('\n' + FINAL, i) // go with B if possible, else fallback to A, it's more permissive
const matchedFullFINAL_ = fullFINALStart_ !== -1 && fullFINALStart === fullFINALStart_ + 1 // this logic is really important, otherwise we might look for FINAL_ at a much later part of the string
const finalStartA = str.indexOf(FINAL, i)
const finalStartB = str.indexOf('\n' + FINAL, i) // go with B if possible, else fallback to A, it's more permissive
const FINAL_ = finalStartB !== -1 ? '\n' + FINAL : FINAL
let finalStart = finalStartB !== -1 ? finalStartB : finalStartA
if (finalStart === -1) { // if didnt find FINAL_, either writing finalStr or FINAL_ right now
const isWritingFINAL = endsWithAnyPrefixOf(str, FINAL_)
let finalStart = matchedFullFINAL_ ? fullFINALStart_ : fullFINALStart
if (finalStart === -1) { // if didnt find FINAL_, either writing finalStr or FINAL or FINAL_ right now
const writingFINALlen = endsWithAnyPrefixOf(str, FINAL)?.length ?? 0
const writingFINALlen_ = endsWithAnyPrefixOf(str, '\n' + FINAL)?.length ?? 0 // this gets priority
const usingWritingFINALlen = Math.max(writingFINALlen, writingFINALlen_)
blocks.push({
orig: origStrDone,
final: str.substring(dividerStart, str.length - (isWritingFINAL?.length ?? 0)),
final: voidSubstr(str, dividerStart, str.length - usingWritingFINALlen),
state: 'writingFinal'
})
return blocks
}
const finalStrDone = str.substring(dividerStart, finalStart)
finalStart += FINAL_.length
const usingFINAL = matchedFullFINAL_ ? '\n' + FINAL : FINAL
const finalStrDone = voidSubstr(str, dividerStart, finalStart)
finalStart += usingFINAL.length
i = finalStart
// wrote >>>>> FINAL
@ -247,9 +250,6 @@ export const extractSearchReplaceBlocks = (str: string) => {
// could simplify this - this assumes we can never add a tag without committing it to the user's screen, but that's not true
export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string, string]): OnText => {
let latestAddIdx = 0 // exclusive index in fullText_
@ -357,3 +357,220 @@ export const extractReasoningOnFinalMessage = (fullText_: string, thinkTags: [st
const fullText = fullText_.substring(0, tag1Idx) + fullText_.substring(tag2Idx + thinkTags[1].length, Infinity)
return { fullText, fullReasoning }
}
// const tests: [string, { shape: Partial<ExtractedSearchReplaceBlock>[] }][] = [[
// `\
// \`\`\`
// <<<<<<< ORIGINA`, { shape: [] }
// ], [
// `\
// \`\`\`
// <<<<<<< ORIGINAL`, { shape: [], }
// ], [
// `\
// \`\`\`
// <<<<<<< ORIGINAL
// A`, { shape: [{ state: 'writingOriginal', orig: 'A' }], }
// ], [
// `\
// \`\`\`
// <<<<<<< ORIGINAL
// A
// B`, { shape: [{ state: 'writingOriginal', orig: 'A\nB' }], }
// ], [
// `\
// \`\`\`
// <<<<<<< ORIGINAL
// A
// B
// `, { shape: [{ state: 'writingOriginal', orig: 'A\nB' }], }
// ], [
// `\
// \`\`\`
// <<<<<<< ORIGINAL
// A
// B
// ===`, { shape: [{ state: 'writingOriginal', orig: 'A\nB' }], }
// ], [
// `\
// \`\`\`
// <<<<<<< ORIGINAL
// A
// B
// ======`, { shape: [{ state: 'writingOriginal', orig: 'A\nB' }], }
// ], [
// `\
// \`\`\`
// <<<<<<< ORIGINAL
// A
// B
// =======`, { shape: [{ state: 'writingOriginal', orig: 'A\nB' }], }
// ], [
// `\
// \`\`\`
// <<<<<<< ORIGINAL
// A
// B
// =======
// `, { shape: [{ state: 'writingFinal', orig: 'A\nB', final: '' }], }
// ], [
// `\
// \`\`\`
// <<<<<<< ORIGINAL
// A
// B
// =======
// >>>>>>> UPDAT`, { shape: [{ state: 'writingFinal', orig: 'A\nB', final: '' }], }
// ], [
// `\
// \`\`\`
// <<<<<<< ORIGINAL
// A
// B
// =======
// >>>>>>> UPDATED`, { shape: [{ state: 'done', orig: 'A\nB', final: '' }], }
// ], [
// `\
// \`\`\`
// <<<<<<< ORIGINAL
// A
// B
// =======
// >>>>>>> UPDATED
// \`\`\``, { shape: [{ state: 'done', orig: 'A\nB', final: '' }], }
// ],
// // alternatively
// [
// `\
// \`\`\`
// <<<<<<< ORIGINAL
// A
// B
// =======
// X`, { shape: [{ state: 'writingFinal', orig: 'A\nB', final: 'X' }], }
// ],
// [
// `\
// \`\`\`
// <<<<<<< ORIGINAL
// A
// B
// =======
// X
// Y`, { shape: [{ state: 'writingFinal', orig: 'A\nB', final: 'X\nY' }], }
// ],
// [
// `\
// \`\`\`
// <<<<<<< ORIGINAL
// A
// B
// =======
// X
// Y
// `, { shape: [{ state: 'writingFinal', orig: 'A\nB', final: 'X\nY' }], }
// ],
// [
// `\
// \`\`\`
// <<<<<<< ORIGINAL
// A
// B
// =======
// X
// Y
// >>>>>>> UPDAT`, { shape: [{ state: 'writingFinal', orig: 'A\nB', final: 'X\nY' }], }
// ], [
// `\
// \`\`\`
// <<<<<<< ORIGINAL
// A
// B
// =======
// X
// Y
// >>>>>>> UPDATED`, { shape: [{ state: 'done', orig: 'A\nB', final: 'X\nY' }], }
// ], [
// `\
// \`\`\`
// <<<<<<< ORIGINAL
// A
// B
// =======
// X
// Y
// >>>>>>> UPDATED
// \`\`\``, { shape: [{ state: 'done', orig: 'A\nB', final: 'X\nY' }], }
// ]]
// function runTests() {
// let passedTests = 0;
// let failedTests = 0;
// for (let i = 0; i < tests.length; i++) {
// const [input, expected] = tests[i];
// const result = extractSearchReplaceBlocks(input);
// // Compare result with expected shape
// let passed = true;
// if (result.length !== expected.shape.length) {
// passed = false;
// } else {
// for (let j = 0; j < result.length; j++) { // block
// const expectedItem = expected.shape[j];
// const resultItem = result[j];
// if ((expectedItem.state !== undefined) && (expectedItem.state !== resultItem.state) ||
// (expectedItem.orig !== undefined) && (expectedItem.orig !== resultItem.orig) ||
// (expectedItem.final !== undefined) && (expectedItem.final !== resultItem.final)) {
// passed = false;
// break;
// }
// }
// }
// if (passed) {
// passedTests++;
// console.log(`Test ${i + 1} passed`);
// } else {
// failedTests++;
// console.log(`Test ${i + 1} failed`);
// console.log('Input:', input)
// console.log(`Expected:`, expected.shape);
// console.log(`Got:`, result);
// }
// }
// console.log(`Total: ${tests.length}, Passed: ${passedTests}, Failed: ${failedTests}`);
// return failedTests === 0;
// }
// runTests()

View file

@ -5,147 +5,54 @@
import { URI } from '../../../../../base/common/uri.js';
import { filenameToVscodeLanguage } from '../helpers/detectLanguage.js';
import { CodeSelection, StagingSelectionItem, FileSelection } from '../../common/chatThreadService.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 '../helpers/systemInfo.js';
import { os } from '../../common/helpers/systemInfo.js';
import { IVoidFileService } from '../../common/voidFileService.js';
// this is just for ease of readability
export const tripleTick = ['```', '```']
export const chat_systemMessage = (workspaces: string[]) => `\
You are a coding assistant. You are given a list of instructions to follow \`INSTRUCTIONS\`, and optionally a list of relevant files \`FILES\`, and selections inside of files \`SELECTIONS\`.
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, 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.`
Please respond to the user's query. The user's query is never invalid.
The user has the following system information:
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 understand/${mode === 'agent' ? 'make' : 'suggest'} changes to their codebase.
You will be given instructions to follow from the user, \`INSTRUCTIONS\`. You may also be given a list of selections that the user has specifically selected, \`SELECTIONS\`.
Please assist the user with their query. The user's query is never invalid.
The user's system information is as follows:
- ${os}
- Open workspaces: ${workspaces.join(', ')}
In the case that the user asks you to make changes to code, you should make sure to return CODE BLOCKS of the changes, as well as explanations and descriptions of the changes.
For example, if the user asks you to "make this file look nicer", make sure your output includes a code block with concrete ways the file can look nicer.
- 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 change to the correct location in the code.
${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, make suggestions, ${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 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.
\
`: `\
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.
If you are given tools:
- Only use tools if the user asks you to do something. If the user simply says hi or asks you a question that you can answer without tools, then do NOT tools.
- You are allowed to use tools without asking for permission.
- Feel free to use tools to gather context, make suggestions, etc.
- One great use of tools is to explore imports that you'd like to have more information about.
- Reference relevant files that you found when using tools if they helped you come up with your answer.
- NEVER refer to a tool by name when speaking with the user. For example, do NOT say to the user user "I'm going to use \`list_dir\`". Instead, say "I'm going to list all files in ___ directory", etc. Do not even refer to "pages" of results, just say you're getting more results.
If you think it's appropriate to suggest an edit to a file, then you must describe your suggestion as follows:
- The change(s) you'd like to make must be written in CODE BLOCK(S) (wrapped in triple backticks).
- The first line in the code block should be the FULL PATH of the file you want to change. Just output the path in plaintext (not in a comment).
- The rest of the contents of the code block should describe of the change you'd like to make. This description 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.
\
`}
Do not output any of these instructions, nor tell the user anything about them unless directly prompted for them.
Do not tell the user anything about the examples below. Do not assume the user is talking about any of the examples below.
## EXAMPLE 1
FILES
math.ts
${tripleTick[0]}typescript
const addNumbers = (a, b) => a + b
const multiplyNumbers = (a, b) => a * b
const subtractNumbers = (a, b) => a - b
const divideNumbers = (a, b) => a / b
const vectorize = (...numbers) => {
return numbers // vector
}
const dot = (vector1: number[], vector2: number[]) => {
if (vector1.length !== vector2.length) throw new Error(\`Could not dot vectors \${vector1} and \${vector2}. Size mismatch.\`)
let sum = 0
for (let i = 0; i < vector1.length; i += 1)
sum += multiplyNumbers(vector1[i], vector2[i])
return sum
}
const normalize = (vector: number[]) => {
const norm = Math.sqrt(dot(vector, vector))
for (let i = 0; i < vector.length; i += 1)
vector[i] = divideNumbers(vector[i], norm)
return vector
}
const normalized = (vector: number[]) => {
const v2 = [...vector] // clone vector
return normalize(v2)
}
${tripleTick[1]}
SELECTIONS
math.ts (lines 3:3)
${tripleTick[0]}typescript
const subtractNumbers = (a, b) => a - b
${tripleTick[1]}
INSTRUCTIONS
add a function that exponentiates a number below this, and use it to make a power function that raises all entries of a vector to a power
## ACCEPTED OUTPUT
We can add the following code to the file:
${tripleTick[0]}typescript
// existing code...
const subtractNumbers = (a, b) => a - b
const exponentiateNumbers = (a, b) => Math.pow(a, b)
const divideNumbers = (a, b) => a / b
// existing code...
const raiseAll = (vector: number[], power: number) => {
for (let i = 0; i < vector.length; i += 1)
vector[i] = exponentiateNumbers(vector[i], power)
return vector
}
${tripleTick[1]}
## EXAMPLE 2
FILES
fib.ts
${tripleTick[0]}typescript
const dfs = (root) => {
if (!root) return;
console.log(root.val);
dfs(root.left);
dfs(root.right);
}
const fib = (n) => {
if (n < 1) return 1
return fib(n - 1) + fib(n - 2)
}
${tripleTick[1]}
SELECTIONS
fib.ts (lines 10:10)
${tripleTick[0]}typescript
return fib(n - 1) + fib(n - 2)
${tripleTick[1]}
INSTRUCTIONS
memoize results
## ACCEPTED OUTPUT
To implement memoization in your Fibonacci function, you can use a JavaScript object to store previously computed results. This will help avoid redundant calculations and improve performance. Here's how you can modify your function:
${tripleTick[0]}typescript
// existing code...
const fib = (n, memo = {}) => {
if (n < 1) return 1;
if (memo[n]) return memo[n]; // Check if result is already computed
memo[n] = fib(n - 1, memo) + fib(n - 2, memo); // Store result in memo
return memo[n];
}
${tripleTick[1]}
Explanation:
Memoization Object: A memo object is used to store the results of Fibonacci calculations for each n.
Check Memo: Before computing fib(n), the function checks if the result is already in memo. If it is, it returns the stored result.
Store Result: After computing fib(n), the result is stored in memo for future reference.
## END EXAMPLES\
\
`
@ -231,7 +138,7 @@ ${selnsStr}`
return null
}
export const chat_userMessageContentWithAllFilesToo = (userMessage: string, selectionsString: string | null) => {
export const chat_lastUserMessageWithFilesAdded = (userMessage: string, selectionsString: string | null) => {
if (userMessage) return `${userMessage}${selectionsString ? `\n${selectionsString}` : ''}`
else return userMessage
}
@ -515,3 +422,118 @@ Return only the completion block of code (of the form ${tripleTick[0]}${language
${tripleTick[1]}).`
};
/*
OLD CHAT EXAMPLES:
Do not tell the user anything about the examples below. Do not assume the user is talking about any of the examples below.
## EXAMPLE 1
FILES
math.ts
${tripleTick[0]}typescript
const addNumbers = (a, b) => a + b
const multiplyNumbers = (a, b) => a * b
const subtractNumbers = (a, b) => a - b
const divideNumbers = (a, b) => a / b
const vectorize = (...numbers) => {
return numbers // vector
}
const dot = (vector1: number[], vector2: number[]) => {
if (vector1.length !== vector2.length) throw new Error(\`Could not dot vectors \${vector1} and \${vector2}. Size mismatch.\`)
let sum = 0
for (let i = 0; i < vector1.length; i += 1)
sum += multiplyNumbers(vector1[i], vector2[i])
return sum
}
const normalize = (vector: number[]) => {
const norm = Math.sqrt(dot(vector, vector))
for (let i = 0; i < vector.length; i += 1)
vector[i] = divideNumbers(vector[i], norm)
return vector
}
const normalized = (vector: number[]) => {
const v2 = [...vector] // clone vector
return normalize(v2)
}
${tripleTick[1]}
SELECTIONS
math.ts (lines 3:3)
${tripleTick[0]}typescript
const subtractNumbers = (a, b) => a - b
${tripleTick[1]}
INSTRUCTIONS
add a function that exponentiates a number below this, and use it to make a power function that raises all entries of a vector to a power
## ACCEPTED OUTPUT
We can add the following code to the file:
${tripleTick[0]}typescript
// existing code...
const subtractNumbers = (a, b) => a - b
const exponentiateNumbers = (a, b) => Math.pow(a, b)
const divideNumbers = (a, b) => a / b
// existing code...
const raiseAll = (vector: number[], power: number) => {
for (let i = 0; i < vector.length; i += 1)
vector[i] = exponentiateNumbers(vector[i], power)
return vector
}
${tripleTick[1]}
## EXAMPLE 2
FILES
fib.ts
${tripleTick[0]}typescript
const dfs = (root) => {
if (!root) return;
console.log(root.val);
dfs(root.left);
dfs(root.right);
}
const fib = (n) => {
if (n < 1) return 1
return fib(n - 1) + fib(n - 2)
}
${tripleTick[1]}
SELECTIONS
fib.ts (lines 10:10)
${tripleTick[0]}typescript
return fib(n - 1) + fib(n - 2)
${tripleTick[1]}
INSTRUCTIONS
memoize results
## ACCEPTED OUTPUT
To implement memoization in your Fibonacci function, you can use a JavaScript object to store previously computed results. This will help avoid redundant calculations and improve performance. Here's how you can modify your function:
${tripleTick[0]}typescript
// existing code...
const fib = (n, memo = {}) => {
if (n < 1) return 1;
if (memo[n]) return memo[n]; // Check if result is already computed
memo[n] = fib(n - 1, memo) + fib(n - 2, memo); // Store result in memo
return memo[n];
}
${tripleTick[1]}
Explanation:
Memoization Object: A memo object is used to store the results of Fibonacci calculations for each n.
Check Memo: Before computing fib(n), the function checks if the result is already in memo. If it is, it returns the stored result.
Store Result: After computing fib(n), the result is stored in memo for future reference.
## END EXAMPLES
*/

View file

@ -8,7 +8,7 @@ import { Action2, registerAction2 } from '../../../../platform/actions/common/ac
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';
import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';
import { IEditCodeService } from './editCodeService.js';
import { IEditCodeService } from './editCodeServiceInterface.js';
import { roundRangeToLines } from './sidebarActions.js';
import { VOID_CTRL_K_ACTION_ID } from './actionIDs.js';
import { localize2 } from '../../../../nls.js';

View file

@ -74,7 +74,7 @@ function saveStylesFile() {
} catch (err) {
console.error('[scope-tailwind] Error saving styles.css:', err);
}
}, 3000);
}, 4000);
}
const args = process.argv.slice(2);

View file

@ -3,7 +3,6 @@ import { useAccessor, useURIStreamState, useSettingsState } from '../util/servic
import { useRefState } from '../util/helpers.js'
import { isFeatureNameDisabled } from '../../../../common/voidSettingsTypes.js'
import { URI } from '../../../../../../../base/common/uri.js'
import { IEditCodeService, URIStreamState } from '../../../editCodeService.js'
enum CopyButtonText {
Idle = 'Copy',
@ -38,7 +37,7 @@ const CopyButton = ({ codeStr }: { codeStr: string }) => {
const isSingleLine = !codeStr.includes('\n')
return <button
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-1 text-void-fg-1 hover:brightness-110 border border-vscode-input-border rounded`}
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-2 text-void-fg-1 hover:brightness-110 border border-void-border-1 rounded`}
onClick={onCopy}
>
{copyButtonText}
@ -56,8 +55,6 @@ const applyingURIOfApplyBoxIdRef: { current: { [applyBoxId: string]: URI | undef
export const ApplyBlockHoverButtons = ({ codeStr, applyBoxId }: { codeStr: string, applyBoxId: string }) => {
console.log('applyboxid', applyBoxId, applyingURIOfApplyBoxIdRef)
const settingsState = useSettingsState()
const isDisabled = !!isFeatureNameDisabled('Apply', settingsState) || !applyBoxId
@ -82,11 +79,12 @@ export const ApplyBlockHoverButtons = ({ codeStr, applyBoxId }: { codeStr: strin
const onSubmit = useCallback(() => {
if (isDisabled) return
if (streamState() === 'streaming') return
const newApplyingUri = editCodeService.startApplying({
const [newApplyingUri, _] = editCodeService.startApplying({
from: 'ClickApply',
type: 'searchReplace',
applyStr: codeStr,
})
uri: 'current',
}) ?? []
applyingURIOfApplyBoxIdRef.current[applyBoxId] = newApplyingUri ?? undefined
rerender(c => c + 1)
metricsService.capture('Apply Code', { length: codeStr.length }) // capture the length only
@ -106,16 +104,14 @@ export const ApplyBlockHoverButtons = ({ codeStr, applyBoxId }: { codeStr: strin
const isSingleLine = !codeStr.includes('\n')
const applyButton = <button
// btn btn-secondary btn-sm border text-sm border-vscode-input-border rounded
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-1 text-void-fg-1 hover:brightness-110 border border-vscode-input-border rounded`}
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-2 text-void-fg-1 hover:brightness-110 border border-void-border-1 rounded`}
onClick={onSubmit}
>
Apply
</button>
const stopButton = <button
// btn btn-secondary btn-sm border text-sm border-vscode-input-border rounded
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-1 text-void-fg-1 hover:brightness-110 border border-vscode-input-border rounded`}
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-2 text-void-fg-1 hover:brightness-110 border border-void-border-1 rounded`}
onClick={onInterrupt}
>
Stop
@ -123,8 +119,7 @@ export const ApplyBlockHoverButtons = ({ codeStr, applyBoxId }: { codeStr: strin
const acceptRejectButtons = <>
<button
// btn btn-secondary btn-sm border text-sm border-vscode-input-border rounded
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-1 text-void-fg-1 hover:brightness-110 border border-vscode-input-border rounded`}
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-2 text-void-fg-1 hover:brightness-110 border border-void-border-1 rounded`}
onClick={() => {
const uri = applyingUri()
if (uri) editCodeService.removeDiffAreas({ uri, behavior: 'accept', removeCtrlKs: false })
@ -133,8 +128,7 @@ export const ApplyBlockHoverButtons = ({ codeStr, applyBoxId }: { codeStr: strin
Accept
</button>
<button
// btn btn-secondary btn-sm border text-sm border-vscode-input-border rounded
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-1 text-void-fg-1 hover:brightness-110 border border-vscode-input-border rounded`}
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-2 text-void-fg-1 hover:brightness-110 border border-void-border-1 rounded`}
onClick={() => {
const uri = applyingUri()
if (uri) editCodeService.removeDiffAreas({ uri, behavior: 'reject', removeCtrlKs: false })
@ -144,8 +138,6 @@ export const ApplyBlockHoverButtons = ({ codeStr, applyBoxId }: { codeStr: strin
</button>
</>
console.log('streamStateRef.current', streamState())
const currStreamState = streamState()
return <>
{currStreamState !== 'streaming' && <CopyButton codeStr={codeStr} />}

View file

@ -6,7 +6,7 @@
import React, { JSX } from 'react'
import { marked, MarkedToken, Token } from 'marked'
import { BlockCode } from './BlockCode.js'
import { nameToVscodeLanguage } from '../../../helpers/detectLanguage.js'
import { nameToVscodeLanguage } from '../../../../common/helpers/detectLanguage.js'
import { ApplyBlockHoverButtons } from './ApplyBlockHoverButtons.js'
export type ChatMessageLocation = {
@ -14,35 +14,21 @@ export type ChatMessageLocation = {
messageIdx: number;
}
type ApplyBoxLocation = ChatMessageLocation & { tokenIdx: string }
const getApplyBoxId = ({ threadId, messageIdx, tokenIdx }: ApplyBoxLocation) => {
return `${threadId}-${messageIdx}-${tokenIdx}`
}
export const CodeSpan = ({ children, className }: { children: React.ReactNode, className?: string }) => {
return <code className={`
bg-void-bg-1
px-1
rounded-sm
font-mono font-medium
break-all
${className}
`}
>
{children}
</code>
}
const RenderToken = ({ token, nested, noSpace, chatMessageLocationForApply, tokenIdx }: { token: Token | string, nested?: boolean, noSpace?: boolean, chatMessageLocationForApply?: ChatMessageLocation, tokenIdx: string }): JSX.Element => {
const RenderToken = ({ token, nested, chatMessageLocationForApply, tokenIdx }: { token: Token | string, nested?: boolean, chatMessageLocationForApply?: ChatMessageLocation, tokenIdx: string }): JSX.Element => {
// deal with built-in tokens first (assume marked token)
const t = token as MarkedToken
if (t.raw.trim() === '') {
return <></>;
}
if (t.type === "space") {
return <span>{t.raw}</span>
}
@ -55,40 +41,30 @@ const RenderToken = ({ token, nested, noSpace, chatMessageLocationForApply, toke
tokenIdx: tokenIdx,
}) : null
return <div className='my-4'>
return <div>
<BlockCode
initValue={t.text}
language={t.lang === undefined ? undefined : nameToVscodeLanguage[t.lang]}
buttonsOnHover={applyBoxId && <ApplyBlockHoverButtons applyBoxId={applyBoxId} codeStr={t.text} />}
/>
initValue={t.text}
language={t.lang === undefined ? undefined : nameToVscodeLanguage[t.lang]}
buttonsOnHover={applyBoxId && <ApplyBlockHoverButtons applyBoxId={applyBoxId} codeStr={t.text} />}
/>
</div>
}
if (t.type === "heading") {
const HeadingTag = `h${t.depth}` as keyof JSX.IntrinsicElements
const headingClasses: { [h: string]: string } = {
h1: "text-4xl font-semibold mt-6 mb-4 pb-2 border-b border-void-bg-2",
h2: "text-3xl font-semibold mt-6 mb-4 pb-2 border-b border-void-bg-2",
h3: "text-2xl font-semibold mt-6 mb-4",
h4: "text-xl font-semibold mt-6 mb-4",
h5: "text-lg font-semibold mt-6 mb-4",
h6: "text-base font-semibold mt-6 mb-4 text-gray-600"
}
return <HeadingTag className={headingClasses[HeadingTag]}>{t.text}</HeadingTag>
return <HeadingTag>{t.text}</HeadingTag>
}
if (t.type === "table") {
return (
<div className={`${noSpace ? '' : 'my-4'} overflow-x-auto`}>
<table className="min-w-full border border-void-bg-2">
<div>
<table>
<thead>
<tr className="bg-void-bg-1">
<tr>
{t.header.map((cell: any, index: number) => (
<th
key={index}
className="px-4 py-2 border border-void-bg-2 font-semibold"
style={{ textAlign: t.align[index] || "left" }}
>
<th key={index}>
{cell.raw}
</th>
))}
@ -96,13 +72,9 @@ const RenderToken = ({ token, nested, noSpace, chatMessageLocationForApply, toke
</thead>
<tbody>
{t.rows.map((row: any[], rowIndex: number) => (
<tr key={rowIndex} className={rowIndex % 2 === 0 ? 'bg-white' : 'bg-void-bg-1'}>
<tr key={rowIndex}>
{row.map((cell: any, cellIndex: number) => (
<td
key={cellIndex}
className="px-4 py-2 border border-void-bg-2"
style={{ textAlign: t.align[cellIndex] || "left" }}
>
<td key={cellIndex} >
{cell.raw}
</td>
))}
@ -112,71 +84,98 @@ const RenderToken = ({ token, nested, noSpace, chatMessageLocationForApply, toke
</table>
</div>
)
// return (
// <div>
// <table className={"min-w-full border border-void-bg-2"}>
// <thead>
// <tr className="bg-void-bg-1">
// {t.header.map((cell: any, index: number) => (
// <th
// key={index}
// className="px-4 py-2 border border-void-bg-2 font-semibold"
// style={{ textAlign: t.align[index] || "left" }}
// >
// {cell.raw}
// </th>
// ))}
// </tr>
// </thead>
// <tbody>
// {t.rows.map((row: any[], rowIndex: number) => (
// <tr key={rowIndex} className={rowIndex % 2 === 0 ? 'bg-white' : 'bg-void-bg-1'}>
// {row.map((cell: any, cellIndex: number) => (
// <td
// key={cellIndex}
// className={"px-4 py-2 border border-void-bg-2"}
// style={{ textAlign: t.align[cellIndex] || "left" }}
// >
// {cell.raw}
// </td>
// ))}
// </tr>
// ))}
// </tbody>
// </table>
// </div>
// )
}
if (t.type === "hr") {
return <hr className="my-6 border-t border-void-bg-2" />
return <hr />
}
if (t.type === "blockquote") {
return <blockquote className={`pl-4 border-l-4 border-void-bg-2 italic ${noSpace ? '' : 'my-4'}`}>{t.text}</blockquote>
return <blockquote>{t.text}</blockquote>
}
if (t.type === 'list_item') {
return <li>
<input type="checkbox" checked={t.checked} readOnly />
<span>
<ChatMarkdownRender chatMessageLocationForApply={chatMessageLocationForApply} string={t.text} nested={true} />
</span>
</li>
}
if (t.type === "list") {
const ListTag = t.ordered ? "ol" : "ul"
return (
<ListTag
start={t.start ? t.start : undefined}
className={`list-inside pl-2 ${noSpace ? '' : 'my-4'} ${t.ordered ? "list-decimal" : "list-disc"}`}
>
<ListTag start={t.start ? t.start : undefined}>
{t.items.map((item, index) => (
<li key={index} className={`${noSpace ? '' : 'mb-4'}`}>
<li key={index}>
{item.task && (
<input type="checkbox" checked={item.checked} readOnly className="mr-2 form-checkbox" />
<input type="checkbox" checked={item.checked} readOnly />
)}
<span className="ml-1">
<span>
<ChatMarkdownRender chatMessageLocationForApply={chatMessageLocationForApply} string={item.text} nested={true} />
</span>
</li>
))}
</ListTag>
)
// attempt at indentation
// return (
// <ListTag
// start={t.start ? t.start : undefined}
// className={`pl-2 ${noSpace ? '' : 'my-4'} ${t.ordered ? "list-decimal" : "list-disc"}`}
// >
// {t.items.map((item, index) => (
// <li key={index} className={`${noSpace ? '' : 'mb-2'} ml-4`}>
// {item.task && (
// <input type="checkbox" className='mr-2 form-checkbox' checked={item.checked} readOnly />
// )}
// <span className-='inline-block pr-2'>
// <ChatMarkdownRender chatMessageLocation={chatMessageLocation} string={item.text} nested={true} />
// </span>
// </li>
// ))}
// </ListTag>
// )
}
if (t.type === "paragraph") {
const contents = <>
{t.tokens.map((token, index) => (
<RenderToken key={index} token={token} tokenIdx={`${tokenIdx ? `${tokenIdx}-` : ''}${index}`} /> // assign a unique tokenId to nested components
<RenderToken key={index}
token={token}
tokenIdx={`${tokenIdx ? `${tokenIdx}-` : ''}${index}`} // assign a unique tokenId to nested components
/>
))}
</>
if (nested) return contents
return <p className={`${noSpace ? '' : 'my-4'}`}>
return <p>
{contents}
</p>
}
if (t.type === "html") {
return (
<p className={`${noSpace ? '' : 'my-4'}`}>
<p>
{t.raw}
</p>
)
@ -193,10 +192,10 @@ const RenderToken = ({ token, nested, noSpace, chatMessageLocationForApply, toke
if (t.type === "link") {
return (
<a
className='underline'
onClick={() => { window.open(t.href) }}
href={t.href}
title={t.title ?? undefined}
className='underline cursor-pointer hover:brightness-90 transition-all duration-200'
>
{t.text}
</a>
@ -208,24 +207,24 @@ const RenderToken = ({ token, nested, noSpace, chatMessageLocationForApply, toke
src={t.href}
alt={t.text}
title={t.title ?? undefined}
className={`max4w-full h-auto rounded ${noSpace ? '' : 'my-4'}`}
/>
}
if (t.type === "strong") {
return <strong className="font-semibold">{t.text}</strong>
return <strong>{t.text}</strong>
}
if (t.type === "em") {
return <em className="italic">{t.text}</em>
return <em>{t.text}</em>
}
// inline code
if (t.type === "codespan") {
return (
<CodeSpan>
<code className="font-mono font-medium rounded-sm bg-void-bg-1 px-1">
{t.text}
</CodeSpan>
</code>
)
}
@ -235,24 +234,22 @@ const RenderToken = ({ token, nested, noSpace, chatMessageLocationForApply, toke
// strikethrough
if (t.type === "del") {
return <del className="line-through">{t.text}</del>
return <del>{t.text}</del>
}
// default
return (
<div className="bg-orange-50 rounded-sm overflow-hidden p-2">
<span className="text-sm text-orange-500">Unknown type:</span>
{t.raw}
<span className="text-sm text-orange-500">Unknown token rendered...</span>
</div>
)
}
export const ChatMarkdownRender = ({ string, nested = false, noSpace, chatMessageLocationForApply }: { string: string, nested?: boolean, noSpace?: boolean, chatMessageLocationForApply?: ChatMessageLocation }) => {
export const ChatMarkdownRender = ({ string, nested = false, chatMessageLocationForApply }: { string: string, nested?: boolean, chatMessageLocationForApply?: ChatMessageLocation }) => {
const tokens = marked.lexer(string); // https://marked.js.org/using_pro#renderer
return (
<>
{tokens.map((token, index) => (
<RenderToken key={index} token={token} nested={nested} noSpace={noSpace} chatMessageLocationForApply={chatMessageLocationForApply} tokenIdx={index + ''} />
<RenderToken key={index} token={token} nested={nested} chatMessageLocationForApply={chatMessageLocationForApply} tokenIdx={index + ''} />
))}
</>
)

View file

@ -2,11 +2,6 @@
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import React, { useEffect, useState } from 'react'
import { mountFnGenerator } from '../util/mountFnGenerator.js'
// import { SidebarSettings } from './SidebarSettings.js';
import { useIsDark, useSidebarState } from '../util/services.js';
// import { SidebarThreadSelector } from './SidebarThreadSelector.js';
@ -20,9 +15,9 @@ export const Sidebar = ({ className }: { className: string }) => {
const sidebarState = useSidebarState()
const { currentTab: tab } = sidebarState
// const isDark = useIsDark()
const isDark = useIsDark()
return <div
className={`@@void-scope`} // ${isDark ? 'dark' : ''}
className={`@@void-scope ${isDark ? 'dark' : ''}`}
style={{ width: '100%', height: '100%' }}
>
<div

View file

@ -68,9 +68,7 @@ export const SidebarThreadSelector = () => {
let firstMsg = null;
// let secondMsg = null;
const firstUserMsgIdx = pastThread.messages.findIndex(
(msg) => msg.role !== 'system' && msg.role !== 'tool' && !!msg.displayContent
);
const firstUserMsgIdx = pastThread.messages.findIndex((msg) => msg.role !== 'tool' && msg.role !== 'tool_request');
if (firstUserMsgIdx !== -1) {
// firstMsg = truncate(pastThread.messages[firstMsgIdx].displayContent ?? '');
@ -88,9 +86,7 @@ export const SidebarThreadSelector = () => {
// secondMsg = truncate(pastThread.messages[secondMsgIdx].displayContent ?? '');
// }
const numMessages = pastThread.messages.filter(
(msg) => msg.role !== 'system'
).length;
const numMessages = pastThread.messages.filter((msg) => msg.role !== 'tool_request').length;
return (
<li key={pastThread.id}>

View file

@ -7,6 +7,30 @@
@tailwind components;
@tailwind utilities;
& {
--void-bg-1: var(--vscode-input-background);
--void-bg-1-alt: var(--vscode-badge-background);
--void-bg-2: var(--vscode-sideBar-background);
--void-bg-2-alt: color-mix(in srgb, var(--vscode-sideBar-background) 30%, var(--vscode-editor-background) 70%);
--void-bg-3: var(--vscode-editor-background);
--void-fg-0: color-mix(in srgb, var(--vscode-tab-activeForeground) 90%, black 10%);
--void-fg-1: var(--vscode-editor-foreground);
--void-fg-2: var(--vscode-input-foreground);
--void-fg-3: var(--vscode-input-placeholderForeground);
/* --void-fg-4: var(--vscode-tab-inactiveForeground); */
--void-fg-4: var(--vscode-list-deemphasizedForeground);
--void-warning: var(--vscode-charts-yellow);
--void-border-1: var(--vscode-commandCenter-activeBorder);
--void-border-2: var(--vscode-commandCenter-border);
--void-border-3: var(--vscode-commandCenter-inactiveBorder);
--void-border-4: var(--vscode-editorGroup-border);
--void-ring-color: #007FD4;
--void-link-color: #007FD4;
}
.select-child-restyle select {
text-overflow: ellipsis;
@ -14,6 +38,10 @@
padding-right: 24px;
}
.void-force-child-placeholder-void-fg-1 ::placeholder {
color: var(--void-fg-3);
}
* {
outline: none !important;
}

View file

@ -169,7 +169,7 @@ export const VoidInputBox = ({ onChangeText, onCreateInstance, inputBoxRef, plac
ctor={InputBox}
className='
bg-void-bg-1
@@[&_::placeholder]:!void-text-void-fg-3
@@void-force-child-placeholder-void-fg-1
'
propsFn={useCallback((container) => [
container,
@ -214,27 +214,188 @@ export const VoidInputBox = ({ onChangeText, onCreateInstance, inputBoxRef, plac
export const VoidSlider = ({
value,
onChange,
size = 'md',
disabled = false,
min = 0,
max = 7,
step = 1,
className = '',
width = 200,
}: {
value: number;
onChange: (value: number) => void;
disabled?: boolean;
size?: 'xxs' | 'xs' | 'sm' | 'sm+' | 'md';
min?: number;
max?: number;
step?: number;
className?: string;
width?: number;
}) => {
// Calculate percentage for position
const percentage = ((value - min) / (max - min)) * 100;
// Handle track click
const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (disabled) return;
const rect = e.currentTarget.getBoundingClientRect();
const clickPosition = e.clientX - rect.left;
const trackWidth = rect.width;
// Calculate new value
const newPercentage = Math.max(0, Math.min(1, clickPosition / trackWidth));
const rawValue = min + newPercentage * (max - min);
// Special handling to ensure max value is always reachable
if (rawValue >= max - step / 2) {
onChange(max);
return;
}
// Normal step calculation
const steppedValue = Math.round((rawValue - min) / step) * step + min;
const clampedValue = Math.max(min, Math.min(max, steppedValue));
onChange(clampedValue);
};
// Helper function to handle thumb dragging that respects steps and max
const handleThumbDrag = (moveEvent: MouseEvent, track: Element) => {
if (!track) return;
const rect = (track as HTMLElement).getBoundingClientRect();
const movePosition = moveEvent.clientX - rect.left;
const trackWidth = rect.width;
// Calculate new value
const newPercentage = Math.max(0, Math.min(1, movePosition / trackWidth));
const rawValue = min + newPercentage * (max - min);
// Special handling to ensure max value is always reachable
if (rawValue >= max - step / 2) {
onChange(max);
return;
}
// Normal step calculation
const steppedValue = Math.round((rawValue - min) / step) * step + min;
const clampedValue = Math.max(min, Math.min(max, steppedValue));
onChange(clampedValue);
};
return (
<div className={`inline-flex items-center flex-shrink-0 ${className}`}>
{/* Outer container with padding to account for thumb overhang */}
<div className={`relative flex-shrink-0 ${disabled ? 'opacity-25' : ''}`}
style={{
width,
// Add horizontal padding equal to half the thumb width
// paddingLeft: thumbSizePx / 2,
// paddingRight: thumbSizePx / 2
}}>
{/* Track container with adjusted width */}
<div className="relative w-full">
{/* Invisible wider clickable area that sits above the track */}
<div
className="absolute w-full cursor-pointer"
style={{
height: '16px',
top: '50%',
transform: 'translateY(-50%)',
zIndex: 1
}}
onClick={handleTrackClick}
/>
{/* Track */}
<div
className={`relative ${size === 'xxs' ? 'h-0.5' :
size === 'xs' ? 'h-1' :
size === 'sm' ? 'h-1.5' :
size === 'sm+' ? 'h-2' : 'h-2.5'
} bg-gray-200 dark:bg-gray-700 rounded-full cursor-pointer`}
onClick={handleTrackClick}
>
{/* Filled part of track */}
<div
className={`absolute left-0 ${size === 'xxs' ? 'h-0.5' :
size === 'xs' ? 'h-1' :
size === 'sm' ? 'h-1.5' :
size === 'sm+' ? 'h-2' : 'h-2.5'
} bg-gray-900 dark:bg-white rounded-full`}
style={{ width: `${percentage}%` }}
/>
</div>
{/* Thumb with sizes matching VoidSwitch */}
<div
className={`absolute top-1/2 transform -translate-x-1/2 -translate-y-1/2
${size === 'xxs' ? 'h-2 w-2' :
size === 'xs' ? 'h-2.5 w-2.5' :
size === 'sm' ? 'h-3 w-3' :
size === 'sm+' ? 'h-3.5 w-3.5' : 'h-4 w-4'
}
bg-white dark:bg-gray-900 rounded-full shadow-md ${disabled ? 'cursor-not-allowed' : 'cursor-grab active:cursor-grabbing'}`}
style={{ left: `${percentage}%`, zIndex: 2 }} // Ensure thumb is above the invisible clickable area
onMouseDown={(e) => {
if (disabled) return;
const track = e.currentTarget.previousElementSibling;
const handleMouseMove = (moveEvent: MouseEvent) => {
handleThumbDrag(moveEvent, track as Element);
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.body.style.userSelect = 'none';
document.body.style.cursor = 'grabbing';
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
e.preventDefault();
}}
/>
</div>
</div>
</div>
);
};
export const VoidSwitch = ({
value,
onChange,
size = 'md',
label,
disabled = false,
}: {
value: boolean;
onChange: (value: boolean) => void;
label?: string;
disabled?: boolean;
size?: 'xs' | 'sm' | 'sm+' | 'md';
size?: 'xxs' | 'xs' | 'sm' | 'sm+' | 'md';
}) => {
return (
<label className="inline-flex items-center cursor-pointer">
<label className="inline-flex items-center">
<div
onClick={() => !disabled && onChange(!value)}
className={`
cursor-pointer
relative inline-flex items-center rounded-full transition-colors duration-200 ease-in-out
${value ? 'bg-gray-900 dark:bg-white' : 'bg-gray-200 dark:bg-gray-700'}
${disabled ? 'opacity-25' : ''}
${size === 'xxs' ? 'h-3 w-5' : ''}
${size === 'xs' ? 'h-4 w-7' : ''}
${size === 'sm' ? 'h-5 w-9' : ''}
${size === 'sm+' ? 'h-5 w-10' : ''}
@ -244,10 +405,12 @@ export const VoidSwitch = ({
<span
className={`
inline-block transform rounded-full bg-white dark:bg-gray-900 shadow transition-transform duration-200 ease-in-out
${size === 'xxs' ? 'h-2 w-2' : ''}
${size === 'xs' ? 'h-2.5 w-2.5' : ''}
${size === 'sm' ? 'h-3 w-3' : ''}
${size === 'sm+' ? 'h-3.5 w-3.5' : ''}
${size === 'md' ? 'h-4 w-4' : ''}
${size === 'xxs' ? (value ? 'translate-x-2.5' : 'translate-x-0.5') : ''}
${size === 'xs' ? (value ? 'translate-x-3.5' : 'translate-x-0.5') : ''}
${size === 'sm' ? (value ? 'translate-x-5' : 'translate-x-1') : ''}
${size === 'sm+' ? (value ? 'translate-x-6' : 'translate-x-1') : ''}
@ -255,14 +418,6 @@ export const VoidSwitch = ({
`}
/>
</div>
{label && (
<span className={`
ml-3 font-medium text-gray-900 dark:text-gray-100
${size === 'xs' ? 'text-xs' : 'text-sm'}
`}>
{label}
</span>
)}
</label>
);
};

View file

@ -4,7 +4,6 @@
*--------------------------------------------------------------------------------------*/
import React, { useState, useEffect, useCallback } from 'react'
import { ThreadStreamState,IChatThreadService, ThreadsState } from '../../../../common/chatThreadService.js'
import { RefreshableProviderName, SettingsOfProvider } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js'
import { IDisposable } from '../../../../../../../base/common/lifecycle.js'
import { VoidSidebarState } from '../../../sidebarStateService.js'
@ -25,7 +24,8 @@ import { IThemeService } from '../../../../../../../platform/theme/common/themeS
import { ILLMMessageService } from '../../../../../../../workbench/contrib/void/common/llmMessageService.js';
import { IRefreshModelService } from '../../../../../../../workbench/contrib/void/common/refreshModelService.js';
import { IVoidSettingsService } from '../../../../../../../workbench/contrib/void/common/voidSettingsService.js';
import { IEditCodeService, URIStreamState } from '../../../editCodeService.js';
import { IEditCodeService, URIStreamState } from '../../../editCodeServiceInterface.js'
import { IVoidUriStateService } from '../../../voidUriStateService.js';
import { IQuickEditStateService } from '../../../quickEditStateService.js';
import { ISidebarStateService } from '../../../sidebarStateService.js';
@ -44,6 +44,7 @@ import { IConfigurationService } from '../../../../../../../platform/configurati
import { IPathService } from '../../../../../../../workbench/services/path/common/pathService.js'
import { IMetricsService } from '../../../../../../../workbench/contrib/void/common/metricsService.js'
import { URI } from '../../../../../../../base/common/uri.js'
import { IChatThreadService, ThreadsState, ThreadStreamState } from '../../../chatThreadService.js'
@ -170,7 +171,7 @@ export const _registerServices = (accessor: ServicesAccessor) => {
colorThemeState = themeService.getColorTheme().type
disposables.push(
themeService.onDidColorThemeChange(theme => {
themeService.onDidColorThemeChange(({ theme }) => {
colorThemeState = theme.type
colorThemeStateListeners.forEach(l => l(colorThemeState))
})

View file

@ -10,7 +10,7 @@ import { _VoidSelectBox, VoidCustomDropdownBox } from '../util/inputs.js'
import { SelectBox } from '../../../../../../../base/browser/ui/selectBox/selectBox.js'
import { IconWarning } from '../sidebar-tsx/SidebarChat.js'
import { VOID_OPEN_SETTINGS_ACTION_ID, VOID_TOGGLE_SETTINGS_ACTION_ID } from '../../../voidSettingsPane.js'
import { ModelOption } from '../../../../../../../workbench/contrib/void/common/voidSettingsService.js'
import { modelFilterOfFeatureName, ModelOption } from '../../../../../../../workbench/contrib/void/common/voidSettingsService.js'
import { WarningBox } from './WarningBox.js'
const optionsEqual = (m1: ModelOption[], m2: ModelOption[]) => {
@ -38,9 +38,9 @@ const ModelSelectBox = ({ options, featureName }: { options: ModelOption[], feat
onChangeOption={onChangeOption}
getOptionDisplayName={(option) => option.selection.modelName}
getOptionDropdownName={(option) => option.selection.modelName}
getOptionDropdownDetail={(option) => option.selection.providerName }
getOptionDropdownDetail={(option) => option.selection.providerName}
getOptionsEqual={(a, b) => optionsEqual([a], [b])}
className='text-xs text-void-fg-3 px-1'
className='text-xs text-void-fg-3'
matchInputWidth={false}
/>
}
@ -82,14 +82,21 @@ const MemoizedModelDropdown = ({ featureName }: { featureName: FeatureName }) =>
const oldOptionsRef = useRef<ModelOption[]>([])
const [memoizedOptions, setMemoizedOptions] = useState(oldOptionsRef.current)
const { filter, emptyMessage } = modelFilterOfFeatureName[featureName]
useEffect(() => {
const oldOptions = oldOptionsRef.current
const newOptions = settingsState._modelOptions
const newOptions = settingsState._modelOptions.filter((o) => filter(o.selection))
if (!optionsEqual(oldOptions, newOptions)) {
setMemoizedOptions(newOptions)
}
oldOptionsRef.current = newOptions
}, [settingsState._modelOptions])
}, [settingsState._modelOptions, filter])
if (memoizedOptions.length === 0) { // Pretty sure this will never be reached unless filter is enabled
return <WarningBox text={emptyMessage || 'No models available'} />
}
return <ModelSelectBox featureName={featureName} options={memoizedOptions} />
@ -103,13 +110,17 @@ export const ModelDropdown = ({ featureName }: { featureName: FeatureName }) =>
const openSettings = () => { commandService.executeCommand(VOID_OPEN_SETTINGS_ACTION_ID); };
const { emptyMessage } = modelFilterOfFeatureName[featureName]
const isDisabled = isFeatureNameDisabled(featureName, settingsState)
if (isDisabled)
return <WarningBox onClick={openSettings} text={
isDisabled === 'needToEnableModel' ? 'Enable a model'
: isDisabled === 'addModel' ? 'Add a model'
: (isDisabled === 'addProvider' || isDisabled === 'notFilledIn' || isDisabled === 'providerNotAutoDetected') ? 'Provider required'
: 'Provider required'
emptyMessage ? emptyMessage :
isDisabled === 'needToEnableModel' ? 'Enable a model'
: isDisabled === 'addModel' ? 'Add a model'
: (isDisabled === 'addProvider' || isDisabled === 'notFilledIn' || isDisabled === 'providerNotAutoDetected') ? 'Provider required'
: 'Provider required'
} />
return <MemoizedModelDropdown featureName={featureName} />

View file

@ -17,7 +17,7 @@ import { env } from '../../../../../../../base/common/process.js'
import { ModelDropdown } from './ModelDropdown.js'
import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js'
import { WarningBox } from './WarningBox.js'
import { os } from '../../../helpers/systemInfo.js'
import { os } from '../../../../common/helpers/systemInfo.js'
const SubtleButton = ({ onClick, text, icon, disabled }: { onClick: () => void, text: string, icon: React.ReactNode, disabled: boolean }) => {
@ -230,9 +230,7 @@ export const ModelDump = () => {
<VoidSwitch
value={disabled ? false : !isHidden}
onChange={() => {
settingsStateService.toggleModelHidden(providerName, modelName)
}}
onChange={() => { settingsStateService.toggleModelHidden(providerName, modelName) }}
disabled={disabled}
size='sm'
/>
@ -293,7 +291,7 @@ const ProviderSetting = ({ providerName, settingName }: { providerName: Provider
isPasswordField={isPasswordField}
/>
{subTextMd === undefined ? null : <div className='py-1 px-3 opacity-50 text-sm'>
<ChatMarkdownRender noSpace string={subTextMd} />
<ChatMarkdownRender string={subTextMd} />
</div>}
</div>
@ -396,6 +394,11 @@ export const AIInstructionsBox = () => {
}
export const FeaturesTab = () => {
const voidSettingsState = useSettingsState()
const accessor = useAccessor()
const voidSettingsService = accessor.get('IVoidSettingsService')
return <>
<h2 className={`text-3xl mb-2`}>Models</h2>
<ErrorBoundary>
@ -412,12 +415,12 @@ export const FeaturesTab = () => {
{/* <h3 className={`opacity-50 mb-2`}>{`Instructions:`}</h3> */}
{/* <h3 className={`mb-2`}>{`Void can access any model that you host locally. We automatically detect your local models by default.`}</h3> */}
<h3 className={`text-void-fg-3 mb-2`}>{`Void can access any model that you host locally. We automatically detect your local models by default.`}</h3>
<div className='pl-4 opacity-50'>
<span className={`text-sm mb-2`}><ChatMarkdownRender noSpace string={`1. Download [Ollama](https://ollama.com/download).`} /></span>
<span className={`text-sm mb-2`}><ChatMarkdownRender noSpace string={`2. Open your terminal.`} /></span>
<span className={`text-sm mb-2 select-text`}><ChatMarkdownRender noSpace string={`3. Run \`ollama run llama3.1:8b\`. This installs Meta's llama3.1 model which is best for chat and inline edits. Requires 5GB of memory.`} /></span>
<span className={`text-sm mb-2 select-text`}><ChatMarkdownRender noSpace string={`4. Run \`ollama run qwen2.5-coder:1.5b\`. This installs a faster autocomplete model. Requires 1GB of memory.`} /></span>
<span className={`text-sm mb-2`}><ChatMarkdownRender noSpace string={`Void automatically detects locally running models and enables them.`} /></span>
<div className='pl-4 prose-ol:list-decimal opacity-80'>
<span className={`text-sm mb-2`}><ChatMarkdownRender string={`1. Download [Ollama](https://ollama.com/download).`} /></span>
<span className={`text-sm mb-2`}><ChatMarkdownRender string={`2. Open your terminal.`} /></span>
<span className={`text-sm mb-2 select-text`}><ChatMarkdownRender string={`3. Run \`ollama run llama3.1:8b\`. This installs Meta's llama3.1 model which is best for chat and inline edits. Requires 5GB of memory.`} /></span>
<span className={`text-sm mb-2 select-text`}><ChatMarkdownRender string={`4. Run \`ollama run qwen2.5-coder:1.5b\`. This installs a faster autocomplete model. Requires 1GB of memory.`} /></span>
<span className={`text-sm mb-2`}><ChatMarkdownRender string={`Void automatically detects locally running models and enables them.`} /></span>
{/* TODO we should create UI for downloading models without user going into terminal */}
</div>
@ -434,17 +437,33 @@ export const FeaturesTab = () => {
<h2 className={`text-3xl mb-2 mt-12`}>Feature Options</h2>
<h2 className={`text-3xl mt-12`}>Feature Options</h2>
<ErrorBoundary>
{featureNames.map(featureName =>
(['Ctrl+L', 'Ctrl+K'] as FeatureName[]).includes(featureName) ? null :
<div key={featureName}
className='mb-2'
>
<h4 className={`text-void-fg-3`}>{displayInfoOfFeatureName(featureName)}</h4>
<ModelDropdown featureName={featureName} />
<div className='flex gap-x-4 items-start justify-around mt-4 mb-16'>
<div className='w-full'>
<h4 className={`text-base`}>{displayInfoOfFeatureName('Autocomplete')}</h4>
<div className='text-sm italic text-void-fg-3 my-1'>Experimental. Only works with models that support FIM.</div>
<div className='flex items-center gap-x-2'>
<VoidSwitch
size='xs'
value={voidSettingsState.globalSettings.enableAutocomplete}
onChange={(newVal) => voidSettingsService.setGlobalSetting('enableAutocomplete', newVal)}
/>
<span className='text-void-fg-3 text-xs pointer-events-none'>{voidSettingsState.globalSettings.enableAutocomplete ? 'Enabled' : 'Disabled'}</span>
</div>
)}
<div className={!voidSettingsState.globalSettings.enableAutocomplete ? 'hidden' : ''}>
<ModelDropdown featureName={'Autocomplete'} />
</div>
</div>
<div className='w-full'>
<h4 className={`text-base`}>{displayInfoOfFeatureName('Apply')}</h4>
<div className='text-sm italic text-void-fg-3 my-1'>We recommend the smartest model you{`'`}ve got, like Claude 3.7 or GPT 4o.</div>
<ModelDropdown featureName={'Apply'} />
</div>
</div>
</ErrorBoundary>
</>
@ -649,7 +668,7 @@ export const Settings = () => {
{/* content */}
<div className='w-full min-w-[600px] overflow-auto'>
<div className='w-full min-w-[550px]'>
<div className={`${tab !== 'models' ? 'hidden' : ''}`}>
<FeaturesTab />

View file

@ -9,6 +9,29 @@ module.exports = {
content: ['./src2/**/*.{jsx,tsx}'], // uses these files to decide how to transform the css file
theme: {
extend: {
typography: {
DEFAULT: {
css: {
'--tw-prose-body': 'var(--void-fg-1)',
'--tw-prose-headings': 'var(--void-fg-1)',
'--tw-prose-lead': 'var(--void-fg-2)',
'--tw-prose-links': 'var(--void-link-color)',
'--tw-prose-bold': 'var(--void-fg-1)',
'--tw-prose-counters': 'var(--void-fg-3)',
'--tw-prose-bullets': 'var(--void-fg-3)',
'--tw-prose-hr': 'var(--void-border-4)',
'--tw-prose-quotes': 'var(--void-fg-1)',
'--tw-prose-quote-borders': 'var(--void-border-2)',
'--tw-prose-captions': 'var(--void-fg-3)',
'--tw-prose-code': 'var(--void-fg-0)',
'--tw-prose-pre-code': 'var(--void-fg-0)',
'--tw-prose-pre-bg': 'var(--void-bg-1)',
'--tw-prose-th-borders': 'var(--void-border-4)',
'--tw-prose-td-borders': 'var(--void-border-4)',
},
},
},
fontSize: {
xs: '10px',
sm: '11px',
@ -27,146 +50,150 @@ module.exports = {
// common colors to use, ordered light to dark
colors: {
"void-bg-1": "var(--vscode-input-background)",
"void-bg-1-alt": "var(--vscode-badge-background)",
"void-bg-2": "var(--vscode-sideBar-background)",
"void-bg-2-alt": "color-mix(in srgb, var(--vscode-sideBar-background) 30%, var(--vscode-editor-background) 70%)",
"void-bg-3": "var(--vscode-editor-background)",
'void-bg-1': 'var(--void-bg-1)',
'void-bg-1-alt': 'var(--void-bg-1-alt)',
'void-bg-2': 'var(--void-bg-2)',
'void-bg-2-alt': 'var(--void-bg-2-alt)',
'void-bg-3': 'var(--void-bg-3)',
"void-fg-1": "var(--vscode-editor-foreground)",
"void-fg-2": "var(--vscode-input-foreground)",
"void-fg-3": "var(--vscode-input-placeholderForeground)",
// "void-fg-4": "var(--vscode-tab-inactiveForeground)",
"void-fg-4": "var(--vscode-list-deemphasizedForeground)",
'void-fg-0': 'var(--void-fg-0)',
'void-fg-1': 'var(--void-fg-1)',
'void-fg-2': 'var(--void-fg-2)',
'void-fg-3': 'var(--void-fg-3)',
// 'void-fg-4': 'var(--vscode-tab-inactiveForeground)',
'void-fg-4': 'var(--void-fg-4)',
'void-warning': 'var(--void-warning)',
"void-warning": "var(--vscode-charts-yellow)",
"void-border-1": "var(--vscode-commandCenter-activeBorder)",
"void-border-2": "var(--vscode-commandCenter-border)",
"void-border-3": "var(--vscode-commandCenter-inactiveBorder)",
"void-border-4": "var(--vscode-editorGroup-border)",
'void-border-1': 'var(--void-border-1)',
'void-border-2': 'var(--void-border-2)',
'void-border-3': 'var(--void-border-3)',
'void-border-4': 'var(--void-border-4)',
'void-ring-color': 'var(--void-ring-color)',
'void-link-color': 'var(--void-link-color)',
vscode: {
// see: https://code.visualstudio.com/api/extension-guides/webview#theming-webview-content
// base colors
"fg": "var(--vscode-foreground)",
"focus-border": "var(--vscode-focusBorder)",
"disabled-fg": "var(--vscode-disabledForeground)",
"widget-border": "var(--vscode-widget-border)",
"widget-shadow": "var(--vscode-widget-shadow)",
"selection-bg": "var(--vscode-selection-background)",
"description-fg": "var(--vscode-descriptionForeground)",
"error-fg": "var(--vscode-errorForeground)",
"icon-fg": "var(--vscode-icon-foreground)",
"sash-hover-border": "var(--vscode-sash-hoverBorder)",
'fg': 'var(--vscode-foreground)',
'focus-border': 'var(--vscode-focusBorder)',
'disabled-fg': 'var(--vscode-disabledForeground)',
'widget-border': 'var(--vscode-widget-border)',
'widget-shadow': 'var(--vscode-widget-shadow)',
'selection-bg': 'var(--vscode-selection-background)',
'description-fg': 'var(--vscode-descriptionForeground)',
'error-fg': 'var(--vscode-errorForeground)',
'icon-fg': 'var(--vscode-icon-foreground)',
'sash-hover-border': 'var(--vscode-sash-hoverBorder)',
// text colors
"text-blockquote-bg": "var(--vscode-textBlockQuote-background)",
"text-blockquote-border": "var(--vscode-textBlockQuote-border)",
"text-codeblock-bg": "var(--vscode-textCodeBlock-background)",
"text-link-active-fg": "var(--vscode-textLink-activeForeground)",
"text-link-fg": "var(--vscode-textLink-foreground)",
"text-preformat-fg": "var(--vscode-textPreformat-foreground)",
"text-preformat-bg": "var(--vscode-textPreformat-background)",
"text-separator-fg": "var(--vscode-textSeparator-foreground)",
'text-blockquote-bg': 'var(--vscode-textBlockQuote-background)',
'text-blockquote-border': 'var(--vscode-textBlockQuote-border)',
'text-codeblock-bg': 'var(--vscode-textCodeBlock-background)',
'text-link-active-fg': 'var(--vscode-textLink-activeForeground)',
'text-link-fg': 'var(--vscode-textLink-foreground)',
'text-preformat-fg': 'var(--vscode-textPreformat-foreground)',
'text-preformat-bg': 'var(--vscode-textPreformat-background)',
'text-separator-fg': 'var(--vscode-textSeparator-foreground)',
// input colors
"input-bg": "var(--vscode-input-background)",
"input-border": "var(--vscode-input-border)",
"input-fg": "var(--vscode-input-foreground)",
"input-placeholder-fg": "var(--vscode-input-placeholderForeground)",
"input-active-bg": "var(--vscode-input-activeBackground)",
"input-option-active-border": "var(--vscode-inputOption-activeBorder)",
"input-option-active-fg": "var(--vscode-inputOption-activeForeground)",
"input-option-hover-bg": "var(--vscode-inputOption-hoverBackground)",
"input-validation-error-bg": "var(--vscode-inputValidation-errorBackground)",
"input-validation-error-fg": "var(--vscode-inputValidation-errorForeground)",
"input-validation-error-border": "var(--vscode-inputValidation-errorBorder)",
"input-validation-info-bg": "var(--vscode-inputValidation-infoBackground)",
"input-validation-info-fg": "var(--vscode-inputValidation-infoForeground)",
"input-validation-info-border": "var(--vscode-inputValidation-infoBorder)",
"input-validation-warning-bg": "var(--vscode-inputValidation-warningBackground)",
"input-validation-warning-fg": "var(--vscode-inputValidation-warningForeground)",
"input-validation-warning-border": "var(--vscode-inputValidation-warningBorder)",
'input-bg': 'var(--vscode-input-background)',
'input-border': 'var(--vscode-input-border)',
'input-fg': 'var(--vscode-input-foreground)',
'input-placeholder-fg': 'var(--vscode-input-placeholderForeground)',
'input-active-bg': 'var(--vscode-input-activeBackground)',
'input-option-active-border': 'var(--vscode-inputOption-activeBorder)',
'input-option-active-fg': 'var(--vscode-inputOption-activeForeground)',
'input-option-hover-bg': 'var(--vscode-inputOption-hoverBackground)',
'input-validation-error-bg': 'var(--vscode-inputValidation-errorBackground)',
'input-validation-error-fg': 'var(--vscode-inputValidation-errorForeground)',
'input-validation-error-border': 'var(--vscode-inputValidation-errorBorder)',
'input-validation-info-bg': 'var(--vscode-inputValidation-infoBackground)',
'input-validation-info-fg': 'var(--vscode-inputValidation-infoForeground)',
'input-validation-info-border': 'var(--vscode-inputValidation-infoBorder)',
'input-validation-warning-bg': 'var(--vscode-inputValidation-warningBackground)',
'input-validation-warning-fg': 'var(--vscode-inputValidation-warningForeground)',
'input-validation-warning-border': 'var(--vscode-inputValidation-warningBorder)',
// command center colors (the top bar)
"commandcenter-fg": "var(--vscode-commandCenter-foreground)",
"commandcenter-active-fg": "var(--vscode-commandCenter-activeForeground)",
"commandcenter-bg": "var(--vscode-commandCenter-background)",
"commandcenter-active-bg": "var(--vscode-commandCenter-activeBackground)",
"commandcenter-border": "var(--vscode-commandCenter-border)",
"commandcenter-inactive-fg": "var(--vscode-commandCenter-inactiveForeground)",
"commandcenter-inactive-border": "var(--vscode-commandCenter-inactiveBorder)",
"commandcenter-active-border": "var(--vscode-commandCenter-activeBorder)",
"commandcenter-debugging-bg": "var(--vscode-commandCenter-debuggingBackground)",
'commandcenter-fg': 'var(--vscode-commandCenter-foreground)',
'commandcenter-active-fg': 'var(--vscode-commandCenter-activeForeground)',
'commandcenter-bg': 'var(--vscode-commandCenter-background)',
'commandcenter-active-bg': 'var(--vscode-commandCenter-activeBackground)',
'commandcenter-border': 'var(--vscode-commandCenter-border)',
'commandcenter-inactive-fg': 'var(--vscode-commandCenter-inactiveForeground)',
'commandcenter-inactive-border': 'var(--vscode-commandCenter-inactiveBorder)',
'commandcenter-active-border': 'var(--vscode-commandCenter-activeBorder)',
'commandcenter-debugging-bg': 'var(--vscode-commandCenter-debuggingBackground)',
// badge colors
"badge-fg": "var(--vscode-badge-foreground)",
"badge-bg": "var(--vscode-badge-background)",
'badge-fg': 'var(--vscode-badge-foreground)',
'badge-bg': 'var(--vscode-badge-background)',
// button colors
"button-bg": "var(--vscode-button-background)",
"button-fg": "var(--vscode-button-foreground)",
"button-border": "var(--vscode-button-border)",
"button-separator": "var(--vscode-button-separator)",
"button-hover-bg": "var(--vscode-button-hoverBackground)",
"button-secondary-fg": "var(--vscode-button-secondaryForeground)",
"button-secondary-bg": "var(--vscode-button-secondaryBackground)",
"button-secondary-hover-bg": "var(--vscode-button-secondaryHoverBackground)",
'button-bg': 'var(--vscode-button-background)',
'button-fg': 'var(--vscode-button-foreground)',
'button-border': 'var(--vscode-button-border)',
'button-separator': 'var(--vscode-button-separator)',
'button-hover-bg': 'var(--vscode-button-hoverBackground)',
'button-secondary-fg': 'var(--vscode-button-secondaryForeground)',
'button-secondary-bg': 'var(--vscode-button-secondaryBackground)',
'button-secondary-hover-bg': 'var(--vscode-button-secondaryHoverBackground)',
// checkbox colors
"checkbox-bg": "var(--vscode-checkbox-background)",
"checkbox-fg": "var(--vscode-checkbox-foreground)",
"checkbox-border": "var(--vscode-checkbox-border)",
"checkbox-select-bg": "var(--vscode-checkbox-selectBackground)",
'checkbox-bg': 'var(--vscode-checkbox-background)',
'checkbox-fg': 'var(--vscode-checkbox-foreground)',
'checkbox-border': 'var(--vscode-checkbox-border)',
'checkbox-select-bg': 'var(--vscode-checkbox-selectBackground)',
// sidebar colors
"sidebar-bg": "var(--vscode-sideBar-background)",
"sidebar-fg": "var(--vscode-sideBar-foreground)",
"sidebar-border": "var(--vscode-sideBar-border)",
"sidebar-drop-bg": "var(--vscode-sideBar-dropBackground)",
"sidebar-title-fg": "var(--vscode-sideBarTitle-foreground)",
"sidebar-header-bg": "var(--vscode-sideBarSectionHeader-background)",
"sidebar-header-fg": "var(--vscode-sideBarSectionHeader-foreground)",
"sidebar-header-border": "var(--vscode-sideBarSectionHeader-border)",
"sidebar-activitybartop-border": "var(--vscode-sideBarActivityBarTop-border)",
"sidebar-title-bg": "var(--vscode-sideBarTitle-background)",
"sidebar-title-border": "var(--vscode-sideBarTitle-border)",
"sidebar-stickyscroll-bg": "var(--vscode-sideBarStickyScroll-background)",
"sidebar-stickyscroll-border": "var(--vscode-sideBarStickyScroll-border)",
"sidebar-stickyscroll-shadow": "var(--vscode-sideBarStickyScroll-shadow)",
'sidebar-bg': 'var(--vscode-sideBar-background)',
'sidebar-fg': 'var(--vscode-sideBar-foreground)',
'sidebar-border': 'var(--vscode-sideBar-border)',
'sidebar-drop-bg': 'var(--vscode-sideBar-dropBackground)',
'sidebar-title-fg': 'var(--vscode-sideBarTitle-foreground)',
'sidebar-header-bg': 'var(--vscode-sideBarSectionHeader-background)',
'sidebar-header-fg': 'var(--vscode-sideBarSectionHeader-foreground)',
'sidebar-header-border': 'var(--vscode-sideBarSectionHeader-border)',
'sidebar-activitybartop-border': 'var(--vscode-sideBarActivityBarTop-border)',
'sidebar-title-bg': 'var(--vscode-sideBarTitle-background)',
'sidebar-title-border': 'var(--vscode-sideBarTitle-border)',
'sidebar-stickyscroll-bg': 'var(--vscode-sideBarStickyScroll-background)',
'sidebar-stickyscroll-border': 'var(--vscode-sideBarStickyScroll-border)',
'sidebar-stickyscroll-shadow': 'var(--vscode-sideBarStickyScroll-shadow)',
// other colors (these are partially complete)
// text formatting
"text-preformat-bg": "var(--vscode-textPreformat-background)",
"text-preformat-fg": "var(--vscode-textPreformat-foreground)",
'text-preformat-bg': 'var(--vscode-textPreformat-background)',
'text-preformat-fg': 'var(--vscode-textPreformat-foreground)',
// editor colors
"editor-bg": "var(--vscode-editor-background)",
"editor-fg": "var(--vscode-editor-foreground)",
'editor-bg': 'var(--vscode-editor-background)',
'editor-fg': 'var(--vscode-editor-foreground)',
// other
"editorwidget-bg": "var(--vscode-editorWidget-background)",
"toolbar-hover-bg": "var(--vscode-toolbar-hoverBackground)",
"toolbar-foreground": "var(--vscode-editorActionList-foreground)",
'editorwidget-bg': 'var(--vscode-editorWidget-background)',
'toolbar-hover-bg': 'var(--vscode-toolbar-hoverBackground)',
'toolbar-foreground': 'var(--vscode-editorActionList-foreground)',
"editorwidget-fg": "var(--vscode-editorWidget-foreground)",
"editorwidget-border": "var(--vscode-editorWidget-border)",
'editorwidget-fg': 'var(--vscode-editorWidget-foreground)',
'editorwidget-border': 'var(--vscode-editorWidget-border)',
"charts-orange": "var(--vscode-charts-orange)",
"charts-yellow": "var(--vscode-charts-yellow)",
'charts-orange': 'var(--vscode-charts-orange)',
'charts-yellow': 'var(--vscode-charts-yellow)',
},
},
},
},
plugins: [],
plugins: [
require('@tailwindcss/typography')
],
prefix: 'void-'
}

View file

@ -11,7 +11,7 @@ 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 '../common/chatThreadService.js';
import { StagingSelectionItem, IChatThreadService } from './chatThreadService.js';
import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';
import { IRange } from '../../../../editor/common/core/range.js';

View file

@ -0,0 +1,71 @@
/*--------------------------------------------------------------------------------------
* 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 { 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';
export interface ITerminalToolService {
readonly _serviceBrand: undefined;
createNewTerminal(terminalId: string): Promise<string>;
runCommand(command: string, terminalId?: string): Promise<void>;
focus(terminalId: string): Promise<void>;
}
export const ITerminalToolService = createDecorator<ITerminalToolService>('TerminalToolService');
export class TerminalToolService extends Disposable implements ITerminalToolService {
readonly _serviceBrand: undefined;
private terminalInstances: Record<string, ITerminalInstance> = {}
constructor(
@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();
}
const terminal = this.terminalInstances[terminalId];
if (!terminal) throw new Error(`Terminal with ID ${terminalId} does not exist`);
terminal.sendText(command, true);
return;
}
async focus(terminalId: string) {
const terminal = this.terminalInstances[terminalId];
if (!terminal) throw new Error(`That terminal was closed.`);
terminal.focus(true);
return;
}
}
registerSingleton(ITerminalToolService, TerminalToolService, InstantiationType.Eager);

View file

@ -0,0 +1,509 @@
import { CancellationToken } from '../../../../base/common/cancellation.js'
import { URI } from '../../../../base/common/uri.js'
import { IFileService } from '../../../../platform/files/common/files.js'
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'
import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'
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'
// 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<string>(toolNames)
export const isAToolName = (toolName: string): toolName is ToolName => {
const isAToolName = toolNamesSet.has(toolName)
return isAToolName
}
export const toolNamesThatRequireApproval = new Set<ToolName>(['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<ToolCallParams[T]> }
export type CallTool = { [T in ToolName]: (p: ToolCallParams[T]) => Promise<ToolResultType[T]> }
export type ToolResultToString = { [T in ToolName]: (p: ToolCallParams[T], result: ToolResultType[T]) => string }
// pagination info
const MAX_FILE_CHARS_PAGE = 50_000
const MAX_CHILDREN_URIs_PAGE = 500
const computeDirectoryResult = async (
fileService: IFileService,
rootURI: URI,
pageNumber: number = 1
): Promise<ToolResultType['list_dir']> => {
const stat = await fileService.resolve(rootURI, { resolveMetadata: false });
if (!stat.isDirectory) {
return { children: null, hasNextPage: false, hasPrevPage: false, itemsRemaining: 0 };
}
const originalChildrenLength = stat.children?.length ?? 0;
const fromChildIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1);
const toChildIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1; // INCLUSIVE
const listChildren = stat.children?.slice(fromChildIdx, toChildIdx + 1) ?? [];
const children: DirectoryItem[] = listChildren.map(child => ({
name: child.name,
uri: child.resource,
isDirectory: child.isDirectory,
isSymbolicLink: child.isSymbolicLink
}));
const hasNextPage = (originalChildrenLength - 1) > toChildIdx;
const hasPrevPage = pageNumber > 1;
const itemsRemaining = Math.max(0, originalChildrenLength - (toChildIdx + 1));
return {
children,
hasNextPage,
hasPrevPage,
itemsRemaining
};
};
const directoryResultToString = (params: ToolCallParams['list_dir'], result: ToolResultType['list_dir']): string => {
if (!result.children) {
return `Error: ${params.rootURI} is not a directory`;
}
let output = '';
const entries = result.children;
if (!result.hasPrevPage) {
output += `${params.rootURI}\n`;
}
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const isLast = i === entries.length - 1 && !result.hasNextPage;
const prefix = isLast ? '└── ' : '├── ';
output += `${prefix}${entry.name}${entry.isDirectory ? '/' : ''}${entry.isSymbolicLink ? ' (symbolic link)' : ''}\n`;
}
if (result.hasNextPage) {
output += `└── (${result.itemsRemaining} results remaining...)\n`;
}
return output;
};
const validateJSON = (s: string): { [s: string]: unknown } => {
try {
const o = JSON.parse(s)
return o
}
catch (e) {
throw new Error(`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.`)
return value
}
// TODO!!!! check to make sure in workspace
const validateURI = (uriStr: unknown) => {
if (typeof uriStr !== 'string') throw new Error('Error: provided uri must be a string.')
const uri = URI.file(uriStr)
return uri
}
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}".`)
return parsedInt
}
const validateRecursiveParamStr = (paramsUnknown: unknown) => {
if (typeof paramsUnknown !== 'string') throw new Error('Error calling tool: provided params must be a string.')
const params = paramsUnknown
const isRecursive = params.includes('r')
return isRecursive
}
export interface IToolsService {
readonly _serviceBrand: undefined;
validateParams: ValidateParams;
callTool: CallTool;
stringOfResult: ToolResultToString;
}
export const IToolsService = createDecorator<IToolsService>('ToolsService');
export class ToolsService implements IToolsService {
readonly _serviceBrand: undefined;
public validateParams: ValidateParams;
public callTool: CallTool;
public stringOfResult: ToolResultToString;
constructor(
@IFileService fileService: IFileService,
@IWorkspaceContextService workspaceContextService: IWorkspaceContextService,
@ISearchService searchService: ISearchService,
@IInstantiationService instantiationService: IInstantiationService,
@IVoidFileService voidFileService: IVoidFileService,
@IEditCodeService editCodeService: IEditCodeService,
// @ITerminalToolService private readonly terminalToolService: ITerminalToolService,
) {
const queryBuilder = instantiationService.createInstance(QueryBuilder);
this.validateParams = {
read_file: async (params: string) => {
const o = validateJSON(params)
const { uri: uriStr, pageNumber: pageNumberUnknown } = o
const uri = validateURI(uriStr)
const pageNumber = validatePageNum(pageNumberUnknown)
return { uri, pageNumber }
},
list_dir: async (params: string) => {
const o = validateJSON(params)
const { uri: uriStr, pageNumber: pageNumberUnknown } = o
const uri = validateURI(uriStr)
const pageNumber = validatePageNum(pageNumberUnknown)
return { rootURI: uri, pageNumber }
},
pathname_search: async (params: string) => {
const o = validateJSON(params)
const { query: queryUnknown, pageNumber: pageNumberUnknown } = o
const queryStr = validateStr('query', queryUnknown)
const pageNumber = validatePageNum(pageNumberUnknown)
return { queryStr, pageNumber }
},
search: async (params: string) => {
const o = validateJSON(params)
const { query: queryUnknown, pageNumber: pageNumberUnknown } = o
const queryStr = validateStr('query', queryUnknown)
const pageNumber = validatePageNum(pageNumberUnknown)
return { queryStr, pageNumber }
},
// ---
create_uri: async (params: string) => {
const o = validateJSON(params)
const { uri: uriStr } = o
const uri = validateURI(uriStr)
return { uri }
},
delete_uri: async (params: string) => {
const o = validateJSON(params)
const { uri: uriStr, params: paramsStr } = o
const uri = validateURI(uriStr)
const isRecursive = validateRecursiveParamStr(paramsStr)
return { uri, isRecursive }
},
edit: async (params: string) => {
console.log('validating edit!!!')
const o = validateJSON(params)
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 = validateStr('command', commandUnknown)
return { command }
},
}
this.callTool = {
read_file: async ({ uri, pageNumber }) => {
const readFileContents = await voidFileService.readFile(uri)
const fromIdx = MAX_FILE_CHARS_PAGE * (pageNumber - 1)
const toIdx = MAX_FILE_CHARS_PAGE * pageNumber - 1
const fileContents = readFileContents.slice(fromIdx, toIdx + 1) || '(empty)' // paginate
const hasNextPage = (readFileContents.length - 1) - toIdx >= 1
return { fileContents, hasNextPage }
},
list_dir: async ({ rootURI, pageNumber }) => {
const dirResult = await computeDirectoryResult(fileService, rootURI, pageNumber)
return dirResult
},
pathname_search: async ({ queryStr, pageNumber }) => {
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)
const toIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1
const uris = data.results
.slice(fromIdx, toIdx + 1) // paginate
.map(({ resource, results }) => resource)
const hasNextPage = (data.results.length - 1) - toIdx >= 1
return { uris, hasNextPage }
},
search: async ({ queryStr, pageNumber }) => {
const query = queryBuilder.text({ pattern: queryStr, }, workspaceContextService.getWorkspace().folders.map(f => f.uri))
const data = await searchService.textSearch(query, CancellationToken.None)
const fromIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1)
const toIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1
const uris = data.results
.slice(fromIdx, toIdx + 1) // paginate
.map(({ resource, results }) => resource)
const hasNextPage = (data.results.length - 1) - toIdx >= 1
return { queryStr, uris, hasNextPage }
},
// ---
create_uri: async ({ uri }) => {
await fileService.createFile(uri)
return {}
},
delete_uri: async ({ uri, isRecursive }) => {
await fileService.del(uri, { recursive: isRecursive })
return {}
},
edit: async ({ uri, changeDescription }) => {
console.log('editing!!!!')
const [_, p] = editCodeService.startApplying({
uri,
applyStr: changeDescription,
from: 'ClickApply',
type: 'searchReplace',
}) ?? []
console.log('B')
await p
return {}
},
terminal_command: async ({ command }) => {
// TODO!!!!
// await // Await user confirmation and then command execution before resolving
return {}
},
}
const nextPageStr = (hasNextPage: boolean) => hasNextPage ? '\n\n(more on next page...)' : ''
// given to the LLM after the call
this.stringOfResult = {
read_file: (params, result) => {
return result.fileContents + nextPageStr(result.hasNextPage)
},
list_dir: (params, result) => {
const dirTreeStr = directoryResultToString(params, result)
return dirTreeStr + nextPageStr(result.hasNextPage)
},
pathname_search: (params, result) => {
return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage)
},
search: (params, result) => {
return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage)
},
// ---
create_uri: (params, result) => {
return `URI ${params.uri.fsPath} successfully created.`
},
delete_uri: (params, result) => {
return `URI ${params.uri.fsPath} successfully deleted.`
},
edit: (params, result) => {
return `Change successfully made ${params.uri.fsPath} successfully deleted.`
},
terminal_command: (params, result) => {
return `Terminal command "${params.command}" successfully executed.`
},
}
}
}
registerSingleton(IToolsService, ToolsService, InstantiationType.Eager);

View file

@ -53,8 +53,8 @@ import '../common/metricsService.js'
import '../common/voidUpdateService.js'
// tools
import '../common/toolsService.js'
import './toolsService.js'
// register Thread History
import '../common/chatThreadService.js'
import './chatThreadService.js'

View file

@ -116,7 +116,7 @@ export class LLMMessageService extends Disposable implements ILLMMessageService
this.llmMessageHooks.onError[requestId] = onError
const { aiInstructions } = this.voidSettingsService.state.globalSettings
const { settingsOfProvider } = this.voidSettingsService.state
const { settingsOfProvider, optionsOfModelSelection, } = this.voidSettingsService.state
// params will be stripped of all its functions over the IPC channel
this.channel.call('sendLLMMessage', {
@ -126,6 +126,7 @@ export class LLMMessageService extends Disposable implements ILLMMessageService
providerName,
modelName,
settingsOfProvider,
optionsOfModelSelection,
} satisfies MainSendLLMMessageParams);
return requestId

View file

@ -3,9 +3,9 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { ChatMessage } from './chatThreadService.js'
import { InternalToolInfo, ToolName } from './toolsService.js'
import { FeatureName, ProviderName, SettingsOfProvider } from './voidSettingsTypes.js'
import type { ChatMessage } from '../browser/chatThreadService.js'
import type { InternalToolInfo, ToolName } from '../browser/toolsService.js'
import { FeatureName, OptionsOfModelSelection, ProviderName, SettingsOfProvider } from './voidSettingsTypes.js'
export const errorDetails = (fullError: Error | null): string | null => {
@ -22,13 +22,19 @@ export const errorDetails = (fullError: Error | null): string | null => {
return null
}
export const getErrorMessage: (error: unknown) => string = (error) => {
if (error instanceof Error) return `${error.name}: ${error.message}`
return error + ''
}
export type LLMChatMessage = {
role: 'system' | 'user';
content: string;
} | {
role: 'assistant',
content: string;
content: string; // text content
rawAnthropicAssistantContent?: RawAnthropicAssistantContent[]; // used for anthropic signing
} | {
role: 'tool';
content: string; // result
@ -40,27 +46,31 @@ export type LLMChatMessage = {
export type ToolCallType = {
name: ToolName;
params: string;
paramsStr: string;
id: string;
}
export type RawAnthropicAssistantContent = { type: 'thinking'; thinking: string; signature: string; } | { type: 'redacted_thinking'; data: string } | { type: 'text', text: string }
export type OnText = (p: { fullText: string; fullReasoning: string }) => void
export type OnFinalMessage = (p: { fullText: string, toolCalls?: ToolCallType[], fullReasoning?: string }) => void // id is tool_use_id
export type OnFinalMessage = (p: { fullText: string, toolCalls?: ToolCallType[], fullReasoning?: string, rawAnthropicAssistantContent?: RawAnthropicAssistantContent[] }) => void // id is tool_use_id
export type OnError = (p: { message: string, fullError: Error | null }) => void
export type AbortRef = { current: (() => void) | null }
export const toLLMChatMessage = (c: ChatMessage): LLMChatMessage => {
export const toLLMChatMessage = (c: ChatMessage): LLMChatMessage | null => {
if (c.role === 'user') {
return { role: c.role, content: c.content || '(empty message)' }
}
else if (c.role === 'assistant')
return { role: c.role, content: c.content || '(empty message)' }
else if (c.role === 'tool')
return { role: c.role, id: c.id, name: c.name, params: c.params, content: c.content || '(empty output)' }
return { role: c.role, id: c.id, name: c.name, params: c.paramsStr, content: c.content || '(empty output)' }
else if (c.role === 'tool_request')
return null
else {
throw 1
throw new Error(`Role ${(c as any).role} not recognized.`)
}
}
@ -103,6 +113,7 @@ export type SendLLMMessageParams = {
providerName: ProviderName;
modelName: string;
settingsOfProvider: SettingsOfProvider;
optionsOfModelSelection: OptionsOfModelSelection;
} & SendLLMType

View file

@ -3,12 +3,11 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { createDecorator, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js';
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js';
import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js';
import { localize2 } from '../../../../nls.js';
import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js';
import { registerAction2, Action2 } from '../../../../platform/actions/common/actions.js';
import { INotificationService } from '../../../../platform/notification/common/notification.js';

View file

@ -0,0 +1,643 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { OptionsOfModelSelection, ProviderName } from './voidSettingsTypes.js';
export const defaultModelsOfProvider = {
openAI: [ // https://platform.openai.com/docs/models/gp
'o3-mini',
'o1',
'o1-mini',
'gpt-4o',
'gpt-4o-mini',
],
anthropic: [ // https://docs.anthropic.com/en/docs/about-claude/models
'claude-3-7-sonnet-latest',
'claude-3-5-sonnet-latest',
'claude-3-5-haiku-latest',
'claude-3-opus-latest',
],
xAI: [ // https://docs.x.ai/docs/models?cluster=us-east-1
'grok-2-latest',
'grok-3-latest',
],
gemini: [ // https://ai.google.dev/gemini-api/docs/models/gemini
'gemini-2.0-flash',
'gemini-1.5-flash',
'gemini-1.5-pro',
'gemini-1.5-flash-8b',
'gemini-2.0-flash-thinking-exp',
],
deepseek: [ // https://api-docs.deepseek.com/quick_start/pricing
'deepseek-chat',
'deepseek-reasoner',
],
ollama: [ // autodetected
],
vLLM: [ // autodetected
],
openRouter: [ // https://openrouter.ai/models
'anthropic/claude-3.5-sonnet',
'deepseek/deepseek-r1',
'mistralai/codestral-2501',
'qwen/qwen-2.5-coder-32b-instruct',
],
groq: [ // https://console.groq.com/docs/models
'qwen-qwq-32b',
'llama-3.3-70b-versatile',
'llama-3.1-8b-instant',
// 'qwen-2.5-coder-32b', // preview mode (experimental)
],
// not supporting mistral right now- it's last on Void usage, and a huge pain to set up since it's nonstandard (it supports codestral FIM but it's on v1/fim/completions, etc)
// mistral: [ // https://docs.mistral.ai/getting-started/models/models_overview/
// 'codestral-latest',
// 'mistral-large-latest',
// 'ministral-3b-latest',
// 'ministral-8b-latest',
// ],
openAICompatible: [], // fallback
} as const satisfies Record<ProviderName, string[]>
type ModelOptions = {
contextWindow: number; // input tokens // <-- UNUSED
maxOutputTokens: number | null; // output tokens // <-- UNUSED
cost: { // <-- UNUSED
input: number;
output: number;
cache_read?: number;
cache_write?: number;
}
supportsSystemMessage: false | 'system-role' | 'developer-role' | 'separated';
supportsTools: false | 'anthropic-style' | 'openai-style';
supportsFIM: boolean;
supportsReasoning: false | {
// 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 reasoningMaxOutputTokens?: number; // overrides normal maxOutputTokens // <-- UNUSED (except anthropic)
readonly reasoningBudgetSlider?: { type: 'slider'; min: number; max: number; default: number };
// options related specifically to model output
// you are allowed to not include openSourceThinkTags if it's not open source (no such cases as of writing)
// if it's open source, put the think tags here so we parse them out in e.g. ollama
readonly openSourceThinkTags?: [string, string];
};
}
type ProviderReasoningIOSettings = {
// include this in payload to get reasoning
input?: { includeInPayload?: { [key: string]: any }, };
// nameOfFieldInDelta: reasoning output is in response.choices[0].delta[deltaReasoningField]
// needsManualParse: whether we must manually parse out the <think> tags
output?:
| { nameOfFieldInDelta?: string, needsManualParse?: undefined, }
| { nameOfFieldInDelta?: undefined, needsManualParse?: true, };
}
type ProviderSettings = {
providerReasoningIOSettings?: ProviderReasoningIOSettings; // input/output settings around thinking (allowed to be empty) - only applied if the model supports reasoning output
modelOptions: { [key: string]: ModelOptions };
modelOptionsFallback: (modelName: string) => (ModelOptions & { modelName: string }) | null;
}
const modelOptionsDefaults: ModelOptions = {
contextWindow: 32_000, // unused
maxOutputTokens: null, // unused
cost: { input: 0, output: 0 }, // unused
supportsSystemMessage: false,
supportsTools: false,
supportsFIM: false,
supportsReasoning: false,
}
const openSourceModelOptions_assumingOAICompat = {
'deepseekR1': {
supportsFIM: false,
supportsSystemMessage: false,
supportsTools: false,
supportsReasoning: { canToggleReasoning: false, canIOReasoning: true, openSourceThinkTags: ['<think>', '</think>'] },
},
'deepseekCoderV2': {
supportsFIM: false,
supportsSystemMessage: false, // unstable
supportsTools: false,
supportsReasoning: false,
},
'codestral': {
supportsFIM: true,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
},
// llama
'llama3': {
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
},
'llama3.1': {
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
},
'llama3.2': {
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
},
'llama3.3': {
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
},
// qwen
'qwen2.5coder': {
supportsFIM: true,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
},
'qwq': {
supportsFIM: false, // no FIM, yes reasoning
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: { canToggleReasoning: false, canIOReasoning: true, openSourceThinkTags: ['<think>', '</think>'] },
},
// FIM only
'starcoder2': {
supportsFIM: true,
supportsSystemMessage: false,
supportsTools: false,
supportsReasoning: false,
},
'codegemma:2b': {
supportsFIM: true,
supportsSystemMessage: false,
supportsTools: false,
supportsReasoning: false,
},
} as const satisfies { [s: string]: Partial<ModelOptions> }
const extensiveModelFallback: ProviderSettings['modelOptionsFallback'] = (modelName) => {
const toFallback = (opts: Omit<ModelOptions, 'cost'>): ModelOptions & { modelName: string } => {
return {
modelName,
...opts,
supportsSystemMessage: opts.supportsSystemMessage ? 'system-role' : false,
cost: { input: 0, output: 0 },
}
}
if (modelName.includes('gpt-4o')) return toFallback(openAIModelOptions['gpt-4o'])
if (modelName.includes('claude-3-5') || modelName.includes('claude-3.5')) return toFallback(anthropicModelOptions['claude-3-5-sonnet-20241022'])
if (modelName.includes('claude')) return toFallback(anthropicModelOptions['claude-3-7-sonnet-20250219'])
if (modelName.includes('grok')) return toFallback(xAIModelOptions['grok-2'])
if (modelName.includes('deepseek-r1') || modelName.includes('deepseek-reasoner')) return toFallback({ ...openSourceModelOptions_assumingOAICompat.deepseekR1, contextWindow: 32_000, maxOutputTokens: 4_096, })
if (modelName.includes('deepseek')) return toFallback({ ...openSourceModelOptions_assumingOAICompat.deepseekCoderV2, contextWindow: 32_000, maxOutputTokens: 4_096, })
if (modelName.includes('llama3')) return toFallback({ ...openSourceModelOptions_assumingOAICompat.llama3, contextWindow: 32_000, maxOutputTokens: 4_096, })
if (modelName.includes('qwen') && modelName.includes('2.5') && modelName.includes('coder')) return toFallback({ ...openSourceModelOptions_assumingOAICompat['qwen2.5coder'], contextWindow: 32_000, maxOutputTokens: 4_096, })
if (modelName.includes('codestral')) return toFallback({ ...openSourceModelOptions_assumingOAICompat.codestral, contextWindow: 32_000, maxOutputTokens: 4_096, })
if (/\bo1\b/.test(modelName) || /\bo3\b/.test(modelName)) return toFallback(openAIModelOptions['o1'])
return toFallback(modelOptionsDefaults)
}
// ---------------- ANTHROPIC ----------------
const anthropicModelOptions = {
'claude-3-7-sonnet-20250219': { // https://docs.anthropic.com/en/docs/about-claude/models/all-models#model-comparison-table
contextWindow: 200_000,
maxOutputTokens: 8_192,
cost: { input: 3.00, cache_read: 0.30, cache_write: 3.75, output: 15.00 },
supportsFIM: false,
supportsSystemMessage: 'separated',
supportsTools: 'anthropic-style',
supportsReasoning: {
canToggleReasoning: 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
},
},
'claude-3-5-sonnet-20241022': {
contextWindow: 200_000,
maxOutputTokens: 8_192,
cost: { input: 3.00, cache_read: 0.30, cache_write: 3.75, output: 15.00 },
supportsFIM: false,
supportsSystemMessage: 'separated',
supportsTools: 'anthropic-style',
supportsReasoning: false,
},
'claude-3-5-haiku-20241022': {
contextWindow: 200_000,
maxOutputTokens: 8_192,
cost: { input: 0.80, cache_read: 0.08, cache_write: 1.00, output: 4.00 },
supportsFIM: false,
supportsSystemMessage: 'separated',
supportsTools: 'anthropic-style',
supportsReasoning: false,
},
'claude-3-opus-20240229': {
contextWindow: 200_000,
maxOutputTokens: 4_096,
cost: { input: 15.00, cache_read: 1.50, cache_write: 18.75, output: 75.00 },
supportsFIM: false,
supportsSystemMessage: 'separated',
supportsTools: 'anthropic-style',
supportsReasoning: 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 },
maxOutputTokens: 4_096,
supportsFIM: false,
supportsSystemMessage: 'separated',
supportsTools: 'anthropic-style',
supportsReasoning: false,
}
} as const satisfies { [s: string]: ModelOptions }
const anthropicSettings: ProviderSettings = {
modelOptions: anthropicModelOptions,
modelOptionsFallback: (modelName) => {
let fallbackName: keyof typeof anthropicModelOptions | null = null
if (modelName.includes('claude-3-7-sonnet')) fallbackName = 'claude-3-7-sonnet-20250219'
if (modelName.includes('claude-3-5-sonnet')) fallbackName = 'claude-3-5-sonnet-20241022'
if (modelName.includes('claude-3-5-haiku')) fallbackName = 'claude-3-5-haiku-20241022'
if (modelName.includes('claude-3-opus')) fallbackName = 'claude-3-opus-20240229'
if (modelName.includes('claude-3-sonnet')) fallbackName = 'claude-3-sonnet-20240229'
if (fallbackName) return { modelName: fallbackName, ...anthropicModelOptions[fallbackName] }
return { modelName, ...modelOptionsDefaults, maxOutputTokens: 4_096 }
}
}
// ---------------- OPENAI ----------------
const openAIModelOptions = { // https://platform.openai.com/docs/pricing
'o1': {
contextWindow: 128_000,
maxOutputTokens: 100_000,
cost: { input: 15.00, cache_read: 7.50, output: 60.00, },
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
},
'o3-mini': {
contextWindow: 200_000,
maxOutputTokens: 100_000,
cost: { input: 1.10, cache_read: 0.55, output: 4.40, },
supportsFIM: false,
supportsTools: false,
supportsSystemMessage: 'developer-role',
supportsReasoning: { canIOReasoning: false, canToggleReasoning: false },
},
'gpt-4o': {
contextWindow: 128_000,
maxOutputTokens: 16_384,
cost: { input: 2.50, cache_read: 1.25, output: 10.00, },
supportsFIM: false,
supportsTools: 'openai-style',
supportsSystemMessage: 'system-role',
supportsReasoning: false,
},
'o1-mini': {
contextWindow: 128_000,
maxOutputTokens: 65_536,
cost: { input: 1.10, cache_read: 0.55, output: 4.40, },
supportsFIM: false,
supportsTools: false,
supportsSystemMessage: false, // does not support any system
supportsReasoning: { canIOReasoning: false, canToggleReasoning: false },
},
'gpt-4o-mini': {
contextWindow: 128_000,
maxOutputTokens: 16_384,
cost: { input: 0.15, cache_read: 0.075, output: 0.60, },
supportsFIM: false,
supportsTools: 'openai-style',
supportsSystemMessage: 'system-role', // ??
supportsReasoning: false,
},
} as const satisfies { [s: string]: ModelOptions }
const openAISettings: ProviderSettings = {
modelOptions: openAIModelOptions,
modelOptionsFallback: (modelName) => {
let fallbackName: keyof typeof openAIModelOptions | null = null
if (modelName.includes('o1')) { fallbackName = 'o1' }
if (modelName.includes('o3-mini')) { fallbackName = 'o3-mini' }
if (modelName.includes('gpt-4o')) { fallbackName = 'gpt-4o' }
if (fallbackName) return { modelName: fallbackName, ...openAIModelOptions[fallbackName] }
return null
}
}
// ---------------- XAI ----------------
const xAIModelOptions = {
'grok-2': {
contextWindow: 131_072,
maxOutputTokens: null, // 131_072,
cost: { input: 2.00, output: 10.00 },
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
},
} as const satisfies { [s: string]: ModelOptions }
const xAISettings: ProviderSettings = {
modelOptions: xAIModelOptions,
modelOptionsFallback: (modelName) => {
let fallbackName: keyof typeof xAIModelOptions | null = null
if (modelName.includes('grok-2')) fallbackName = 'grok-2'
if (fallbackName) return { modelName: fallbackName, ...xAIModelOptions[fallbackName] }
return null
}
}
// ---------------- GEMINI ----------------
const geminiModelOptions = { // https://ai.google.dev/gemini-api/docs/pricing
'gemini-2.0-flash': {
contextWindow: 1_048_576,
maxOutputTokens: null, // 8_192,
cost: { input: 0.10, output: 0.40 },
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style', // we are assuming OpenAI SDK when calling gemini
supportsReasoning: false,
},
'gemini-2.0-flash-lite-preview-02-05': {
contextWindow: 1_048_576,
maxOutputTokens: null, // 8_192,
cost: { input: 0.075, output: 0.30 },
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
},
'gemini-1.5-flash': {
contextWindow: 1_048_576,
maxOutputTokens: null, // 8_192,
cost: { input: 0.075, output: 0.30 }, // TODO!!! price doubles after 128K tokens, we are NOT encoding that info right now
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
},
'gemini-1.5-pro': {
contextWindow: 2_097_152,
maxOutputTokens: null, // 8_192,
cost: { input: 1.25, output: 5.00 }, // TODO!!! price doubles after 128K tokens, we are NOT encoding that info right now
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
},
'gemini-1.5-flash-8b': {
contextWindow: 1_048_576,
maxOutputTokens: null, // 8_192,
cost: { input: 0.0375, output: 0.15 }, // TODO!!! price doubles after 128K tokens, we are NOT encoding that info right now
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
},
} as const satisfies { [s: string]: ModelOptions }
const geminiSettings: ProviderSettings = {
modelOptions: geminiModelOptions,
modelOptionsFallback: (modelName) => { return null }
}
// ---------------- DEEPSEEK API ----------------
const deepseekModelOptions = {
'deepseek-chat': {
...openSourceModelOptions_assumingOAICompat.deepseekR1,
contextWindow: 64_000, // https://api-docs.deepseek.com/quick_start/pricing
maxOutputTokens: null, // 8_000,
cost: { cache_read: .07, input: .27, output: 1.10, },
},
'deepseek-reasoner': {
...openSourceModelOptions_assumingOAICompat.deepseekCoderV2,
contextWindow: 64_000,
maxOutputTokens: null, // 8_000,
cost: { cache_read: .14, input: .55, output: 2.19, },
},
} as const satisfies { [s: string]: ModelOptions }
const deepseekSettings: ProviderSettings = {
modelOptions: deepseekModelOptions,
providerReasoningIOSettings: {
// reasoning: OAICompat + response.choices[0].delta.reasoning_content // https://api-docs.deepseek.com/guides/reasoning_model
output: { nameOfFieldInDelta: 'reasoning_content' },
},
modelOptionsFallback: (modelName) => { return null }
}
// ---------------- GROQ ----------------
const groqModelOptions = { // https://console.groq.com/docs/models, https://groq.com/pricing/
'llama-3.3-70b-versatile': {
contextWindow: 128_000,
maxOutputTokens: null, // 32_768,
cost: { input: 0.59, output: 0.79 },
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
},
'llama-3.1-8b-instant': {
contextWindow: 128_000,
maxOutputTokens: null, // 8_192,
cost: { input: 0.05, output: 0.08 },
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
},
'qwen-2.5-coder-32b': {
contextWindow: 128_000,
maxOutputTokens: null, // not specified?
cost: { input: 0.79, output: 0.79 },
supportsFIM: false, // unfortunately looks like no FIM support on groq
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
},
'qwen-qwq-32b': { // https://huggingface.co/Qwen/QwQ-32B
contextWindow: 128_000,
maxOutputTokens: null, // not specified?
cost: { input: 0.29, output: 0.39 },
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: { canIOReasoning: true, canToggleReasoning: false, openSourceThinkTags: ['<think>', '</think>'] }, // 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
modelOptions: groqModelOptions,
modelOptionsFallback: (modelName) => { return null }
}
// ---------------- VLLM, OLLAMA, OPENAICOMPAT (self-hosted / local) ----------------
const vLLMSettings: ProviderSettings = {
// reasoning: OAICompat + response.choices[0].delta.reasoning_content // https://docs.vllm.ai/en/stable/features/reasoning_outputs.html#streaming-chat-completions
providerReasoningIOSettings: { output: { nameOfFieldInDelta: 'reasoning_content' }, },
modelOptionsFallback: (modelName) => extensiveModelFallback(modelName),
modelOptions: {},
}
const ollamaSettings: ProviderSettings = {
// reasoning: we need to filter out reasoning <think> tags manually
providerReasoningIOSettings: { output: { needsManualParse: true }, },
modelOptionsFallback: (modelName) => extensiveModelFallback(modelName),
modelOptions: {},
}
const openaiCompatible: ProviderSettings = {
// reasoning: we have no idea what endpoint they used, so we can't consistently parse out reasoning
modelOptionsFallback: (modelName) => extensiveModelFallback(modelName),
modelOptions: {},
}
// ---------------- OPENROUTER ----------------
const openRouterModelOptions_assumingOpenAICompat = {
'deepseek/deepseek-r1': {
...openSourceModelOptions_assumingOAICompat.deepseekR1,
contextWindow: 128_000,
maxOutputTokens: null,
cost: { input: 0.8, output: 2.4 },
},
'anthropic/claude-3.7-sonnet': {
contextWindow: 200_000,
maxOutputTokens: null,
cost: { input: 3.00, output: 15.00 },
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: { canIOReasoning: true, canToggleReasoning: false }, // TODO!!! false for now
},
'anthropic/claude-3.5-sonnet': {
contextWindow: 200_000,
maxOutputTokens: null,
cost: { input: 3.00, output: 15.00 },
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
},
'mistralai/codestral-2501': {
...openSourceModelOptions_assumingOAICompat.codestral,
contextWindow: 256_000,
maxOutputTokens: null,
cost: { input: 0.3, output: 0.9 },
supportsTools: 'openai-style',
supportsReasoning: false,
},
'qwen/qwen-2.5-coder-32b-instruct': {
...openSourceModelOptions_assumingOAICompat['qwen2.5coder'],
contextWindow: 33_000,
maxOutputTokens: null,
supportsTools: false, // openrouter qwen doesn't seem to support tools...?
cost: { input: 0.07, output: 0.16 },
},
'qwen/qwq-32b': {
...openSourceModelOptions_assumingOAICompat['qwq'],
contextWindow: 33_000,
maxOutputTokens: null,
supportsTools: false, // openrouter qwen doesn't seem to support tools...?
cost: { input: 0.07, output: 0.16 },
}
} as const satisfies { [s: string]: ModelOptions }
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 } },
output: { nameOfFieldInDelta: 'reasoning' },
},
modelOptions: openRouterModelOptions_assumingOpenAICompat,
// TODO!!! send a query to openrouter to get the price, etc.
modelOptionsFallback: (modelName) => extensiveModelFallback(modelName),
}
// ---------------- model settings of everything above ----------------
const modelSettingsOfProvider: { [providerName in ProviderName]: ProviderSettings } = {
openAI: openAISettings,
anthropic: anthropicSettings,
xAI: xAISettings,
gemini: geminiSettings,
// open source models
deepseek: deepseekSettings,
groq: groqSettings,
// open source models + providers (mixture of everything)
openRouter: openRouterSettings,
vLLM: vLLMSettings,
ollama: ollamaSettings,
openAICompatible: openaiCompatible,
// googleVertex: {},
// microsoftAzure: {},
} as const
// ---------------- exports ----------------
// returns the capabilities and the adjusted modelName if it was a fallback
export const getModelCapabilities = (providerName: ProviderName, modelName: string): ModelOptions & { modelName: string; isUnrecognizedModel: boolean } => {
const { modelOptions, modelOptionsFallback } = modelSettingsOfProvider[providerName]
if (modelName in modelOptions) return { modelName, ...modelOptions[modelName], isUnrecognizedModel: false }
const result = modelOptionsFallback(modelName)
if (result) return { ...result, isUnrecognizedModel: false }
return { modelName, ...modelOptionsDefaults, isUnrecognizedModel: true }
}
// non-model settings
export const getProviderCapabilities = (providerName: ProviderName) => {
const { providerReasoningIOSettings } = modelSettingsOfProvider[providerName]
return { providerReasoningIOSettings }
}
// state from optionsOfModelSelection
export const getModelSelectionState = (providerName: ProviderName, modelName: string, optionsOfModelSelection: OptionsOfModelSelection): { isReasoningEnabled: boolean, reasoningBudget: number | undefined } => {
const { canToggleReasoning } = getModelCapabilities(providerName, modelName).supportsReasoning || {}
const defaultEnabledVal = canToggleReasoning ? true : false
const isReasoningEnabled = optionsOfModelSelection[providerName]?.[modelName]?.reasoningEnabled ?? defaultEnabledVal
const reasoningBudget = optionsOfModelSelection[providerName]?.[modelName]?.reasoningBudget
return { isReasoningEnabled, reasoningBudget }
}

View file

@ -1,388 +0,0 @@
import { CancellationToken } from '../../../../base/common/cancellation.js'
import { URI } from '../../../../base/common/uri.js'
import { IFileService } from '../../../../platform/files/common/files.js'
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'
import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'
import { QueryBuilder } from '../../../../workbench/services/search/common/queryBuilder.js'
import { ISearchService } from '../../../../workbench/services/search/common/search.js'
import { IVoidFileService } from './voidFileService.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 = {
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'],
},
// create_file: {
// name: 'create_file',
// description: `Creates a file at the given path. Fails gracefully if the file already exists by doing nothing.`,
// params: {
// uri: { type: 'string', description: undefined },
// },
// required: ['uri'],
// },
// create_folder: {
// name: 'create_folder',
// description: `Creates a folder at the given path. Fails gracefully if the folder already exists by doing nothing.`,
// params: {
// uri: { type: 'string', description: undefined },
// },
// required: ['uri'],
// },
// go_to_definition: {
// },
// go_to_usages:
// create_file: {
// name: 'create_file',
// description: `Creates a file at the given path. Fails gracefully if the file already exists by doing nothing.`
// params: {
// uri: { type: 'string', description: undefined },
// }
// }
// semantic_search: {
// description: 'Searches files semantically for the given string query.',
// // RAG
// },
} satisfies { [name: string]: InternalToolInfo }
export type ToolName = keyof typeof voidTools
export const toolNames = Object.keys(voidTools) as ToolName[]
const toolNamesSet = new Set<string>(toolNames)
export const isAToolName = (toolName: string): toolName is ToolName => {
const isAToolName = toolNamesSet.has(toolName)
return isAToolName
}
export type ToolParamNames<T extends ToolName> = keyof typeof voidTools[T]['params']
export type ToolParamsObj<T extends ToolName> = { [paramName in ToolParamNames<T>]: unknown }
export type ToolCallReturnType = {
'read_file': { uri: URI, fileContents: string, hasNextPage: boolean },
'list_dir': { rootURI: URI, children: DirectoryItem[] | null, hasNextPage: boolean, hasPrevPage: boolean, itemsRemaining: number },
'pathname_search': { queryStr: string, uris: URI[] | string, hasNextPage: boolean },
'search': { queryStr: string, uris: URI[] | string, hasNextPage: boolean }
'create_file': {}
}
type DirectoryItem = {
uri: URI;
name: string;
isDirectory: boolean;
isSymbolicLink: boolean;
}
export type ToolFns = { [T in ToolName]: (p: string) => Promise<ToolCallReturnType[T]> }
export type ToolResultToString = { [T in ToolName]: (result: ToolCallReturnType[T]) => string }
// pagination info
const MAX_FILE_CHARS_PAGE = 50_000
const MAX_CHILDREN_URIs_PAGE = 500
const computeDirectoryResult = async (
fileService: IFileService,
rootURI: URI,
pageNumber: number = 1
): Promise<ToolCallReturnType['list_dir']> => {
const stat = await fileService.resolve(rootURI, { resolveMetadata: false });
if (!stat.isDirectory) {
return { rootURI, children: null, hasNextPage: false, hasPrevPage: false, itemsRemaining: 0 };
}
const originalChildrenLength = stat.children?.length ?? 0;
const fromChildIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1);
const toChildIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1; // INCLUSIVE
const listChildren = stat.children?.slice(fromChildIdx, toChildIdx + 1) ?? [];
const children: DirectoryItem[] = listChildren.map(child => ({
name: child.name,
uri: child.resource,
isDirectory: child.isDirectory,
isSymbolicLink: child.isSymbolicLink
}));
const hasNextPage = (originalChildrenLength - 1) > toChildIdx;
const hasPrevPage = pageNumber > 1;
const itemsRemaining = Math.max(0, originalChildrenLength - (toChildIdx + 1));
return {
rootURI,
children,
hasNextPage,
hasPrevPage,
itemsRemaining
};
};
const directoryResultToString = (result: ToolCallReturnType['list_dir']): string => {
if (!result.children) {
return `Error: ${result.rootURI} is not a directory`;
}
let output = '';
const entries = result.children;
if (!result.hasPrevPage) {
output += `${result.rootURI}\n`;
}
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const isLast = i === entries.length - 1 && !result.hasNextPage;
const prefix = isLast ? '└── ' : '├── ';
output += `${prefix}${entry.name}${entry.isDirectory ? '/' : ''}${entry.isSymbolicLink ? ' (symbolic link)' : ''}\n`;
}
if (result.hasNextPage) {
output += `└── (${result.itemsRemaining} results remaining...)\n`;
}
return output;
};
const validateJSON = (s: string): { [s: string]: unknown } => {
try {
const o = JSON.parse(s)
return o
}
catch (e) {
throw new Error(`Tool parameter was not a valid JSON: "${s}".`)
}
}
const validateQueryStr = (queryStr: unknown) => {
if (typeof queryStr !== 'string') throw new Error('Error calling tool: provided query must be a string.')
return queryStr
}
// TODO!!!! check to make sure in workspace
const validateURI = (uriStr: unknown) => {
if (typeof uriStr !== 'string') throw new Error('Error calling tool: provided uri must be a string.')
const uri = URI.file(uriStr)
return uri
}
const validatePageNum = (pageNumberUnknown: unknown) => {
const proposedPageNum = Number.parseInt(pageNumberUnknown + '')
const num = Number.isInteger(proposedPageNum) ? proposedPageNum : 1
const pageNumber = num < 1 ? 1 : num
return pageNumber
}
export interface IToolsService {
readonly _serviceBrand: undefined;
toolFns: ToolFns;
toolResultToString: ToolResultToString;
}
export const IToolsService = createDecorator<IToolsService>('ToolsService');
export class ToolsService implements IToolsService {
readonly _serviceBrand: undefined;
public toolFns: ToolFns
public toolResultToString: ToolResultToString
constructor(
@IFileService fileService: IFileService,
@IWorkspaceContextService workspaceContextService: IWorkspaceContextService,
@ISearchService searchService: ISearchService,
@IInstantiationService instantiationService: IInstantiationService,
@IVoidFileService voidFileService: IVoidFileService,
) {
const queryBuilder = instantiationService.createInstance(QueryBuilder);
this.toolFns = {
read_file: async (s: string) => {
console.log('read_file')
const o = validateJSON(s)
const { uri: uriStr, pageNumber: pageNumberUnknown } = o
const uri = validateURI(uriStr)
const pageNumber = validatePageNum(pageNumberUnknown)
const readFileContents = await voidFileService.readFile(uri)
const fromIdx = MAX_FILE_CHARS_PAGE * (pageNumber - 1)
const toIdx = MAX_FILE_CHARS_PAGE * pageNumber - 1
const fileContents = readFileContents.slice(fromIdx, toIdx + 1) || '(empty)' // paginate
const hasNextPage = (readFileContents.length - 1) - toIdx >= 1
console.log('read_file result:', fileContents)
return { uri, fileContents, hasNextPage }
},
list_dir: async (s: string) => {
console.log('list_dir')
const o = validateJSON(s)
const { uri: uriStr, pageNumber: pageNumberUnknown } = o
const uri = validateURI(uriStr)
const pageNumber = validatePageNum(pageNumberUnknown)
const dirResult = await computeDirectoryResult(fileService, uri, pageNumber)
console.log('list_dir result:', dirResult)
return dirResult
},
pathname_search: async (s: string) => {
console.log('pathname_search')
const o = validateJSON(s)
const { query: queryUnknown, pageNumber: pageNumberUnknown } = o
const queryStr = validateQueryStr(queryUnknown)
const pageNumber = validatePageNum(pageNumberUnknown)
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)
const toIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1
const uris = data.results
.slice(fromIdx, toIdx + 1) // paginate
.map(({ resource, results }) => resource)
const hasNextPage = (data.results.length - 1) - toIdx >= 1
console.log('pathname_search result:', uris)
return { queryStr, uris, hasNextPage }
},
search: async (s: string) => {
console.log('search')
const o = validateJSON(s)
const { query: queryUnknown, pageNumber: pageNumberUnknown } = o
const queryStr = validateQueryStr(queryUnknown)
const pageNumber = validatePageNum(pageNumberUnknown)
const query = queryBuilder.text({ pattern: queryStr, }, workspaceContextService.getWorkspace().folders.map(f => f.uri))
const data = await searchService.textSearch(query, CancellationToken.None)
const fromIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1)
const toIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1
const uris = data.results
.slice(fromIdx, toIdx + 1) // paginate
.map(({ resource, results }) => resource)
const hasNextPage = (data.results.length - 1) - toIdx >= 1
console.log('search result:', uris)
return { queryStr, uris, hasNextPage }
},
}
const nextPageStr = (hasNextPage: boolean) => hasNextPage ? '\n\n(more on next page...)' : ''
this.toolResultToString = {
read_file: (result) => {
return nextPageStr(result.hasNextPage)
},
list_dir: (result) => {
const dirTreeStr = directoryResultToString(result)
return dirTreeStr + nextPageStr(result.hasNextPage)
},
pathname_search: (result) => {
if (typeof result.uris === 'string') return result.uris
return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage)
},
search: (result) => {
if (typeof result.uris === 'string') return result.uris
return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage)
},
}
}
}
registerSingleton(IToolsService, ToolsService, InstantiationType.Eager);

View file

@ -3,7 +3,6 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { isWindows } from '../../../../base/common/platform.js';
import { URI } from '../../../../base/common/uri.js';
import { EndOfLinePreference } from '../../../../editor/common/model.js';
import { IModelService } from '../../../../editor/common/services/model.js';
@ -11,11 +10,6 @@ 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';
// linebreak symbols
export const allLinebreakSymbols = ['\r\n', '\n']
export const _ln = isWindows ? allLinebreakSymbols[0] : allLinebreakSymbols[1]
export interface IVoidFileService {
readonly _serviceBrand: undefined;
@ -52,19 +46,10 @@ export class VoidFileService implements IVoidFileService {
_readFileRaw = async (uri: URI, range?: { startLineNumber: number, endLineNumber: number }): Promise<string | null> => {
try { // this throws an error if no file exists (eg it was deleted)
const res = await this.fileService.readFile(uri);
if (range) {
return res.value.toString()
.split(_ln)
.slice(range.startLineNumber - 1, range.endLineNumber)
.join(_ln)
}
return res.value.toString();
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;
}

View file

@ -11,11 +11,18 @@ import { registerSingleton, InstantiationType } from '../../../../platform/insta
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import { IMetricsService } from './metricsService.js';
import { defaultSettingsOfProvider, FeatureName, ProviderName, ModelSelectionOfFeature, SettingsOfProvider, SettingName, providerNames, ModelSelection, modelSelectionsEqual, featureNames, VoidModelInfo, GlobalSettings, GlobalSettingName, defaultGlobalSettings, defaultProviderSettings } from './voidSettingsTypes.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';
const STORAGE_KEY = 'void.settingsServiceStorage'
// name is the name in the dropdown
export type ModelOption = { name: string, selection: ModelSelection }
type SetSettingOfProviderFn = <S extends SettingName>(
providerName: ProviderName,
settingName: S,
@ -25,16 +32,17 @@ type SetSettingOfProviderFn = <S extends SettingName>(
type SetModelSelectionOfFeatureFn = <K extends FeatureName>(
featureName: K,
newVal: ModelSelectionOfFeature[K],
options?: { doNotApplyEffects?: true }
) => Promise<void>;
type SetGlobalSettingFn = <T extends GlobalSettingName>(settingName: T, newVal: GlobalSettings[T]) => void;
export type ModelOption = { name: string, selection: ModelSelection }
type SetOptionsOfModelSelection = (providerName: ProviderName, modelName: string, newVal: Partial<ModelSelectionOptions>) => void
export type VoidSettingsState = {
readonly settingsOfProvider: SettingsOfProvider; // optionsOfProvider
readonly modelSelectionOfFeature: ModelSelectionOfFeature; // stateOfFeature
readonly optionsOfModelSelection: OptionsOfModelSelection;
readonly globalSettings: GlobalSettings;
readonly _modelOptions: ModelOption[] // computed based on the two above items
@ -49,12 +57,11 @@ export interface IVoidSettingsService {
readonly state: VoidSettingsState; // in order to play nicely with react, you should immutably change state
readonly waitForInitState: Promise<void>;
readAndInitializeState: (providedState?: VoidSettingsState) => Promise<void>;
onDidChangeState: Event<void>;
setSettingOfProvider: SetSettingOfProviderFn;
setModelSelectionOfFeature: SetModelSelectionOfFeatureFn;
setOptionsOfModelSelection: SetOptionsOfModelSelection;
setGlobalSetting: SetGlobalSettingFn;
setAutodetectedModels(providerName: ProviderName, modelNames: string[], logging: object): void;
@ -88,6 +95,14 @@ 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' },
'Ctrl+L': { filter: o => true, emptyMessage: null },
'Ctrl+K': { filter: o => true, emptyMessage: null },
'Apply': { filter: o => true, emptyMessage: null },
}
const _validatedState = (state: Omit<VoidSettingsState, '_modelOptions'>) => {
let newSettingsOfProvider = state.settingsOfProvider
@ -125,14 +140,17 @@ const _validatedState = (state: Omit<VoidSettingsState, '_modelOptions'>) => {
let newModelSelectionOfFeature = state.modelSelectionOfFeature
for (const featureName of featureNames) {
const modelSelectionAtFeature = newModelSelectionOfFeature[featureName]
const selnIdx = modelSelectionAtFeature === null ? -1 : newModelOptions.findIndex(m => modelSelectionsEqual(m.selection, modelSelectionAtFeature))
const { filter } = modelFilterOfFeatureName[featureName]
const modelOptionsForThisFeature = newModelOptions.filter((o) => filter(o.selection))
if (selnIdx !== -1) continue
const modelSelectionAtFeature = newModelSelectionOfFeature[featureName]
const selnIdx = modelSelectionAtFeature === null ? -1 : modelOptionsForThisFeature.findIndex(m => modelSelectionsEqual(m.selection, modelSelectionAtFeature))
if (selnIdx !== -1) continue // no longer in list, so update to 1st in list or null
newModelSelectionOfFeature = {
...newModelSelectionOfFeature,
[featureName]: newModelOptions.length === 0 ? null : newModelOptions[0].selection
[featureName]: modelOptionsForThisFeature.length === 0 ? null : modelOptionsForThisFeature[0].selection
}
}
@ -156,6 +174,7 @@ const defaultState = () => {
settingsOfProvider: deepClone(defaultSettingsOfProvider),
modelSelectionOfFeature: { 'Ctrl+L': null, 'Ctrl+K': null, 'Autocomplete': null, 'Apply': null },
globalSettings: deepClone(defaultGlobalSettings),
optionsOfModelSelection: {},
_modelOptions: [], // computed later
}
return d
@ -192,48 +211,13 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
this.readAndInitializeState()
}
async readAndInitializeState(providedState?: VoidSettingsState) {
// If providedState is given, use it instead of reading from storage
const readS = providedState || await this._readState();
async readAndInitializeState() {
const readS = await this._readState();
// the stored data structure might be outdated, so we need to update it here
const newSettingsOfProvider = {
// A HACK BECAUSE WE ADDED DEEPSEEK (did not exist before, comes before readS)
...{ deepseek: defaultSettingsOfProvider.deepseek },
// A HACK BECAUSE WE ADDED XAI (did not exist before, comes before readS)
...{ xAI: defaultSettingsOfProvider.xAI },
// A HACK BECAUSE WE ADDED VLLM (did not exist before, comes before readS)
...{ vLLM: defaultSettingsOfProvider.vLLM },
...readS.settingsOfProvider,
// A HACK BECAUSE WE ADDED NEW GEMINI MODELS (existed before, comes after readS)
gemini: {
...readS.settingsOfProvider.gemini,
models: [
...readS.settingsOfProvider.gemini.models,
...defaultSettingsOfProvider.gemini.models.filter(m => /* if cant find the model in readS (yes this is O(n^2), very small) */ !readS.settingsOfProvider.gemini.models.find(m2 => m2.modelName === m.modelName))
]
}
};
const newModelSelectionOfFeature = {
// A HACK BECAUSE WE ADDED FastApply
...{ 'Apply': null },
...readS.modelSelectionOfFeature,
};
const finalState = {
...readS,
settingsOfProvider: newSettingsOfProvider,
modelSelectionOfFeature: newModelSelectionOfFeature,
};
const finalState = readS
this.state = _validatedState(finalState);
this._resolver();
this._onDidChangeState.fire();
}
@ -260,6 +244,8 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
const newModelSelectionOfFeature = this.state.modelSelectionOfFeature
const newOptionsOfModelSelection = this.state.optionsOfModelSelection
const newSettingsOfProvider: SettingsOfProvider = {
...this.state.settingsOfProvider,
[providerName]: {
@ -272,6 +258,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
const newState = {
modelSelectionOfFeature: newModelSelectionOfFeature,
optionsOfModelSelection: newOptionsOfModelSelection,
settingsOfProvider: newSettingsOfProvider,
globalSettings: newGlobalSettings,
}
@ -299,7 +286,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
}
setModelSelectionOfFeature: SetModelSelectionOfFeatureFn = async (featureName, newVal, options) => {
setModelSelectionOfFeature: SetModelSelectionOfFeatureFn = async (featureName, newVal) => {
const newState: VoidSettingsState = {
...this.state,
modelSelectionOfFeature: {
@ -310,8 +297,26 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
this.state = newState
if (options?.doNotApplyEffects)
return
await this._storeState()
this._onDidChangeState.fire()
}
setOptionsOfModelSelection = async (providerName: ProviderName, modelName: string, newVal: Partial<ModelSelectionOptions>) => {
const newState: VoidSettingsState = {
...this.state,
optionsOfModelSelection: {
...this.state.optionsOfModelSelection,
[providerName]: {
...this.state.optionsOfModelSelection[providerName],
[modelName]: {
...this.state.optionsOfModelSelection[providerName]?.[modelName],
...newVal
}
}
}
}
this.state = newState
await this._storeState()
this._onDidChangeState.fire()

View file

@ -4,6 +4,7 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { defaultModelsOfProvider } from './modelCapabilities.js';
import { VoidSettingsState } from './voidSettingsService.js'
@ -47,63 +48,6 @@ export const defaultProviderSettings = {
export const defaultModelsOfProvider = {
openAI: [ // https://platform.openai.com/docs/models/gp
'o1',
'o3-mini',
'o1-mini',
'gpt-4o',
'gpt-4o-mini',
],
anthropic: [ // https://docs.anthropic.com/en/docs/about-claude/models
'claude-3-7-sonnet-latest',
// 'claude-3-5-sonnet-latest',
'claude-3-5-haiku-latest',
'claude-3-opus-latest',
],
xAI: [ // https://docs.x.ai/docs/models?cluster=us-east-1
'grok-2-latest',
'grok-3-latest',
],
gemini: [ // https://ai.google.dev/gemini-api/docs/models/gemini
'gemini-2.0-flash',
'gemini-1.5-flash',
'gemini-1.5-pro',
'gemini-1.5-flash-8b',
'gemini-2.0-flash-thinking-exp',
],
deepseek: [ // https://api-docs.deepseek.com/quick_start/pricing
'deepseek-chat',
'deepseek-reasoner',
],
ollama: [ // autodetected
],
vLLM: [ // autodetected
],
openRouter: [ // https://openrouter.ai/models
'anthropic/claude-3.5-sonnet',
'deepseek/deepseek-r1',
'mistralai/codestral-2501',
'qwen/qwen-2.5-coder-32b-instruct',
],
groq: [ // https://console.groq.com/docs/models
'llama-3.3-70b-versatile',
'llama-3.1-8b-instant',
'qwen-2.5-coder-32b', // preview mode (experimental)
],
// not supporting mistral right now- it's last on Void usage, and a huge pain to set up since it's nonstandard (it supports codestral FIM but it's on v1/fim/completions, etc)
// mistral: [ // https://docs.mistral.ai/getting-started/models/models_overview/
// 'codestral-latest',
// 'mistral-large-latest',
// 'ministral-3b-latest',
// 'ministral-8b-latest',
// ],
openAICompatible: [], // fallback
} as const satisfies Record<ProviderName, string[]>
export type ProviderName = keyof typeof defaultProviderSettings
export const providerNames = Object.keys(defaultProviderSettings) as ProviderName[]
@ -197,7 +141,7 @@ export const displayInfoOfProviderName = (providerName: ProviderName): DisplayIn
}
else if (providerName === 'xAI') {
return {
title: 'xAI API',
title: 'Grok (xAI)',
}
}
@ -380,7 +324,7 @@ export const displayInfoOfFeatureName = (featureName: FeatureName) => {
else if (featureName === 'Ctrl+L')
return 'Chat'
else if (featureName === 'Apply')
return 'Apply'
return 'Fast Apply'
else
throw new Error(`Feature Name ${featureName} not allowed`)
}
@ -434,15 +378,21 @@ export const isFeatureNameDisabled = (featureName: FeatureName, settingsState: V
export type ChatMode = 'agent' | 'gather' | 'chat'
export type GlobalSettings = {
autoRefreshModels: boolean;
aiInstructions: string;
enableAutocomplete: boolean;
chatMode: ChatMode;
}
export const defaultGlobalSettings: GlobalSettings = {
autoRefreshModels: true,
aiInstructions: '',
enableAutocomplete: false,
chatMode: 'agent',
}
export type GlobalSettingName = keyof GlobalSettings
@ -459,4 +409,9 @@ export const globalSettingNames = Object.keys(defaultGlobalSettings) as GlobalSe
export type ModelSelectionOptions = {
reasoningEnabled?: boolean;
reasoningBudget?: number;
}
export type OptionsOfModelSelection = Partial<{ [providerName in ProviderName]: { [modelName: string]: ModelSelectionOptions | undefined } }>

View file

@ -1,6 +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 { LLMChatMessage, LLMFIMMessage } from '../../common/llmMessageTypes.js';
import { RawAnthropicAssistantContent, LLMChatMessage, LLMFIMMessage } from '../../common/llmMessageTypes.js';
import { deepClone } from '../../../../../base/common/objects.js';
@ -14,9 +17,28 @@ export const parseObject = (args: unknown) => {
}
type InternalLLMChatMessage = {
role: 'system' | 'user';
content: string;
} | {
role: 'assistant',
content: string | (RawAnthropicAssistantContent | { type: 'text'; text: string })[];
rawAnthropicAssistantContent?: RawAnthropicAssistantContent[] | undefined;
} | {
role: 'tool';
content: string; // result
name: string;
params: string;
id: string;
}
const prepareMessages_normalize = ({ messages: messages_ }: { messages: LLMChatMessage[] }) => {
const messages = deepClone(messages_)
const newMessages: LLMChatMessage[] = []
if (messages.length >= 0) newMessages.push(messages[0])
// remove duplicate roles
for (let i = 1; i < messages.length; i += 1) {
const curr = messages[i]
const prev = messages[i - 1]
@ -32,13 +54,42 @@ const prepareMessages_normalize = ({ messages: messages_ }: { messages: LLMChatM
return { messages: finalMessages }
}
// remove rawAnthropicAssistantContent, and make content equal to it if supportsAnthropicContent
const prepareMessages_anthropicContent = ({ messages, supportsAnthropicContent }: { messages: LLMChatMessage[], supportsAnthropicContent: boolean }) => {
const newMessages: InternalLLMChatMessage[] = []
for (const m of messages) {
if (m.role !== 'assistant') {
newMessages.push(m)
continue
}
let newMessage: InternalLLMChatMessage
if (supportsAnthropicContent) {
const newContent = m.rawAnthropicAssistantContent
newMessage = { role: 'assistant', content: newContent ?? m.content }
}
else {
newMessage = m
}
delete newMessage.rawAnthropicAssistantContent // important to delete this field
newMessages.push(m)
}
return { messages: newMessages }
}
// no matter whether the model supports a system message or not (or what format it supports), add it in some way
const prepareMessages_systemMessage = ({
messages,
aiInstructions,
supportsSystemMessage,
}: {
messages: LLMChatMessage[],
messages: InternalLLMChatMessage[],
aiInstructions: string,
supportsSystemMessage: false | 'system-role' | 'developer-role' | 'separated',
})
@ -56,7 +107,7 @@ const prepareMessages_systemMessage = ({
let separateSystemMessageStr: string | undefined = undefined
// remove all system messages
const newMessages: (LLMChatMessage | { role: 'developer', content: string })[] = messages.filter(msg => msg.role !== 'system')
const newMessages: (InternalLLMChatMessage | { role: 'developer', content: string })[] = messages.filter(msg => msg.role !== 'system')
// if (!supportsTools) {
@ -65,6 +116,7 @@ const prepareMessages_systemMessage = ({
// }
// 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
if (supportsSystemMessage) {
@ -77,25 +129,18 @@ const prepareMessages_systemMessage = ({
}
// if does not support system message
else {
if (supportsSystemMessage) {
if (newMessages.length === 0)
newMessages.push({ role: 'user', content: systemMessageStr })
// add system mesasges to first message (should be a user message)
else {
const newFirstMessage = {
role: 'user',
content: (''
+ '<SYSTEM_MESSAGE>\n'
+ systemMessageStr
+ '\n'
+ '</SYSTEM_MESSAGE>\n'
+ newMessages[0].content
)
} as const
newMessages.splice(0, 1) // delete first message
newMessages.unshift(newFirstMessage) // add new first message
}
}
const newFirstMessage = {
role: 'user',
content: (''
+ '<SYSTEM_MESSAGE>\n'
+ systemMessageStr
+ '\n'
+ '</SYSTEM_MESSAGE>\n'
+ newMessages[0].content
)
} as const
newMessages.splice(0, 1) // delete first message
newMessages.unshift(newFirstMessage) // add new first message
}
}
@ -128,12 +173,12 @@ openai on prompting - https://platform.openai.com/docs/guides/reasoning#advice-o
openai on developer system message - https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command
*/
const prepareMessages_tools_openai = ({ messages }: { messages: LLMChatMessage[], }) => {
const prepareMessages_tools_openai = ({ messages }: { messages: InternalLLMChatMessage[], }) => {
const newMessages: (
Exclude<LLMChatMessage, { role: 'assistant' | 'tool' }> | {
Exclude<InternalLLMChatMessage, { role: 'assistant' | 'tool' }> | {
role: 'assistant',
content: string;
content: string | object[];
tool_calls?: {
type: 'function';
id: string;
@ -205,19 +250,22 @@ anthropic RESPONSE (role=user):
}]
*/
const prepareMessages_tools_anthropic = ({ messages }: { messages: LLMChatMessage[], }) => {
const prepareMessages_tools_anthropic = ({ messages }: { messages: InternalLLMChatMessage[], }) => {
const newMessages: (
Exclude<LLMChatMessage, { role: 'assistant' | 'user' }> | {
Exclude<InternalLLMChatMessage, { role: 'assistant' | 'user' }> | {
role: 'assistant',
content: string | ({
type: 'text';
text: string;
} | {
type: 'tool_use';
name: string;
input: Record<string, any>;
id: string;
})[]
content: string | (
| RawAnthropicAssistantContent
| {
type: 'text';
text: string;
}
| {
type: 'tool_use';
name: string;
input: Record<string, any>;
id: string;
})[]
} | {
role: 'user',
content: string | ({
@ -260,7 +308,7 @@ const prepareMessages_tools_anthropic = ({ messages }: { messages: LLMChatMessag
const prepareMessages_tools = ({ messages, supportsTools }: { messages: LLMChatMessage[], supportsTools: false | 'anthropic-style' | 'openai-style' }) => {
const prepareMessages_tools = ({ messages, supportsTools }: { messages: InternalLLMChatMessage[], supportsTools: false | 'anthropic-style' | 'openai-style' }) => {
if (!supportsTools) {
return { messages: messages }
}
@ -271,7 +319,7 @@ const prepareMessages_tools = ({ messages, supportsTools }: { messages: LLMChatM
return prepareMessages_tools_openai({ messages })
}
else {
throw 1
throw new Error(`supportsTools type not recognized`)
}
}
@ -311,26 +359,28 @@ gemini response:
// --- CHAT ---
export const prepareMessages = ({
messages,
aiInstructions,
supportsSystemMessage,
supportsTools,
supportsAnthropicContent,
}: {
messages: LLMChatMessage[],
aiInstructions: string,
supportsSystemMessage: false | 'system-role' | 'developer-role' | 'separated',
supportsTools: false | 'anthropic-style' | 'openai-style',
supportsAnthropicContent: boolean,
}) => {
const { messages: messages1 } = prepareMessages_normalize({ messages })
const { messages: messages2, separateSystemMessageStr } = prepareMessages_systemMessage({ messages: messages1, aiInstructions, supportsSystemMessage })
const { messages: messages3 } = prepareMessages_tools({ messages: messages2, supportsTools })
const { messages: messages2 } = prepareMessages_anthropicContent({ messages: messages1, supportsAnthropicContent })
const { messages: messages3, separateSystemMessageStr } = prepareMessages_systemMessage({ messages: messages2, aiInstructions, supportsSystemMessage })
const { messages: messages4 } = prepareMessages_tools({ messages: messages3, supportsTools })
return {
messages: messages3 as any,
messages: messages4 as any,
separateSystemMessageStr
} as const
}
@ -339,6 +389,10 @@ export const prepareMessages = ({
// --- FIM ---
export const prepareFIMMessage = ({
messages,
aiInstructions,
@ -358,6 +412,5 @@ ${messages.prefix}`
const suffix = messages.suffix
const stopTokens = messages.stopTokens
const ret = { prefix, suffix, stopTokens, maxTokens: 300 } as const
console.log('ret', ret)
return ret
}

View file

@ -0,0 +1,553 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
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 { LLMChatMessage, LLMFIMMessage, ModelListParams, OllamaModelResponse, OnError, OnFinalMessage, OnText } from '../../common/llmMessageTypes.js';
import { InternalToolInfo, isAToolName, ToolName } from '../../browser/toolsService.js';
import { defaultProviderSettings, displayInfoOfProviderName, OptionsOfModelSelection, ProviderName, SettingsOfProvider } from '../../common/voidSettingsTypes.js';
import { prepareFIMMessage, prepareMessages } from './preprocessLLMMessages.js';
import { getModelSelectionState, getModelCapabilities, getProviderCapabilities } from '../../common/modelCapabilities.js';
type InternalCommonMessageParams = {
aiInstructions: string;
onText: OnText;
onFinalMessage: OnFinalMessage;
onError: OnError;
providerName: ProviderName;
settingsOfProvider: SettingsOfProvider;
optionsOfModelSelection: OptionsOfModelSelection;
modelName: string;
_setAborter: (aborter: () => void) => void;
}
type SendChatParams_Internal = InternalCommonMessageParams & { messages: LLMChatMessage[]; tools?: InternalToolInfo[] }
type SendFIMParams_Internal = InternalCommonMessageParams & { messages: LLMFIMMessage; }
export type ListParams_Internal<ModelResponse> = ModelListParams<ModelResponse>
const invalidApiKeyMessage = (providerName: ProviderName) => `Invalid ${displayInfoOfProviderName(providerName).title} API key.`
// ------------ OPENAI-COMPATIBLE (HELPERS) ------------
const toOpenAICompatibleTool = (toolInfo: InternalToolInfo) => {
const { name, description, params, required } = toolInfo
return {
type: 'function',
function: {
name: name,
description: description,
parameters: {
type: 'object',
properties: params,
required: required,
}
}
} satisfies OpenAI.Chat.Completions.ChatCompletionTool
}
type ToolCallOfIndex = { [index: string]: { name: string, paramsStr: string, id: string } } // type used to stream tool calls as they come in
type ToolCallsFrom_ReturnType = { name: ToolName, id: string, paramsStr: string }[] // return type of toolCallsFrom_<PROVIDER>
const toolCallsFrom_OpenAICompat = (toolCallOfIndex: ToolCallOfIndex): ToolCallsFrom_ReturnType => {
return Object.keys(toolCallOfIndex).map(index => {
const tool = toolCallOfIndex[index]
return isAToolName(tool.name) ? { name: tool.name, id: tool.id, paramsStr: tool.paramsStr } : null
}).filter(t => !!t)
}
const newOpenAICompatibleSDK = ({ settingsOfProvider, providerName, includeInPayload }: { settingsOfProvider: SettingsOfProvider, providerName: ProviderName, includeInPayload?: { [s: string]: any } }) => {
const commonPayloadOpts: ClientOptions = {
dangerouslyAllowBrowser: true,
...includeInPayload,
}
if (providerName === 'openAI') {
const thisConfig = settingsOfProvider[providerName]
return new OpenAI({ apiKey: thisConfig.apiKey, ...commonPayloadOpts })
}
else if (providerName === 'ollama') {
const thisConfig = settingsOfProvider[providerName]
return new OpenAI({ baseURL: `${thisConfig.endpoint}/v1`, apiKey: 'noop', ...commonPayloadOpts })
}
else if (providerName === 'vLLM') {
const thisConfig = settingsOfProvider[providerName]
return new OpenAI({ baseURL: `${thisConfig.endpoint}/v1`, apiKey: 'noop', ...commonPayloadOpts })
}
else if (providerName === 'openRouter') {
const thisConfig = settingsOfProvider[providerName]
return new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: thisConfig.apiKey,
defaultHeaders: {
'HTTP-Referer': 'https://voideditor.com', // Optional, for including your app on openrouter.ai rankings.
'X-Title': 'Void', // Optional. Shows in rankings on openrouter.ai.
},
...commonPayloadOpts,
})
}
else if (providerName === 'gemini') {
const thisConfig = settingsOfProvider[providerName]
return new OpenAI({ baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai', apiKey: thisConfig.apiKey, ...commonPayloadOpts })
}
else if (providerName === 'deepseek') {
const thisConfig = settingsOfProvider[providerName]
return new OpenAI({ baseURL: 'https://api.deepseek.com/v1', apiKey: thisConfig.apiKey, ...commonPayloadOpts })
}
else if (providerName === 'openAICompatible') {
const thisConfig = settingsOfProvider[providerName]
return new OpenAI({ baseURL: thisConfig.endpoint, apiKey: thisConfig.apiKey, ...commonPayloadOpts })
}
else if (providerName === 'groq') {
const thisConfig = settingsOfProvider[providerName]
return new OpenAI({ baseURL: 'https://api.groq.com/openai/v1', apiKey: thisConfig.apiKey, ...commonPayloadOpts })
}
else if (providerName === 'xAI') {
const thisConfig = settingsOfProvider[providerName]
return new OpenAI({ baseURL: 'https://api.x.ai/v1', apiKey: thisConfig.apiKey, ...commonPayloadOpts })
}
else throw new Error(`Void providerName was invalid: ${providerName}.`)
}
const _sendOpenAICompatibleFIM = ({ messages: messages_, onFinalMessage, onError, settingsOfProvider, modelName: modelName_, _setAborter, providerName, aiInstructions, }: SendFIMParams_Internal) => {
const { modelName, supportsFIM } = getModelCapabilities(providerName, modelName_)
if (!supportsFIM) {
if (modelName === modelName_)
onError({ message: `Model ${modelName} does not support FIM.`, fullError: null })
else
onError({ message: `Model ${modelName_} (${modelName}) does not support FIM.`, fullError: null })
return
}
const messages = prepareFIMMessage({ messages: messages_, aiInstructions, })
const openai = newOpenAICompatibleSDK({ providerName, settingsOfProvider })
openai.completions
.create({
model: modelName,
prompt: messages.prefix,
suffix: messages.suffix,
stop: messages.stopTokens,
max_tokens: messages.maxTokens,
})
.then(async response => {
const fullText = response.choices[0]?.text
onFinalMessage({ fullText, });
})
.catch(error => {
if (error instanceof OpenAI.APIError && error.status === 401) { onError({ message: invalidApiKeyMessage(providerName), fullError: error }); }
else { onError({ message: error + '', fullError: error }); }
})
}
const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName: modelName_, _setAborter, providerName, aiInstructions, tools: tools_ }: SendChatParams_Internal) => {
const {
modelName,
supportsReasoning,
supportsSystemMessage,
supportsTools,
// maxOutputTokens, right now we are ignoring this
} = getModelCapabilities(providerName, modelName_)
const {
canIOReasoning,
openSourceThinkTags,
} = supportsReasoning || {}
const { providerReasoningIOSettings } = getProviderCapabilities(providerName)
const { messages } = prepareMessages({ messages: messages_, aiInstructions, supportsSystemMessage, supportsTools, supportsAnthropicContent: false }) // can change supportsAnthropicContent if e.g. OpenRouter starts supporting anthropic extended thinking
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, }
const { needsManualParse: needsManualReasoningParse, nameOfFieldInDelta: nameOfReasoningFieldInDelta } = providerReasoningIOSettings?.output ?? {}
const manuallyParseReasoning = needsManualReasoningParse && canIOReasoning && openSourceThinkTags
if (manuallyParseReasoning) {
onText = extractReasoningOnTextWrapper(onText, openSourceThinkTags)
}
let fullReasoningSoFar = ''
let fullTextSoFar = ''
const toolCallOfIndex: ToolCallOfIndex = {}
openai.chat.completions
.create(options)
.then(async response => {
_setAborter(() => response.controller.abort())
// when receive text
for await (const chunk of response) {
// tool call
for (const tool of chunk.choices[0]?.delta?.tool_calls ?? []) {
const index = tool.index
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 ?? ''
}
// message
const newText = chunk.choices[0]?.delta?.content ?? ''
fullTextSoFar += newText
// reasoning
let newReasoning = ''
if (nameOfReasoningFieldInDelta) {
// @ts-ignore
newReasoning = (chunk.choices[0]?.delta?.[nameOfReasoningFieldInDelta] || '') + ''
fullReasoningSoFar += newReasoning
}
onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
}
// on final
const toolCalls = toolCallsFrom_OpenAICompat(toolCallOfIndex)
if (!fullTextSoFar && !fullReasoningSoFar && toolCalls.length === 0) {
onError({ message: 'Void: Response from model was empty.', fullError: null })
}
else {
if (manuallyParseReasoning) {
const { fullText, fullReasoning } = extractReasoningOnFinalMessage(fullTextSoFar, openSourceThinkTags)
onFinalMessage({ fullText, fullReasoning, toolCalls });
} else {
onFinalMessage({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar, toolCalls });
}
}
})
// when error/fail - this catches errors of both .create() and .then(for await)
.catch(error => {
if (error instanceof OpenAI.APIError && error.status === 401) { onError({ message: invalidApiKeyMessage(providerName), fullError: error }); }
else { onError({ message: error + '', fullError: error }); }
})
}
const _openaiCompatibleList = async ({ onSuccess: onSuccess_, onError: onError_, settingsOfProvider, providerName }: ListParams_Internal<OpenAIModel>) => {
const onSuccess = ({ models }: { models: OpenAIModel[] }) => {
onSuccess_({ models })
}
const onError = ({ error }: { error: string }) => {
onError_({ error })
}
try {
const openai = newOpenAICompatibleSDK({ providerName, settingsOfProvider })
openai.models.list()
.then(async (response) => {
const models: OpenAIModel[] = []
models.push(...response.data)
while (response.hasNextPage()) {
models.push(...(await response.getNextPage()).data)
}
onSuccess({ models })
})
.catch((error) => {
onError({ error: error + '' })
})
}
catch (error) {
onError({ error: error + '' })
}
}
// ------------ ANTHROPIC ------------
const toAnthropicTool = (toolInfo: InternalToolInfo) => {
const { name, description, params, required } = toolInfo
return {
name: name,
description: description,
input_schema: {
type: 'object',
properties: params,
required: required,
}
} satisfies Anthropic.Messages.Tool
}
const toolCallsFrom_AnthropicContent = (content: Anthropic.Messages.ContentBlock[]): ToolCallsFrom_ReturnType => {
return content.map(c => {
if (c.type !== 'tool_use') return null
if (!isAToolName(c.name)) return null
return c.type === 'tool_use' ? { name: c.name, paramsStr: JSON.stringify(c.input), id: c.id } : null
}).filter(t => !!t)
}
const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalMessage, onError, settingsOfProvider, optionsOfModelSelection, modelName: modelName_, _setAborter, aiInstructions, tools: tools_ }: SendChatParams_Internal) => {
const {
modelName,
supportsSystemMessage,
supportsTools,
maxOutputTokens,
supportsReasoning,
} = getModelCapabilities(providerName, modelName_)
const {
isReasoningEnabled,
reasoningBudget,
} = getModelSelectionState(providerName, modelName_, optionsOfModelSelection) // user's modelName_ here
const { messages, separateSystemMessageStr } = prepareMessages({ messages: messages_, aiInstructions, supportsSystemMessage, supportsTools, supportsAnthropicContent: true })
const thisConfig = settingsOfProvider.anthropic
const anthropic = new Anthropic({ apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true });
const tools = ((tools_?.length ?? 0) !== 0) ? tools_?.map(tool => toAnthropicTool(tool)) : undefined
const toolsObj: Partial<Anthropic.Messages.MessageStreamParams> = tools ? {
tools: tools,
tool_choice: { type: 'auto', disable_parallel_tool_use: true } // one tool at a time
} : {}
const enableThinking = supportsReasoning && isReasoningEnabled && reasoningBudget
const maxTokens = enableThinking ? supportsReasoning.reasoningMaxOutputTokens : maxOutputTokens
const thinkingObj: Partial<Anthropic.Messages.MessageStreamParams> = enableThinking ? {
thinking: { type: 'enabled', budget_tokens: reasoningBudget } // thinking enabled
} : {}
const stream = anthropic.messages.stream({
system: separateSystemMessageStr,
messages: messages,
model: modelName,
max_tokens: maxTokens ?? 4_096, // anthropic requires this
...toolsObj,
...thinkingObj,
})
// when receive text
let fullText = ''
let fullReasoning = ''
// there are no events for tool_use, it comes in at the end
stream.on('streamEvent', e => {
// start block
if (e.type === 'content_block_start') {
if (e.content_block.type === 'text') {
if (fullText) fullText += '\n\n' // starting a 2nd text block
fullText += e.content_block.text
onText({ fullText, fullReasoning })
}
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 })
}
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 })
}
}
// delta
else if (e.type === 'content_block_delta') {
if (e.delta.type === 'text_delta') {
fullText += e.delta.text
onText({ fullText, fullReasoning })
}
else if (e.delta.type === 'thinking_delta') {
fullReasoning += e.delta.thinking
onText({ fullText, fullReasoning })
}
}
})
// on done - (or when error/fail) - this is called AFTER last streamEvent
stream.on('finalMessage', (response) => {
const toolCalls = toolCallsFrom_AnthropicContent(response.content)
onFinalMessage({ fullText, fullReasoning, toolCalls, rawAnthropicAssistantContent: response.content as any })
})
// on error
stream.on('error', (error) => {
if (error instanceof Anthropic.APIError && error.status === 401) { onError({ message: invalidApiKeyMessage(providerName), fullError: error }) }
else { onError({ message: error + '', fullError: error }) }
})
_setAborter(() => stream.controller.abort())
}
// // in future, can do tool_use streaming in anthropic, but it's pretty fast even without streaming...
// const toolCallOfIndex: { [index: string]: { name: string, args: string } } = {}
// stream.on('streamEvent', e => {
// if (e.type === 'content_block_start') {
// if (e.content_block.type !== 'tool_use') return
// const index = e.index
// if (!toolCallOfIndex[index]) toolCallOfIndex[index] = { name: '', args: '' }
// toolCallOfIndex[index].name += e.content_block.name ?? ''
// toolCallOfIndex[index].args += e.content_block.input ?? ''
// }
// else if (e.type === 'content_block_delta') {
// if (e.delta.type !== 'input_json_delta') return
// toolCallOfIndex[e.index].args += e.delta.partial_json
// }
// })
// ------------ OLLAMA ------------
const newOllamaSDK = ({ endpoint }: { endpoint: string }) => {
// if endpoint is empty, normally ollama will send to 11434, but we want it to fail - the user should type it in
if (!endpoint) throw new Error(`Ollama Endpoint was empty (please enter ${defaultProviderSettings.ollama.endpoint} in Void if you want the default url).`)
const ollama = new Ollama({ host: endpoint })
return ollama
}
const ollamaList = async ({ onSuccess: onSuccess_, onError: onError_, settingsOfProvider }: ListParams_Internal<OllamaModelResponse>) => {
const onSuccess = ({ models }: { models: OllamaModelResponse[] }) => {
onSuccess_({ models })
}
const onError = ({ error }: { error: string }) => {
onError_({ error })
}
try {
const thisConfig = settingsOfProvider.ollama
const ollama = newOllamaSDK({ endpoint: thisConfig.endpoint })
ollama.list()
.then((response) => {
const { models } = response
onSuccess({ models })
})
.catch((error) => {
onError({ error: error + '' })
})
}
catch (error) {
onError({ error: error + '' })
}
}
const sendOllamaFIM = ({ messages: messages_, onFinalMessage, onError, settingsOfProvider, modelName, aiInstructions, _setAborter }: SendFIMParams_Internal) => {
const thisConfig = settingsOfProvider.ollama
const ollama = newOllamaSDK({ endpoint: thisConfig.endpoint })
const messages = prepareFIMMessage({ messages: messages_, aiInstructions, })
let fullText = ''
ollama.generate({
model: modelName,
prompt: messages.prefix,
suffix: messages.suffix,
options: {
stop: messages.stopTokens,
num_predict: messages.maxTokens, // max tokens
// repeat_penalty: 1,
},
raw: true,
stream: true, // stream is not necessary but lets us expose the
})
.then(async stream => {
_setAborter(() => stream.abort())
for await (const chunk of stream) {
const newText = chunk.response
fullText += newText
}
onFinalMessage({ fullText })
})
// when error/fail
.catch((error) => {
onError({ message: error + '', fullError: error })
})
}
type CallFnOfProvider = {
[providerName in ProviderName]: {
sendChat: (params: SendChatParams_Internal) => void;
sendFIM: ((params: SendFIMParams_Internal) => void) | null;
list: ((params: ListParams_Internal<any>) => void) | null;
}
}
export const sendLLMMessageToProviderImplementation = {
anthropic: {
sendChat: sendAnthropicChat,
sendFIM: null,
list: null,
},
openAI: {
sendChat: (params) => _sendOpenAICompatibleChat(params),
sendFIM: null,
list: null,
},
xAI: {
sendChat: (params) => _sendOpenAICompatibleChat(params),
sendFIM: null,
list: null,
},
gemini: {
sendChat: (params) => _sendOpenAICompatibleChat(params),
sendFIM: null,
list: null,
},
ollama: {
sendChat: (params) => _sendOpenAICompatibleChat(params),
sendFIM: sendOllamaFIM,
list: ollamaList,
},
openAICompatible: {
sendChat: (params) => _sendOpenAICompatibleChat(params), // using openai's SDK is not ideal (your implementation might not do tools, reasoning, FIM etc correctly), talk to us for a custom integration
sendFIM: (params) => _sendOpenAICompatibleFIM(params),
list: null,
},
openRouter: {
sendChat: (params) => _sendOpenAICompatibleChat(params),
sendFIM: (params) => _sendOpenAICompatibleFIM(params),
list: null,
},
vLLM: {
sendChat: (params) => _sendOpenAICompatibleChat(params),
sendFIM: (params) => _sendOpenAICompatibleFIM(params),
list: (params) => _openaiCompatibleList(params),
},
deepseek: {
sendChat: (params) => _sendOpenAICompatibleChat(params),
sendFIM: null,
list: null,
},
groq: {
sendChat: (params) => _sendOpenAICompatibleChat(params),
sendFIM: null,
list: null,
},
} satisfies CallFnOfProvider
/*
FIM info (this may be useful in the future with vLLM, but in most cases the only way to use FIM is if the provider explicitly supports it):
qwen2.5-coder https://ollama.com/library/qwen2.5-coder/blobs/e94a8ecb9327
<|fim_prefix|>{{ .Prompt }}<|fim_suffix|>{{ .Suffix }}<|fim_middle|>
codestral https://ollama.com/library/codestral/blobs/51707752a87c
[SUFFIX]{{ .Suffix }}[PREFIX] {{ .Prompt }}
deepseek-coder-v2 https://ollama.com/library/deepseek-coder-v2/blobs/22091531faf0
<fimbegin>{{ .Prompt }}<fimhole>{{ .Suffix }}<fimend>
starcoder2 https://ollama.com/library/starcoder2/blobs/3b190e68fefe
<file_sep>
<fim_prefix>
{{ .Prompt }}<fim_suffix>{{ .Suffix }}<fim_middle>
<|end_of_text|>
codegemma https://ollama.com/library/codegemma:2b/blobs/48d9a8140749
<|fim_prefix|>{{ .Prompt }}<|fim_suffix|>{{ .Suffix }}<|fim_middle|>
*/

View file

@ -6,7 +6,7 @@
import { SendLLMMessageParams, OnText, OnFinalMessage, OnError } from '../../common/llmMessageTypes.js';
import { IMetricsService } from '../../common/metricsService.js';
import { displayInfoOfProviderName } from '../../common/voidSettingsTypes.js';
import { sendLLMMessageToProviderImplementation } from './MODELS.js';
import { sendLLMMessageToProviderImplementation } from './sendLLMMessage.impl.js';
export const sendLLMMessage = ({
@ -19,6 +19,7 @@ export const sendLLMMessage = ({
abortRef: abortRef_,
logging: { loggingName },
settingsOfProvider,
optionsOfModelSelection,
providerName,
modelName,
tools,
@ -104,12 +105,12 @@ export const sendLLMMessage = ({
}
const { sendFIM, sendChat } = implementation
if (messagesType === 'chatMessages') {
sendChat({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName, aiInstructions, tools })
sendChat({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, optionsOfModelSelection, modelName, _setAborter, providerName, aiInstructions, tools })
return
}
if (messagesType === 'FIMMessage') {
if (sendFIM) {
sendFIM({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName, aiInstructions })
sendFIM({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, optionsOfModelSelection, modelName, _setAborter, providerName, aiInstructions })
return
}
onError({ message: `Error: This provider does not support Autocomplete yet.`, fullError: null })

View file

@ -11,7 +11,7 @@ import { Emitter, Event } from '../../../../base/common/event.js';
import { EventLLMMessageOnTextParams, EventLLMMessageOnErrorParams, EventLLMMessageOnFinalMessageParams, MainSendLLMMessageParams, AbortRef, SendLLMMessageParams, MainLLMMessageAbortParams, ModelListParams, EventModelListOnSuccessParams, EventModelListOnErrorParams, OllamaModelResponse, VLLMModelResponse, MainModelListParams, } from '../common/llmMessageTypes.js';
import { sendLLMMessage } from './llmMessage/sendLLMMessage.js'
import { IMetricsService } from '../common/metricsService.js';
import { sendLLMMessageToProviderImplementation } from './llmMessage/MODELS.js';
import { sendLLMMessageToProviderImplementation } from './llmMessage/sendLLMMessage.impl.js';
// NODE IMPLEMENTATION - calls actual sendLLMMessage() and returns listeners to it

View file

@ -192,8 +192,9 @@ import './contrib/notebook/browser/notebook.contribution.js';
import './contrib/speech/browser/speech.contribution.js';
// Chat
// import './contrib/chat/browser/chat.contribution.js'; // Void - remove vscode built-in chat
// import './contrib/inlineChat/browser/inlineChat.contribution.js';
// Void - this is still registered to avoid console errors, we just commented it out in chatParticipant.contribution.ts
import './contrib/chat/browser/chat.contribution.js';
import './contrib/inlineChat/browser/inlineChat.contribution.js';
// Interactive
import './contrib/interactive/browser/interactive.contribution.js';