Compare commits

...

85 Commits

Author SHA1 Message Date
Andrew Pareles 3de9faed5c fix katex? 2025-04-21 16:37:31 -07:00
Andrew Pareles c179912368 ajv 2025-04-21 14:45:20 -07:00
Andrew Pareles 89c2adee70 Merge pull request #424 from voideditor/model-selection
update package-lock??
2025-04-20 20:32:27 -07:00
Andrew Pareles c4b9637546 update package-lock?? 2025-04-20 20:32:04 -07:00
Andrew Pareles 868736330a Merge pull request #423 from voideditor/model-selection
Fix build?
2025-04-20 20:20:54 -07:00
Andrew Pareles 2c856c9932 rm google vertex and fast-json-stable-stringify, and revert package-lock to try to fix build 2025-04-20 20:09:48 -07:00
Andrew Pareles a3b49d8aa2 add extensions transfer omits 2025-04-20 19:04:56 -07:00
Andrew Pareles da9817bd99 misc 2025-04-20 18:44:07 -07:00
Andrew Pareles 58b4d90b49 changeDiff prompt, chat thread state, improve import/export 2025-04-20 18:32:13 -07:00
Andrew Pareles 39ea63b207 add download/upload/reset 2025-04-20 06:43:49 -07:00
Andrew Pareles b02b2f0c89 duplicate thread, retry, search_in_file 2025-04-20 04:22:05 -07:00
Mathew Pareles 902d419026 at to mention draft 2025-04-20 02:06:35 -07:00
Andrew Pareles b244f2f08b tooltips 2025-04-20 01:54:44 -07:00
Andrew Pareles 4d9b52ede6 loader and misc 2025-04-20 01:13:47 -07:00
Andrew Pareles e94d2de641 bgTerminalId !== null 2025-04-19 23:59:12 -07:00
Andrew Pareles 5fdc5d6df2 Merge pull request #421 from voideditor/model-selection
fix ajv missing dep
2025-04-19 17:10:12 -07:00
Andrew Pareles cf5ff98081 fix ajv missing dep 2025-04-19 17:07:59 -07:00
Andrew Pareles 45eeb14b54 Merge pull request #420 from voideditor/model-selection
package-lock fix
2025-04-19 16:28:11 -07:00
Andrew Pareles dcfb5d443c fix package-lock? 2025-04-19 16:27:39 -07:00
Andrew Pareles f839e73ebc @types/katex as dev dep 2025-04-19 00:26:47 -07:00
Andrew Pareles c775d34d75 Merge pull request #417 from voideditor/model-selection
Misc updates
2025-04-18 20:58:55 -07:00
Andrew Pareles 3023ed87d2 1.2.6 2025-04-18 20:58:43 -07:00
Andrew Pareles 3a5a04ceae Update README.md 2025-04-18 20:54:57 -07:00
Andrew Pareles 22475f7206 Update README.md 2025-04-18 20:54:40 -07:00
Andrew Pareles 68333ce349 Update README.md 2025-04-18 20:54:01 -07:00
Andrew Pareles 9f3dbc3eb4 Update VOID_CODEBASE_GUIDE.md 2025-04-18 20:52:13 -07:00
Andrew Pareles 10949b44d1 add retries, fix key, X on autodetected 2025-04-18 20:48:57 -07:00
Andrew Pareles 45778d5735 Update VOID_CODEBASE_GUIDE.md 2025-04-18 18:49:17 -07:00
Andrew Pareles 816d6090cc Update VOID_CODEBASE_GUIDE.md 2025-04-18 18:46:04 -07:00
Andrew Pareles 355d8d9a5a Update VOID_CODEBASE_GUIDE.md 2025-04-18 18:41:06 -07:00
Andrew Pareles b47e8f2237 Update VOID_CODEBASE_GUIDE.md 2025-04-18 18:14:36 -07:00
Andrew Pareles 0543b2c893 Update VOID_CODEBASE_GUIDE.md 2025-04-18 18:11:38 -07:00
Andrew Pareles 69cf58af5a Update VOID_CODEBASE_GUIDE.md 2025-04-18 18:06:25 -07:00
Andrew Pareles f9b3dbe316 Update VOID_CODEBASE_GUIDE.md 2025-04-18 18:05:48 -07:00
Andrew Pareles 176b988539 Update VOID_CODEBASE_GUIDE.md 2025-04-18 18:04:21 -07:00
Andrew Pareles 11cc9f8b4e Update VOID_CODEBASE_GUIDE.md 2025-04-18 18:03:33 -07:00
Andrew Pareles ab396d4c1d Update VOID_CODEBASE_GUIDE.md 2025-04-18 18:02:22 -07:00
Andrew Pareles a01ca97b18 Update VOID_CODEBASE_GUIDE.md 2025-04-18 18:01:09 -07:00
Andrew Pareles 1b4105ed2b Update issue_template.md 2025-04-18 14:57:16 -07:00
Andrew Pareles 4024ed40c2 Update issue_template.md 2025-04-18 14:56:56 -07:00
Andrew Pareles 8c53878681 Update issue_template.md 2025-04-18 14:56:33 -07:00
Andrew Pareles 7bd52438f5 try/catch voidrules 2025-04-18 06:24:33 -07:00
Andrew Pareles 6f2d513d3f Delete .github/issue_template.md 2025-04-18 06:19:54 -07:00
Andrew Pareles 800aeea247 misc fixes 2025-04-18 06:08:54 -07:00
Mathew Pareles 021acf541a latex works with some hacks 2025-04-18 05:42:35 -07:00
Andrew Pareles 7a739e567c prepare retry loop 2025-04-18 04:44:14 -07:00
Andrew Pareles ef52ebfdc2 2.5 pro 2025-04-18 04:03:32 -07:00
Andrew Pareles e83a172816 reorder 2025-04-18 04:00:01 -07:00
Andrew Pareles c3ef7e616f lm studio reasoning! 2025-04-18 03:59:23 -07:00
Andrew Pareles f77907fe0d LM studio refreshing 2025-04-18 03:48:44 -07:00
Andrew Pareles 6d28b0627f add GCP vertex (w/ auth), MSFT azure, litellm, lm studio - NEEDS TESTING 2025-04-18 03:48:44 -07:00
Andrew Pareles 7d3ce48f5f extract \n from FIM 2025-04-18 03:48:44 -07:00
Andrew Pareles 97c611f4f2 improve Fast Apply prompt 2025-04-18 03:48:44 -07:00
Mathew Pareles 90d94ecd3c ux 2025-04-18 01:03:08 -07:00
Mathew Pareles 9f7154ddab table 2025-04-18 00:11:36 -07:00
Mathew Pareles 5cdb4b0c47 ux 2025-04-17 23:35:23 -07:00
Mathew Pareles a61fdd0419 ux 2025-04-17 23:32:01 -07:00
Mathew Pareles b7db527866 history ux 2025-04-17 22:41:40 -07:00
Mathew Pareles c921059690 thread history ux 2025-04-17 21:27:21 -07:00
Mathew Pareles cee6dcc524 rename .voidinstructions to .voidrules ... 2025-04-17 21:27:21 -07:00
Andrew Pareles b9e097bfc0 Update issue_template.md 2025-04-17 18:46:58 -07:00
Andrew Pareles 87c6669020 Update issue_template.md 2025-04-17 18:46:42 -07:00
Andrew Pareles 80448c4471 Update issue_template.md 2025-04-17 18:44:57 -07:00
Andrew Pareles b4f9c1fa97 Delete .github/issue_template.md 2025-04-17 18:43:57 -07:00
Andrew Pareles 10052819ce Update issue_template.md 2025-04-17 18:43:44 -07:00
Andrew Pareles 0f2ebecbd3 Create issue_template.md 2025-04-17 18:39:58 -07:00
Andrew Pareles eb68e1d294 Update issue_template.md 2025-04-17 18:38:55 -07:00
Andrew Pareles 17aa9eea97 prompt 2025-04-17 18:29:37 -07:00
Andrew Pareles 5e85d804f8 update 2025-04-17 17:30:07 -07:00
Andrew Pareles 8f2a2d642f Merge pull request #415 from voideditor/model-selection
Template
2025-04-17 17:22:41 -07:00
Andrew Pareles 6141351e15 issue template 2025-04-17 17:21:53 -07:00
Andrew Pareles 00e196a55f Update HOW_TO_CONTRIBUTE.md 2025-04-17 15:54:16 -07:00
Andrew Pareles 16913e96e5 prompt 2025-04-16 23:44:24 -07:00
Andrew Pareles 6f55e6900b Merge pull request #413 from voideditor/model-selection
1.2.5 misc
2025-04-16 22:57:50 -07:00
Andrew Pareles 36e10dcc08 1.2.5 2025-04-16 22:56:52 -07:00
Andrew Pareles ce8c790e5c undo version 2025-04-16 22:47:52 -07:00
Mathew Pareles 738067951a ux 2025-04-16 22:31:28 -07:00
Andrew Pareles 8b1a42499b add more boundaries 2025-04-16 21:56:23 -07:00
Mathew Pareles 7fd52b4805 fix commandbarinchat state 2025-04-16 20:44:47 -07:00
Mathew Pareles 5f30506f46 terminal tool style 2025-04-16 20:44:47 -07:00
Andrew Pareles 48efc32dd3 lint finite size 2025-04-16 20:25:12 -07:00
Andrew Pareles 5fffd2fe0f rename command tool 2025-04-16 20:23:52 -07:00
Andrew Pareles 664428c8f0 add download chat buttons 2025-04-16 18:09:03 -07:00
Andrew Pareles aa8b999da0 Merge pull request #409 from voideditor/model-selection
prompt update for o3
2025-04-16 14:31:10 -07:00
Andrew Pareles d99d2cca4a prompt update for o3 2025-04-16 14:30:49 -07:00
43 changed files with 9409 additions and 5041 deletions
+9
View File
@@ -0,0 +1,9 @@
---
name: Issue
about: Submit an Issue to Void
title: For VSCode-related issues (eg builds), please start the title with `[App]`. Otherwise, start it with `[Bug]` or `[Feature]`.
---
1. Press `Cmd+Shift+P` in Void, and type `Help: About`. Please paste the information here. Also let us know any other relevant details, like the model and provider you're using if applicable.
2. Describe the issue/feature here.
+1 -1
View File
@@ -119,7 +119,7 @@ workspace/
```
### Distributing
Void's maintainers distribute Void on our website and in releases. If you'd like to see the scripts to convert `Mac .app -> .dmg`, `Windows folder -> .exe`, and `Linux folder -> appimage` for distribution, feel free to reach out.
Void's maintainers distribute Void on our website and in releases. Our build pipeline is a fork of VSCodium, and it works by running GitHub Actions which create the downloadables. The build repo with more instructions lives [here](https://github.com/voideditor/void-builder).
## Pull Request Guidelines
+2 -2
View File
@@ -27,14 +27,14 @@ This repo contains the full sourcecode for Void. We are currently in [open beta]
1. Feel free to attend a weekly meeting in our Discord channel if you'd like to contribute!
2. To get started working on Void, see [here](https://github.com/voideditor/void/blob/main/HOW_TO_CONTRIBUTE.md).
2. To get started working on Void, see [`HOW_TO_CONTRIBUTE`](https://github.com/voideditor/void/blob/main/HOW_TO_CONTRIBUTE.md).
3. We're open to collaborations and suggestions of all types - just reach out.
## Reference
Void is a fork of the [vscode](https://github.com/microsoft/vscode) repository. For a guide to the VSCode/Void codebase, see [our Codebase Guide](https://github.com/voideditor/void/blob/main/VOID_CODEBASE_GUIDE.md).
Void is a fork of the [vscode](https://github.com/microsoft/vscode) repository. For a guide to the VSCode/Void codebase, see [`VOID_CODEBASE_GUIDE`](https://github.com/voideditor/void/blob/main/VOID_CODEBASE_GUIDE.md).
## Support
Feel free to reach out in our Discord or contact us via email: hello@voideditor.com.
+34 -25
View File
@@ -4,47 +4,53 @@ The Void codebase is not as intimidating as it seems!
Most of Void's code lives in the folder `src/vs/workbench/contrib/void/`.
The purpose of this document is to explain how Void's codebase works. If you want build instructions, see [Contributing](https://github.com/voideditor/void/blob/main/HOW_TO_CONTRIBUTE.md).
The purpose of this document is to explain how Void's codebase works. If you want build instructions instead, see [Contributing](https://github.com/voideditor/void/blob/main/HOW_TO_CONTRIBUTE.md).
## Void Codebase Guide
### Terminology
### VSCode Rundown
Here's a VSCode rundown if you're just getting started with Void. You can also see Microsoft's [wiki](https://github.com/microsoft/vscode/wiki/Source-Code-Organization) for some pictures. VSCode is an Electron app. Electron runs two processes: a **main** process (for internals) and a **browser** process (browser means HTML in general, not just "web browser").
<p align="center" >
<img src="https://github.com/user-attachments/assets/eef80306-2bfe-4cac-ba15-6156f65ab3bb" alt="Credit - https://github.com/microsoft/vscode/wiki/Source-Code-Organization" width="700px">
</p>
Here is some important terminology you should know if you're working inside VSCode:
- An **Editor** is the thing that you type your code in. If you have 10 tabs open, that's just one editor! Editors contain tabs (or "models").
- A **Model** is an internal representation of a file's contents. It's shared between editors (for example, if you press `Cmd+\` to make a new editor, then the model of a file like `A.ts` is shared between them. Two editors, one model. That's how changes sync.).
- Each model has a **URI** it represents, like `/Users/.../my_file.txt`. (A URI or "resource" is generally just a path).
- The **Workbench** is the wrapper that contains all the editors, the terminal, the file system tree, etc.
- Usually you use the `ITextModel` type for models and the `ICodeEditor` type for editors. There aren't that many other types.
### Minimal VSCode Rundown
Here's a minimal VSCode rundown if you're just getting started with Void:
- VSCode is (and therefore Void is) an Electron app. Electron runs two processes: a **main** process (for internals) and a **browser** process (browser means HTML in general, not just "web browser").
- Code in a `browser/` folder always lives on the browser process, and it can use `window` and other browser items.
- Code in an `electron-main/` folder always lives on the main process, and it can import `node_modules`.
- Code in `common/` can be used by either process, but doesn't get any special imports.
- The browser environment is not allowed to import `node_modules`, but there are two workarounds:
- The browser environment is not allowed to import `node_modules`. We came up with two workarounds:
1. Bundle the raw node_module code to the browser - we're doing this for React.
2. Implement the code on `electron-main/` and set up a channel between main/browser - we're doing this for sendLLMMessage.
VSCode is organized into "Services". A service is just a class that mounts a single time (in computer science theory this is called a "singleton"). You can register services with `registerSingleton` so that you can easily use them in any constructor with `@<Service>`. See _dummyContrib for an example we put together on how to register them. The registration is the same every time.
Services are always lazily created, even if you register them as Eager. If you want something that always runs on Void's mount, you should use a "workbench contribution". See _dummyContrib for this. Very similar to a Service, just registered slightly differently.
### Terminology
Actions or "commands" are functions you register on VSCode so that either you or the user can call them later. You can run actions as a user by pressing Cmd+Shift+P (opens the command pallete), or you can run them internally by using the commandService to call them by ID. We use actions to register keybinding listeners like Cmd+L, Cmd+K, etc. The nice thing about actions is the user can change the keybindings.
Here's some terminology you might want to know about when working inside VSCode:
- An **Editor** is the thing that you type your code in. If you have 10 tabs open, that's just one editor! Editors contain tabs (or "models").
- A **Model** is an internal representation of a file's contents. It's shared between editors (for example, if you press `Cmd+\` to make a new editor, then the model of a file like `A.ts` is shared between them. Two editors, one model. That's how changes sync.).
- Each model has a **URI** it represents, like `/Users/.../my_file.txt`. (A URI or "resource" is generally just a path).
- The **Workbench** is the wrapper that contains all the editors, the terminal, the file system tree, etc.
- Usually you use the `ITextModel` type for models and the `ICodeEditor` type for editors. There aren't that many other types.
<p align="center" >
<img src="https://github.com/user-attachments/assets/6521c228-dc96-4cf5-a673-6b9ca78b9b06" alt="Credit - https://code.visualstudio.com/docs/getstarted/userinterface" width="400px">
</p>
See [here](https://github.com/microsoft/vscode/wiki/Source-Code-Organization) for a decent VSCode guide with even more info.
- VSCode is organized into "**Services**". A service is just a class that mounts a single time (in computer science theory this is called a "singleton"). You can register services with `registerSingleton` so that you can easily use them in any constructor with `@<Service>`. See _dummyContrib for an example we put together on how to register them. The registration is the same every time.
- "**Actions**" are functions you register on VSCode so that either you or the user can call them later. They're also called "**Commands**".
- You can run actions as a user by pressing Cmd+Shift+P (opens the command pallete), or you can run them internally by using the commandService to call them by ID. We use actions to register keybinding listeners like Cmd+L, Cmd+K, etc. The nice thing about actions is the user can change the keybindings.
Each section below contains an overview of a core part of Void's sourcecode. You might want to scroll to find the item that's relevant to you.
### Internal LLM Message Pipeline
@@ -80,13 +86,16 @@ This is what allows Void to quickly apply code even on 1000-line files. It's the
The `editCodeService` file runs Apply. The same exact code is also used when the LLM calls the Edit tool, and when you submit Cmd+K. Just different versions of Fast/Slow Apply mode.
Here is some important terminology:
- A **DiffZone** is a {startLine, endLine} region in which we show Diffs (red/green areas). We update it when the user types, so it's always accurate.
- A **DiffArea** is a generalization that tracks line numbers like a DiffZone.
- The only type of zone that can "stream" is a DiffZone. Each DiffZone has an llmCancelToken if it's streaming.
- A **DiffZone** is a {startLine, endLine} region of text where we compute and show red/green areas, or **Diffs**. When any changes are made to a file, we loop through all the DiffAreas on that file and refresh its Diffs.
- A **DiffArea** is a generalization that just tracks line numbers like a DiffZone.
- The only type of DiffArea that can "stream" is a DiffZone. Each DiffZone has an llmCancelToken if it's streaming.
How Apply works:
- When you click Apply, we create a **DiffZone** over that the full file so that any changes that the LLM makes will show up in red/green. We then stream the change.
- When an LLM calls Edit, it's really calling Apply.
- When you submit Cmd+K, it's the same as Apply except we create a smaller DiffZone (not on the whole file).
### Writing Files Inner Workings
When Void wants to change your code, it just writes to a text model. This means all you need to know to write to a file is its URI - you don't have to load it, save it, etc. There are some annoying background URI/model things to think about to get this to work, but we handled them all in `voidModelService`.
@@ -121,7 +130,7 @@ If you want to know how our build pipeline works, see our build repo [here](http
## VSCode Codebase Guide (Not Void)
## VSCode Codebase Guide
The Void team put together this list of links to get up and running with VSCode's sourcecode, the foundation of Void. We hope it's helpful!
+3 -3
View File
@@ -86,7 +86,7 @@ function prepareDebPackage(arch) {
const dependencies = await dependenciesGenerator.getDependencies('deb', binaryDir, product.applicationName, debArch);
gulp.src('resources/linux/debian/control.template', { base: '.' })
.pipe(replace('@@NAME@@', product.applicationName))
.pipe(replace('@@VERSION@@', `${product.voidVersion}.${packageJson.release}`))
.pipe(replace('@@VERSION@@', `${packageJson.version}.${packageJson.release}`))
.pipe(replace('@@ARCHITECTURE@@', debArch))
.pipe(replace('@@DEPENDS@@', dependencies.join(', ')))
.pipe(replace('@@RECOMMENDS@@', debianRecommendedDependencies.join(', ')))
@@ -201,7 +201,7 @@ function prepareRpmPackage(arch) {
.pipe(replace('@@NAME@@', product.applicationName))
.pipe(replace('@@NAME_LONG@@', product.nameLong))
.pipe(replace('@@ICON@@', product.linuxIconName))
.pipe(replace('@@VERSION@@', `${product.voidVersion}.${packageJson.release}`))
.pipe(replace('@@VERSION@@', `${packageJson.version}.${packageJson.release}`))
.pipe(replace('@@ARCHITECTURE@@', rpmArch))
.pipe(replace('@@LICENSE@@', product.licenseName))
.pipe(replace('@@QUALITY@@', product.quality || '@@QUALITY@@'))
@@ -277,7 +277,7 @@ function prepareSnapPackage(arch) {
const snapcraft = gulp.src('resources/linux/snap/snapcraft.yaml', { base: '.' })
.pipe(replace('@@NAME@@', product.applicationName))
.pipe(replace('@@VERSION@@', `${product.voidVersion}.${packageJson.release}`))
.pipe(replace('@@VERSION@@', `${packageJson.version}.${packageJson.release}`))
// Possible run-on values https://snapcraft.io/docs/architectures
.pipe(replace('@@ARCHITECTURE@@', arch === 'x64' ? 'amd64' : arch))
.pipe(rename('snap/snapcraft.yaml'));
+6388 -3938
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -77,6 +77,7 @@
"@microsoft/1ds-core-js": "^3.2.13",
"@microsoft/1ds-post-js": "^3.2.13",
"@mistralai/mistralai": "^1.5.0",
"@modelcontextprotocol/sdk": "^1.9.0",
"@parcel/watcher": "2.5.1",
"@types/semver": "^7.5.8",
"@vscode/deviceid": "^0.1.1",
@@ -102,6 +103,7 @@
"@xterm/addon-webgl": "^0.19.0-beta.98",
"@xterm/headless": "^5.6.0-beta.98",
"@xterm/xterm": "^5.6.0-beta.98",
"ajv": "^8.17.1",
"cross-spawn": "^7.0.6",
"diff": "^7.0.0",
"eslint-plugin-react": "^7.37.4",
@@ -109,6 +111,7 @@
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.2",
"jschardet": "3.1.4",
"katex": "^0.16.22",
"kerberos": "2.1.1",
"lucide-react": "^0.477.0",
"marked": "^15.0.7",
@@ -144,6 +147,7 @@
"@types/eslint": "^9.6.1",
"@types/gulp-svgmin": "^1.2.1",
"@types/http-proxy-agent": "^2.0.1",
"@types/katex": "^0.16.7",
"@types/kerberos": "^1.1.2",
"@types/minimist": "^1.2.1",
"@types/mocha": "^9.1.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"nameShort": "Void",
"nameLong": "Void",
"voidVersion": "1.2.4",
"voidVersion": "1.2.6",
"applicationName": "void",
"dataFolderName": ".void-editor",
"win32MutexName": "voideditor",
@@ -38,7 +38,6 @@ registerAction2(class extends Action2 {
});
}
async run(accessor: ServicesAccessor): Promise<void> {
console.log('hi')
const n = accessor.get(IDummyService)
console.log('Hi', n._serviceBrand)
}
@@ -59,6 +58,6 @@ class DummyService extends Disposable implements IWorkbenchContribution, IDummyS
// pick one and delete the other:
registerSingleton(IDummyService, DummyService, InstantiationType.Eager);
registerSingleton(IDummyService, DummyService, InstantiationType.Eager); // lazily loaded, even if Eager
registerWorkbenchContribution2(DummyService.ID, DummyService, WorkbenchPhase.BlockRestore);
registerWorkbenchContribution2(DummyService.ID, DummyService, WorkbenchPhase.BlockRestore); // mounts on start
@@ -16,7 +16,7 @@ import { getErrorMessage, RawToolCallObj, RawToolParamsObj } from '../common/sen
import { generateUuid } from '../../../../base/common/uuid.js';
import { FeatureName, ModelSelection, ModelSelectionOptions } from '../common/voidSettingsTypes.js';
import { IVoidSettingsService } from '../common/voidSettingsService.js';
import { ToolCallParams, ToolResultType, toolNamesThatRequireApproval } from '../common/toolsServiceTypes.js';
import { approvalTypeOfToolName, ToolCallParams, ToolResultType } from '../common/toolsServiceTypes.js';
import { IToolsService } from './toolsService.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js';
@@ -32,6 +32,14 @@ import { INotificationService, Severity } from '../../../../platform/notificatio
import { truncate } from '../../../../base/common/strings.js';
import { THREAD_STORAGE_KEY } from '../common/storageKeys.js';
import { IConvertToLLMMessageService } from './convertToLLMMessageService.js';
import { timeout } from '../../../../base/common/async.js';
import { deepClone } from '../../../../base/common/objects.js';
// related to retrying when LLM message has error
const CHAT_RETRIES = 3
const RETRY_DELAY = 2500
export const findStagingSelectionIndex = (currentSelections: StagingSelectionItem[] | undefined, newSelection: StagingSelectionItem): number | null => {
if (!currentSelections) return null
@@ -91,7 +99,7 @@ const defaultMessageState: UserMessageState = {
// a 'thread' means a chat message history
type ThreadType = {
export type ThreadType = {
id: string; // store the id here too
createdAt: string; // ISO string
lastModified: string; // ISO string
@@ -179,6 +187,10 @@ export interface IChatThreadService {
openNewThread(): void;
switchToThread(threadId: string): void;
// thread selector
deleteThread(threadId: string): void;
duplicateThread(threadId: string): void;
// exposed getters/setters
// these all apply to current thread
getCurrentMessageState: (messageIdx: number) => UserMessageState
@@ -190,6 +202,10 @@ export interface IChatThreadService {
getCurrentFocusedMessageIdx(): number | undefined;
isCurrentlyFocusingMessage(): boolean;
setCurrentlyFocusedMessageIdx(messageIdx: number | undefined): void;
dangerousSetState: (newState: ThreadsState) => void;
resetState: () => void;
// // current thread's staging selections
// closeCurrentStagingSelectionsInMessage(opts: { messageIdx: number }): void;
// closeCurrentStagingSelectionsInThread(): void;
@@ -281,6 +297,16 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
dangerousSetState = (newState: ThreadsState) => {
this.state = newState
this._onDidChangeCurrentThread.fire()
}
resetState = () => {
this.state = { allThreads: {}, currentThreadId: null as unknown as string } // see constructor
this.openNewThread()
this._onDidChangeCurrentThread.fire()
}
// !!! this is important for properly restoring URIs from storage
// should probably re-use code from void/src/vs/base/common/marshalling.ts instead. but this is simple enough
private _convertThreadDataFromStorage(threadsStr: string): ChatThreads {
@@ -364,7 +390,8 @@ class ChatThreadService extends Disposable implements IChatThreadService {
if (!messages) return false
const lastMsg = messages[messages.length - 1]
if (!lastMsg) return false
if (lastMsg.role === 'tool' && (lastMsg.type === 'running_now' || lastMsg.type === 'tool_request')) {
if (lastMsg.role === 'tool' && lastMsg.type !== 'invalid_params') {
this._editMessageInThread(threadId, messages.length - 1, tool)
return true
}
@@ -381,9 +408,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
if (!thread) return // should never happen
const lastMsg = thread.messages[thread.messages.length - 1]
if (!(
lastMsg.role === 'tool' && (lastMsg.type === 'tool_request')
)) return // should never happen
if (!(lastMsg.role === 'tool' && lastMsg.type === 'tool_request')) return // should never happen
const callThisToolFirst: ToolMessage<ToolName> = lastMsg
@@ -399,7 +424,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
const lastMsg = thread.messages[thread.messages.length - 1]
let params: ToolCallParams[ToolName]
if (lastMsg.role === 'tool' && (lastMsg.type === 'running_now' || lastMsg.type === 'tool_request')) {
if (lastMsg.role === 'tool' && lastMsg.type !== 'invalid_params') {
params = lastMsg.params
}
else return
@@ -482,11 +507,13 @@ class ChatThreadService extends Disposable implements IChatThreadService {
if (toolName === 'edit_file') { this._addToolEditCheckpoint({ threadId, uri: (toolParams as ToolCallParams['edit_file']).uri }) }
// 2. if tool requires approval, break from the loop, awaiting approval
const toolRequiresApproval = toolNamesThatRequireApproval.has(toolName)
if (toolRequiresApproval) {
const autoApprove = this._settingsService.state.globalSettings.autoApprove
const approvalType = approvalTypeOfToolName[toolName]
if (approvalType) {
const autoApprove = this._settingsService.state.globalSettings.autoApprove[approvalType]
// add a tool_request because we use it for UI if a tool is loading (this should be improved in the future)
this._addMessageToThread(threadId, { role: 'tool', type: 'tool_request', content: '(never)', result: null, name: toolName, params: toolParams, id: toolId, rawParams: opts.unvalidatedToolParams })
this._addMessageToThread(threadId, { role: 'tool', type: 'tool_request', content: '(Awaiting user permission...)', result: null, name: toolName, params: toolParams, id: toolId, rawParams: opts.unvalidatedToolParams })
if (!autoApprove) {
return { awaitingUserApproval: true }
}
@@ -515,6 +542,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
if (interrupted) { return { interrupted: true } } // the tool result is added where we interrupt, not here
}
catch (error) {
delete this._currentlyRunningToolInterruptor[threadId]
if (interrupted) { return { interrupted: true } } // the tool result is added where we interrupt, not here
@@ -534,6 +562,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
// 5. add to history and keep going
this._updateLatestTool(threadId, { role: 'tool', type: 'success', params: toolParams, result: toolResult, name: toolName, content: toolResultStr, id: toolId, rawParams: opts.unvalidatedToolParams })
delete this._currentlyRunningToolInterruptor[threadId]
return {}
};
@@ -564,7 +593,6 @@ class ChatThreadService extends Disposable implements IChatThreadService {
let nMessagesSent = 0
let shouldSendAnotherMessage = true
let isRunningWhenEnd: IsRunningType = undefined
let aborted = false
// before enter loop, call tool
if (callThisToolFirst) {
@@ -579,12 +607,6 @@ class ChatThreadService extends Disposable implements IChatThreadService {
isRunningWhenEnd = undefined
nMessagesSent += 1
let resMessageIsDonePromise: (toolCall?: RawToolCallObj | undefined) => void // resolves when user approves this tool use (or if tool doesn't require approval)
const messageIsDonePromise = new Promise<RawToolCallObj | undefined>((res, rej) => { resMessageIsDonePromise = res })
// send llm message
this._setStreamState(threadId, { isRunning: 'LLM' }, 'merge')
const chatMessages = this.state.allThreads[threadId]?.messages ?? []
const { messages, separateSystemMessage } = await this._convertToLLMMessagesService.prepareLLMChatMessages({
chatMessages,
@@ -592,70 +614,102 @@ class ChatThreadService extends Disposable implements IChatThreadService {
chatMode
})
const llmCancelToken = this._llmMessageService.sendLLMMessage({
messagesType: 'chatMessages',
chatMode,
messages: messages,
modelSelection,
modelSelectionOptions,
logging: { loggingName: `Chat - ${chatMode}`, loggingExtras: { threadId, nMessagesSent, chatMode } },
separateSystemMessage: separateSystemMessage,
onText: ({ fullText, fullReasoning, toolCall }) => {
this._setStreamState(threadId, { displayContentSoFar: fullText, reasoningSoFar: fullReasoning, toolCallSoFar: toolCall }, 'merge')
},
onFinalMessage: async ({ fullText, fullReasoning, toolCall, anthropicReasoning, }) => {
this._addMessageToThread(threadId, { role: 'assistant', displayContent: fullText, reasoning: fullReasoning, anthropicReasoning })
this._setStreamState(threadId, { displayContentSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, toolCallSoFar: undefined }, 'merge')
resMessageIsDonePromise(toolCall) // resolve with tool calls
},
onError: (error) => {
const messageSoFar = this.streamState[threadId]?.displayContentSoFar ?? ''
const reasoningSoFar = this.streamState[threadId]?.reasoningSoFar ?? ''
// const toolCallSoFar = this.streamState[threadId]?.toolCallSoFar
// add assistant's message to chat history, and clear selection
this._addMessageToThread(threadId, { role: 'assistant', displayContent: messageSoFar, reasoning: reasoningSoFar, anthropicReasoning: null })
this._setStreamState(threadId, { error }, 'set')
resMessageIsDonePromise()
},
onAbort: () => {
// stop the loop to free up the promise, but don't modify state (already handled by whatever stopped it)
resMessageIsDonePromise()
this._metricsService.capture('Agent Loop Done (Aborted)', { nMessagesSent, chatMode })
aborted = true
},
})
let aborted = false
let shouldRetry = true
let nAttempts = 0
while (shouldRetry) {
shouldRetry = false
// should never happen, just for safety
if (llmCancelToken === null) {
this._setStreamState(threadId, {
error: { message: 'There was an unexpected error when sending your chat message.', fullError: null }
}, 'set')
break
}
this._setStreamState(threadId, { streamingToken: llmCancelToken }, 'merge') // new stream token for the new message
const toolCall = await messageIsDonePromise // wait for message to complete
if (aborted) { return }
this._setStreamState(threadId, { streamingToken: undefined }, 'merge') // streaming message is done
let resMessageIsDonePromise: (toolCall?: RawToolCallObj | undefined) => void // resolves when user approves this tool use (or if tool doesn't require approval)
const messageIsDonePromise = new Promise<RawToolCallObj | undefined>((res, rej) => { resMessageIsDonePromise = res })
// call tool if there is one
const tool: RawToolCallObj | undefined = toolCall
if (tool) {
const { awaitingUserApproval, interrupted } = await this._runToolCall(threadId, tool.name, tool.id, { preapproved: false, unvalidatedToolParams: tool.rawParams })
// send llm message
this._setStreamState(threadId, { isRunning: 'LLM' }, 'merge')
// stop if interrupted. we don't have to do this for llmMessage because we have a stream token for it and onAbort gets called, but we don't have the equivalent for tools.
// just detect tool interruption which is the same as chat interruption right now
if (interrupted) { return }
const llmCancelToken = this._llmMessageService.sendLLMMessage({
messagesType: 'chatMessages',
chatMode,
messages: messages,
modelSelection,
modelSelectionOptions,
logging: { loggingName: `Chat - ${chatMode}`, loggingExtras: { threadId, nMessagesSent, chatMode } },
separateSystemMessage: separateSystemMessage,
onText: ({ fullText, fullReasoning, toolCall }) => {
this._setStreamState(threadId, { displayContentSoFar: fullText, reasoningSoFar: fullReasoning, toolCallSoFar: toolCall }, 'merge')
},
onFinalMessage: async ({ fullText, fullReasoning, toolCall, anthropicReasoning, }) => {
this._addMessageToThread(threadId, { role: 'assistant', displayContent: fullText, reasoning: fullReasoning, anthropicReasoning })
this._setStreamState(threadId, { displayContentSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, toolCallSoFar: undefined }, 'merge')
resMessageIsDonePromise(toolCall) // resolve with tool calls
if (awaitingUserApproval) {
isRunningWhenEnd = 'awaiting_user'
},
onError: async (error) => {
const messageSoFar = this.streamState[threadId]?.displayContentSoFar ?? ''
const reasoningSoFar = this.streamState[threadId]?.reasoningSoFar ?? ''
this._setStreamState(threadId, { displayContentSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, toolCallSoFar: undefined }, 'merge')
if (nAttempts < CHAT_RETRIES) {
nAttempts += 1
shouldRetry = true
await timeout(RETRY_DELAY)
resMessageIsDonePromise()
}
else {
// const toolCallSoFar = this.streamState[threadId]?.toolCallSoFar
// add assistant's message to chat history, and clear selection
this._addMessageToThread(threadId, { role: 'assistant', displayContent: messageSoFar, reasoning: reasoningSoFar, anthropicReasoning: null })
this._setStreamState(threadId, { error }, 'set')
resMessageIsDonePromise()
}
},
onAbort: () => {
// stop the loop to free up the promise, but don't modify state (already handled by whatever stopped it)
aborted = true
resMessageIsDonePromise()
this._metricsService.capture('Agent Loop Done (Aborted)', { nMessagesSent, chatMode })
},
})
// should never happen, just for safety
if (llmCancelToken === null) {
this._setStreamState(threadId, {
error: { message: 'There was an unexpected error when sending your chat message.', fullError: null }
}, 'set')
break
}
else {
shouldSendAnotherMessage = true
this._setStreamState(threadId, { streamingToken: llmCancelToken }, 'merge') // new stream token for the new message
const toolCall = await messageIsDonePromise // wait for message to complete
this._setStreamState(threadId, { streamingToken: undefined }, 'merge') // streaming message is done
// this is a complete hack to make it so if an error loop was aborted, we stop (because onAbort does not get called if error happens instantly)
// maybe we should remove all the abort stuff and just make it so that we only go by state?
if (!this.streamState[threadId]?.isRunning) { return }
if (aborted) { return }
if (shouldRetry) { continue }
// call tool if there is one
const tool: RawToolCallObj | undefined = toolCall
if (tool) {
const { awaitingUserApproval, interrupted } = await this._runToolCall(threadId, tool.name, tool.id, { preapproved: false, unvalidatedToolParams: tool.rawParams })
// stop if interrupted. we don't have to do this for llmMessage because we have a stream token for it and onAbort gets called, but we don't have the equivalent for tools.
// just detect tool interruption which is the same as chat interruption right now
if (!this.streamState[threadId]?.isRunning) { return }
if (aborted) { return }
if (interrupted) { return }
if (awaitingUserApproval) {
console.log('awaiting...')
isRunningWhenEnd = 'awaiting_user'
}
else {
shouldSendAnotherMessage = true
}
}
}
} // end while
} // end while (attempts)
} // end while (send message)
// if awaiting user approval, keep isRunning true, else end isRunning
this._setStreamState(threadId, { isRunning: isRunningWhenEnd }, 'merge')
@@ -853,7 +907,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
const [_, toIdx] = c
if (toIdx === fromIdx) return
console.log(`going from ${fromIdx} to ${toIdx}`)
// console.log(`going from ${fromIdx} to ${toIdx}`)
// update the user's checkpoint
this._addUserModificationsToCurrCheckpoint({ threadId })
@@ -991,8 +1045,8 @@ We only need to do it for files that were edited since `from`, ie files between
if (!thread) return // should never happen
const llmCancelToken = this.streamState[threadId]?.streamingToken // currently streaming LLM on this thread
if (llmCancelToken === undefined && this.streamState[threadId]?.isRunning === 'LLM') {
// if about to call the other LLM, just wait for it by stopping right now
if (llmCancelToken === undefined && this.streamState[threadId]?.isRunning) {
// if about to call the other LLM, just wait for and stop now
return
}
// stop it (this simply resolves the promise to free up space)
@@ -1171,8 +1225,9 @@ We only need to do it for files that were edited since `from`, ie files between
// else search codebase for `target`
let uris: URI[] = []
try {
const { result } = await this._toolsService.callTool['search_pathnames_only']({ queryStr: target, searchInFolder: null, pageNumber: 0 })
uris = result.uris
const { result } = await this._toolsService.callTool['search_pathnames_only']({ query: target, includePattern: null, pageNumber: 0 })
const { uris: uris_ } = await result
uris = uris_
} catch (e) {
return null
}
@@ -1370,10 +1425,9 @@ We only need to do it for files that were edited since `from`, ie files between
const { allThreads: currentThreads } = this.state
for (const threadId in currentThreads) {
if (currentThreads[threadId]!.messages.length === 0) {
// switch to the thread
// switch to the existing empty thread and exit
this.switchToThread(threadId)
return
}
}
// otherwise, start a new thread
@@ -1389,6 +1443,35 @@ We only need to do it for files that were edited since `from`, ie files between
}
deleteThread(threadId: string): void {
const { allThreads: currentThreads } = this.state
// delete the thread
const newThreads = { ...currentThreads };
delete newThreads[threadId];
// store the updated threads
this._storeAllThreads(newThreads);
this._setState({ ...this.state, allThreads: newThreads }, true)
}
duplicateThread(threadId: string) {
const { allThreads: currentThreads } = this.state
const threadToDuplicate = currentThreads[threadId]
if (!threadToDuplicate) return
const newThread = {
...deepClone(threadToDuplicate),
id: generateUuid(),
}
const newThreads = {
...currentThreads,
[newThread.id]: newThread,
}
this._storeAllThreads(newThreads)
this._setState({ allThreads: newThreads }, true)
}
private _addMessageToThread(threadId: string, message: ChatMessage) {
const { allThreads } = this.state
const oldThread = allThreads[threadId]
@@ -438,27 +438,32 @@ class ConvertToLLMMessageService extends Disposable implements IConvertToLLMMess
super()
}
// Read .voidinstructions files from workspace folders
private _getVoidInstructionsFileContents(): string {
const workspaceFolders = this.workspaceContextService.getWorkspace().folders;
let voidInstructions = '';
for (const folder of workspaceFolders) {
const uri = URI.joinPath(folder.uri, '.voidinstructions')
const { model } = this.voidModelService.getModel(uri)
if (!model) continue
voidInstructions += model.getValue() + '\n\n';
// Read .voidrules files from workspace folders
private _getVoidRulesFileContents(): string {
try {
const workspaceFolders = this.workspaceContextService.getWorkspace().folders;
let voidRules = '';
for (const folder of workspaceFolders) {
const uri = URI.joinPath(folder.uri, '.voidrules')
const { model } = this.voidModelService.getModel(uri)
if (!model) continue
voidRules += model.getValue() + '\n\n';
}
return voidRules.trim();
}
catch (e) {
return ''
}
return voidInstructions.trim();
}
// Get combined AI instructions from settings and .voidinstructions files
// Get combined AI instructions from settings and .voidrules files
private _getCombinedAIInstructions(): string {
const globalAIInstructions = this.voidSettingsService.state.globalSettings.aiInstructions;
const voidInstructionsFileContent = this._getVoidInstructionsFileContents();
const voidRulesFileContent = this._getVoidRulesFileContents();
const ans: string[] = []
if (globalAIInstructions) ans.push(globalAIInstructions)
if (voidInstructionsFileContent) ans.push(voidInstructionsFileContent)
if (voidRulesFileContent) ans.push(voidRulesFileContent)
return ans.join('\n\n')
}
@@ -21,8 +21,8 @@ class ConvertContribWorkbenchContribution extends Disposable implements IWorkben
const initializeURI = (uri: URI) => {
this.workspaceContext.getWorkspace()
const voidInstrsURI = URI.joinPath(uri, '.voidinstructions')
this.voidModelService.initializeModel(voidInstrsURI)
const voidRulesURI = URI.joinPath(uri, '.voidrules')
this.voidModelService.initializeModel(voidRulesURI)
}
// call
@@ -10,11 +10,10 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta
import { IFileService } from '../../../../platform/files/common/files.js';
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
import { ShallowDirectoryItem, ToolCallParams, ToolResultType } from '../common/toolsServiceTypes.js';
import { MAX_CHILDREN_URIs_PAGE } from './toolsService.js';
import { IExplorerService } from '../../files/browser/files.js';
import { SortOrder } from '../../files/common/files.js';
import { ExplorerItem } from '../../files/common/explorerModel.js';
import { MAX_DIRSTR_CHARS_TOTAL_BEGINNING, MAX_DIRSTR_CHARS_TOTAL_TOOL } from '../common/prompt/prompts.js';
import { MAX_CHILDREN_URIs_PAGE, MAX_DIRSTR_CHARS_TOTAL_BEGINNING, MAX_DIRSTR_CHARS_TOTAL_TOOL } from '../common/prompt/prompts.js';
const MAX_FILES_TOTAL = 300;
@@ -111,14 +110,14 @@ export const computeDirectoryTree1Deep = async (
export const stringifyDirectoryTree1Deep = (params: ToolCallParams['ls_dir'], result: ToolResultType['ls_dir']): string => {
if (!result.children) {
return `Error: ${params.rootURI} is not a directory`;
return `Error: ${params.uri} is not a directory`;
}
let output = '';
const entries = result.children;
if (!result.hasPrevPage) { // is first page
output += `${params.rootURI.fsPath}\n`;
output += `${params.uri.fsPath}\n`;
}
for (let i = 0; i < entries.length; i++) {
@@ -327,7 +326,7 @@ class DirectoryStrService extends Disposable implements IDirectoryStrService {
async getDirectoryStrTool(uri: URI, options?: { maxItemsPerDir?: number }) {
const eRoot = this.explorerService.findClosest(uri)
if (!eRoot) throw new Error(`There was a problem reading the URI: ${uri.fsPath}.`)
if (!eRoot) throw new Error(`The folder ${uri.fsPath} does not exist.`)
const maxItemsPerDir = options?.maxItemsPerDir ?? START_MAX_ITEMS_PER_DIR; // Use START_MAX_ITEMS_PER_DIR
@@ -419,7 +418,7 @@ class DirectoryStrService extends Disposable implements IDirectoryStrService {
}
if (cutOff) {
return `${str}\n${cutOffMessage}`
return `${str.trimEnd()}\n${cutOffMessage}`
}
return str
@@ -31,14 +31,11 @@ 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 '../common/helpers/extractCodeFromResult.js';
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
import { isMacintosh } from '../../../../base/common/platform.js';
import { INotificationService, } from '../../../../platform/notification/common/notification.js';
import { EditorOption } from '../../../../editor/common/config/editorOptions.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/sendLLMMessageService.js';
import { LLMChatMessage, OnError, errorDetails } from '../common/sendLLMMessageTypes.js';
import { LLMChatMessage } from '../common/sendLLMMessageTypes.js';
import { IMetricsService } from '../common/metricsService.js';
import { IEditCodeService, AddCtrlKOpts, StartApplyingOpts, CallBeforeStartApplyingOpts, } from './editCodeServiceInterface.js';
import { IVoidSettingsService } from '../common/voidSettingsService.js';
@@ -48,6 +45,8 @@ import { deepClone } from '../../../../base/common/objects.js';
import { acceptBg, acceptBorder, buttonFontSize, buttonTextColor, rejectBg, rejectBorder } from '../common/helpers/colors.js';
import { DiffArea, Diff, CtrlKZone, VoidFileSnapshot, DiffAreaSnapshotEntry, diffAreaSnapshotKeys, DiffZone, TrackingZone, ComputedDiff } from '../common/editCodeServiceTypes.js';
import { IConvertToLLMMessageService } from './convertToLLMMessageService.js';
// import { isMacintosh } from '../../../../base/common/platform.js';
// import { VOID_OPEN_SETTINGS_ACTION_ID } from './voidSettingsPane.js';
const configOfBG = (color: Color) => {
return { dark: color, light: color, hcDark: color, hcLight: color, }
@@ -199,7 +198,7 @@ class EditCodeService extends Disposable implements IEditCodeService {
@IConsistentEditorItemService private readonly _consistentEditorItemService: IConsistentEditorItemService,
@IMetricsService private readonly _metricsService: IMetricsService,
@INotificationService private readonly _notificationService: INotificationService,
@ICommandService private readonly _commandService: ICommandService,
// @ICommandService private readonly _commandService: ICommandService,
@IVoidSettingsService private readonly _settingsService: IVoidSettingsService,
// @IFileService private readonly _fileService: IFileService,
@IVoidModelService private readonly _voidModelService: IVoidModelService,
@@ -279,24 +278,24 @@ class EditCodeService extends Disposable implements IEditCodeService {
private _notifyError = (e: Parameters<OnError>[0]) => {
const details = errorDetails(e.fullError)
this._notificationService.notify({
severity: Severity.Warning,
message: `Void Error: ${e.message}`,
actions: {
secondary: [{
id: 'void.onerror.opensettings',
enabled: true,
label: `Open Void's settings`,
tooltip: '',
class: undefined,
run: () => { this._commandService.executeCommand(VOID_OPEN_SETTINGS_ACTION_ID) }
}]
},
source: details ? `(Hold ${isMacintosh ? 'Option' : 'Alt'} to hover) - ${details}\n\nIf this persists, feel free to [report](https://github.com/voideditor/void/issues/new) it.` : undefined
})
}
// private _notifyError = (e: Parameters<OnError>[0]) => {
// const details = errorDetails(e.fullError)
// this._notificationService.notify({
// severity: Severity.Warning,
// message: `Void Error: ${e.message}`,
// actions: {
// secondary: [{
// id: 'void.onerror.opensettings',
// enabled: true,
// label: `Open Void's settings`,
// tooltip: '',
// class: undefined,
// run: () => { this._commandService.executeCommand(VOID_OPEN_SETTINGS_ACTION_ID) }
// }]
// },
// source: details ? `(Hold ${isMacintosh ? 'Option' : 'Alt'} to hover) - ${details}\n\nIf this persists, feel free to [report](https://github.com/voideditor/void/issues/new) it.` : undefined
// })
// }
@@ -1393,7 +1392,7 @@ class EditCodeService extends Disposable implements IEditCodeService {
// throws
const onError = (e: { message: string; fullError: Error | null; }) => {
this._notifyError(e)
// this._notifyError(e)
onDone()
this._undoHistory(uri)
throw e.fullError
@@ -1612,7 +1611,7 @@ class EditCodeService extends Disposable implements IEditCodeService {
}
const onError = (e: { message: string; fullError: Error | null; }) => {
this._notifyError(e)
// this._notifyError(e)
onDone()
this._undoHistory(uri)
throw e.fullError || new Error(e.message) // throw error h
@@ -37,7 +37,7 @@ export const IconShell1 = ({ onClick, Icon, disabled, className, ...props }: Ico
size-[18px]
p-[2px]
flex items-center justify-center
text-sm bg-void-bg-3 text-void-fg-3
text-sm text-void-fg-3
hover:brightness-110
disabled:opacity-50 disabled:cursor-not-allowed
${className}
@@ -69,7 +69,7 @@ export const IconShell1 = ({ onClick, Icon, disabled, className, ...props }: Ico
const COPY_FEEDBACK_TIMEOUT = 1500 // amount of time to say 'Copied!'
export const CopyButton = ({ codeStr }: { codeStr: string }) => {
export const CopyButton = ({ codeStr, toolTipName }: { codeStr: string | (() => Promise<string> | string), toolTipName: string }) => {
const accessor = useAccessor()
const metricsService = accessor.get('IMetricsService')
@@ -83,8 +83,8 @@ export const CopyButton = ({ codeStr }: { codeStr: string }) => {
}, COPY_FEEDBACK_TIMEOUT)
}, [copyButtonText])
const onCopy = useCallback(() => {
clipboardService.writeText(codeStr)
const onCopy = useCallback(async () => {
clipboardService.writeText(typeof codeStr === 'string' ? codeStr : await codeStr())
.then(() => { setCopyButtonText(CopyButtonText.Copied) })
.catch(() => { setCopyButtonText(CopyButtonText.Error) })
metricsService.capture('Copy Code', { length: codeStr.length }) // capture the length only
@@ -93,7 +93,7 @@ export const CopyButton = ({ codeStr }: { codeStr: string }) => {
return <IconShell1
Icon={copyButtonText === CopyButtonText.Copied ? Check : copyButtonText === CopyButtonText.Error ? X : Copy}
onClick={onCopy}
{...tooltipPropsForApplyBlock({ tooltipName: 'Copy' })}
{...tooltipPropsForApplyBlock({ tooltipName: toolTipName })}
/>
}
@@ -374,7 +374,7 @@ export const BlockCodeApplyWrapper = ({
</div>
<div className={`${canApply ? '' : 'hidden'} flex items-center gap-1`}>
<JumpToFileButton uri={uri} />
{currStreamState === 'idle-no-changes' && <CopyButton codeStr={initValue} />}
{currStreamState === 'idle-no-changes' && <CopyButton codeStr={initValue} toolTipName='Copy' />}
<ApplyButtonsHTML uri={uri} applyBoxId={applyBoxId} codeStr={initValue} reapplyIcon={false} />
</div>
</div>
@@ -31,6 +31,63 @@ function isValidUri(s: string): boolean {
return s.length > 5 && isAbsolute(s) && !s.includes('//') && !s.includes('/*') // common case that is a false positive is comments like //
}
// renders contiguous string of latex eg $e^{i\pi}$
const LatexRender = ({ latex }: { latex: string }) => {
return <span className="katex-error text-red-500">{latex}</span>
// try {
// let formula = latex;
// let displayMode = false;
// // Extract the formula from delimiters
// if (latex.startsWith('$') && latex.endsWith('$')) {
// // Check if it's display math $$...$$
// if (latex.startsWith('$$') && latex.endsWith('$$')) {
// formula = latex.slice(2, -2);
// displayMode = true;
// } else {
// formula = latex.slice(1, -1);
// }
// } else if (latex.startsWith('\\(') && latex.endsWith('\\)')) {
// formula = latex.slice(2, -2);
// } else if (latex.startsWith('\\[') && latex.endsWith('\\]')) {
// formula = latex.slice(2, -2);
// displayMode = true;
// }
// // Render LaTeX
// const html = katex.renderToString(formula, {
// displayMode: displayMode,
// throwOnError: false,
// output: 'html'
// });
// // Sanitize the HTML output with DOMPurify
// const sanitizedHtml = dompurify.sanitize(html, {
// RETURN_TRUSTED_TYPE: true,
// USE_PROFILES: { html: true, svg: true, mathMl: true }
// });
// // Add proper styling based on mode
// const className = displayMode
// ? 'katex-block my-2 text-center'
// : 'katex-inline';
// // Use the ref approach to avoid dangerouslySetInnerHTML
// const mathRef = React.useRef<HTMLSpanElement>(null);
// React.useEffect(() => {
// if (mathRef.current) {
// mathRef.current.innerHTML = sanitizedHtml as unknown as string;
// }
// }, [sanitizedHtml]);
// return <span ref={mathRef} className={className}></span>;
// } catch (error) {
// console.error('KaTeX rendering error:', error);
// return <span className="katex-error text-red-500">{latex}</span>;
// }
}
const Codespan = ({ text, className, onClick }: { text: string, className?: string, onClick?: () => void }) => {
// TODO compute this once for efficiency. we should use `labels.ts/shorten` to display duplicates properly
@@ -108,6 +165,105 @@ const CodespanWithLink = ({ text, rawText, chatMessageLocation }: { text: string
}
const paragraphToLatexSegments = (paragraphText: string) => {
const segments: React.ReactNode[] = [];
if (paragraphText
&& !(paragraphText.includes('#') || paragraphText.includes('`')) // don't process latex if a codespan or header tag
&& !/^[\w\s.()[\]{}]+$/.test(paragraphText) // don't process latex if string only contains alphanumeric chars, whitespace, periods, and brackets
) {
const rawText = paragraphText;
// Regular expressions to match LaTeX delimiters
const displayMathRegex = /\$\$(.*?)\$\$/g; // Display math: $$...$$
const inlineMathRegex = /\$((?!\$).*?)\$/g; // Inline math: $...$ (but not $$)
// Check if the paragraph contains any LaTeX expressions
if (displayMathRegex.test(rawText) || inlineMathRegex.test(rawText)) {
// Reset the regex state (since we used .test earlier)
displayMathRegex.lastIndex = 0;
inlineMathRegex.lastIndex = 0;
// Parse the text into segments of regular text and LaTeX
let lastIndex = 0;
let segmentId = 0;
// First replace display math ($$...$$)
let match;
while ((match = displayMathRegex.exec(rawText)) !== null) {
const [fullMatch, formula] = match;
const matchIndex = match.index;
// Add text before the LaTeX expression
if (matchIndex > lastIndex) {
const textBefore = rawText.substring(lastIndex, matchIndex);
segments.push(
<span key={`text-${segmentId++}`}>
{textBefore}
</span>
);
}
// Add the LaTeX expression
segments.push(
<LatexRender key={`latex-${segmentId++}`} latex={fullMatch} />
);
lastIndex = matchIndex + fullMatch.length;
}
// Add any remaining text (which might contain inline math)
if (lastIndex < rawText.length) {
const remainingText = rawText.substring(lastIndex);
// Process inline math in the remaining text
lastIndex = 0;
inlineMathRegex.lastIndex = 0;
const inlineSegments: React.ReactNode[] = [];
while ((match = inlineMathRegex.exec(remainingText)) !== null) {
const [fullMatch] = match;
const matchIndex = match.index;
// Add text before the inline LaTeX
if (matchIndex > lastIndex) {
const textBefore = remainingText.substring(lastIndex, matchIndex);
inlineSegments.push(
<span key={`inline-text-${segmentId++}`}>
{textBefore}
</span>
);
}
// Add the inline LaTeX
inlineSegments.push(
<LatexRender key={`inline-latex-${segmentId++}`} latex={fullMatch} />
);
lastIndex = matchIndex + fullMatch.length;
}
// Add any remaining text after all inline math
if (lastIndex < remainingText.length) {
inlineSegments.push(
<span key={`inline-final-${segmentId++}`}>
{remainingText.substring(lastIndex)}
</span>
);
}
segments.push(...inlineSegments);
}
}
}
return segments
}
export type RenderTokenOptions = { isApplyEnabled?: boolean, isLinkDetectionEnabled?: boolean }
const RenderToken = ({ token, inPTag, codeURI, chatMessageLocation, tokenIdx, ...options }: { token: Token | string, inPTag?: boolean, codeURI?: URI, chatMessageLocation?: ChatMessageLocation, tokenIdx: string, } & RenderTokenOptions): React.ReactNode => {
const accessor = useAccessor()
@@ -189,24 +345,25 @@ const RenderToken = ({ token, inPTag, codeURI, chatMessageLocation, tokenIdx, ..
}
if (t.type === 'table') {
return (
<div>
<table>
<thead>
<tr>
{t.header.map((cell: any, index: number) => (
<th key={index}>
{cell.raw}
{t.header.map((h, hIdx: number) => (
<th key={hIdx}>
{h.text}
</th>
))}
</tr>
</thead>
<tbody>
{t.rows.map((row: any[], rowIndex: number) => (
<tr key={rowIndex}>
{row.map((cell: any, cellIndex: number) => (
<td key={cellIndex} >
{cell.raw}
{t.rows.map((row, rowIdx: number) => (
<tr key={rowIdx}>
{row.map((r, rIdx: number) => (
<td key={rIdx} >
{r.text}
</td>
))}
</tr>
@@ -288,6 +445,17 @@ const RenderToken = ({ token, inPTag, codeURI, chatMessageLocation, tokenIdx, ..
}
if (t.type === 'paragraph') {
// check for latex
const latexSegments = paragraphToLatexSegments(t.raw)
if (latexSegments.length !== 0) {
if (inPTag) {
return <span className='block'>{latexSegments}</span>;
}
return <p>{latexSegments}</p>;
}
// if no latex, default behavior
const contents = <>
{t.tokens.map((token, index) => (
<RenderToken key={index}
@@ -384,4 +552,3 @@ export const ChatMarkdownRender = ({ string, inPTag = false, chatMessageLocation
</>
)
}
@@ -4,7 +4,6 @@
*--------------------------------------------------------------------------------------*/
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { ErrorDisplay } from './ErrorDisplay.js';
import { WarningBox } from '../void-settings-tsx/WarningBox.js';
interface Props {
File diff suppressed because it is too large Load Diff
@@ -3,35 +3,20 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import React from "react";
import { useAccessor, useChatThreadsState } from '../util/services.js';
import { ISidebarStateService } from '../../../sidebarStateService.js';
import { useMemo, useState } from 'react';
import { CopyButton, IconShell1 } from '../markdown/ApplyBlockHoverButtons.js';
import { useAccessor, useChatThreadsState, useChatThreadsStreamState, useFullChatThreadsStreamState, useSettingsState } from '../util/services.js';
import { IconX } from './SidebarChat.js';
import { Check, Copy, Icon, LoaderCircle, MessageCircleQuestion, Trash2, UserCheck, X } from 'lucide-react';
import { IsRunningType, ThreadType } from '../../../chatThreadService.js';
const truncate = (s: string) => {
let len = s.length
const TRUNC_AFTER = 16
if (len >= TRUNC_AFTER)
s = s.substring(0, TRUNC_AFTER) + '...'
return s
}
export const OldSidebarThreadSelector = () => {
export const SidebarThreadSelector = () => {
const threadsState = useChatThreadsState()
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
const sidebarStateService = accessor.get('ISidebarStateService')
const { allThreads } = threadsState
// sorted by most recent to least recent
const sortedThreadIds = Object.keys(allThreads ?? {})
.sort((threadId1, threadId2) => (allThreads[threadId1]?.lastModified ?? 0) > (allThreads[threadId2]?.lastModified ?? 0) ? -1 : 1)
.filter(threadId => (allThreads![threadId]?.messages.length ?? 0) !== 0)
return (
<div className="flex p-2 flex-col gap-y-1 max-h-[200px] overflow-y-auto">
@@ -52,72 +37,369 @@ export const SidebarThreadSelector = () => {
</div>
{/* a list of all the past threads */}
<div className="px-1">
<ul className="flex flex-col gap-y-0.5 overflow-y-auto list-disc">
{sortedThreadIds.length === 0
? <div key="nothreads" className="text-center text-void-fg-3 brightness-90 text-sm">{`There are no chat threads yet.`}</div>
: sortedThreadIds.map((threadId) => {
if (!allThreads) {
return <li key="error" className="text-void-warning">{`Error accessing chat history.`}</li>;
}
const pastThread = allThreads[threadId];
if (!pastThread) {
return <li key="error" className="text-void-warning">{`Error accessing chat history.`}</li>;
}
let firstMsg = null;
// let secondMsg = null;
const firstUserMsgIdx = pastThread.messages.findIndex((msg) => msg.role === 'user');
if (firstUserMsgIdx !== -1) {
// firstMsg = truncate(pastThread.messages[firstMsgIdx].displayContent ?? '');
const firsUsertMsgObj = pastThread.messages[firstUserMsgIdx]
firstMsg = firsUsertMsgObj.role === 'user' && firsUsertMsgObj.displayContent || '';
} else {
firstMsg = '""';
}
// const secondMsgIdx = pastThread.messages.findIndex(
// (msg, i) => msg.role !== 'system' && !!msg.displayContent && i > firstMsgIdx
// );
// if (secondMsgIdx !== -1) {
// secondMsg = truncate(pastThread.messages[secondMsgIdx].displayContent ?? '');
// }
const numMessages = pastThread.messages.filter((msg) => msg.role === 'assistant' || msg.role === 'user').length;
return (
<li key={pastThread.id}>
<button
type='button'
className={`
hover:bg-void-bg-1
${threadsState.currentThreadId === pastThread.id ? 'bg-void-bg-1' : ''}
rounded-sm px-2 py-1
w-full
text-left
flex items-center
`}
onClick={() => chatThreadsService.switchToThread(pastThread.id)}
onDoubleClick={() => sidebarStateService.setState({ isHistoryOpen: false })}
title={new Date(pastThread.lastModified).toLocaleString()}
>
<div className='truncate'>{`${firstMsg}`}</div>
<div>{`\u00A0(${numMessages})`}</div>
</button>
</li>
);
})
}
</ul>
</div>
{/* <OldPastThreadsList /> */}
</div>
)
}
const truncate = (s: string) => {
let len = s.length
const TRUNC_AFTER = 16
if (len >= TRUNC_AFTER)
s = s.substring(0, TRUNC_AFTER) + '...'
return s
}
const OldPastThreadsList = () => {
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
const sidebarStateService = accessor.get('ISidebarStateService')
const threadsState = useChatThreadsState()
const { allThreads } = threadsState
// sorted by most recent to least recent
const sortedThreadIds = Object.keys(allThreads ?? {})
.sort((threadId1, threadId2) => (allThreads[threadId1]?.lastModified ?? 0) > (allThreads[threadId2]?.lastModified ?? 0) ? -1 : 1)
.filter(threadId => (allThreads![threadId]?.messages.length ?? 0) !== 0)
return <div className="px-1">
<ul className="flex flex-col gap-y-0.5 overflow-y-auto list-disc">
{sortedThreadIds.length === 0
? <div key="nothreads" className="text-center text-void-fg-3 brightness-90 text-root">{`There are no chat threads yet.`}</div>
: sortedThreadIds.map((threadId) => {
if (!allThreads) {
return <li key="error" className="text-void-warning">{`Error accessing chat history.`}</li>;
}
const pastThread = allThreads[threadId];
if (!pastThread) {
return <li key="error" className="text-void-warning">{`Error accessing chat history.`}</li>;
}
let firstMsg = null;
// let secondMsg = null;
const firstUserMsgIdx = pastThread.messages.findIndex((msg) => msg.role === 'user');
if (firstUserMsgIdx !== -1) {
// firstMsg = truncate(pastThread.messages[firstMsgIdx].displayContent ?? '');
const firsUsertMsgObj = pastThread.messages[firstUserMsgIdx]
firstMsg = firsUsertMsgObj.role === 'user' && firsUsertMsgObj.displayContent || '';
} else {
firstMsg = '""';
}
// const secondMsgIdx = pastThread.messages.findIndex(
// (msg, i) => msg.role !== 'system' && !!msg.displayContent && i > firstMsgIdx
// );
// if (secondMsgIdx !== -1) {
// secondMsg = truncate(pastThread.messages[secondMsgIdx].displayContent ?? '');
// }
const numMessages = pastThread.messages.filter((msg) => msg.role === 'assistant' || msg.role === 'user').length;
return (
<li key={pastThread.id}>
<button
type='button'
className={`
hover:bg-void-bg-1
${threadsState.currentThreadId === pastThread.id ? 'bg-void-bg-1' : ''}
rounded-sm px-2 py-1
w-full
text-left
flex items-center
`}
onClick={() => {
chatThreadsService.switchToThread(pastThread.id);
sidebarStateService.setState({ isHistoryOpen: false })
}}
title={new Date(pastThread.lastModified).toLocaleString()}
>
<div className='truncate'>{`${firstMsg}`}</div>
<div>{`\u00A0(${numMessages})`}</div>
</button>
</li>
);
})
}
</ul>
</div>
}
const numInitialThreads = 3
export const PastThreadsList = ({ className = '' }: { className?: string }) => {
const [showAll, setShowAll] = useState(false);
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null)
const threadsState = useChatThreadsState()
const { allThreads } = threadsState
const streamState = useFullChatThreadsStreamState()
const runningThreadIds: { [threadId: string]: IsRunningType | undefined } = {}
for (const threadId in streamState) {
const isRunning = streamState[threadId]?.isRunning
if (isRunning) { runningThreadIds[threadId] = isRunning }
}
if (!allThreads) {
return <div key="error" className="p-1">{`Error accessing chat history.`}</div>;
}
// sorted by most recent to least recent
const sortedThreadIds = Object.keys(allThreads ?? {})
.sort((threadId1, threadId2) => (allThreads[threadId1]?.lastModified ?? 0) > (allThreads[threadId2]?.lastModified ?? 0) ? -1 : 1)
.filter(threadId => (allThreads![threadId]?.messages.length ?? 0) !== 0)
// Get only first 5 threads if not showing all
const hasMoreThreads = sortedThreadIds.length > numInitialThreads;
const displayThreads = showAll ? sortedThreadIds : sortedThreadIds.slice(0, numInitialThreads);
return (
<div className={`flex flex-col mb-2 gap-2 w-full text-nowrap text-void-fg-3 select-none relative ${className}`}>
{displayThreads.length === 0
? <></> // No chats yet... Suggestion: Tell me about my codebase Suggestion: Create a new .voidrules file in the root of my repo
: displayThreads.map((threadId, i) => {
const pastThread = allThreads[threadId];
if (!pastThread) {
return <div key={i} className="p-1">{`Error accessing chat history.`}</div>;
}
return (
<PastThreadElement
key={pastThread.id}
pastThread={pastThread}
idx={i}
hoveredIdx={hoveredIdx}
setHoveredIdx={setHoveredIdx}
isRunning={runningThreadIds[pastThread.id]}
/>
);
})
}
{hasMoreThreads && !showAll && (
<div
className="text-void-fg-3 opacity-60 hover:opacity-100 hover:brightness-115 cursor-pointer p-1 text-xs"
onClick={() => setShowAll(true)}
>
Show {sortedThreadIds.length - numInitialThreads} more...
</div>
)}
{hasMoreThreads && showAll && (
<div
className="text-void-fg-3 opacity-60 hover:opacity-100 hover:brightness-115 cursor-pointer p-1 text-xs"
onClick={() => setShowAll(false)}
>
Show less
</div>
)}
</div>
);
};
// Format date to display as today, yesterday, or date
const formatDate = (date: Date) => {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date >= today) {
return 'Today';
} else if (date >= yesterday) {
return 'Yesterday';
} else {
return `${date.toLocaleString('default', { month: 'short' })} ${date.getDate()}`;
}
};
// Format time to 12-hour format
const formatTime = (date: Date) => {
return date.toLocaleString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
};
const DuplicateButton = ({ threadId }: { threadId: string }) => {
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
return <IconShell1
Icon={Copy}
className='size-[11px]'
onClick={() => { chatThreadsService.duplicateThread(threadId); }}
data-tooltip-id='void-tooltip'
data-tooltip-place='top'
data-tooltip-content='Duplicate thread'
>
</IconShell1>
}
const TrashButton = ({ threadId }: { threadId: string }) => {
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
const [isTrashPressed, setIsTrashPressed] = useState(false)
return (isTrashPressed ?
<div className='flex flex-nowrap text-nowrap gap-1'>
<IconShell1
Icon={X}
className='size-[11px]'
onClick={() => { setIsTrashPressed(false); }}
data-tooltip-id='void-tooltip'
data-tooltip-place='top'
data-tooltip-content='Cancel'
/>
<IconShell1
Icon={Check}
className='size-[11px]'
onClick={() => { chatThreadsService.deleteThread(threadId); setIsTrashPressed(false); }}
data-tooltip-id='void-tooltip'
data-tooltip-place='top'
data-tooltip-content='Confirm'
/>
</div>
: <IconShell1
Icon={Trash2}
className='size-[11px]'
onClick={() => { setIsTrashPressed(true); }}
data-tooltip-id='void-tooltip'
data-tooltip-place='top'
data-tooltip-content='Delete thread'
/>
)
}
const PastThreadElement = ({ pastThread, idx, hoveredIdx, setHoveredIdx, isRunning }: {
pastThread: ThreadType,
idx: number,
hoveredIdx: number | null,
setHoveredIdx: (idx: number | null) => void,
isRunning: IsRunningType | undefined,
}
) => {
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
const sidebarStateService = accessor.get('ISidebarStateService')
// const settingsState = useSettingsState()
// const convertService = accessor.get('IConvertToLLMMessageService')
// const chatMode = settingsState.globalSettings.chatMode
// const modelSelection = settingsState.modelSelectionOfFeature?.Chat ?? null
// const copyChatButton = <CopyButton
// codeStr={async () => {
// const { messages } = await convertService.prepareLLMChatMessages({
// chatMessages: currentThread.messages,
// chatMode,
// modelSelection,
// })
// return JSON.stringify(messages, null, 2)
// }}
// toolTipName={modelSelection === null ? 'Copy As Messages Payload' : `Copy As ${displayInfoOfProviderName(modelSelection.providerName).title} Payload`}
// />
// const currentThread = chatThreadsService.getCurrentThread()
// const copyChatButton2 = <CopyButton
// codeStr={async () => {
// return JSON.stringify(currentThread.messages, null, 2)
// }}
// toolTipName={`Copy As Void Chat`}
// />
let firstMsg = null;
const firstUserMsgIdx = pastThread.messages.findIndex((msg) => msg.role === 'user');
if (firstUserMsgIdx !== -1) {
const firsUsertMsgObj = pastThread.messages[firstUserMsgIdx];
firstMsg = firsUsertMsgObj.role === 'user' && firsUsertMsgObj.displayContent || '';
} else {
firstMsg = '""';
}
const numMessages = pastThread.messages.filter((msg) => msg.role === 'assistant' || msg.role === 'user').length;
const detailsHTML = <span
className='gap-1 inline-flex items-center'
// data-tooltip-id='void-tooltip'
// data-tooltip-content={`Last modified ${formatTime(new Date(pastThread.lastModified))}`}
// data-tooltip-place='top'
>
{/* <span>{numMessages}</span> */}
{formatDate(new Date(pastThread.lastModified))}
</span>
return <div
key={pastThread.id}
className={`
py-1 px-2 rounded text-sm bg-zinc-700/5 hover:bg-zinc-700/10 dark:bg-zinc-300/5 dark:hover:bg-zinc-300/10 cursor-pointer opacity-80 hover:opacity-100
`}
onClick={() => {
chatThreadsService.switchToThread(pastThread.id);
sidebarStateService.setState({ isHistoryOpen: false });
}}
onMouseEnter={() => setHoveredIdx(idx)}
onMouseLeave={() => setHoveredIdx(null)}
>
<div className="flex items-center justify-between gap-1">
<span className="flex items-center gap-2 min-w-0 overflow-hidden">
{/* spinner */}
{isRunning === 'LLM' || isRunning === 'tool' ? <LoaderCircle className="animate-spin bg-void-stroke-1 flex-shrink-0 flex-grow-0" size={14} />
:
isRunning === 'awaiting_user' ? <MessageCircleQuestion className="bg-void-stroke-1 flex-shrink-0 flex-grow-0" size={14} />
:
null}
{/* name */}
<span className="truncate overflow-hidden text-ellipsis">{firstMsg}</span>
</span>
<div className="flex items-center gap-x-1 opacity-60">
{idx === hoveredIdx ?
<>
{/* trash icon */}
<DuplicateButton threadId={pastThread.id} />
{/* trash icon */}
<TrashButton threadId={pastThread.id} />
</>
: <>
{detailsHTML}
</>
}
</div>
</div>
</div>
}
@@ -12,6 +12,7 @@
--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-editor-background) 30%, var(--vscode-sideBar-background) 70%);
--void-bg-2-hover: color-mix(in srgb, var(--vscode-editor-foreground) 2%, var(--vscode-sideBar-background) 98%);
--void-bg-3: var(--vscode-editor-background);
--void-fg-0: color-mix(in srgb, var(--vscode-tab-activeForeground) 90%, black 10%);
@@ -48,6 +48,80 @@ export const WidgetComponent = <CtorParams extends any[], Instance>({ ctor, prop
return <div ref={containerRef} className={className === undefined ? `w-full` : className}>{children}</div>
}
type GenerateNextOptions = (newPathText: string) => Option[]
type Option = {
name: string,
displayName: string,
} & (
| { nextOptions: Option[], generateNextOptions?: undefined }
| { nextOptions?: undefined, generateNextOptions: GenerateNextOptions }
| { nextOptions?: undefined, generateNextOptions?: undefined }
)
const getOptionsAtPath = (accessor: ReturnType<typeof useAccessor>, path: string[], newPathText: string) => {
const allOptions: Option[] = [
{
name: 'files',
displayName: 'files',
generateNextOptions: () => [
{ name: 'a.txt', displayName: 'a.txt', },
{ name: 'b.txt', displayName: 'b.txt', },
{ name: 'c.txt', displayName: 'c.txt', },
{ name: 'd.txt', displayName: 'd.txt', },
{ name: 'e.txt', displayName: 'e.txt', },
{ name: 'f.txt', displayName: 'f.txt', },
{ name: 'g.txt', displayName: 'g.txt', },
{ name: '!a.txt', displayName: '!a.txt', },
{ name: '!b.txt', displayName: '!b.txt', },
{ name: '!c.txt', displayName: '!c.txt', },
{ name: '!d.txt', displayName: '!d.txt', },
{ name: '!e.txt', displayName: '!e.txt', },
{ name: '!f.txt', displayName: '!f.txt', },
{ name: '!g.txt', displayName: '!g.txt', },
]
},
{
name: 'folders',
displayName: 'folders',
nextOptions: [
{ name: 'FOLDER', displayName: 'FOLDER', },
]
},
]
// follow the path in the optionsTree (until the last path element)
let nextOptionsAtPath = allOptions
let generateNextOptionsAtPath: GenerateNextOptions | undefined = undefined
for (const pn of path) {
const selectedOption = nextOptionsAtPath.find(o => o.name.toLowerCase() === pn.toLowerCase())
if (!selectedOption) return;
nextOptionsAtPath = selectedOption.nextOptions! // assume nextOptions exists until we hit the very last option (the path will never contain the last possible option)
generateNextOptionsAtPath = selectedOption.generateNextOptions
}
if (generateNextOptionsAtPath) {
nextOptionsAtPath = generateNextOptionsAtPath(newPathText)
}
const optionsAtPath = nextOptionsAtPath.filter(o => o.name.includes(newPathText))
return optionsAtPath
}
export type TextAreaFns = { setValue: (v: string) => void, enable: () => void, disable: () => void }
type InputBox2Props = {
@@ -64,8 +138,235 @@ type InputBox2Props = {
}
export const VoidInputBox2 = forwardRef<HTMLTextAreaElement, InputBox2Props>(function X({ initValue, placeholder, multiline, fnsRef, className, onKeyDown, onFocus, onBlur, onChangeText }, ref) {
// mirrors whatever is in ref
const accessor = useAccessor()
const toolsService = accessor.get('IToolsService')
const textAreaRef = useRef<HTMLTextAreaElement | null>(null)
const selectedOptionRef = useRef<HTMLDivElement>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [path, setPath] = useState<string[]>([]);
const [optionIdx, setOptionIdx] = useState<number>(0);
const [options, setOptions] = useState<Option[]>([]);
const [newPathText, setNewPathText] = useState<string>('');
const insertTextAtCursor = (text: string) => {
const textarea = textAreaRef.current;
if (!textarea) return;
// Focus the textarea first
textarea.focus();
// The most reliable way to simulate typing is to use execCommand
// which will trigger all the appropriate native events
document.execCommand('insertText', false, text);
// React's onChange relies on a SyntheticEvent system
// The best way to ensure it runs is to call callbacks directly
if (onChangeText) {
onChangeText(textarea.value);
}
adjustHeight();
};
const onSelectOption = () => {
if (!options.length) { return; }
const option = options[optionIdx];
const newPath = [...path, option.name]
const isLastOption = !option.generateNextOptions && !option.nextOptions
setPath(newPath)
setNewPathText('')
setOptionIdx(0)
if (isLastOption) {
setIsMenuOpen(false)
insertTextAtCursor(`TODO-${option.displayName}`)
}
else {
setOptions(getOptionsAtPath(accessor, newPath, '') || [])
}
}
const onRemoveOption = () => {
const newPath = [...path.slice(0, path.length - 1)]
setPath(newPath)
setNewPathText('')
setOptionIdx(0)
setOptions(getOptionsAtPath(accessor, newPath, '') || [])
}
const onOpenOptionMenu = () => {
setPath([])
setNewPathText('')
setIsMenuOpen(true);
setOptionIdx(0);
setOptions(getOptionsAtPath(accessor, [], '') || []);
}
const onCloseOptionMenu = () => {
setIsMenuOpen(false);
}
const onNavigateUp = () => {
if (options.length === 0) return;
setOptionIdx((prevIdx) => (prevIdx - 1 + options.length) % options.length);
}
const onNavigateDown = () => {
if (options.length === 0) return;
setOptionIdx((prevIdx) => (prevIdx + 1) % options.length);
}
const onPathTextChange = (newStr: string) => {
setNewPathText(newStr);
setOptions(getOptionsAtPath(accessor, path, newStr) || []);
}
const onMenuKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'ArrowUp') {
onNavigateUp();
} else if (e.key === 'ArrowDown') {
onNavigateDown();
} else if (e.key === 'ArrowLeft') {
onSelectOption();
} else if (e.key === 'ArrowRight') {
onSelectOption();
} else if (e.key === 'Enter') {
onSelectOption();
} else if (e.key === 'Escape') {
onCloseOptionMenu()
} else if (e.key === 'Backspace') {
if (!newPathText) { // No text remaining
if (path.length === 0) {
onCloseOptionMenu()
} else {
onRemoveOption();
}
}
else if (e.altKey || e.ctrlKey || e.metaKey) { // Ctrl+Backspace
onPathTextChange('')
}
else { // Backspace
onPathTextChange(newPathText.slice(0, -1))
}
} else if (e.key.length === 1) {
if (e.altKey || e.ctrlKey || e.metaKey) { // Ctrl+letter
// do nothing
}
else { // letter
onPathTextChange(newPathText + e.key)
}
}
e.preventDefault();
e.stopPropagation();
};
// scroll the selected optionIdx into view on optionIdx and newPathText changes
useEffect(() => {
if (isMenuOpen && selectedOptionRef.current) {
selectedOptionRef.current.scrollIntoView({
behavior: 'instant',
block: 'nearest',
inline: 'nearest',
});
}
}, [optionIdx, isMenuOpen, newPathText, selectedOptionRef]);
const measureRef = useRef<HTMLDivElement>(null);
const gapPx = 2
const offsetPx = 2
const {
x,
y,
strategy,
refs,
middlewareData,
update
} = useFloating({
open: isMenuOpen,
onOpenChange: setIsMenuOpen,
placement: 'top',
middleware: [
offset({ mainAxis: gapPx, crossAxis: offsetPx }),
flip({
boundary: document.body,
padding: 8
}),
shift({
boundary: document.body,
padding: 8,
}),
size({
apply({ availableHeight, elements, rects }) {
const maxHeight = Math.min(availableHeight)
Object.assign(elements.floating.style, {
maxHeight: `${maxHeight}px`,
overflowY: 'auto',
// Ensure the width isn't constrained by the parent
width: `${Math.max(
rects.reference.width,
measureRef.current?.offsetWidth ?? 0
)}px`
});
},
padding: 8,
// Use viewport as boundary instead of any parent element
boundary: document.body,
}),
],
whileElementsMounted: autoUpdate,
strategy: 'fixed',
});
useEffect(() => {
if (!isMenuOpen) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
const floating = refs.floating.current;
const reference = refs.reference.current;
// Check if reference is an HTML element before using contains
const isReferenceHTMLElement = reference && 'contains' in reference;
if (
floating &&
(!isReferenceHTMLElement || !reference.contains(target)) &&
!floating.contains(target)
) {
setIsMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isMenuOpen, refs.floating, refs.reference]);
const [isEnabled, setEnabled] = useState(true)
const adjustHeight = useCallback(() => {
@@ -104,18 +405,20 @@ export const VoidInputBox2 = forwardRef<HTMLTextAreaElement, InputBox2Props>(fun
return (
return <>
<textarea
autoFocus={false}
ref={useCallback((r: HTMLTextAreaElement | null) => {
if (fnsRef)
fnsRef.current = fns
refs.setReference(r)
textAreaRef.current = r
if (typeof ref === 'function') ref(r)
else if (ref) ref.current = r
adjustHeight()
}, [fnsRef, fns, setEnabled, adjustHeight, ref])}
}, [fnsRef, fns, setEnabled, adjustHeight, ref, refs])}
onFocus={onFocus}
onBlur={onBlur}
@@ -130,7 +433,16 @@ export const VoidInputBox2 = forwardRef<HTMLTextAreaElement, InputBox2Props>(fun
// inputBorder: asCssVariable(inputBorder),
}}
onChange={useCallback(() => {
onInput={useCallback((event: React.FormEvent<HTMLTextAreaElement>) => {
const latestChange = (event.nativeEvent as InputEvent).data;
if (latestChange === '@') {
onOpenOptionMenu()
}
}, [onOpenOptionMenu, accessor])}
onChange={useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const r = textAreaRef.current
if (!r) return
onChangeText?.(r.value)
@@ -138,18 +450,75 @@ export const VoidInputBox2 = forwardRef<HTMLTextAreaElement, InputBox2Props>(fun
}, [onChangeText, adjustHeight])}
onKeyDown={useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (isMenuOpen) {
onMenuKeyDown(e)
return;
}
if (e.key === 'Enter') {
// Shift + Enter when multiline = newline
const shouldAddNewline = e.shiftKey && multiline
if (!shouldAddNewline) e.preventDefault(); // prevent newline from being created
}
onKeyDown?.(e)
}, [onKeyDown, multiline])}
}, [onKeyDown, onMenuKeyDown, multiline])}
rows={1}
placeholder={placeholder}
/>
)
{/* <div>{`idx ${optionIdx}`}</div> */}
{isMenuOpen && (
<div
ref={refs.setFloating}
className="z-[100] bg-void-bg-1 border-void-border-3 border rounded shadow-lg"
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
width: refs.reference.current instanceof HTMLElement ? refs.reference.current.offsetWidth : 0
}}
onWheel={(e) => e.stopPropagation()}
>
<div className="py-1">
{/* Path navigation breadcrumbs */}
<div className="px-2 py-1 text-void-fg-3 text-sm border-b border-void-border-3">
{[...path, newPathText].join(' > ')}
</div>
{/* Options list */}
{options.length === 0 ? (
<div className="px-3 py-2 text-void-fg-3">No options available</div>
) : (
options.map((o, oIdx) => (
<div
ref={oIdx === optionIdx ? selectedOptionRef : null}
key={o.name}
className={`px-3 py-1.5 cursor-pointer bg-void-bg-2 ${oIdx === optionIdx ? 'bg-void-bg-2-hover' : ''}`}
onClick={() => { onSelectOption(); }}
>
<div className="flex items-center">
<span className="text-void-fg-1">{o.displayName}</span>
{o.nextOptions || o.generateNextOptions ? (
<svg className="ml-2 h-3 w-3 text-void-fg-3" viewBox="0 0 12 12" fill="none">
<path
d="M4.5 2.5L8 6L4.5 9.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : null}
</div>
</div>
))
)}
</div>
</div>
)}
</>
})
@@ -664,7 +1033,7 @@ export const VoidCustomDropdownBox = <T extends NonNullable<any>>({
{isOpen && (
<div
ref={refs.setFloating}
className="z-10 bg-void-bg-1 border-void-border-3 border rounded shadow-lg"
className="z-[100] bg-void-bg-1 border-void-border-3 border rounded shadow-lg"
style={{
position: strategy,
top: y ?? 0,
@@ -689,7 +1058,7 @@ export const VoidCustomDropdownBox = <T extends NonNullable<any>>({
key={optionName}
className={`flex items-center px-2 py-1 pr-4 cursor-pointer whitespace-nowrap
transition-all duration-100
${thisOptionIsSelected ? 'bg-void-bg-2' : 'bg-void-bg-2-alt hover:bg-void-bg-2'}
${thisOptionIsSelected ? 'bg-void-bg-2-hover' : 'bg-void-bg-2-alt hover:bg-void-bg-2-hover'}
`}
onClick={() => {
onChangeOption(option);
@@ -709,7 +1078,7 @@ export const VoidCustomDropdownBox = <T extends NonNullable<any>>({
</svg>
)}
</div>
<span className="flex justify-between w-full">
<span className="flex justify-between items-center w-full gap-x-1">
<span>{optionName}</span>
<span className='text-void-fg-4 opacity-60'>{optionDetail}</span>
</span>
@@ -48,6 +48,7 @@ import { IVoidCommandBarService } from '../../../voidCommandBarService.js'
import { INativeHostService } from '../../../../../../../platform/native/common/native.js';
import { IEditCodeService } from '../../../editCodeServiceInterface.js'
import { IToolsService } from '../../../toolsService.js'
import { IConvertToLLMMessageService } from '../../../convertToLLMMessageService.js'
// normally to do this you'd use a useEffect that calls .onDidChangeState(), but useEffect mounts too late and misses initial state changes
@@ -217,6 +218,7 @@ const getReactAccessor = (accessor: ServicesAccessor) => {
IVoidCommandBarService: accessor.get(IVoidCommandBarService),
INativeHostService: accessor.get(INativeHostService),
IToolsService: accessor.get(IToolsService),
IConvertToLLMMessageService: accessor.get(IConvertToLLMMessageService),
} as const
return reactAccessor
@@ -302,6 +304,16 @@ export const useChatThreadsStreamState = (threadId: string) => {
return s
}
export const useFullChatThreadsStreamState = () => {
const [s, ss] = useState(chatThreadsStreamState)
useEffect(() => {
ss(chatThreadsStreamState)
const listener = () => { ss(chatThreadsStreamState) }
chatThreadsStreamStateListeners.add(listener)
return () => { chatThreadsStreamStateListeners.delete(listener) }
}, [ss])
return s
}
@@ -9,8 +9,9 @@ import { Brain, Check, ChevronRight, DollarSign, ExternalLink, Lock, X } from 'l
import { displayInfoOfProviderName, ProviderName, providerNames, refreshableProviderNames } from '../../../../common/voidSettingsTypes.js';
import { getModelCapabilities, ollamaRecommendedModels } from '../../../../common/modelCapabilities.js';
import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js';
import { AddModelInputBox, AnimatedCheckmarkButton, ollamaSetupInstructions, OneClickSwitchButton, SettingsForProvider } from '../void-settings-tsx/Settings.js';
import { AddModelInputBox, AnimatedCheckmarkButton, OllamaSetupInstructions, OneClickSwitchButton, SettingsForProvider } from '../void-settings-tsx/Settings.js';
import { ColorScheme } from '../../../../../../../platform/theme/common/theme.js';
import ErrorBoundary from '../sidebar-tsx/ErrorBoundary.js';
const OVERRIDE_VALUE = false
@@ -29,7 +30,9 @@ export const VoidOnboarding = () => {
transition-all duration-1000 ${isOnboardingComplete ? 'opacity-0 pointer-events-none' : 'opacity-100 pointer-events-auto'}
`}
>
<VoidOnboardingContent />
<ErrorBoundary>
<VoidOnboardingContent />
</ErrorBoundary>
</div>
</div>
)
@@ -303,11 +306,11 @@ const TableOfModelsForProvider = ({ providerName }: { providerName: ProviderName
// info used to show the table
const infoOfModelName: Record<string, { showAsDefault: boolean, isDownloaded: boolean }> = {}
const infoOfModelName: Record<string, { showAsDefault: boolean, isDownloaded: boolean } | undefined> = {}
voidSettingsState.settingsOfProvider[providerName].models.forEach(m => {
infoOfModelName[m.modelName] = {
showAsDefault: m.isDefault,
showAsDefault: m.type !== 'custom',
isDownloaded: true
}
})
@@ -317,7 +320,7 @@ const TableOfModelsForProvider = ({ providerName }: { providerName: ProviderName
for (const modelName of ollamaRecommendedModels) {
if (modelName in infoOfModelName) continue
infoOfModelName[modelName] = {
...infoOfModelName[modelName],
isDownloaded: infoOfModelName[modelName]?.isDownloaded ?? false,
showAsDefault: true,
}
}
@@ -339,7 +342,7 @@ const TableOfModelsForProvider = ({ providerName }: { providerName: ProviderName
</thead>
<tbody>
{Object.keys(infoOfModelName).map(modelName => {
const { showAsDefault, isDownloaded } = infoOfModelName[modelName]
const { showAsDefault, isDownloaded } = infoOfModelName[modelName] ?? {}
const capabilities = getModelCapabilities(providerName, modelName)
@@ -367,7 +370,7 @@ const TableOfModelsForProvider = ({ providerName }: { providerName: ProviderName
return (
<tr key={modelName} className="border-b border-void-border-1 hover:bg-void-bg-3/50">
<tr key={`${modelName}${providerName}`} className="border-b border-void-border-1 hover:bg-void-bg-3/50">
<td className="py-2 px-3 relative">
{!showAsDefault && removeModelButton}
{modelName}
@@ -380,7 +383,7 @@ const TableOfModelsForProvider = ({ providerName }: { providerName: ProviderName
{/* <td className="py-2 px-3"><YesNoText val={!!reasoningCapabilities} /></td> */}
{isDetectableLocally && <td className="py-2 px-3 flex items-center justify-center">{!!isDownloaded ? <Check className="w-4 h-4" /> : <></>}</td>}
{providerName === 'ollama' && <th className="py-2 px-3">
<OllamaDownloadOrRemoveModelButton modelName={modelName} isModelInstalled={infoOfModelName[modelName].isDownloaded} sizeGb={downloadable && downloadable.sizeGb} />
<OllamaDownloadOrRemoveModelButton modelName={modelName} isModelInstalled={!!infoOfModelName[modelName]?.isDownloaded} sizeGb={downloadable && downloadable.sizeGb} />
</th>}
</tr>
@@ -388,10 +391,13 @@ const TableOfModelsForProvider = ({ providerName }: { providerName: ProviderName
})}
<tr className="hover:bg-void-bg-3/50">
<td className="py-2 px-3 text-void-accent">
<AddModelInputBox
key={providerName}
providerName={providerName}
compact={true} />
<ErrorBoundary>
<AddModelInputBox
key={providerName}
providerName={providerName}
compact={true}
/>
</ErrorBoundary>
</td>
<td colSpan={4}></td>
</tr>
@@ -497,7 +503,7 @@ const VoidOnboardingContent = () => {
const providerNamesOfWantToUseOption: { [wantToUseOption in WantToUseOption]: ProviderName[] } = {
smart: ['anthropic', 'openAI', 'gemini', 'openRouter'],
private: ['ollama', 'vLLM', 'openAICompatible'],
private: ['ollama', 'vLLM', 'openAICompatible', 'lmStudio'],
cheap: ['gemini', 'deepseek', 'openRouter', 'ollama', 'vLLM'],
all: providerNames,
}
@@ -672,19 +678,22 @@ const VoidOnboardingContent = () => {
{ id: 'cheap', label: 'Affordable' },
{ id: 'all', label: 'All' }
].map(option => (
<button
<ErrorBoundary
key={option.id}
onClick={() => setWantToUseOption(option.id as WantToUseOption)}
className={`py-1 px-2 text-xs cursor-pointer whitespace-nowrap rounded-sm transition-colors ${wantToUseOption === option.id
? 'dark:text-white text-black font-medium'
: 'text-void-fg-3 hover:text-void-fg-2'
}`}
data-tooltip-id='void-tooltip'
data-tooltip-content={`${option.label} providers`}
data-tooltip-place='bottom'
>
{option.label}
</button>
<button
onClick={() => setWantToUseOption(option.id as WantToUseOption)}
className={`py-1 px-2 text-xs cursor-pointer whitespace-nowrap rounded-sm transition-colors ${wantToUseOption === option.id
? 'dark:text-white text-black font-medium'
: 'text-void-fg-3 hover:text-void-fg-2'
}`}
data-tooltip-id='void-tooltip'
data-tooltip-content={`${option.label} providers`}
data-tooltip-place='bottom'
>
{option.label}
</button>
</ErrorBoundary>
))}
</div>
@@ -693,108 +702,129 @@ const VoidOnboardingContent = () => {
{/* Provider Buttons - Modified to use separate components for each tab */}
<div className="mb-2 w-full">
{/* Intelligent tab */}
<div className={`flex flex-wrap items-center w-full ${wantToUseOption === 'smart' ? 'flex' : 'hidden'}`}>
{providerNamesOfWantToUseOption['smart'].map((providerName) => {
const isSelected = selectedIntelligentProvider === providerName;
return (
<button
key={providerName}
onClick={() => setSelectedIntelligentProvider(providerName)}
className={`py-[2px] px-2 mx-0.5 my-0.5 text-xs font-medium cursor-pointer relative rounded-full transition-all duration-300
<ErrorBoundary>
<div className={`flex flex-wrap items-center w-full ${wantToUseOption === 'smart' ? 'flex' : 'hidden'}`}>
{providerNamesOfWantToUseOption['smart'].map((providerName) => {
const isSelected = selectedIntelligentProvider === providerName;
return (
<button
key={providerName}
onClick={() => setSelectedIntelligentProvider(providerName)}
className={`py-[2px] px-2 mx-0.5 my-0.5 text-xs font-medium cursor-pointer relative rounded-full transition-all duration-300
${isSelected ? 'bg-zinc-100 text-zinc-900 shadow-sm border-white/80' : 'bg-zinc-100/40 hover:bg-zinc-100/50 text-zinc-900 border-white/20'}`}
>
{displayInfoOfProviderName(providerName).title}
</button>
);
})}
</div>
>
{displayInfoOfProviderName(providerName).title}
</button>
);
})}
</div>
</ErrorBoundary>
{/* Private tab */}
<div className={`flex flex-wrap items-center w-full ${wantToUseOption === 'private' ? 'flex' : 'hidden'}`}>
{providerNamesOfWantToUseOption['private'].map((providerName) => {
const isSelected = selectedPrivateProvider === providerName;
return (
<button
key={providerName}
onClick={() => setSelectedPrivateProvider(providerName)}
className={`py-[2px] px-2 mx-0.5 my-0.5 text-xs font-medium cursor-pointer relative rounded-full transition-all duration-300
<ErrorBoundary>
<div className={`flex flex-wrap items-center w-full ${wantToUseOption === 'private' ? 'flex' : 'hidden'}`}>
{providerNamesOfWantToUseOption['private'].map((providerName) => {
const isSelected = selectedPrivateProvider === providerName;
return (
<button
key={providerName}
onClick={() => setSelectedPrivateProvider(providerName)}
className={`py-[2px] px-2 mx-0.5 my-0.5 text-xs font-medium cursor-pointer relative rounded-full transition-all duration-300
${isSelected ? 'bg-zinc-100 text-zinc-900 shadow-sm border-white/80' : 'bg-zinc-100/40 hover:bg-zinc-100/50 text-zinc-900 border-white/20'}`}
>
{displayInfoOfProviderName(providerName).title}
</button>
);
})}
</div>
>
{displayInfoOfProviderName(providerName).title}
</button>
);
})}
</div>
</ErrorBoundary>
{/* Affordable tab */}
<div className={`flex flex-wrap items-center w-full ${wantToUseOption === 'cheap' ? 'flex' : 'hidden'}`}>
{providerNamesOfWantToUseOption['cheap'].map((providerName) => {
const isSelected = selectedAffordableProvider === providerName;
return (
<button
key={providerName}
onClick={() => setSelectedAffordableProvider(providerName)}
className={`py-[2px] px-2 mx-0.5 my-0.5 text-xs font-medium cursor-pointer relative rounded-full transition-all duration-300
<ErrorBoundary>
<div className={`flex flex-wrap items-center w-full ${wantToUseOption === 'cheap' ? 'flex' : 'hidden'}`}>
{providerNamesOfWantToUseOption['cheap'].map((providerName) => {
const isSelected = selectedAffordableProvider === providerName;
return (
<button
key={providerName}
onClick={() => setSelectedAffordableProvider(providerName)}
className={`py-[2px] px-2 mx-0.5 my-0.5 text-xs font-medium cursor-pointer relative rounded-full transition-all duration-300
${isSelected ? 'bg-zinc-100 text-zinc-900 shadow-sm border-white/80' : 'bg-zinc-100/40 hover:bg-zinc-100/50 text-zinc-900 border-white/20'}`}
>
{displayInfoOfProviderName(providerName).title}
</button>
);
})}
</div>
>
{displayInfoOfProviderName(providerName).title}
</button>
);
})}
</div>
</ErrorBoundary>
{/* All tab */}
<div className={`flex flex-wrap items-center w-full ${wantToUseOption === 'all' ? 'flex' : 'hidden'}`}>
{providerNames.map((providerName) => {
const isSelected = selectedAllProvider === providerName;
return (
<button
key={providerName}
onClick={() => setSelectedAllProvider(providerName)}
className={`py-[2px] px-2 mx-0.5 my-0.5 text-xs font-medium cursor-pointer relative rounded-full transition-all duration-300
<ErrorBoundary>
<div className={`flex flex-wrap items-center w-full ${wantToUseOption === 'all' ? 'flex' : 'hidden'}`}>
{providerNames.map((providerName) => {
const isSelected = selectedAllProvider === providerName;
return (
<button
key={providerName}
onClick={() => setSelectedAllProvider(providerName)}
className={`py-[2px] px-2 mx-0.5 my-0.5 text-xs font-medium cursor-pointer relative rounded-full transition-all duration-300
${isSelected ? 'bg-zinc-100 text-zinc-900 shadow-sm border-white/80' : 'bg-zinc-100/40 hover:bg-zinc-100/50 text-zinc-900 border-white/20'}`}
>
{displayInfoOfProviderName(providerName).title}
</button>
);
})}
</div>
>
{displayInfoOfProviderName(providerName).title}
</button>
);
})}
</div>
</ErrorBoundary>
</div>
{/* Description */}
<div className="text-left self-start text-sm text-void-fg-3 px-2 py-1">
<ChatMarkdownRender string={detailedDescOfWantToUseOption[wantToUseOption]} chatMessageLocation={undefined} />
</div>
<ErrorBoundary>
<div className="text-left self-start text-sm text-void-fg-3 px-2 py-1">
<ChatMarkdownRender string={detailedDescOfWantToUseOption[wantToUseOption]} chatMessageLocation={undefined} />
</div>
</ErrorBoundary>
{/* ModelsTable and ProviderFields */}
{selectedProviderName && <div className='mt-4 w-fit mx-auto'>
{/* Models Table */}
<TableOfModelsForProvider providerName={selectedProviderName} />
<ErrorBoundary>
<TableOfModelsForProvider providerName={selectedProviderName} />
</ErrorBoundary>
{/* Add provider section - simplified styling */}
<div className='mb-5 mt-8 mx-auto'>
<div className=''>
Add {displayInfoOfProviderName(selectedProviderName).title}
<ErrorBoundary>
<div className=''>
Add {displayInfoOfProviderName(selectedProviderName).title}
<div className='my-4'>
{selectedProviderName === 'ollama' ? <OllamaSetupInstructions /> : ''}
</div>
<div className='my-4'>
{selectedProviderName === 'ollama' ? ollamaSetupInstructions : ''}
</div>
</ErrorBoundary>
</div>
{selectedProviderName &&
<SettingsForProvider providerName={selectedProviderName} showProviderTitle={false} showProviderSuggestions={false} />
}
<ErrorBoundary>
{selectedProviderName &&
<SettingsForProvider providerName={selectedProviderName} showProviderTitle={false} showProviderSuggestions={false} />
}
</ErrorBoundary>
{/* Button and status indicators */}
{!didFillInProviderSettings ? <p className="text-xs text-void-fg-3 mt-2">Please fill in all fields to continue</p>
: !isAtLeastOneModel ? <p className="text-xs text-void-fg-3 mt-2">Please add a model to continue</p>
: !isApiKeyLongEnoughIfApiKeyExists ? <p className="text-xs text-void-fg-3 mt-2">Please enter a valid API key</p>
: <AnimatedCheckmarkButton className='text-xs text-void-fg-3 mt-2' text='Added' />}
<ErrorBoundary>
{!didFillInProviderSettings ? <p className="text-xs text-void-fg-3 mt-2">Please fill in all fields to continue</p>
: !isAtLeastOneModel ? <p className="text-xs text-void-fg-3 mt-2">Please add a model to continue</p>
: !isApiKeyLongEnoughIfApiKeyExists ? <p className="text-xs text-void-fg-3 mt-2">Please enter a valid API key</p>
: <AnimatedCheckmarkButton className='text-xs text-void-fg-3 mt-2' text='Added' />}
</ErrorBoundary>
</div>
</div>}
@@ -802,10 +832,11 @@ const VoidOnboardingContent = () => {
}
bottom={
<FadeIn delayMs={50} durationMs={10}>
{prevAndNextButtons}
</FadeIn>
<ErrorBoundary>
<FadeIn delayMs={50} durationMs={10}>
{prevAndNextButtons}
</FadeIn>
</ErrorBoundary>
}
/>,
@@ -864,7 +895,9 @@ const VoidOnboardingContent = () => {
return <div key={pageIndex} className="w-full h-full text-left mx-auto overflow-y-scroll flex flex-col items-center justify-around">
{contentOfIdx[pageIndex]}
<ErrorBoundary>
{contentOfIdx[pageIndex]}
</ErrorBoundary>
</div>
}
@@ -3,8 +3,8 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { ProviderName, SettingName, displayInfoOfSettingName, providerNames, VoidStatefulModelInfo, customSettingNamesOfProvider, RefreshableProviderName, refreshableProviderNames, displayInfoOfProviderName, nonlocalProviderNames, localProviderNames, GlobalSettingName, featureNames, displayInfoOfFeatureName, isProviderNameDisabled, FeatureName, hasDownloadButtonsOnModelsProviderNames } from '../../../../common/voidSettingsTypes.js'
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'
import { ProviderName, SettingName, displayInfoOfSettingName, providerNames, VoidStatefulModelInfo, customSettingNamesOfProvider, RefreshableProviderName, refreshableProviderNames, displayInfoOfProviderName, nonlocalProviderNames, localProviderNames, GlobalSettingName, featureNames, displayInfoOfFeatureName, isProviderNameDisabled, FeatureName, hasDownloadButtonsOnModelsProviderNames, subTextMdOfProviderName } from '../../../../common/voidSettingsTypes.js'
import ErrorBoundary from '../sidebar-tsx/ErrorBoundary.js'
import { VoidButtonBgDarken, VoidCustomDropdownBox, VoidInputBox2, VoidSimpleInputBox, VoidSwitch } from '../util/inputs.js'
import { useAccessor, useIsDark, useRefreshModelListener, useRefreshModelState, useSettingsState } from '../util/services.js'
@@ -16,7 +16,8 @@ import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js'
import { WarningBox } from './WarningBox.js'
import { os } from '../../../../common/helpers/systemInfo.js'
import { IconLoading } from '../sidebar-tsx/SidebarChat.js'
import { ToolApprovalType, toolApprovalTypes } from '../../../../common/toolsServiceTypes.js'
import Severity from '../../../../../../../base/common/severity.js'
const ButtonLeftTextRightOption = ({ text, leftButton }: { text: string, leftButton?: React.ReactNode }) => {
@@ -152,6 +153,36 @@ const AddButton = ({ disabled, text = 'Add', ...props }: { disabled?: boolean, t
}
// ConfirmButton prompts for a second click to confirm an action, cancels if clicking outside
const ConfirmButton = ({ children, onConfirm, className }: { children: React.ReactNode, onConfirm: () => void, className?: string }) => {
const [confirm, setConfirm] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!confirm) return;
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setConfirm(false);
}
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}, [confirm]);
return (
<div ref={ref} className={`inline-block`}>
<VoidButtonBgDarken className={className} onClick={() => {
if (!confirm) {
setConfirm(true);
} else {
onConfirm();
setConfirm(false);
}
}}>
{confirm ? `Confirm Reset` : children}
</VoidButtonBgDarken>
</div>
);
};
// shows a providerName dropdown if no `providerName` is given
export const AddModelInputBox = ({ providerName: permanentProviderName, className, compact }: { providerName?: ProviderName, className?: string, compact?: boolean }) => {
@@ -165,14 +196,14 @@ export const AddModelInputBox = ({ providerName: permanentProviderName, classNam
const [showCheckmark, setShowCheckmark] = useState(false)
// const providerNameRef = useRef<ProviderName | null>(null)
const [userChosenProviderName, setUserChosenProviderName] = useState<ProviderName>('anthropic')
const [userChosenProviderName, setUserChosenProviderName] = useState<ProviderName | null>(null)
const providerName = permanentProviderName ?? userChosenProviderName;
const [modelName, setModelName] = useState<string>('')
const [errorString, setErrorString] = useState('')
const numModels = settingsState.settingsOfProvider[providerName].models.length
const numModels = providerName === null ? 0 : settingsState.settingsOfProvider[providerName].models.length
if (showCheckmark) {
return <AnimatedCheckmarkButton text='Added' className={`bg-[#0e70c0] text-white dark:text-black px-3 py-1 rounded-sm ${className}`} />
@@ -198,59 +229,66 @@ export const AddModelInputBox = ({ providerName: permanentProviderName, classNam
<button onClick={() => { setIsOpen(false) }} className='text-void-fg-4'><X className='size-4' /></button> */}
{/* provider input */}
{!permanentProviderName &&
<VoidCustomDropdownBox
options={providerNames}
selectedOption={providerName}
onChangeOption={(pn) => setUserChosenProviderName(pn)}
getOptionDisplayName={(pn) => pn ? displayInfoOfProviderName(pn).title : 'Provider Name'}
getOptionDropdownName={(pn) => pn ? displayInfoOfProviderName(pn).title : 'Provider Name'}
getOptionsEqual={(a, b) => a === b}
// className={`max-w-44 w-full border border-void-border-2 bg-void-bg-1 text-void-fg-3 text-root py-[4px] px-[6px]`}
className={`max-w-32 mx-2 w-full resize-none bg-void-bg-1 text-void-fg-1 placeholder:text-void-fg-3 border border-void-border-2 focus:border-void-border-1 py-1 px-2 rounded`}
arrowTouchesText={false}
/>
}
<ErrorBoundary>
{!permanentProviderName &&
<VoidCustomDropdownBox
options={providerNames}
selectedOption={providerName}
onChangeOption={(pn) => setUserChosenProviderName(pn)}
getOptionDisplayName={(pn) => pn ? displayInfoOfProviderName(pn).title : 'Provider Name'}
getOptionDropdownName={(pn) => pn ? displayInfoOfProviderName(pn).title : 'Provider Name'}
getOptionsEqual={(a, b) => a === b}
// className={`max-w-44 w-full border border-void-border-2 bg-void-bg-1 text-void-fg-3 text-root py-[4px] px-[6px]`}
className={`max-w-32 mx-2 w-full resize-none bg-void-bg-1 text-void-fg-1 placeholder:text-void-fg-3 border border-void-border-2 focus:border-void-border-1 py-1 px-2 rounded`}
arrowTouchesText={false}
/>
}
</ErrorBoundary>
{/* model input */}
<VoidSimpleInputBox
value={modelName}
onChangeValue={setModelName}
placeholder='Model Name'
compact={compact}
className={'max-w-32'}
/>
<ErrorBoundary>
<VoidSimpleInputBox
value={modelName}
onChangeValue={setModelName}
placeholder='Model Name'
compact={compact}
className={'max-w-32'}
/>
</ErrorBoundary>
{/* add button */}
<AddButton
type='submit'
disabled={!modelName}
onClick={(e) => {
if (providerName === null) {
setErrorString('Please select a provider.')
return
}
if (!modelName) {
setErrorString('Please enter a model name.')
return
}
// if model already exists here
if (settingsState.settingsOfProvider[providerName].models.find(m => m.modelName === modelName)) {
// setErrorString(`This model already exists under ${providerName}.`)
setErrorString(`This model already exists.`)
return
}
<ErrorBoundary>
<AddButton
type='submit'
disabled={!modelName}
onClick={(e) => {
if (providerName === null) {
setErrorString('Please select a provider.')
return
}
if (!modelName) {
setErrorString('Please enter a model name.')
return
}
// if model already exists here
if (settingsState.settingsOfProvider[providerName].models.find(m => m.modelName === modelName)) {
// setErrorString(`This model already exists under ${providerName}.`)
setErrorString(`This model already exists.`)
return
}
settingsStateService.addModel(providerName, modelName)
setShowCheckmark(true)
setTimeout(() => {
setShowCheckmark(false)
setIsOpen(false)
}, 1500)
setErrorString('')
setModelName('')
}}
/>
settingsStateService.addModel(providerName, modelName)
setShowCheckmark(true)
setTimeout(() => {
setShowCheckmark(false)
setIsOpen(false)
}, 1500)
setErrorString('')
setModelName('')
}}
/>
</ErrorBoundary>
</form>
@@ -286,7 +324,7 @@ export const ModelDump = () => {
return <div className=''>
{modelDump.map((m, i) => {
const { isHidden, isDefault, isAutodetected, modelName, providerName, providerEnabled } = m
const { isHidden, type, modelName, providerName, providerEnabled } = m
const isNewProviderName = (i > 0 ? modelDump[i - 1] : undefined)?.providerName !== providerName
@@ -318,7 +356,7 @@ export const ModelDump = () => {
// : (isHidden ? `'${modelName}' won't appear in dropdowns` : ``)
// }
>
<span className='opacity-50 truncate'>{isAutodetected ? '(detected locally)' : isDefault ? '' : '(custom model)'}</span>
<span className='opacity-50 truncate'>{type === 'autodetected' ? '(detected locally)' : type === 'default' ? '' : '(custom model)'}</span>
<VoidSwitch
value={value}
@@ -332,7 +370,7 @@ export const ModelDump = () => {
/>
<div className={`w-5 flex items-center justify-center`}>
{isDefault ? null : <button onClick={() => { settingsStateService.deleteModel(providerName, modelName) }}><X className='size-4' /></button>}
{type === 'default' || type === 'autodetected' ? null : <button onClick={() => { settingsStateService.deleteModel(providerName, modelName) }}><X className='size-4' /></button>}
</div>
</div>
</div>
@@ -344,9 +382,9 @@ export const ModelDump = () => {
// providers
const ProviderSetting = ({ providerName, settingName }: { providerName: ProviderName, settingName: SettingName }) => {
const ProviderSetting = ({ providerName, settingName, subTextMd }: { providerName: ProviderName, settingName: SettingName, subTextMd: React.ReactNode }) => {
const { title: settingTitle, placeholder, isPasswordField, subTextMd } = displayInfoOfSettingName(providerName, settingName)
const { title: settingTitle, placeholder, isPasswordField } = displayInfoOfSettingName(providerName, settingName)
const accessor = useAccessor()
const voidSettingsService = accessor.get('IVoidSettingsService')
@@ -370,10 +408,9 @@ const ProviderSetting = ({ providerName, settingName }: { providerName: Provider
passwordBlur={isPasswordField}
compact={true}
/>
{subTextMd === undefined ? null : <div className='py-1 px-3 opacity-50 text-sm'>
<ChatMarkdownRender string={subTextMd} chatMessageLocation={undefined} />
{!subTextMd ? null : <div className='py-1 px-3 opacity-50 text-sm'>
{subTextMd}
</div>}
</div>
</ErrorBoundary>
}
@@ -456,7 +493,14 @@ export const SettingsForProvider = ({ providerName, showProviderTitle, showProvi
<div className='px-0'>
{/* settings besides models (e.g. api key) */}
{settingNames.map((settingName, i) => {
return <ProviderSetting key={settingName} providerName={providerName} settingName={settingName} />
return <ProviderSetting
key={settingName}
providerName={providerName}
settingName={settingName}
subTextMd={i !== settingNames.length - 1 ? null
: <ChatMarkdownRender string={subTextMdOfProviderName(providerName)} chatMessageLocation={undefined} />}
/>
})}
{showProviderSuggestions && needsModel ?
@@ -545,18 +589,20 @@ const FastApplyMethodDropdown = () => {
}
export const ollamaSetupInstructions = <div className='prose-p:my-0 prose-ol:list-decimal prose-p:py-0 prose-ol:my-0 prose-ol:py-0 prose-span:my-0 prose-span:py-0 text-void-fg-3 text-sm list-decimal select-text'>
<div className=''><ChatMarkdownRender string={`Ollama Setup Instructions`} chatMessageLocation={undefined} /></div>
<div className=' pl-6'><ChatMarkdownRender string={`1. Download [Ollama](https://ollama.com/download).`} chatMessageLocation={undefined} /></div>
<div className=' pl-6'><ChatMarkdownRender string={`2. Open your terminal.`} chatMessageLocation={undefined} /></div>
<div
className='pl-6 flex items-center w-fit'
data-tooltip-id='void-tooltip-ollama-settings'
>
<ChatMarkdownRender string={`3. Run \`ollama pull your_model\` to install a model.`} chatMessageLocation={undefined} />
export const OllamaSetupInstructions = () => {
return <div className='prose-p:my-0 prose-ol:list-decimal prose-p:py-0 prose-ol:my-0 prose-ol:py-0 prose-span:my-0 prose-span:py-0 text-void-fg-3 text-sm list-decimal select-text'>
<div className=''><ChatMarkdownRender string={`Ollama Setup Instructions`} chatMessageLocation={undefined} /></div>
<div className=' pl-6'><ChatMarkdownRender string={`1. Download [Ollama](https://ollama.com/download).`} chatMessageLocation={undefined} /></div>
<div className=' pl-6'><ChatMarkdownRender string={`2. Open your terminal.`} chatMessageLocation={undefined} /></div>
<div
className='pl-6 flex items-center w-fit'
data-tooltip-id='void-tooltip-ollama-settings'
>
<ChatMarkdownRender string={`3. Run \`ollama pull your_model\` to install a model.`} chatMessageLocation={undefined} />
</div>
<div className=' pl-6'><ChatMarkdownRender string={`Void automatically detects locally running models and enables them.`} chatMessageLocation={undefined} /></div>
</div>
<div className=' pl-6'><ChatMarkdownRender string={`Void automatically detects locally running models and enables them.`} chatMessageLocation={undefined} /></div>
</div>
}
const RedoOnboardingButton = ({ className }: { className?: string }) => {
@@ -705,6 +751,34 @@ const transferTheseFilesOfOS = (os: 'mac' | 'windows' | 'linux' | null, fromEdit
}
export const ToolApprovalTypeSwitch = ({ approvalType, size, desc }: { approvalType: ToolApprovalType, size: "xxs" | "xs" | "sm" | "sm+" | "md", desc: string }) => {
const accessor = useAccessor()
const voidSettingsService = accessor.get('IVoidSettingsService')
const voidSettingsState = useSettingsState()
const metricsService = accessor.get('IMetricsService')
const onToggleAutoApprove = useCallback((approvalType: ToolApprovalType, newValue: boolean) => {
voidSettingsService.setGlobalSetting('autoApprove', {
...voidSettingsService.state.globalSettings.autoApprove,
[approvalType]: newValue
})
metricsService.capture('Tool Auto-Accept Toggle', { enabled: newValue })
}, [voidSettingsService, metricsService])
return <>
<VoidSwitch
size={size}
value={voidSettingsState.globalSettings.autoApprove[approvalType] ?? false}
onChange={(newVal) => onToggleAutoApprove(approvalType, newVal)}
/>
<span className="text-void-fg-3 text-xs">{desc}</span>
</>
}
export const OneClickSwitchButton = ({ fromEditor = 'VS Code', className = '' }: { fromEditor?: TransferEditorType, className?: string }) => {
const accessor = useAccessor()
const fileService = accessor.get('IFileService')
@@ -731,7 +805,28 @@ export const OneClickSwitchButton = ({ fromEditor = 'VS Code', className = '' }:
setTransferState({ type: 'loading' })
let errAcc = ''
for (let { from, to } of transferTheseFiles) {
// Define extensions to skip when transferring
const extensionBlacklist = [
// ignore extensions
'ms-vscode-remote.remote-ssh',
'ms-vscode-remote.remote-wsl',
// ignore other AI copilots that could conflict with Void keybindings
'sourcegraph.cody-ai',
'continue.continue',
'codeium.codeium',
'saoudrizwan.claude-dev', // cline
'rooveterinaryinc.roo-cline', // roo
];
for (const { from, to } of transferTheseFiles) {
try {
// find a blacklisted item
const isBlacklisted = extensionBlacklist.find(blacklistItem => {
return from.fsPath?.includes(blacklistItem)
})
if (isBlacklisted) continue
} catch { }
console.log('transferring', from, to)
// Check if the source file exists before attempting to copy
try {
@@ -788,6 +883,72 @@ export const Settings = () => {
const nativeHostService = accessor.get('INativeHostService')
const settingsState = useSettingsState()
const voidSettingsService = accessor.get('IVoidSettingsService')
const chatThreadsService = accessor.get('IChatThreadService')
const notificationService = accessor.get('INotificationService')
const onDownload = (t: 'Chats' | 'Settings') => {
let dataStr: string
let downloadName: string
if (t === 'Chats') {
// Export chat threads
dataStr = JSON.stringify(chatThreadsService.state, null, 2)
downloadName = 'void-chats.json'
}
else if (t === 'Settings') {
// Export user settings
dataStr = JSON.stringify(voidSettingsService.state, null, 2)
downloadName = 'void-settings.json'
}
else {
dataStr = ''
downloadName = ''
}
const blob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = downloadName
a.click()
URL.revokeObjectURL(url)
}
// Add file input refs
const fileInputSettingsRef = useRef<HTMLInputElement>(null)
const fileInputChatsRef = useRef<HTMLInputElement>(null)
const [s, ss] = useState(0)
const handleUpload = (t: 'Chats' | 'Settings') => (e: React.ChangeEvent<HTMLInputElement>,) => {
const files = e.target.files
if (!files) return;
const file = files[0]
if (!file) return
const reader = new FileReader();
reader.onload = () => {
try {
const json = JSON.parse(reader.result as string);
if (t === 'Chats') {
chatThreadsService.dangerousSetState(json as any)
}
else if (t === 'Settings') {
voidSettingsService.dangerousSetState(json as any)
}
notificationService.info(`${t} imported successfully!`)
} catch (err) {
notificationService.notify({ message: `Failed to import ${t}`, source: err + '', severity: Severity.Error, })
}
};
reader.readAsText(file);
e.target.value = '';
ss(s => s + 1)
}
return <div className={`@@void-scope ${isDark ? 'dark' : ''}`} style={{ height: '100%', width: '100%' }}>
<div className='overflow-y-auto w-full h-full px-10 py-10 select-none'>
@@ -799,6 +960,8 @@ export const Settings = () => {
{/* separator */}
<div className='w-full h-[1px] my-4' />
{/* Models section (formerly FeaturesTab) */}
{/* Models section (formerly FeaturesTab) */}
<ErrorBoundary>
<h2 className={`text-3xl mb-2`}>Models</h2>
@@ -814,7 +977,7 @@ export const Settings = () => {
<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='opacity-80 mb-4'>
{ollamaSetupInstructions}
<OllamaSetupInstructions />
</div>
<ErrorBoundary>
@@ -927,15 +1090,12 @@ export const Settings = () => {
<div className='my-2'>
{/* Auto Accept Switch */}
<ErrorBoundary>
{[...toolApprovalTypes].map((approvalType) => {
return <div key={approvalType} className="flex items-center gap-x-2 my-2">
<ToolApprovalTypeSwitch size='xs' approvalType={approvalType} desc={`Auto-approve ${approvalType}`} />
</div>
})}
<div className='flex items-center gap-x-2 my-2'>
<VoidSwitch
size='xs'
value={settingsState.globalSettings.autoApprove}
onChange={(newVal) => voidSettingsService.setGlobalSetting('autoApprove', newVal)}
/>
<span className='text-void-fg-3 text-xs pointer-events-none'>{settingsState.globalSettings.autoApprove ? 'Auto-approve' : 'Auto-approve'}</span>
</div>
</ErrorBoundary>
{/* Tool Lint Errors Switch */}
@@ -979,10 +1139,10 @@ export const Settings = () => {
{/* General section (formerly GeneralTab) */}
<div className='mt-12'>
<ErrorBoundary>
<h2 className={`text-3xl mb-2 mt-12`}>One-Click Switch</h2>
<h4 className={`text-void-fg-3 mb-4`}>{`Transfer your settings from another editor to Void in one click.`}</h4>
<h2 className='text-3xl mb-2 mt-12'>One-Click Switch</h2>
<h4 className='text-void-fg-3 mb-4'>{`Transfer your settings from another editor to Void in one click.`}</h4>
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-2'>
<OneClickSwitchButton className='w-48' fromEditor="VS Code" />
<OneClickSwitchButton className='w-48' fromEditor="Cursor" />
<OneClickSwitchButton className='w-48' fromEditor="Windsurf" />
@@ -990,6 +1150,41 @@ export const Settings = () => {
</ErrorBoundary>
</div>
{/* Import/Export section, as its own block right after One-Click Switch */}
<div className='mt-12'>
<h2 className='text-3xl mb-2'>Import/Export</h2>
<div className='flex gap-8'>
{/* Settings Subcategory */}
<div className='flex flex-col gap-2 max-w-48 w-full'>
<h3 className='text-xl mb-2'>Settings</h3>
<input key={2 * s} ref={fileInputSettingsRef} type='file' accept='.json' className='hidden' onChange={handleUpload('Settings')} />
<VoidButtonBgDarken className='px-4 py-1 w-full' onClick={() => { fileInputSettingsRef.current?.click() }}>
Import Settings
</VoidButtonBgDarken>
<VoidButtonBgDarken className='px-4 py-1 w-full' onClick={() => onDownload('Settings')}>
Export Settings
</VoidButtonBgDarken>
<ConfirmButton className='px-4 py-1 w-full' onConfirm={() => { voidSettingsService.resetState(); }}>
Reset Settings
</ConfirmButton>
</div>
{/* Chats Subcategory */}
<div className='flex flex-col gap-2 w-full max-w-48'>
<h3 className='text-xl mb-2'>Chat</h3>
<input key={2 * s + 1} ref={fileInputChatsRef} type='file' accept='.json' className='hidden' onChange={handleUpload('Chats')} />
<VoidButtonBgDarken className='px-4 py-1 w-full' onClick={() => { fileInputChatsRef.current?.click() }}>
Import Chats
</VoidButtonBgDarken>
<VoidButtonBgDarken className='px-4 py-1 w-full' onClick={() => onDownload('Chats')}>
Export Chats
</VoidButtonBgDarken>
<ConfirmButton className='px-4 py-1 w-full' onConfirm={() => { chatThreadsService.resetState(); }}>
Reset Chats
</ConfirmButton>
</div>
</div>
</div>
<div className='mt-12'>
@@ -998,23 +1193,17 @@ export const Settings = () => {
<h4 className={`text-void-fg-3 mb-4`}>{`IDE settings, keyboard settings, and theme customization.`}</h4>
<ErrorBoundary>
<div className='my-4'>
<VoidButtonBgDarken className='px-4 py-2' onClick={() => { commandService.executeCommand('workbench.action.openSettings') }}>
<div className='flex flex-col gap-2 justify-center max-w-48 w-full'>
<VoidButtonBgDarken className='px-4 py-1' onClick={() => { commandService.executeCommand('workbench.action.openSettings') }}>
General Settings
</VoidButtonBgDarken>
</div>
<div className='my-4'>
<VoidButtonBgDarken className='px-4 py-2' onClick={() => { commandService.executeCommand('workbench.action.openGlobalKeybindings') }}>
<VoidButtonBgDarken className='px-4 py-1' onClick={() => { commandService.executeCommand('workbench.action.openGlobalKeybindings') }}>
Keyboard Settings
</VoidButtonBgDarken>
</div>
<div className='my-4'>
<VoidButtonBgDarken className='px-4 py-2' onClick={() => { commandService.executeCommand('workbench.action.selectTheme') }}>
<VoidButtonBgDarken className='px-4 py-1' onClick={() => { commandService.executeCommand('workbench.action.selectTheme') }}>
Theme Settings
</VoidButtonBgDarken>
</div>
<div className='my-4'>
<VoidButtonBgDarken className='px-4 py-2' onClick={() => { nativeHostService.showItemInFolder(environmentService.logsHome.fsPath) }}>
<VoidButtonBgDarken className='px-4 py-1' onClick={() => { nativeHostService.showItemInFolder(environmentService.logsHome.fsPath) }}>
Open Logs
</VoidButtonBgDarken>
</div>
@@ -1025,11 +1214,11 @@ export const Settings = () => {
<div className='mt-12 max-w-[600px]'>
<h2 className={`text-3xl mb-2`}>AI Instructions</h2>
<h4 className={`text-void-fg-3 mb-4`}>
<ChatMarkdownRender inPTag={true} string={`
<ChatMarkdownRender inPTag={true} string={`
System instructions to include with all AI requests.
Alternatively, place a \`.voidinstructions\` file in the root of your workspace.
Alternatively, place a \`.voidrules\` file in the root of your workspace.
`} chatMessageLocation={undefined} />
</h4>
</h4>
<ErrorBoundary>
<AIInstructionsBox />
</ErrorBoundary>
@@ -15,7 +15,7 @@ export const WarningBox = ({ text, onClick, className }: { text: string; onClick
>
<IconWarning
size={14}
className='mr-1'
className='mr-1 flex-shrink-0'
/>
<span>{text}</span>
</div>
@@ -53,6 +53,7 @@ module.exports = {
'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-2-hover': 'var(--void-bg-2-hover)',
'void-bg-3': 'var(--void-bg-3)',
@@ -202,7 +202,19 @@ registerAction2(class extends Action2 {
})
const openNewThreadAndFireFocus = (accessor: ServicesAccessor) => {
const stateService = accessor.get(ISidebarStateService)
stateService.setState({ isHistoryOpen: false, currentTab: 'chat' })
const chatThreadService = accessor.get(IChatThreadService)
chatThreadService.openNewThread()
// focus
stateService.fireFocusChat()
const window = getActiveWindow()
window.requestAnimationFrame(() => stateService.fireFocusChat())
}
// New chat menu button
@@ -213,6 +225,25 @@ registerAction2(class extends Action2 {
title: 'New Chat',
icon: { id: 'add' },
menu: [{ id: MenuId.ViewTitle, group: 'navigation', when: ContextKeyExpr.equals('view', VOID_VIEW_ID), }],
});
}
async run(accessor: ServicesAccessor): Promise<void> {
const metricsService = accessor.get(IMetricsService)
metricsService.capture('Chat Navigation', { type: 'New Chat' })
openNewThreadAndFireFocus(accessor)
}
})
// New chat keybind
registerAction2(class extends Action2 {
constructor() {
super({
id: 'void.newChatKeybindAction',
title: 'New Chat Keybind',
keybinding: {
primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyL,
weight: KeybindingWeight.VoidExtension,
@@ -220,19 +251,16 @@ registerAction2(class extends Action2 {
});
}
async run(accessor: ServicesAccessor): Promise<void> {
const stateService = accessor.get(ISidebarStateService)
const metricsService = accessor.get(IMetricsService)
const commandService = accessor.get(ICommandService)
metricsService.capture('Chat Navigation', { type: 'New Chat Keybind' })
metricsService.capture('Chat Navigation', { type: 'New Chat' })
openNewThreadAndFireFocus(accessor)
stateService.setState({ isHistoryOpen: false, currentTab: 'chat' })
const chatThreadService = accessor.get(IChatThreadService)
chatThreadService.openNewThread()
// add user's selection to chat
await commandService.executeCommand(VOID_CTRL_L_ACTION_ID)
// focus
stateService.fireFocusChat()
const window = getActiveWindow()
window.requestAnimationFrame(() => stateService.fireFocusChat())
}
})
@@ -247,13 +275,27 @@ registerAction2(class extends Action2 {
});
}
async run(accessor: ServicesAccessor): Promise<void> {
// do not do anything if there are no messages (without this it clears all of the user's selections if the button is pressed)
// TODO the history button should be disabled in this case so we can remove this logic
const thread = accessor.get(IChatThreadService).getCurrentThread()
if (thread.messages.length === 0) {
return;
}
const stateService = accessor.get(ISidebarStateService)
const metricsService = accessor.get(IMetricsService)
metricsService.capture('Chat Navigation', { type: 'History' })
openNewThreadAndFireFocus(accessor)
// doesnt do anything right now
stateService.setState({ isHistoryOpen: !stateService.state.isHistoryOpen, currentTab: 'chat' })
stateService.fireBlurChat()
}
})
@@ -13,7 +13,7 @@ import { VOID_OPEN_SIDEBAR_ACTION_ID } from './sidebarPane.js';
// service that manages sidebar's state
export type VoidSidebarState = {
isHistoryOpen: boolean;
isHistoryOpen: boolean; // this isn't doing anything right now
currentTab: 'chat';
}
@@ -3,14 +3,14 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js';
import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
import { removeAnsiEscapeCodes } from '../../../../base/common/strings.js';
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { TerminalLocation } from '../../../../platform/terminal/common/terminal.js';
import { TerminalExitReason, TerminalLocation } from '../../../../platform/terminal/common/terminal.js';
import { ITerminalService, ITerminalInstance } from '../../../../workbench/contrib/terminal/browser/terminal.js';
import { MAX_TERMINAL_CHARS, MAX_TERMINAL_INACTIVE_TIME } from '../common/prompt/prompts.js';
import { TerminalResolveReason } from '../common/toolsServiceTypes.js';
import { MAX_TERMINAL_CHARS_PAGE, TERMINAL_BG_WAIT_TIME, TERMINAL_TIMEOUT_TIME } from './toolsService.js';
@@ -18,9 +18,12 @@ export interface ITerminalToolService {
readonly _serviceBrand: undefined;
listTerminalIds(): string[];
runCommand(command: string, proposedTerminalId: string, waitForCompletion: boolean): Promise<{ terminalId: string, didCreateTerminal: boolean, result: string, resolveReason: TerminalResolveReason }>;
openTerminal(terminalId: string): Promise<void>
runCommand(command: string, bgTerminalId: string | null): Promise<{ terminalId: string, resPromise: Promise<{ result: string, resolveReason: TerminalResolveReason }> }>;
focusTerminal(terminalId: string): Promise<void>
terminalExists(terminalId: string): boolean
createTerminal(): Promise<string>
killTerminal(terminalId: string): Promise<void>
}
export const ITerminalToolService = createDecorator<ITerminalToolService>('TerminalToolService');
@@ -103,12 +106,7 @@ export class TerminalToolService extends Disposable implements ITerminalToolServ
throw new Error('This should never be reached by pigeonhole principle');
}
private async _getOrCreateTerminal(proposedTerminalId: string) {
// if terminal ID exists, return it
if (proposedTerminalId in this.terminalInstanceOfId) return { terminalId: proposedTerminalId, didCreateTerminal: false }
async createTerminal() {
// create new terminal and return its ID
const terminalId = this.getValidNewTerminalId();
const terminal = await this.terminalService.createTerminal({
@@ -127,13 +125,21 @@ export class TerminalToolService extends Disposable implements ITerminalToolServ
})
disposables.push(d)
})
const waitForTimeout = new Promise<void>(res => { setTimeout(() => { res() }, 1000) })
const waitForTimeout = new Promise<void>(res => { setTimeout(() => { res() }, 5000) })
await Promise.any([waitForMount, waitForTimeout,])
disposables.forEach(d => d.dispose())
this.terminalInstanceOfId[terminalId] = terminal
return { terminalId, didCreateTerminal: true }
return terminalId
}
async killTerminal(terminalId: string) {
const terminal = this.terminalInstanceOfId[terminalId]
if (!terminal) throw new Error(`Kill Terminal: Terminal with ID ${terminalId} did not exist.`);
terminal.dispose(TerminalExitReason.Extension)
delete this.terminalInstanceOfId[terminalId]
return
}
terminalExists(terminalId: string): boolean {
@@ -141,7 +147,7 @@ export class TerminalToolService extends Disposable implements ITerminalToolServ
}
openTerminal: ITerminalToolService['openTerminal'] = async (terminalId) => {
focusTerminal: ITerminalToolService['focusTerminal'] = async (terminalId) => {
if (!terminalId) return
const terminal = this.terminalInstanceOfId[terminalId]
if (!terminal) return // should never happen
@@ -151,81 +157,104 @@ export class TerminalToolService extends Disposable implements ITerminalToolServ
runCommand: ITerminalToolService['runCommand'] = async (command, proposedTerminalId, waitForCompletion) => {
runCommand: ITerminalToolService['runCommand'] = async (command, bgTerminalId) => {
await this.terminalService.whenConnected;
const { terminalId, didCreateTerminal } = await this._getOrCreateTerminal(proposedTerminalId)
const terminal = this.terminalInstanceOfId[terminalId];
if (!terminal) throw new Error(`Unexpected internal error: Terminal with ID ${terminalId} did not exist.`);
// focus the terminal about to run
this.terminalService.setActiveInstance(terminal)
await this.terminalService.focusActiveInstance()
let result: string = ''
let resolveReason: TerminalResolveReason | undefined = undefined
let terminal: ITerminalInstance
const disposables: IDisposable[] = []
const waitUntilDone = new Promise<void>((res, rej) => {
const d2 = terminal.onData(async newData => {
if (resolveReason) return
const isBG = bgTerminalId !== null
let terminalId: string
if (isBG) { // BG process
terminal = this.terminalInstanceOfId[bgTerminalId];
if (!terminal) throw new Error(`Unexpected internal error: Terminal with ID ${bgTerminalId} did not exist.`);
terminalId = bgTerminalId
}
else {
terminalId = await this.createTerminal()
terminal = this.terminalInstanceOfId[terminalId]
if (!terminal) throw new Error(`Unexpected error: Terminal could not be created.`)
}
result += newData
// onPageFull
if (result.length > MAX_TERMINAL_CHARS_PAGE) {
result = result.substring(0, MAX_TERMINAL_CHARS_PAGE)
await terminal.sendText('\x03', true) // interrupt the terminal with Ctrl+C
resolveReason = { type: 'toofull' }
res()
return
}
const waitForResult = async () => {
// focus the terminal about to run
this.terminalService.setActiveInstance(terminal)
await this.terminalService.focusActiveInstance()
// onDone
const isDone = isCommandComplete(result)
if (isDone) {
resolveReason = { type: 'done', exitCode: isDone.exitCode }
res()
return
}
let result: string = ''
let resolveReason: TerminalResolveReason | undefined = undefined
// create this before we send so that we don't miss events on terminal
const waitUntilDone = new Promise<void>((res, rej) => {
const d2 = terminal.onData(async newData => {
if (resolveReason) return
result += newData
// onDone
const isDone = isCommandComplete(result)
if (isDone) {
resolveReason = { type: 'done', exitCode: isDone.exitCode }
res()
return
}
})
disposables.push(d2)
})
disposables.push(d2)
})
// send the command here
await terminal.sendText(command, true)
// send the command here
await terminal.sendText(command, true)
// timeout promise
const waitUntilTimeout = new Promise<void>((res, rej) => {
setTimeout(async () => {
if (resolveReason) return
await terminal.sendText('\x03', true) // interrupt the terminal with Ctrl+C
resolveReason = { type: waitForCompletion ? 'timeout' : 'bgtask' }
res()
return
}, (waitForCompletion ? TERMINAL_TIMEOUT_TIME : TERMINAL_BG_WAIT_TIME) * 1000)
})
// inactivity-based timeout
const waitUntilInactive = new Promise<void>(res => {
let globalTimeoutId: ReturnType<typeof setTimeout>;
const resetTimer = () => {
clearTimeout(globalTimeoutId);
globalTimeoutId = setTimeout(() => {
if (resolveReason) return
await Promise.any([
waitUntilDone,
waitUntilTimeout,
])
resolveReason = { type: 'timeout' };
res();
}, MAX_TERMINAL_INACTIVE_TIME * 1000);
};
disposables.forEach(d => d.dispose())
const dTimeout = terminal.onData(() => { resetTimer(); });
disposables.push(dTimeout, toDisposable(() => clearTimeout(globalTimeoutId)));
resetTimer();
});
if (!resolveReason) throw new Error('Unexpected internal error: Promise.any should have resolved with a reason.')
// wait for result
await Promise.any([waitUntilDone, waitUntilInactive,])
disposables.forEach(d => d.dispose())
if (!isBG) {
await this.killTerminal(terminalId)
}
result = removeAnsiEscapeCodes(result)
.split('\n').slice(1, -1) // remove first and last line (first = command, last = andrewpareles/void %)
.join('\n')
if (!resolveReason) throw new Error('Unexpected internal error: Promise.any should have resolved with a reason.')
return { terminalId, didCreateTerminal, result, resolveReason }
result = removeAnsiEscapeCodes(result)
.split('\n').slice(1, -1) // remove first and last line (first = command, last = andrewpareles/void %)
.join('\n')
if (result.length > MAX_TERMINAL_CHARS) {
const half = MAX_TERMINAL_CHARS / 2
result = result.slice(0, half)
+ '\n...\n'
+ result.slice(result.length - half, Infinity)
}
return { result, resolveReason }
}
const resPromise = waitForResult()
return { terminalId, resPromise }
}
}
registerSingleton(ITerminalToolService, TerminalToolService, InstantiationType.Delayed);
@@ -17,7 +17,7 @@ import { computeDirectoryTree1Deep, IDirectoryStrService, stringifyDirectoryTree
import { IMarkerService, MarkerSeverity } from '../../../../platform/markers/common/markers.js'
import { timeout } from '../../../../base/common/async.js'
import { RawToolParamsObj } from '../common/sendLLMMessageTypes.js'
import { ToolName } from '../common/prompt/prompts.js'
import { MAX_CHILDREN_URIs_PAGE, MAX_FILE_CHARS_PAGE, MAX_TERMINAL_INACTIVE_TIME, ToolName } from '../common/prompt/prompts.js'
import { IVoidSettingsService } from '../common/voidSettingsService.js'
@@ -27,20 +27,11 @@ import { IVoidSettingsService } from '../common/voidSettingsService.js'
type ValidateParams = { [T in ToolName]: (p: RawToolParamsObj) => ToolCallParams[T] }
type CallTool = { [T in ToolName]: (p: ToolCallParams[T]) => Promise<{ result: ToolResultType[T], interruptTool?: () => void }> }
type CallTool = { [T in ToolName]: (p: ToolCallParams[T]) => Promise<{ result: ToolResultType[T] | Promise<ToolResultType[T]>, interruptTool?: () => void }> }
type ToolResultToString = { [T in ToolName]: (p: ToolCallParams[T], result: Awaited<ToolResultType[T]>) => string }
// pagination info
export const MAX_FILE_CHARS_PAGE = Infinity
export const MAX_CHILDREN_URIs_PAGE = 500
export const MAX_TERMINAL_CHARS_PAGE = 20_000
export const TERMINAL_TIMEOUT_TIME = 5 // seconds
export const TERMINAL_BG_WAIT_TIME = 1
const isFalsy = (u: unknown) => {
return !u || u === 'null' || u === 'undefined'
}
@@ -176,12 +167,12 @@ export class ToolsService implements IToolsService {
const uri = validateURI(uriStr)
const pageNumber = validatePageNum(pageNumberUnknown)
return { rootURI: uri, pageNumber }
return { uri, pageNumber }
},
get_dir_structure: (params: RawToolParamsObj) => {
get_dir_tree: (params: RawToolParamsObj) => {
const { uri: uriStr, } = params
const uri = validateURI(uriStr)
return { rootURI: uri }
return { uri }
},
search_pathnames_only: (params: RawToolParamsObj) => {
const {
@@ -192,9 +183,9 @@ export class ToolsService implements IToolsService {
const queryStr = validateStr('query', queryUnknown)
const pageNumber = validatePageNum(pageNumberUnknown)
const searchInFolder = validateOptionalStr('search_in_folder', includeUnknown)
const includePattern = validateOptionalStr('include_pattern', includeUnknown)
return { queryStr, searchInFolder, pageNumber }
return { query: queryStr, includePattern, pageNumber }
},
search_for_files: (params: RawToolParamsObj) => {
@@ -204,14 +195,23 @@ export class ToolsService implements IToolsService {
is_regex: isRegexUnknown,
page_number: pageNumberUnknown
} = params
const queryStr = validateStr('query', queryUnknown)
const pageNumber = validatePageNum(pageNumberUnknown)
const searchInFolder = validateOptionalURI(searchInFolderUnknown)
const isRegex = validateBoolean(isRegexUnknown, { default: false })
return { queryStr, searchInFolder, isRegex, pageNumber }
return {
query: queryStr,
isRegex,
searchInFolder,
pageNumber
}
},
search_in_file: (params: RawToolParamsObj) => {
const { uri: uriStr, query: queryUnknown, is_regex: isRegexUnknown } = params;
const uri = validateURI(uriStr);
const query = validateStr('query', queryUnknown);
const isRegex = validateBoolean(isRegexUnknown, { default: false });
return { uri, query, isRegex };
},
read_lint_errors: (params: RawToolParamsObj) => {
@@ -242,18 +242,28 @@ export class ToolsService implements IToolsService {
},
edit_file: (params: RawToolParamsObj) => {
const { uri: uriStr, change_description: changeDescriptionUnknown } = params
const { uri: uriStr, change_diff: changeDiffUnknown } = params
const uri = validateURI(uriStr)
const changeDescription = validateStr('changeDescription', changeDescriptionUnknown)
return { uri, changeDescription }
const changeDiff = validateStr('changeDiff', changeDiffUnknown)
return { uri, changeDiff }
},
run_terminal_command: (params: RawToolParamsObj) => {
const { command: commandUnknown, terminal_id: terminalIdUnknown, wait_for_completion: waitForCompletionUnknown } = params
const command = validateStr('command', commandUnknown)
const proposedTerminalId = validateProposedTerminalId(terminalIdUnknown)
const waitForCompletion = validateBoolean(waitForCompletionUnknown, { default: true })
return { command, proposedTerminalId, waitForCompletion }
// ---
run_command: (params: RawToolParamsObj) => {
const { command: commandUnknown, terminal_id: terminalIdUnknown } = params;
const command = validateStr('command', commandUnknown);
const proposedTerminalId = terminalIdUnknown ? validateProposedTerminalId(terminalIdUnknown) : null;
return { command, bgTerminalId: proposedTerminalId };
},
open_persistent_terminal: (_params: RawToolParamsObj) => {
// No parameters needed; will open a new background terminal
return {};
},
kill_persistent_terminal: (params: RawToolParamsObj) => {
const { terminal_id: terminalIdUnknown } = params;
const terminalId = validateProposedTerminalId(terminalIdUnknown);
return { terminalId };
},
}
@@ -263,7 +273,7 @@ export class ToolsService implements IToolsService {
read_file: async ({ uri, startLine, endLine, pageNumber }) => {
await voidModelService.initializeModel(uri)
const { model } = await voidModelService.getModelSafe(uri)
if (model === null) { throw new Error(`Contents were empty. There may have been an error, or the file may not exist.`) }
if (model === null) { throw new Error(`No contents; File does not exist.`) }
let contents: string
if (startLine === null && endLine === null) {
@@ -283,21 +293,21 @@ export class ToolsService implements IToolsService {
return { result: { fileContents, totalFileLen, hasNextPage } }
},
ls_dir: async ({ rootURI, pageNumber }) => {
const dirResult = await computeDirectoryTree1Deep(fileService, rootURI, pageNumber)
ls_dir: async ({ uri, pageNumber }) => {
const dirResult = await computeDirectoryTree1Deep(fileService, uri, pageNumber)
return { result: dirResult }
},
get_dir_structure: async ({ rootURI }) => {
const str = await this.directoryStrService.getDirectoryStrTool(rootURI)
get_dir_tree: async ({ uri }) => {
const str = await this.directoryStrService.getDirectoryStrTool(uri)
return { result: { str } }
},
search_pathnames_only: async ({ queryStr, searchInFolder, pageNumber }) => {
search_pathnames_only: async ({ query: queryStr, includePattern, pageNumber }) => {
const query = queryBuilder.file(workspaceContextService.getWorkspace().folders.map(f => f.uri), {
filePattern: queryStr,
includePattern: searchInFolder ?? undefined,
includePattern: includePattern ?? undefined,
})
const data = await searchService.fileSearch(query, CancellationToken.None)
@@ -311,7 +321,7 @@ export class ToolsService implements IToolsService {
return { result: { uris, hasNextPage } }
},
search_for_files: async ({ queryStr, isRegex, searchInFolder, pageNumber }) => {
search_for_files: async ({ query: queryStr, isRegex, searchInFolder, pageNumber }) => {
const searchFolders = searchInFolder === null ?
workspaceContextService.getWorkspace().folders.map(f => f.uri)
: [searchInFolder]
@@ -332,6 +342,24 @@ export class ToolsService implements IToolsService {
const hasNextPage = (data.results.length - 1) - toIdx >= 1
return { result: { queryStr, uris, hasNextPage } }
},
search_in_file: async ({ uri, query, isRegex }) => {
await voidModelService.initializeModel(uri);
const { model } = await voidModelService.getModelSafe(uri);
if (model === null) { throw new Error(`No contents; File does not exist.`); }
const contents = model.getValue(EndOfLinePreference.LF);
const contentOfLine = contents.split('\n');
const totalLines = contentOfLine.length;
const regex = isRegex ? new RegExp(query) : null;
const lines: number[] = []
for (let i = 0; i < totalLines; i++) {
const line = contentOfLine[i];
if ((isRegex && regex!.test(line)) || (!isRegex && line.includes(query))) {
const matchLine = i + 1;
lines.push(matchLine);
}
}
return { result: { lines } };
},
read_lint_errors: async ({ uri }) => {
await timeout(1000)
@@ -355,14 +383,14 @@ export class ToolsService implements IToolsService {
return { result: {} }
},
edit_file: async ({ uri, changeDescription }) => {
edit_file: async ({ uri, changeDiff }) => {
await voidModelService.initializeModel(uri)
if (this.commandBarService.getStreamState(uri) === 'streaming') {
throw new Error(`Another LLM is currently making changes to this file. Please stop streaming for now and resume later.`)
throw new Error(`Another LLM is currently making changes to this file. Please stop streaming for now and ask the user to resume later.`)
}
const opts = {
uri,
applyStr: changeDescription,
applyStr: changeDiff,
from: 'ClickApply',
startBehavior: 'keep-conflicts',
} as const
@@ -385,10 +413,25 @@ export class ToolsService implements IToolsService {
return { result: lintErrorsPromise, interruptTool }
},
run_terminal_command: async ({ command, proposedTerminalId, waitForCompletion }) => {
const { terminalId, didCreateTerminal, result, resolveReason } = await this.terminalToolService.runCommand(command, proposedTerminalId, waitForCompletion)
return { result: { terminalId, didCreateTerminal, result, resolveReason } }
// ---
run_command: async ({ command, bgTerminalId }) => {
const { terminalId, resPromise } = await this.terminalToolService.runCommand(command, bgTerminalId)
const interruptTool = () => {
this.terminalToolService.killTerminal(terminalId)
}
return { result: resPromise, interruptTool }
},
open_persistent_terminal: async () => {
// Open a new background terminal without waiting for completion
const terminalId = await this.terminalToolService.createTerminal()
return { result: { terminalId } }
},
kill_persistent_terminal: async ({ terminalId }) => {
// Close the background terminal by sending exit
await this.terminalToolService.killTerminal(terminalId)
return { result: {} }
},
}
@@ -398,9 +441,10 @@ export class ToolsService implements IToolsService {
return lintErrors
.map((e, i) => `Error ${i + 1}:\nLines Affected: ${e.startLineNumber}-${e.endLineNumber}\nError message:${e.message}`)
.join('\n\n')
.substring(0, MAX_FILE_CHARS_PAGE)
}
// given to the LLM after the call
// given to the LLM after the call for successful tool calls
this.stringOfResult = {
read_file: (params, result) => {
return `${params.uri.fsPath}\n\`\`\`\n${result.fileContents}\n\`\`\`${nextPageStr(result.hasNextPage)}`
@@ -409,7 +453,7 @@ export class ToolsService implements IToolsService {
const dirTreeStr = stringifyDirectoryTree1Deep(params, result)
return dirTreeStr // + nextPageStr(result.hasNextPage) // already handles num results remaining
},
get_dir_structure: (params, result) => {
get_dir_tree: (params, result) => {
return result.str
},
search_pathnames_only: (params, result) => {
@@ -418,6 +462,15 @@ export class ToolsService implements IToolsService {
search_for_files: (params, result) => {
return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage)
},
search_in_file: (params, result) => {
const { model } = voidModelService.getModel(params.uri)
if (!model) return '<Error getting string of result>'
const lines = result.lines.map(n => {
const lineContent = model.getValueInRange({ startLineNumber: n, startColumn: 1, endLineNumber: n, endColumn: Number.MAX_SAFE_INTEGER }, EndOfLinePreference.LF)
return `Line ${n}:\n\`\`\`\n${lineContent}\n\`\`\``
}).join('\n\n');
return lines;
},
read_lint_errors: (params, result) => {
return result.lintErrors ?
stringifyLintErrors(result.lintErrors)
@@ -439,30 +492,41 @@ export class ToolsService implements IToolsService {
return `Change successfully made to ${params.uri.fsPath}.${lintErrsString}`
},
run_terminal_command: (params, result) => {
run_command: (params, result) => {
const {
terminalId,
didCreateTerminal,
resolveReason,
result: result_,
} = result
const { bgTerminalId } = params
const terminalDesc = `terminal ${terminalId}${didCreateTerminal ? ` (a newly-created terminal)` : ''}`
// success
if (resolveReason.type === 'done') {
const desc = bgTerminalId ? ` in terminal ${bgTerminalId}` : ''
return `Terminal command executed and finished${desc}. Result (exit code ${resolveReason.exitCode}):\n${result_}`
}
if (resolveReason.type === 'timeout') {
return `Terminal command ran in ${terminalDesc}, but did not complete after ${TERMINAL_TIMEOUT_TIME} seconds. Result:\n${result_}`
// bg command
if (bgTerminalId !== null) {
if (resolveReason.type === 'timeout') {
return `Terminal command is running in the background in terminal ${bgTerminalId}. Here were the outputs after ${MAX_TERMINAL_INACTIVE_TIME} seconds:\n${result_}`
}
}
else if (resolveReason.type === 'bgtask') {
return `Terminal command is running in the background in ${terminalDesc}. Here were the outputs after ${TERMINAL_BG_WAIT_TIME} seconds:\n${result_}`
}
else if (resolveReason.type === 'toofull') {
return `Terminal command executed in terminal ${terminalDesc}. Command was interrupted because output was too long. Result:\n${result_}`
}
else if (resolveReason.type === 'done') {
return `Terminal command executed in terminal ${terminalDesc}. Result (exit code ${resolveReason.exitCode}):\n${result_}`
// normal command
else {
if (resolveReason.type === 'timeout') {
return `Terminal command ran, but was interrupted after ${MAX_TERMINAL_INACTIVE_TIME}s of inactivity and did not necessarily finish successfully. Full output:\n${result_}`
}
}
throw new Error(`Unexpected internal error: Terminal command did not resolve with a valid reason.`)
},
open_persistent_terminal: (_params, result) => {
const { terminalId } = result;
return `Successfully created background terminal with ID ${terminalId}`;
},
kill_persistent_terminal: (params, _result) => {
return `Successfully closed terminal ${params.terminalId}.`;
},
}
@@ -475,6 +539,7 @@ export class ToolsService implements IToolsService {
const lintErrors = this.markerService
.read({ resource: uri })
.filter(l => l.severity === MarkerSeverity.Error || l.severity === MarkerSeverity.Warning)
.slice(0, 100)
.map(l => ({
code: typeof l.code === 'string' ? l.code : l.code?.value || '',
message: (l.severity === MarkerSeverity.Error ? '(error) ' : '(warning) ') + l.message,
@@ -153,6 +153,7 @@ export const extractCodeFromFIM = ({ text, recentlyAddedTextLen, midTag, }: { te
const foundMid = pm.removePrefix(`<${midTag}>`)
if (foundMid) {
pm.removeSuffix(`\n`) // sometimes outputs \n
pm.removeSuffix(`</${midTag}>`)
}
const s = pm.value()
@@ -43,7 +43,22 @@ export const defaultProviderSettings = {
},
mistral: {
apiKey: '',
}
},
lmStudio: {
endpoint: 'http://localhost:1234',
},
liteLLM: { // https://docs.litellm.ai/docs/providers/openai_compatible
endpoint: '',
},
// googleVertex: { // google https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/call-vertex-using-openai-library
// region: 'us-west2',
// project: '',
// },
microsoftAzure: { // microsoft Azure Foundry
project: '', // really 'resource'
apiKey: '',
azureApiVersion: '2024-05-01-preview',
},
} as const
@@ -84,6 +99,8 @@ export const defaultModelsOfProvider = {
],
vLLM: [ // autodetected
],
lmStudio: [], // autodetected
openRouter: [ // https://openrouter.ai/models
// 'anthropic/claude-3.7-sonnet:thinking',
'anthropic/claude-3.7-sonnet',
@@ -112,6 +129,11 @@ export const defaultModelsOfProvider = {
'ministral-8b-latest',
],
openAICompatible: [], // fallback
// googleVertex: [],
microsoftAzure: [],
liteLLM: [],
} as const satisfies Record<ProviderName, string[]>
@@ -168,7 +190,7 @@ type VoidStaticProviderInfo = { // doesn't change (not stateful)
const modelOptionsDefaults: VoidStaticModelInfo = {
contextWindow: 32_000,
contextWindow: 16_000,
maxOutputTokens: 4_096,
cost: { input: 0, output: 0 },
downloadable: false,
@@ -806,6 +828,25 @@ const groqSettings: VoidStaticProviderInfo = {
modelOptionsFallback: (modelName) => { return null }
}
// ---------------- GOOGLE VERTEX ----------------
// const googleVertexModelOptions = {
// } as const satisfies Record<string, VoidStaticModelInfo>
// const googleVertexSettings: VoidStaticProviderInfo = {
// modelOptions: googleVertexModelOptions,
// modelOptionsFallback: (modelName) => { return null }
// }
// ---------------- MICROSOFT AZURE ----------------
const microsoftAzureModelOptions = {
} as const satisfies Record<string, VoidStaticModelInfo>
const microsoftAzureSettings: VoidStaticProviderInfo = {
modelOptions: microsoftAzureModelOptions,
modelOptionsFallback: (modelName) => { return null }
}
// ---------------- VLLM, OLLAMA, OPENAICOMPAT (self-hosted / local) ----------------
const ollamaModelOptions = {
'qwen2.5-coder:1.5b': {
contextWindow: 32_000,
@@ -858,9 +899,6 @@ const ollamaModelOptions = {
export const ollamaRecommendedModels = ['qwen2.5-coder:1.5b', 'llama3.1', 'qwq', 'deepseek-r1'] as const satisfies (keyof typeof ollamaModelOptions)[]
// ---------------- VLLM, OLLAMA, OPENAICOMPAT (self-hosted / local) ----------------
const vLLMSettings: VoidStaticProviderInfo = {
// 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' }, },
@@ -868,6 +906,12 @@ const vLLMSettings: VoidStaticProviderInfo = {
modelOptions: {}, // TODO
}
const lmStudioSettings: VoidStaticProviderInfo = {
providerReasoningIOSettings: { output: { needsManualParse: true }, },
modelOptionsFallback: (modelName) => extensiveModelFallback(modelName, { downloadable: { sizeGb: 'not-known' } }),
modelOptions: {}, // TODO
}
const ollamaSettings: VoidStaticProviderInfo = {
// reasoning: we need to filter out reasoning <think> tags manually
providerReasoningIOSettings: { output: { needsManualParse: true }, },
@@ -881,6 +925,12 @@ const openaiCompatible: VoidStaticProviderInfo = {
modelOptions: {},
}
const liteLLMSettings: VoidStaticProviderInfo = { // https://docs.litellm.ai/docs/reasoning_content
providerReasoningIOSettings: { output: { nameOfFieldInDelta: 'reasoning_content' } },
modelOptionsFallback: (modelName) => extensiveModelFallback(modelName, { downloadable: { sizeGb: 'not-known' } }),
modelOptions: {}, // TODO
}
// ---------------- OPENROUTER ----------------
const openRouterModelOptions_assumingOpenAICompat = {
@@ -1027,9 +1077,12 @@ const modelSettingsOfProvider: { [providerName in ProviderName]: VoidStaticProvi
ollama: ollamaSettings,
openAICompatible: openaiCompatible,
mistral: mistralSettings,
// googleVertex: {},
// microsoftAzure: {},
// openHands: {},
liteLLM: liteLLMSettings,
lmStudio: lmStudioSettings,
// googleVertex: googleVertexSettings,
microsoftAzure: microsoftAzureSettings,
} as const
@@ -7,7 +7,7 @@ import { EndOfLinePreference } from '../../../../../editor/common/model.js';
import { StagingSelectionItem } from '../chatThreadServiceTypes.js';
import { os } from '../helpers/systemInfo.js';
import { RawToolParamsObj } from '../sendLLMMessageTypes.js';
import { toolNamesThatRequireApproval } from '../toolsServiceTypes.js';
import { approvalTypeOfToolName, ToolResultType } from '../toolsServiceTypes.js';
import { IVoidModelService } from '../voidModelService.js';
import { ChatMode } from '../voidSettingsTypes.js';
@@ -20,6 +20,14 @@ export const MAX_DIRSTR_CHARS_TOTAL_TOOL = 20_000
export const MAX_DIRSTR_RESULTS_TOTAL_BEGINNING = 100
export const MAX_DIRSTR_RESULTS_TOTAL_TOOL = 100
// tool info
export const MAX_FILE_CHARS_PAGE = 500_000
export const MAX_CHILDREN_URIs_PAGE = 500
// terminal tool info
export const MAX_TERMINAL_CHARS = 100_000
export const MAX_TERMINAL_INACTIVE_TIME = 8 // seconds
// Maximum character limits for prefix and suffix context
export const MAX_PREFIX_SUFFIX_CHARS = 20_000
@@ -66,7 +74,36 @@ const paginationParam = {
} as const
// export type SnakeCase<S extends string> =
// // exact acronym URI
// S extends 'URI' ? 'uri'
// // suffix URI: e.g. 'rootURI' -> snakeCase('root') + '_uri'
// : S extends `${infer Prefix}URI` ? `${SnakeCase<Prefix>}_uri`
// // default: for each char, prefix '_' on uppercase letters
// : S extends `${infer C}${infer Rest}`
// ? `${C extends Lowercase<C> ? C : `_${Lowercase<C>}`}${SnakeCase<Rest>}`
// : S;
// export type SnakeCaseKeys<T extends Record<string, any>> = {
// [K in keyof T as SnakeCase<Extract<K, string>>]: T[K]
// };
export const voidTools = {
// export const voidTools
// : {
// [T in keyof ToolCallParams]: {
// name: string;
// description: string;
// params: {
// [paramName in keyof SnakeCaseKeys<ToolCallParams[T]>]: { description: string }
// }
// }
// }
// = {
// --- context-gathering (read/search/list) ---
read_file: {
@@ -74,8 +111,8 @@ export const voidTools = {
description: `Returns full contents of a given file.`,
params: {
...uriParam('file'),
start_line: { description: 'Optional. Only fill this in if you already know the line numbers you need to search. Defaults to 1.' },
end_line: { description: 'Optional. Only fill this in if you already know the line numbers you need to search. Defaults to Infinity.' },
start_line: { description: 'Optional. Do NOT fill this in unless you already know the line numbers you need to search. Defaults to 1.' },
end_line: { description: 'Optional. Do NOT fill this in unless you already know the line numbers you need to search. Defaults to Infinity.' },
...paginationParam,
},
},
@@ -89,8 +126,8 @@ export const voidTools = {
},
},
get_dir_structure: {
name: 'get_dir_structure',
get_dir_tree: {
name: 'get_dir_tree',
description: `This is a very effective way to learn about the user's codebase. Returns a tree diagram of all the files and folders in the given folder. `,
params: {
...uriParam('folder')
@@ -106,7 +143,7 @@ export const voidTools = {
description: `Returns all pathnames that match a given query (searches ONLY file names). You should use this when looking for a file with a specific name or path.`,
params: {
query: { description: `Your query for the search.` },
search_in_folder: { description: 'Optional. Only fill this in if you need to limit your search because there were too many results.' },
include_pattern: { description: 'Optional. Only fill this in if you need to limit your search because there were too many results.' },
...paginationParam,
},
},
@@ -118,12 +155,23 @@ export const voidTools = {
description: `Returns a list of file names whose content matches the given query. The query can be any substring or regex.`,
params: {
query: { description: `Your query for the search.` },
search_in_folder: { description: 'Optional. Only fill this in if you need to limit your search because there were too many results.' },
is_regex: { description: 'Optional. Default is false. Whether query is a regex.' },
search_in_folder: { description: 'Optional. Leave as blank by default. ONLY fill this in if your previous search with the same query was truncated. Searches descendants of this folder only.' },
is_regex: { description: 'Optional. Default is false. Whether the query is a regex.' },
...paginationParam,
},
},
// add new search_in_file tool
search_in_file: {
name: 'search_in_file',
description: `Returns an array of all the start line numbers where the content appears in the file.`,
params: {
...uriParam('file'),
query: { description: 'The string or regex to search for in the file.' },
is_regex: { description: 'Optional. Default is false. Whether the query is a regex.' }
}
},
read_lint_errors: {
name: 'read_lint_errors',
description: `Returns all lint errors on a given file.`,
@@ -156,33 +204,46 @@ export const voidTools = {
description: `Edits the contents of a file given the file's URI and a description.`,
params: {
...uriParam('file'),
change_description: {
change_diff: {
description: `\
Your description MUST be wrapped in triple backticks. \
A code description of the change you want to make, with comments like "// ... existing code ..." to condense your writing. \
NEVER re-write the whole file. Bias towards writing as little as possible. \
Here's an example of a good description:\n${editToolDescriptionExample}`
A code diff describing the change to make to the file. \
Your DIFF is the only context that will be given to another LLM to apply the change, so it must be accurate and complete. \
Your DIFF MUST be wrapped in triple backticks. \
NEVER re-write the whole file. Always bias towards writing as little as possible. \
Use comments like "// ... existing code ..." to condense your writing. \
Here's an example of a good output:\n${editToolDescriptionExample}`
}
},
},
run_terminal_command: {
name: 'run_terminal_command',
description: `Executes a terminal command.`,
run_command: {
name: 'run_command',
description: `Runs a terminal command and waits for the result (times out after ${MAX_TERMINAL_INACTIVE_TIME}s of inactivity). You can use this tool to run any command: sed, grep, etc. Do not edit any files with this tool; use edit_file instead. When working with git and other tools that open an editor (e.g. git diff), you should pipe to cat to get all results and not get stuck in vim.`,
params: {
command: { description: 'The terminal command to execute. If working with tools like git that can paginate, you should pipe to cat so results are not truncated.' },
wait_for_completion: { description: `Optional. Default is true. Make this value false when you want a command to run without waiting for it to complete.` },
terminal_id: { description: 'Optional. The ID of the terminal instance that should execute the command (if not provided, defaults to the preferred terminal ID). The primary purpose of this is to let you open a new terminal for testing or background processes (e.g. running a dev server for the user in a separate terminal). Must be an integer >= 1.' },
command: { description: 'The terminal command to run.' },
bg_terminal_id: { description: 'Optional. This only applies to terminals that have been opened with open_persistent_terminal. Runs the command in the terminal with the specified ID.' },
},
},
open_persistent_terminal: {
name: 'open_persistent_terminal',
description: `Use this tool when you want to run a terminal command indefinitely, like a dev server (eg \`npm run dev\`), a background listener, etc. Opens a new terminal in the user's environment which will not awaited for or killed.`,
params: {}
},
kill_persistent_terminal: {
name: 'kill_persistent_terminal',
description: `Closes a BG terminal with the given ID.`,
params: { terminal_id: { description: `The terminal ID to interrupt and close.` } }
}
// go_to_definition
// go_to_usages
} satisfies { [name: string]: InternalToolInfo }
} satisfies { [T in keyof ToolResultType]: InternalToolInfo }
export type ToolName = keyof typeof voidTools
export type ToolName = keyof ToolResultType
export const toolNames = Object.keys(voidTools) as ToolName[]
type ToolParamNameOfTool<T extends ToolName> = keyof (typeof voidTools)[T]['params']
@@ -197,7 +258,7 @@ export const isAToolName = (toolName: string): toolName is ToolName => {
export const availableTools = (chatMode: ChatMode) => {
const toolNames: ToolName[] | undefined = chatMode === 'normal' ? undefined
: chatMode === 'gather' ? (Object.keys(voidTools) as ToolName[]).filter(toolName => !toolNamesThatRequireApproval.has(toolName))
: chatMode === 'gather' ? (Object.keys(voidTools) as ToolName[]).filter(toolName => !(toolName in approvalTypeOfToolName))
: chatMode === 'agent' ? Object.keys(voidTools) as ToolName[]
: undefined
@@ -293,8 +354,8 @@ ${directoryStr}
if (mode === 'agent' || mode === 'gather') {
details.push(`Only call tools if they help you accomplish the user's goal. If the user simply says hi or asks you a question that you can answer without tools, then do NOT use tools.`)
details.push('Only use ONE tool call at a time, and always wait for the result before proceeding.') // XML
details.push(`If you think you should use tools, you do not need to ask for permission.`)
details.push('Only use ONE tool call at a time.')
details.push(`NEVER say something like "I'm going to use \`tool_name\`". Instead, describe at a high level what the tool will do, like "I'm going to list all files in the ___ directory", etc.`)
details.push(`Many tools only work if the user has a workspace open.`)
}
@@ -330,6 +391,7 @@ Here's an example of a good edit suggestion:
${fileNameEditExample}.`)
}
details.push(`NEVER write the FULL PATH of a file when speaking with the user. Just write the file name ONLY.`)
details.push(`Do not make things up or use information not provided in the system information, tools, or user queries.`)
details.push(`Today's date is ${new Date().toDateString()}.`)
@@ -446,9 +508,10 @@ export const DIVIDER = `=======`
export const FINAL = `>>>>>>> UPDATED`
export const searchReplace_systemMessage = `\
You are a coding assistant that generates SEARCH/REPLACE code blocks that will be used to edit a file.
You are a coding assistant that takes in a diff, and outputs SEARCH/REPLACE code blocks to implement the change(s) in the diff.
The diff will be labeled \`DIFF\` and the original file will be labeled \`ORIGINAL_FILE\`.
A SEARCH/REPLACE block describes the code before and after a change. Here is the format:
Format your SEARCH/REPLACE blocks as follows:
${tripleTick[0]}
${ORIGINAL}
// ... original code goes here
@@ -457,22 +520,28 @@ ${DIVIDER}
${FINAL}
${tripleTick[1]}
You will be given the original file \`ORIGINAL_FILE\` and a description of a change \`CHANGE\` to make.
Output SEARCH/REPLACE blocks to edit the file according to the desired change. You may output multiple SEARCH/REPLACE blocks.
1. Your SEARCH/REPLACE block(s) must implement the diff EXACTLY. Do NOT leave anything out.
Directions:
1. Your OUTPUT should consist ONLY of SEARCH/REPLACE blocks. Do NOT output any text or explanations before or after this.
2. The "ORIGINAL" code in each SEARCH/REPLACE block must EXACTLY match lines in the original file. The original code must NOT includes any new whitespace, comments, or any other modifications from the original code.
3. The "ORIGINAL" code in each SEARCH/REPLACE block must include enough text to uniquely identify the change in the file, but please bias towards writing as little as possible.
4. The "ORIGINAL" code in each SEARCH/REPLACE block must be disjoint from all other blocks.
2. You are allowed to output multiple SEARCH/REPLACE blocks to implement the change.
The SEARCH/REPLACE blocks you generate will be applied immediately, and so they **MUST** produce a file that the user can run IMMEDIATELY.
- Make sure you add all necessary imports.
- Make sure the "UPDATED" code is ready for production as-is, and fix any relevant lint errors.
3. Assume any comments in the diff are PART OF THE CHANGE. Include them in the output.
Follow coding conventions of the user (spaces, semilcolons, comments, etc). If the user spaces or formats things a certain way, CONTINUE formatting it that way, even if you prefer otherwise.
4. Your output should consist ONLY of SEARCH/REPLACE blocks. Do NOT output any text or explanations before or after this.
5. The ORIGINAL code in each SEARCH/REPLACE block must EXACTLY match lines in the original file. Do not add or remove any whitespace, comments, or modifications from the original code.
6. Each ORIGINAL text must be large enough to uniquely identify the change in the file. However; bias towards writing as little as possible.
7. Each ORIGINAL text must be DISJOINT from all other ORIGINAL text.
## EXAMPLE 1
DIFF
${tripleTick[0]}
// ... existing code
let x = 6.5
// ... existing code
${tripleTick[1]}
ORIGINAL_FILE
${tripleTick[0]}
let w = 5
@@ -481,15 +550,6 @@ let y = 7
let z = 8
${tripleTick[1]}
CHANGE
Make x equal to 6.5, not 6.
${tripleTick[0]}
// ... existing code
let x = 6.5
// ... existing code
${tripleTick[1]}
## ACCEPTED OUTPUT
${tripleTick[0]}
${ORIGINAL}
@@ -501,11 +561,14 @@ ${tripleTick[1]}
`
export const searchReplace_userMessage = ({ originalCode, applyStr }: { originalCode: string, applyStr: string }) => `\
ORIGINAL_FILE
${originalCode}
DIFF
${applyStr}
CHANGE
${applyStr}`
ORIGINAL_FILE
${tripleTick[0]}
${originalCode}
${tripleTick[1]}
`
@@ -8,7 +8,7 @@ import { ILLMMessageService } from './sendLLMMessageService.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js';
import { RefreshableProviderName, refreshableProviderNames, SettingsOfProvider } from './voidSettingsTypes.js';
import { OllamaModelResponse, VLLMModelResponse } from './sendLLMMessageTypes.js';
import { OllamaModelResponse, OpenaiCompatibleModelResponse } from './sendLLMMessageTypes.js';
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
@@ -46,6 +46,7 @@ export type RefreshModelStateOfProvider = Record<RefreshableProviderName, Refres
const refreshBasedOn: { [k in RefreshableProviderName]: (keyof SettingsOfProvider[k])[] } = {
ollama: ['_didFillInProviderSettings', 'endpoint'],
vLLM: ['_didFillInProviderSettings', 'endpoint'],
lmStudio: ['_didFillInProviderSettings', 'endpoint'],
// openAICompatible: ['_didFillInProviderSettings', 'endpoint', 'apiKey'],
}
const REFRESH_INTERVAL = 5_000
@@ -142,6 +143,7 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ
state: RefreshModelStateOfProvider = {
ollama: { state: 'init', timeoutId: null },
vLLM: { state: 'init', timeoutId: null },
lmStudio: { state: 'init', timeoutId: null },
}
@@ -160,18 +162,18 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ
}
}
const listFn = providerName === 'ollama' ? this.llmMessageService.ollamaList
: providerName === 'vLLM' ? this.llmMessageService.vLLMList
: () => { }
: this.llmMessageService.openAICompatibleList
listFn({
providerName,
onSuccess: ({ models }) => {
// set the models to the detected models
this.voidSettingsService.setAutodetectedModels(
providerName,
models.map(model => {
if (providerName === 'ollama') return (model as OllamaModelResponse).name;
else if (providerName === 'vLLM') return (model as VLLMModelResponse).id;
else if (providerName === 'vLLM') return (model as OpenaiCompatibleModelResponse).id;
else if (providerName === 'lmStudio') return (model as OpenaiCompatibleModelResponse).id;
else throw new Error('refreshMode fn: unknown provider', providerName);
}),
{ enableProviderOnSuccess: options.enableProviderOnSuccess, hideRefresh: options.doNotFire }
@@ -3,7 +3,7 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { EventLLMMessageOnTextParams, EventLLMMessageOnErrorParams, EventLLMMessageOnFinalMessageParams, ServiceSendLLMMessageParams, MainSendLLMMessageParams, MainLLMMessageAbortParams, ServiceModelListParams, EventModelListOnSuccessParams, EventModelListOnErrorParams, MainModelListParams, OllamaModelResponse, VLLMModelResponse, } from './sendLLMMessageTypes.js';
import { EventLLMMessageOnTextParams, EventLLMMessageOnErrorParams, EventLLMMessageOnFinalMessageParams, ServiceSendLLMMessageParams, MainSendLLMMessageParams, MainLLMMessageAbortParams, ServiceModelListParams, EventModelListOnSuccessParams, EventModelListOnErrorParams, MainModelListParams, OllamaModelResponse, OpenaiCompatibleModelResponse, } from './sendLLMMessageTypes.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js';
@@ -22,7 +22,7 @@ export interface ILLMMessageService {
sendLLMMessage: (params: ServiceSendLLMMessageParams) => string | null;
abort: (requestId: string) => void;
ollamaList: (params: ServiceModelListParams<OllamaModelResponse>) => void;
vLLMList: (params: ServiceModelListParams<VLLMModelResponse>) => void;
openAICompatibleList: (params: ServiceModelListParams<OpenaiCompatibleModelResponse>) => void;
}
@@ -46,12 +46,12 @@ export class LLMMessageService extends Disposable implements ILLMMessageService
success: {} as { [eventId: string]: ((params: EventModelListOnSuccessParams<OllamaModelResponse>) => void) },
error: {} as { [eventId: string]: ((params: EventModelListOnErrorParams<OllamaModelResponse>) => void) },
},
vLLM: {
success: {} as { [eventId: string]: ((params: EventModelListOnSuccessParams<VLLMModelResponse>) => void) },
error: {} as { [eventId: string]: ((params: EventModelListOnErrorParams<VLLMModelResponse>) => void) },
openAICompat: {
success: {} as { [eventId: string]: ((params: EventModelListOnSuccessParams<OpenaiCompatibleModelResponse>) => void) },
error: {} as { [eventId: string]: ((params: EventModelListOnErrorParams<OpenaiCompatibleModelResponse>) => void) },
}
} satisfies {
[providerName: string]: {
[providerName in 'ollama' | 'openAICompat']: {
success: { [eventId: string]: ((params: EventModelListOnSuccessParams<any>) => void) },
error: { [eventId: string]: ((params: EventModelListOnErrorParams<any>) => void) },
}
@@ -70,14 +70,31 @@ export class LLMMessageService extends Disposable implements ILLMMessageService
// .listen sets up an IPC channel and takes a few ms, so we set up listeners immediately and add hooks to them instead
// llm
this._register((this.channel.listen('onText_sendLLMMessage') satisfies Event<EventLLMMessageOnTextParams>)(e => { this.llmMessageHooks.onText[e.requestId]?.(e) }))
this._register((this.channel.listen('onFinalMessage_sendLLMMessage') satisfies Event<EventLLMMessageOnFinalMessageParams>)(e => { this.llmMessageHooks.onFinalMessage[e.requestId]?.(e); this._clearChannelHooks(e.requestId) }))
this._register((this.channel.listen('onError_sendLLMMessage') satisfies Event<EventLLMMessageOnErrorParams>)(e => { this.llmMessageHooks.onError[e.requestId]?.(e); this._clearChannelHooks(e.requestId); console.error('Error in LLMMessageService:', JSON.stringify(e)) }))
// ollama .list()
this._register((this.channel.listen('onSuccess_list_ollama') satisfies Event<EventModelListOnSuccessParams<OllamaModelResponse>>)(e => { this.listHooks.ollama.success[e.requestId]?.(e) }))
this._register((this.channel.listen('onError_list_ollama') satisfies Event<EventModelListOnErrorParams<OllamaModelResponse>>)(e => { this.listHooks.ollama.error[e.requestId]?.(e) }))
this._register((this.channel.listen('onSuccess_list_vLLM') satisfies Event<EventModelListOnSuccessParams<VLLMModelResponse>>)(e => { this.listHooks.vLLM.success[e.requestId]?.(e) }))
this._register((this.channel.listen('onError_list_vLLM') satisfies Event<EventModelListOnErrorParams<VLLMModelResponse>>)(e => { this.listHooks.vLLM.error[e.requestId]?.(e) }))
this._register((this.channel.listen('onText_sendLLMMessage') satisfies Event<EventLLMMessageOnTextParams>)(e => {
this.llmMessageHooks.onText[e.requestId]?.(e)
}))
this._register((this.channel.listen('onFinalMessage_sendLLMMessage') satisfies Event<EventLLMMessageOnFinalMessageParams>)(e => {
this.llmMessageHooks.onFinalMessage[e.requestId]?.(e);
this._clearChannelHooks(e.requestId)
}))
this._register((this.channel.listen('onError_sendLLMMessage') satisfies Event<EventLLMMessageOnErrorParams>)(e => {
this.llmMessageHooks.onError[e.requestId]?.(e);
this._clearChannelHooks(e.requestId);
console.error('Error in LLMMessageService:', JSON.stringify(e))
}))
// .list()
this._register((this.channel.listen('onSuccess_list_ollama') satisfies Event<EventModelListOnSuccessParams<OllamaModelResponse>>)(e => {
this.listHooks.ollama.success[e.requestId]?.(e)
}))
this._register((this.channel.listen('onError_list_ollama') satisfies Event<EventModelListOnErrorParams<OllamaModelResponse>>)(e => {
this.listHooks.ollama.error[e.requestId]?.(e)
}))
this._register((this.channel.listen('onSuccess_list_openAICompatible') satisfies Event<EventModelListOnSuccessParams<OpenaiCompatibleModelResponse>>)(e => {
this.listHooks.openAICompat.success[e.requestId]?.(e)
}))
this._register((this.channel.listen('onError_list_openAICompatible') satisfies Event<EventModelListOnErrorParams<OpenaiCompatibleModelResponse>>)(e => {
this.listHooks.openAICompat.error[e.requestId]?.(e)
}))
}
@@ -143,25 +160,24 @@ export class LLMMessageService extends Disposable implements ILLMMessageService
}
vLLMList = (params: ServiceModelListParams<VLLMModelResponse>) => {
openAICompatibleList = (params: ServiceModelListParams<OpenaiCompatibleModelResponse>) => {
const { onSuccess, onError, ...proxyParams } = params
const { settingsOfProvider } = this.voidSettingsService.state
// add state for request id
const requestId_ = generateUuid();
this.listHooks.vLLM.success[requestId_] = onSuccess
this.listHooks.vLLM.error[requestId_] = onError
this.listHooks.openAICompat.success[requestId_] = onSuccess
this.listHooks.openAICompat.error[requestId_] = onError
this.channel.call('vLLMList', {
this.channel.call('openAICompatibleList', {
...proxyParams,
settingsOfProvider,
providerName: 'vLLM',
requestId: requestId_,
} satisfies MainModelListParams<VLLMModelResponse>)
} satisfies MainModelListParams<OpenaiCompatibleModelResponse>)
}
_clearChannelHooks(requestId: string) {
private _clearChannelHooks(requestId: string) {
delete this.llmMessageHooks.onText[requestId]
delete this.llmMessageHooks.onFinalMessage[requestId]
delete this.llmMessageHooks.onError[requestId]
@@ -169,8 +185,8 @@ export class LLMMessageService extends Disposable implements ILLMMessageService
delete this.listHooks.ollama.success[requestId]
delete this.listHooks.ollama.error[requestId]
delete this.listHooks.vLLM.success[requestId]
delete this.listHooks.vLLM.error[requestId]
delete this.listHooks.openAICompat.success[requestId]
delete this.listHooks.openAICompat.error[requestId]
}
}
@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------*/
import { ToolName, ToolParamName } from './prompt/prompts.js'
import { ChatMode, ModelSelection, ModelSelectionOptions, ProviderName, SettingsOfProvider } from './voidSettingsTypes.js'
import { ChatMode, ModelSelection, ModelSelectionOptions, ProviderName, RefreshableProviderName, SettingsOfProvider } from './voidSettingsTypes.js'
export const errorDetails = (fullError: Error | null): string | null => {
@@ -162,15 +162,13 @@ export type OllamaModelResponse = {
size_vram: number;
}
type OpenaiCompatibleModelResponse = {
export type OpenaiCompatibleModelResponse = {
id: string;
created: number;
object: 'model';
owned_by: string;
}
export type VLLMModelResponse = OpenaiCompatibleModelResponse
// params to the true list fn
@@ -183,12 +181,13 @@ export type ModelListParams<ModelResponse> = {
// params to the service
export type ServiceModelListParams<modelResponse> = {
providerName: RefreshableProviderName;
onSuccess: (param: { models: modelResponse[] }) => void;
onError: (param: { error: any }) => void;
}
type BlockedMainModelListParams = 'onSuccess' | 'onError'
export type MainModelListParams<modelResponse> = Omit<ModelListParams<modelResponse>, BlockedMainModelListParams> & { requestId: string }
export type MainModelListParams<modelResponse> = Omit<ModelListParams<modelResponse>, BlockedMainModelListParams> & { providerName: RefreshableProviderName, requestId: string }
export type EventModelListOnSuccessParams<modelResponse> = Parameters<ModelListParams<modelResponse>['onSuccess']>[0] & { requestId: string }
export type EventModelListOnErrorParams<modelResponse> = Parameters<ModelListParams<modelResponse>['onError']>[0] & { requestId: string }
@@ -3,7 +3,7 @@ import { ToolName } from './prompt/prompts.js';
export type TerminalResolveReason = { type: 'toofull' | 'timeout' | 'bgtask' } | { type: 'done', exitCode: number }
export type TerminalResolveReason = { type: 'timeout' } | { type: 'done', exitCode: number }
export type LintErrorItem = { code: string, message: string, startLineNumber: number, endLineNumber: number }
@@ -16,39 +16,57 @@ export type ShallowDirectoryItem = {
}
export const approvalTypeOfToolName: Partial<{ [T in ToolName]?: 'edits' | 'terminal' }> = {
'create_file_or_folder': 'edits',
'delete_file_or_folder': 'edits',
'edit_file': 'edits',
'run_command': 'terminal',
}
const toolNamesWithApproval = ['create_file_or_folder', 'delete_file_or_folder', 'edit_file', 'run_terminal_command'] as const satisfies readonly ToolName[]
export type ToolNameWithApproval = typeof toolNamesWithApproval[number]
export const toolNamesThatRequireApproval = new Set<ToolName>(toolNamesWithApproval)
// {{add: define new type for approval types}}
export type ToolApprovalType = NonNullable<(typeof approvalTypeOfToolName)[keyof typeof approvalTypeOfToolName]>;
export const toolApprovalTypes = new Set<ToolApprovalType>(
Object.values(approvalTypeOfToolName).filter((v): v is ToolApprovalType => v !== undefined)
)
// PARAMS OF TOOL CALL
export type ToolCallParams = {
'read_file': { uri: URI, startLine: number | null, endLine: number | null, pageNumber: number },
'ls_dir': { rootURI: URI, pageNumber: number },
'get_dir_structure': { rootURI: URI },
'search_pathnames_only': { queryStr: string, searchInFolder: string | null, pageNumber: number },
'search_for_files': { queryStr: string, isRegex: boolean, searchInFolder: URI | null, pageNumber: number },
'ls_dir': { uri: URI, pageNumber: number },
'get_dir_tree': { uri: URI },
'search_pathnames_only': { query: string, includePattern: string | null, pageNumber: number },
'search_for_files': { query: string, isRegex: boolean, searchInFolder: URI | null, pageNumber: number },
'search_in_file': { uri: URI, query: string, isRegex: boolean },
'read_lint_errors': { uri: URI },
// ---
'edit_file': { uri: URI, changeDescription: string },
'edit_file': { uri: URI, changeDiff: string },
'create_file_or_folder': { uri: URI, isFolder: boolean },
'delete_file_or_folder': { uri: URI, isRecursive: boolean, isFolder: boolean },
'run_terminal_command': { command: string, proposedTerminalId: string, waitForCompletion: boolean },
// ---
'run_command': { command: string; bgTerminalId: string | null },
'open_persistent_terminal': {},
'kill_persistent_terminal': { terminalId: string },
}
// RESULT OF TOOL CALL
export type ToolResultType = {
'read_file': { fileContents: string, totalFileLen: number, hasNextPage: boolean },
'ls_dir': { children: ShallowDirectoryItem[] | null, hasNextPage: boolean, hasPrevPage: boolean, itemsRemaining: number },
'get_dir_structure': { str: string, },
'get_dir_tree': { str: string, },
'search_pathnames_only': { uris: URI[], hasNextPage: boolean },
'search_for_files': { uris: URI[], hasNextPage: boolean },
'search_in_file': { lines: number[]; },
'read_lint_errors': { lintErrors: LintErrorItem[] | null },
// ---
'edit_file': Promise<{ lintErrors: LintErrorItem[] | null }>,
'create_file_or_folder': {},
'delete_file_or_folder': {},
'run_terminal_command': { terminalId: string, didCreateTerminal: boolean, result: string; resolveReason: TerminalResolveReason; },
// ---
'run_command': { result: string; resolveReason: TerminalResolveReason; },
'open_persistent_terminal': { terminalId: string },
'kill_persistent_terminal': {},
}
@@ -42,10 +42,15 @@ class VoidModelService extends Disposable implements IVoidModelService {
}
initializeModel = async (uri: URI) => {
if (uri.fsPath in this._modelRefOfURI) return;
const editorModelRef = await this._textModelService.createModelReference(uri);
// Keep a strong reference to prevent disposal
this._modelRefOfURI[uri.fsPath] = editorModelRef;
try {
if (uri.fsPath in this._modelRefOfURI) return;
const editorModelRef = await this._textModelService.createModelReference(uri);
// Keep a strong reference to prevent disposal
this._modelRefOfURI[uri.fsPath] = editorModelRef;
}
catch (e) {
console.log('InitializeModel error:', e)
}
};
getModelFromFsPath = (fsPath: string): VoidModelType => {
@@ -62,6 +62,9 @@ export interface IVoidSettingsService {
setOptionsOfModelSelection: SetOptionsOfModelSelection;
setGlobalSetting: SetGlobalSettingFn;
dangerousSetState(newState: VoidSettingsState): Promise<void>;
resetState(): Promise<void>;
setAutodetectedModels(providerName: ProviderName, modelNames: string[], logging: object): void;
toggleModelHidden(providerName: ProviderName, modelName: string): void;
addModel(providerName: ProviderName, modelName: string): void;
@@ -71,24 +74,22 @@ export interface IVoidSettingsService {
const _updatedModelsAfterDefaultModelsChange = (defaultModelNames: string[], options: { existingModels: VoidStatefulModelInfo[], didAutoDetect: boolean }) => {
const { existingModels, didAutoDetect } = options
const _modelsWithSwappedInNewModels = (options: { existingModels: VoidStatefulModelInfo[], models: string[], type: 'autodetected' | 'default' }) => {
const { existingModels, models, type } = options
const existingModelsMap: Record<string, VoidStatefulModelInfo> = {}
for (const existingModel of existingModels) {
existingModelsMap[existingModel.modelName] = existingModel
}
const newDefaultModels = defaultModelNames.map((modelName, i) => ({
modelName,
isDefault: true,
isAutodetected: didAutoDetect,
isHidden: !!existingModelsMap[modelName]?.isHidden,
}))
const newDefaultModels = models.map((modelName, i) => ({ modelName, type, isHidden: !!existingModelsMap[modelName]?.isHidden, }))
return [
...newDefaultModels, // swap out all the default models for the new default models
...existingModels.filter(m => !m.isDefault), // keep any non-default (custom) models
...newDefaultModels, // swap out all the models of this type for the new models of this type
...existingModels.filter(m => {
const keep = m.type !== type
return keep
})
]
}
@@ -101,7 +102,7 @@ export const modelFilterOfFeatureName: { [featureName in FeatureName]: { filter:
}
const _stateWithUpdatedDefaultModels = (state: VoidSettingsState): VoidSettingsState => {
const _stateWithMergedDefaultModels = (state: VoidSettingsState): VoidSettingsState => {
let newSettingsOfProvider = state.settingsOfProvider
// recompute default models
@@ -109,7 +110,7 @@ const _stateWithUpdatedDefaultModels = (state: VoidSettingsState): VoidSettingsS
const defaultModels = defaultSettingsOfProvider[providerName]?.models ?? []
const currentModels = newSettingsOfProvider[providerName]?.models ?? []
const defaultModelNames = defaultModels.map(m => m.modelName)
const newModels = _updatedModelsAfterDefaultModelsChange(defaultModelNames, { existingModels: currentModels, didAutoDetect: false })
const newModels = _modelsWithSwappedInNewModels({ existingModels: currentModels, models: defaultModelNames, type: 'default' })
newSettingsOfProvider = {
...newSettingsOfProvider,
[providerName]: {
@@ -233,36 +234,76 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
this.readAndInitializeState()
}
dangerousSetState = async (newState: VoidSettingsState) => {
this.state = _validatedModelState(newState)
await this._storeState()
this._onDidChangeState.fire()
this._onUpdate_syncApplyToChat()
}
async resetState() {
await this.dangerousSetState(defaultState())
}
async readAndInitializeState() {
let readS: VoidSettingsState
try {
readS = await this._readState();
// 1.0.3 addition, remove when enough users have had this code run
if (readS.globalSettings.includeToolLintErrors === undefined) readS.globalSettings.includeToolLintErrors = true
// autoapprove is now an obj not a boolean (1.2.5)
if (typeof readS.globalSettings.autoApprove === 'boolean') readS.globalSettings.autoApprove = {}
}
catch (e) {
readS = defaultState()
}
// the stored data structure might be outdated, so we need to update it here
readS = {
...readS,
settingsOfProvider: {
try {
readS = {
...readS,
...defaultSettingsOfProvider,
...readS.settingsOfProvider,
mistral: { // we added mistral
...defaultSettingsOfProvider.mistral,
...readS.settingsOfProvider.mistral,
},
} // we added mistral
}
for (const providerName of providerNames) {
readS.settingsOfProvider[providerName] = {
...defaultSettingsOfProvider[providerName],
...readS.settingsOfProvider[providerName],
} as any
// conversion from 1.0.3 to 1.2.5 (can remove this when enough people update)
for (const m of readS.settingsOfProvider[providerName].models) {
if (!m.type) {
const old = (m as { isAutodetected?: boolean; isDefault?: boolean })
if (old.isAutodetected)
m.type = 'autodetected'
else if (old.isDefault)
m.type = 'default'
else m.type = 'custom'
}
}
}
}
catch (e) {
readS = defaultState()
}
this.state = readS
this.state = _stateWithUpdatedDefaultModels(this.state)
this.state = _stateWithMergedDefaultModels(this.state)
this.state = _validatedModelState(this.state);
this._resolver();
this._onDidChangeState.fire();
}
@@ -389,7 +430,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
const { models } = this.state.settingsOfProvider[providerName]
const oldModelNames = models.map(m => m.modelName)
const newModels = _updatedModelsAfterDefaultModelsChange(autodetectedModelNames, { existingModels: models, didAutoDetect: true })
const newModels = _modelsWithSwappedInNewModels({ existingModels: models, models: autodetectedModelNames, type: 'autodetected' })
this.setSettingOfProvider(providerName, 'models', newModels)
// if the models changed, log it
@@ -423,7 +464,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
if (existingIdx !== -1) return // if exists, do nothing
const newModels = [
...models,
{ modelName, isDefault: false, isHidden: false }
{ modelName, type: 'custom', isHidden: false } as const
]
this.setSettingOfProvider(providerName, 'models', newModels)
@@ -5,6 +5,7 @@
*--------------------------------------------------------------------------------------*/
import { defaultModelsOfProvider, defaultProviderSettings } from './modelCapabilities.js';
import { ToolApprovalType } from './toolsServiceTypes.js';
import { VoidSettingsState } from './voidSettingsService.js'
@@ -15,7 +16,7 @@ type UnionOfKeys<T> = T extends T ? keyof T : never;
export type ProviderName = keyof typeof defaultProviderSettings
export const providerNames = Object.keys(defaultProviderSettings) as ProviderName[]
export const localProviderNames = ['ollama', 'vLLM'] satisfies ProviderName[] // all local names
export const localProviderNames = ['ollama', 'vLLM', 'lmStudio'] satisfies ProviderName[] // all local names
export const nonlocalProviderNames = providerNames.filter((name) => !(localProviderNames as string[]).includes(name)) // all non-local names
type CustomSettingName = UnionOfKeys<typeof defaultProviderSettings[ProviderName]>
@@ -30,9 +31,8 @@ export const customSettingNamesOfProvider = (providerName: ProviderName) => {
export type VoidStatefulModelInfo = { // <-- STATEFUL
modelName: string,
isDefault: boolean, // whether or not it's a default for its provider
type: 'default' | 'autodetected' | 'custom';
isHidden: boolean, // whether or not the user is hiding it (switched off)
isAutodetected?: boolean, // whether the model was autodetected by polling
} // TODO!!! eventually we'd want to let the user change supportsFIM, etc on the model themselves
@@ -59,74 +59,78 @@ type DisplayInfoForProviderName = {
export const displayInfoOfProviderName = (providerName: ProviderName): DisplayInfoForProviderName => {
if (providerName === 'anthropic') {
return {
title: 'Anthropic',
}
return { title: 'Anthropic', }
}
else if (providerName === 'openAI') {
return {
title: 'OpenAI',
}
return { title: 'OpenAI', }
}
else if (providerName === 'deepseek') {
return {
// title: 'DeepSeek.com API',
title: 'DeepSeek',
}
return { title: 'DeepSeek', }
}
else if (providerName === 'openRouter') {
return {
title: 'OpenRouter',
}
return { title: 'OpenRouter', }
}
else if (providerName === 'ollama') {
return {
title: 'Ollama',
}
return { title: 'Ollama', }
}
else if (providerName === 'vLLM') {
return {
title: 'vLLM',
}
return { title: 'vLLM', }
}
else if (providerName === 'liteLLM') {
return { title: 'LiteLLM', }
}
else if (providerName === 'lmStudio') {
return { title: 'LM Studio', }
}
else if (providerName === 'openAICompatible') {
return {
title: 'OpenAI-Compatible',
}
return { title: 'OpenAI-Compatible', }
}
else if (providerName === 'gemini') {
return {
// title: 'Gemini API',
title: 'Gemini',
}
return { title: 'Gemini', }
}
else if (providerName === 'groq') {
return {
// title: 'Groq.com API',
title: 'Groq',
}
return { title: 'Groq', }
}
else if (providerName === 'xAI') {
return {
// title: 'Grok (xAI)',
title: 'xAI',
}
return { title: 'xAI', }
}
else if (providerName === 'mistral') {
return {
// title: 'Mistral API',
title: 'Mistral',
}
return { title: 'Mistral', }
}
// else if (providerName === 'googleVertex') {
// return { title: 'Google Vertex AI', }
// }
else if (providerName === 'microsoftAzure') {
return { title: 'Microsoft Azure OpenAI', }
}
throw new Error(`descOfProviderName: Unknown provider name: "${providerName}"`)
}
export const subTextMdOfProviderName = (providerName: ProviderName): string => {
if (providerName === 'anthropic') return 'Get your [API Key here](https://console.anthropic.com/settings/keys).'
if (providerName === 'openAI') return 'Get your [API Key here](https://platform.openai.com/api-keys).'
if (providerName === 'deepseek') return 'Get your [API Key here](https://platform.deepseek.com/api_keys).'
if (providerName === 'openRouter') return 'Get your [API Key here](https://openrouter.ai/settings/keys).'
if (providerName === 'gemini') return 'Get your [API Key here](https://aistudio.google.com/apikey).'
if (providerName === 'groq') return 'Get your [API Key here](https://console.groq.com/keys).'
if (providerName === 'xAI') return 'Get your [API Key here](https://console.x.ai).'
if (providerName === 'mistral') return 'Get your [API Key here](https://console.mistral.ai/api-keys).'
if (providerName === 'openAICompatible') return `Use any OpenAI-compatible endpoint (LM Studio, LiteLM, etc).`
// if (providerName === 'googleVertex') return 'You must authenticate before using Vertex with Void. Read more about endpoints [here](https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/call-vertex-using-openai-library), and regions [here](https://cloud.google.com/vertex-ai/docs/general/locations#available-regions).'
if (providerName === 'microsoftAzure') return 'Read more about endpoints [here](https://learn.microsoft.com/en-us/rest/api/aifoundry/model-inference/get-chat-completions/get-chat-completions?view=rest-aifoundry-model-inference-2024-05-01-preview&tabs=HTTP), and get your API key [here](https://learn.microsoft.com/en-us/azure/search/search-security-api-keys?tabs=rest-use%2Cportal-find%2Cportal-query#find-existing-keys).'
if (providerName === 'ollama') return 'If you would like to change this endpoint, please read more about [Endpoints here](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-can-i-expose-ollama-on-my-network).'
if (providerName === 'vLLM') return 'If you would like to change this endpoint, please read more about [Endpoints here](https://docs.vllm.ai/en/latest/getting_started/quickstart.html#openai-compatible-server).'
if (providerName === 'lmStudio') return 'If you would like to change this endpoint, please more about [Endpoints here](https://lmstudio.ai/docs/app/api/endpoints/openai).'
if (providerName === 'liteLLM') return 'Read more about endpoints [here](https://docs.litellm.ai/docs/providers/openai_compatible).'
throw new Error(`subTextMdOfProviderName: Unknown provider name: "${providerName}"`)
}
type DisplayInfo = {
title: string;
placeholder: string;
subTextMd?: string;
isPasswordField?: boolean;
}
export const displayInfoOfSettingName = (providerName: ProviderName, settingName: SettingName): DisplayInfo => {
@@ -140,23 +144,15 @@ export const displayInfoOfSettingName = (providerName: ProviderName, settingName
providerName === 'openAI' ? 'sk-proj-key...' :
providerName === 'deepseek' ? 'sk-key...' :
providerName === 'openRouter' ? 'sk-or-key...' : // sk-or-v1-key
providerName === 'gemini' ? 'key...' :
providerName === 'gemini' ? 'AIzaSy...' :
providerName === 'groq' ? 'gsk_key...' :
providerName === 'openAICompatible' ? 'sk-key...' :
providerName === 'xAI' ? 'xai-key...' :
providerName === 'mistral' ? 'api-key...' :
'',
// providerName === 'googleVertex' ? 'AIzaSy...' :
providerName === 'microsoftAzure' ? 'key-...' :
'',
subTextMd: providerName === 'anthropic' ? 'Get your [API Key here](https://console.anthropic.com/settings/keys).' :
providerName === 'openAI' ? 'Get your [API Key here](https://platform.openai.com/api-keys).' :
providerName === 'deepseek' ? 'Get your [API Key here](https://platform.deepseek.com/api_keys).' :
providerName === 'openRouter' ? 'Get your [API Key here](https://openrouter.ai/settings/keys).' :
providerName === 'gemini' ? 'Get your [API Key here](https://aistudio.google.com/apikey).' :
providerName === 'groq' ? 'Get your [API Key here](https://console.groq.com/keys).' :
providerName === 'xAI' ? 'Get your [API Key here](https://console.x.ai).' :
providerName === 'mistral' ? 'Get your [API Key here](https://console.mistral.ai/api-keys).' :
providerName === 'openAICompatible' ? `Use any OpenAI-compatible endpoint (LM Studio, LiteLM, etc).` :
'',
isPasswordField: true,
}
}
@@ -164,19 +160,51 @@ export const displayInfoOfSettingName = (providerName: ProviderName, settingName
return {
title: providerName === 'ollama' ? 'Endpoint' :
providerName === 'vLLM' ? 'Endpoint' :
providerName === 'openAICompatible' ? 'baseURL' : // (do not include /chat/completions)
'(never)',
providerName === 'lmStudio' ? 'Endpoint' :
providerName === 'openAICompatible' ? 'baseURL' : // (do not include /chat/completions)
// providerName === 'googleVertex' ? 'baseURL' :
providerName === 'microsoftAzure' ? 'baseURL' :
providerName === 'liteLLM' ? 'baseURL' :
'(never)',
placeholder: providerName === 'ollama' ? defaultProviderSettings.ollama.endpoint
: providerName === 'vLLM' ? defaultProviderSettings.vLLM.endpoint
: providerName === 'openAICompatible' ? 'https://my-website.com/v1'
: '(never)',
: providerName === 'lmStudio' ? defaultProviderSettings.lmStudio.endpoint
: providerName === 'liteLLM' ? 'http://localhost:4000'
: '(never)',
subTextMd: providerName === 'ollama' ? 'If you would like to change this endpoint, please read more about [Endpoints here](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-can-i-expose-ollama-on-my-network).' :
providerName === 'vLLM' ? 'If you would like to change this endpoint, please read more about [Endpoints here](https://docs.vllm.ai/en/latest/getting_started/quickstart.html#openai-compatible-server).' :
undefined,
}
}
// else if (settingName === 'region') {
// // vertex only
// return {
// title: 'Region',
// placeholder: providerName === 'googleVertex' ? defaultProviderSettings.googleVertex.region
// : ''
// }
// }
else if (settingName === 'azureApiVersion') {
// azure only
return {
title: 'API Version',
placeholder: providerName === 'microsoftAzure' ? defaultProviderSettings.microsoftAzure.azureApiVersion
: ''
}
}
else if (settingName === 'project') {
return {
title: providerName === 'microsoftAzure' ? 'Resource'
// : providerName === 'googleVertex' ? 'Project'
: '',
placeholder: providerName === 'microsoftAzure' ? 'my-resource'
// : providerName === 'googleVertex' ? 'my-project'
: ''
}
}
else if (settingName === '_didFillInProviderSettings') {
return {
title: '(never)',
@@ -200,6 +228,9 @@ export const displayInfoOfSettingName = (providerName: ProviderName, settingName
const defaultCustomSettings: Record<CustomSettingName, undefined> = {
apiKey: undefined,
endpoint: undefined,
// region: undefined, // googleVertex
project: undefined,
azureApiVersion: undefined,
}
@@ -207,8 +238,7 @@ const modelInfoOfDefaultModelNames = (defaultModelNames: string[]): { models: Vo
return {
models: defaultModelNames.map((modelName, i) => ({
modelName,
isDefault: true,
isAutodetected: false,
type: 'default',
isHidden: defaultModelNames.length >= 10, // hide all models if there are a ton of them, and make user enable them individually
}))
}
@@ -252,6 +282,18 @@ export const defaultSettingsOfProvider: SettingsOfProvider = {
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.mistral),
_didFillInProviderSettings: undefined,
},
liteLLM: {
...defaultCustomSettings,
...defaultProviderSettings.liteLLM,
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.liteLLM),
_didFillInProviderSettings: undefined,
},
lmStudio: {
...defaultCustomSettings,
...defaultProviderSettings.lmStudio,
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.lmStudio),
_didFillInProviderSettings: undefined,
},
groq: { // aggregator (serves models from multiple providers)
...defaultCustomSettings,
...defaultProviderSettings.groq,
@@ -282,6 +324,18 @@ export const defaultSettingsOfProvider: SettingsOfProvider = {
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.vLLM),
_didFillInProviderSettings: undefined,
},
// googleVertex: { // aggregator (serves models from multiple providers)
// ...defaultCustomSettings,
// ...defaultProviderSettings.googleVertex,
// ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.googleVertex),
// _didFillInProviderSettings: undefined,
// },
microsoftAzure: { // aggregator (serves models from multiple providers)
...defaultCustomSettings,
...defaultProviderSettings.microsoftAzure,
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.microsoftAzure),
_didFillInProviderSettings: undefined,
},
}
@@ -372,7 +426,7 @@ export type GlobalSettings = {
syncApplyToChat: boolean;
enableFastApply: boolean;
chatMode: ChatMode;
autoApprove: boolean;
autoApprove: { [approvalType in ToolApprovalType]?: boolean };
showInlineSuggestions: boolean;
includeToolLintErrors: boolean;
isOnboardingComplete: boolean;
@@ -385,7 +439,7 @@ export const defaultGlobalSettings: GlobalSettings = {
syncApplyToChat: true,
enableFastApply: true,
chatMode: 'agent',
autoApprove: false,
autoApprove: {},
showInlineSuggestions: true,
includeToolLintErrors: true,
isOnboardingComplete: false,
@@ -10,6 +10,7 @@ import { Ollama } from 'ollama';
import OpenAI, { ClientOptions } from 'openai';
import { MistralCore } from '@mistralai/mistralai/core.js';
import { fimComplete } from '@mistralai/mistralai/funcs/fimComplete.js';
// import { GoogleAuth } from 'google-auth-library'
/* eslint-enable */
import { AnthropicLLMChatMessage, LLMChatMessage, LLMFIMMessage, ModelListParams, OllamaModelResponse, OnError, OnFinalMessage, OnText, RawToolCallObj, RawToolParamsObj } from '../../common/sendLLMMessageTypes.js';
@@ -19,6 +20,8 @@ import { extractReasoningWrapper, extractXMLToolsWrapper } from './extractGramma
import { availableTools, InternalToolInfo, isAToolName, ToolParamName, voidTools } from '../../common/prompt/prompts.js';
type InternalCommonMessageParams = {
onText: OnText;
onFinalMessage: OnFinalMessage;
@@ -39,7 +42,16 @@ const invalidApiKeyMessage = (providerName: ProviderName) => `Invalid ${displayI
// ------------ OPENAI-COMPATIBLE (HELPERS) ------------
const newOpenAICompatibleSDK = ({ settingsOfProvider, providerName, includeInPayload }: { settingsOfProvider: SettingsOfProvider, providerName: ProviderName, includeInPayload?: { [s: string]: any } }) => {
// const getGoogleApiKey = async () => {
// // modulelevel singleton
// const auth = new GoogleAuth({ scopes: `https://www.googleapis.com/auth/cloud-platform` });
// const key = await auth.getAccessToken()
// if (!key) throw new Error(`Google API failed to generate a key.`)
// return key
// }
const newOpenAICompatibleSDK = async ({ settingsOfProvider, providerName, includeInPayload }: { settingsOfProvider: SettingsOfProvider, providerName: ProviderName, includeInPayload?: { [s: string]: any } }) => {
const commonPayloadOpts: ClientOptions = {
dangerouslyAllowBrowser: true,
...includeInPayload,
@@ -56,6 +68,14 @@ const newOpenAICompatibleSDK = ({ settingsOfProvider, providerName, includeInPay
const thisConfig = settingsOfProvider[providerName]
return new OpenAI({ baseURL: `${thisConfig.endpoint}/v1`, apiKey: 'noop', ...commonPayloadOpts })
}
else if (providerName === 'liteLLM') {
const thisConfig = settingsOfProvider[providerName]
return new OpenAI({ baseURL: `${thisConfig.endpoint}/v1`, apiKey: 'noop', ...commonPayloadOpts })
}
else if (providerName === 'lmStudio') {
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({
@@ -70,8 +90,21 @@ const newOpenAICompatibleSDK = ({ settingsOfProvider, providerName, includeInPay
}
else if (providerName === 'gemini') {
const thisConfig = settingsOfProvider[providerName]
return new OpenAI({ baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai/', apiKey: thisConfig.apiKey, ...commonPayloadOpts })
return new OpenAI({ baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai', apiKey: thisConfig.apiKey, ...commonPayloadOpts })
}
// else if (providerName === 'googleVertex') {
// // https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/call-vertex-using-openai-library
// const thisConfig = settingsOfProvider[providerName]
// const baseURL = `https://${thisConfig.region}-aiplatform.googleapis.com/v1/projects/${thisConfig.project}/locations/${thisConfig.region}/endpoints/${'openapi'}`
// return new OpenAI({ baseURL: baseURL, apiKey: apiKey, ...commonPayloadOpts })
// }
else if (providerName === 'microsoftAzure') {
// https://learn.microsoft.com/en-us/rest/api/aifoundry/model-inference/get-chat-completions/get-chat-completions?view=rest-aifoundry-model-inference-2024-05-01-preview&tabs=HTTP
const thisConfig = settingsOfProvider[providerName]
const baseURL = `https://${thisConfig.project}.services.ai.azure.com/api/models/chat/completions??api-version=${thisConfig.azureApiVersion}`
return new OpenAI({ baseURL: baseURL, 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 })
@@ -97,7 +130,7 @@ const newOpenAICompatibleSDK = ({ settingsOfProvider, providerName, includeInPay
}
const _sendOpenAICompatibleFIM = ({ messages: { prefix, suffix, stopTokens }, onFinalMessage, onError, settingsOfProvider, modelName: modelName_, _setAborter, providerName, }: SendFIMParams_Internal) => {
const _sendOpenAICompatibleFIM = async ({ messages: { prefix, suffix, stopTokens }, onFinalMessage, onError, settingsOfProvider, modelName: modelName_, _setAborter, providerName, }: SendFIMParams_Internal) => {
const { modelName, supportsFIM } = getModelCapabilities(providerName, modelName_)
if (!supportsFIM) {
if (modelName === modelName_)
@@ -107,7 +140,7 @@ const _sendOpenAICompatibleFIM = ({ messages: { prefix, suffix, stopTokens }, on
return
}
const openai = newOpenAICompatibleSDK({ providerName, settingsOfProvider })
const openai = await newOpenAICompatibleSDK({ providerName, settingsOfProvider })
openai.completions
.create({
model: modelName,
@@ -178,7 +211,7 @@ const openAIToolToRawToolCallObj = (name: string, toolParamsStr: string, id: str
// ------------ OPENAI-COMPATIBLE ------------
const _sendOpenAICompatibleChat = ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName: modelName_, _setAborter, providerName, chatMode, separateSystemMessage }: SendChatParams_Internal) => {
const _sendOpenAICompatibleChat = async ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName: modelName_, _setAborter, providerName, chatMode, separateSystemMessage }: SendChatParams_Internal) => {
const {
modelName,
specialToolFormat,
@@ -199,7 +232,7 @@ const _sendOpenAICompatibleChat = ({ messages, onText, onFinalMessage, onError,
: {}
// instance
const openai: OpenAI = newOpenAICompatibleSDK({ providerName, settingsOfProvider, includeInPayload })
const openai: OpenAI = await newOpenAICompatibleSDK({ providerName, settingsOfProvider, includeInPayload })
const options: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model: modelName,
messages: messages as any,
@@ -300,7 +333,7 @@ const _openaiCompatibleList = async ({ onSuccess: onSuccess_, onError: onError_,
onError_({ error })
}
try {
const openai = newOpenAICompatibleSDK({ providerName, settingsOfProvider })
const openai = await newOpenAICompatibleSDK({ providerName, settingsOfProvider })
openai.models.list()
.then(async (response) => {
const models: OpenAIModel[] = []
@@ -360,7 +393,7 @@ const anthropicToolToRawToolCallObj = (toolBlock: Anthropic.Messages.ToolUseBloc
}
// ------------ ANTHROPIC ------------
const sendAnthropicChat = ({ messages, providerName, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName: modelName_, _setAborter, separateSystemMessage, chatMode }: SendChatParams_Internal) => {
const sendAnthropicChat = async ({ messages, providerName, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName: modelName_, _setAborter, separateSystemMessage, chatMode }: SendChatParams_Internal) => {
const {
modelName,
specialToolFormat,
@@ -505,6 +538,7 @@ const sendMistralFIM = ({ messages, onFinalMessage, onError, settingsOfProvider,
stop: messages.stopTokens,
})
.then(async response => {
// unfortunately, _setAborter() does not exist
let content = response?.ok ? response.value.choices?.[0]?.message?.content ?? '' : '';
const fullText = typeof content === 'string' ? content
: content.map(chunk => (chunk.type === 'text' ? chunk.text : '')).join('')
@@ -584,7 +618,7 @@ const sendOllamaFIM = ({ messages, onFinalMessage, onError, settingsOfProvider,
type CallFnOfProvider = {
[providerName in ProviderName]: {
sendChat: (params: SendChatParams_Internal) => void;
sendChat: (params: SendChatParams_Internal) => Promise<void>;
sendFIM: ((params: SendFIMParams_Internal) => void) | null;
list: ((params: ListParams_Internal<any>) => void) | null;
}
@@ -646,6 +680,27 @@ export const sendLLMMessageToProviderImplementation = {
sendFIM: null,
list: null,
},
lmStudio: {
sendChat: (params) => _sendOpenAICompatibleChat(params),
sendFIM: null, // lmStudio has no suffix parameter in /completions
list: (params) => _openaiCompatibleList(params),
},
liteLLM: {
sendChat: (params) => _sendOpenAICompatibleChat(params),
sendFIM: null,
list: null,
},
// googleVertex: {
// sendChat: (params) => _sendOpenAICompatibleChat(params),
// sendFIM: null,
// list: null,
// },
microsoftAzure: {
sendChat: (params) => _sendOpenAICompatibleChat(params),
sendFIM: null,
list: null,
},
} satisfies CallFnOfProvider
@@ -9,7 +9,7 @@ import { displayInfoOfProviderName } from '../../common/voidSettingsTypes.js';
import { sendLLMMessageToProviderImplementation } from './sendLLMMessage.impl.js';
export const sendLLMMessage = ({
export const sendLLMMessage = async ({
messagesType,
messages: messages_,
onText: onText_,
@@ -108,12 +108,12 @@ export const sendLLMMessage = ({
}
const { sendFIM, sendChat } = implementation
if (messagesType === 'chatMessages') {
sendChat({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName, _setAborter, providerName, separateSystemMessage, chatMode })
await sendChat({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName, _setAborter, providerName, separateSystemMessage, chatMode })
return
}
if (messagesType === 'FIMMessage') {
if (sendFIM) {
sendFIM({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName, _setAborter, providerName, separateSystemMessage })
await sendFIM({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName, _setAborter, providerName, separateSystemMessage })
return
}
onError({ message: `Error: This provider does not support Autocomplete yet.`, fullError: null })
@@ -8,7 +8,7 @@
import { IServerChannel } from '../../../../base/parts/ipc/common/ipc.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { EventLLMMessageOnTextParams, EventLLMMessageOnErrorParams, EventLLMMessageOnFinalMessageParams, MainSendLLMMessageParams, AbortRef, SendLLMMessageParams, MainLLMMessageAbortParams, ModelListParams, EventModelListOnSuccessParams, EventModelListOnErrorParams, OllamaModelResponse, VLLMModelResponse, MainModelListParams, } from '../common/sendLLMMessageTypes.js';
import { EventLLMMessageOnTextParams, EventLLMMessageOnErrorParams, EventLLMMessageOnFinalMessageParams, MainSendLLMMessageParams, AbortRef, SendLLMMessageParams, MainLLMMessageAbortParams, ModelListParams, EventModelListOnSuccessParams, EventModelListOnErrorParams, OllamaModelResponse, OpenaiCompatibleModelResponse, MainModelListParams, } from '../common/sendLLMMessageTypes.js';
import { sendLLMMessage } from './llmMessage/sendLLMMessage.js'
import { IMetricsService } from '../common/metricsService.js';
import { sendLLMMessageToProviderImplementation } from './llmMessage/sendLLMMessage.impl.js';
@@ -25,7 +25,7 @@ export class LLMMessageChannel implements IServerChannel {
}
// aborters for above
private readonly abortRefOfRequestId: Record<string, AbortRef> = {}
private readonly _infoOfRunningRequest: Record<string, { waitForSend: Promise<void> | undefined, abortRef: AbortRef }> = {}
// list
@@ -34,12 +34,12 @@ export class LLMMessageChannel implements IServerChannel {
success: new Emitter<EventModelListOnSuccessParams<OllamaModelResponse>>(),
error: new Emitter<EventModelListOnErrorParams<OllamaModelResponse>>(),
},
vLLM: {
success: new Emitter<EventModelListOnSuccessParams<VLLMModelResponse>>(),
error: new Emitter<EventModelListOnErrorParams<VLLMModelResponse>>(),
}
openaiCompat: {
success: new Emitter<EventModelListOnSuccessParams<OpenaiCompatibleModelResponse>>(),
error: new Emitter<EventModelListOnErrorParams<OpenaiCompatibleModelResponse>>(),
},
} satisfies {
[providerName: string]: {
[providerName in 'ollama' | 'openaiCompat']: {
success: Emitter<EventModelListOnSuccessParams<any>>,
error: Emitter<EventModelListOnErrorParams<any>>,
}
@@ -59,8 +59,8 @@ export class LLMMessageChannel implements IServerChannel {
// list
else if (event === 'onSuccess_list_ollama') return this.listEmitters.ollama.success.event;
else if (event === 'onError_list_ollama') return this.listEmitters.ollama.error.event;
else if (event === 'onSuccess_list_vLLM') return this.listEmitters.vLLM.success.event;
else if (event === 'onError_list_vLLM') return this.listEmitters.vLLM.error.event;
else if (event === 'onSuccess_list_openAICompatible') return this.listEmitters.openaiCompat.success.event;
else if (event === 'onError_list_openAICompatible') return this.listEmitters.openaiCompat.error.event;
else throw new Error(`Event not found: ${event}`);
}
@@ -72,13 +72,13 @@ export class LLMMessageChannel implements IServerChannel {
this._callSendLLMMessage(params)
}
else if (command === 'abort') {
this._callAbort(params)
await this._callAbort(params)
}
else if (command === 'ollamaList') {
this._callOllamaList(params)
}
else if (command === 'vLLMList') {
this._callVLLMList(params)
else if (command === 'openAICompatibleList') {
this._callOpenAICompatibleList(params)
}
else {
throw new Error(`Void sendLLM: command "${command}" not recognized.`)
@@ -90,27 +90,37 @@ export class LLMMessageChannel implements IServerChannel {
}
// the only place sendLLMMessage is actually called
private async _callSendLLMMessage(params: MainSendLLMMessageParams) {
private _callSendLLMMessage(params: MainSendLLMMessageParams) {
const { requestId } = params;
if (!(requestId in this.abortRefOfRequestId))
this.abortRefOfRequestId[requestId] = { current: null }
if (!(requestId in this._infoOfRunningRequest))
this._infoOfRunningRequest[requestId] = { waitForSend: undefined, abortRef: { current: null } }
const mainThreadParams: SendLLMMessageParams = {
...params,
onText: (p) => { this.llmMessageEmitters.onText.fire({ requestId, ...p }); },
onFinalMessage: (p) => { this.llmMessageEmitters.onFinalMessage.fire({ requestId, ...p }); },
onError: (p) => { console.log('sendLLM: firing err'); this.llmMessageEmitters.onError.fire({ requestId, ...p }); },
abortRef: this.abortRefOfRequestId[requestId],
onText: (p) => {
this.llmMessageEmitters.onText.fire({ requestId, ...p });
},
onFinalMessage: (p) => {
this.llmMessageEmitters.onFinalMessage.fire({ requestId, ...p });
},
onError: (p) => {
console.log('sendLLM: firing err');
this.llmMessageEmitters.onError.fire({ requestId, ...p });
},
abortRef: this._infoOfRunningRequest[requestId].abortRef,
}
sendLLMMessage(mainThreadParams, this.metricsService);
const p = sendLLMMessage(mainThreadParams, this.metricsService);
this._infoOfRunningRequest[requestId].waitForSend = p
}
private _callAbort(params: MainLLMMessageAbortParams) {
private async _callAbort(params: MainLLMMessageAbortParams) {
const { requestId } = params;
if (!(requestId in this.abortRefOfRequestId)) return
this.abortRefOfRequestId[requestId].current?.()
delete this.abortRefOfRequestId[requestId]
if (!(requestId in this._infoOfRunningRequest)) return
const { waitForSend, abortRef } = this._infoOfRunningRequest[requestId]
await waitForSend // wait for the send to finish so we know abortRef was set
abortRef?.current?.()
delete this._infoOfRunningRequest[requestId]
}
@@ -128,15 +138,15 @@ export class LLMMessageChannel implements IServerChannel {
sendLLMMessageToProviderImplementation.ollama.list(mainThreadParams)
}
_callVLLMList = (params: MainModelListParams<VLLMModelResponse>) => {
const { requestId } = params
const emitters = this.listEmitters.vLLM
const mainThreadParams: ModelListParams<VLLMModelResponse> = {
_callOpenAICompatibleList = (params: MainModelListParams<OpenaiCompatibleModelResponse>) => {
const { requestId, providerName } = params
const emitters = this.listEmitters.openaiCompat
const mainThreadParams: ModelListParams<OpenaiCompatibleModelResponse> = {
...params,
onSuccess: (p) => { emitters.success.fire({ requestId, ...p }); },
onError: (p) => { emitters.error.fire({ requestId, ...p }); },
}
sendLLMMessageToProviderImplementation.vLLM.list(mainThreadParams)
sendLLMMessageToProviderImplementation[providerName].list(mainThreadParams)
}