Compare commits

...

140 Commits

Author SHA1 Message Date
Mathew Pareles 93d40be1ac Merge remote-tracking branch 'origin/main' into model-selection 2025-03-01 17:43:18 -08:00
Mathew Pareles cfc39b83bb styles 2025-03-01 17:42:17 -08:00
Mathew Pareles 9c76a35837 outline 2025-03-01 17:42:17 -08:00
Mathew Pareles 4a00bd3e00 Cmd+L selections open better ux 2025-03-01 17:42:17 -08:00
Mathew Pareles 6d6e2fa974 fix annoying refactor 2025-03-01 17:42:17 -08:00
Mathew Pareles 971ce88df2 style 2025-03-01 17:40:29 -08:00
Andrew Pareles 7e34037ffe Merge pull request #288 from eswaldots/main
Fix encryption service not working on Linux
2025-02-28 14:11:52 -08:00
Andrew Pareles aa835d468b misc 2025-02-27 20:13:50 -08:00
Mathew Pareles 661eba3ae9 fix selection state bug 2025-02-27 17:08:49 -08:00
Mathew Pareles de17b3add1 styles 2025-02-27 16:27:34 -08:00
Mathew Pareles 6fa3901ab0 small style 2025-02-27 16:22:13 -08:00
Andrew Pareles d14a2f1fff marketplace URL 2025-02-26 02:22:58 -08:00
Andrew Pareles c1f3b92c51 fix manual reasoning parsing 2025-02-26 02:14:00 -08:00
Andrew Pareles 0de63a088d reasoning UI! 2025-02-26 02:01:04 -08:00
Andrew Pareles 0de1c20551 progress 2025-02-26 02:01:03 -08:00
Mathew Pareles a769eb4db0 Merge branch 'recovered-branch' into model-selection 2025-02-25 19:09:28 -08:00
Mathew Pareles 3e7e54b88f Merge branch 'model-selection' into HEAD 2025-02-25 18:48:38 -08:00
Mathew Pareles 1b43799371 prepare merg 2025-02-25 18:48:17 -08:00
Andrew Pareles 8438f80834 updates 2025-02-25 15:23:34 -08:00
Andrew Pareles 63b15af385 add claude 3.7 init 2025-02-25 15:23:34 -08:00
Andrew Pareles 57c9184f4e Update CONTRIBUTING.md 2025-02-24 18:53:42 -08:00
Andrew Pareles 0b6dd721e9 Update CONTRIBUTING.md 2025-02-24 18:53:18 -08:00
Andrew Pareles 88ac766f53 Update CONTRIBUTING.md 2025-02-24 18:51:56 -08:00
Andrew Pareles 58466afe72 Merge pull request #287 from jhacksman/devin/1739626558-fix-think-tag-apply
Fix Apply failure with <think> tags (#283)
2025-02-24 14:58:10 -08:00
Andrew Pareles 04efe1d235 added extractReasoningFromText in extractCodeFromResult instead 2025-02-24 14:57:50 -08:00
Andrew Pareles aa9a102b71 Merge remote-tracking branch 'origin/main' into pr/jhacksman/287 2025-02-24 14:55:01 -08:00
Andrew Pareles dc0f9bdada Merge pull request #282 from bjoaquinc/remote-ssh-extension
Added remote-ssh extension to /extensions
2025-02-24 14:52:40 -08:00
Andrew Pareles 75f30bf7cd Merge pull request #294 from voideditor/model-selection
Provider Settings + UX improvements
2025-02-24 06:24:08 -08:00
Andrew Pareles 4a43b628d4 improvements 2025-02-24 06:23:24 -08:00
Mathew Pareles 38370c751e + 2025-02-24 05:59:56 -08:00
Mathew Pareles 764b1a2ccf improve input box 2025-02-24 05:47:58 -08:00
Andrew Pareles 5c047b78f3 style+FIM prompt 2025-02-24 05:33:45 -08:00
Andrew Pareles 5cbd0a19a4 minor fixes 2025-02-24 04:38:08 -08:00
Andrew Pareles d2fb0fb4ff maxTokens for anthropic 2025-02-24 04:32:53 -08:00
Mathew Pareles 1dffbfb061 style 2025-02-24 04:09:09 -08:00
Andrew Pareles 3ae8f75641 remove mistral, finish(?) models! 2025-02-24 04:01:00 -08:00
Mathew Pareles df6de2d7fc styles for tool use 2025-02-24 03:51:59 -08:00
Mathew Pareles 4ebfa91a83 fix URI staleness bug in local storage 2025-02-24 00:46:03 -08:00
Andrew Pareles 9f20476eea provider support progress
Co-authored-by: Mathew Pareles <mathewpareles@users.noreply.github.com>
2025-02-23 21:37:34 -08:00
Andrew Pareles fd5e523434 models 2025-02-23 16:58:43 -08:00
Andrew Pareles 2c2714273e partway through adding better support for more providers 2025-02-23 01:28:51 -08:00
Mathew Pareles c650091418 style 2025-02-22 21:11:58 -08:00
Mathew Pareles d96a9d5f6b fix typeerror 2025-02-22 21:11:58 -08:00
Mathew Pareles a9b3cc146b refactor 2025-02-22 21:11:58 -08:00
Mathew Pareles bdb897d032 tool UI draft 2025-02-22 21:11:58 -08:00
Andrew Pareles 2a876d8efe + 2025-02-22 21:08:34 -08:00
Andrew Pareles bf4392a52f safe 2025-02-22 17:53:14 -08:00
Andrew Pareles dd24c3180d + 2025-02-22 17:52:58 -08:00
Andrew Pareles f40acd76f1 endpoint 2025-02-22 17:52:28 -08:00
Andrew Pareles 96de4f028f remove unnecessary check 2025-02-22 04:35:56 -08:00
Andrew Pareles 2ecce822f3 fix react - tedious 2025-02-22 04:30:17 -08:00
Andrew Pareles ea10765abd remove applybox stuff from editCode 2025-02-22 03:41:16 -08:00
Andrew Pareles 16f6181395 + 2025-02-22 03:23:44 -08:00
Andrew Pareles 19ebf4a1a2 seems to work! some annoying react state stuff.. 2025-02-22 03:03:05 -08:00
Andrew Pareles 63b71dec24 should work, just need to debug 2025-02-22 00:40:46 -08:00
Andrew Pareles 1079893527 add codeBoxId, but now will change to uri 2025-02-21 23:31:06 -08:00
Andrew Pareles 64ac6d4a12 ctrlK 2025-02-21 21:09:03 -08:00
Andrew Pareles 9005de65a6 quick edit stream state? 2025-02-21 20:53:09 -08:00
Andrew Pareles 86dfc5521d more progress (still broken) + fix weird _useThread() + rm initStreamingDiffZoneId 2025-02-21 20:10:02 -08:00
Andrew Pareles b005b1e95a progress (BROKEN) 2025-02-21 18:53:55 -08:00
Andrew Pareles ce14986d2f split apply buttons 2025-02-21 16:19:01 -08:00
Andrew Pareles 06ce9e1017 settings styles + password field 2025-02-21 15:06:42 -08:00
Andrew Pareles 09f5d3e14b nitpick style 2025-02-21 00:19:58 -08:00
Andrew Pareles 73c3a8133a massively simplified, seems to work! 2025-02-20 23:59:16 -08:00
Andrew Pareles 8918a14404 almost there 2025-02-20 23:23:58 -08:00
Andrew Pareles 1959c53d75 progress 2025-02-20 23:23:55 -08:00
Andrew Pareles 33d80bed80 progress 2025-02-20 17:25:53 -08:00
Andrew Pareles 970f0bdb9d revert "Void's" on the button 2025-02-20 01:28:46 -08:00
Andrew Pareles 7711c74ef0 reorder 2025-02-20 01:21:56 -08:00
Andrew Pareles e14aab632d multiple find/replace blocks almost work. only 1 diffarea per find/replace application 2025-02-20 01:21:28 -08:00
Andrew Pareles 62c2622ced Void's settings 2025-02-19 17:57:40 -08:00
Andrew Pareles 0f26ee2288 misc fixes 2025-02-19 00:47:03 -08:00
Andrew Pareles 6a44d668db + 2025-02-19 00:16:25 -08:00
Mathew Pareles 858b6f6a91 file service read 2025-02-19 00:12:51 -08:00
Andrew Pareles 7206209743 Merge pull request #290 from voideditor/model-selection
Tool use progress
2025-02-19 00:03:01 -08:00
Andrew Pareles 02f64b7ff6 finish tool pagination 2025-02-18 22:28:04 -08:00
Andrew Pareles ac1788ae9a tool pages work, improve prompt 2025-02-18 21:25:23 -08:00
Andrew Pareles 5699cf19f4 merge updates 2025-02-18 16:12:16 -08:00
Andrew Pareles 9ab9196b38 Merge remote-tracking branch 'origin/edit-chats' into model-selection 2025-02-18 16:12:11 -08:00
Mathew Pareles baa89cc17d dummy marker service 2025-02-18 14:14:38 -08:00
Andrew Pareles 667769c987 tool update; multiple SEARCH/REPLACE blocks 2025-02-17 23:57:59 -08:00
Mathew Pareles d3aa0bc3cc fix readfile 2025-02-17 17:56:20 -08:00
Andrew Pareles d3547134e7 get ready to add searchReplaceService 2025-02-17 15:22:24 -08:00
Mathew Pareles b92420012c minor 2025-02-17 15:18:11 -08:00
Andrew Pareles 0fd10f404e tool results in ChatMessage 2025-02-17 14:12:52 -08:00
Andrew Pareles 137e0068a3 add vllm 2025-02-17 01:50:28 -08:00
Andrew Pareles 366dbf0b52 add xAI and update system/tool information and system prompt 2025-02-17 01:06:21 -08:00
Mathew Pareles 89d08071fc scrollbar fix 2025-02-17 00:23:48 -08:00
Andrew Pareles 8a8ed1ac56 + 2025-02-17 00:20:01 -08:00
Andrew Pareles 491312218f + 2025-02-17 00:19:39 -08:00
Andrew Pareles b74b031906 + 2025-02-17 00:18:55 -08:00
Andrew Pareles 5608aca168 process 2025-02-17 00:17:14 -08:00
Andrew Pareles fecfbd924a error 2025-02-17 00:07:33 -08:00
Andrew Pareles a78a6169f8 tools + misc fixes 2025-02-17 00:07:11 -08:00
Andrew Pareles 628adedaec toolcalls 2025-02-16 20:51:42 -08:00
Andrew Pareles 432a1766af anthropic tool use fix 2025-02-16 20:50:07 -08:00
Mathew Pareles 74f8303803 discard star changes 2025-02-16 20:11:13 -08:00
Andrew Pareles 2bc3d67e39 minor tool use fix 2025-02-16 19:51:58 -08:00
Joaquin Coromina 2c61310077 Merge branch 'voideditor:main' into remote-ssh-extension 2025-02-16 23:32:00 +07:00
Joaquin Coromina a519100852 Added open-remote-ssh to npm install dirs and the output files in eslint ignore 2025-02-16 23:31:21 +07:00
eswaldots a38fc81a58 Fix encryption service dont working on Linux 2025-02-16 15:34:53 +00:00
Andrew Pareles af41d6a439 tool improvements 2025-02-16 03:23:37 -08:00
Andrew Pareles 7244d433dd tools should work! 2025-02-16 02:53:56 -08:00
Andrew Pareles 131493b5e1 tool structure 2025-02-16 00:43:46 -08:00
Devin AI 4c5e444bcb Improve token streaming support with character-by-character matching
Co-Authored-By: Jack Hacksman <slack@hannis.io>
2025-02-16 05:14:40 +00:00
Devin AI 2f55580e67 Improve token streaming support with character-by-character matching
Co-Authored-By: Jack Hacksman <slack@hannis.io>
2025-02-16 05:06:37 +00:00
Devin AI b24cd1ecb7 Improve token streaming support for think tags
Co-Authored-By: Jack Hacksman <slack@hannis.io>
2025-02-16 05:01:57 +00:00
Devin AI a519bd7690 Add streaming support for think tags and test cases
Co-Authored-By: Jack Hacksman <slack@hannis.io>
2025-02-16 04:59:55 +00:00
Andrew Pareles 8591d06244 tool use plugboard progress 2025-02-15 19:23:15 -08:00
Andrew Pareles a26816d597 Update src/vs/workbench/contrib/chat/common/chatModel.ts
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-02-15 15:46:39 -08:00
Andrew Pareles ea6e025511 Merge pull request #284 from adrieljss/main
fix ENOENT error spawn on Windows
2025-02-15 15:37:39 -08:00
Devin AI 7849b0a107 Fix Apply failure with <think> tags (#283)
- Add utility to strip <think> tags from responses
- Strip tags before computing diffs in Apply operation
- Handle nested tags properly
- Keep original response intact for UI display

Fixes #283

Co-Authored-By: Jack Hacksman <slack@hannis.io>
2025-02-15 13:35:58 +00:00
Andrew Pareles 152e605856 tool progress! 2025-02-15 02:05:37 -08:00
Andrew Pareles bc6150aeac tools 2025-02-15 00:02:17 -08:00
Andrew Pareles 05d8b3a982 handle updates with version number instead of weird check 2025-02-15 00:01:37 -08:00
Andrew Pareles d91ec9da2e inlineDiffsService -> editCodeService 2025-02-14 21:43:38 -08:00
adrieljss 1839acab1f fix ENOENT error spawn on Windows 2025-02-14 17:59:56 +08:00
Andrew Pareles 6ad48ffa20 recognizedModels 2025-02-14 01:59:17 -08:00
Andrew Pareles 0975f1bf5f add developer info for models 2025-02-14 01:52:17 -08:00
Andrew Pareles 9cfcf396c1 update types for tool support 2025-02-14 01:05:10 -08:00
Andrew Pareles 0bcd88dad6 fix 2025-02-13 23:54:22 -08:00
Andrew Pareles 343ee5eb94 Apply name 2025-02-13 23:00:01 -08:00
Mathew Pareles d9e4679b65 breaking changes - ai regex 2025-02-13 22:58:42 -08:00
Joaquin Coromina 4950de2cb5 Merge branch 'voideditor:main' into remote-ssh-extension 2025-02-14 10:06:42 +07:00
Joaquin Coromina 28bf838b55 Updated void-server endpoiont to make it dynamic 2025-02-14 10:05:30 +07:00
Andrew Pareles b01684393a streaming works! 2025-02-13 00:37:43 -08:00
Andrew Pareles 34b8027fb9 streaming progress 2025-02-13 00:08:23 -08:00
Andrew Pareles f7af9c336b tokenIdx 2025-02-12 21:58:57 -08:00
Andrew Pareles a479a32ad5 small fix 2025-02-12 21:55:58 -08:00
Andrew Pareles 7cdb003c47 stream state 2025-02-12 21:51:58 -08:00
Andrew Pareles 4f9f16b93f almost works (but doesn't delete old text) 2025-02-12 21:27:39 -08:00
Andrew Pareles 7fbe8d905f Merge pull request #281 from voideditor/model-selection
Latest streaming changes
2025-02-12 18:51:55 -08:00
Joaquin Coromina 79fb8f63a7 Added Open Remote-SSH extension to Void 2025-02-13 01:33:43 +07:00
Andrew Pareles 0dfa81f637 Search and replace? 2025-02-12 01:43:50 -08:00
Andrew Pareles d504daffa5 searchRepalce 2025-02-12 00:34:24 -08:00
Andrew Pareles fc97949887 autocomplete 2025-02-11 22:02:46 -08:00
Andrew Pareles 0346e90ca7 add parsing of <<< ORIGINAL, ===, FINAL >>> 2025-02-11 21:38:56 -08:00
Andrew Pareles a3e6deb1a8 Merge pull request #278 from bjoaquinc/build-void-server
fix(build): fixes void-reh (remote host) build crashes and updates the remote-host output path to say void rather than vscode
2025-02-11 16:43:41 -08:00
Joaquin Coromina 6f6f43c46e Fixed compilation for void-reh bug by updating package.json in remote and changed destination folder to say void 2025-02-10 19:54:50 +07:00
Andrew Pareles 5fa21be9d2 Merge pull request #273 from voideditor/model-selection
UI improvements
2025-02-06 02:05:18 -08:00
88 changed files with 7854 additions and 2361 deletions
+1
View File
@@ -11,6 +11,7 @@
**/extensions/markdown-language-features/notebook-out/**
**/extensions/markdown-math/notebook-out/**
**/extensions/notebook-renderers/renderer-out/index.js
**/extensions/open-remote-ssh/out/extension.js
**/extensions/simple-browser/media/index.js
**/extensions/typescript-language-features/test-workspace/**
**/extensions/typescript-language-features/extension.webpack.config.js
+1 -1
View File
@@ -18,7 +18,7 @@ We highly recommend reading [this](https://github.com/microsoft/vscode/wiki/Sour
We wrote a [guide to working in VSCode].
-->
Most of Void's code lives in the two folders called `void/`.
Most of Void's code lives in the folder `src/vs/workbench/contrib/void/`.
+1
View File
@@ -134,6 +134,7 @@ module.exports.indentationFilter = [
'!extensions/markdown-math/notebook-out/*.js',
'!extensions/ipynb/notebook-out/**',
'!extensions/notebook-renderers/renderer-out/*.js',
'!extensions/open-remote-ssh/out/*.js',
'!extensions/simple-browser/media/*.js',
];
+1
View File
@@ -55,6 +55,7 @@ const compilations = [
'extensions/microsoft-authentication/tsconfig.json',
'extensions/notebook-renderers/tsconfig.json',
'extensions/npm/tsconfig.json',
'extensions/open-remote-ssh/tsconfig.json',
'extensions/php-language-features/tsconfig.json',
'extensions/references-view/tsconfig.json',
'extensions/search-result/tsconfig.json',
+1 -1
View File
@@ -516,7 +516,7 @@ function tweakProductForServerWeb(product) {
['', 'min'].forEach(minified => {
const sourceFolderName = `out-vscode-${type}${dashed(minified)}`;
const destinationFolderName = `vscode-${type}${dashed(platform)}${dashed(arch)}`;
const destinationFolderName = `void-${type}${dashed(platform)}${dashed(arch)}`;
const serverTaskCI = task.define(`vscode-${type}${dashed(platform)}${dashed(arch)}${dashed(minified)}-ci`, task.series(
gulp.task(`node-${platform}-${arch}`),
+1
View File
@@ -36,6 +36,7 @@ const dirs = [
'extensions/microsoft-authentication',
'extensions/notebook-renderers',
'extensions/npm',
'extensions/open-remote-ssh',
'extensions/php-language-features',
'extensions/references-view',
'extensions/search-result',
+74
View File
@@ -0,0 +1,74 @@
## 0.0.48
- Support `%n` in ProxyCommand
- fix: add missing direct @types/ssh2-stream dependency (#177)
- fix Win32 internal error (#178)
## 0.0.47
- Add support for loong64 (#175)
- Add s390x support (#174)
- Support vscodium alpine reh (#142)
## 0.0.46
- Add riscv64 support (#147)
## 0.0.45
- Use windows-x64 server on windows-arm64
## 0.0.44
- Update ssh2 lib
- Properly set extensionHost env variables
## 0.0.43
- Fix parsing multiple include directives
## 0.0.42
- Fix remote label to show port when connecting to a port other than 22
## 0.0.41
- Take into account parsed port from ssh destination. Fixes (#110)
## 0.0.40
- Update ssh-config package
## 0.0.39
- output error messages when downloading vscode server (#39)
- Add PreferredAuthentications support (#97)
## 0.0.38
- Enable remote support for ppc64le (#93)
## 0.0.37
- Default to Current OS User in Connection String if No User Provided (#91)
- Add support for (unofficial) DragonFly reh (#86)
## 0.0.36
- Make wget support continue download (#85)
## 0.0.35
- Fixes hardcoded agentsock for windows breaks pageant compatibility (#81)
## 0.0.34
- Add remote.SSH.connectTimeout setting
- adding %r username replacement to proxycommand (#77)
## 0.0.33
- feat: support %r user substitution in proxycommand
## 0.0.32
- feat: use serverDownloadUrlTemplate from product.json (#59)
## 0.0.31
- feat: support glob patterns in SSH include directives
## 0.0.30
- feat: support file patterns in SSH include directives
+48
View File
@@ -0,0 +1,48 @@
# Open Remote - SSH
## SSH Host Requirements
You can connect to a running SSH server on the following platforms.
**Supported**:
- x86_64 Debian 8+, Ubuntu 16.04+, CentOS / RHEL 7+ Linux.
- ARMv7l (AArch32) Raspbian Stretch/9+ (32-bit).
- ARMv8l (AArch64) Ubuntu 18.04+ (64-bit).
- macOS 10.14+ (Mojave)
- Windows 10+
- FreeBSD 13 (Requires manual remote-extension-host installation)
- DragonFlyBSD (Requires manual remote-extension-host installation)
## Requirements
**Activation**
Enable the extension in your `argv.json`
```json
{
...
"enable-proposed-api": [
...,
"jeanp413.open-remote-ssh",
]
...
}
```
which you can open by running the `Preferences: Configure Runtime Arguments` command.
The file is located in `~/.vscode-oss/argv.json`.
**Alpine linux**
When running on alpine linux, the packages `libstdc++` and `bash` are necessary and can be installed via
running
```bash
sudo apk add bash libstdc++
```
## SSH configuration file
[OpenSSH](https://www.openssh.com/) supports using a [configuration file](https://linuxize.com/post/using-the-ssh-config-file/) to store all your different SSH connections. To use an SSH config file, run the `Remote-SSH: Open SSH Configuration File...` command.
@@ -0,0 +1,17 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
//@ts-check
'use strict';
const withBrowserDefaults = require('../shared.webpack.config').browser;
module.exports = withBrowserDefaults({
context: __dirname,
entry: {
extension: './src/extension.ts'
}
});
@@ -0,0 +1,34 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
//@ts-check
'use strict';
const withDefaults = require('../shared.webpack.config');
const { IgnorePlugin } = require('webpack');
module.exports = withDefaults({
context: __dirname,
resolve: {
mainFields: ['module', 'main']
},
entry: {
extension: './src/extension.ts',
},
externals: {
vscode: "commonjs vscode",
bufferutil: "commonjs bufferutil",
"utf-8-validate": "commonjs utf-8-validate",
},
plugins: [
new IgnorePlugin({
resourceRegExp: /crypto\/build\/Release\/sshcrypto\.node$/,
}),
new IgnorePlugin({
resourceRegExp: /cpu-features/,
})
]
});
+370
View File
@@ -0,0 +1,370 @@
{
"name": "open-remote-ssh",
"version": "0.0.48",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-remote-ssh",
"version": "0.0.48",
"dependencies": {
"@jeanp413/ssh-config": "^4.3.1",
"glob": "^9.3.1",
"simple-socks": "git+https://github.com/jeanp413/simple-socks#main",
"socks": "^2.5.0",
"ssh2": "git+https://github.com/jeanp413/ssh2#master"
},
"devDependencies": {
"@types/ssh2": "^0.5.52",
"@types/ssh2-streams": "0.1.12"
},
"engines": {
"vscode": "^1.70.2"
}
},
"node_modules/@babel/runtime-corejs3": {
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.26.0.tgz",
"integrity": "sha512-YXHu5lN8kJCb1LOb9PgV6pvak43X2h4HvRApcN5SdWeaItQOzfn1hgP6jasD6KWQyJDBxrVmA9o9OivlnNJK/w==",
"license": "MIT",
"dependencies": {
"core-js-pure": "^3.30.2",
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@jeanp413/ssh-config": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@jeanp413/ssh-config/-/ssh-config-4.3.1.tgz",
"integrity": "sha512-x0EaWRdjs5sPDNmYr11wVB1GdwWQgRekc7SbueuO5FK7YZUav98qZKtZZU5iSDKyxJkooCs3rgVizB1wIWrF7g==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.10.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
"integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
}
},
"node_modules/@types/ssh2": {
"version": "0.5.52",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz",
"integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/ssh2-streams": "*"
}
},
"node_modules/@types/ssh2-streams": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@types/ssh2-streams/-/ssh2-streams-0.1.12.tgz",
"integrity": "sha512-Sy8tpEmCce4Tq0oSOYdfqaBpA3hDM8SoxoFh5vzFsu2oL+znzGz8oVWW7xb4K920yYMUY+PIG31qZnFMfPWNCg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/binary": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz",
"integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==",
"license": "MIT",
"dependencies": {
"buffers": "~0.1.1",
"chainsaw": "~0.1.0"
},
"engines": {
"node": "*"
}
},
"node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/buffers": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz",
"integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==",
"engines": {
"node": ">=0.2.0"
}
},
"node_modules/buildcheck": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz",
"integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/chainsaw": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz",
"integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==",
"license": "MIT/X11",
"dependencies": {
"traverse": ">=0.3.0 <0.4"
},
"engines": {
"node": "*"
}
},
"node_modules/core-js-pure": {
"version": "3.40.0",
"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.40.0.tgz",
"integrity": "sha512-AtDzVIgRrmRKQai62yuSIN5vNiQjcJakJb4fbhVw3ehxx7Lohphvw9SGNWKhLFqSxC4ilD0g/L1huAYFQU3Q6A==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/glob": {
"version": "9.3.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz",
"integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"minimatch": "^8.0.2",
"minipass": "^4.2.4",
"path-scurry": "^1.6.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ip-address": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
"integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
"license": "MIT",
"dependencies": {
"jsbn": "1.1.0",
"sprintf-js": "^1.1.3"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/jsbn": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
"integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz",
"integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz",
"integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==",
"license": "ISC",
"engines": {
"node": ">=8"
}
},
"node_modules/nan": {
"version": "2.22.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz",
"integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==",
"license": "MIT",
"optional": true
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/simple-socks": {
"version": "2.2.2",
"resolved": "git+ssh://git@github.com/jeanp413/simple-socks.git#2ac739301a82d6baff04804ed494436a026acb60",
"license": "MIT",
"dependencies": {
"@babel/runtime-corejs3": "^7.16.8",
"binary": "^0.3.0"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz",
"integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==",
"license": "MIT",
"dependencies": {
"ip-address": "^9.0.5",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"license": "BSD-3-Clause"
},
"node_modules/ssh2": {
"version": "1.14.0",
"resolved": "git+ssh://git@github.com/jeanp413/ssh2.git#a169f627213aa663e0aa2fd2f0ef5c8931890c26",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.9",
"nan": "^2.17.0"
}
},
"node_modules/traverse": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz",
"integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==",
"license": "MIT/X11",
"engines": {
"node": "*"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"license": "MIT"
}
}
}
+351
View File
@@ -0,0 +1,351 @@
{
"name": "open-remote-ssh",
"displayName": "Open Remote - SSH",
"publisher": "voideditor",
"description": "Use any remote machine with a SSH server as your development environment.",
"version": "0.0.48",
"icon": "resources/icon.png",
"engines": {
"vscode": "^1.70.2"
},
"extensionKind": [
"ui"
],
"enabledApiProposals": [
"resolvers",
"contribViewsRemote"
],
"keywords": [
"remote development",
"remote",
"ssh"
],
"api": "none",
"activationEvents": [
"onCommand:openremotessh.openEmptyWindow",
"onCommand:openremotessh.openEmptyWindowInCurrentWindow",
"onCommand:openremotessh.openConfigFile",
"onCommand:openremotessh.showLog",
"onResolveRemoteAuthority:ssh-remote",
"onView:sshHosts"
],
"main": "./out/extension.js",
"contributes": {
"configuration": {
"title": "Remote - SSH",
"properties": {
"remote.SSH.configFile": {
"type": "string",
"description": "The absolute file path to a custom SSH config file.",
"default": "",
"scope": "application"
},
"remote.SSH.connectTimeout": {
"type": "number",
"description": "Specifies the timeout in seconds used for the SSH command that connects to the remote.",
"default": 60,
"scope": "application",
"minimum": 1
},
"remote.SSH.defaultExtensions": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of extensions that should be installed automatically on all SSH hosts.",
"scope": "application"
},
"remote.SSH.enableDynamicForwarding": {
"type": "boolean",
"description": "Whether to use SSH dynamic forwarding to allow setting up new port tunnels over an existing SSH connection.",
"scope": "application",
"default": true
},
"remote.SSH.enableAgentForwarding": {
"type": "boolean",
"markdownDescription": "Enable fixing the remote environment so that the SSH config option `ForwardAgent` will take effect as expected from VS Code's remote extension host.",
"scope": "application",
"default": true
},
"remote.SSH.serverDownloadUrlTemplate": {
"type": "string",
"description": "The URL from where the vscode server will be downloaded. You can use the following variables and they will be replaced dynamically:\n- ${quality}: vscode server quality, e.g. stable or insiders\n- ${version}: vscode server version, e.g. 1.69.0\n- ${commit}: vscode server release commit\n- ${arch}: vscode server arch, e.g. x64, armhf, arm64\n- ${release}: release number",
"scope": "application",
"default": "https://github.com/voideditor/${NAME_OF_REPO}/releases/download/${version}.${release}/void-server-${os}-${arch}-${version}.${release}.tar.gz"
},
"remote.SSH.remotePlatform": {
"type": "object",
"description": "A map of the remote hostname to the platform for that remote. Valid values: linux, macos, windows.",
"scope": "application",
"default": {},
"additionalProperties": {
"type": "string",
"enum": [
"linux",
"macos",
"windows"
]
}
},
"remote.SSH.remoteServerListenOnSocket": {
"type": "boolean",
"description": "When true, the remote vscode server will listen on a socket path instead of opening a port. Only valid for Linux and macOS remotes. Requires `AllowStreamLocalForwarding` to be enabled for the SSH server.",
"default": false
},
"remote.SSH.experimental.serverBinaryName": {
"type": "string",
"description": "**Experimental:** The name of the server binary, use this **only if** you are using a client without a corresponding server release",
"scope": "application",
"default": ""
}
}
},
"views": {
"remote": [
{
"id": "sshHosts",
"name": "SSH Targets",
"group": "targets@1",
"remoteName": "ssh-remote"
}
]
},
"commands": [
{
"command": "openremotessh.openEmptyWindow",
"title": "Connect to Host...",
"category": "Remote-SSH"
},
{
"command": "openremotessh.openEmptyWindowInCurrentWindow",
"title": "Connect Current Window to Host...",
"category": "Remote-SSH"
},
{
"command": "openremotessh.openConfigFile",
"title": "Open SSH Configuration File...",
"category": "Remote-SSH"
},
{
"command": "openremotessh.showLog",
"title": "Show Log",
"category": "Remote-SSH"
},
{
"command": "openremotessh.explorer.emptyWindowInNewWindow",
"title": "Connect to Host in New Window",
"icon": "$(empty-window)"
},
{
"command": "openremotessh.explorer.emptyWindowInCurrentWindow",
"title": "Connect to Host in Current Window"
},
{
"command": "openremotessh.explorer.reopenFolderInCurrentWindow",
"title": "Open on SSH Host in Current Window"
},
{
"command": "openremotessh.explorer.reopenFolderInNewWindow",
"title": "Open on SSH Host in New Window",
"icon": "$(folder-opened)"
},
{
"command": "openremotessh.explorer.deleteFolderHistoryItem",
"title": "Remove From Recent List",
"icon": "$(x)"
},
{
"command": "openremotessh.explorer.refresh",
"title": "Refresh",
"icon": "$(refresh)"
},
{
"command": "openremotessh.explorer.configure",
"title": "Configure",
"icon": "$(gear)"
},
{
"command": "openremotessh.explorer.add",
"title": "Add New",
"icon": "$(plus)"
}
],
"resourceLabelFormatters": [
{
"scheme": "vscode-remote",
"authority": "ssh-remote+*",
"formatting": {
"label": "${path}",
"separator": "/",
"tildify": true,
"workspaceSuffix": "SSH"
}
}
],
"menus": {
"statusBar/remoteIndicator": [
{
"command": "openremotessh.openEmptyWindow",
"when": "remoteName =~ /^ssh-remote$/ && remoteConnectionState == connected",
"group": "remote_20_ssh_1general@1"
},
{
"command": "openremotessh.openEmptyWindowInCurrentWindow",
"when": "remoteName =~ /^ssh-remote$/ && remoteConnectionState == connected",
"group": "remote_20_ssh_1general@2"
},
{
"command": "openremotessh.openConfigFile",
"when": "remoteName =~ /^ssh-remote$/ && remoteConnectionState == connected",
"group": "remote_20_ssh_1general@3"
},
{
"command": "openremotessh.showLog",
"when": "remoteName =~ /^ssh-remote$/ && remoteConnectionState == connected",
"group": "remote_20_ssh_1general@4"
},
{
"command": "openremotessh.openEmptyWindow",
"when": "remoteConnectionState == disconnected",
"group": "remote_20_ssh_3local@1"
},
{
"command": "openremotessh.openEmptyWindowInCurrentWindow",
"when": "remoteConnectionState == disconnected",
"group": "remote_20_ssh_3local@2"
},
{
"command": "openremotessh.openConfigFile",
"when": "remoteConnectionState == disconnected",
"group": "remote_20_ssh_3local@3"
},
{
"command": "openremotessh.openEmptyWindow",
"when": "!remoteName && !virtualWorkspace",
"group": "remote_20_ssh_3local@5"
},
{
"command": "openremotessh.openEmptyWindowInCurrentWindow",
"when": "!remoteName && !virtualWorkspace",
"group": "remote_20_ssh_3local@6"
},
{
"command": "openremotessh.openConfigFile",
"when": "!remoteName && !virtualWorkspace",
"group": "remote_20_ssh_3local@7"
}
],
"commandPalette": [
{
"command": "openremotessh.explorer.refresh",
"when": "false"
},
{
"command": "openremotessh.explorer.configure",
"when": "false"
},
{
"command": "openremotessh.explorer.add",
"when": "false"
},
{
"command": "openremotessh.explorer.emptyWindowInNewWindow",
"when": "false"
},
{
"command": "openremotessh.explorer.emptyWindowInCurrentWindow",
"when": "false"
},
{
"command": "openremotessh.explorer.reopenFolderInCurrentWindow",
"when": "false"
},
{
"command": "openremotessh.explorer.reopenFolderInNewWindow",
"when": "false"
},
{
"command": "openremotessh.explorer.deleteFolderHistoryItem",
"when": "false"
}
],
"view/title": [
{
"command": "openremotessh.explorer.add",
"when": "view == sshHosts",
"group": "navigation"
},
{
"command": "openremotessh.explorer.configure",
"when": "view == sshHosts",
"group": "navigation"
},
{
"command": "openremotessh.explorer.refresh",
"when": "view == sshHosts",
"group": "navigation"
}
],
"view/item/context": [
{
"command": "openremotessh.explorer.emptyWindowInNewWindow",
"when": "viewItem =~ /^openremotessh.explorer.host$/",
"group": "inline@1"
},
{
"command": "openremotessh.explorer.emptyWindowInNewWindow",
"when": "viewItem =~ /^openremotessh.explorer.host$/",
"group": "navigation@2"
},
{
"command": "openremotessh.explorer.emptyWindowInCurrentWindow",
"when": "viewItem =~ /^openremotessh.explorer.host$/",
"group": "navigation@1"
},
{
"command": "openremotessh.explorer.reopenFolderInNewWindow",
"when": "viewItem == openremotessh.explorer.folder",
"group": "inline@1"
},
{
"command": "openremotessh.explorer.reopenFolderInNewWindow",
"when": "viewItem == openremotessh.explorer.folder",
"group": "navigation@2"
},
{
"command": "openremotessh.explorer.reopenFolderInCurrentWindow",
"when": "viewItem == openremotessh.explorer.folder",
"group": "navigation@1"
},
{
"command": "openremotessh.explorer.deleteFolderHistoryItem",
"when": "viewItem =~ /^openremotessh.explorer.folder/",
"group": "navigation@3"
},
{
"command": "openremotessh.explorer.deleteFolderHistoryItem",
"when": "viewItem =~ /^openremotessh.explorer.folder/",
"group": "inline@2"
}
]
}
},
"scripts": {
"vscode:prepublish": "npm run compile",
"compile": "gulp compile-extension:open-remote-ssh",
"compile-web": "npx webpack-cli --config extension-browser.webpack.config --mode none",
"watch": "gulp watch-extension:open-remote-ssh",
"watch-web": "npx webpack-cli --config extension-browser.webpack.config --mode none --watch --info-verbosity verbose"
},
"devDependencies": {
"@types/ssh2": "^0.5.52",
"@types/ssh2-streams": "0.1.12"
},
"dependencies": {
"glob": "^9.3.1",
"simple-socks": "git+https://github.com/jeanp413/simple-socks#main",
"socks": "^2.5.0",
"@jeanp413/ssh-config": "^4.3.1",
"ssh2": "git+https://github.com/jeanp413/ssh2#master"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,464 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as cp from 'child_process';
import * as fs from 'fs';
import * as net from 'net';
import * as stream from 'stream';
import { SocksClient, SocksClientOptions } from 'socks';
import * as vscode from 'vscode';
import * as ssh2 from 'ssh2';
import type { ParsedKey } from 'ssh2-streams';
import Log from './common/logger';
import SSHDestination from './ssh/sshDestination';
import SSHConnection, { SSHTunnelConfig } from './ssh/sshConnection';
import SSHConfiguration from './ssh/sshConfig';
import { gatherIdentityFiles } from './ssh/identityFiles';
import { untildify, exists as fileExists } from './common/files';
import { findRandomPort } from './common/ports';
import { disposeAll } from './common/disposable';
import { installCodeServer, ServerInstallError } from './serverSetup';
import { isWindows } from './common/platform';
import * as os from 'os';
const PASSWORD_RETRY_COUNT = 3;
const PASSPHRASE_RETRY_COUNT = 3;
export const REMOTE_SSH_AUTHORITY = 'ssh-remote';
export function getRemoteAuthority(host: string) {
return `${REMOTE_SSH_AUTHORITY}+${host}`;
}
class TunnelInfo implements vscode.Disposable {
constructor(
readonly localPort: number,
readonly remotePortOrSocketPath: number | string,
private disposables: vscode.Disposable[]
) {
}
dispose() {
disposeAll(this.disposables);
}
}
interface SSHKey {
filename: string;
parsedKey: ParsedKey;
fingerprint: string;
agentSupport?: boolean;
isPrivate?: boolean;
}
export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode.Disposable {
private proxyConnections: SSHConnection[] = [];
private sshConnection: SSHConnection | undefined;
private sshAgentSock: string | undefined;
private proxyCommandProcess: cp.ChildProcessWithoutNullStreams | undefined;
private socksTunnel: SSHTunnelConfig | undefined;
private tunnels: TunnelInfo[] = [];
private labelFormatterDisposable: vscode.Disposable | undefined;
constructor(
readonly context: vscode.ExtensionContext,
readonly logger: Log
) {
}
resolve(authority: string, context: vscode.RemoteAuthorityResolverContext): Thenable<vscode.ResolverResult> {
const [type, dest] = authority.split('+');
if (type !== REMOTE_SSH_AUTHORITY) {
throw new Error(`Invalid authority type for SSH resolver: ${type}`);
}
this.logger.info(`Resolving ssh remote authority '${authority}' (attemp #${context.resolveAttempt})`);
const sshDest = SSHDestination.parseEncoded(dest);
// It looks like default values are not loaded yet when resolving a remote,
// so let's hardcode the default values here
const remoteSSHconfig = vscode.workspace.getConfiguration('remote.SSH');
const enableDynamicForwarding = remoteSSHconfig.get<boolean>('enableDynamicForwarding', true)!;
const enableAgentForwarding = remoteSSHconfig.get<boolean>('enableAgentForwarding', true)!;
const serverDownloadUrlTemplate = remoteSSHconfig.get<string>('serverDownloadUrlTemplate');
const defaultExtensions = remoteSSHconfig.get<string[]>('defaultExtensions', []);
const remotePlatformMap = remoteSSHconfig.get<Record<string, string>>('remotePlatform', {});
const remoteServerListenOnSocket = remoteSSHconfig.get<boolean>('remoteServerListenOnSocket', false)!;
const connectTimeout = remoteSSHconfig.get<number>('connectTimeout', 60)!;
return vscode.window.withProgress({
title: `Setting up SSH Host ${sshDest.hostname}`,
location: vscode.ProgressLocation.Notification,
cancellable: false
}, async () => {
try {
const sshconfig = await SSHConfiguration.loadFromFS();
const sshHostConfig = sshconfig.getHostConfiguration(sshDest.hostname);
const sshHostName = sshHostConfig['HostName'] ? sshHostConfig['HostName'].replace('%h', sshDest.hostname) : sshDest.hostname;
const sshUser = sshHostConfig['User'] || sshDest.user || os.userInfo().username || ''; // https://github.com/openssh/openssh-portable/blob/5ec5504f1d328d5bfa64280cd617c3efec4f78f3/sshconnect.c#L1561-L1562
const sshPort = sshHostConfig['Port'] ? parseInt(sshHostConfig['Port'], 10) : (sshDest.port || 22);
this.sshAgentSock = sshHostConfig['IdentityAgent'] || process.env['SSH_AUTH_SOCK'] || (isWindows ? '\\\\.\\pipe\\openssh-ssh-agent' : undefined);
this.sshAgentSock = this.sshAgentSock ? untildify(this.sshAgentSock) : undefined;
const agentForward = enableAgentForwarding && (sshHostConfig['ForwardAgent'] || 'no').toLowerCase() === 'yes';
const agent = agentForward && this.sshAgentSock ? new ssh2.OpenSSHAgent(this.sshAgentSock) : undefined;
const preferredAuthentications = sshHostConfig['PreferredAuthentications'] ? sshHostConfig['PreferredAuthentications'].split(',').map(s => s.trim()) : ['publickey', 'password', 'keyboard-interactive'];
const identityFiles: string[] = (sshHostConfig['IdentityFile'] as unknown as string[]) || [];
const identitiesOnly = (sshHostConfig['IdentitiesOnly'] || 'no').toLowerCase() === 'yes';
const identityKeys = await gatherIdentityFiles(identityFiles, this.sshAgentSock, identitiesOnly, this.logger);
// Create proxy jump connections if any
let proxyStream: ssh2.ClientChannel | stream.Duplex | undefined;
if (sshHostConfig['ProxyJump']) {
const proxyJumps = sshHostConfig['ProxyJump'].split(',').filter(i => !!i.trim())
.map(i => {
const proxy = SSHDestination.parse(i);
const proxyHostConfig = sshconfig.getHostConfiguration(proxy.hostname);
return [proxy, proxyHostConfig] as [SSHDestination, Record<string, string>];
});
for (let i = 0; i < proxyJumps.length; i++) {
const [proxy, proxyHostConfig] = proxyJumps[i];
const proxyHostName = proxyHostConfig['HostName'] || proxy.hostname;
const proxyUser = proxyHostConfig['User'] || proxy.user || sshUser;
const proxyPort = proxyHostConfig['Port'] ? parseInt(proxyHostConfig['Port'], 10) : (proxy.port || sshPort);
const proxyAgentForward = enableAgentForwarding && (proxyHostConfig['ForwardAgent'] || 'no').toLowerCase() === 'yes';
const proxyAgent = proxyAgentForward && this.sshAgentSock ? new ssh2.OpenSSHAgent(this.sshAgentSock) : undefined;
const proxyIdentityFiles: string[] = (proxyHostConfig['IdentityFile'] as unknown as string[]) || [];
const proxyIdentitiesOnly = (proxyHostConfig['IdentitiesOnly'] || 'no').toLowerCase() === 'yes';
const proxyIdentityKeys = await gatherIdentityFiles(proxyIdentityFiles, this.sshAgentSock, proxyIdentitiesOnly, this.logger);
const proxyAuthHandler = this.getSSHAuthHandler(proxyUser, proxyHostName, proxyIdentityKeys, preferredAuthentications);
const proxyConnection = new SSHConnection({
host: !proxyStream ? proxyHostName : undefined,
port: !proxyStream ? proxyPort : undefined,
sock: proxyStream,
username: proxyUser,
readyTimeout: connectTimeout * 1000,
strictVendor: false,
agentForward: proxyAgentForward,
agent: proxyAgent,
authHandler: (arg0, arg1, arg2) => (proxyAuthHandler(arg0, arg1, arg2), undefined)
});
this.proxyConnections.push(proxyConnection);
const nextProxyJump = i < proxyJumps.length - 1 ? proxyJumps[i + 1] : undefined;
const destIP = nextProxyJump ? (nextProxyJump[1]['HostName'] || nextProxyJump[0].hostname) : sshHostName;
const destPort = nextProxyJump ? ((nextProxyJump[1]['Port'] && parseInt(nextProxyJump[1]['Port'], 10)) || nextProxyJump[0].port || 22) : sshPort;
proxyStream = await proxyConnection.forwardOut('127.0.0.1', 0, destIP, destPort);
}
} else if (sshHostConfig['ProxyCommand']) {
let proxyArgs = (sshHostConfig['ProxyCommand'] as unknown as string[])
.map((arg) => arg.replace('%h', sshHostName).replace('%n', sshDest.hostname).replace('%p', sshPort.toString()).replace('%r', sshUser));
let proxyCommand = proxyArgs.shift()!;
let options = {};
if (isWindows && /\.(bat|cmd)$/.test(proxyCommand)) {
proxyCommand = `"${proxyCommand}"`;
proxyArgs = proxyArgs.map((arg) => arg.includes(' ') ? `"${arg}"` : arg);
options = { shell: true, windowsHide: true, windowsVerbatimArguments: true };
}
this.logger.trace(`Spawning ProxyCommand: ${proxyCommand} ${proxyArgs.join(' ')}`);
const child = cp.spawn(proxyCommand, proxyArgs, options);
proxyStream = stream.Duplex.from({ readable: child.stdout, writable: child.stdin });
this.proxyCommandProcess = child;
}
// Create final shh connection
const sshAuthHandler = this.getSSHAuthHandler(sshUser, sshHostName, identityKeys, preferredAuthentications);
this.sshConnection = new SSHConnection({
host: !proxyStream ? sshHostName : undefined,
port: !proxyStream ? sshPort : undefined,
sock: proxyStream,
username: sshUser,
readyTimeout: connectTimeout * 1000,
strictVendor: false,
agentForward,
agent,
authHandler: (arg0, arg1, arg2) => (sshAuthHandler(arg0, arg1, arg2), undefined),
});
await this.sshConnection.connect();
const envVariables: Record<string, string | null> = {};
if (agentForward) {
envVariables['SSH_AUTH_SOCK'] = null;
}
const installResult = await installCodeServer(this.sshConnection, serverDownloadUrlTemplate, defaultExtensions, Object.keys(envVariables), remotePlatformMap[sshDest.hostname], remoteServerListenOnSocket, this.logger);
for (const key of Object.keys(envVariables)) {
if (installResult[key] !== undefined) {
envVariables[key] = installResult[key];
}
}
// Update terminal env variables
this.context.environmentVariableCollection.persistent = false;
for (const [key, value] of Object.entries(envVariables)) {
if (value) {
this.context.environmentVariableCollection.replace(key, value);
}
}
if (enableDynamicForwarding) {
const socksPort = await findRandomPort();
this.socksTunnel = await this.sshConnection!.addTunnel({
name: `ssh_tunnel_socks_${socksPort}`,
localPort: socksPort,
socks: true
});
}
const tunnelConfig = await this.openTunnel(0, installResult.listeningOn);
this.tunnels.push(tunnelConfig);
// Enable ports view
vscode.commands.executeCommand('setContext', 'forwardedPortsViewEnabled', true);
this.labelFormatterDisposable?.dispose();
this.labelFormatterDisposable = vscode.workspace.registerResourceLabelFormatter({
scheme: 'vscode-remote',
authority: `${REMOTE_SSH_AUTHORITY}+*`,
formatting: {
label: '${path}',
separator: '/',
tildify: true,
workspaceSuffix: `SSH: ${sshDest.hostname}` + (sshDest.port && sshDest.port !== 22 ? `:${sshDest.port}` : '')
}
});
const resolvedResult: vscode.ResolverResult = new vscode.ResolvedAuthority('127.0.0.1', tunnelConfig.localPort, installResult.connectionToken);
resolvedResult.extensionHostEnv = envVariables;
return resolvedResult;
} catch (e: unknown) {
this.logger.error(`Error resolving authority`, e);
// Initial connection
if (context.resolveAttempt === 1) {
this.logger.show();
const closeRemote = 'Close Remote';
const retry = 'Retry';
const result = await vscode.window.showErrorMessage(`Could not establish connection to "${sshDest.hostname}"`, { modal: true }, closeRemote, retry);
if (result === closeRemote) {
await vscode.commands.executeCommand('workbench.action.remote.close');
} else if (result === retry) {
await vscode.commands.executeCommand('workbench.action.reloadWindow');
}
}
if (e instanceof ServerInstallError || !(e instanceof Error)) {
throw vscode.RemoteAuthorityResolverError.NotAvailable(e instanceof Error ? e.message : String(e));
} else {
throw vscode.RemoteAuthorityResolverError.TemporarilyNotAvailable(e.message);
}
}
});
}
private async openTunnel(localPort: number, remotePortOrSocketPath: number | string) {
localPort = localPort > 0 ? localPort : await findRandomPort();
const disposables: vscode.Disposable[] = [];
const remotePort = typeof remotePortOrSocketPath === 'number' ? remotePortOrSocketPath : undefined;
const remoteSocketPath = typeof remotePortOrSocketPath === 'string' ? remotePortOrSocketPath : undefined;
if (this.socksTunnel && remotePort) {
const forwardingServer = await new Promise<net.Server>((resolve, reject) => {
this.logger.trace(`Creating forwarding server ${localPort}(local) => ${this.socksTunnel!.localPort!}(socks) => ${remotePort}(remote)`);
const socksOptions: SocksClientOptions = {
proxy: {
host: '127.0.0.1',
port: this.socksTunnel!.localPort!,
type: 5
},
command: 'connect',
destination: {
host: '127.0.0.1',
port: remotePort
}
};
const server: net.Server = net.createServer()
.on('error', reject)
.on('connection', async (socket: net.Socket) => {
try {
const socksConn = await SocksClient.createConnection(socksOptions);
socket.pipe(socksConn.socket);
socksConn.socket.pipe(socket);
} catch (error) {
this.logger.error(`Error while creating SOCKS connection`, error);
}
})
.on('listening', () => resolve(server))
.listen(localPort);
});
disposables.push({
dispose: () => forwardingServer.close(() => {
this.logger.trace(`SOCKS forwading server closed`);
}),
});
} else {
this.logger.trace(`Opening tunnel ${localPort}(local) => ${remotePortOrSocketPath}(remote)`);
const tunnelConfig = await this.sshConnection!.addTunnel({
name: `ssh_tunnel_${localPort}_${remotePortOrSocketPath}`,
remoteAddr: '127.0.0.1',
remotePort,
remoteSocketPath,
localPort
});
disposables.push({
dispose: () => {
this.sshConnection?.closeTunnel(tunnelConfig.name);
this.logger.trace(`Tunnel ${tunnelConfig.name} closed`);
}
});
}
return new TunnelInfo(localPort, remotePortOrSocketPath, disposables);
}
private getSSHAuthHandler(sshUser: string, sshHostName: string, identityKeys: SSHKey[], preferredAuthentications: string[]) {
let passwordRetryCount = PASSWORD_RETRY_COUNT;
let keyboardRetryCount = PASSWORD_RETRY_COUNT;
identityKeys = identityKeys.slice();
return async (methodsLeft: string[] | null, _partialSuccess: boolean | null, callback: (nextAuth: ssh2.AuthHandlerResult) => void) => {
if (methodsLeft === null) {
this.logger.info(`Trying no-auth authentication`);
return callback({
type: 'none',
username: sshUser,
});
}
if (methodsLeft.includes('publickey') && identityKeys.length && preferredAuthentications.includes('publickey')) {
const identityKey = identityKeys.shift()!;
this.logger.info(`Trying publickey authentication: ${identityKey.filename} ${identityKey.parsedKey.type} SHA256:${identityKey.fingerprint}`);
if (identityKey.agentSupport) {
return callback({
type: 'agent',
username: sshUser,
agent: new class extends ssh2.OpenSSHAgent {
// Only return the current key
override getIdentities(callback: (err: Error | undefined, publicKeys?: ParsedKey[]) => void): void {
callback(undefined, [identityKey.parsedKey]);
}
}(this.sshAgentSock!)
});
}
if (identityKey.isPrivate) {
return callback({
type: 'publickey',
username: sshUser,
key: identityKey.parsedKey
});
}
if (!await fileExists(identityKey.filename)) {
// Try next identity file
return callback(null as any);
}
const keyBuffer = await fs.promises.readFile(identityKey.filename);
let result = ssh2.utils.parseKey(keyBuffer); // First try without passphrase
if (result instanceof Error && result.message === 'Encrypted private OpenSSH key detected, but no passphrase given') {
let passphraseRetryCount = PASSPHRASE_RETRY_COUNT;
while (result instanceof Error && passphraseRetryCount > 0) {
const passphrase = await vscode.window.showInputBox({
title: `Enter passphrase for ${identityKey.filename}`,
password: true,
ignoreFocusOut: true
});
if (!passphrase) {
break;
}
result = ssh2.utils.parseKey(keyBuffer, passphrase);
passphraseRetryCount--;
}
}
if (!result || result instanceof Error) {
// Try next identity file
return callback(null as any);
}
const key = Array.isArray(result) ? result[0] : result;
return callback({
type: 'publickey',
username: sshUser,
key
});
}
if (methodsLeft.includes('password') && passwordRetryCount > 0 && preferredAuthentications.includes('password')) {
if (passwordRetryCount === PASSWORD_RETRY_COUNT) {
this.logger.info(`Trying password authentication`);
}
const password = await vscode.window.showInputBox({
title: `Enter password for ${sshUser}@${sshHostName}`,
password: true,
ignoreFocusOut: true
});
passwordRetryCount--;
return callback(password
? {
type: 'password',
username: sshUser,
password
}
: false);
}
if (methodsLeft.includes('keyboard-interactive') && keyboardRetryCount > 0 && preferredAuthentications.includes('keyboard-interactive')) {
if (keyboardRetryCount === PASSWORD_RETRY_COUNT) {
this.logger.info(`Trying keyboard-interactive authentication`);
}
return callback({
type: 'keyboard-interactive',
username: sshUser,
prompt: async (_name, _instructions, _instructionsLang, prompts, finish) => {
const responses: string[] = [];
for (const prompt of prompts) {
const response = await vscode.window.showInputBox({
title: `(${sshUser}@${sshHostName}) ${prompt.prompt}`,
password: !prompt.echo,
ignoreFocusOut: true
});
if (response === undefined) {
keyboardRetryCount = 0;
break;
}
responses.push(response);
}
keyboardRetryCount--;
finish(responses);
}
});
}
callback(false);
};
}
dispose() {
disposeAll(this.tunnels);
// If there's proxy connections then just close the parent connection
if (this.proxyConnections.length) {
this.proxyConnections[0].close();
} else {
this.sshConnection?.close();
}
this.proxyCommandProcess?.kill();
this.labelFormatterDisposable?.dispose();
}
}
@@ -0,0 +1,68 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import * as fs from 'fs';
import { getRemoteAuthority } from './authResolver';
import { getSSHConfigPath } from './ssh/sshConfig';
import { exists as fileExists } from './common/files';
import SSHDestination from './ssh/sshDestination';
export async function promptOpenRemoteSSHWindow(reuseWindow: boolean) {
const host = await vscode.window.showInputBox({
title: 'Enter [user@]hostname[:port]'
});
if (!host) {
return;
}
const sshDest = new SSHDestination(host);
openRemoteSSHWindow(sshDest.toEncodedString(), reuseWindow);
}
export function openRemoteSSHWindow(host: string, reuseWindow: boolean) {
vscode.commands.executeCommand('vscode.newWindow', { remoteAuthority: getRemoteAuthority(host), reuseWindow });
}
export function openRemoteSSHLocationWindow(host: string, path: string, reuseWindow: boolean) {
vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.from({ scheme: 'vscode-remote', authority: getRemoteAuthority(host), path }), { forceNewWindow: !reuseWindow });
}
export async function addNewHost() {
const sshConfigPath = getSSHConfigPath();
if (!await fileExists(sshConfigPath)) {
await fs.promises.appendFile(sshConfigPath, '');
}
await vscode.commands.executeCommand('vscode.open', vscode.Uri.file(sshConfigPath), { preview: false });
const textEditor = vscode.window.activeTextEditor;
if (textEditor?.document.uri.fsPath !== sshConfigPath) {
return;
}
const textDocument = textEditor.document;
const lastLine = textDocument.lineAt(textDocument.lineCount - 1);
if (!lastLine.isEmptyOrWhitespace) {
await textEditor.edit((editBuilder: vscode.TextEditorEdit) => {
editBuilder.insert(lastLine.range.end, '\n');
});
}
const snippet = '\nHost ${1:dev}\n\tHostName ${2:dev.example.com}\n\tUser ${3:john}';
await textEditor.insertSnippet(
new vscode.SnippetString(snippet),
new vscode.Position(textDocument.lineCount, 0)
);
}
export async function openSSHConfigFile() {
const sshConfigPath = getSSHConfigPath();
if (!await fileExists(sshConfigPath)) {
await fs.promises.appendFile(sshConfigPath, '');
}
vscode.commands.executeCommand('vscode.open', vscode.Uri.file(sshConfigPath));
}
@@ -0,0 +1,41 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
export function disposeAll(disposables: vscode.Disposable[]): void {
while (disposables.length) {
const item = disposables.pop();
if (item) {
item.dispose();
}
}
}
export abstract class Disposable {
private _isDisposed = false;
protected _disposables: vscode.Disposable[] = [];
public dispose(): any {
if (this._isDisposed) {
return;
}
this._isDisposed = true;
disposeAll(this._disposables);
}
protected _register<T extends vscode.Disposable>(value: T): T {
if (this._isDisposed) {
value.dispose();
} else {
this._disposables.push(value);
}
return value;
}
protected get isDisposed(): boolean {
return this._isDisposed;
}
}
@@ -0,0 +1,25 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as fs from 'fs';
import * as os from 'os';
const homeDir = os.homedir();
export async function exists(path: string) {
try {
await fs.promises.access(path);
return true;
} catch {
return false;
}
}
export function untildify(path: string) {
return path.replace(/^~(?=$|\/|\\)/, homeDir);
}
export function normalizeToSlash(path: string) {
return path.replace(/\\/g, '/');
}
@@ -0,0 +1,63 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
type LogLevel = 'Trace' | 'Info' | 'Error';
export default class Log {
private output: vscode.OutputChannel;
constructor(name: string) {
this.output = vscode.window.createOutputChannel(name);
}
private data2String(data: any): string {
if (data instanceof Error) {
return data.stack || data.message;
}
if (data.success === false && data.message) {
return data.message;
}
return data.toString();
}
public trace(message: string, data?: any): void {
this.logLevel('Trace', message, data);
}
public info(message: string, data?: any): void {
this.logLevel('Info', message, data);
}
public error(message: string, data?: any): void {
this.logLevel('Error', message, data);
}
public logLevel(level: LogLevel, message: string, data?: any): void {
this.output.appendLine(`[${level} - ${this.now()}] ${message}`);
if (data) {
this.output.appendLine(this.data2String(data));
}
}
private now(): string {
const now = new Date();
return padLeft(now.getUTCHours() + '', 2, '0')
+ ':' + padLeft(now.getMinutes() + '', 2, '0')
+ ':' + padLeft(now.getUTCSeconds() + '', 2, '0') + '.' + now.getMilliseconds();
}
public show() {
this.output.show();
}
public dispose() {
this.output.dispose();
}
}
function padLeft(s: string, n: number, pad = ' ') {
return pad.repeat(Math.max(0, n - s.length)) + s;
}
@@ -0,0 +1,7 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export const isWindows = process.platform === 'win32';
export const isMacintosh = process.platform === 'darwin';
export const isLinux = process.platform === 'linux';
@@ -0,0 +1,133 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as net from 'net';
/**
* Finds a random unused port assigned by the operating system. Will reject in case no free port can be found.
*/
export function findRandomPort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer({ pauseOnConnect: true });
server.on('error', reject);
server.on('listening', () => {
const port = (server.address() as net.AddressInfo).port;
server.close(() => resolve(port));
});
server.listen(0, '127.0.0.1');
});
}
/**
* Given a start point and a max number of retries, will find a port that
* is openable. Will return 0 in case no free port can be found.
*/
export function findFreePort(startPort: number, giveUpAfter: number, timeout: number, stride = 1): Promise<number> {
let done = false;
return new Promise(resolve => {
const timeoutHandle = setTimeout(() => {
if (!done) {
done = true;
return resolve(0);
}
}, timeout);
doFindFreePort(startPort, giveUpAfter, stride, (port) => {
if (!done) {
done = true;
clearTimeout(timeoutHandle);
return resolve(port);
}
});
});
}
function doFindFreePort(startPort: number, giveUpAfter: number, stride: number, clb: (port: number) => void): void {
if (giveUpAfter === 0) {
return clb(0);
}
const client = new net.Socket();
// If we can connect to the port it means the port is already taken so we continue searching
client.once('connect', () => {
dispose(client);
return doFindFreePort(startPort + stride, giveUpAfter - 1, stride, clb);
});
client.once('data', () => {
// this listener is required since node.js 8.x
});
client.once('error', (err: Error & { code?: string }) => {
dispose(client);
// If we receive any non ECONNREFUSED error, it means the port is used but we cannot connect
if (err.code !== 'ECONNREFUSED') {
return doFindFreePort(startPort + stride, giveUpAfter - 1, stride, clb);
}
// Otherwise it means the port is free to use!
return clb(startPort);
});
client.connect(startPort, '127.0.0.1');
}
/**
* Uses listen instead of connect. Is faster, but if there is another listener on 0.0.0.0 then this will take 127.0.0.1 from that listener.
*/
export function findFreePortFaster(startPort: number, giveUpAfter: number, timeout: number): Promise<number> {
let resolved = false;
let timeoutHandle: NodeJS.Timeout | undefined = undefined;
let countTried = 1;
const server = net.createServer({ pauseOnConnect: true });
function doResolve(port: number, resolve: (port: number) => void) {
if (!resolved) {
resolved = true;
server.removeAllListeners();
server.close();
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
resolve(port);
}
}
return new Promise<number>(resolve => {
timeoutHandle = setTimeout(() => {
doResolve(0, resolve);
}, timeout);
server.on('listening', () => {
doResolve(startPort, resolve);
});
server.on('error', err => {
if (err && ((<any>err).code === 'EADDRINUSE' || (<any>err).code === 'EACCES') && (countTried < giveUpAfter)) {
startPort++;
countTried++;
server.listen(startPort, '127.0.0.1');
} else {
doResolve(0, resolve);
}
});
server.on('close', () => {
doResolve(0, resolve);
});
server.listen(startPort, '127.0.0.1');
});
}
function dispose(socket: net.Socket): void {
try {
socket.removeAllListeners('connect');
socket.removeAllListeners('error');
socket.end();
socket.destroy();
socket.unref();
} catch (error) {
console.error(error); // otherwise this error would get lost in the callback chain
}
}
@@ -0,0 +1,37 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import Log from './common/logger';
import { RemoteSSHResolver, REMOTE_SSH_AUTHORITY } from './authResolver';
import { openSSHConfigFile, promptOpenRemoteSSHWindow } from './commands';
import { HostTreeDataProvider } from './hostTreeView';
import { getRemoteWorkspaceLocationData, RemoteLocationHistory } from './remoteLocationHistory';
export async function activate(context: vscode.ExtensionContext) {
const logger = new Log('Remote - SSH');
context.subscriptions.push(logger);
const remoteSSHResolver = new RemoteSSHResolver(context, logger);
context.subscriptions.push(vscode.workspace.registerRemoteAuthorityResolver(REMOTE_SSH_AUTHORITY, remoteSSHResolver));
context.subscriptions.push(remoteSSHResolver);
const locationHistory = new RemoteLocationHistory(context);
const locationData = getRemoteWorkspaceLocationData();
if (locationData) {
await locationHistory.addLocation(locationData[0], locationData[1]);
}
const hostTreeDataProvider = new HostTreeDataProvider(locationHistory);
context.subscriptions.push(vscode.window.createTreeView('sshHosts', { treeDataProvider: hostTreeDataProvider }));
context.subscriptions.push(hostTreeDataProvider);
context.subscriptions.push(vscode.commands.registerCommand('openremotessh.openEmptyWindow', () => promptOpenRemoteSSHWindow(false)));
context.subscriptions.push(vscode.commands.registerCommand('openremotessh.openEmptyWindowInCurrentWindow', () => promptOpenRemoteSSHWindow(true)));
context.subscriptions.push(vscode.commands.registerCommand('openremotessh.openConfigFile', () => openSSHConfigFile()));
context.subscriptions.push(vscode.commands.registerCommand('openremotessh.showLog', () => logger.show()));
}
export function deactivate() {
}
@@ -0,0 +1,109 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import * as path from 'path';
import SSHConfiguration, { getSSHConfigPath } from './ssh/sshConfig';
import { RemoteLocationHistory } from './remoteLocationHistory';
import { Disposable } from './common/disposable';
import { addNewHost, openRemoteSSHLocationWindow, openRemoteSSHWindow, openSSHConfigFile } from './commands';
import SSHDestination from './ssh/sshDestination';
class HostItem {
constructor(
public hostname: string,
public locations: string[]
) {
}
}
class HostLocationItem {
constructor(
public path: string,
public hostname: string
) {
}
}
type DataTreeItem = HostItem | HostLocationItem;
export class HostTreeDataProvider extends Disposable implements vscode.TreeDataProvider<DataTreeItem> {
private readonly _onDidChangeTreeData = this._register(new vscode.EventEmitter<DataTreeItem | DataTreeItem[] | void>());
public readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
constructor(
private locationHistory: RemoteLocationHistory
) {
super();
this._register(vscode.commands.registerCommand('openremotessh.explorer.add', () => addNewHost()));
this._register(vscode.commands.registerCommand('openremotessh.explorer.configure', () => openSSHConfigFile()));
this._register(vscode.commands.registerCommand('openremotessh.explorer.refresh', () => this.refresh()));
this._register(vscode.commands.registerCommand('openremotessh.explorer.emptyWindowInNewWindow', e => this.openRemoteSSHWindow(e, false)));
this._register(vscode.commands.registerCommand('openremotessh.explorer.emptyWindowInCurrentWindow', e => this.openRemoteSSHWindow(e, true)));
this._register(vscode.commands.registerCommand('openremotessh.explorer.reopenFolderInNewWindow', e => this.openRemoteSSHLocationWindow(e, false)));
this._register(vscode.commands.registerCommand('openremotessh.explorer.reopenFolderInCurrentWindow', e => this.openRemoteSSHLocationWindow(e, true)));
this._register(vscode.commands.registerCommand('openremotessh.explorer.deleteFolderHistoryItem', e => this.deleteHostLocation(e)));
this._register(vscode.workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration('remote.SSH.configFile')) {
this.refresh();
}
}));
this._register(vscode.workspace.onDidSaveTextDocument(e => {
if (e.uri.fsPath === getSSHConfigPath()) {
this.refresh();
}
}));
}
getTreeItem(element: DataTreeItem): vscode.TreeItem {
if (element instanceof HostLocationItem) {
const label = path.posix.basename(element.path).replace(/\.code-workspace$/, ' (Workspace)');
const treeItem = new vscode.TreeItem(label);
treeItem.description = path.posix.dirname(element.path);
treeItem.iconPath = new vscode.ThemeIcon('folder');
treeItem.contextValue = 'openremotessh.explorer.folder';
return treeItem;
}
const treeItem = new vscode.TreeItem(element.hostname);
treeItem.collapsibleState = element.locations.length ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None;
treeItem.iconPath = new vscode.ThemeIcon('vm');
treeItem.contextValue = 'openremotessh.explorer.host';
return treeItem;
}
async getChildren(element?: HostItem): Promise<DataTreeItem[]> {
if (!element) {
const sshConfigFile = await SSHConfiguration.loadFromFS();
const hosts = sshConfigFile.getAllConfiguredHosts();
return hosts.map(hostname => new HostItem(hostname, this.locationHistory.getHistory(hostname)));
}
if (element instanceof HostItem) {
return element.locations.map(location => new HostLocationItem(location, element.hostname));
}
return [];
}
private refresh() {
this._onDidChangeTreeData.fire();
}
private async deleteHostLocation(element: HostLocationItem) {
await this.locationHistory.removeLocation(element.hostname, element.path);
this.refresh();
}
private async openRemoteSSHWindow(element: HostItem, reuseWindow: boolean) {
const sshDest = new SSHDestination(element.hostname);
openRemoteSSHWindow(sshDest.toEncodedString(), reuseWindow);
}
private async openRemoteSSHLocationWindow(element: HostLocationItem, reuseWindow: boolean) {
const sshDest = new SSHDestination(element.hostname);
openRemoteSSHLocationWindow(sshDest.toEncodedString(), element.path, reuseWindow);
}
}
@@ -0,0 +1,58 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { REMOTE_SSH_AUTHORITY } from './authResolver';
import SSHDestination from './ssh/sshDestination';
export class RemoteLocationHistory {
private static STORAGE_KEY = 'remoteLocationHistory_v0';
private remoteLocationHistory: Record<string, string[]> = {};
constructor(private context: vscode.ExtensionContext) {
// context.globalState.update(RemoteLocationHistory.STORAGE_KEY, undefined);
this.remoteLocationHistory = context.globalState.get(RemoteLocationHistory.STORAGE_KEY) || {};
}
getHistory(host: string): string[] {
return this.remoteLocationHistory[host] || [];
}
async addLocation(host: string, path: string) {
const hostLocations = this.remoteLocationHistory[host] || [];
if (!hostLocations.includes(path)) {
hostLocations.unshift(path);
this.remoteLocationHistory[host] = hostLocations;
await this.context.globalState.update(RemoteLocationHistory.STORAGE_KEY, this.remoteLocationHistory);
}
}
async removeLocation(host: string, path: string) {
let hostLocations = this.remoteLocationHistory[host] || [];
hostLocations = hostLocations.filter(l => l !== path);
this.remoteLocationHistory[host] = hostLocations;
await this.context.globalState.update(RemoteLocationHistory.STORAGE_KEY, this.remoteLocationHistory);
}
}
export function getRemoteWorkspaceLocationData(): [string, string] | undefined {
let location = vscode.workspace.workspaceFile;
if (location && location.scheme === 'vscode-remote' && location.authority.startsWith(REMOTE_SSH_AUTHORITY) && location.path.endsWith('.code-workspace')) {
const [, host] = location.authority.split('+');
const sshDest = SSHDestination.parseEncoded(host);
return [sshDest.hostname, location.path];
}
location = vscode.workspace.workspaceFolders?.[0].uri;
if (location && location.scheme === 'vscode-remote' && location.authority.startsWith(REMOTE_SSH_AUTHORITY)) {
const [, host] = location.authority.split('+');
const sshDest = SSHDestination.parseEncoded(host);
return [sshDest.hostname, location.path];
}
return undefined;
}
@@ -0,0 +1,43 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
let vscodeProductJson: any;
async function getVSCodeProductJson() {
if (!vscodeProductJson) {
const productJsonStr = await fs.promises.readFile(path.join(vscode.env.appRoot, 'product.json'), 'utf8');
vscodeProductJson = JSON.parse(productJsonStr);
}
return vscodeProductJson;
}
export interface IServerConfig {
version: string;
commit: string;
quality: string;
release?: string; // void-like specific
serverApplicationName: string;
serverDataFolderName: string;
serverDownloadUrlTemplate?: string; // void-like specific
}
export async function getVSCodeServerConfig(): Promise<IServerConfig> {
const productJson = await getVSCodeProductJson();
const customServerBinaryName = vscode.workspace.getConfiguration('remote.SSH.experimental').get<string>('serverBinaryName', '');
return {
version: vscode.version.replace('-insider', ''),
commit: productJson.commit,
quality: productJson.quality,
release: productJson.release,
serverApplicationName: customServerBinaryName || productJson.serverApplicationName,
serverDataFolderName: productJson.serverDataFolderName,
serverDownloadUrlTemplate: productJson.serverDownloadUrlTemplate
};
}
@@ -0,0 +1,626 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as crypto from 'crypto';
import Log from './common/logger';
import { getVSCodeServerConfig } from './serverConfig';
import SSHConnection from './ssh/sshConnection';
export interface ServerInstallOptions {
id: string;
quality: string;
commit: string;
version: string;
release?: string; // void specific
extensionIds: string[];
envVariables: string[];
useSocketPath: boolean;
serverApplicationName: string;
serverDataFolderName: string;
serverDownloadUrlTemplate: string;
}
export interface ServerInstallResult {
exitCode: number;
listeningOn: number | string;
connectionToken: string;
logFile: string;
osReleaseId: string;
arch: string;
platform: string;
tmpDir: string;
[key: string]: any;
}
export class ServerInstallError extends Error {
constructor(message: string) {
super(message);
}
}
const DEFAULT_DOWNLOAD_URL_TEMPLATE = 'https://github.com/voideditor/void-updates-server/releases/download/test/void-server-${os}-${arch}.tar.gz';
export async function installCodeServer(conn: SSHConnection, serverDownloadUrlTemplate: string | undefined, extensionIds: string[], envVariables: string[], platform: string | undefined, useSocketPath: boolean, logger: Log): Promise<ServerInstallResult> {
let shell = 'powershell';
// detect platform and shell for windows
if (!platform || platform === 'windows') {
const result = await conn.exec('uname -s');
if (result.stdout) {
if (result.stdout.includes('windows32')) {
platform = 'windows';
} else if (result.stdout.includes('MINGW64')) {
platform = 'windows';
shell = 'bash';
}
} else if (result.stderr) {
if (result.stderr.includes('FullyQualifiedErrorId : CommandNotFoundException')) {
platform = 'windows';
}
if (result.stderr.includes('is not recognized as an internal or external command')) {
platform = 'windows';
shell = 'cmd';
}
}
if (platform) {
logger.trace(`Detected platform: ${platform}, ${shell}`);
}
}
const scriptId = crypto.randomBytes(12).toString('hex');
const vscodeServerConfig = await getVSCodeServerConfig();
const installOptions: ServerInstallOptions = {
id: scriptId,
version: vscodeServerConfig.version,
commit: vscodeServerConfig.commit,
quality: vscodeServerConfig.quality,
release: vscodeServerConfig.release,
extensionIds,
envVariables,
useSocketPath,
serverApplicationName: vscodeServerConfig.serverApplicationName,
serverDataFolderName: vscodeServerConfig.serverDataFolderName,
serverDownloadUrlTemplate: serverDownloadUrlTemplate ?? vscodeServerConfig.serverDownloadUrlTemplate ?? DEFAULT_DOWNLOAD_URL_TEMPLATE,
};
let commandOutput: { stdout: string; stderr: string };
if (platform === 'windows') {
const installServerScript = generatePowerShellInstallScript(installOptions);
logger.trace('Server install command:', installServerScript);
const installDir = `$HOME\\${vscodeServerConfig.serverDataFolderName}\\install`;
const installScript = `${installDir}\\${vscodeServerConfig.commit}.ps1`;
const endRegex = new RegExp(`${scriptId}: end`);
// investigate if it's possible to use `-EncodedCommand` flag
// https://devblogs.microsoft.com/powershell/invoking-powershell-with-complex-expressions-using-scriptblocks/
let command = '';
if (shell === 'powershell') {
command = `md -Force ${installDir}; echo @'\n${installServerScript}\n'@ | Set-Content ${installScript}; powershell -ExecutionPolicy ByPass -File "${installScript}"`;
} else if (shell === 'bash') {
command = `mkdir -p ${installDir.replace(/\\/g, '/')} && echo '\n${installServerScript.replace(/'/g, '\'"\'"\'')}\n' > ${installScript.replace(/\\/g, '/')} && powershell -ExecutionPolicy ByPass -File "${installScript}"`;
} else if (shell === 'cmd') {
const script = installServerScript.trim()
// remove comments
.replace(/^#.*$/gm, '')
// remove empty lines
.replace(/\n{2,}/gm, '\n')
// remove leading spaces
.replace(/^\s*/gm, '')
// escape double quotes (from powershell/cmd)
.replace(/"/g, '"""')
// escape single quotes (from cmd)
.replace(/'/g, `''`)
// escape redirect (from cmd)
.replace(/>/g, `^>`)
// escape new lines (from powershell/cmd)
.replace(/\n/g, '\'`n\'');
command = `powershell "md -Force ${installDir}" && powershell "echo '${script}'" > ${installScript.replace('$HOME', '%USERPROFILE%')} && powershell -ExecutionPolicy ByPass -File "${installScript.replace('$HOME', '%USERPROFILE%')}"`;
logger.trace('Command length (8191 max):', command.length);
if (command.length > 8191) {
throw new ServerInstallError(`Command line too long`);
}
} else {
throw new ServerInstallError(`Not supported shell: ${shell}`);
}
commandOutput = await conn.execPartial(command, (stdout: string) => endRegex.test(stdout));
} else {
const installServerScript = generateBashInstallScript(installOptions);
logger.trace('Server install command:', installServerScript);
// Fish shell does not support heredoc so let's workaround it using -c option,
// also replace single quotes (') within the script with ('\'') as there's no quoting within single quotes, see https://unix.stackexchange.com/a/24676
commandOutput = await conn.exec(`bash -c '${installServerScript.replace(/'/g, `'\\''`)}'`);
}
if (commandOutput.stderr) {
logger.trace('Server install command stderr:', commandOutput.stderr);
}
logger.trace('Server install command stdout:', commandOutput.stdout);
const resultMap = parseServerInstallOutput(commandOutput.stdout, scriptId);
if (!resultMap) {
throw new ServerInstallError(`Failed parsing install script output`);
}
const exitCode = parseInt(resultMap.exitCode, 10);
if (exitCode !== 0) {
throw new ServerInstallError(`Couldn't install vscode server on remote server, install script returned non-zero exit status`);
}
const listeningOn = resultMap.listeningOn.match(/^\d+$/)
? parseInt(resultMap.listeningOn, 10)
: resultMap.listeningOn;
const remoteEnvVars = Object.fromEntries(Object.entries(resultMap).filter(([key,]) => envVariables.includes(key)));
return {
exitCode,
listeningOn,
connectionToken: resultMap.connectionToken,
logFile: resultMap.logFile,
osReleaseId: resultMap.osReleaseId,
arch: resultMap.arch,
platform: resultMap.platform,
tmpDir: resultMap.tmpDir,
...remoteEnvVars
};
}
function parseServerInstallOutput(str: string, scriptId: string): { [k: string]: string } | undefined {
const startResultStr = `${scriptId}: start`;
const endResultStr = `${scriptId}: end`;
const startResultIdx = str.indexOf(startResultStr);
if (startResultIdx < 0) {
return undefined;
}
const endResultIdx = str.indexOf(endResultStr, startResultIdx + startResultStr.length);
if (endResultIdx < 0) {
return undefined;
}
const installResult = str.substring(startResultIdx + startResultStr.length, endResultIdx);
const resultMap: { [k: string]: string } = {};
const resultArr = installResult.split(/\r?\n/);
for (const line of resultArr) {
const [key, value] = line.split('==');
resultMap[key] = value;
}
return resultMap;
}
function generateBashInstallScript({ id, quality, version, commit, release, extensionIds, envVariables, useSocketPath, serverApplicationName, serverDataFolderName, serverDownloadUrlTemplate }: ServerInstallOptions) {
const extensions = extensionIds.map(id => '--install-extension ' + id).join(' ');
return `
# Server installation script
TMP_DIR="\${XDG_RUNTIME_DIR:-"/tmp"}"
DISTRO_VERSION="${version}"
DISTRO_COMMIT="${commit}"
DISTRO_QUALITY="${quality}"
DISTRO_VOID_RELEASE="${release ?? ''}"
SERVER_APP_NAME="${serverApplicationName}"
SERVER_INITIAL_EXTENSIONS="${extensions}"
SERVER_LISTEN_FLAG="${useSocketPath ? `--socket-path="$TMP_DIR/vscode-server-sock-${crypto.randomUUID()}"` : '--port=0'}"
SERVER_DATA_DIR="$HOME/${serverDataFolderName}"
SERVER_DIR="$SERVER_DATA_DIR/bin/$DISTRO_COMMIT"
SERVER_SCRIPT="$SERVER_DIR/bin/$SERVER_APP_NAME"
SERVER_LOGFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.log"
SERVER_PIDFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.pid"
SERVER_TOKENFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.token"
SERVER_ARCH=
SERVER_CONNECTION_TOKEN=
SERVER_DOWNLOAD_URL=
LISTENING_ON=
OS_RELEASE_ID=
ARCH=
PLATFORM=
# Mimic output from logs of remote-ssh extension
print_install_results_and_exit() {
echo "${id}: start"
echo "exitCode==$1=="
echo "listeningOn==$LISTENING_ON=="
echo "connectionToken==$SERVER_CONNECTION_TOKEN=="
echo "logFile==$SERVER_LOGFILE=="
echo "osReleaseId==$OS_RELEASE_ID=="
echo "arch==$ARCH=="
echo "platform==$PLATFORM=="
echo "tmpDir==$TMP_DIR=="
${envVariables.map(envVar => `echo "${envVar}==$${envVar}=="`).join('\n')}
echo "${id}: end"
exit 0
}
# Check if platform is supported
KERNEL="$(uname -s)"
case $KERNEL in
Darwin)
PLATFORM="darwin"
;;
Linux)
PLATFORM="linux"
;;
FreeBSD)
PLATFORM="freebsd"
;;
DragonFly)
PLATFORM="dragonfly"
;;
*)
echo "Error platform not supported: $KERNEL"
print_install_results_and_exit 1
;;
esac
# Check machine architecture
ARCH="$(uname -m)"
case $ARCH in
x86_64 | amd64)
SERVER_ARCH="x64"
;;
armv7l | armv8l)
SERVER_ARCH="armhf"
;;
arm64 | aarch64)
SERVER_ARCH="arm64"
;;
ppc64le)
SERVER_ARCH="ppc64le"
;;
riscv64)
SERVER_ARCH="riscv64"
;;
loongarch64)
SERVER_ARCH="loong64"
;;
s390x)
SERVER_ARCH="s390x"
;;
*)
echo "Error architecture not supported: $ARCH"
print_install_results_and_exit 1
;;
esac
# https://www.freedesktop.org/software/systemd/man/os-release.html
OS_RELEASE_ID="$(grep -i '^ID=' /etc/os-release 2>/dev/null | sed 's/^ID=//gi' | sed 's/"//g')"
if [[ -z $OS_RELEASE_ID ]]; then
OS_RELEASE_ID="$(grep -i '^ID=' /usr/lib/os-release 2>/dev/null | sed 's/^ID=//gi' | sed 's/"//g')"
if [[ -z $OS_RELEASE_ID ]]; then
OS_RELEASE_ID="unknown"
fi
fi
# Create installation folder
if [[ ! -d $SERVER_DIR ]]; then
mkdir -p $SERVER_DIR
if (( $? > 0 )); then
echo "Error creating server install directory"
print_install_results_and_exit 1
fi
fi
# adjust platform for void download, if needed
if [[ $OS_RELEASE_ID = alpine ]]; then
PLATFORM=$OS_RELEASE_ID
fi
SERVER_DOWNLOAD_URL="$(echo "${serverDownloadUrlTemplate.replace(/\$\{/g, '\\${')}" | sed "s/\\\${quality}/$DISTRO_QUALITY/g" | sed "s/\\\${version}/$DISTRO_VERSION/g" | sed "s/\\\${commit}/$DISTRO_COMMIT/g" | sed "s/\\\${os}/$PLATFORM/g" | sed "s/\\\${arch}/$SERVER_ARCH/g" | sed "s/\\\${release}/$DISTRO_VOID_RELEASE/g")"
# Check if server script is already installed
if [[ ! -f $SERVER_SCRIPT ]]; then
case "$PLATFORM" in
darwin | linux | alpine )
;;
*)
echo "Error '$PLATFORM' needs manual installation of remote extension host"
print_install_results_and_exit 1
;;
esac
pushd $SERVER_DIR > /dev/null
if [[ ! -z $(which wget) ]]; then
wget --tries=3 --timeout=10 --continue --no-verbose -O vscode-server.tar.gz $SERVER_DOWNLOAD_URL
elif [[ ! -z $(which curl) ]]; then
curl --retry 3 --connect-timeout 10 --location --show-error --silent --output vscode-server.tar.gz $SERVER_DOWNLOAD_URL
else
echo "Error no tool to download server binary"
print_install_results_and_exit 1
fi
if (( $? > 0 )); then
echo "Error downloading server from $SERVER_DOWNLOAD_URL"
print_install_results_and_exit 1
fi
tar -xf vscode-server.tar.gz --strip-components 1
if (( $? > 0 )); then
echo "Error while extracting server contents"
print_install_results_and_exit 1
fi
if [[ ! -f $SERVER_SCRIPT ]]; then
echo "Error server contents are corrupted"
print_install_results_and_exit 1
fi
rm -f vscode-server.tar.gz
popd > /dev/null
else
echo "Server script already installed in $SERVER_SCRIPT"
fi
# Try to find if server is already running
if [[ -f $SERVER_PIDFILE ]]; then
SERVER_PID="$(cat $SERVER_PIDFILE)"
SERVER_RUNNING_PROCESS="$(ps -o pid,args -p $SERVER_PID | grep $SERVER_SCRIPT)"
else
SERVER_RUNNING_PROCESS="$(ps -o pid,args -A | grep $SERVER_SCRIPT | grep -v grep)"
fi
if [[ -z $SERVER_RUNNING_PROCESS ]]; then
if [[ -f $SERVER_LOGFILE ]]; then
rm $SERVER_LOGFILE
fi
if [[ -f $SERVER_TOKENFILE ]]; then
rm $SERVER_TOKENFILE
fi
touch $SERVER_TOKENFILE
chmod 600 $SERVER_TOKENFILE
SERVER_CONNECTION_TOKEN="${crypto.randomUUID()}"
echo $SERVER_CONNECTION_TOKEN > $SERVER_TOKENFILE
$SERVER_SCRIPT --start-server --host=127.0.0.1 $SERVER_LISTEN_FLAG $SERVER_INITIAL_EXTENSIONS --connection-token-file $SERVER_TOKENFILE --telemetry-level off --enable-remote-auto-shutdown --accept-server-license-terms &> $SERVER_LOGFILE &
echo $! > $SERVER_PIDFILE
else
echo "Server script is already running $SERVER_SCRIPT"
fi
if [[ -f $SERVER_TOKENFILE ]]; then
SERVER_CONNECTION_TOKEN="$(cat $SERVER_TOKENFILE)"
else
echo "Error server token file not found $SERVER_TOKENFILE"
print_install_results_and_exit 1
fi
if [[ -f $SERVER_LOGFILE ]]; then
for i in {1..5}; do
LISTENING_ON="$(cat $SERVER_LOGFILE | grep -E 'Extension host agent listening on .+' | sed 's/Extension host agent listening on //')"
if [[ -n $LISTENING_ON ]]; then
break
fi
sleep 0.5
done
if [[ -z $LISTENING_ON ]]; then
echo "Error server did not start successfully"
print_install_results_and_exit 1
fi
else
echo "Error server log file not found $SERVER_LOGFILE"
print_install_results_and_exit 1
fi
# Finish server setup
print_install_results_and_exit 0
`;
}
function generatePowerShellInstallScript({ id, quality, version, commit, release, extensionIds, envVariables, useSocketPath, serverApplicationName, serverDataFolderName, serverDownloadUrlTemplate }: ServerInstallOptions) {
const extensions = extensionIds.map(id => '--install-extension ' + id).join(' ');
const downloadUrl = serverDownloadUrlTemplate
.replace(/\$\{quality\}/g, quality)
.replace(/\$\{version\}/g, version)
.replace(/\$\{commit\}/g, commit)
.replace(/\$\{os\}/g, 'win32')
.replace(/\$\{arch\}/g, 'x64')
.replace(/\$\{release\}/g, release ?? '');
return `
# Server installation script
$TMP_DIR="$env:TEMP\\$([System.IO.Path]::GetRandomFileName())"
$ProgressPreference = "SilentlyContinue"
$DISTRO_VERSION="${version}"
$DISTRO_COMMIT="${commit}"
$DISTRO_QUALITY="${quality}"
$DISTRO_VOID_RELEASE="${release ?? ''}"
$SERVER_APP_NAME="${serverApplicationName}"
$SERVER_INITIAL_EXTENSIONS="${extensions}"
$SERVER_LISTEN_FLAG="${useSocketPath ? `--socket-path="$TMP_DIR/vscode-server-sock-${crypto.randomUUID()}"` : '--port=0'}"
$SERVER_DATA_DIR="$(Resolve-Path ~)\\${serverDataFolderName}"
$SERVER_DIR="$SERVER_DATA_DIR\\bin\\$DISTRO_COMMIT"
$SERVER_SCRIPT="$SERVER_DIR\\bin\\$SERVER_APP_NAME.cmd"
$SERVER_LOGFILE="$SERVER_DATA_DIR\\.$DISTRO_COMMIT.log"
$SERVER_PIDFILE="$SERVER_DATA_DIR\\.$DISTRO_COMMIT.pid"
$SERVER_TOKENFILE="$SERVER_DATA_DIR\\.$DISTRO_COMMIT.token"
$SERVER_ARCH=
$SERVER_CONNECTION_TOKEN=
$SERVER_DOWNLOAD_URL=
$LISTENING_ON=
$OS_RELEASE_ID=
$ARCH=
$PLATFORM="win32"
function printInstallResults($code) {
"${id}: start"
"exitCode==$code=="
"listeningOn==$LISTENING_ON=="
"connectionToken==$SERVER_CONNECTION_TOKEN=="
"logFile==$SERVER_LOGFILE=="
"osReleaseId==$OS_RELEASE_ID=="
"arch==$ARCH=="
"platform==$PLATFORM=="
"tmpDir==$TMP_DIR=="
${envVariables.map(envVar => `"${envVar}==$${envVar}=="`).join('\n')}
"${id}: end"
}
# Check machine architecture
$ARCH=$env:PROCESSOR_ARCHITECTURE
# Use x64 version for ARM64, as it's not yet available.
if(($ARCH -eq "AMD64") -or ($ARCH -eq "IA64") -or ($ARCH -eq "ARM64")) {
$SERVER_ARCH="x64"
}
else {
"Error architecture not supported: $ARCH"
printInstallResults 1
exit 0
}
# Create installation folder
if(!(Test-Path $SERVER_DIR)) {
try {
ni -it d $SERVER_DIR -f -ea si
} catch {
"Error creating server install directory - $($_.ToString())"
exit 1
}
if(!(Test-Path $SERVER_DIR)) {
"Error creating server install directory"
exit 1
}
}
cd $SERVER_DIR
# Check if server script is already installed
if(!(Test-Path $SERVER_SCRIPT)) {
del vscode-server.tar.gz
$REQUEST_ARGUMENTS = @{
Uri="${downloadUrl}"
TimeoutSec=20
OutFile="vscode-server.tar.gz"
UseBasicParsing=$True
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-RestMethod @REQUEST_ARGUMENTS
if(Test-Path "vscode-server.tar.gz") {
tar -xf vscode-server.tar.gz --strip-components 1
del vscode-server.tar.gz
}
if(!(Test-Path $SERVER_SCRIPT)) {
"Error while installing the server binary"
exit 1
}
}
else {
"Server script already installed in $SERVER_SCRIPT"
}
# Try to find if server is already running
if(Get-Process node -ErrorAction SilentlyContinue | Where-Object Path -Like "$SERVER_DIR\\*") {
echo "Server script is already running $SERVER_SCRIPT"
}
else {
if(Test-Path $SERVER_LOGFILE) {
del $SERVER_LOGFILE
}
if(Test-Path $SERVER_PIDFILE) {
del $SERVER_PIDFILE
}
if(Test-Path $SERVER_TOKENFILE) {
del $SERVER_TOKENFILE
}
$SERVER_CONNECTION_TOKEN="${crypto.randomUUID()}"
[System.IO.File]::WriteAllLines($SERVER_TOKENFILE, $SERVER_CONNECTION_TOKEN)
$SCRIPT_ARGUMENTS="--start-server --host=127.0.0.1 $SERVER_LISTEN_FLAG $SERVER_INITIAL_EXTENSIONS --connection-token-file $SERVER_TOKENFILE --telemetry-level off --enable-remote-auto-shutdown --accept-server-license-terms *> '$SERVER_LOGFILE'"
$START_ARGUMENTS = @{
FilePath = "powershell.exe"
WindowStyle = "hidden"
ArgumentList = @(
"-ExecutionPolicy", "Unrestricted", "-NoLogo", "-NoProfile", "-NonInteractive", "-c", "$SERVER_SCRIPT $SCRIPT_ARGUMENTS"
)
PassThru = $True
}
$SERVER_ID = (start @START_ARGUMENTS).ID
if($SERVER_ID) {
[System.IO.File]::WriteAllLines($SERVER_PIDFILE, $SERVER_ID)
}
}
if(Test-Path $SERVER_TOKENFILE) {
$SERVER_CONNECTION_TOKEN="$(cat $SERVER_TOKENFILE)"
}
else {
"Error server token file not found $SERVER_TOKENFILE"
printInstallResults 1
exit 0
}
sleep -Milliseconds 500
$SELECT_ARGUMENTS = @{
Path = $SERVER_LOGFILE
Pattern = "Extension host agent listening on (\\d+)"
}
for($I = 1; $I -le 5; $I++) {
if(Test-Path $SERVER_LOGFILE) {
$GROUPS = (Select-String @SELECT_ARGUMENTS).Matches.Groups
if($GROUPS) {
$LISTENING_ON = $GROUPS[1].Value
break
}
}
sleep -Milliseconds 500
}
if(!(Test-Path $SERVER_LOGFILE)) {
"Error server log file not found $SERVER_LOGFILE"
printInstallResults 1
exit 0
}
# Finish server setup
printInstallResults 0
if($SERVER_ID) {
while($True) {
if(!(gps -Id $SERVER_ID)) {
"server died, exit"
exit 0
}
sleep 30
}
}
`;
}
@@ -0,0 +1,46 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { exists as folderExists } from '../common/files';
const PATH_SSH_USER_DIR = path.join(os.homedir(), '.ssh');
const KNOW_HOST_FILE = path.join(PATH_SSH_USER_DIR, 'known_hosts');
const HASH_MAGIC = '|1|';
const HASH_DELIM = '|';
export async function checkNewHostInHostkeys(host: string): Promise<boolean> {
const fileContent = await fs.promises.readFile(KNOW_HOST_FILE, { encoding: 'utf8' });
const lines = fileContent.split(/\r?\n/);
for (let line of lines) {
line = line.trim();
if (!line.startsWith(HASH_MAGIC)) {
continue;
}
const [hostEncripted_] = line.split(' ');
const [salt_, hostHash_] = hostEncripted_.substring(HASH_MAGIC.length).split(HASH_DELIM);
const hostHash = crypto.createHmac('sha1', Buffer.from(salt_, 'base64')).update(host).digest();
if (hostHash.toString('base64') === hostHash_) {
return false;
}
}
return true;
}
export async function addHostToHostFile(host: string, hostKey: Buffer, type: string): Promise<void> {
if (!folderExists(PATH_SSH_USER_DIR)) {
await fs.promises.mkdir(PATH_SSH_USER_DIR, 0o700);
}
const salt = crypto.randomBytes(20);
const hostHash = crypto.createHmac('sha1', salt).update(host).digest();
const entry = `${HASH_MAGIC}${salt.toString('base64')}${HASH_DELIM}${hostHash.toString('base64')} ${type} ${hostKey.toString('base64')}\n`;
await fs.promises.appendFile(KNOW_HOST_FILE, entry);
}
@@ -0,0 +1,120 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as crypto from 'crypto';
import type { ParsedKey } from 'ssh2-streams';
import * as ssh2 from 'ssh2';
import { untildify, exists as fileExists } from '../common/files';
import Log from '../common/logger';
const homeDir = os.homedir();
const PATH_SSH_CLIENT_ID_DSA = path.join(homeDir, '.ssh', '/id_dsa');
const PATH_SSH_CLIENT_ID_ECDSA = path.join(homeDir, '.ssh', '/id_ecdsa');
const PATH_SSH_CLIENT_ID_RSA = path.join(homeDir, '.ssh', '/id_rsa');
const PATH_SSH_CLIENT_ID_ED25519 = path.join(homeDir, '.ssh', '/id_ed25519');
const PATH_SSH_CLIENT_ID_XMSS = path.join(homeDir, '.ssh', '/id_xmss');
const PATH_SSH_CLIENT_ID_ECDSA_SK = path.join(homeDir, '.ssh', '/id_ecdsa_sk');
const PATH_SSH_CLIENT_ID_ED25519_SK = path.join(homeDir, '.ssh', '/id_ed25519_sk');
const DEFAULT_IDENTITY_FILES: string[] = [
PATH_SSH_CLIENT_ID_RSA,
PATH_SSH_CLIENT_ID_ECDSA,
PATH_SSH_CLIENT_ID_ECDSA_SK,
PATH_SSH_CLIENT_ID_ED25519,
PATH_SSH_CLIENT_ID_ED25519_SK,
PATH_SSH_CLIENT_ID_XMSS,
PATH_SSH_CLIENT_ID_DSA,
];
export interface SSHKey {
filename: string;
parsedKey: ParsedKey;
fingerprint: string;
agentSupport?: boolean;
isPrivate?: boolean;
}
// From https://github.com/openssh/openssh-portable/blob/acb2059febaddd71ee06c2ebf63dcf211d9ab9f2/sshconnect2.c#L1689-L1690
export async function gatherIdentityFiles(identityFiles: string[], sshAgentSock: string | undefined, identitiesOnly: boolean, logger: Log) {
identityFiles = identityFiles.map(untildify).map(i => i.replace(/\.pub$/, ''));
if (identityFiles.length === 0) {
identityFiles.push(...DEFAULT_IDENTITY_FILES);
}
const identityFileContentsResult = await Promise.allSettled(identityFiles.map(async keyPath => {
keyPath = await fileExists(keyPath + '.pub') ? keyPath + '.pub' : keyPath;
return fs.promises.readFile(keyPath);
}));
const fileKeys: SSHKey[] = identityFileContentsResult.map((result, i) => {
if (result.status === 'rejected') {
return undefined;
}
const parsedResult = ssh2.utils.parseKey(result.value);
if (parsedResult instanceof Error || !parsedResult) {
logger.error(`Error while parsing SSH public key ${identityFiles[i]}:`, parsedResult);
return undefined;
}
const parsedKey = Array.isArray(parsedResult) ? parsedResult[0] : parsedResult;
const fingerprint = crypto.createHash('sha256').update(parsedKey.getPublicSSH()).digest('base64');
return {
filename: identityFiles[i],
parsedKey,
fingerprint
};
}).filter(<T>(v: T | undefined): v is T => !!v);
let sshAgentParsedKeys: ParsedKey[] = [];
try {
if (!sshAgentSock) {
throw new Error(`SSH_AUTH_SOCK environment variable not defined`);
}
sshAgentParsedKeys = await new Promise<ParsedKey[]>((resolve, reject) => {
const sshAgent = new ssh2.OpenSSHAgent(sshAgentSock);
sshAgent.getIdentities((err, publicKeys) => {
if (err) {
reject(err);
} else {
resolve(publicKeys || []);
}
});
});
} catch (e) {
logger.error(`Couldn't get identities from OpenSSH agent`, e);
}
const sshAgentKeys: SSHKey[] = sshAgentParsedKeys.map(parsedKey => {
const fingerprint = crypto.createHash('sha256').update(parsedKey.getPublicSSH()).digest('base64');
return {
filename: parsedKey.comment,
parsedKey,
fingerprint,
agentSupport: true
};
});
const agentKeys: SSHKey[] = [];
const preferredIdentityKeys: SSHKey[] = [];
for (const agentKey of sshAgentKeys) {
const foundIdx = fileKeys.findIndex(k => agentKey.parsedKey.type === k.parsedKey.type && agentKey.fingerprint === k.fingerprint);
if (foundIdx >= 0) {
preferredIdentityKeys.push({ ...fileKeys[foundIdx], agentSupport: true });
fileKeys.splice(foundIdx, 1);
} else if (!identitiesOnly) {
agentKeys.push(agentKey);
}
}
preferredIdentityKeys.push(...agentKeys);
preferredIdentityKeys.push(...fileKeys);
logger.trace(`Identity keys:`, preferredIdentityKeys.length ? preferredIdentityKeys.map(k => `${k.filename} ${k.parsedKey.type} SHA256:${k.fingerprint}`).join('\n') : 'None');
return preferredIdentityKeys;
}
@@ -0,0 +1,129 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import SSHConfig, { Directive, Line, Section } from '@jeanp413/ssh-config';
import * as vscode from 'vscode';
import { exists as fileExists, normalizeToSlash, untildify } from '../common/files';
import { isWindows } from '../common/platform';
import { glob } from 'glob';
const systemSSHConfig = isWindows ? path.resolve(process.env.ALLUSERSPROFILE || 'C:\\ProgramData', 'ssh\\ssh_config') : '/etc/ssh/ssh_config';
const defaultSSHConfigPath = path.resolve(os.homedir(), '.ssh/config');
export function getSSHConfigPath() {
const sshConfigPath = vscode.workspace.getConfiguration('remote.SSH').get<string>('configFile');
return sshConfigPath ? untildify(sshConfigPath) : defaultSSHConfigPath;
}
function isDirective(line: Line): line is Directive {
return line.type === SSHConfig.DIRECTIVE;
}
function isHostSection(line: Line): line is Section {
return isDirective(line) && line.param === 'Host' && !!line.value && !!(line as Section).config;
}
function isIncludeDirective(line: Line): line is Section {
return isDirective(line) && line.param === 'Include' && !!line.value;
}
const SSH_CONFIG_PROPERTIES: Record<string, string> = {
'host': 'Host',
'hostname': 'HostName',
'user': 'User',
'port': 'Port',
'identityagent': 'IdentityAgent',
'identitiesonly': 'IdentitiesOnly',
'identityfile': 'IdentityFile',
'forwardagent': 'ForwardAgent',
'preferredauthentications': 'PreferredAuthentications',
'proxyjump': 'ProxyJump',
'proxycommand': 'ProxyCommand',
'include': 'Include',
};
function normalizeProp(prop: Directive) {
prop.param = SSH_CONFIG_PROPERTIES[prop.param.toLowerCase()] || prop.param;
}
function normalizeSSHConfig(config: SSHConfig) {
for (const line of config) {
if (isDirective(line)) {
normalizeProp(line);
}
if (isHostSection(line)) {
normalizeSSHConfig(line.config);
}
}
return config;
}
async function parseSSHConfigFromFile(filePath: string, userConfig: boolean) {
let content = '';
if (await fileExists(filePath)) {
content = (await fs.promises.readFile(filePath, 'utf8')).trim();
}
const config = normalizeSSHConfig(SSHConfig.parse(content));
const includedConfigs: [number, SSHConfig[]][] = [];
for (let i = 0; i < config.length; i++) {
const line = config[i];
if (isIncludeDirective(line)) {
const values = (line.value as string).split(',').map(s => s.trim());
const configs: SSHConfig[] = [];
for (const value of values) {
const includePaths = await glob(normalizeToSlash(untildify(value)), {
absolute: true,
cwd: normalizeToSlash(path.dirname(userConfig ? defaultSSHConfigPath : systemSSHConfig))
});
for (const p of includePaths) {
configs.push(await parseSSHConfigFromFile(p, userConfig));
}
}
includedConfigs.push([i, configs]);
}
}
for (const [idx, includeConfigs] of includedConfigs.reverse()) {
config.splice(idx, 1, ...includeConfigs.flat());
}
return config;
}
export default class SSHConfiguration {
static async loadFromFS(): Promise<SSHConfiguration> {
const config = await parseSSHConfigFromFile(getSSHConfigPath(), true);
config.push(...await parseSSHConfigFromFile(systemSSHConfig, false));
return new SSHConfiguration(config);
}
constructor(private sshConfig: SSHConfig) {
}
getAllConfiguredHosts(): string[] {
const hosts = new Set<string>();
for (const line of this.sshConfig) {
if (isHostSection(line)) {
const value = Array.isArray(line.value) ? line.value[0] : line.value;
const isPattern = /^!/.test(value) || /[?*]/.test(value);
if (!isPattern) {
hosts.add(value);
}
}
}
return [...hosts.keys()];
}
getHostConfiguration(host: string): Record<string, string> {
// Only a few directives return an array
// https://github.com/jeanp413/ssh-config/blob/8d187bb8f1d83a51ff2b1d127e6b6269d24092b5/src/ssh-config.ts#L9C1-L9C118
return this.sshConfig.compute(host) as Record<string, string>;
}
}
@@ -0,0 +1,367 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { EventEmitter } from 'events';
import * as net from 'net';
import * as fs from 'fs';
import * as stream from 'stream';
import { Client, ClientChannel, ClientErrorExtensions, ExecOptions, ShellOptions, ConnectConfig } from 'ssh2';
import { Server } from 'net';
import { SocksConnectionInfo, createServer as createSocksServer } from 'simple-socks';
export interface SSHConnectConfig extends ConnectConfig {
/** Optional Unique ID attached to ssh connection. */
uniqueId?: string;
/** Automatic retry to connect, after disconnect. Default true */
reconnect?: boolean;
/** Number of reconnect retry, after disconnect. Default 10 */
reconnectTries?: number;
/** Delay after which reconnect should be done. Default 5000ms */
reconnectDelay?: number;
/** Path to private key */
identity?: string | Buffer;
}
export interface SSHTunnelConfig {
/** Remote Address to connect */
remoteAddr?: string;
/** Local port to bind to. By default, it will bind to a random port, if not passed */
localPort?: number;
/** Remote Port to connect */
remotePort?: number;
/** Remote socket path to connect */
remoteSocketPath?: string;
socks?: boolean;
/** Unique name */
name?: string;
}
const defaultOptions: Partial<SSHConnectConfig> = {
reconnect: false,
port: 22,
reconnectTries: 3,
reconnectDelay: 5000
};
const SSHConstants = {
'CHANNEL': {
SSH: 'ssh',
TUNNEL: 'tunnel',
X11: 'x11'
},
'STATUS': {
BEFORECONNECT: 'beforeconnect',
CONNECT: 'connect',
BEFOREDISCONNECT: 'beforedisconnect',
DISCONNECT: 'disconnect'
}
};
export default class SSHConnection extends EventEmitter {
public config: SSHConnectConfig;
private activeTunnels: { [index: string]: SSHTunnelConfig & { server: Server } } = {};
private __$connectPromise: Promise<SSHConnection> | null = null;
private __retries: number = 0;
private __err: Error & ClientErrorExtensions & { code?: string } | null = null;
private sshConnection: Client | null = null;
constructor(options: SSHConnectConfig) {
super();
this.config = Object.assign({}, defaultOptions, options);
this.config.uniqueId = this.config.uniqueId || `${this.config.username}@${this.config.host}`;
}
/**
* Emit message on this channel
*/
override emit(channel: string, status: string, payload?: any): boolean {
super.emit(channel, status, this, payload);
return super.emit(`${channel}:${status}`, this, payload);
}
/**
* Get shell socket
*/
shell(options: ShellOptions = {}): Promise<ClientChannel> {
return this.connect().then(() => {
return new Promise<ClientChannel>((resolve, reject) => {
this.sshConnection!.shell(options, (err, stream) => err ? reject(err) : resolve(stream));
});
});
}
/**
* Exec a command
*/
exec(cmd: string, params?: Array<string>, options: ExecOptions = {}): Promise<{ stdout: string; stderr: string }> {
cmd += (Array.isArray(params) ? (' ' + params.join(' ')) : '');
return this.connect().then(() => {
return new Promise((resolve, reject) => {
this.sshConnection!.exec(cmd, options, (err, stream) => {
if (err) {
return reject(err);
}
let stdout = '';
let stderr = '';
stream.on('close', function () {
return resolve({ stdout, stderr });
}).on('data', function (data: Buffer | string) {
stdout += data.toString();
}).stderr.on('data', function (data: Buffer | string) {
stderr += data.toString();
});
});
});
});
}
/**
* Exec a command
*/
execPartial(cmd: string, tester: (stdout: string, stderr: string) => boolean, params?: Array<string>, options: ExecOptions = {}): Promise<{ stdout: string; stderr: string }> {
cmd += (Array.isArray(params) ? (' ' + params.join(' ')) : '');
return this.connect().then(() => {
return new Promise((resolve, reject) => {
this.sshConnection!.exec(cmd, options, (err, stream) => {
if (err) {
return reject(err);
}
let stdout = '';
let stderr = '';
let resolved = false;
stream.on('close', function () {
if (!resolved) {
return resolve({ stdout, stderr });
}
}).on('data', function (data: Buffer | string) {
stdout += data.toString();
if (tester(stdout, stderr)) {
resolved = true;
return resolve({ stdout, stderr });
}
}).stderr.on('data', function (data: Buffer | string) {
stderr += data.toString();
if (tester(stdout, stderr)) {
resolved = true;
return resolve({ stdout, stderr });
}
});
});
});
});
}
/**
* Forward out
*/
forwardOut(srcIP: string, srcPort: number, destIP: string, destPort: number): Promise<ClientChannel> {
return this.connect().then(() => {
return new Promise((resolve, reject) => {
this.sshConnection!.forwardOut(srcIP, srcPort, destIP, destPort, (err, stream) => {
if (err) {
return reject(err);
}
resolve(stream);
});
});
});
}
/**
* Get a Socks Port
*/
getSocksPort(localPort: number): Promise<number> {
return this.addTunnel({ name: '__socksServer', socks: true, localPort: localPort }).then((tunnel) => {
return tunnel.localPort!;
});
}
/**
* Close SSH Connection
*/
close(): Promise<void> {
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.BEFOREDISCONNECT);
return this.closeTunnel().then(() => {
if (this.sshConnection) {
this.sshConnection.end();
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.DISCONNECT);
}
});
}
/**
* Connect the SSH Connection
*/
connect(c?: SSHConnectConfig): Promise<SSHConnection> {
this.config = Object.assign(this.config, c);
++this.__retries;
if (this.__$connectPromise) {
return this.__$connectPromise;
}
this.__$connectPromise = new Promise((resolve, reject) => {
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.BEFORECONNECT);
if (!this.config || typeof this.config === 'function' || !(this.config.host || this.config.sock) || !this.config.username) {
reject(`Invalid SSH connection configuration host/username can't be empty`);
this.__$connectPromise = null;
return;
}
if (this.config.identity) {
if (fs.existsSync(this.config.identity)) {
this.config.privateKey = fs.readFileSync(this.config.identity);
}
delete this.config.identity;
}
//Start ssh server connection
this.sshConnection = new Client();
this.sshConnection.on('ready', (err: Error & ClientErrorExtensions) => {
if (err) {
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.DISCONNECT, { err: err });
this.__$connectPromise = null;
return reject(err);
}
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.CONNECT);
this.__retries = 0;
this.__err = null;
resolve(this);
}).on('error', (err) => {
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.DISCONNECT, { err: err });
this.__err = err;
}).on('close', () => {
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.DISCONNECT, { err: this.__err });
if (this.config.reconnect && this.__retries <= this.config.reconnectTries! && this.__err && this.__err.level !== 'client-authentication' && this.__err.code !== 'ENOTFOUND') {
setTimeout(() => {
this.__$connectPromise = null;
resolve(this.connect());
}, this.config.reconnectDelay);
} else {
reject(this.__err);
}
}).connect(this.config);
});
return this.__$connectPromise;
}
/**
* Get existing tunnel by name
*/
getTunnel(name: string) {
return this.activeTunnels[name];
}
/**
* Add new tunnel if not exist
*/
addTunnel(SSHTunnelConfig: SSHTunnelConfig): Promise<SSHTunnelConfig & { server: Server }> {
SSHTunnelConfig.name = SSHTunnelConfig.name || `${SSHTunnelConfig.remoteAddr}@${SSHTunnelConfig.remotePort || SSHTunnelConfig.remoteSocketPath}`;
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.BEFORECONNECT, { SSHTunnelConfig: SSHTunnelConfig });
if (this.getTunnel(SSHTunnelConfig.name)) {
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.CONNECT, { SSHTunnelConfig: SSHTunnelConfig });
return Promise.resolve(this.getTunnel(SSHTunnelConfig.name));
} else {
return new Promise((resolve, reject) => {
let server: net.Server;
if (SSHTunnelConfig.socks) {
server = createSocksServer({
connectionFilter: (destination: SocksConnectionInfo, origin: SocksConnectionInfo, callback: (err?: any, dest?: stream.Duplex) => void) => {
this.connect().then(() => {
this.sshConnection!.forwardOut(
origin.address,
origin.port,
destination.address,
destination.port,
(err, stream) => {
if (err) {
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err });
return callback(err);
}
return callback(null, stream);
});
});
}
}).on('proxyError', (err: any) => {
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err });
});
} else {
server = net.createServer()
.on('connection', (socket) => {
this.connect().then(() => {
if (SSHTunnelConfig.remotePort) {
this.sshConnection!.forwardOut('127.0.0.1', 0, SSHTunnelConfig.remoteAddr!, SSHTunnelConfig.remotePort!, (err, stream) => {
if (err) {
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err });
return;
}
stream.pipe(socket);
socket.pipe(stream);
});
} else {
this.sshConnection!.openssh_forwardOutStreamLocal(SSHTunnelConfig.remoteSocketPath!, (err, stream) => {
if (err) {
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err });
return;
}
stream.pipe(socket);
socket.pipe(stream);
});
}
});
});
}
SSHTunnelConfig.localPort = SSHTunnelConfig.localPort || 0;
server.on('listening', () => {
SSHTunnelConfig.localPort = (server.address() as net.AddressInfo).port;
this.activeTunnels[SSHTunnelConfig.name!] = Object.assign({}, { server }, SSHTunnelConfig);
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.CONNECT, { SSHTunnelConfig: SSHTunnelConfig });
resolve(this.activeTunnels[SSHTunnelConfig.name!]);
}).on('error', (err: any) => {
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err });
server.close();
reject(err);
delete this.activeTunnels[SSHTunnelConfig.name!];
}).listen(SSHTunnelConfig.localPort);
});
}
}
/**
* Close the tunnel
*/
closeTunnel(name?: string): Promise<void> {
if (name && this.activeTunnels[name]) {
return new Promise((resolve) => {
const tunnel = this.activeTunnels[name];
this.emit(
SSHConstants.CHANNEL.TUNNEL,
SSHConstants.STATUS.BEFOREDISCONNECT,
{ SSHTunnelConfig: tunnel }
);
tunnel.server.close(() => {
this.emit(
SSHConstants.CHANNEL.TUNNEL,
SSHConstants.STATUS.DISCONNECT,
{ SSHTunnelConfig: this.activeTunnels[name] }
);
delete this.activeTunnels[name];
resolve();
});
});
} else if (!name) {
const tunnels = Object.keys(this.activeTunnels).map((key) => this.closeTunnel(key));
return Promise.all(tunnels).then(() => { });
}
return Promise.resolve();
}
}
@@ -0,0 +1,58 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export default class SSHDestination {
constructor(
public readonly hostname: string,
public readonly user?: string,
public readonly port?: number
) {
}
static parse(dest: string): SSHDestination {
let user: string | undefined;
const atPos = dest.lastIndexOf('@');
if (atPos !== -1) {
user = dest.substring(0, atPos);
}
let port: number | undefined;
const colonPos = dest.lastIndexOf(':');
if (colonPos !== -1) {
port = parseInt(dest.substring(colonPos + 1), 10);
}
const start = atPos !== -1 ? atPos + 1 : 0;
const end = colonPos !== -1 ? colonPos : dest.length;
const hostname = dest.substring(start, end);
return new SSHDestination(hostname, user, port);
}
toString(): string {
let result = this.hostname;
if (this.user) {
result = `${this.user}@` + result;
}
if (this.port) {
result = result + `:${this.port}`;
}
return result;
}
// vscode.uri implementation lowercases the authority, so when reopen or restore
// a remote session from the recently openend list the connection fails
static parseEncoded(dest: string): SSHDestination {
try {
const data = JSON.parse(Buffer.from(dest, 'hex').toString());
return new SSHDestination(data.hostName, data.user, data.port);
} catch {
}
return SSHDestination.parse(dest.replace(/\\x([0-9a-f]{2})/g, (_, charCode) => String.fromCharCode(parseInt(charCode, 16))));
}
toEncodedString(): string {
return this.toString().replace(/[A-Z]/g, (ch) => `\\x${ch.charCodeAt(0).toString(16).toLowerCase()}`);
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./out",
},
"include": [
"src/**/*",
"../../src/vscode-dts/vscode.d.ts",
"../../src/vscode-dts/vscode.proposed.resolvers.d.ts",
"../../src/vscode-dts/vscode.proposed.contribViewsRemote.d.ts",
]
}
+17 -15
View File
@@ -10,7 +10,7 @@
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.32.1",
"@anthropic-ai/sdk": "^0.37.0",
"@floating-ui/react": "^0.27.3",
"@google/generative-ai": "^0.21.0",
"@microsoft/1ds-core-js": "^3.2.13",
@@ -42,6 +42,7 @@
"@xterm/headless": "^5.6.0-beta.64",
"@xterm/xterm": "^5.6.0-beta.64",
"ajv": "^8.17.1",
"cross-spawn": "^7.0.6",
"diff": "^7.0.0",
"groq-sdk": "^0.9.0",
"http-proxy-agent": "^7.0.0",
@@ -56,7 +57,7 @@
"node-pty": "1.1.0-beta21",
"ollama": "^0.5.11",
"open": "^8.4.2",
"openai": "^4.76.1",
"openai": "^4.85.4",
"posthog-node": "^4.3.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -222,9 +223,10 @@
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.32.1",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.32.1.tgz",
"integrity": "sha512-U9JwTrDvdQ9iWuABVsMLj8nJVwAyQz6QXvgLsVhryhCEPkLsbcP/MXxm+jYcAwLoV8ESbaTTjnD4kuAFa+Hyjg==",
"version": "0.37.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.37.0.tgz",
"integrity": "sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==",
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
@@ -7116,7 +7118,7 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@@ -14249,8 +14251,7 @@
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
},
"node_modules/isobject": {
"version": "3.0.1",
@@ -17079,9 +17080,10 @@
}
},
"node_modules/openai": {
"version": "4.77.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.77.0.tgz",
"integrity": "sha512-WWacavtns/7pCUkOWvQIjyOfcdr9X+9n9Vvb0zFeKVDAqwCMDHB+iSr24SVaBAhplvSG6JrRXFpcNM9gWhOGIw==",
"version": "4.85.4",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.85.4.tgz",
"integrity": "sha512-Nki51PBSu+Aryo7WKbdXvfm0X/iKkQS2fq3O0Uqb/O3b4exOZFid2te1BZ52bbO5UwxQZ5eeHJDCTqtrJLPw0w==",
"license": "Apache-2.0",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
@@ -17095,9 +17097,13 @@
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
@@ -17536,7 +17542,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -20241,7 +20246,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"dependencies": {
"shebang-regex": "^3.0.0"
},
@@ -20253,7 +20257,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -23733,7 +23736,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"dependencies": {
"isexe": "^2.0.0"
},
+3 -2
View File
@@ -78,7 +78,7 @@
"update-build-ts-version": "npm install typescript@next && tsc -p ./build/tsconfig.build.json"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.32.1",
"@anthropic-ai/sdk": "^0.37.0",
"@floating-ui/react": "^0.27.3",
"@google/generative-ai": "^0.21.0",
"@microsoft/1ds-core-js": "^3.2.13",
@@ -110,6 +110,7 @@
"@xterm/headless": "^5.6.0-beta.64",
"@xterm/xterm": "^5.6.0-beta.64",
"ajv": "^8.17.1",
"cross-spawn": "^7.0.6",
"diff": "^7.0.0",
"groq-sdk": "^0.9.0",
"http-proxy-agent": "^7.0.0",
@@ -124,7 +125,7 @@
"node-pty": "1.1.0-beta21",
"ollama": "^0.5.11",
"open": "^8.4.2",
"openai": "^4.76.1",
"openai": "^4.85.4",
"posthog-node": "^4.3.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+2 -2
View File
@@ -31,8 +31,8 @@
"nodejsRepository": "https://nodejs.org",
"urlProtocol": "void-editor",
"extensionsGallery": {
"serviceUrl": "https://open-vsx.org/vscode/gallery",
"itemUrl": "https://open-vsx.org/vscode/item"
"serviceUrl": "https://marketplace.visualstudio.com/_apis/public/gallery",
"itemUrl": "https://marketplace.visualstudio.com/items"
},
"builtInExtensions": []
}
+6
View File
@@ -38,6 +38,7 @@
"native-watchdog": "^1.4.1",
"node-pty": "1.1.0-beta21",
"tas-client-umd": "0.2.0",
"tslib": "^2.8.1",
"vscode-oniguruma": "1.7.0",
"vscode-regexpp": "^3.1.0",
"vscode-textmate": "9.1.0",
@@ -1035,6 +1036,11 @@
"node": ">=8.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+1
View File
@@ -33,6 +33,7 @@
"native-watchdog": "^1.4.1",
"node-pty": "1.1.0-beta21",
"tas-client-umd": "0.2.0",
"tslib": "^2.8.1",
"vscode-oniguruma": "1.7.0",
"vscode-regexpp": "^3.1.0",
"vscode-textmate": "9.1.0",
@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { safeStorage as safeStorageElectron, app } from 'electron';
import { isMacintosh, isWindows } from '../../../base/common/platform.js';
import { isMacintosh, isWindows, isLinux } from '../../../base/common/platform.js';
import { KnownStorageProvider, IEncryptionMainService, PasswordStoreCLIOption } from '../common/encryptionService.js';
import { ILogService } from '../../log/common/log.js';
@@ -23,6 +23,11 @@ export class EncryptionMainService implements IEncryptionMainService {
constructor(
@ILogService private readonly logService: ILogService
) {
if (isLinux && !app.commandLine.getSwitchValue('password-store')) {
this.logService.trace('[EncryptionMainService] No password-store switch, defaulting to basic...');
app.commandLine.appendSwitch('password-store', PasswordStoreCLIOption.basic);
}
// if this commandLine switch is set, the user has opted in to using basic text encryption
if (app.commandLine.getSwitchValue('password-store') === PasswordStoreCLIOption.basic) {
this.logService.trace('[EncryptionMainService] setting usePlainTextEncryption to true...');
@@ -432,7 +432,7 @@ export class UnpinEditorAction extends Action {
label: string,
@ICommandService private readonly commandService: ICommandService
) {
super(id, label, ThemeIcon.asClassName(Codicon.starFull));
super(id, label, ThemeIcon.asClassName(Codicon.pinned));
}
override run(context?: IEditorCommandsContext): Promise<void> {
@@ -440,24 +440,6 @@ export class UnpinEditorAction extends Action {
}
}
export class PinEditorAction extends Action {
static readonly ID = 'workbench.action.pinEditor';
static readonly LABEL = localize('pinEditor', "Pin Editor");
constructor(
id: string,
label: string,
@ICommandService private readonly commandService: ICommandService
) {
super(id, label, ThemeIcon.asClassName(Codicon.star));
}
override async run(context?: IEditorCommandsContext): Promise<void> {
return this.commandService.executeCommand('workbench.action.pinEditor', undefined, context);
}
}
export class CloseEditorTabAction extends Action {
static readonly ID = 'workbench.action.closeActiveEditor';
@@ -270,7 +270,7 @@ export class EditorGroupWatermark extends Disposable {
const keys3 = this.keybindingService.lookupKeybinding('workbench.action.openGlobalKeybindings');
const button3 = append(recentsBox, $('button'));
button3.textContent = 'Void Settings'
button3.textContent = `Void Settings`
button3.style.display = 'block'
button3.style.marginLeft = 'auto'
button3.style.marginRight = 'auto'
@@ -385,7 +385,7 @@
.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.tab-actions-right.sizing-shrink > .tab-actions,
.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.tab-actions-right.sizing-fixed > .tab-actions {
flex: 0;
overflow: visible; /* ensure tab actions are always visible */
overflow: hidden; /* let the tab actions be pushed out of view when sizing is set to shrink/fixed to make more room */
}
.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.dirty.tab-actions-right.sizing-shrink > .tab-actions,
@@ -399,8 +399,18 @@
overflow: visible; /* ...but still show the tab actions on hover, focus and when dirty or sticky */
}
.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.close-action-off:not(.dirty) > .tab-actions,
.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sticky-compact > .tab-actions {
display: none; /* only hide tab actions when sticky-compact */
display: none; /* hide the tab actions when we are configured to hide it (unless dirty, but always when sticky-compact) */
}
.monaco-workbench .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab.active > .tab-actions .action-label, /* always show tab actions for active tab */
.monaco-workbench .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab > .tab-actions .action-label:focus, /* always show tab actions on focus */
.monaco-workbench .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab:hover > .tab-actions .action-label, /* always show tab actions on hover */
.monaco-workbench .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab.active:hover > .tab-actions .action-label, /* always show tab actions on hover */
.monaco-workbench .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab.sticky:not(.pinned-action-off) > .tab-actions .action-label, /* always show tab actions for sticky tabs */
.monaco-workbench .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab.dirty > .tab-actions .action-label { /* always show tab actions for dirty tabs */
opacity: 1;
}
.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab > .tab-actions .actions-container {
@@ -434,11 +444,11 @@
.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.dirty > .tab-actions .action-label,
.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sticky:not(.pinned-action-off) > .tab-actions .action-label,
.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:hover > .tab-actions .action-label {
opacity: 1;
opacity: 0.5; /* show tab actions dimmed for inactive group */
}
.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab > .tab-actions .action-label {
opacity: 1;
opacity: 0;
}
/* Tab Actions: Off */
@@ -35,7 +35,7 @@ import { MergeGroupMode, IMergeGroupOptions } from '../../../services/editor/com
import { addDisposableListener, EventType, EventHelper, Dimension, scheduleAtNextAnimationFrame, findParentWithClass, clearNode, DragAndDropObserver, isMouseEvent, getWindow } from '../../../../base/browser/dom.js';
import { localize } from '../../../../nls.js';
import { IEditorGroupsView, EditorServiceImpl, IEditorGroupView, IInternalEditorOpenOptions, IEditorPartsView } from './editor.js';
import { CloseEditorTabAction, PinEditorAction, UnpinEditorAction } from './editorActions.js';
import { CloseEditorTabAction, UnpinEditorAction } from './editorActions.js';
import { assertAllDefined, assertIsDefined } from '../../../../base/common/types.js';
import { IEditorService } from '../../../services/editor/common/editorService.js';
import { basenameOrAuthority } from '../../../../base/common/resources.js';
@@ -113,7 +113,6 @@ export class MultiEditorTabsControl extends EditorTabsControl {
private readonly closeEditorAction = this._register(this.instantiationService.createInstance(CloseEditorTabAction, CloseEditorTabAction.ID, CloseEditorTabAction.LABEL));
private readonly unpinEditorAction = this._register(this.instantiationService.createInstance(UnpinEditorAction, UnpinEditorAction.ID, UnpinEditorAction.LABEL));
private readonly pinEditorAction = this._register(this.instantiationService.createInstance(PinEditorAction, PinEditorAction.ID, PinEditorAction.LABEL)); // Add this line
private readonly tabResourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER));
private tabLabels: IEditorInputLabel[] = [];
@@ -1519,28 +1518,28 @@ export class MultiEditorTabsControl extends EditorTabsControl {
this.redrawTabLabel(editor, tabIndex, tabContainer, tabLabelWidget, tabLabel);
// Action
const hasCloseAction = options.tabActionCloseVisibility;
const hasAction = true; // Always show actions
const hasUnpinAction = isTabSticky && options.tabActionUnpinVisibility;
const hasCloseAction = !hasUnpinAction && options.tabActionCloseVisibility;
const hasAction = hasUnpinAction || hasCloseAction;
// Determine which action to show
let tabAction;
if (isTabSticky) {
tabAction = this.unpinEditorAction;
if (hasAction) {
tabAction = hasUnpinAction ? this.unpinEditorAction : this.closeEditorAction;
} else {
tabAction = this.pinEditorAction; // Use pin action instead of close action
// Even if the action is not visible, add it as it contains the dirty indicator
tabAction = isTabSticky ? this.unpinEditorAction : this.closeEditorAction;
}
// Update action bar
if (!tabActionBar.hasAction(tabAction)) {
if (!tabActionBar.isEmpty()) {
tabActionBar.clear();
}
tabActionBar.push(tabAction, { icon: true, label: false, keybinding: this.getKeybindingLabel(tabAction) });
}
tabContainer.classList.toggle('sticky', isTabSticky);
tabContainer.classList.toggle(`pinned-action-off`, false);
tabContainer.classList.toggle(`close-action-off`, !hasCloseAction);
tabContainer.classList.toggle(`pinned-action-off`, isTabSticky && !hasUnpinAction);
tabContainer.classList.toggle(`close-action-off`, !hasUnpinAction && !hasCloseAction);
for (const option of ['left', 'right']) {
tabContainer.classList.toggle(`tab-actions-${option}`, hasAction && options.tabActionLocation === option);
@@ -0,0 +1,135 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { Disposable } from '../../../../base/common/lifecycle.js';
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { IMarkerService, MarkerSeverity } from '../../../../platform/markers/common/markers.js';
import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js';
import { ITextModelService } from '../../../../editor/common/services/resolverService.js';
import { Range } from '../../../../editor/common/core/range.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { CodeActionContext, CodeActionTriggerType } from '../../../../editor/common/languages.js';
import { URI } from '../../../../base/common/uri.js';
export interface IMarkerCheckService {
readonly _serviceBrand: undefined;
}
export const IMarkerCheckService = createDecorator<IMarkerCheckService>('markerCheckService');
class MarkerCheckService extends Disposable implements IMarkerCheckService {
_serviceBrand: undefined;
constructor(
@IMarkerService private readonly _markerService: IMarkerService,
@ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService,
@ITextModelService private readonly _textModelService: ITextModelService,
) {
super();
setInterval(async () => {
const allMarkers = this._markerService.read();
const errors = allMarkers.filter(marker => marker.severity === MarkerSeverity.Error);
if (errors.length > 0) {
for (const error of errors) {
console.log(`----------------------------------------------`);
console.log(`${error.resource.toString()}: ${error.startLineNumber} ${error.message} ${error.severity}`); // ! all errors in the file
try {
// Get the text model for the file
const modelReference = await this._textModelService.createModelReference(error.resource);
const model = modelReference.object.textEditorModel;
// Create a range from the marker
const range = new Range(
error.startLineNumber,
error.startColumn,
error.endLineNumber,
error.endColumn
);
// Get code action providers for this model
const codeActionProvider = this._languageFeaturesService.codeActionProvider;
const providers = codeActionProvider.ordered(model);
if (providers.length > 0) {
// Request code actions from each provider
for (const provider of providers) {
const context: CodeActionContext = {
trigger: CodeActionTriggerType.Invoke, // keeping 'trigger' since it works
only: 'quickfix' // adding this to filter for quick fixes
};
const actions = await provider.provideCodeActions(
model,
range,
context,
CancellationToken.None
);
if (actions?.actions?.length) {
const quickFixes = actions.actions.filter(action => action.isPreferred); // ! all quickFixes for the error
const quickFixesForImports = actions.actions.filter(action => action.isPreferred && action.title.includes('import')); // ! all possible imports
quickFixesForImports
if (quickFixes.length > 0) {
console.log('Available Quick Fixes:');
quickFixes.forEach(action => {
console.log(`- ${action.title}`);
});
}
}
}
}
// Dispose the model reference
modelReference.dispose();
} catch (e) {
console.error('Error getting quick fixes:', e);
}
}
}
}, 5000);
}
fixErrorsInFiles(uris: URI[], contextSoFar: []) {
// const allMarkers = this._markerService.read();
// check errors in files
// give LLM errors in files
}
// private _onMarkersChanged = (changedResources: readonly URI[]): void => {
// for (const resource of changedResources) {
// const markers = this._markerService.read({ resource });
// if (markers.length === 0) {
// console.log(`${resource.toString()}: No diagnostics`);
// continue;
// }
// console.log(`Diagnostics for ${resource.toString()}:`);
// markers.forEach(marker => this._logMarker(marker));
// }
// };
}
registerSingleton(IMarkerCheckService, MarkerCheckService, InstantiationType.Eager);
@@ -0,0 +1,108 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
// 1. search(ai)
// - tool use to find all possible changes
// - if search only: is this file related to the search?
// - if search + replace: should I modify this file?
// 2. replace(ai)
// - what changes to make?
// 3. postprocess errors
// -fastapply changes simultaneously
// -iterate on syntax errors (all files can be changed from a syntax error, not just the one with the error)
// private async _searchUsingAI({ searchClause }: { searchClause: string }) {
// // const relevantURIs: URI[] = []
// // const gatherPrompt = `\
// // asdasdas
// // `
// // const filterPrompt = `\
// // Is this file relevant?
// // `
// // // optimizations (DO THESE LATER!!!!!!)
// // // if tool includes a uri in uriSet, skip it obviously
// // let uriSet = new Set<URI>()
// // // gather
// // let messages = []
// // while (true) {
// // const result = await new Promise((res, rej) => {
// // sendLLMMessage({
// // messages,
// // tools: ['search'],
// // onFinalMessage: ({ result: r, }) => {
// // res(r)
// // },
// // onError: (error) => {
// // rej(error)
// // }
// // })
// // })
// // messages.push({ role: 'tool', content: turnToString(result) })
// // sendLLMMessage({
// // messages: { 'Output ': result },
// // onFinalMessage: (r) => {
// // // output is file1\nfile2\nfile3\n...
// // }
// // })
// // uriSet.add(...)
// // }
// // // writes
// // if (!replaceClause) return
// // for (const uri of uriSet) {
// // // in future, batch these
// // applyWorkflow({ uri, applyStr: replaceClause })
// // }
// // while (true) {
// // const result = new Promise((res, rej) => {
// // sendLLMMessage({
// // messages,
// // tools: ['search'],
// // onResult: (r) => {
// // res(r)
// // }
// // })
// // })
// // messages.push(result)
// // }
// }
// private async _replaceUsingAI({ searchClause, replaceClause, relevantURIs }: { searchClause: string, replaceClause: string, relevantURIs: URI[] }) {
// for (const uri of relevantURIs) {
// uri
// }
// // should I change this file?
// // if so what changes to make?
// // fast apply the changes
// }
@@ -16,9 +16,9 @@ import { isCodeEditor } from '../../../../editor/browser/editorBrowser.js';
import { EditorResourceAccessor } from '../../../common/editor.js';
import { IModelService } from '../../../../editor/common/services/model.js';
import { extractCodeFromRegular } from './helpers/extractCodeFromResult.js';
import { isWindows } from '../../../../base/common/platform.js';
import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js';
import { ILLMMessageService } from '../common/llmMessageService.js';
import { _ln, allLinebreakSymbols } from '../common/voidFileService.js';
// import { IContextGatheringService } from './contextGatheringService.js';
// The extension this was called from is here - https://github.com/voideditor/void/blob/autocomplete/extensions/void/src/extension/extension.ts
@@ -415,9 +415,6 @@ const toInlineCompletions = ({ autocompletionMatchup, autocompletion, prefixAndS
// }
const allLinebreakSymbols = ['\r\n', '\n']
const _ln = isWindows ? allLinebreakSymbols[0] : allLinebreakSymbols[1]
type PrefixAndSuffixInfo = { prefix: string, suffix: string, prefixLines: string[], suffixLines: string[], prefixToTheLeftOfCursor: string, suffixToTheRightOfCursor: string }
const getPrefixAndSuffixInfo = (model: ITextModel, position: Position): PrefixAndSuffixInfo => {
@@ -798,26 +795,27 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ
},
useProviderFor: 'Autocomplete',
logging: { loggingName: 'Autocomplete' },
onText: async ({ fullText, newText }) => {
onText: () => { }, // unused in FIMMessage
// onText: async ({ fullText, newText }) => {
newAutocompletion.insertText = fullText
// newAutocompletion.insertText = fullText
// count newlines in newText
const numNewlines = newText.match(/\n|\r\n/g)?.length || 0
newAutocompletion._newlineCount += numNewlines
// // count newlines in newText
// const numNewlines = newText.match(/\n|\r\n/g)?.length || 0
// newAutocompletion._newlineCount += numNewlines
// if too many newlines, resolve up to last newline
if (newAutocompletion._newlineCount > 10) {
const lastNewlinePos = fullText.lastIndexOf('\n')
newAutocompletion.insertText = fullText.substring(0, lastNewlinePos)
resolve(newAutocompletion.insertText)
return
}
// // if too many newlines, resolve up to last newline
// if (newAutocompletion._newlineCount > 10) {
// const lastNewlinePos = fullText.lastIndexOf('\n')
// newAutocompletion.insertText = fullText.substring(0, lastNewlinePos)
// resolve(newAutocompletion.insertText)
// return
// }
// if (!getAutocompletionMatchup({ prefix: this._lastPrefix, autocompletion: newAutocompletion })) {
// reject('LLM response did not match user\'s text.')
// }
},
// // if (!getAutocompletionMatchup({ prefix: this._lastPrefix, autocompletion: newAutocompletion })) {
// // reject('LLM response did not match user\'s text.')
// // }
// },
onFinalMessage: ({ fullText }) => {
// console.log('____res: ', JSON.stringify(newAutocompletion.insertText))
@@ -1,3 +1,7 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
// eg "bash" -> "shell"
export const nameToVscodeLanguage: { [key: string]: string } = {
@@ -3,6 +3,9 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { OnText } from '../../common/llmMessageTypes.js'
import { DIVIDER, FINAL, ORIGINAL } from '../prompt/prompts.js'
class SurroundingsRemover {
readonly originalS: string
i: number
@@ -57,12 +60,12 @@ class SurroundingsRemover {
// return offset === suffix.length
// }
removeFromStartUntil = (until: string, alsoRemoveUntilStr: boolean) => {
removeFromStartUntilFullMatch = (until: string, alsoRemoveUntilStr: boolean) => {
const index = this.originalS.indexOf(until, this.i)
if (index === -1) {
this.i = this.j + 1
return false
return null
}
// console.log('index', index, until.length)
@@ -84,7 +87,7 @@ class SurroundingsRemover {
const foundCodeBlock = pm.removePrefix('```')
if (!foundCodeBlock) return false
pm.removeFromStartUntil('\n', true) // language
pm.removeFromStartUntilFullMatch('\n', true) // language
const j = pm.j
let foundCodeBlockEnd = pm.removeSuffix('```')
@@ -157,21 +160,200 @@ export const extractCodeFromFIM = ({ text, recentlyAddedTextLen, midTag, }: { te
const [delta, ignoredSuffix] = pm.deltaInfo(recentlyAddedTextLen)
return [s, delta, ignoredSuffix]
// // const regex = /[\s\S]*?(?:`{1,3}\s*([a-zA-Z_]+[\w]*)?[\s\S]*?)?<MID>([\s\S]*?)(?:<\/MID>|`{1,3}|$)/;
// const regex = new RegExp(
// `[\\s\\S]*?(?:\`{1,3}\\s*([a-zA-Z_]+[\\w]*)?[\\s\\S]*?)?<${midTag}>([\\s\\S]*?)(?:</${midTag}>|\`{1,3}|$)`,
// ''
// );
// const match = text.match(regex);
// if (match) {
// const [_, languageName, codeBetweenMidTags] = match;
// return [languageName, codeBetweenMidTags] as const
// } else {
// return [undefined, extractCodeFromRegular(text)] as const
// }
}
export type ExtractedSearchReplaceBlock = {
state: 'writingOriginal' | 'writingFinal' | 'done',
orig: string,
final: string,
}
const endsWithAnyPrefixOf = (str: string, anyPrefix: string) => {
// for each prefix
for (let i = anyPrefix.length; i >= 1; i--) { // i >= 1 because must not be empty string
const prefix = anyPrefix.slice(0, i)
if (str.endsWith(prefix)) return prefix
}
return null
}
// guarantees if you keep adding text, array length will strictly grow and state will progress without going back
export const extractSearchReplaceBlocks = (str: string) => {
const ORIGINAL_ = ORIGINAL + `\n`
const DIVIDER_ = '\n' + DIVIDER + `\n`
// logic for FINAL_ is slightly more complicated - should be '\n' + FINAL, but that ignores if the final output is empty
const blocks: ExtractedSearchReplaceBlock[] = []
let i = 0 // search i and beyond (this is done by plain index, not by line number. much simpler this way)
while (true) {
let origStart = str.indexOf(ORIGINAL_, i)
if (origStart === -1) { return blocks }
origStart += ORIGINAL_.length
i = origStart
// wrote <<<< ORIGINAL
let dividerStart = str.indexOf(DIVIDER_, i)
if (dividerStart === -1) { // if didnt find DIVIDER_, either writing originalStr or DIVIDER_ right now
const isWritingDIVIDER = endsWithAnyPrefixOf(str, DIVIDER_)
blocks.push({
orig: str.substring(origStart, str.length - (isWritingDIVIDER?.length ?? 0)),
final: '',
state: 'writingOriginal'
})
return blocks
}
const origStrDone = str.substring(origStart, dividerStart)
dividerStart += DIVIDER_.length
i = dividerStart
// wrote =====
const finalStartA = str.indexOf(FINAL, i)
const finalStartB = str.indexOf('\n' + FINAL, i) // go with B if possible, else fallback to A, it's more permissive
const FINAL_ = finalStartB !== -1 ? '\n' + FINAL : FINAL
let finalStart = finalStartB !== -1 ? finalStartB : finalStartA
if (finalStart === -1) { // if didnt find FINAL_, either writing finalStr or FINAL_ right now
const isWritingFINAL = endsWithAnyPrefixOf(str, FINAL_)
blocks.push({
orig: origStrDone,
final: str.substring(dividerStart, str.length - (isWritingFINAL?.length ?? 0)),
state: 'writingFinal'
})
return blocks
}
const finalStrDone = str.substring(dividerStart, finalStart)
finalStart += FINAL_.length
i = finalStart
// wrote >>>>> FINAL
blocks.push({
orig: origStrDone,
final: finalStrDone,
state: 'done'
})
}
}
// could simplify this - this assumes we can never add a tag without committing it to the user's screen, but that's not true
export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string, string]): OnText => {
let latestAddIdx = 0 // exclusive index in fullText_
let foundTag1 = false
let foundTag2 = false
let fullTextSoFar = ''
let fullReasoningSoFar = ''
let onText_ = onText
onText = (params) => {
onText_(params)
}
const newOnText: OnText = ({ fullText: fullText_ }) => {
// until found the first think tag, keep adding to fullText
if (!foundTag1) {
const endsWithTag1 = endsWithAnyPrefixOf(fullText_, thinkTags[0])
if (endsWithTag1) {
// console.log('endswith1', { fullTextSoFar, fullReasoningSoFar, fullText_ })
// wait until we get the full tag or know more
return
}
// if found the first tag
const tag1Index = fullText_.indexOf(thinkTags[0])
if (tag1Index !== -1) {
// console.log('tag1Index !==1', { tag1Index, fullTextSoFar, fullReasoningSoFar, thinkTags, fullText_ })
foundTag1 = true
// Add text before the tag to fullTextSoFar
fullTextSoFar += fullText_.substring(0, tag1Index)
// Update latestAddIdx to after the first tag
latestAddIdx = tag1Index + thinkTags[0].length
onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
return
}
// console.log('adding to text A', { fullTextSoFar, fullReasoningSoFar })
// add the text to fullText
fullTextSoFar = fullText_
latestAddIdx = fullText_.length
onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
return
}
// at this point, we found <tag1>
// until found the second think tag, keep adding to fullReasoning
if (!foundTag2) {
const endsWithTag2 = endsWithAnyPrefixOf(fullText_, thinkTags[1])
if (endsWithTag2) {
// console.log('endsWith2', { fullTextSoFar, fullReasoningSoFar })
// wait until we get the full tag or know more
return
}
// if found the second tag
const tag2Index = fullText_.indexOf(thinkTags[1], latestAddIdx)
if (tag2Index !== -1) {
// console.log('tag2Index !== -1', { fullTextSoFar, fullReasoningSoFar })
foundTag2 = true
// Add everything between first and second tag to reasoning
fullReasoningSoFar += fullText_.substring(latestAddIdx, tag2Index)
// Update latestAddIdx to after the second tag
latestAddIdx = tag2Index + thinkTags[1].length
onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
return
}
// add the text to fullReasoning (content after first tag but before second tag)
// console.log('adding to text B', { fullTextSoFar, fullReasoningSoFar })
// If we have more text than we've processed, add it to reasoning
if (fullText_.length > latestAddIdx) {
fullReasoningSoFar += fullText_.substring(latestAddIdx)
latestAddIdx = fullText_.length
}
onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
return
}
// at this point, we found <tag2> - content after the second tag is normal text
// console.log('adding to text C', { fullTextSoFar, fullReasoningSoFar })
// Add any new text after the closing tag to fullTextSoFar
if (fullText_.length > latestAddIdx) {
fullTextSoFar += fullText_.substring(latestAddIdx)
latestAddIdx = fullText_.length
}
onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
}
return newOnText
}
export const extractReasoningOnFinalMessage = (fullText_: string, thinkTags: [string, string]): { fullText: string, fullReasoning: string } => {
const tag1Idx = fullText_.indexOf(thinkTags[0])
const tag2Idx = fullText_.indexOf(thinkTags[1])
if (tag1Idx === -1) return { fullText: fullText_, fullReasoning: '' } // never started reasoning
if (tag2Idx === -1) return { fullText: '', fullReasoning: fullText_ } // never stopped reasoning
const fullReasoning = fullText_.substring(tag1Idx + thinkTags[0].length, tag2Idx)
const fullText = fullText_.substring(0, tag1Idx) + fullText_.substring(tag2Idx + thinkTags[1].length, Infinity)
return { fullText, fullReasoning }
}
@@ -1,3 +1,8 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { URI } from '../../../../../base/common/uri'
import { EndOfLinePreference } from '../../../../../editor/common/model'
import { IModelService } from '../../../../../editor/common/services/model.js'
@@ -5,7 +10,7 @@ import { IFileService } from '../../../../../platform/files/common/files'
// attempts to read URI of currently opened model, then of raw file
export const VSReadFile = async (modelService: IModelService, fileService: IFileService, uri: URI) => {
export const VSReadFile = async (uri: URI, modelService: IModelService, fileService: IFileService) => {
const modelResult = await _VSReadModel(modelService, uri)
if (modelResult) return modelResult
@@ -18,27 +23,30 @@ export const VSReadFile = async (modelService: IModelService, fileService: IFile
}
// read files from VSCode. preferred (but appears to only work if the model of this URI already exists. If it doesn't use the other function.)
export const _VSReadModel = async (modelService: IModelService, uri: URI): Promise<string | null> => {
const _VSReadModel = async (modelService: IModelService, uri: URI): Promise<string | null> => {
// attempt to read saved model (sometimes doesn't work if page is reloaded)
// attempt to read saved model (doesn't work if application was reloaded...)
const model = modelService.getModel(uri)
if (model) {
return model.getValue(EndOfLinePreference.LF)
}
// look at all opened models and check if they have the same `fsPath`
const models = modelService.getModels();
// backup logic - look at all opened models and check if they have the same `fsPath`
const models = modelService.getModels()
for (const model of models) {
if (model.uri.fsPath.toString() === uri.fsPath.toString()) {
if (model.uri.fsPath === uri.fsPath)
return model.getValue(EndOfLinePreference.LF);
}
}
return null
}
export const _VSReadFileRaw = async (fileService: IFileService, uri: URI) => {
const res = await fileService.readFile(uri)
const str = res.value.toString()
return str
const _VSReadFileRaw = async (fileService: IFileService, uri: URI) => {
try {
const res = await fileService.readFile(uri)
const str = res.value.toString()
return str
} catch (e) {
return null
}
}
@@ -0,0 +1,14 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { isLinux, isMacintosh, isWindows } from '../../../../../base/common/platform.js';
// import { OS, OperatingSystem } from '../../../../../base/common/platform.js';
// alternatively could use ^ and OS === OperatingSystem.Windows ? ...
export const os = isWindows ? 'windows' : isMacintosh ? 'mac' : isLinux ? 'linux' : null
@@ -6,30 +6,41 @@
import { URI } from '../../../../../base/common/uri.js';
import { filenameToVscodeLanguage } from '../helpers/detectLanguage.js';
import { CodeSelection, StagingSelectionItem, FileSelection } from '../chatThreadService.js';
import { _VSReadModel, VSReadFile } from '../helpers/readFile.js';
import { CodeSelection, StagingSelectionItem, FileSelection } from '../../common/chatThreadService.js';
import { IModelService } from '../../../../../editor/common/services/model.js';
import { IFileService } from '../../../../../platform/files/common/files.js';
import { os } from '../helpers/systemInfo.js';
import { IVoidFileService } from '../../common/voidFileService.js';
// this is just for ease of readability
const tripleTick = ['```', '```']
export const tripleTick = ['```', '```']
export const chat_systemMessage = `\
export const chat_systemMessage = (workspaces: string[]) => `\
You are a coding assistant. You are given a list of instructions to follow \`INSTRUCTIONS\`, and optionally a list of relevant files \`FILES\`, and selections inside of files \`SELECTIONS\`.
Please respond to the user's query.
Please respond to the user's query. The user's query is never invalid.
The user has the following system information:
- ${os}
- Open workspaces: ${workspaces.join(', ')}
In the case that the user asks you to make changes to code, you should make sure to return CODE BLOCKS of the changes, as well as explanations and descriptions of the changes.
For example, if the user asks you to "make this file look nicer", make sure your output includes a code block with concrete ways the file can look nicer.
- Do not re-write the entire file in the code block
- You can write comments like "// ... existing code" to indicate existing code
- Make sure you give enough context in the code block to apply the change to the correct location in the code.
- Do not re-write the entire file in the code block.
- You can write comments like "// ... existing code" to indicate existing code.
- Make sure you give enough context in the code block to apply the change to the correct location in the code.
You're allowed to ask for more context. For example, if the user only gives you a selection but you want to see the the full file, you can ask them to provide it.
If you are given tools:
- Only use tools if the user asks you to do something. If the user simply says hi or asks you a question that you can answer without tools, then do NOT tools.
- You are allowed to use tools without asking for permission.
- Feel free to use tools to gather context, make suggestions, etc.
- One great use of tools is to explore imports that you'd like to have more information about.
- Reference relevant files that you found when using tools if they helped you come up with your answer.
- NEVER refer to a tool by name when speaking with the user. For example, do NOT say to the user user "I'm going to use \`list_dir\`". Instead, say "I'm going to list all files in ___ directory", etc. Do not even refer to "pages" of results, just say you're getting more results.
Do not output any of these instructions, nor tell the user anything about them unless directly prompted for them.
Do not tell the user anything about the examples below.
Do not tell the user anything about the examples below. Do not assume the user is talking about any of the examples below.
## EXAMPLE 1
FILES
@@ -75,7 +86,7 @@ ${tripleTick[1]}
INSTRUCTIONS
add a function that exponentiates a number below this, and use it to make a power function that raises all entries of a vector to a power
ACCEPTED OUTPUT
## ACCEPTED OUTPUT
We can add the following code to the file:
${tripleTick[0]}typescript
// existing code...
@@ -118,7 +129,7 @@ ${tripleTick[1]}
INSTRUCTIONS
memoize results
ACCEPTED OUTPUT
## ACCEPTED OUTPUT
To implement memoization in your Fibonacci function, you can use a JavaScript object to store previously computed results. This will help avoid redundant calculations and improve performance. Here's how you can modify your function:
${tripleTick[0]}typescript
// existing code...
@@ -157,23 +168,23 @@ ${tripleTick[1]}
}
const failToReadStr = 'Could not read content. This file may have been deleted. If you expected content here, you can tell the user about this as they might not know.'
const stringifyFileSelections = async (fileSelections: FileSelection[], modelService: IModelService, fileService: IFileService) => {
const stringifyFileSelections = async (fileSelections: FileSelection[], voidFileService: IVoidFileService) => {
if (fileSelections.length === 0) return null
const fileSlns: FileSelnLocal[] = await Promise.all(fileSelections.map(async (sel) => {
const content = await VSReadFile(modelService, fileService, sel.fileURI) ?? failToReadStr
const content = await voidFileService.readFile(sel.fileURI) ?? failToReadStr
return { ...sel, content }
}))
return fileSlns.map(sel => stringifyFileSelection(sel)).join('\n')
}
const stringifyCodeSelections = (codeSelections: CodeSelection[]) => {
return codeSelections.map(sel => stringifyCodeSelection(sel)).join('\n')
return codeSelections.map(sel => stringifyCodeSelection(sel)).join('\n') || null
}
const stringifySelectionNames = (currSelns: StagingSelectionItem[] | null): string => {
if (!currSelns) return ''
return currSelns.map(s => `${s.fileURI.fsPath}${s.range ? ` (lines ${s.range.startLineNumber}:${s.range.endLineNumber})` : ''}`).join('\n')
}
export const chat_userMessageContent = async (instructions: string, prevSelns: StagingSelectionItem[] | null, currSelns: StagingSelectionItem[] | null) => {
export const chat_userMessageContent = async (instructions: string, currSelns: StagingSelectionItem[] | null) => {
const selnsStr = stringifySelectionNames(currSelns)
@@ -183,11 +194,13 @@ export const chat_userMessageContent = async (instructions: string, prevSelns: S
return str;
};
export const chat_userMessageContentWithAllFilesToo = async (instructions: string, prevSelns: StagingSelectionItem[] | null, currSelns: StagingSelectionItem[] | null, modelService: IModelService, fileService: IFileService) => {
export const chat_selectionsString = async (prevSelns: StagingSelectionItem[] | null, currSelns: StagingSelectionItem[] | null, voidFileService: IVoidFileService) => {
// ADD IN FILES AT TOP
const allSelections = [...currSelns || [], ...prevSelns || []]
if (allSelections.length === 0) return null
const codeSelections: CodeSelection[] = []
const fileSelections: FileSelection[] = []
const filesURIs = new Set<string>()
@@ -206,26 +219,25 @@ export const chat_userMessageContentWithAllFilesToo = async (instructions: strin
}
}
const filesStr = await stringifyFileSelections(fileSelections, modelService, fileService)
const filesStr = await stringifyFileSelections(fileSelections, voidFileService)
const selnsStr = stringifyCodeSelections(codeSelections)
// ACTUAL MESSAGE CONTENT
const messageContent = await chat_userMessageContent(instructions, prevSelns, currSelns)
if (filesStr || selnsStr) return `\
ALL FILE CONTENTS
${filesStr}
${selnsStr}`
return null
}
export const chat_userMessageContentWithAllFilesToo = (userMessage: string, selectionsString: string | null) => {
if (userMessage) return `${userMessage}${selectionsString ? `\n${selectionsString}` : ''}`
else return userMessage
}
let str = ''
str += 'ALL FILE CONTENTS\n'
if (filesStr) str += `${filesStr}\n`
if (selnsStr) str += `${selnsStr}\n`
if (messageContent) str += `\n${messageContent}\n`
return str;
};
export const fastApply_rewritewholething_systemMessage = `\
export const rewriteCode_systemMessage = `\
You are a coding assistant that re-writes an entire file to make a change. You are given the original file \`ORIGINAL_FILE\` and a change \`CHANGE\`.
Directions:
@@ -237,7 +249,7 @@ Directions:
export const fastApply_rewritewholething_userMessage = ({ originalCode, applyStr, uri }: { originalCode: string, applyStr: string, uri: URI }) => {
export const rewriteCode_userMessage = ({ originalCode, applyStr, uri }: { originalCode: string, applyStr: string, uri: URI }) => {
const language = filenameToVscodeLanguage(uri.fsPath) ?? ''
@@ -260,39 +272,140 @@ Please finish writing the new file by applying the change to the original file.
export const fastApply_searchreplace_systemMessage = `\
You are a coding assistant that re-writes an entire file to make a change. You are given the original file \`ORIGINAL_FILE\` and a change \`CHANGE\`.
Directions:
1. Please rewrite the original file \`ORIGINAL_FILE\`, making the change \`CHANGE\`. You must completely re-write the whole file.
2. Keep all of the original comments, spaces, newlines, and other details whenever possible.
3. ONLY output the full new file. Do not add any other explanations or text.
`
export const fastApply_searchreplace_userMessage = ({ originalCode, applyStr, uri }: { originalCode: string, applyStr: string, uri: URI }) => {
export const aiRegex_computeReplacementsForFile_systemMessage = `\
You are a "search and replace" coding assistant.
const language = filenameToVscodeLanguage(uri.fsPath) ?? ''
You are given a FILE that the user is editing, and your job is to search for all occurences of a SEARCH_CLAUSE, and change them according to a REPLACE_CLAUSE.
The SEARCH_CLAUSE may be a string, regex, or high-level description of what the user is searching for.
The REPLACE_CLAUSE will always be a high-level description of what the user wants to replace.
The user's request may be "fuzzy" or not well-specified, and it is your job to interpret all of the changes they want to make for them. For example, the user may ask you to search and replace all instances of a variable, but this may involve changing parameters, function names, types, and so on to agree with the change they want to make. Feel free to make all of the changes you *think* that the user wants to make, but also make sure not to make unnessecary or unrelated changes.
## Instructions
1. If you do not want to make any changes, you should respond with the word "no".
2. If you want to make changes, you should return a single CODE BLOCK of the changes that you want to make.
For example, if the user is asking you to "make this variable a better name", make sure your output includes all the changes that are needed to improve the variable name.
- Do not re-write the entire file in the code block
- You can write comments like "// ... existing code" to indicate existing code
- Make sure you give enough context in the code block to apply the changes to the correct location in the code`
export const aiRegex_computeReplacementsForFile_userMessage = async ({ searchClause, replaceClause, fileURI, voidFileService }: { searchClause: string, replaceClause: string, fileURI: URI, modelService: IModelService, voidFileService: IVoidFileService }) => {
// we may want to do this in batches
const fileSelection: FileSelection = { type: 'File', fileURI, selectionStr: null, range: null, state: { isOpened: false } }
const file = await stringifyFileSelections([fileSelection], voidFileService)
return `\
ORIGINAL_FILE
\`\`\`${language}
${originalCode}
\`\`\`
## FILE
${file}
CHANGE
\`\`\`
${applyStr}
\`\`\`
## SEARCH_CLAUSE
Here is what the user is searching for:
${searchClause}
INSTRUCTIONS
Please finish writing the new file by applying the change to the original file. Return ONLY the completion of the file, without any explanation.
`
## REPLACE_CLAUSE
Here is what the user wants to replace it with:
${replaceClause}
## INSTRUCTIONS
Please return the changes you want to make to the file in a codeblock, or return "no" if you do not want to make changes.`
}
// don't have to tell it it will be given the history; just give it to it
export const aiRegex_search_systemMessage = `\
You are a coding assistant that executes the SEARCH part of a user's search and replace query.
You will be given the user's search query, SEARCH, which is the user's query for what files to search for in the codebase. You may also be given the user's REPLACE query for additional context.
Output
- Regex query
- Files to Include (optional)
- Files to Exclude? (optional)
`
export const ORIGINAL = `<<<<<<< ORIGINAL`
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.
A SEARCH/REPLACE block describes the code before and after a change. Here is the format:
${tripleTick[0]}
${ORIGINAL}
// ... original code goes here
${DIVIDER}
// ... final code goes here
${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.
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 of code in the original file.
3. The original code in each SEARCH/REPLACE block must include enough text to uniquely identify the change in the file.
4. The original code in each SEARCH/REPLACE block must be disjoint from all other blocks.
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 "final" code is complete and will not result in syntax/lint errors.
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.
## EXAMPLE 1
ORIGINAL_FILE
${tripleTick[0]}
let w = 5
let x = 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}
let x = 6
${DIVIDER}
let x = 6.5
${FINAL}
${tripleTick[1]}
`
export const searchReplace_userMessage = ({ originalCode, applyStr }: { originalCode: string, applyStr: string }) => `\
ORIGINAL_FILE
${originalCode}
CHANGE
${applyStr}
INSTRUCTIONS
Please output SEARCH/REPLACE blocks to make the change. Return ONLY your suggested SEARCH/REPLACE blocks, without any explanation.
`
@@ -347,19 +460,19 @@ export const voidPrefixAndSuffix = ({ fullFileStr, startLine, endLine }: { fullF
}
export type FimTagsType = {
export type QuickEditFimTagsType = {
preTag: string,
sufTag: string,
midTag: string
}
export const defaultFimTags: FimTagsType = {
export const defaultQuickEditFimTags: QuickEditFimTagsType = {
preTag: 'ABOVE',
sufTag: 'BELOW',
midTag: 'SELECTION',
}
// this should probably be longer
export const ctrlKStream_systemMessage = ({ fimTags: { preTag, midTag, sufTag } }: { fimTags: FimTagsType }) => {
export const ctrlKStream_systemMessage = ({ quickEditFIMTags: { preTag, midTag, sufTag } }: { quickEditFIMTags: QuickEditFimTagsType }) => {
return `\
You are a FIM (fill-in-the-middle) coding assistant. Your task is to fill in the middle SELECTION marked by <${midTag}> tags.
@@ -375,7 +488,7 @@ Instructions:
}
export const ctrlKStream_userMessage = ({ selection, prefix, suffix, instructions, fimTags, isOllamaFIM, language }: {
selection: string, prefix: string, suffix: string, instructions: string, fimTags: FimTagsType, language: string,
selection: string, prefix: string, suffix: string, instructions: string, fimTags: QuickEditFimTagsType, language: string,
isOllamaFIM: false, // we require this be false for clarity
}) => {
const { preTag, sufTag, midTag } = fimTags
@@ -8,7 +8,7 @@ import { Action2, registerAction2 } from '../../../../platform/actions/common/ac
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';
import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';
import { IInlineDiffsService } from './inlineDiffsService.js';
import { IEditCodeService } from './editCodeService.js';
import { roundRangeToLines } from './sidebarActions.js';
import { VOID_CTRL_K_ACTION_ID } from './actionIDs.js';
import { localize2 } from '../../../../nls.js';
@@ -17,7 +17,6 @@ import { IMetricsService } from '../common/metricsService.js';
export type QuickEditPropsType = {
diffareaid: number,
initStreamingDiffZoneId: number | null,
textAreaRef: (ref: HTMLTextAreaElement | null) => void;
onChangeHeight: (height: number) => void;
onChangeText: (text: string) => void;
@@ -63,7 +62,7 @@ registerAction2(class extends Action2 {
const { startLineNumber: startLine, endLineNumber: endLine } = selection
const inlineDiffsService = accessor.get(IInlineDiffsService)
inlineDiffsService.addCtrlKZone({ startLine, endLine, editor })
const editCodeService = accessor.get(IEditCodeService)
editCodeService.addCtrlKZone({ startLine, endLine, editor })
}
});
@@ -3,7 +3,8 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { spawn, execSync } from 'child_process';
import { execSync } from 'child_process';
import { spawn } from 'cross-spawn'
// Added lines below
import fs from 'fs';
import path from 'path';
@@ -0,0 +1,156 @@
import { useState, useEffect, useCallback } from 'react'
import { useAccessor, useURIStreamState, useSettingsState } from '../util/services.js'
import { useRefState } from '../util/helpers.js'
import { isFeatureNameDisabled } from '../../../../common/voidSettingsTypes.js'
import { URI } from '../../../../../../../base/common/uri.js'
import { IEditCodeService, URIStreamState } from '../../../editCodeService.js'
enum CopyButtonText {
Idle = 'Copy',
Copied = 'Copied!',
Error = 'Could not copy',
}
const COPY_FEEDBACK_TIMEOUT = 1000 // amount of time to say 'Copied!'
const CopyButton = ({ codeStr }: { codeStr: string }) => {
const accessor = useAccessor()
const metricsService = accessor.get('IMetricsService')
const clipboardService = accessor.get('IClipboardService')
const [copyButtonText, setCopyButtonText] = useState(CopyButtonText.Idle)
useEffect(() => {
if (copyButtonText === CopyButtonText.Idle) return
setTimeout(() => {
setCopyButtonText(CopyButtonText.Idle)
}, COPY_FEEDBACK_TIMEOUT)
}, [copyButtonText])
const onCopy = useCallback(() => {
clipboardService.writeText(codeStr)
.then(() => { setCopyButtonText(CopyButtonText.Copied) })
.catch(() => { setCopyButtonText(CopyButtonText.Error) })
metricsService.capture('Copy Code', { length: codeStr.length }) // capture the length only
}, [metricsService, clipboardService, codeStr, setCopyButtonText])
const isSingleLine = !codeStr.includes('\n')
return <button
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-1 text-void-fg-1 hover:brightness-110 border border-vscode-input-border rounded`}
onClick={onCopy}
>
{copyButtonText}
</button>
}
// state persisted for duration of react only
const applyingURIOfApplyBoxIdRef: { current: { [applyBoxId: string]: URI | undefined } } = { current: {} }
export const ApplyBlockHoverButtons = ({ codeStr, applyBoxId }: { codeStr: string, applyBoxId: string }) => {
console.log('applyboxid', applyBoxId, applyingURIOfApplyBoxIdRef)
const settingsState = useSettingsState()
const isDisabled = !!isFeatureNameDisabled('Apply', settingsState) || !applyBoxId
const accessor = useAccessor()
const editCodeService = accessor.get('IEditCodeService')
const metricsService = accessor.get('IMetricsService')
const [_, rerender] = useState(0)
const applyingUri = useCallback(() => applyingURIOfApplyBoxIdRef.current[applyBoxId] ?? null, [applyBoxId])
const streamState = useCallback(() => editCodeService.getURIStreamState({ uri: applyingUri() }), [editCodeService, applyingUri])
// listen for stream updates
useURIStreamState(
useCallback((uri, newStreamState) => {
const shouldUpdate = applyingUri()?.fsPath !== uri.fsPath
if (shouldUpdate) return
rerender(c => c + 1)
}, [applyBoxId, editCodeService, applyingUri])
)
const onSubmit = useCallback(() => {
if (isDisabled) return
if (streamState() === 'streaming') return
const newApplyingUri = editCodeService.startApplying({
from: 'ClickApply',
type: 'searchReplace',
applyStr: codeStr,
})
applyingURIOfApplyBoxIdRef.current[applyBoxId] = newApplyingUri ?? undefined
rerender(c => c + 1)
metricsService.capture('Apply Code', { length: codeStr.length }) // capture the length only
}, [isDisabled, streamState, editCodeService, codeStr, applyBoxId, metricsService])
const onInterrupt = useCallback(() => {
if (streamState() !== 'streaming') return
const uri = applyingUri()
if (!uri) return
editCodeService.interruptURIStreaming({ uri })
metricsService.capture('Stop Apply', {})
}, [streamState, applyingUri, editCodeService, metricsService])
const isSingleLine = !codeStr.includes('\n')
const applyButton = <button
// btn btn-secondary btn-sm border text-sm border-vscode-input-border rounded
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-1 text-void-fg-1 hover:brightness-110 border border-vscode-input-border rounded`}
onClick={onSubmit}
>
Apply
</button>
const stopButton = <button
// btn btn-secondary btn-sm border text-sm border-vscode-input-border rounded
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-1 text-void-fg-1 hover:brightness-110 border border-vscode-input-border rounded`}
onClick={onInterrupt}
>
Stop
</button>
const acceptRejectButtons = <>
<button
// btn btn-secondary btn-sm border text-sm border-vscode-input-border rounded
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-1 text-void-fg-1 hover:brightness-110 border border-vscode-input-border rounded`}
onClick={() => {
const uri = applyingUri()
if (uri) editCodeService.removeDiffAreas({ uri, behavior: 'accept', removeCtrlKs: false })
}}
>
Accept
</button>
<button
// btn btn-secondary btn-sm border text-sm border-vscode-input-border rounded
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-1 text-void-fg-1 hover:brightness-110 border border-vscode-input-border rounded`}
onClick={() => {
const uri = applyingUri()
if (uri) editCodeService.removeDiffAreas({ uri, behavior: 'reject', removeCtrlKs: false })
}}
>
Reject
</button>
</>
console.log('streamStateRef.current', streamState())
const currStreamState = streamState()
return <>
{currStreamState !== 'streaming' && <CopyButton codeStr={codeStr} />}
{currStreamState === 'idle' && !isDisabled && applyButton}
{currStreamState === 'streaming' && stopButton}
{currStreamState === 'acceptRejectAll' && acceptRejectButtons}
</>
}
@@ -13,7 +13,7 @@ export const BlockCode = ({ buttonsOnHover, ...codeEditorProps }: { buttonsOnHov
return (
<>
<div className="relative group w-full overflow-hidden my-4">
<div className="relative group w-full overflow-hidden">
{buttonsOnHover === null ? null : (
<div className={`z-[1] absolute top-0 right-0 opacity-0 group-hover:opacity-100 duration-200 ${isSingleLine ? 'h-full flex items-center' : ''}`}>
<div className={`flex space-x-1 ${isSingleLine ? 'pr-2' : 'p-2'}`}>
@@ -3,76 +3,26 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import React, { JSX, useCallback, useEffect, useState } from 'react'
import React, { JSX } from 'react'
import { marked, MarkedToken, Token } from 'marked'
import { BlockCode } from './BlockCode.js'
import { useAccessor, useChatThreadsState, useChatThreadsStreamState } from '../util/services.js'
import { ChatLocation, getApplyBoxId, } from '../../../searchAndReplaceService.js'
import { nameToVscodeLanguage } from '../../../helpers/detectLanguage.js'
import { ApplyBlockHoverButtons } from './ApplyBlockHoverButtons.js'
enum CopyButtonState {
Copy = 'Copy',
Copied = 'Copied!',
Error = 'Could not copy',
export type ChatMessageLocation = {
threadId: string;
messageIdx: number;
}
const COPY_FEEDBACK_TIMEOUT = 1000 // amount of time to say 'Copied!'
const ApplyButtonsOnHover = ({ applyStr, applyBoxId }: { applyStr: string, applyBoxId: string }) => {
const accessor = useAccessor()
type ApplyBoxLocation = ChatMessageLocation & { tokenIdx: string }
const [copyButtonState, setCopyButtonState] = useState(CopyButtonState.Copy)
const inlineDiffService = accessor.get('IInlineDiffsService')
const clipboardService = accessor.get('IClipboardService')
const metricsService = accessor.get('IMetricsService')
useEffect(() => {
if (copyButtonState !== CopyButtonState.Copy) {
setTimeout(() => {
setCopyButtonState(CopyButtonState.Copy)
}, COPY_FEEDBACK_TIMEOUT)
}
}, [copyButtonState])
const onCopy = useCallback(() => {
clipboardService.writeText(applyStr)
.then(() => { setCopyButtonState(CopyButtonState.Copied) })
.catch(() => { setCopyButtonState(CopyButtonState.Error) })
metricsService.capture('Copy Code', { length: applyStr.length }) // capture the length only
}, [metricsService, clipboardService, applyStr])
const onApply = useCallback(() => {
inlineDiffService.startApplying({
from: 'Chat',
applyStr,
applyBoxId,
})
metricsService.capture('Apply Code', { length: applyStr.length }) // capture the length only
}, [metricsService, inlineDiffService, applyStr])
const isSingleLine = !applyStr.includes('\n')
return <>
<button
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-1 text-void-fg-1 hover:brightness-110 border border-vscode-input-border rounded`}
onClick={onCopy}
>
{copyButtonState}
</button>
<button
// btn btn-secondary btn-sm border text-sm border-vscode-input-border rounded
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-1 text-void-fg-1 hover:brightness-110 border border-vscode-input-border rounded`}
onClick={onApply}
>
Apply
</button>
</>
const getApplyBoxId = ({ threadId, messageIdx, tokenIdx }: ApplyBoxLocation) => {
return `${threadId}-${messageIdx}-${tokenIdx}`
}
export const CodeSpan = ({ children, className }: { children: React.ReactNode, className?: string }) => {
return <code className={`
bg-void-bg-1
@@ -87,7 +37,7 @@ export const CodeSpan = ({ children, className }: { children: React.ReactNode, c
</code>
}
const RenderToken = ({ token, nested = false, noSpace = false, chatLocation, tokenId = '', }: { token: Token | string, nested?: boolean, noSpace?: boolean, chatLocation?: ChatLocation, tokenId?: string, }): JSX.Element => {
const RenderToken = ({ token, nested, noSpace, chatMessageLocationForApply, tokenIdx }: { token: Token | string, nested?: boolean, noSpace?: boolean, chatMessageLocationForApply?: ChatMessageLocation, tokenIdx: string }): JSX.Element => {
// deal with built-in tokens first (assume marked token)
@@ -98,19 +48,20 @@ const RenderToken = ({ token, nested = false, noSpace = false, chatLocation, tok
}
if (t.type === "code") {
const isCodeblockClosed = t.raw?.startsWith('```') && t.raw?.endsWith('```');
const applyBoxId = getApplyBoxId({
threadId: chatLocation!.threadId,
messageIdx: chatLocation!.messageIdx,
codeblockId: tokenId,
})
const applyBoxId = chatMessageLocationForApply ? getApplyBoxId({
threadId: chatMessageLocationForApply.threadId,
messageIdx: chatMessageLocationForApply.messageIdx,
tokenIdx: tokenIdx,
}) : null
return <BlockCode
return <div className='my-4'>
<BlockCode
initValue={t.text}
language={t.lang === undefined ? undefined : nameToVscodeLanguage[t.lang]}
buttonsOnHover={<ApplyButtonsOnHover applyStr={t.text} applyBoxId={applyBoxId} />}
buttonsOnHover={applyBoxId && <ApplyBlockHoverButtons applyBoxId={applyBoxId} codeStr={t.text} />}
/>
</div>
}
if (t.type === "heading") {
@@ -184,18 +135,36 @@ const RenderToken = ({ token, nested = false, noSpace = false, chatLocation, tok
<input type="checkbox" checked={item.checked} readOnly className="mr-2 form-checkbox" />
)}
<span className="ml-1">
<ChatMarkdownRender string={item.text} nested={true} />
<ChatMarkdownRender chatMessageLocationForApply={chatMessageLocationForApply} string={item.text} nested={true} />
</span>
</li>
))}
</ListTag>
)
// attempt at indentation
// return (
// <ListTag
// start={t.start ? t.start : undefined}
// className={`pl-2 ${noSpace ? '' : 'my-4'} ${t.ordered ? "list-decimal" : "list-disc"}`}
// >
// {t.items.map((item, index) => (
// <li key={index} className={`${noSpace ? '' : 'mb-2'} ml-4`}>
// {item.task && (
// <input type="checkbox" className='mr-2 form-checkbox' checked={item.checked} readOnly />
// )}
// <span className-='inline-block pr-2'>
// <ChatMarkdownRender chatMessageLocation={chatMessageLocation} string={item.text} nested={true} />
// </span>
// </li>
// ))}
// </ListTag>
// )
}
if (t.type === "paragraph") {
const contents = <>
{t.tokens.map((token, index) => (
<RenderToken key={index} token={token} tokenId={`${tokenId}-${index}`} /> // assign a unique tokenId to nested components
<RenderToken key={index} token={token} tokenIdx={`${tokenIdx ? `${tokenIdx}-` : ''}${index}`} /> // assign a unique tokenId to nested components
))}
</>
if (nested) return contents
@@ -278,12 +247,12 @@ const RenderToken = ({ token, nested = false, noSpace = false, chatLocation, tok
)
}
export const ChatMarkdownRender = ({ string, nested = false, noSpace, chatLocation }: { string: string, nested?: boolean, noSpace?: boolean, chatLocation?: ChatLocation }) => {
export const ChatMarkdownRender = ({ string, nested = false, noSpace, chatMessageLocationForApply }: { string: string, nested?: boolean, noSpace?: boolean, chatMessageLocationForApply?: ChatMessageLocation }) => {
const tokens = marked.lexer(string); // https://marked.js.org/using_pro#renderer
return (
<>
{tokens.map((token, index) => (
<RenderToken key={index} token={token} nested={nested} noSpace={noSpace} chatLocation={chatLocation} />
<RenderToken key={index} token={token} nested={nested} noSpace={noSpace} chatMessageLocationForApply={chatMessageLocationForApply} tokenIdx={index + ''} />
))}
</>
)
@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------*/
import React, { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useSettingsState, useSidebarState, useChatThreadsState, useQuickEditState, useAccessor } from '../util/services.js';
import { useSettingsState, useSidebarState, useChatThreadsState, useQuickEditState, useAccessor, useCtrlKZoneStreamingState } from '../util/services.js';
import { TextAreaFns, VoidInputBox2 } from '../util/inputs.js';
import { QuickEditPropsType } from '../../../quickEditActions.js';
import { ButtonStop, ButtonSubmit, IconX, VoidChatArea } from '../sidebar-tsx/SidebarChat.js';
@@ -16,7 +16,6 @@ import { isFeatureNameDisabled } from '../../../../../../../workbench/contrib/vo
export const QuickEditChat = ({
diffareaid,
initStreamingDiffZoneId,
onChangeHeight,
onChangeText: onChangeText_,
textAreaRef: textAreaRef_,
@@ -24,7 +23,7 @@ export const QuickEditChat = ({
}: QuickEditPropsType) => {
const accessor = useAccessor()
const inlineDiffsService = accessor.get('IInlineDiffsService')
const editCodeService = accessor.get('IEditCodeService')
const sizerRef = useRef<HTMLDivElement | null>(null)
const textAreaRef = useRef<HTMLTextAreaElement | null>(null)
const textAreaFnsRef = useRef<TextAreaFns | null>(null)
@@ -49,33 +48,37 @@ export const QuickEditChat = ({
const [instructionsAreEmpty, setInstructionsAreEmpty] = useState(!(initText ?? '')) // the user's instructions
const isDisabled = instructionsAreEmpty || !!isFeatureNameDisabled('Ctrl+K', settingsState)
const [currStreamingDiffZoneRef, setCurrentlyStreamingDiffZone] = useRefState<number | null>(initStreamingDiffZoneId)
const isStreaming = currStreamingDiffZoneRef.current !== null
const [isStreamingRef, setIsStreamingRef] = useRefState(editCodeService.isCtrlKZoneStreaming({ diffareaid }))
useCtrlKZoneStreamingState(useCallback((diffareaid2, isStreaming) => {
if (diffareaid !== diffareaid2) return
setIsStreamingRef(isStreaming)
}, [diffareaid, setIsStreamingRef]))
const onSubmit = useCallback(() => {
if (isDisabled) return
if (currStreamingDiffZoneRef.current !== null) return
if (isStreamingRef.current) return
textAreaFnsRef.current?.disable()
const id = inlineDiffsService.startApplying({
editCodeService.startApplying({
from: 'QuickEdit',
diffareaid: diffareaid,
type: 'rewrite',
diffareaid,
})
setCurrentlyStreamingDiffZone(id ?? null)
}, [currStreamingDiffZoneRef, setCurrentlyStreamingDiffZone, isDisabled, inlineDiffsService, diffareaid])
}, [isStreamingRef, isDisabled, editCodeService, diffareaid])
const onInterrupt = useCallback(() => {
if (currStreamingDiffZoneRef.current === null) return
inlineDiffsService.interruptStreaming(currStreamingDiffZoneRef.current)
setCurrentlyStreamingDiffZone(null)
if (!isStreamingRef.current) return
editCodeService.interruptCtrlKStreaming({ diffareaid })
textAreaFnsRef.current?.enable()
}, [currStreamingDiffZoneRef, setCurrentlyStreamingDiffZone, inlineDiffsService])
}, [isStreamingRef, editCodeService])
const onX = useCallback(() => {
onInterrupt()
inlineDiffsService.removeCtrlKZone({ diffareaid })
}, [inlineDiffsService, diffareaid])
editCodeService.removeCtrlKZone({ diffareaid })
}, [editCodeService, diffareaid])
useScrollbarStyles(sizerRef)
@@ -88,7 +91,7 @@ export const QuickEditChat = ({
onSubmit={onSubmit}
onAbort={onInterrupt}
onClose={onX}
isStreaming={isStreaming}
isStreaming={isStreamingRef.current}
isDisabled={isDisabled}
featureName="Ctrl+K"
className="py-2 w-full"
@@ -7,10 +7,10 @@ import React, { ButtonHTMLAttributes, FormEvent, FormHTMLAttributes, Fragment, K
import { useAccessor, useSidebarState, useChatThreadsState, useChatThreadsStreamState, useUriState, useSettingsState } from '../util/services.js';
import { ChatMessage, StagingSelectionItem } from '../../../chatThreadService.js';
import { ChatMessage, StagingSelectionItem, ToolMessage } from '../../../../common/chatThreadService.js';
import { BlockCode } from '../markdown/BlockCode.js';
import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js';
import { ChatMarkdownRender, ChatMessageLocation } from '../markdown/ChatMarkdownRender.js';
import { URI } from '../../../../../../../base/common/uri.js';
import { IDisposable } from '../../../../../../../base/common/lifecycle.js';
import { ErrorDisplay } from './ErrorDisplay.js';
@@ -21,10 +21,11 @@ import { useScrollbarStyles } from '../util/useScrollbarStyles.js';
import { VOID_CTRL_L_ACTION_ID } from '../../../actionIDs.js';
import { filenameToVscodeLanguage } from '../../../helpers/detectLanguage.js';
import { VOID_OPEN_SETTINGS_ACTION_ID } from '../../../voidSettingsPane.js';
import { Pencil, X } from 'lucide-react';
import { ChevronRight, Pencil, X } from 'lucide-react';
import { FeatureName, isFeatureNameDisabled } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js';
import { WarningBox } from '../void-settings-tsx/WarningBox.js';
import { ChatLocation } from '../../../searchAndReplaceService.js';
import { ToolCallReturnType, ToolName } from '../../../../common/toolsService.js';
@@ -138,6 +139,9 @@ export const IconLoading = ({ className = '' }: { className?: string }) => {
}
const getChatBubbleId = (threadId: string, messageIdx: number) => `${threadId}-${messageIdx}`;
interface VoidChatAreaProps {
// Required
children: React.ReactNode; // This will be the input component
@@ -186,12 +190,15 @@ export const VoidChatArea: React.FC<VoidChatAreaProps> = ({
return (
<div
ref={divRef}
// border border-void-border-3 focus-within:border-void-border-1 hover:border-void-border-1
className={`
flex flex-col gap-1 p-2 relative input text-left shrink-0
gap-1
flex flex-col p-2 relative input text-left shrink-0
transition-all duration-200
rounded-md
bg-vscode-input-bg
border border-void-border-3 focus-within:border-void-border-1 hover:border-void-border-1
outline-1 outline-void-border-3 focus-within:outline-void-border-1 hover:outline-void-border-1
max-h-[80vh] overflow-y-auto
${className}
`}
onClick={(e) => {
@@ -370,12 +377,6 @@ export const SelectedFiles = (
| { type: 'staging', selections: StagingSelectionItem[]; setSelections: ((newSelections: StagingSelectionItem[]) => void), showProspectiveSelections?: boolean }
) => {
// index -> isOpened
const [selectionIsOpened, setSelectionIsOpened] = useState<(boolean)[]>(selections?.map(() => false) ?? [])
// state for tracking hover on clear all button
const [isClearHovered, setIsClearHovered] = useState(false)
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
@@ -403,6 +404,7 @@ export const SelectedFiles = (
fileURI: uri,
selectionStr: null,
range: null,
state: { isOpened: false },
}))
}
@@ -413,106 +415,96 @@ export const SelectedFiles = (
}
return (
<div className='flex items-center flex-wrap text-left relative'>
<div className='flex items-center flex-wrap text-left relative gap-x-0.5 gap-y-1'>
{allSelections.map((selection, i) => {
const isThisSelectionOpened = !!(selection.selectionStr && selectionIsOpened[i])
const isThisSelectionOpened = (!!selection.selectionStr && selection.state.isOpened) //!!(selection.selectionStr && selectionIsOpened[i])
const isThisSelectionAFile = selection.selectionStr === null
const isThisSelectionProspective = i > selections.length - 1
const thisKey = `${isThisSelectionProspective}-${i}-${selections.length}`
const selectionHTML = (<div key={thisKey} // container for `selectionSummary` and `selectionText`
return <div // container for summarybox and code
key={thisKey}
className={`
flex flex-col space-y-[1px]
${isThisSelectionOpened ? 'w-full' : ''}
`}
>
{/* selection summary */}
<div // container for item and its delete button (if it's last)
className='flex items-center gap-1 mr-0.5 my-0.5'
{/* summarybox */}
<div
className={`
flex items-center gap-0.5 relative
px-1
w-fit h-fit
select-none
${isThisSelectionProspective ? 'bg-void-bg-1 text-void-fg-3 opacity-80' : 'bg-void-bg-3 hover:brightness-95 text-void-fg-1'}
text-xs text-nowrap
border rounded-sm ${isThisSelectionProspective
? 'border-void-border-2'
: isThisSelectionOpened
? 'border-void-border-1 ring-1 ring-[#007FD4]'
: 'border-void-border-1'
}
hover:border-void-border-1
transition-all duration-150
`}
onClick={() => {
if (type !== 'staging') return; // (never)
if (isThisSelectionProspective) { // add prospective selection to selections
setSelections([...selections, selection])
} else if (isThisSelectionAFile) { // open files
commandService.executeCommand('vscode.open', selection.fileURI, {
preview: true,
// preserveFocus: false,
});
} else { // show text
const selection = selections[i]
const newSelection = { ...selection, state: { isOpened: !selection.state.isOpened } }
const newSelections = [
...selections.slice(0, i),
newSelection,
...selections.slice(i + 1)
]
setSelections(newSelections)
// setSelectionIsOpened(s => {
// const newS = [...s]
// newS[i] = !newS[i]
// return newS
// });
}
}}
>
<div // styled summary box
className={`flex items-center gap-0.5 relative
px-1
w-fit h-fit
select-none
${isThisSelectionProspective ? 'bg-void-1 text-void-fg-3 opacity-80' : 'bg-void-bg-3 hover:brightness-95 text-void-fg-1'}
text-xs text-nowrap
border rounded-sm ${isClearHovered && !isThisSelectionProspective ? 'border-void-border-1' : 'border-void-border-2'} hover:border-void-border-1
transition-all duration-150`}
onClick={() => {
if (isThisSelectionProspective) { // add prospective selection to selections
if (type !== 'staging') return; // (never)
setSelections([...selections, selection])
{ // file name and range
getBasename(selection.fileURI.fsPath)
+ (isThisSelectionAFile ? '' : ` (${selection.range.startLineNumber}-${selection.range.endLineNumber})`)
}
} else if (isThisSelectionAFile) { // open files
commandService.executeCommand('vscode.open', selection.fileURI, {
preview: true,
// preserveFocus: false,
});
} else { // show text
setSelectionIsOpened(s => {
const newS = [...s]
newS[i] = !newS[i]
return newS
});
}
}}
>
<span>
{/* file name */}
{getBasename(selection.fileURI.fsPath)}
{/* selection range */}
{!isThisSelectionAFile ? ` (${selection.range.startLineNumber}-${selection.range.endLineNumber})` : ''}
</span>
{/* X button */}
{type === 'staging' && !isThisSelectionProspective &&
<span
className='cursor-pointer z-1'
onClick={(e) => {
e.stopPropagation(); // don't open/close selection
if (type !== 'staging') return;
setSelections([...selections.slice(0, i), ...selections.slice(i + 1)])
setSelectionIsOpened(o => [...o.slice(0, i), ...o.slice(i + 1)])
}}
>
<IconX size={10} className="stroke-[2]" />
</span>}
</div>
{/* clear all selections button */}
{/* {type !== 'staging' || selections.length === 0 || i !== selections.length - 1
? null
: <div className={`flex items-center ${isThisSelectionOpened ? 'w-full' : ''}`}>
<div
className='rounded-md'
onMouseEnter={() => setIsClearHovered(true)}
onMouseLeave={() => setIsClearHovered(false)}
>
<Delete
size={16}
className={`stroke-[1]
stroke-void-fg-1
fill-void-bg-3
opacity-40
hover:opacity-60
transition-all duration-150
cursor-pointer
`}
onClick={() => { setSelections([]) }}
/>
</div>
</div>
} */}
{type === 'staging' && !isThisSelectionProspective ? // X button
<IconX
className='cursor-pointer z-1 stroke-[2]'
onClick={(e) => {
e.stopPropagation(); // don't open/close selection
if (type !== 'staging') return;
setSelections([...selections.slice(0, i), ...selections.slice(i + 1)])
}}
size={10}
/>
: <></>
}
</div>
{/* selection text */}
{isThisSelectionOpened &&
{/* code box */}
{isThisSelectionOpened ?
<div
className='w-full px-1 rounded-sm border-vscode-editor-border'
className={`
w-full px-1 rounded-sm border-vscode-editor-border
${isThisSelectionOpened ? 'ring-1 ring-[#007FD4]' : ''}
`}
onClick={(e) => {
e.stopPropagation(); // don't focus input box
}}
@@ -524,14 +516,9 @@ export const SelectedFiles = (
showScrollbars={true}
/>
</div>
: <></>
}
</div>)
return <Fragment key={thisKey}>
{/* divider between `selections` and `prospectiveSelections` */}
{/* {selections.length > 0 && i === selections.length && <div className='w-full'></div>} */}
{selectionHTML}
</Fragment>
</div>
})}
@@ -542,26 +529,215 @@ export const SelectedFiles = (
}
type ToolResultToComponent = { [T in ToolName]: (props: { message: ToolMessage<T> }) => React.ReactNode }
interface ToolResultProps {
actionTitle: string;
actionParam: string;
actionNumResults?: number;
children?: React.ReactNode;
onClick?: () => void;
}
const ToolResult = ({
actionTitle,
actionParam,
actionNumResults,
children,
onClick,
}: ToolResultProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const isDropdown = !!children
const isClickable = !!isDropdown || !!onClick
return (
<div className="mx-4 select-none">
<div className="border border-void-border-3 rounded px-2 py-1 bg-void-bg-2-alt overflow-hidden">
<div
className={`flex items-center min-h-[24px] ${isClickable ? 'cursor-pointer hover:brightness-125 transition-all duration-150' : ''} ${!isDropdown ? 'mx-1' : ''}`}
onClick={() => {
if (children) { setIsExpanded(v => !v); }
if (onClick) { onClick(); }
}}
>
{isDropdown && (
<ChevronRight
className={`text-void-fg-3 mr-0.5 h-5 w-5 flex-shrink-0 transition-transform duration-100 ease-[cubic-bezier(0.4,0,0.2,1)] ${isExpanded ? 'rotate-90' : ''}`}
/>
)}
<div className="flex items-center flex-nowrap whitespace-nowrap gap-x-2">
<span className="text-void-fg-3">{actionTitle}</span>
<span className="text-void-fg-4 text-xs italic">{actionParam}</span>
{actionNumResults !== undefined && (
<span className="text-void-fg-4 text-xs">
{`(`}{actionNumResults}{` result`}{actionNumResults !== 1 ? 's' : ''}{`)`}
</span>
)}
</div>
</div>
<div
// the py-1 here makes sure all elements in the container have py-2 total. this makes a nice animation effect during transition.
className={`overflow-hidden transition-all duration-200 ease-in-out ${isExpanded ? 'opacity-100 py-1' : 'max-h-0 opacity-0'}`}
>
{children}
</div>
</div>
</div>
);
};
const toolResultToComponent: ToolResultToComponent = {
'read_file': ({ message }) => {
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
return (
<ToolResult
actionTitle="Read file"
actionParam={getBasename(message.result.uri.fsPath)}
onClick={() => { commandService.executeCommand('vscode.open', message.result.uri, { preview: true }) }}
/>
)
},
'list_dir': ({ message }) => {
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
const explorerService = accessor.get('IExplorerService')
// message.result.hasNextPage = true
// message.result.itemsRemaining = 400
return (
<ToolResult
actionTitle="Inspected folder"
actionParam={`${getBasename(message.result.rootURI.fsPath)}/`}
actionNumResults={message.result.children?.length}
>
<div className="text-void-fg-4 px-2 py-1 bg-black bg-opacity-20 border border-void-border-4 border-opacity-50 rounded-sm">
{message.result.children?.map((child, i) => (
<div
key={i}
className="hover:brightness-125 hover:cursor-pointer transition-all duration-200 flex items-center flex-nowrap"
onClick={() => {
commandService.executeCommand('workbench.view.explorer');
explorerService.select(child.uri, true);
}}
>
<svg className="w-1 h-1 opacity-60 mr-1.5 fill-current" viewBox="0 0 100 40"><rect x="0" y="15" width="100" height="10" /></svg>
{`${child.name}${child.isDirectory ? '/' : ''}`}
</div>
))}
{message.result.hasNextPage && (
<div className="italic">
{message.result.itemsRemaining} more items...
</div>
)}
</div>
</ToolResult>
)
},
'pathname_search': ({ message }) => {
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
return (
<ToolResult
actionTitle="Searched filename"
actionParam={`"${message.result.queryStr}"`}
actionNumResults={Array.isArray(message.result.uris) ? message.result.uris.length : 0}
>
<div className="text-void-fg-4 px-2 py-1 bg-black bg-opacity-20 border border-void-border-4 border-opacity-50 rounded-sm">
{Array.isArray(message.result.uris) ?
message.result.uris.map((uri, i) => (
<div
key={i}
className="hover:brightness-125 hover:cursor-pointer transition-all duration-200 flex items-center flex-nowrap"
onClick={() => {
commandService.executeCommand('vscode.open', uri, { preview: true })
}}
>
<svg className="w-1 h-1 opacity-60 mr-1.5 fill-current" viewBox="0 0 100 40"><rect x="0" y="15" width="100" height="10" /></svg>
{uri.fsPath.split('/').pop()}
</div>
)) :
<div className="">{message.result.uris}</div>
}
{message.result.hasNextPage && (
<div className="italic">
More results available...
</div>
)}
</div>
</ToolResult>
)
},
'search': ({ message }) => {
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
return (
<ToolResult
actionTitle="Searched"
actionParam={`"${message.result.queryStr}"`}
actionNumResults={Array.isArray(message.result.uris) ? message.result.uris.length : 0}
>
<div className="text-void-fg-4 px-2 py-1 bg-black bg-opacity-20 border border-void-border-4 border-opacity-50 rounded-sm">
{Array.isArray(message.result.uris) ?
message.result.uris.map((uri, i) => (
<div
key={i}
className="hover:brightness-125 hover:cursor-pointer transition-all duration-200 flex items-center flex-nowrap"
onClick={() => {
commandService.executeCommand('vscode.open', uri, { preview: true })
}}
>
<svg className="w-1 h-1 opacity-60 mr-1.5 fill-current" viewBox="0 0 100 40"><rect x="0" y="15" width="100" height="10" /></svg>
{uri.fsPath.split('/').pop()}
</div>
)) :
<div className="">{message.result.uris}</div>
}
{message.result.hasNextPage && (
<div className="italic">
More results available...
</div>
)}
</div>
</ToolResult>
)
}
};
type ChatBubbleMode = 'display' | 'edit'
const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatMessage, messageIdx?: number, isLoading?: boolean, }) => {
const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatMessage, messageIdx: number, isLoading?: boolean, }) => {
const role = chatMessage.role
// Only show reasoning dropdown when there's actual content
const reasoningStr = (chatMessage.role === 'assistant' && chatMessage.reasoning?.trim()) || null
const hasReasoning = !!reasoningStr
const [isReasoningOpen, setIsReasoningOpen] = useState(false)
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
// global state
let isBeingEdited = false
let setIsBeingEdited = (v: boolean) => { }
let stagingSelections: StagingSelectionItem[] = []
let setStagingSelections = (s: StagingSelectionItem[]) => { }
let setIsBeingEdited = (_: boolean) => { }
let setStagingSelections = (_: StagingSelectionItem[]) => { }
if (messageIdx !== undefined) {
const [_state, _setState] = chatThreadsService._useCurrentMessageState(messageIdx)
const _state = chatThreadsService.getCurrentMessageState(messageIdx)
isBeingEdited = _state.isBeingEdited
setIsBeingEdited = (v) => _setState({ isBeingEdited: v })
stagingSelections = _state.stagingSelections
setStagingSelections = (s) => { _setState({ stagingSelections: s }) }
setIsBeingEdited = (v) => chatThreadsService.setCurrentMessageState(messageIdx, { isBeingEdited: v })
setStagingSelections = (s) => chatThreadsService.setCurrentMessageState(messageIdx, { stagingSelections: s })
}
@@ -580,7 +756,6 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM
const shouldInitialize = _justEnabledEdit.current || _mustInitialize.current
if (canInitialize && shouldInitialize) {
setStagingSelections(chatMessage.selections || [])
if (textAreaFnsRef.current)
textAreaFnsRef.current.setValue(chatMessage.displayContent || '')
@@ -590,7 +765,7 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM
_mustInitialize.current = false
}
}, [role, mode, _justEnabledEdit, textAreaRefState, textAreaFnsRef.current, _justEnabledEdit.current, _mustInitialize.current])
}, [chatMessage, role, mode, _justEnabledEdit, textAreaRefState, textAreaFnsRef.current, _justEnabledEdit.current, _mustInitialize.current])
const EditSymbol = mode === 'display' ? Pencil : X
const onOpenEdit = () => {
setIsBeingEdited(true)
@@ -610,7 +785,7 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM
if (mode === 'display') {
chatbubbleContents = <>
<SelectedFiles type='past' selections={chatMessage.selections || []} />
{chatMessage.displayContent}
<span className='px-0.5'>{chatMessage.displayContent}</span>
</>
}
else if (mode === 'edit') {
@@ -631,7 +806,7 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM
// stream the edit
const userMessage = textAreaRefState.value;
await chatThreadsService.editUserMessageAndStreamResponse(userMessage, messageIdx)
await chatThreadsService.editUserMessageAndStreamResponse({ userMessage, chatMode: 'agent', messageIdx, })
}
const onAbort = () => {
@@ -666,7 +841,7 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM
>
<VoidInputBox2
ref={setTextAreaRef}
className='min-h-[81px] max-h-[500px] p-1'
className='min-h-[81px] max-h-[500px] px-0.5'
placeholder="Edit your message..."
onChangeText={(text) => setIsDisabled(!text)}
onFocus={() => {
@@ -687,12 +862,53 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM
else if (role === 'assistant') {
const thread = chatThreadsService.getCurrentThread()
const chatLocation: ChatLocation = {
const chatMessageLocation: ChatMessageLocation = {
threadId: thread.id,
messageIdx: messageIdx!,
messageIdx: messageIdx,
}
chatbubbleContents = <ChatMarkdownRender string={chatMessage.displayContent ?? ''} chatLocation={chatLocation} />
const reasoningDropdown = hasReasoning ? (
<div className="mx-4 select-none mt-2">
<div className="border border-void-border-3 rounded px-1 py-0.5 bg-void-bg-tool">
<div
className="flex items-center min-h-[24px] cursor-pointer hover:brightness-125 transition-all duration-150"
onClick={() => setIsReasoningOpen(!isReasoningOpen)}
>
<ChevronRight
className={`text-void-fg-3 mr-0.5 h-5 w-5 flex-shrink-0 transition-transform duration-100 ease-[cubic-bezier(0.4,0,0.2,1)] ${isReasoningOpen ? 'rotate-90' : ''}`}
/>
<div className="flex items-center flex-wrap gap-x-2 gap-y-0.5">
<span className="text-void-fg-3">Reasoning</span>
<span className="text-void-fg-4 text-xs italic">Model's step-by-step thinking</span>
</div>
</div>
<div
className={`mt-1 overflow-hidden transition-all duration-200 ease-in-out ${isReasoningOpen ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0'}`}
>
<div className="text-void-fg-2 p-2 bg-void-bg-1 rounded">
<ChatMarkdownRender string={reasoningStr} chatMessageLocationForApply={chatMessageLocation} />
</div>
</div>
</div>
</div>
) : null
chatbubbleContents = (<>
{/* Reasoning dropdown (conditional) */}
{reasoningDropdown}
{/* Main content */}
<ChatMarkdownRender string={chatMessage.content ?? ''} chatMessageLocationForApply={chatMessageLocation} />
</>)
}
else if (role === 'tool') {
const ToolComponent = toolResultToComponent[chatMessage.name] as ({ message }: { message: any }) => React.ReactNode // ts isnt smart enough to deal with the types here...
chatbubbleContents = <ToolComponent message={chatMessage} />
console.log('tool result:', chatMessage.name, chatMessage.params, chatMessage.result)
}
return <div
@@ -703,7 +919,6 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM
: role === 'user' ? `px-2 self-end w-fit max-w-full whitespace-pre-wrap` // user words should be pre
: role === 'assistant' ? `px-2 self-start w-full max-w-full` : ''
}
${role !== 'assistant' ? 'my-2' : ''}
`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
@@ -714,7 +929,7 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM
text-left rounded-lg
max-w-full
${mode === 'edit' ? ''
: role === 'user' ? 'p-2 bg-void-bg-1 text-void-fg-1 overflow-x-auto'
: role === 'user' ? 'p-2 flex flex-col gap-1 bg-void-bg-1 text-void-fg-1 overflow-x-auto'
: role === 'assistant' ? 'px-2 overflow-x-auto' : ''
}
`}
@@ -778,15 +993,15 @@ export const SidebarChat = () => {
const currentThread = chatThreadsService.getCurrentThread()
const previousMessages = currentThread?.messages ?? []
const [_state, _setState] = chatThreadsService._useCurrentThreadState()
const selections = _state.stagingSelections
const setSelections = (s: StagingSelectionItem[]) => { _setState({ stagingSelections: s }) }
const selections = currentThread.state.stagingSelections
const setSelections = (s: StagingSelectionItem[]) => { chatThreadsService.setCurrentThreadState({ stagingSelections: s }) }
// stream state
const currThreadStreamState = useChatThreadsStreamState(chatThreadsState.currentThreadId)
const isStreaming = !!currThreadStreamState?.streamingToken
const latestError = currThreadStreamState?.error
const messageSoFar = currThreadStreamState?.messageSoFar
const reasoningSoFar = currThreadStreamState?.reasoningSoFar
// ----- SIDEBAR CHAT state (local) -----
@@ -810,13 +1025,13 @@ export const SidebarChat = () => {
// send message to LLM
const userMessage = textAreaRef.current?.value ?? ''
await chatThreadsService.addUserMessageAndStreamResponse(userMessage)
await chatThreadsService.addUserMessageAndStreamResponse({ userMessage, chatMode: 'agent' })
setSelections([]) // clear staging
textAreaFnsRef.current?.setValue('')
textAreaRef.current?.focus() // focus input after submit
}, [chatThreadsService, isDisabled, isStreaming, textAreaRef, textAreaFnsRef, selections, setSelections])
}, [chatThreadsService, isDisabled, isStreaming, textAreaRef, textAreaFnsRef, setSelections])
const onAbort = () => {
const threadId = currentThread.id
@@ -835,13 +1050,27 @@ export const SidebarChat = () => {
}, [isHistoryOpen, currentThread.id])
const prevMessagesHTML = useMemo(() => {
const pastMessagesHTML = useMemo(() => {
return previousMessages.map((message, i) =>
<ChatBubble key={`${message.displayContent}-${i}`} chatMessage={message} messageIdx={i} />
<ChatBubble key={getChatBubbleId(currentThread.id, i)} chatMessage={message} messageIdx={i} />
)
}, [previousMessages])
const streamingChatIdx = pastMessagesHTML.length
const currStreamingMessageHTML = !!(reasoningSoFar || messageSoFar || isStreaming) ?
<ChatBubble key={getChatBubbleId(currentThread.id, streamingChatIdx)}
messageIdx={streamingChatIdx} chatMessage={{
role: 'assistant',
content: messageSoFar ?? null,
reasoning: reasoningSoFar ?? null,
}}
isLoading={isStreaming}
/> : null
const allMessagesHTML = [...pastMessagesHTML, currStreamingMessageHTML]
const threadSelector = <div ref={historyRef}
className={`w-full h-auto ${isHistoryOpen ? '' : 'hidden'} ring-2 ring-widget-shadow ring-inset z-10`}
>
@@ -851,6 +1080,7 @@ export const SidebarChat = () => {
const messagesHTML = <ScrollToBottomContainer
key={currentThread.id} // force rerender on all children if id changes
scrollContainerRef={scrollContainerRef}
className={`
w-full h-auto
@@ -858,20 +1088,17 @@ export const SidebarChat = () => {
overflow-x-hidden
overflow-y-auto
py-4
${prevMessagesHTML.length === 0 && !messageSoFar ? 'hidden' : ''}
${pastMessagesHTML.length === 0 && !messageSoFar ? 'hidden' : ''}
`}
style={{ maxHeight: sidebarDimensions.height - historyDimensions.height - chatAreaDimensions.height - 36 }} // the height of the previousMessages is determined by all other heights
>
{/* previous messages */}
{prevMessagesHTML}
{/* message stream */}
<ChatBubble chatMessage={{ role: 'assistant', content: messageSoFar ?? '', displayContent: messageSoFar || null }} isLoading={isStreaming} />
{allMessagesHTML}
{/* error message */}
{latestError === undefined ? null :
<div className='px-2'>
<div className='px-2 my-1'>
<ErrorDisplay
message={latestError.message}
fullError={latestError.fullError}
@@ -901,14 +1128,14 @@ export const SidebarChat = () => {
isStreaming={isStreaming}
isDisabled={isDisabled}
showSelections={true}
showProspectiveSelections={prevMessagesHTML.length === 0}
showProspectiveSelections={pastMessagesHTML.length === 0}
selections={selections}
setSelections={setSelections}
onClickAnywhere={() => { textAreaRef.current?.focus() }}
featureName="Ctrl+L"
>
<VoidInputBox2
className='min-h-[81px] p-1'
className='min-h-[81px] px-0.5'
placeholder={`${keybindingString ? `${keybindingString} to select. ` : ''}Enter instructions...`}
onChangeText={onChangeText}
onKeyDown={onKeyDown}
@@ -68,13 +68,14 @@ export const SidebarThreadSelector = () => {
let firstMsg = null;
// let secondMsg = null;
const firstMsgIdx = pastThread.messages.findIndex(
(msg) => msg.role !== 'system' && !!msg.displayContent
const firstUserMsgIdx = pastThread.messages.findIndex(
(msg) => msg.role !== 'system' && msg.role !== 'tool' && !!msg.displayContent
);
if (firstMsgIdx !== -1) {
if (firstUserMsgIdx !== -1) {
// firstMsg = truncate(pastThread.messages[firstMsgIdx].displayContent ?? '');
firstMsg = pastThread.messages[firstMsgIdx].displayContent ?? '';
const firsUsertMsgObj = pastThread.messages[firstUserMsgIdx]
firstMsg = firsUsertMsgObj.role === 'user' && firsUsertMsgObj.displayContent || '';
} else {
firstMsg = '""';
}
@@ -152,12 +152,13 @@ export const VoidInputBox2 = forwardRef<HTMLTextAreaElement, InputBox2Props>(fun
})
export const VoidInputBox = ({ onChangeText, onCreateInstance, inputBoxRef, placeholder, multiline }: {
export const VoidInputBox = ({ onChangeText, onCreateInstance, inputBoxRef, placeholder, isPasswordField, multiline }: {
onChangeText: (value: string) => void;
styles?: Partial<IInputBoxStyles>,
onCreateInstance?: (instance: InputBox) => void | IDisposable[];
inputBoxRef?: { current: InputBox | null };
placeholder: string;
isPasswordField?: boolean;
multiline: boolean;
}) => {
@@ -182,6 +183,7 @@ export const VoidInputBox = ({ onChangeText, onCreateInstance, inputBoxRef, plac
},
placeholder,
tooltip: '',
type: isPasswordField ? 'password' : undefined,
flexibleHeight: multiline,
flexibleMaxHeight: 500,
flexibleWidth: false,
@@ -308,6 +310,7 @@ export const VoidCustomDropdownBox = <T extends any>({
selectedOption,
onChangeOption,
getOptionDropdownName,
getOptionDropdownDetail,
getOptionDisplayName,
getOptionsEqual,
className,
@@ -319,6 +322,7 @@ export const VoidCustomDropdownBox = <T extends any>({
selectedOption: T | undefined;
onChangeOption: (newValue: T) => void;
getOptionDropdownName: (option: T) => string;
getOptionDropdownDetail?: (option: T) => string;
getOptionDisplayName: (option: T) => string;
getOptionsEqual: (a: T, b: T) => boolean;
className?: string;
@@ -418,12 +422,21 @@ export const VoidCustomDropdownBox = <T extends any>({
className="opacity-0 pointer-events-none absolute -left-[999999px] -top-[999999px] flex flex-col"
aria-hidden="true"
>
{options.map((option) => (
<div key={getOptionDropdownName(option)} className="flex items-center whitespace-nowrap">
<div className="w-4" />
<span className="px-2">{getOptionDropdownName(option)}</span>
</div>
))}
{options.map((option) => {
const optionName = getOptionDropdownName(option);
const optionDetail = getOptionDropdownDetail?.(option) || '';
return (
<div key={optionName + optionDetail} className="flex items-center whitespace-nowrap">
<div className="w-4" />
<span className="flex justify-between w-full">
<span>{optionName}</span>
<span>{optionDetail}</span>
<span>______</span>
</span>
</div>
)
})}
</div>
{/* Select Button */}
@@ -471,6 +484,7 @@ export const VoidCustomDropdownBox = <T extends any>({
{options.map((option) => {
const thisOptionIsSelected = getOptionsEqual(option, selectedOption);
const optionName = getOptionDropdownName(option);
const optionDetail = getOptionDropdownDetail?.(option) || '';
return (
<div
@@ -498,7 +512,10 @@ export const VoidCustomDropdownBox = <T extends any>({
</svg>
)}
</div>
<span>{optionName}</span>
<span className="flex justify-between w-full">
<span>{optionName}</span>
<span className='text-void-fg-4 opacity-60'>{optionDetail}</span>
</span>
</div>
);
})}
@@ -711,7 +728,7 @@ export const VoidCodeEditor = ({ initValue, language, maxHeight, showScrollbars
onCreateInstance={useCallback((editor: CodeEditorWidget) => {
const model = modelOfEditorId[id] ?? modelService.createModel(
initValueRef.current, {
initValueRef.current + '\n', {
languageId: languageRef.current ? languageRef.current : 'typescript',
onDidChange: (e) => { return { dispose: () => { } } } // no idea why they'd require this
})
@@ -3,8 +3,8 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import React, { useState, useEffect } from 'react'
import { ThreadStreamState, ThreadsState } from '../../../chatThreadService.js'
import React, { useState, useEffect, useCallback } from 'react'
import { ThreadStreamState,IChatThreadService, ThreadsState } from '../../../../common/chatThreadService.js'
import { RefreshableProviderName, SettingsOfProvider } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js'
import { IDisposable } from '../../../../../../../base/common/lifecycle.js'
import { VoidSidebarState } from '../../../sidebarStateService.js'
@@ -14,11 +14,8 @@ import { VoidUriState } from '../../../voidUriStateService.js';
import { VoidQuickEditState } from '../../../quickEditStateService.js'
import { RefreshModelStateOfProvider } from '../../../../../../../workbench/contrib/void/common/refreshModelService.js'
import { ServicesAccessor } from '../../../../../../../editor/browser/editorExtensions.js';
import { IExplorerService } from '../../../../../../../workbench/contrib/files/browser/files.js'
import { IModelService } from '../../../../../../../editor/common/services/model.js';
import { IClipboardService } from '../../../../../../../platform/clipboard/common/clipboardService.js';
import { IContextViewService, IContextMenuService } from '../../../../../../../platform/contextview/browser/contextView.js';
@@ -28,11 +25,10 @@ import { IThemeService } from '../../../../../../../platform/theme/common/themeS
import { ILLMMessageService } from '../../../../../../../workbench/contrib/void/common/llmMessageService.js';
import { IRefreshModelService } from '../../../../../../../workbench/contrib/void/common/refreshModelService.js';
import { IVoidSettingsService } from '../../../../../../../workbench/contrib/void/common/voidSettingsService.js';
import { IInlineDiffsService } from '../../../inlineDiffsService.js';
import { IEditCodeService, URIStreamState } from '../../../editCodeService.js';
import { IVoidUriStateService } from '../../../voidUriStateService.js';
import { IQuickEditStateService } from '../../../quickEditStateService.js';
import { ISidebarStateService } from '../../../sidebarStateService.js';
import { IChatThreadService } from '../../../chatThreadService.js';
import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'
import { ICodeEditorService } from '../../../../../../../editor/browser/services/codeEditorService.js'
import { ICommandService } from '../../../../../../../platform/commands/common/commands.js'
@@ -47,6 +43,7 @@ import { IEnvironmentService } from '../../../../../../../platform/environment/c
import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'
import { IPathService } from '../../../../../../../workbench/services/path/common/pathService.js'
import { IMetricsService } from '../../../../../../../workbench/contrib/void/common/metricsService.js'
import { URI } from '../../../../../../../base/common/uri.js'
@@ -79,6 +76,11 @@ const refreshModelProviderListeners: Set<(p: RefreshableProviderName, s: Refresh
let colorThemeState: ColorScheme
const colorThemeStateListeners: Set<(s: ColorScheme) => void> = new Set()
const ctrlKZoneStreamingStateListeners: Set<(diffareaid: number, s: boolean) => void> = new Set()
const uriStreamingStateListeners: Set<(uri: URI, s: URIStreamState) => void> = new Set()
// must call this before you can use any of the hooks below
// this should only be called ONCE! this is the only place you don't need to dispose onDidChange. If you use state.onDidChange anywhere else, make sure to dispose it!
let wasCalled = false
@@ -103,10 +105,10 @@ export const _registerServices = (accessor: ServicesAccessor) => {
settingsStateService: accessor.get(IVoidSettingsService),
refreshModelService: accessor.get(IRefreshModelService),
themeService: accessor.get(IThemeService),
inlineDiffsService: accessor.get(IInlineDiffsService),
editCodeService: accessor.get(IEditCodeService),
}
const { uriStateService, sidebarStateService, quickEditStateService, settingsStateService, chatThreadsStateService, refreshModelService, themeService, inlineDiffsService } = stateServices
const { uriStateService, sidebarStateService, quickEditStateService, settingsStateService, chatThreadsStateService, refreshModelService, themeService, editCodeService } = stateServices
uriState = uriStateService.state
disposables.push(
@@ -162,7 +164,7 @@ export const _registerServices = (accessor: ServicesAccessor) => {
refreshModelService.onDidChangeState((providerName) => {
refreshModelState = refreshModelService.state
refreshModelStateListeners.forEach(l => l(refreshModelState))
refreshModelProviderListeners.forEach(l => l(providerName, refreshModelState))
refreshModelProviderListeners.forEach(l => l(providerName, refreshModelState)) // no state
})
)
@@ -174,6 +176,21 @@ export const _registerServices = (accessor: ServicesAccessor) => {
})
)
// no state
disposables.push(
editCodeService.onDidChangeCtrlKZoneStreaming(({ diffareaid }) => {
const isStreaming = editCodeService.isCtrlKZoneStreaming({ diffareaid })
ctrlKZoneStreamingStateListeners.forEach(l => l(diffareaid, isStreaming))
})
)
disposables.push(
editCodeService.onDidChangeURIStreamState(({ uri }) => {
const isStreaming = editCodeService.getURIStreamState({ uri })
uriStreamingStateListeners.forEach(l => l(uri, isStreaming))
})
)
return disposables
}
@@ -192,7 +209,7 @@ const getReactAccessor = (accessor: ServicesAccessor) => {
ILLMMessageService: accessor.get(ILLMMessageService),
IRefreshModelService: accessor.get(IRefreshModelService),
IVoidSettingsService: accessor.get(IVoidSettingsService),
IInlineDiffsService: accessor.get(IInlineDiffsService),
IEditCodeService: accessor.get(IEditCodeService),
IVoidUriStateService: accessor.get(IVoidUriStateService),
IQuickEditStateService: accessor.get(IQuickEditStateService),
ISidebarStateService: accessor.get(ISidebarStateService),
@@ -209,6 +226,7 @@ const getReactAccessor = (accessor: ServicesAccessor) => {
ILanguageFeaturesService: accessor.get(ILanguageFeaturesService),
IKeybindingService: accessor.get(IKeybindingService),
IExplorerService: accessor.get(IExplorerService),
IEnvironmentService: accessor.get(IEnvironmentService),
IConfigurationService: accessor.get(IConfigurationService),
IPathService: accessor.get(IPathService),
@@ -336,7 +354,21 @@ export const useRefreshModelListener = (listener: (providerName: RefreshableProv
useEffect(() => {
refreshModelProviderListeners.add(listener)
return () => { refreshModelProviderListeners.delete(listener) }
}, [listener])
}, [listener, refreshModelProviderListeners])
}
export const useCtrlKZoneStreamingState = (listener: (diffareaid: number, s: boolean) => void) => {
useEffect(() => {
ctrlKZoneStreamingStateListeners.add(listener)
return () => { ctrlKZoneStreamingStateListeners.delete(listener) }
}, [listener, ctrlKZoneStreamingStateListeners])
}
export const useURIStreamState = (listener: (uri: URI, s: URIStreamState) => void) => {
useEffect(() => {
uriStreamingStateListeners.add(listener)
return () => { uriStreamingStateListeners.delete(listener) }
}, [listener, uriStreamingStateListeners])
}
@@ -353,3 +385,4 @@ export const useIsDark = () => {
return isDark
}
@@ -19,90 +19,87 @@ export const useScrollbarStyles = (containerRef: React.MutableRefObject<HTMLDivE
...Array.from(containerRef.current?.querySelectorAll(overflowSelector) || [])
];
// Clean up existing elements first
// Apply basic styling to all elements
scrollElements.forEach(element => {
if ((element as any).__scrollbarCleanup) {
(element as any).__scrollbarCleanup();
}
element.classList.add('void-scrollable-element');
});
// Apply styles and listeners to each scroll element
// Only initialize fade effects for elements that haven't been initialized yet
scrollElements.forEach(element => {
// Add the scrollable class directly to the overflow element
element.classList.add('void-scrollable-element');
if (!(element as any).__scrollbarCleanup) {
let fadeTimeout: NodeJS.Timeout | null = null;
let fadeInterval: NodeJS.Timeout | null = null;
let fadeTimeout: NodeJS.Timeout | null = null;
let fadeInterval: NodeJS.Timeout | null = null;
const fadeIn = () => {
if (fadeInterval) clearInterval(fadeInterval);
const fadeIn = () => {
if (fadeInterval) clearInterval(fadeInterval);
let step = 0;
fadeInterval = setInterval(() => {
if (step <= 10) {
element.classList.remove(`show-scrollbar-${step - 1}`);
element.classList.add(`show-scrollbar-${step}`);
step++;
} else {
clearInterval(fadeInterval!);
}
}, 10);
};
let step = 0;
fadeInterval = setInterval(() => {
if (step <= 10) {
element.classList.remove(`show-scrollbar-${step - 1}`);
element.classList.add(`show-scrollbar-${step}`);
step++;
} else {
clearInterval(fadeInterval!);
const fadeOut = () => {
if (fadeInterval) clearInterval(fadeInterval);
let step = 10;
fadeInterval = setInterval(() => {
if (step >= 0) {
element.classList.remove(`show-scrollbar-${step + 1}`);
element.classList.add(`show-scrollbar-${step}`);
step--;
} else {
clearInterval(fadeInterval!);
}
}, 60);
};
const onMouseEnter = () => {
if (fadeTimeout) clearTimeout(fadeTimeout);
if (fadeInterval) clearInterval(fadeInterval);
fadeIn();
};
const onMouseLeave = () => {
if (fadeTimeout) clearTimeout(fadeTimeout);
fadeTimeout = setTimeout(() => {
fadeOut();
}, 10);
};
element.addEventListener('mouseenter', onMouseEnter);
element.addEventListener('mouseleave', onMouseLeave);
// Store cleanup function
const cleanup = () => {
element.removeEventListener('mouseenter', onMouseEnter);
element.removeEventListener('mouseleave', onMouseLeave);
if (fadeTimeout) clearTimeout(fadeTimeout);
if (fadeInterval) clearInterval(fadeInterval);
element.classList.remove('void-scrollable-element');
// Remove any remaining show-scrollbar classes
for (let i = 0; i <= 10; i++) {
element.classList.remove(`show-scrollbar-${i}`);
}
}, 10);
};
};
const fadeOut = () => {
if (fadeInterval) clearInterval(fadeInterval);
let step = 10;
fadeInterval = setInterval(() => {
if (step >= 0) {
element.classList.remove(`show-scrollbar-${step + 1}`);
element.classList.add(`show-scrollbar-${step}`);
step--;
} else {
clearInterval(fadeInterval!);
}
}, 60);
};
const onMouseEnter = () => {
if (fadeTimeout) clearTimeout(fadeTimeout);
if (fadeInterval) clearInterval(fadeInterval);
fadeIn();
};
const onMouseLeave = () => {
if (fadeTimeout) clearTimeout(fadeTimeout);
fadeTimeout = setTimeout(() => {
fadeOut();
}, 10);
};
element.addEventListener('mouseenter', onMouseEnter);
element.addEventListener('mouseleave', onMouseLeave);
// Store cleanup function
const cleanup = () => {
element.removeEventListener('mouseenter', onMouseEnter);
element.removeEventListener('mouseleave', onMouseLeave);
if (fadeTimeout) clearTimeout(fadeTimeout);
if (fadeInterval) clearInterval(fadeInterval);
element.classList.remove('void-scrollable-element');
// Remove any remaining show-scrollbar classes
for (let i = 0; i <= 10; i++) {
element.classList.remove(`show-scrollbar-${i}`);
}
};
// Store the cleanup function on the element for later use
(element as any).__scrollbarCleanup = cleanup;
// Store the cleanup function on the element for later use
(element as any).__scrollbarCleanup = cleanup;
}
});
};
// Initialize for the first time
initializeScrollbarStyles();
// Set up mutation observer
const observer = new MutationObserver((mutations) => {
// Set up mutation observer to do the same
const observer = new MutationObserver(() => {
initializeScrollbarStyles();
});
@@ -37,7 +37,8 @@ const ModelSelectBox = ({ options, featureName }: { options: ModelOption[], feat
selectedOption={selectedOption}
onChangeOption={onChangeOption}
getOptionDisplayName={(option) => option.selection.modelName}
getOptionDropdownName={(option) => option.name}
getOptionDropdownName={(option) => option.selection.modelName}
getOptionDropdownDetail={(option) => option.selection.providerName }
getOptionsEqual={(a, b) => optionsEqual([a], [b])}
className='text-xs text-void-fg-3 px-1'
matchInputWidth={false}
@@ -5,7 +5,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { InputBox } from '../../../../../../../base/browser/ui/inputbox/inputBox.js'
import { ProviderName, SettingName, displayInfoOfSettingName, providerNames, VoidModelInfo, globalSettingNames, customSettingNamesOfProvider, RefreshableProviderName, refreshableProviderNames, displayInfoOfProviderName, defaultProviderSettings, nonlocalProviderNames, localProviderNames, GlobalSettingName, featureNames, displayInfoOfFeatureName, isProviderNameDisabled } from '../../../../common/voidSettingsTypes.js'
import { ProviderName, SettingName, displayInfoOfSettingName, providerNames, VoidModelInfo, globalSettingNames, customSettingNamesOfProvider, RefreshableProviderName, refreshableProviderNames, displayInfoOfProviderName, defaultProviderSettings, nonlocalProviderNames, localProviderNames, GlobalSettingName, featureNames, displayInfoOfFeatureName, isProviderNameDisabled, FeatureName } from '../../../../common/voidSettingsTypes.js'
import ErrorBoundary from '../sidebar-tsx/ErrorBoundary.js'
import { VoidButton, VoidCheckBox, VoidCustomDropdownBox, VoidInputBox, VoidInputBox2, VoidSwitch } from '../util/inputs.js'
import { useAccessor, useIsDark, useRefreshModelListener, useRefreshModelState, useSettingsState } from '../util/services.js'
@@ -17,10 +17,11 @@ import { env } from '../../../../../../../base/common/process.js'
import { ModelDropdown } from './ModelDropdown.js'
import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js'
import { WarningBox } from './WarningBox.js'
import { os } from '../../../helpers/systemInfo.js'
const SubtleButton = ({ onClick, text, icon, disabled }: { onClick: () => void, text: string, icon: React.ReactNode, disabled: boolean }) => {
return <div className='flex items-center text-void-fg-3 mb-1 px-3 rounded-sm overflow-hidden gap-2 hover:bg-black/10 dark:hover:bg-gray-300/10'>
return <div className='flex items-center text-void-fg-3 px-3 py-0.5 rounded-sm overflow-hidden gap-2 hover:bg-black/10 dark:hover:bg-gray-300/10'>
<button className='flex items-center' disabled={disabled} onClick={onClick}>
{icon}
</button>
@@ -81,9 +82,7 @@ const RefreshableModels = () => {
const buttons = refreshableProviderNames.map(providerName => {
if (!settingsState.settingsOfProvider[providerName]._didFillInProviderSettings) return null
return <div key={providerName} className='pb-4'>
<RefreshModelButton providerName={providerName} />
</div>
return <RefreshModelButton key={providerName} providerName={providerName} />
})
return <>
@@ -256,7 +255,7 @@ const ProviderSetting = ({ providerName, settingName }: { providerName: Provider
// const { title: providerTitle, } = displayInfoOfProviderName(providerName)
const { title: settingTitle, placeholder, subTextMd } = displayInfoOfSettingName(providerName, settingName)
const { title: settingTitle, placeholder, isPasswordField, subTextMd } = displayInfoOfSettingName(providerName, settingName)
const accessor = useAccessor()
const voidSettingsService = accessor.get('IVoidSettingsService')
@@ -268,6 +267,7 @@ const ProviderSetting = ({ providerName, settingName }: { providerName: Provider
<VoidInputBox
// placeholder={`${providerTitle} ${settingTitle} (${placeholder})`}
placeholder={`${settingTitle} (${placeholder})`}
onChangeText={useCallback((newVal) => {
if (weChangedTextRef) return
voidSettingsService.setSettingOfProvider(providerName, settingName, newVal)
@@ -290,6 +290,7 @@ const ProviderSetting = ({ providerName, settingName }: { providerName: Provider
return [disposable]
}, [voidSettingsService, providerName, settingName])}
multiline={false}
isPasswordField={isPasswordField}
/>
{subTextMd === undefined ? null : <div className='py-1 px-3 opacity-50 text-sm'>
<ChatMarkdownRender noSpace string={subTextMd} />
@@ -338,7 +339,7 @@ const SettingsForProvider = ({ providerName }: { providerName: ProviderName }) =
{needsModel ?
providerName === 'ollama' ?
<WarningBox text={`Please install an Ollama model. We'll auto-detect it.`} />
: <WarningBox text={`Please add a model for ${providerTitle} below (Models).`} />
: <WarningBox text={`Please add a model for ${providerTitle} (Models section).`} />
: null}
</div>
</div >
@@ -376,6 +377,7 @@ export const AutoRefreshToggle = () => {
icon={enabled ? <Check className='stroke-green-500 size-3' /> : <X className='stroke-red-500 size-3' />}
disabled={false}
/>
}
export const AIInstructionsBox = () => {
@@ -385,7 +387,7 @@ export const AIInstructionsBox = () => {
return <VoidInputBox2
className='min-h-[81px] p-3 rounded-sm'
initValue={voidSettingsState.globalSettings.aiInstructions}
placeholder={`Do not change my indentation or delete my comments. When writing TS or JS, do not add ;'s. Respond to all queries in French. `}
placeholder={`Do not change my indentation or delete my comments. When writing TS or JS, do not add ;'s. Write new code using Rust if possible. `}
multiline
onChangeText={(newText) => {
voidSettingsService.setGlobalSetting('aiInstructions', newText)
@@ -395,7 +397,17 @@ export const AIInstructionsBox = () => {
export const FeaturesTab = () => {
return <>
<h2 className={`text-3xl mb-2`}>Local Providers</h2>
<h2 className={`text-3xl mb-2`}>Models</h2>
<ErrorBoundary>
<AutoRefreshToggle />
<RefreshableModels />
<div className='py-2' />
<ModelDump />
<AddModelMenuFull />
</ErrorBoundary>
<h2 className={`text-3xl mb-2 mt-12`}>Local Providers</h2>
{/* <h3 className={`opacity-50 mb-2`}>{`Keep your data private by hosting AI locally on your computer.`}</h3> */}
{/* <h3 className={`opacity-50 mb-2`}>{`Instructions:`}</h3> */}
{/* <h3 className={`mb-2`}>{`Void can access any model that you host locally. We automatically detect your local models by default.`}</h3> */}
@@ -403,7 +415,7 @@ export const FeaturesTab = () => {
<div className='pl-4 opacity-50'>
<span className={`text-sm mb-2`}><ChatMarkdownRender noSpace string={`1. Download [Ollama](https://ollama.com/download).`} /></span>
<span className={`text-sm mb-2`}><ChatMarkdownRender noSpace string={`2. Open your terminal.`} /></span>
<span className={`text-sm mb-2 select-text`}><ChatMarkdownRender noSpace string={`3. Run \`ollama run llama3.1\`. This installs Meta's llama3.1 model which is best for chat and inline edits. Requires 5GB of memory.`} /></span>
<span className={`text-sm mb-2 select-text`}><ChatMarkdownRender noSpace string={`3. Run \`ollama run llama3.1:8b\`. This installs Meta's llama3.1 model which is best for chat and inline edits. Requires 5GB of memory.`} /></span>
<span className={`text-sm mb-2 select-text`}><ChatMarkdownRender noSpace string={`4. Run \`ollama run qwen2.5-coder:1.5b\`. This installs a faster autocomplete model. Requires 1GB of memory.`} /></span>
<span className={`text-sm mb-2`}><ChatMarkdownRender noSpace string={`Void automatically detects locally running models and enables them.`} /></span>
{/* TODO we should create UI for downloading models without user going into terminal */}
@@ -420,13 +432,21 @@ export const FeaturesTab = () => {
<VoidProviderSettings providerNames={nonlocalProviderNames} />
</ErrorBoundary>
<h2 className={`text-3xl mb-2 mt-12`}>Models</h2>
<h2 className={`text-3xl mb-2 mt-12`}>Feature Options</h2>
<ErrorBoundary>
<AutoRefreshToggle />
<RefreshableModels />
<ModelDump />
<AddModelMenuFull />
{featureNames.map(featureName =>
(['Ctrl+L', 'Ctrl+K'] as FeatureName[]).includes(featureName) ? null :
<div key={featureName}
className='mb-2'
>
<h4 className={`text-void-fg-3`}>{displayInfoOfFeatureName(featureName)}</h4>
<ModelDropdown featureName={featureName} />
</div>
)}
</ErrorBoundary>
</>
}
@@ -489,7 +509,7 @@ const transferTheseFilesOfOS = (os: 'mac' | 'windows' | 'linux' | null): Transfe
throw new Error(`os '${os}' not recognized`)
}
const os = isWindows ? 'windows' : isMacintosh ? 'mac' : isLinux ? 'linux' : null
let transferTheseFiles: TransferFilesInfo = []
let transferError: string | null = null
@@ -588,17 +608,6 @@ const GeneralTab = () => {
<AIInstructionsBox />
</div>
<div className='mt-12'>
<h2 className={`text-3xl mb-2`}>Model Selection</h2>
{featureNames.map(featureName =>
<div key={featureName}
className='mb-2'
>
<h4 className={`text-void-fg-3`}>{displayInfoOfFeatureName(featureName)}</h4>
<ModelDropdown featureName={featureName} />
</div>
)}
</div>
</>
}
@@ -618,7 +627,7 @@ export const Settings = () => {
<div className='max-w-5xl mx-auto'>
<h1 className='text-2xl w-full'>Void Settings</h1>
<h1 className='text-2xl w-full'>{`Void's Settings`}</h1>
{/* separator */}
<div className='w-full h-[1px] my-4' />
@@ -28,17 +28,25 @@ module.exports = {
colors: {
"void-bg-1": "var(--vscode-input-background)",
"void-bg-1-alt": "var(--vscode-badge-background)",
"void-bg-2": "var(--vscode-sideBar-background)",
"void-bg-2-alt": "color-mix(in srgb, var(--vscode-sideBar-background) 30%, var(--vscode-editor-background) 70%)",
"void-bg-3": "var(--vscode-editor-background)",
"void-fg-1": "var(--vscode-editor-foreground)",
"void-fg-2": "var(--vscode-input-foreground)",
"void-fg-3": "var(--vscode-input-placeholderForeground)",
// "void-fg-4": "var(--vscode-tab-inactiveForeground)",
"void-fg-4": "var(--vscode-list-deemphasizedForeground)",
"void-warning": "var(--vscode-charts-yellow)",
"void-border-1": "var(--vscode-commandCenter-activeBorder)",
"void-border-2": "var(--vscode-commandCenter-border)",
"void-border-3": "var(--vscode-commandCenter-inactiveBorder)",
"void-border-4": "var(--vscode-editorGroup-border)",
vscode: {
@@ -1,76 +0,0 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
export type ChatLocation = {
threadId: string;
messageIdx: number;
}
export type ApplyBoxLocation = ChatLocation & { codeblockId: string }
export const getApplyBoxId = ({ threadId, messageIdx, codeblockId }: ApplyBoxLocation) => {
return `${threadId}-${messageIdx}-${codeblockId}}`
}
export type SearchAndReplaceBlock = {
search: string;
replace: string;
}
// service that manages state
export type ApplyState = {
[applyBoxId: string]: {
searchAndReplaceBlocks: SearchAndReplaceBlock;
}
}
// the purpose of this service is to generate search and replace blocks for a given codeblock `codeblockId` and on a file `fileName` and version `fileVersion`
export interface IFastApplyService {
readonly _serviceBrand: undefined;
// readonly state: ApplyState; // readonly to the user
// setState(newState: Partial<ApplyState>): void;
// onDidChangeState: Event<void>;
}
export const IVoidFastApplyService = createDecorator<IFastApplyService>('voidFastApplyService');
class VoidFastApplyService extends Disposable implements IFastApplyService {
_serviceBrand: undefined;
static readonly ID = 'voidFastApplyService';
private readonly _onDidChangeState = new Emitter<void>();
readonly onDidChangeState: Event<void> = this._onDidChangeState.event;
// state
// state: ApplyState
constructor(
) {
super()
// initial state
// this.state = { currentUri: undefined }
}
setState(newState: Partial<ApplyState>) {
// this.state = { ...this.state, ...newState }
this._onDidChangeState.fire()
}
}
registerSingleton(IVoidFastApplyService, VoidFastApplyService, InstantiationType.Eager);
@@ -0,0 +1,46 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
// import { ILLMMessageService } from '../common/llmMessageService.js';
// import { ServiceSendLLMMessageParams } from '../common/llmMessageTypes.js';
export interface ISearchReplaceService {
readonly _serviceBrand: undefined;
}
export const ISearchReplaceService = createDecorator<ISearchReplaceService>('SearchReplaceCacheService');
class SearchReplaceService extends Disposable implements ISearchReplaceService {
_serviceBrand: undefined;
private readonly _onDidChangeState = new Emitter<void>();
readonly onDidChangeState: Event<void> = this._onDidChangeState.event;
constructor(
// @ILLMMessageService private readonly llmMessageService: ILLMMessageService,
) {
super()
}
// send(params: ServiceSendLLMMessageParams & { onText: (p: { newText: string, fullText: string }) => { retry: boolean } }) {
// this.llmMessageService.sendLLMMessage({
// ...params as ServiceSendLLMMessageParams,
// onText: (p) => {
// const { retry } = params.onText(p)
// if (retry) {
// }
// }
// })
// }
}
registerSingleton(ISearchReplaceService, SearchReplaceService, InstantiationType.Eager);
@@ -11,7 +11,7 @@ import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js
import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
import { StagingSelectionItem, IChatThreadService } from './chatThreadService.js';
import { StagingSelectionItem, IChatThreadService } from '../common/chatThreadService.js';
import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';
import { IRange } from '../../../../editor/common/core/range.js';
@@ -124,11 +124,13 @@ registerAction2(class extends Action2 {
fileURI: model.uri,
selectionStr: null,
range: null,
state: { isOpened: false, }
} : {
type: 'Selection',
fileURI: model.uri,
selectionStr: selectionStr,
range: selectionRange,
state: { isOpened: true, }
}
// update the staging selections
@@ -141,15 +143,16 @@ registerAction2(class extends Action2 {
let setSelections = (s: StagingSelectionItem[]) => { }
if (focusedMessageIdx === undefined) {
const [state, setState] = chatThreadService._useCurrentThreadState()
selections = state.stagingSelections
setSelections = (s) => setState({ stagingSelections: s })
selections = chatThreadService.getCurrentThreadState().stagingSelections
setSelections = (s: StagingSelectionItem[]) => chatThreadService.setCurrentThreadState({ stagingSelections: s })
} else {
const [state, setState] = chatThreadService._useCurrentMessageState(focusedMessageIdx)
selections = state.stagingSelections
setSelections = (s) => setState({ stagingSelections: s })
selections = chatThreadService.getCurrentMessageState(focusedMessageIdx).stagingSelections
setSelections = (s) => chatThreadService.setCurrentMessageState(focusedMessageIdx, { stagingSelections: s })
}
// close all selections besides the new one
selections = selections.map(s => ({ ...s, state: { ...s.state, isOpened: false } }))
// if matches with existing selection, overwrite (since text may change)
const matchingStagingEltIdx = findMatchingStagingIndex(selections, selection)
if (matchingStagingEltIdx !== undefined && matchingStagingEltIdx !== -1) {
@@ -241,7 +244,7 @@ registerAction2(class extends Action2 {
constructor() {
super({
id: 'void.settingsAction',
title: 'Void Settings',
title: `Void's Settings`,
icon: { id: 'settings-gear' },
menu: [{ id: MenuId.ViewTitle, group: 'navigation', when: ContextKeyExpr.equals('view', VOID_VIEW_ID), }]
});
@@ -5,7 +5,7 @@
// register inline diffs
import './inlineDiffsService.js'
import './editCodeService.js'
// register Sidebar pane, state, actions (keybinds, menus) (Ctrl+L)
import './sidebarActions.js'
@@ -15,8 +15,6 @@ import './sidebarStateService.js'
// register quick edit (Ctrl+K)
import './quickEditActions.js'
// register Thread History
import './chatThreadService.js'
// register Autocomplete
import './autocompleteService.js'
@@ -56,3 +54,7 @@ import '../common/voidUpdateService.js'
// tools
import '../common/toolsService.js'
// register Thread History
import '../common/chatThreadService.js'
@@ -49,7 +49,7 @@ class VoidSettingsInput extends EditorInput {
}
override getName(): string {
return nls.localize('voidSettingsInputsName', 'Void Settings');
return nls.localize('voidSettingsInputsName', 'Void\'s Settings');
}
override getIcon() {
@@ -112,7 +112,7 @@ class VoidSettingsPane extends EditorPane {
// register Settings pane
Registry.as<IEditorPaneRegistry>(EditorExtensions.EditorPane).registerEditorPane(
EditorPaneDescriptor.create(VoidSettingsPane, VoidSettingsPane.ID, nls.localize('VoidSettingsPane', "Void Settings Pane")),
EditorPaneDescriptor.create(VoidSettingsPane, VoidSettingsPane.ID, nls.localize('VoidSettingsPane', "Void\'s Settings Pane")),
[new SyncDescriptor(VoidSettingsInput)]
);
@@ -202,7 +202,7 @@ MenuRegistry.appendMenuItem(MenuId.GlobalActivity, {
group: '0_command',
command: {
id: VOID_TOGGLE_SETTINGS_ACTION_ID,
title: nls.localize('voidSettings', "Void Settings")
title: nls.localize('voidSettings', "Void\'s Settings")
},
order: 1
});
@@ -11,11 +11,24 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo
import { URI } from '../../../../base/common/uri.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { IRange } from '../../../../editor/common/core/range.js';
import { ILLMMessageService } from '../common/llmMessageService.js';
import { IModelService } from '../../../../editor/common/services/model.js';
import { chat_userMessageContent, chat_systemMessage, chat_userMessageContentWithAllFilesToo as chat_userMessageContentWithAllFiles } from './prompt/prompts.js';
import { LLMChatMessage } from '../common/llmMessageTypes.js';
import { IFileService } from '../../../../platform/files/common/files.js';
import { ILLMMessageService } from './llmMessageService.js';
import { chat_userMessageContent, chat_systemMessage, chat_userMessageContentWithAllFilesToo as chat_userMessageContentWithAllFiles, chat_selectionsString } from '../browser/prompt/prompts.js';
import { InternalToolInfo, IToolsService, ToolCallReturnType, ToolFns, ToolName, voidTools } from './toolsService.js';
import { toLLMChatMessage } from './llmMessageTypes.js';
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
import { IVoidFileService } from './voidFileService.js';
import { generateUuid } from '../../../../base/common/uuid.js';
const findLastIndex = <T>(arr: T[], condition: (t: T) => boolean): number => {
for (let i = arr.length - 1; i >= 0; i--) {
if (condition(arr[i])) {
return i;
}
}
return -1;
}
// one of the square items that indicates a selection in a chat bubble (NOT a file, a Selection of text)
export type CodeSelection = {
@@ -23,6 +36,9 @@ export type CodeSelection = {
fileURI: URI;
selectionStr: string;
range: IRange;
state: {
isOpened: boolean;
};
}
export type FileSelection = {
@@ -30,13 +46,27 @@ export type FileSelection = {
fileURI: URI;
selectionStr: null;
range: null;
state: {
isOpened: boolean;
};
}
export type StagingSelectionItem = CodeSelection | FileSelection
export type ToolMessage<T extends ToolName> = {
role: 'tool';
name: T; // internal use
params: string; // internal use
id: string; // apis require this tool use id
content: string; // result
result: ToolCallReturnType[T]; // text message of result
}
// WARNING: changing this format is a big deal!!!!!! need to migrate old format to new format on users' computers so people don't get errors.
export type ChatMessage =
| {
{
role: 'user';
content: string | null; // content displayed to the LLM on future calls - allowed to be '', will be replaced with (empty)
displayContent: string | null; // content displayed to user - allowed to be '', will be ignored
@@ -45,22 +75,20 @@ export type ChatMessage =
stagingSelections: StagingSelectionItem[];
isBeingEdited: boolean;
}
}
| {
} | {
role: 'assistant';
content: string | null; // content received from LLM - allowed to be '', will be replaced with (empty)
displayContent: string | null; // content displayed to user (this is the same as content for now) - allowed to be '', will be ignored
}
| {
role: 'system';
content: string;
displayContent?: undefined;
reasoning: string | null; // reasoning from the LLM, used for step-by-step thinking
}
| ToolMessage<ToolName>
type UserMessageType = ChatMessage & { role: 'user' }
type UserMessageState = UserMessageType['state']
export const defaultMessageState: UserMessageState = { stagingSelections: [], isBeingEdited: false }
export const defaultMessageState: UserMessageState = {
stagingSelections: [],
isBeingEdited: false,
}
// a 'thread' means a chat message history
export type ChatThreads = {
@@ -72,14 +100,18 @@ export type ChatThreads = {
state: {
stagingSelections: StagingSelectionItem[];
focusedMessageIdx: number | undefined; // index of the message that is being edited (undefined if none)
isCheckedOfSelectionId: { [selectionId: string]: boolean };
isCheckedOfSelectionId: { [selectionId: string]: boolean }; // TODO
}
};
}
type ThreadType = ChatThreads[string]
const defaultThreadState: ThreadType['state'] = { stagingSelections: [], focusedMessageIdx: undefined, isCheckedOfSelectionId: {} }
const defaultThreadState: ThreadType['state'] = {
stagingSelections: [],
focusedMessageIdx: undefined,
isCheckedOfSelectionId: {}
}
export type ThreadsState = {
allThreads: ChatThreads;
@@ -90,6 +122,7 @@ export type ThreadStreamState = {
[threadId: string]: undefined | {
error?: { message: string, fullError: Error | null, };
messageSoFar?: string;
reasoningSoFar?: string;
streamingToken?: string;
}
}
@@ -98,24 +131,19 @@ export type ThreadStreamState = {
const newThreadObject = () => {
const now = new Date().toISOString()
return {
id: new Date().getTime().toString(),
id: generateUuid(),
createdAt: now,
lastModified: now,
messages: [],
state: {
stagingSelections: [],
focusedMessageIdx: undefined,
isCheckedOfSelectionId: {}
},
state: defaultThreadState,
} satisfies ChatThreads[string]
}
const THREAD_VERSION_KEY = 'void.chatThreadVersion'
const THREAD_VERSION = 'v2'
export const THREAD_STORAGE_KEY = 'void.chatThreadStorage'
const THREAD_STORAGE_KEY = 'void.chatThreadStorage'
type ChatMode = 'agent' | 'chat'
export interface IChatThreadService {
readonly _serviceBrand: undefined;
@@ -129,16 +157,25 @@ export interface IChatThreadService {
openNewThread(): void;
switchToThread(threadId: string): void;
// you can edit multiple messages
// the one you're currently editing is "focused", and we add items to that one when you press cmd+L.
getFocusedMessageIdx(): number | undefined;
isFocusingMessage(): boolean;
setFocusedMessageIdx(messageIdx: number | undefined): void;
// _useFocusedStagingState(messageIdx?: number | undefined): readonly [StagingInfo, (stagingInfo: StagingInfo) => void];
_useCurrentThreadState(): readonly [ThreadType['state'], (newState: Partial<ThreadType['state']>) => void];
_useCurrentMessageState(messageIdx: number): readonly [UserMessageState, (newState: Partial<UserMessageState>) => void];
// exposed getters/setters
getCurrentMessageState: (messageIdx: number) => UserMessageState
setCurrentMessageState: (messageIdx: number, newState: Partial<UserMessageState>) => void
getCurrentThreadState: () => ThreadType['state']
setCurrentThreadState: (newState: Partial<ThreadType['state']>) => void
// call to edit a message
editUserMessageAndStreamResponse({ userMessage, chatMode, messageIdx }: { userMessage: string, chatMode: ChatMode, messageIdx: number }): Promise<void>;
// call to add a message
addUserMessageAndStreamResponse({ userMessage, chatMode }: { userMessage: string, chatMode: ChatMode }): Promise<void>;
editUserMessageAndStreamResponse(userMessage: string, messageIdx: number): Promise<void>;
addUserMessageAndStreamResponse(userMessage: string): Promise<void>;
cancelStreaming(threadId: string): void;
dismissStreamError(threadId: string): void;
@@ -160,90 +197,55 @@ class ChatThreadService extends Disposable implements IChatThreadService {
constructor(
@IStorageService private readonly _storageService: IStorageService,
@IModelService private readonly _modelService: IModelService,
@IFileService private readonly _fileService: IFileService,
@IVoidFileService private readonly _voidFileService: IVoidFileService,
@ILLMMessageService private readonly _llmMessageService: ILLMMessageService,
@IToolsService private readonly _toolsService: IToolsService,
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService,
) {
super()
this.state = { allThreads: {}, currentThreadId: null as unknown as string } // default state
const readThreads = this._readAllThreads() || {}
const allThreads = readThreads
this.state = {
allThreads: this._readAllThreads(),
allThreads: allThreads,
currentThreadId: null as unknown as string, // gets set in startNewThread()
}
// always be in a thread
this.openNewThread()
// for now just write the version, anticipating bigger changes in the future where we'll want to access this
this._storageService.store(THREAD_VERSION_KEY, THREAD_VERSION, StorageScope.APPLICATION, StorageTarget.USER)
}
private _readAllThreads(): ChatThreads {
// PUT ANY VERSION CHANGE FORMAT CONVERSION CODE HERE
// CAN ADD "v0" TAG IN STORAGE AND CONVERT
const threadsStr = this._storageService.get(THREAD_STORAGE_KEY, StorageScope.APPLICATION)
const threads: ChatThreads = threadsStr ? JSON.parse(threadsStr) : {}
this._updateThreadsToVersion(threads, THREAD_VERSION)
return threads
// !!! this is important for properly restoring URIs from storage
private _convertThreadDataFromStorage(threadsStr: string): ChatThreads {
return JSON.parse(threadsStr, (key, value) => {
if (value && typeof value === 'object' && value.$mid === 1) { //$mid is the MarshalledId. $mid === 1 means it is a URI
return URI.from(value);
}
return value;
});
}
private _updateThreadsToVersion(oldThreadsObject: any, toVersion: string) {
if (toVersion === 'v2') {
const threads: ChatThreads = oldThreadsObject
/** v1 -> v2
- threads.state.currentStagingSelections: CodeStagingSelection[] | null;
+ thread[threadIdx].state
+ message.state
*/
// check if we need to update
let shouldUpdate = false
for (const thread of Object.values(threads)) {
if (!thread.state) {
shouldUpdate = true
}
for (const chatMessage of Object.values(thread.messages)) {
if (chatMessage.role === 'user' && !chatMessage.state) {
shouldUpdate = true
}
}
}
if (!shouldUpdate) return;
// update the threads
for (const thread of Object.values(threads)) {
if (!thread.state) {
thread.state = defaultThreadState
}
for (const chatMessage of Object.values(thread.messages)) {
if (chatMessage.role === 'user' && !chatMessage.state) {
chatMessage.state = defaultMessageState
}
}
}
// push the update
this._storeAllThreads(threads)
private _readAllThreads(): ChatThreads | null {
const threadsStr = this._storageService.get(THREAD_STORAGE_KEY, StorageScope.APPLICATION);
if (!threadsStr) {
return null
}
return this._convertThreadDataFromStorage(threadsStr);
}
private _storeAllThreads(threads: ChatThreads) {
this._storageService.store(THREAD_STORAGE_KEY, JSON.stringify(threads), StorageScope.APPLICATION, StorageTarget.USER)
const serializedThreads = JSON.stringify(threads);
this._storageService.store(
THREAD_STORAGE_KEY,
serializedThreads,
StorageScope.APPLICATION,
StorageTarget.USER
);
}
// this should be the only place this.state = ... appears besides constructor
private _setState(state: Partial<ThreadsState>, affectsCurrent: boolean) {
this.state = {
@@ -276,15 +278,16 @@ class ChatThreadService extends Disposable implements IChatThreadService {
// ---------- streaming ----------
finishStreaming = (threadId: string, content: string, error?: { message: string, fullError: Error | null }) => {
private _finishStreamingTextMessage = (threadId: string, options: { content: string, reasoning?: string }, error?: { message: string, fullError: Error | null }) => {
// add assistant's message to chat history, and clear selection
const assistantHistoryElt: ChatMessage = { role: 'assistant', content, displayContent: content || null }
this._addMessageToThread(threadId, assistantHistoryElt)
this._setStreamState(threadId, { messageSoFar: undefined, streamingToken: undefined, error })
this._addMessageToThread(threadId, { role: 'assistant', content: options.content, reasoning: options.reasoning || null })
this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, error })
}
async editUserMessageAndStreamResponse(userMessage: string, messageIdx: number) {
async editUserMessageAndStreamResponse({ userMessage, chatMode, messageIdx }: { userMessage: string, chatMode: ChatMode, messageIdx: number }) {
const thread = this.getCurrentThread()
@@ -293,8 +296,8 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
// get prev and curr selections before clearing the message
const prevSelns = this._getSelectionsUpToMessageIdx(messageIdx)
const currSelns = thread.messages[messageIdx].selections || []
const prevSelns = this._getSelectionsUpToMessageIdx(messageIdx) // selections for previous messages
const currSelns = thread.messages[messageIdx].state.stagingSelections || [] // staging selections for the edited message
// clear messages up to the index
const slicedMessages = thread.messages.slice(0, messageIdx)
@@ -308,67 +311,141 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
}, true)
// stream the edit
this.addUserMessageAndStreamResponse(userMessage, { prevSelns, currSelns })
// re-add the message and stream it
this.addUserMessageAndStreamResponse({ userMessage, chatMode, chatSelections: { prevSelns, currSelns } })
}
async addUserMessageAndStreamResponse(userMessage: string, options?: { prevSelns?: StagingSelectionItem[], currSelns?: StagingSelectionItem[] }) {
async addUserMessageAndStreamResponse({ userMessage, chatMode, chatSelections }: { userMessage: string, chatMode: ChatMode, chatSelections?: { prevSelns?: StagingSelectionItem[], currSelns?: StagingSelectionItem[] } }) {
const thread = this.getCurrentThread()
const threadId = thread.id
// selections in all past chats, then in current chat (can have many duplicates here)
const prevSelns: StagingSelectionItem[] = chatSelections?.prevSelns ?? this._getAllSelections()
const currSelns: StagingSelectionItem[] = chatSelections?.currSelns ?? thread.state.stagingSelections
// add user's message to chat history
const instructions = userMessage
const userMessageContent = await chat_userMessageContent(instructions, currSelns)
const selectionsStr = await chat_selectionsString(prevSelns, currSelns, this._voidFileService)
const userMessageFullContent = chat_userMessageContentWithAllFiles(userMessageContent, selectionsStr)
const prevSelns: StagingSelectionItem[] = options?.prevSelns ?? this._getAllSelections()
const currSelns: StagingSelectionItem[] = options?.currSelns ?? thread.state.stagingSelections
// read all curr+previous files on demand instead of adding them to the history
const messageContent = await chat_userMessageContent(instructions, prevSelns, currSelns)
const messageContentWithAllFiles = await chat_userMessageContentWithAllFiles(instructions, prevSelns, currSelns, this._modelService, this._fileService)
const prevLLMMessages = this.getCurrentThread().messages.map(m => ({ role: m.role, content: m.content || '(empty model output)' }))
const currLLMMessage: LLMChatMessage = { role: 'user', content: messageContentWithAllFiles }
const userHistoryElt: ChatMessage = { role: 'user', content: messageContent, displayContent: instructions, selections: currSelns, state: defaultMessageState }
const userHistoryElt: ChatMessage = { role: 'user', content: userMessageContent, displayContent: instructions, selections: currSelns, state: defaultMessageState }
this._addMessageToThread(threadId, userHistoryElt)
this._setStreamState(threadId, { error: undefined })
console.log(`messageContent`)
console.log([{ role: 'system', content: chat_systemMessage },
...prevLLMMessages,
currLLMMessage,])
const llmCancelToken = this._llmMessageService.sendLLMMessage({
messagesType: 'chatMessages',
logging: { loggingName: 'Chat' },
useProviderFor: 'Ctrl+L',
messages: [
{ role: 'system', content: chat_systemMessage },
...prevLLMMessages,
currLLMMessage,
],
onText: ({ newText, fullText }) => {
this._setStreamState(threadId, { messageSoFar: fullText })
},
onFinalMessage: ({ fullText: content }) => {
this.finishStreaming(threadId, content)
},
onError: (error) => {
this.finishStreaming(threadId, this.streamState[threadId]?.messageSoFar ?? '', error)
},
const tools: InternalToolInfo[] | undefined = (
chatMode === 'chat' ? undefined
: chatMode === 'agent' ? Object.keys(voidTools).map(toolName => voidTools[toolName as ToolName])
: undefined)
})
if (llmCancelToken === null) return
this._setStreamState(threadId, { streamingToken: llmCancelToken })
// agent loop
const agentLoop = async () => {
let shouldSendAnotherMessage = true
let nMessagesSent = 0
while (shouldSendAnotherMessage) {
shouldSendAnotherMessage = false
nMessagesSent += 1
let res_: () => void
const awaitable = new Promise<void>((res, rej) => { res_ = res })
// replace last userMessage with userMessageFullContent (which contains all the files too)
const messages_ = this.getCurrentThread().messages.map(m => (toLLMChatMessage(m)))
const lastUserMsgIdx = findLastIndex(messages_, m => m.role === 'user')
let messages = messages_
if (lastUserMsgIdx !== -1) { // should never be -1
messages = [
...messages.slice(0, lastUserMsgIdx),
{ role: 'user', content: userMessageFullContent },
...messages.slice(lastUserMsgIdx + 1, Infinity)]
}
const llmCancelToken = this._llmMessageService.sendLLMMessage({
messagesType: 'chatMessages',
useProviderFor: 'Ctrl+L',
logging: { loggingName: `Agent` },
messages: [
{ role: 'system', content: chat_systemMessage(this._workspaceContextService.getWorkspace().folders.map(f => f.uri.fsPath)) },
...messages,
],
tools: tools,
onText: ({ fullText, fullReasoning }) => {
this._setStreamState(threadId, { messageSoFar: fullText, reasoningSoFar: fullReasoning })
},
onFinalMessage: async ({ fullText, toolCalls, fullReasoning }) => {
if ((toolCalls?.length ?? 0) === 0) {
this._finishStreamingTextMessage(threadId, { content: fullText, reasoning: fullReasoning })
}
else {
this._addMessageToThread(threadId, { role: 'assistant', content: fullText, reasoning: fullReasoning || null })
this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined }) // clear streaming message
for (const tool of toolCalls ?? []) {
const toolName = tool.name as ToolName
// 1.
let toolResult: Awaited<ReturnType<ToolFns[ToolName]>>
let toolResultVal: ToolCallReturnType[ToolName]
try {
toolResult = await this._toolsService.toolFns[toolName](tool.params)
toolResultVal = toolResult
} catch (error) {
this._setStreamState(threadId, { error })
shouldSendAnotherMessage = false
break
}
// 2.
let toolResultStr: string
try {
toolResultStr = this._toolsService.toolResultToString[toolName](toolResult as any) // typescript is so bad it doesn't even couple the type of ToolResult with the type of the function being called here
} catch (error) {
this._setStreamState(threadId, { error })
shouldSendAnotherMessage = false
break
}
this._addMessageToThread(threadId, { role: 'tool', name: toolName, params: tool.params, id: tool.id, content: toolResultStr, result: toolResultVal, })
shouldSendAnotherMessage = true
}
}
res_()
},
onError: (error) => {
const messageSoFar = this.streamState[threadId]?.messageSoFar ?? ''
const reasoningSoFar = this.streamState[threadId]?.reasoningSoFar ?? ''
this._finishStreamingTextMessage(threadId, { content: messageSoFar, reasoning: reasoningSoFar }, error)
res_()
},
})
if (llmCancelToken === null) break
this._setStreamState(threadId, { streamingToken: llmCancelToken })
await awaitable
}
}
agentLoop() // DO NOT AWAIT THIS, this fn should resolve when ready to clear inputs
}
cancelStreaming(threadId: string) {
const llmCancelToken = this.streamState[threadId]?.streamingToken
if (llmCancelToken !== undefined) this._llmMessageService.abort(llmCancelToken)
this.finishStreaming(threadId, this.streamState[threadId]?.messageSoFar ?? '')
const messageSoFar = this.streamState[threadId]?.messageSoFar ?? ''
const reasoningSoFar = this.streamState[threadId]?.reasoningSoFar ?? ''
this._finishStreamingTextMessage(threadId, { content: messageSoFar, reasoning: reasoningSoFar })
}
dismissStreamError(threadId: string): void {
@@ -381,7 +458,8 @@ class ChatThreadService extends Disposable implements IChatThreadService {
getCurrentThread(): ChatThreads[string] {
const state = this.state
return state.allThreads[state.currentThreadId]
const thread = state.allThreads[state.currentThreadId]
return thread
}
getFocusedMessageIdx() {
@@ -518,35 +596,30 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
_useCurrentMessageState(messageIdx: number) {
const thread = this.getCurrentThread()
const messages = thread.messages
const currMessage = messages[messageIdx]
if (currMessage.role !== 'user') {
return [defaultMessageState, (s: any) => { }] as const
}
const state = currMessage.state
const setState = (newState: Partial<UserMessageState>) => this._setCurrentMessageState(newState, messageIdx)
return [state, setState] as const
getCurrentThreadState = () => {
const currentThread = this.getCurrentThread()
return currentThread.state
}
_useCurrentThreadState() {
const thread = this.getCurrentThread()
const state = thread.state
const setState = this._setCurrentThreadState.bind(this)
return [state, setState] as const
setCurrentThreadState = (newState: Partial<ThreadType['state']>) => {
this._setCurrentThreadState(newState)
}
// gets `staging` and `setStaging` of the currently focused element, given the index of the currently selected message (or undefined if no message is selected)
getCurrentMessageState(messageIdx: number): UserMessageState {
const currMessage = this.getCurrentThread()?.messages?.[messageIdx]
if (!currMessage || currMessage.role !== 'user') return defaultMessageState
return currMessage.state
}
setCurrentMessageState(messageIdx: number, newState: Partial<UserMessageState>) {
const currMessage = this.getCurrentThread()?.messages?.[messageIdx]
if (!currMessage || currMessage.role !== 'user') return
this._setCurrentMessageState(newState, messageIdx)
}
}
registerSingleton(IChatThreadService, ChatThreadService, InstantiationType.Eager);
@@ -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, OpenaiCompatibleModelResponse, } from './llmMessageTypes.js';
import { EventLLMMessageOnTextParams, EventLLMMessageOnErrorParams, EventLLMMessageOnFinalMessageParams, ServiceSendLLMMessageParams, MainSendLLMMessageParams, MainLLMMessageAbortParams, ServiceModelListParams, EventModelListOnSuccessParams, EventModelListOnErrorParams, MainModelListParams, OllamaModelResponse, VLLMModelResponse, } from './llmMessageTypes.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js';
@@ -24,27 +24,39 @@ export interface ILLMMessageService {
sendLLMMessage: (params: ServiceSendLLMMessageParams) => string | null;
abort: (requestId: string) => void;
ollamaList: (params: ServiceModelListParams<OllamaModelResponse>) => void;
openAICompatibleList: (params: ServiceModelListParams<OpenaiCompatibleModelResponse>) => void;
vLLMList: (params: ServiceModelListParams<VLLMModelResponse>) => void;
}
// open this file side by side with llmMessageChannel
export class LLMMessageService extends Disposable implements ILLMMessageService {
readonly _serviceBrand: undefined;
private readonly channel: IChannel // LLMMessageChannel
// llmMessage
private readonly onTextHooks_llm: { [eventId: string]: ((params: EventLLMMessageOnTextParams) => void) } = {}
private readonly onFinalMessageHooks_llm: { [eventId: string]: ((params: EventLLMMessageOnFinalMessageParams) => void) } = {}
private readonly onErrorHooks_llm: { [eventId: string]: ((params: EventLLMMessageOnErrorParams) => void) } = {}
// sendLLMMessage
private readonly llmMessageHooks = {
onText: {} as { [eventId: string]: ((params: EventLLMMessageOnTextParams) => void) },
onFinalMessage: {} as { [eventId: string]: ((params: EventLLMMessageOnFinalMessageParams) => void) },
onError: {} as { [eventId: string]: ((params: EventLLMMessageOnErrorParams) => void) },
}
// ollamaList
private readonly onSuccess_ollama: { [eventId: string]: ((params: EventModelListOnSuccessParams<OllamaModelResponse>) => void) } = {}
private readonly onError_ollama: { [eventId: string]: ((params: EventModelListOnErrorParams<OllamaModelResponse>) => void) } = {}
// openAICompatibleList
private readonly onSuccess_openAICompatible: { [eventId: string]: ((params: EventModelListOnSuccessParams<OpenaiCompatibleModelResponse>) => void) } = {}
private readonly onError_openAICompatible: { [eventId: string]: ((params: EventModelListOnErrorParams<OpenaiCompatibleModelResponse>) => void) } = {}
// list hooks
private readonly listHooks = {
ollama: {
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) },
}
} satisfies {
[providerName: string]: {
success: { [eventId: string]: ((params: EventModelListOnSuccessParams<any>) => void) },
error: { [eventId: string]: ((params: EventModelListOnErrorParams<any>) => void) },
}
}
constructor(
@IMainProcessService private readonly mainProcessService: IMainProcessService, // used as a renderer (only usable on client side)
@@ -59,32 +71,14 @@ 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_llm') satisfies Event<EventLLMMessageOnTextParams>)(e => {
this.onTextHooks_llm[e.requestId]?.(e)
}))
this._register((this.channel.listen('onFinalMessage_llm') satisfies Event<EventLLMMessageOnFinalMessageParams>)(e => {
this.onFinalMessageHooks_llm[e.requestId]?.(e)
this._onRequestIdDone(e.requestId)
}))
this._register((this.channel.listen('onError_llm') satisfies Event<EventLLMMessageOnErrorParams>)(e => {
console.error('Error in LLMMessageService:', JSON.stringify(e))
this.onErrorHooks_llm[e.requestId]?.(e)
this._onRequestIdDone(e.requestId)
}))
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._onRequestIdDone(e.requestId) }))
this._register((this.channel.listen('onError_sendLLMMessage') satisfies Event<EventLLMMessageOnErrorParams>)(e => { this.llmMessageHooks.onError[e.requestId]?.(e); this._onRequestIdDone(e.requestId); console.error('Error in LLMMessageService:', JSON.stringify(e)) }))
// ollama .list()
this._register((this.channel.listen('onSuccess_ollama') satisfies Event<EventModelListOnSuccessParams<OllamaModelResponse>>)(e => {
this.onSuccess_ollama[e.requestId]?.(e)
}))
this._register((this.channel.listen('onError_ollama') satisfies Event<EventModelListOnErrorParams<OllamaModelResponse>>)(e => {
this.onError_ollama[e.requestId]?.(e)
}))
// openaiCompatible .list()
this._register((this.channel.listen('onSuccess_openAICompatible') satisfies Event<EventModelListOnSuccessParams<OpenaiCompatibleModelResponse>>)(e => {
this.onSuccess_openAICompatible[e.requestId]?.(e)
}))
this._register((this.channel.listen('onError_openAICompatible') satisfies Event<EventModelListOnErrorParams<OpenaiCompatibleModelResponse>>)(e => {
this.onError_openAICompatible[e.requestId]?.(e)
}))
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) }))
}
@@ -99,15 +93,15 @@ export class LLMMessageService extends Disposable implements ILLMMessageService
let message: string
if (isDisabled === 'addProvider' || isDisabled === 'providerNotAutoDetected')
message = `Please add a provider in Void Settings.`
message = `Please add a provider in Void's Settings.`
else if (isDisabled === 'addModel')
message = `Please add a model.`
else if (isDisabled === 'needToEnableModel')
message = `Please enable a model.`
else if (isDisabled === 'notFilledIn')
message = `Please fill in Void Settings${modelSelection !== null ? ` for ${displayInfoOfProviderName(modelSelection.providerName).title}` : ''}.`
message = `Please fill in Void's Settings${modelSelection !== null ? ` for ${displayInfoOfProviderName(modelSelection.providerName).title}` : ''}.`
else
message = 'Please add a provider in Void Settings.'
message = `Please add a provider in Void's Settings.`
onError({ message, fullError: null })
return null
@@ -117,9 +111,9 @@ export class LLMMessageService extends Disposable implements ILLMMessageService
// add state for request id
const requestId = generateUuid();
this.onTextHooks_llm[requestId] = onText
this.onFinalMessageHooks_llm[requestId] = onFinalMessage
this.onErrorHooks_llm[requestId] = onError
this.llmMessageHooks.onText[requestId] = onText
this.llmMessageHooks.onFinalMessage[requestId] = onFinalMessage
this.llmMessageHooks.onError[requestId] = onError
const { aiInstructions } = this.voidSettingsService.state.globalSettings
const { settingsOfProvider } = this.voidSettingsService.state
@@ -151,43 +145,46 @@ export class LLMMessageService extends Disposable implements ILLMMessageService
// add state for request id
const requestId_ = generateUuid();
this.onSuccess_ollama[requestId_] = onSuccess
this.onError_ollama[requestId_] = onError
this.listHooks.ollama.success[requestId_] = onSuccess
this.listHooks.ollama.error[requestId_] = onError
this.channel.call('ollamaList', {
...proxyParams,
settingsOfProvider,
providerName: 'ollama',
requestId: requestId_,
} satisfies MainModelListParams<OllamaModelResponse>)
}
openAICompatibleList = (params: ServiceModelListParams<OpenaiCompatibleModelResponse>) => {
vLLMList = (params: ServiceModelListParams<VLLMModelResponse>) => {
const { onSuccess, onError, ...proxyParams } = params
const { settingsOfProvider } = this.voidSettingsService.state
// add state for request id
const requestId_ = generateUuid();
this.onSuccess_openAICompatible[requestId_] = onSuccess
this.onError_openAICompatible[requestId_] = onError
this.listHooks.vLLM.success[requestId_] = onSuccess
this.listHooks.vLLM.error[requestId_] = onError
this.channel.call('openAICompatibleList', {
this.channel.call('vLLMList', {
...proxyParams,
settingsOfProvider,
providerName: 'vLLM',
requestId: requestId_,
} satisfies MainModelListParams<OpenaiCompatibleModelResponse>)
} satisfies MainModelListParams<VLLMModelResponse>)
}
_onRequestIdDone(requestId: string) {
delete this.onTextHooks_llm[requestId]
delete this.onFinalMessageHooks_llm[requestId]
delete this.onErrorHooks_llm[requestId]
delete this.llmMessageHooks.onText[requestId]
delete this.llmMessageHooks.onFinalMessage[requestId]
delete this.llmMessageHooks.onError[requestId]
delete this.onSuccess_ollama[requestId]
delete this.onError_ollama[requestId]
delete this.listHooks.ollama.success[requestId]
delete this.listHooks.ollama.error[requestId]
delete this.listHooks.vLLM.success[requestId]
delete this.listHooks.vLLM.error[requestId]
}
}
@@ -3,6 +3,8 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { ChatMessage } from './chatThreadService.js'
import { InternalToolInfo, ToolName } from './toolsService.js'
import { FeatureName, ProviderName, SettingsOfProvider } from './voidSettingsTypes.js'
@@ -20,22 +22,50 @@ export const errorDetails = (fullError: Error | null): string | null => {
return null
}
export type OnText = (p: { newText: string, fullText: string }) => void
export type OnFinalMessage = (p: { fullText: string }) => void
export type LLMChatMessage = {
role: 'system' | 'user';
content: string;
} | {
role: 'assistant',
content: string;
} | {
role: 'tool';
content: string; // result
name: string;
params: string;
id: string;
}
export type ToolCallType = {
name: ToolName;
params: string;
id: string;
}
export type OnText = (p: { fullText: string; fullReasoning: string }) => void
export type OnFinalMessage = (p: { fullText: string, toolCalls?: ToolCallType[], fullReasoning?: string }) => void // id is tool_use_id
export type OnError = (p: { message: string, fullError: Error | null }) => void
export type AbortRef = { current: (() => void) | null }
export type LLMChatMessage = {
role: 'system' | 'user' | 'assistant';
content: string;
export const toLLMChatMessage = (c: ChatMessage): LLMChatMessage => {
if (c.role === 'user') {
return { role: c.role, content: c.content || '(empty message)' }
}
else if (c.role === 'assistant')
return { role: c.role, content: c.content || '(empty message)' }
else if (c.role === 'tool')
return { role: c.role, id: c.id, name: c.name, params: c.params, content: c.content || '(empty output)' }
else {
throw 1
}
}
export type _InternalLLMChatMessage = {
role: 'user' | 'assistant';
content: string;
}
type _InternalSendFIMMessage = {
export type LLMFIMMessage = {
prefix: string;
suffix: string;
stopTokens: string[];
@@ -44,9 +74,11 @@ type _InternalSendFIMMessage = {
type SendLLMType = {
messagesType: 'chatMessages';
messages: LLMChatMessage[];
tools?: InternalToolInfo[];
} | {
messagesType: 'FIMMessage';
messages: _InternalSendFIMMessage;
messages: LLMFIMMessage;
tools?: undefined;
}
// service types
@@ -86,34 +118,6 @@ export type EventLLMMessageOnFinalMessageParams = Parameters<OnFinalMessage>[0]
export type EventLLMMessageOnErrorParams = Parameters<OnError>[0] & { requestId: string }
export type _InternalSendLLMChatMessageFnType = (
params: {
onText: OnText;
onFinalMessage: OnFinalMessage;
onError: OnError;
providerName: ProviderName;
settingsOfProvider: SettingsOfProvider;
modelName: string;
_setAborter: (aborter: () => void) => void;
messages: _InternalLLMChatMessage[];
}
) => void
export type _InternalSendLLMFIMMessageFnType = (
params: {
onText: OnText;
onFinalMessage: OnFinalMessage;
onError: OnError;
providerName: ProviderName;
settingsOfProvider: SettingsOfProvider;
modelName: string;
_setAborter: (aborter: () => void) => void;
messages: _InternalSendFIMMessage;
}
) => void
// service -> main -> internal -> event (back to main)
// (browser)
@@ -145,18 +149,22 @@ export type OllamaModelResponse = {
size_vram: number;
}
export type OpenaiCompatibleModelResponse = {
type OpenaiCompatibleModelResponse = {
id: string;
created: number;
object: 'model';
owned_by: string;
}
export type VLLMModelResponse = OpenaiCompatibleModelResponse
// params to the true list fn
export type ModelListParams<modelResponse> = {
export type ModelListParams<ModelResponse> = {
providerName: ProviderName;
settingsOfProvider: SettingsOfProvider;
onSuccess: (param: { models: modelResponse[] }) => void;
onSuccess: (param: { models: ModelResponse[] }) => void;
onError: (param: { error: string }) => void;
}
@@ -175,4 +183,3 @@ export type EventModelListOnErrorParams<modelResponse> = Parameters<ModelListPar
export type _InternalModelListFnType<modelResponse> = (params: ModelListParams<modelResponse>) => void
@@ -8,7 +8,7 @@ import { ILLMMessageService } from './llmMessageService.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, OpenaiCompatibleModelResponse } from './llmMessageTypes.js';
import { OllamaModelResponse, VLLMModelResponse } from './llmMessageTypes.js';
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
@@ -45,6 +45,7 @@ export type RefreshModelStateOfProvider = Record<RefreshableProviderName, Refres
const refreshBasedOn: { [k in RefreshableProviderName]: (keyof SettingsOfProvider[k])[] } = {
ollama: ['_didFillInProviderSettings', 'endpoint'],
vLLM: ['_didFillInProviderSettings', 'endpoint'],
// openAICompatible: ['_didFillInProviderSettings', 'endpoint', 'apiKey'],
}
const REFRESH_INTERVAL = 5_000
@@ -140,10 +141,11 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ
state: RefreshModelStateOfProvider = {
ollama: { state: 'init', timeoutId: null },
vLLM: { state: 'init', timeoutId: null },
}
// start listening for models (and don't stop until success)
// start listening for models (and don't stop)
startRefreshingModels: IRefreshModelService['startRefreshingModels'] = (providerName, options) => {
this._clearProviderTimeout(providerName)
@@ -158,7 +160,7 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ
}
}
const listFn = providerName === 'ollama' ? this.llmMessageService.ollamaList
: providerName === 'openAICompatible' ? this.llmMessageService.openAICompatibleList
: providerName === 'vLLM' ? this.llmMessageService.vLLMList
: () => { }
listFn({
@@ -169,7 +171,7 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ
providerName,
models.map(model => {
if (providerName === 'ollama') return (model as OllamaModelResponse).name;
else if (providerName === 'openAICompatible') return (model as OpenaiCompatibleModelResponse).id;
else if (providerName === 'vLLM') return (model as VLLMModelResponse).id;
else throw new Error('refreshMode fn: unknown provider', providerName);
}),
{ enableProviderOnSuccess: options.enableProviderOnSuccess, hideRefresh: options.doNotFire }
@@ -1,12 +1,12 @@
import { CancellationToken } from '../../../../base/common/cancellation.js'
import { URI } from '../../../../base/common/uri.js'
import { IFileService, IFileStat } from '../../../../platform/files/common/files.js'
import { IFileService } from '../../../../platform/files/common/files.js'
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'
import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'
import { _VSReadFileRaw } from '../../../../workbench/contrib/void/browser/helpers/readFile.js'
import { QueryBuilder } from '../../../../workbench/services/search/common/queryBuilder.js'
import { ISearchService } from '../../../../workbench/services/search/common/search.js'
import { IVoidFileService } from './voidFileService.js'
// tool use for AI
@@ -15,6 +15,7 @@ import { ISearchService } from '../../../../workbench/services/search/common/sea
// we do this using Anthropic's style and convert to OpenAI style later
export type InternalToolInfo = {
name: string,
description: string,
params: {
[paramName: string]: { type: string, description: string | undefined } // name -> type
@@ -22,15 +23,15 @@ export type InternalToolInfo = {
required: string[], // required paramNames
}
// helper
const pagination = {
const paginationHelper = {
desc: `Very large results may be paginated (indicated in the result). Pagination fails gracefully if out of bounds or invalid page number.`,
param: { pageNumber: { type: 'number', description: 'The page number (optional, default is 1).' }, }
} as const
export const contextTools = {
export const voidTools = {
read_file: {
description: 'Returns file contents of a given URI.',
name: 'read_file',
description: `Returns file contents of a given URI. ${paginationHelper.desc}`,
params: {
uri: { type: 'string', description: undefined },
},
@@ -38,145 +39,330 @@ export const contextTools = {
},
list_dir: {
description: `Returns all file names and folder names in a given URI. ${pagination.desc}`,
name: 'list_dir',
description: `Returns all file names and folder names in a given URI. ${paginationHelper.desc}`,
params: {
uri: { type: 'string', description: undefined },
...pagination.param
...paginationHelper.param
},
required: ['uri'],
},
pathname_search: {
description: `Returns all pathnames that match a given grep query. You should use this when looking for a file with a specific name or path. This does NOT search file content. ${pagination.desc}`,
name: 'pathname_search',
description: `Returns all pathnames that match a given grep query. You should use this when looking for a file with a specific name or path. This does NOT search file content. ${paginationHelper.desc}`,
params: {
query: { type: 'string', description: undefined },
...pagination.param,
},
required: ['query']
},
search: {
description: `Returns all code excerpts containing the given string or grep query. This does NOT search pathname. As a follow-up, you may want to use read_file to view the full file contents of the results. ${pagination.desc}`,
params: {
query: { type: 'string', description: undefined },
...pagination.param,
...paginationHelper.param,
},
required: ['query'],
},
search: {
name: 'search',
description: `Returns all code excerpts containing the given string or grep query. This does NOT search pathname. As a follow-up, you may want to use read_file to view the full file contents of the results. ${paginationHelper.desc}`,
params: {
query: { type: 'string', description: undefined },
...paginationHelper.param,
},
required: ['query'],
},
// go_to_definition: {
// },
// go_to_usages:
// create_file: {
// name: 'create_file',
// description: `Creates a file at the given path. Fails gracefully if the file already exists by doing nothing.`
// params: {
// uri: { type: 'string', description: undefined },
// }
// }
// semantic_search: {
// description: 'Searches files semantically for the given string query.',
// // RAG
// },
} satisfies { [name: string]: InternalToolInfo }
} as const satisfies { [name: string]: InternalToolInfo }
export type ToolName = keyof typeof voidTools
export const toolNames = Object.keys(voidTools) as ToolName[]
export type ContextToolName = keyof typeof contextTools
type ContextToolParamNames<T extends ContextToolName> = keyof typeof contextTools[T]['params']
type ContextToolParams<T extends ContextToolName> = { [paramName in ContextToolParamNames<T>]: unknown }
type AllContextToolCallFns = {
[ToolName in ContextToolName]: ((p: (ContextToolParams<ToolName>)) => Promise<string>)
const toolNamesSet = new Set<string>(toolNames)
export const isAToolName = (toolName: string): toolName is ToolName => {
const isAToolName = toolNamesSet.has(toolName)
return isAToolName
}
export type ToolParamNames<T extends ToolName> = keyof typeof voidTools[T]['params']
export type ToolParamsObj<T extends ToolName> = { [paramName in ToolParamNames<T>]: unknown }
export type ToolCallReturnType = {
'read_file': { uri: URI, fileContents: string, hasNextPage: boolean },
'list_dir': { rootURI: URI, children: DirectoryItem[] | null, hasNextPage: boolean, hasPrevPage: boolean, itemsRemaining: number },
'pathname_search': { queryStr: string, uris: URI[] | string, hasNextPage: boolean },
'search': { queryStr: string, uris: URI[] | string, hasNextPage: boolean }
'create_file': {}
}
type DirectoryItem = {
uri: URI;
name: string;
isDirectory: boolean;
isSymbolicLink: boolean;
}
export type ToolFns = { [T in ToolName]: (p: string) => Promise<ToolCallReturnType[T]> }
export type ToolResultToString = { [T in ToolName]: (result: ToolCallReturnType[T]) => string }
// pagination info
const MAX_FILE_CHARS_PAGE = 50_000
const MAX_CHILDREN_URIs_PAGE = 500
// TODO check to make sure in workspace
// TODO check to make sure is not gitignored
async function generateDirectoryTreeMd(fileService: IFileService, rootURI: URI): Promise<string> {
let output = ''
function traverseChildren(children: IFileStat[], depth: number) {
const indentation = ' '.repeat(depth);
for (const child of children) {
output += `${indentation}- ${child.name}\n`;
traverseChildren(child.children ?? [], depth + 1);
}
}
const computeDirectoryResult = async (
fileService: IFileService,
rootURI: URI,
pageNumber: number = 1
): Promise<ToolCallReturnType['list_dir']> => {
const stat = await fileService.resolve(rootURI, { resolveMetadata: false });
if (!stat.isDirectory) {
return { rootURI, children: null, hasNextPage: false, hasPrevPage: false, itemsRemaining: 0 };
}
// kickstart recursion
output += `${stat.name}\n`;
traverseChildren(stat.children ?? [], 1);
const originalChildrenLength = stat.children?.length ?? 0;
const fromChildIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1);
const toChildIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1; // INCLUSIVE
const listChildren = stat.children?.slice(fromChildIdx, toChildIdx + 1) ?? [];
const children: DirectoryItem[] = listChildren.map(child => ({
name: child.name,
uri: child.resource,
isDirectory: child.isDirectory,
isSymbolicLink: child.isSymbolicLink
}));
const hasNextPage = (originalChildrenLength - 1) > toChildIdx;
const hasPrevPage = pageNumber > 1;
const itemsRemaining = Math.max(0, originalChildrenLength - (toChildIdx + 1));
return {
rootURI,
children,
hasNextPage,
hasPrevPage,
itemsRemaining
};
};
const directoryResultToString = (result: ToolCallReturnType['list_dir']): string => {
if (!result.children) {
return `Error: ${result.rootURI} is not a directory`;
}
let output = '';
const entries = result.children;
if (!result.hasPrevPage) {
output += `${result.rootURI}\n`;
}
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const isLast = i === entries.length - 1 && !result.hasNextPage;
const prefix = isLast ? '└── ' : '├── ';
output += `${prefix}${entry.name}${entry.isDirectory ? '/' : ''}${entry.isSymbolicLink ? ' (symbolic link)' : ''}\n`;
}
if (result.hasNextPage) {
output += `└── (${result.itemsRemaining} results remaining...)\n`;
}
return output;
};
const validateJSON = (s: string): { [s: string]: unknown } => {
try {
const o = JSON.parse(s)
return o
}
catch (e) {
throw new Error(`Tool parameter was not a valid JSON: "${s}".`)
}
}
const validateQueryStr = (queryStr: unknown) => {
if (typeof queryStr !== 'string') throw new Error('Error calling tool: provided query must be a string.')
return queryStr
}
// TODO!!!! check to make sure in workspace
const validateURI = (uriStr: unknown) => {
if (typeof uriStr !== 'string') throw new Error('(uri was not a string)')
if (typeof uriStr !== 'string') throw new Error('Error calling tool: provided uri must be a string.')
const uri = URI.file(uriStr)
return uri
}
export interface IToolService {
const validatePageNum = (pageNumberUnknown: unknown) => {
const proposedPageNum = Number.parseInt(pageNumberUnknown + '')
const num = Number.isInteger(proposedPageNum) ? proposedPageNum : 1
const pageNumber = num < 1 ? 1 : num
return pageNumber
}
export interface IToolsService {
readonly _serviceBrand: undefined;
callContextTool: <T extends ContextToolName>(toolName: T, params: ContextToolParams<T>) => Promise<string>
toolFns: ToolFns;
toolResultToString: ToolResultToString;
}
export const IToolService = createDecorator<IToolService>('ToolService');
export const IToolsService = createDecorator<IToolsService>('ToolsService');
export class ToolService implements IToolService {
export class ToolsService implements IToolsService {
readonly _serviceBrand: undefined;
contextToolCallFns: AllContextToolCallFns
public toolFns: ToolFns
public toolResultToString: ToolResultToString
constructor(
@IFileService fileService: IFileService,
@IWorkspaceContextService workspaceContextService: IWorkspaceContextService,
@ISearchService searchService: ISearchService,
@IInstantiationService instantiationService: IInstantiationService,
@IVoidFileService voidFileService: IVoidFileService,
) {
const queryBuilder = instantiationService.createInstance(QueryBuilder);
this.contextToolCallFns = {
read_file: async ({ uri: uriStr }) => {
this.toolFns = {
read_file: async (s: string) => {
console.log('read_file')
const o = validateJSON(s)
const { uri: uriStr, pageNumber: pageNumberUnknown } = o
const uri = validateURI(uriStr)
const fileContents = await _VSReadFileRaw(fileService, uri)
return fileContents ?? '(could not read file)'
const pageNumber = validatePageNum(pageNumberUnknown)
const readFileContents = await voidFileService.readFile(uri)
const fromIdx = MAX_FILE_CHARS_PAGE * (pageNumber - 1)
const toIdx = MAX_FILE_CHARS_PAGE * pageNumber - 1
const fileContents = readFileContents.slice(fromIdx, toIdx + 1) || '(empty)' // paginate
const hasNextPage = (readFileContents.length - 1) - toIdx >= 1
console.log('read_file result:', fileContents)
return { uri, fileContents, hasNextPage }
},
list_dir: async ({ uri: uriStr }) => {
list_dir: async (s: string) => {
console.log('list_dir')
const o = validateJSON(s)
const { uri: uriStr, pageNumber: pageNumberUnknown } = o
const uri = validateURI(uriStr)
const treeStr = await generateDirectoryTreeMd(fileService, uri)
return treeStr
},
pathname_search: async ({ query: queryStr }) => {
if (typeof queryStr !== 'string') return '(Error: query was not a string)'
const query = queryBuilder.file(workspaceContextService.getWorkspace().folders.map(f => f.uri), { filePattern: queryStr, });
const pageNumber = validatePageNum(pageNumberUnknown)
const data = await searchService.fileSearch(query, CancellationToken.None);
const str = data.results.map(({ resource, results }) => resource.fsPath).join('\n')
return str
},
search: async ({ query: queryStr }) => {
if (typeof queryStr !== 'string') return '(Error: query was not a string)'
const query = queryBuilder.text({ pattern: queryStr, }, workspaceContextService.getWorkspace().folders.map(f => f.uri));
const dirResult = await computeDirectoryResult(fileService, uri, pageNumber)
console.log('list_dir result:', dirResult)
const data = await searchService.textSearch(query, CancellationToken.None);
const str = data.results.map(({ resource, results }) => resource.fsPath).join('\n')
return str
return dirResult
},
pathname_search: async (s: string) => {
console.log('pathname_search')
const o = validateJSON(s)
const { query: queryUnknown, pageNumber: pageNumberUnknown } = o
const queryStr = validateQueryStr(queryUnknown)
const pageNumber = validatePageNum(pageNumberUnknown)
const query = queryBuilder.file(workspaceContextService.getWorkspace().folders.map(f => f.uri), { filePattern: queryStr, })
const data = await searchService.fileSearch(query, CancellationToken.None)
const fromIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1)
const toIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1
const uris = data.results
.slice(fromIdx, toIdx + 1) // paginate
.map(({ resource, results }) => resource)
const hasNextPage = (data.results.length - 1) - toIdx >= 1
console.log('pathname_search result:', uris)
return { queryStr, uris, hasNextPage }
},
search: async (s: string) => {
console.log('search')
const o = validateJSON(s)
const { query: queryUnknown, pageNumber: pageNumberUnknown } = o
const queryStr = validateQueryStr(queryUnknown)
const pageNumber = validatePageNum(pageNumberUnknown)
const query = queryBuilder.text({ pattern: queryStr, }, workspaceContextService.getWorkspace().folders.map(f => f.uri))
const data = await searchService.textSearch(query, CancellationToken.None)
const fromIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1)
const toIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1
const uris = data.results
.slice(fromIdx, toIdx + 1) // paginate
.map(({ resource, results }) => resource)
const hasNextPage = (data.results.length - 1) - toIdx >= 1
console.log('search result:', uris)
return { queryStr, uris, hasNextPage }
},
}
const nextPageStr = (hasNextPage: boolean) => hasNextPage ? '\n\n(more on next page...)' : ''
this.toolResultToString = {
read_file: (result) => {
return nextPageStr(result.hasNextPage)
},
list_dir: (result) => {
const dirTreeStr = directoryResultToString(result)
return dirTreeStr + nextPageStr(result.hasNextPage)
},
pathname_search: (result) => {
if (typeof result.uris === 'string') return result.uris
return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage)
},
search: (result) => {
if (typeof result.uris === 'string') return result.uris
return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage)
},
}
}
callContextTool: IToolService['callContextTool'] = (toolName, params) => {
return this.contextToolCallFns[toolName](params)
}
}
registerSingleton(IToolService, ToolService, InstantiationType.Eager);
registerSingleton(IToolsService, ToolsService, InstantiationType.Eager);
@@ -0,0 +1,109 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { isWindows } from '../../../../base/common/platform.js';
import { URI } from '../../../../base/common/uri.js';
import { EndOfLinePreference } from '../../../../editor/common/model.js';
import { IModelService } from '../../../../editor/common/services/model.js';
import { IFileService } from '../../../../platform/files/common/files.js';
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
// linebreak symbols
export const allLinebreakSymbols = ['\r\n', '\n']
export const _ln = isWindows ? allLinebreakSymbols[0] : allLinebreakSymbols[1]
export interface IVoidFileService {
readonly _serviceBrand: undefined;
readFile(uri: URI, range?: { startLineNumber: number, endLineNumber: number }): Promise<string>;
readModel(uri: URI, range?: { startLineNumber: number, endLineNumber: number }): string | null;
}
export const IVoidFileService = createDecorator<IVoidFileService>('VoidFileService');
// implemented by calling channel
export class VoidFileService implements IVoidFileService {
readonly _serviceBrand: undefined;
constructor(
@IModelService private readonly modelService: IModelService,
@IFileService private readonly fileService: IFileService,
) {
}
readFile = async (uri: URI, range?: { startLineNumber: number, endLineNumber: number }): Promise<string> => {
// attempt to read the model
const modelResult = this.readModel(uri, range);
if (modelResult) return modelResult;
// if no model, read the raw file
const fileResult = await this._readFileRaw(uri, range);
if (fileResult) return fileResult;
return '';
}
_readFileRaw = async (uri: URI, range?: { startLineNumber: number, endLineNumber: number }): Promise<string | null> => {
try { // this throws an error if no file exists (eg it was deleted)
const res = await this.fileService.readFile(uri);
if (range) {
return res.value.toString()
.split(_ln)
.slice(range.startLineNumber - 1, range.endLineNumber)
.join(_ln)
}
return res.value.toString();
} catch (e) {
return null;
}
}
readModel = (uri: URI, range?: { startLineNumber: number, endLineNumber: number }): string | null => {
// read saved model (sometimes null if the user reloads application)
let model = this.modelService.getModel(uri);
// check all opened models for the same `fsPath`
if (!model) {
const models = this.modelService.getModels();
for (const m of models) {
if (m.uri.fsPath === uri.fsPath) {
model = m
break;
}
}
}
// if still not found, return
if (!model) { return null }
// if range, read it
if (range) {
return model.getValueInRange({
startLineNumber: range.startLineNumber,
endLineNumber: range.endLineNumber,
startColumn: 1,
endColumn: Number.MAX_VALUE
}, EndOfLinePreference.LF);
} else {
return model.getValue(EndOfLinePreference.LF)
}
}
}
registerSingleton(IVoidFileService, VoidFileService, InstantiationType.Eager);
@@ -11,7 +11,7 @@ import { registerSingleton, InstantiationType } from '../../../../platform/insta
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import { IMetricsService } from './metricsService.js';
import { defaultSettingsOfProvider, FeatureName, ProviderName, ModelSelectionOfFeature, SettingsOfProvider, SettingName, providerNames, ModelSelection, modelSelectionsEqual, featureNames, modelInfoOfDefaultModelNames, VoidModelInfo, GlobalSettings, GlobalSettingName, defaultGlobalSettings, displayInfoOfProviderName, defaultProviderSettings } from './voidSettingsTypes.js';
import { defaultSettingsOfProvider, FeatureName, ProviderName, ModelSelectionOfFeature, SettingsOfProvider, SettingName, providerNames, ModelSelection, modelSelectionsEqual, featureNames, VoidModelInfo, GlobalSettings, GlobalSettingName, defaultGlobalSettings, defaultProviderSettings } from './voidSettingsTypes.js';
const STORAGE_KEY = 'void.settingsServiceStorage'
@@ -28,12 +28,10 @@ type SetModelSelectionOfFeatureFn = <K extends FeatureName>(
options?: { doNotApplyEffects?: true }
) => Promise<void>;
type SetGlobalSettingFn = <T extends GlobalSettingName, >(settingName: T, newVal: GlobalSettings[T]) => void;
type SetGlobalSettingFn = <T extends GlobalSettingName>(settingName: T, newVal: GlobalSettings[T]) => void;
export type ModelOption = { name: string, selection: ModelSelection }
export type VoidSettingsState = {
readonly settingsOfProvider: SettingsOfProvider; // optionsOfProvider
readonly modelSelectionOfFeature: ModelSelectionOfFeature; // stateOfFeature
@@ -51,6 +49,8 @@ export interface IVoidSettingsService {
readonly state: VoidSettingsState; // in order to play nicely with react, you should immutably change state
readonly waitForInitState: Promise<void>;
readAndInitializeState: (providedState?: VoidSettingsState) => Promise<void>;
onDidChangeState: Event<void>;
setSettingOfProvider: SetSettingOfProviderFn;
@@ -65,7 +65,30 @@ export interface IVoidSettingsService {
const _updatedValidatedState = (state: Omit<VoidSettingsState, '_modelOptions'>) => {
const _updatedModelsAfterDefaultModelsChange = (defaultModelNames: string[], options: { existingModels: VoidModelInfo[] }) => {
const { existingModels } = options
const existingModelsMap: Record<string, VoidModelInfo> = {}
for (const existingModel of existingModels) {
existingModelsMap[existingModel.modelName] = existingModel
}
const newDefaultModels = defaultModelNames.map((modelName, i) => ({
modelName,
isDefault: true,
isAutodetected: true,
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
]
}
const _validatedState = (state: Omit<VoidSettingsState, '_modelOptions'>) => {
let newSettingsOfProvider = state.settingsOfProvider
@@ -89,7 +112,7 @@ const _updatedValidatedState = (state: Omit<VoidSettingsState, '_modelOptions'>)
// update model options
let newModelOptions: ModelOption[] = []
for (const providerName of providerNames) {
const providerTitle = displayInfoOfProviderName(providerName).title.toLowerCase() // looks better lowercase, best practice to not use raw providerName
const providerTitle = providerName // displayInfoOfProviderName(providerName).title.toLowerCase() // looks better lowercase, best practice to not use raw providerName
if (!newSettingsOfProvider[providerName]._didFillInProviderSettings) continue // if disabled, don't display model options
for (const { modelName, isHidden } of newSettingsOfProvider[providerName].models) {
if (isHidden) continue
@@ -131,7 +154,7 @@ const _updatedValidatedState = (state: Omit<VoidSettingsState, '_modelOptions'>)
const defaultState = () => {
const d: VoidSettingsState = {
settingsOfProvider: deepClone(defaultSettingsOfProvider),
modelSelectionOfFeature: { 'Ctrl+L': null, 'Ctrl+K': null, 'Autocomplete': null, 'FastApply': null },
modelSelectionOfFeature: { 'Ctrl+L': null, 'Ctrl+K': null, 'Autocomplete': null, 'Apply': null },
globalSettings: deepClone(defaultGlobalSettings),
_modelOptions: [], // computed later
}
@@ -147,6 +170,8 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
readonly onDidChangeState: Event<void> = this._onDidChangeState.event; // this is primarily for use in react, so react can listen + update on state changes
state: VoidSettingsState;
private readonly _resolver: () => void
waitForInitState: Promise<void> // await this if you need a valid state initially
constructor(
@@ -160,52 +185,57 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
// at the start, we haven't read the partial config yet, but we need to set state to something
this.state = defaultState()
let resolver: () => void = () => { }
this.waitForInitState = new Promise((res, rej) => resolver = res)
this._resolver = resolver
// read and update the actual state immediately
this._readState().then(readS => {
this.readAndInitializeState()
}
// the stored data structure might be outdated, so we need to update it here (can do a more general solution later when we need to)
const newSettingsOfProvider = {
// A HACK BECAUSE WE ADDED DEEPSEEK (did not exist before, comes before readS)
...{ deepseek: defaultSettingsOfProvider.deepseek },
async readAndInitializeState(providedState?: VoidSettingsState) {
// If providedState is given, use it instead of reading from storage
const readS = providedState || await this._readState();
// A HACK BECAUSE WE ADDED MISTRAL (did not exist before, comes before readS)
...{ mistral: defaultSettingsOfProvider.mistral },
// the stored data structure might be outdated, so we need to update it here
const newSettingsOfProvider = {
// A HACK BECAUSE WE ADDED DEEPSEEK (did not exist before, comes before readS)
...{ deepseek: defaultSettingsOfProvider.deepseek },
...readS.settingsOfProvider,
// A HACK BECAUSE WE ADDED XAI (did not exist before, comes before readS)
...{ xAI: defaultSettingsOfProvider.xAI },
// A HACK BECAUSE WE ADDED NEW GEMINI MODELS (existed before, comes after readS)
gemini: {
...readS.settingsOfProvider.gemini,
models: [
...readS.settingsOfProvider.gemini.models,
...defaultSettingsOfProvider.gemini.models.filter(m => /* if cant find the model in readS (yes this is O(n^2), very small) */ !readS.settingsOfProvider.gemini.models.find(m2 => m2.modelName === m.modelName))
]
}
// A HACK BECAUSE WE ADDED VLLM (did not exist before, comes before readS)
...{ vLLM: defaultSettingsOfProvider.vLLM },
...readS.settingsOfProvider,
// A HACK BECAUSE WE ADDED NEW GEMINI MODELS (existed before, comes after readS)
gemini: {
...readS.settingsOfProvider.gemini,
models: [
...readS.settingsOfProvider.gemini.models,
...defaultSettingsOfProvider.gemini.models.filter(m => /* if cant find the model in readS (yes this is O(n^2), very small) */ !readS.settingsOfProvider.gemini.models.find(m2 => m2.modelName === m.modelName))
]
}
};
const newModelSelectionOfFeature = {
// A HACK BECAUSE WE ADDED FastApply
...{ 'FastApply': null },
...readS.modelSelectionOfFeature,
}
const newModelSelectionOfFeature = {
// A HACK BECAUSE WE ADDED FastApply
...{ 'Apply': null },
...readS.modelSelectionOfFeature,
};
readS = {
...readS,
settingsOfProvider: newSettingsOfProvider,
modelSelectionOfFeature: newModelSelectionOfFeature,
}
const finalState = {
...readS,
settingsOfProvider: newSettingsOfProvider,
modelSelectionOfFeature: newModelSelectionOfFeature,
};
this.state = _updatedValidatedState(readS)
resolver()
this._onDidChangeState.fire()
})
this.state = _validatedState(finalState);
this._resolver();
this._onDidChangeState.fire();
}
@@ -246,7 +276,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
globalSettings: newGlobalSettings,
}
this.state = _updatedValidatedState(newState)
this.state = _validatedState(newState)
await this._storeState()
this._onDidChangeState.fire()
@@ -289,27 +319,21 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
setAutodetectedModels(providerName: ProviderName, newDefaultModelNames: string[], logging: object) {
setAutodetectedModels(providerName: ProviderName, autodetectedModelNames: string[], logging: object) {
const { models } = this.state.settingsOfProvider[providerName]
const oldModelNames = models.map(m => m.modelName)
const newDefaultModelInfo = modelInfoOfDefaultModelNames(newDefaultModelNames, { isAutodetected: true, existingModels: models })
const newModelInfo = [
...newDefaultModelInfo, // swap out all the default models for the new default models
...models.filter(m => !m.isDefault), // keep any non-defaul (custom) models
]
this.setSettingOfProvider(providerName, 'models', newModelInfo)
const newModels = _updatedModelsAfterDefaultModelsChange(autodetectedModelNames, { existingModels: models })
this.setSettingOfProvider(providerName, 'models', newModels)
// if the models changed, log it
const new_names = newModelInfo.map(m => m.modelName)
const new_names = newModels.map(m => m.modelName)
if (!(oldModelNames.length === new_names.length
&& oldModelNames.every((_, i) => oldModelNames[i] === new_names[i]))
) {
this._metricsService.capture('Autodetect Models', { providerName, newModels: newModelInfo, ...logging })
this._metricsService.capture('Autodetect Models', { providerName, newModels: newModels, ...logging })
}
}
toggleModelHidden(providerName: ProviderName, modelName: string) {
@@ -7,141 +7,9 @@
import { VoidSettingsState } from './voidSettingsService.js'
export type VoidModelInfo = {
modelName: string,
isDefault: boolean, // whether or not it's a default for its provider
isHidden: boolean, // whether or not the user is hiding it (switched off)
isAutodetected?: boolean, // whether the model was autodetected by polling
}
// creates `modelInfo` from `modelNames`
export const modelInfoOfDefaultModelNames = (defaultModelNames: string[], options?: { isAutodetected: true, existingModels: VoidModelInfo[] }): VoidModelInfo[] => {
const { isAutodetected, existingModels } = options ?? {}
if (!existingModels) { // default settings
return defaultModelNames.map((modelName, i) => ({
modelName,
isDefault: true,
isAutodetected: isAutodetected,
isHidden: defaultModelNames.length >= 10 // hide all models if there are a ton of them, and make user enable them individually
}))
} else { // settings if there are existing models (keep existing `isHidden` property)
const existingModelsMap: Record<string, VoidModelInfo> = {}
for (const existingModel of existingModels) {
existingModelsMap[existingModel.modelName] = existingModel
}
return defaultModelNames.map((modelName, i) => ({
modelName,
isDefault: true,
isAutodetected: isAutodetected,
isHidden: !!existingModelsMap[modelName]?.isHidden,
}))
}
}
// https://docs.anthropic.com/en/docs/about-claude/models
export const defaultAnthropicModels = modelInfoOfDefaultModelNames([
'claude-3-5-sonnet-20241022',
'claude-3-5-haiku-20241022',
'claude-3-opus-20240229',
'claude-3-sonnet-20240229',
// 'claude-3-haiku-20240307',
])
// https://platform.openai.com/docs/models/gp
export const defaultOpenAIModels = modelInfoOfDefaultModelNames([
'o1',
'o1-mini',
'o3-mini',
'gpt-4o',
'gpt-4o-mini',
// 'gpt-4o-2024-05-13',
// 'gpt-4o-2024-08-06',
// 'gpt-4o-mini-2024-07-18',
// 'gpt-4-turbo',
// 'gpt-4-turbo-2024-04-09',
// 'gpt-4-turbo-preview',
// 'gpt-4-0125-preview',
// 'gpt-4-1106-preview',
// 'gpt-4',
// 'gpt-4-0613',
// 'gpt-3.5-turbo-0125',
// 'gpt-3.5-turbo',
// 'gpt-3.5-turbo-1106',
])
// https://platform.openai.com/docs/models/gp
export const defaultDeepseekModels = modelInfoOfDefaultModelNames([
'deepseek-chat',
'deepseek-reasoner',
])
// https://console.groq.com/docs/models
export const defaultGroqModels = modelInfoOfDefaultModelNames([
"llama3-70b-8192",
"llama-3.3-70b-versatile",
"llama-3.1-8b-instant",
"gemma2-9b-it",
"mixtral-8x7b-32768"
])
export const defaultGeminiModels = modelInfoOfDefaultModelNames([
'gemini-1.5-flash',
'gemini-1.5-pro',
'gemini-1.5-flash-8b',
'gemini-2.0-flash-exp',
'gemini-2.0-flash-thinking-exp-1219',
'learnlm-1.5-pro-experimental'
])
export const defaultMistralModels = modelInfoOfDefaultModelNames([
"codestral-latest",
"open-codestral-mamba",
"open-mistral-nemo",
"mistral-large-latest",
"pixtral-large-latest",
"ministral-3b-latest",
"ministral-8b-latest",
"mistral-small-latest",
])
// export const parseMaxTokensStr = (maxTokensStr: string) => {
// // parse the string but only if the full string is a valid number, eg parseInt('100abc') should return NaN
// const int = isNaN(Number(maxTokensStr)) ? undefined : parseInt(maxTokensStr)
// if (Number.isNaN(int))
// return undefined
// return int
// }
export const anthropicMaxPossibleTokens = (modelName: string) => {
if (modelName === 'claude-3-5-sonnet-20241022'
|| modelName === 'claude-3-5-haiku-20241022')
return 8192
if (modelName === 'claude-3-opus-20240229'
|| modelName === 'claude-3-sonnet-20240229'
|| modelName === 'claude-3-haiku-20240307')
return 4096
return 1024 // return a reasonably small number if they're using a different model
}
type UnionOfKeys<T> = T extends T ? keyof T : never;
export const defaultProviderSettings = {
anthropic: {
apiKey: '',
@@ -155,6 +23,9 @@ export const defaultProviderSettings = {
ollama: {
endpoint: 'http://127.0.0.1:11434',
},
vLLM: {
endpoint: 'http://localhost:8000',
},
openRouter: {
apiKey: '',
},
@@ -168,15 +39,75 @@ export const defaultProviderSettings = {
groq: {
apiKey: '',
},
mistral: {
xAI: {
apiKey: ''
}
},
} as const
export const defaultModelsOfProvider = {
openAI: [ // https://platform.openai.com/docs/models/gp
'o1',
'o3-mini',
'o1-mini',
'gpt-4o',
'gpt-4o-mini',
],
anthropic: [ // https://docs.anthropic.com/en/docs/about-claude/models
'claude-3-7-sonnet-latest',
// 'claude-3-5-sonnet-latest',
'claude-3-5-haiku-latest',
'claude-3-opus-latest',
],
xAI: [ // https://docs.x.ai/docs/models?cluster=us-east-1
'grok-2-latest',
'grok-3-latest',
],
gemini: [ // https://ai.google.dev/gemini-api/docs/models/gemini
'gemini-2.0-flash',
'gemini-1.5-flash',
'gemini-1.5-pro',
'gemini-1.5-flash-8b',
'gemini-2.0-flash-thinking-exp',
],
deepseek: [ // https://api-docs.deepseek.com/quick_start/pricing
'deepseek-chat',
'deepseek-reasoner',
],
ollama: [ // autodetected
],
vLLM: [ // autodetected
],
openRouter: [ // https://openrouter.ai/models
'anthropic/claude-3.5-sonnet',
'deepseek/deepseek-r1',
'mistralai/codestral-2501',
'qwen/qwen-2.5-coder-32b-instruct',
],
groq: [ // https://console.groq.com/docs/models
'llama-3.3-70b-versatile',
'llama-3.1-8b-instant',
'qwen-2.5-coder-32b', // preview mode (experimental)
],
// not supporting mistral right now- it's last on Void usage, and a huge pain to set up since it's nonstandard (it supports codestral FIM but it's on v1/fim/completions, etc)
// mistral: [ // https://docs.mistral.ai/getting-started/models/models_overview/
// 'codestral-latest',
// 'mistral-large-latest',
// 'ministral-3b-latest',
// 'ministral-8b-latest',
// ],
openAICompatible: [], // fallback
} as const satisfies Record<ProviderName, string[]>
export type ProviderName = keyof typeof defaultProviderSettings
export const providerNames = Object.keys(defaultProviderSettings) as ProviderName[]
export const localProviderNames = ['ollama'] satisfies ProviderName[] // all local names
export const localProviderNames = ['ollama', 'vLLM'] 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]>
@@ -189,6 +120,14 @@ export const customSettingNamesOfProvider = (providerName: ProviderName) => {
export type VoidModelInfo = { // <-- STATEFUL
modelName: string,
isDefault: boolean, // whether or not it's a default for its provider
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
type CommonProviderSettings = {
_didFillInProviderSettings: boolean | undefined, // undefined initially, computed when user types in all fields
@@ -205,10 +144,6 @@ export type SettingsOfProvider = {
export type SettingName = keyof SettingsAtProvider<ProviderName>
type DisplayInfoForProviderName = {
title: string,
desc?: string,
@@ -238,7 +173,11 @@ export const displayInfoOfProviderName = (providerName: ProviderName): DisplayIn
else if (providerName === 'ollama') {
return {
title: 'Ollama',
}
}
else if (providerName === 'vLLM') {
return {
title: 'vLLM',
}
}
else if (providerName === 'openAICompatible') {
@@ -256,19 +195,21 @@ export const displayInfoOfProviderName = (providerName: ProviderName): DisplayIn
title: 'Groq.com API',
}
}
else if (providerName === 'mistral') {
else if (providerName === 'xAI') {
return {
title: 'Mistral API',
title: 'xAI API',
}
}
throw new Error(`descOfProviderName: Unknown provider name: "${providerName}"`)
}
type DisplayInfo = {
title: string,
placeholder: string,
subTextMd?: string,
title: string;
placeholder: string;
subTextMd?: string;
isPasswordField?: boolean;
}
export const displayInfoOfSettingName = (providerName: ProviderName, settingName: SettingName): DisplayInfo => {
if (settingName === 'apiKey') {
@@ -283,8 +224,8 @@ export const displayInfoOfSettingName = (providerName: ProviderName, settingName
providerName === 'openRouter' ? 'sk-or-key...' : // sk-or-v1-key
providerName === 'gemini' ? 'key...' :
providerName === 'groq' ? 'gsk_key...' :
providerName === 'mistral' ? 'key...' :
providerName === 'openAICompatible' ? 'sk-key...' :
providerName === 'openAICompatible' ? 'sk-key...' :
providerName === 'xAI' ? 'xai-key...' :
'',
subTextMd: providerName === 'anthropic' ? 'Get your [API Key here](https://console.anthropic.com/settings/keys).' :
@@ -293,23 +234,27 @@ export const displayInfoOfSettingName = (providerName: ProviderName, settingName
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 === 'mistral' ? 'Get your [API Key here](https://console.mistral.ai/api-keys/).' :
providerName === 'xAI' ? 'Get your [API Key here](https://console.x.ai).' :
providerName === 'openAICompatible' ? undefined :
'',
isPasswordField: true,
}
}
else if (settingName === 'endpoint') {
return {
title: providerName === 'ollama' ? 'Endpoint' :
providerName === 'openAICompatible' ? 'baseURL' // (do not include /chat/completions)
: '(never)',
providerName === 'vLLM' ? 'Endpoint' :
providerName === 'openAICompatible' ? 'baseURL' : // (do not include /chat/completions)
'(never)',
placeholder: providerName === 'ollama' ? defaultProviderSettings.ollama.endpoint
: providerName === 'openAICompatible' ? 'https://my-website.com/v1'
: '(never)',
: providerName === 'vLLM' ? defaultProviderSettings.vLLM.endpoint
: providerName === 'openAICompatible' ? 'https://my-website.com/v1'
: '(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).' :
undefined,
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 === '_didFillInProviderSettings') {
@@ -338,92 +283,77 @@ const defaultCustomSettings: Record<CustomSettingName, undefined> = {
}
export const voidInitModelOptions = {
anthropic: {
models: defaultAnthropicModels,
},
openAI: {
models: defaultOpenAIModels,
},
deepseek: {
models: defaultDeepseekModels,
},
ollama: {
models: [],
},
openRouter: {
models: [], // any string
},
openAICompatible: {
models: [],
},
gemini: {
models: defaultGeminiModels,
},
groq: {
models: defaultGroqModels,
},
mistral: {
models: defaultMistralModels,
const modelInfoOfDefaultModelNames = (defaultModelNames: string[]): { models: VoidModelInfo[] } => {
return {
models: defaultModelNames.map((modelName, i) => ({
modelName,
isDefault: true,
isAutodetected: false,
isHidden: defaultModelNames.length >= 10, // hide all models if there are a ton of them, and make user enable them individually
}))
}
}
// used when waiting and for a type reference
export const defaultSettingsOfProvider: SettingsOfProvider = {
anthropic: {
...defaultCustomSettings,
...defaultProviderSettings.anthropic,
...voidInitModelOptions.anthropic,
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.anthropic),
_didFillInProviderSettings: undefined,
},
openAI: {
...defaultCustomSettings,
...defaultProviderSettings.openAI,
...voidInitModelOptions.openAI,
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.openAI),
_didFillInProviderSettings: undefined,
},
deepseek: {
...defaultCustomSettings,
...defaultProviderSettings.deepseek,
...voidInitModelOptions.deepseek,
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.deepseek),
_didFillInProviderSettings: undefined,
},
gemini: {
...defaultCustomSettings,
...defaultProviderSettings.gemini,
...voidInitModelOptions.gemini,
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.gemini),
_didFillInProviderSettings: undefined,
},
mistral: {
xAI: {
...defaultCustomSettings,
...defaultProviderSettings.mistral,
...voidInitModelOptions.mistral,
...defaultProviderSettings.xAI,
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.xAI),
_didFillInProviderSettings: undefined,
},
groq: { // aggregator
...defaultCustomSettings,
...defaultProviderSettings.groq,
...voidInitModelOptions.groq,
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.groq),
_didFillInProviderSettings: undefined,
},
openRouter: { // aggregator
...defaultCustomSettings,
...defaultProviderSettings.openRouter,
...voidInitModelOptions.openRouter,
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.openRouter),
_didFillInProviderSettings: undefined,
},
openAICompatible: { // aggregator
...defaultCustomSettings,
...defaultProviderSettings.openAICompatible,
...voidInitModelOptions.openAICompatible,
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.openAICompatible),
_didFillInProviderSettings: undefined,
},
ollama: { // aggregator
...defaultCustomSettings,
...defaultProviderSettings.ollama,
...voidInitModelOptions.ollama,
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.ollama),
_didFillInProviderSettings: undefined,
},
vLLM: { // aggregator
...defaultCustomSettings,
...defaultProviderSettings.vLLM,
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.vLLM),
_didFillInProviderSettings: undefined,
},
}
@@ -436,18 +366,20 @@ export const modelSelectionsEqual = (m1: ModelSelection, m2: ModelSelection) =>
}
// this is a state
export const featureNames = ['Ctrl+L', 'Ctrl+K', 'Autocomplete', 'FastApply'] as const
export const featureNames = ['Ctrl+L', 'Ctrl+K', 'Autocomplete', 'Apply'] as const
export type ModelSelectionOfFeature = Record<(typeof featureNames)[number], ModelSelection | null>
export type FeatureName = keyof ModelSelectionOfFeature
export const displayInfoOfFeatureName = (featureName: FeatureName) => {
// editor:
if (featureName === 'Autocomplete')
return 'Autocomplete'
else if (featureName === 'Ctrl+K')
return 'Quick-Edit'
return 'Quick Edit'
// sidebar:
else if (featureName === 'Ctrl+L')
return 'Chat'
else if (featureName === 'FastApply')
else if (featureName === 'Apply')
return 'Apply'
else
throw new Error(`Feature Name ${featureName} not allowed`)
@@ -528,77 +460,3 @@ export const globalSettingNames = Object.keys(defaultGlobalSettings) as GlobalSe
export const recognizedModels = [
// chat
'OpenAI 4o',
'Anthropic Claude',
'Llama 3.x',
'Deepseek Chat', // deepseek coder v2 is now merged into chat (V3) https://api-docs.deepseek.com/updates#deepseek-coder--deepseek-chat-upgraded-to-deepseek-v25-model
// 'xAI Grok',
// 'Google Gemini, Gemma',
// 'Microsoft Phi4',
// coding (autocomplete)
'Alibaba Qwen2.5 Coder Instruct', // we recommend this over Qwen2.5
'Mistral Codestral',
// thinking
'OpenAI o1, o3',
'Deepseek R1',
// general
'<General>'
// 'Mixtral 8x7b'
// 'Qwen2.5',
] as const
type RecognizedModel = (typeof recognizedModels)[number]
// const modelCapabilities: { [recognizedModel in RecognizedModel]: ({ }) => string } = {
// 'OpenAI 4o': {
// template: ({ prefix, suffix, }: { prefix: string; suffix: string; }) => `\
// `
// }
// }
export function getRecognizedModel(modelName: string): RecognizedModel {
const lower = modelName.toLowerCase();
if (lower.includes('gpt-4o')) {
return 'OpenAI 4o';
}
if (lower.includes('claude')) {
return 'Anthropic Claude';
}
if (lower.includes('llama')) {
return 'Llama 3.x';
}
if (lower.includes('qwen2.5-coder')) {
return 'Alibaba Qwen2.5 Coder Instruct';
}
if (lower.includes('mistral')) {
return 'Mistral Codestral';
}
// Check for "o1" or "o3"
if (/\bo1\b/.test(lower) || /\bo3\b/.test(lower)) {
return 'OpenAI o1, o3';
}
if (lower.includes('deepseek-r1') || lower.includes('deepseek-reasoner')) {
return 'Deepseek R1';
}
// Fallback:
return '<General>';
}
File diff suppressed because it is too large Load Diff
@@ -1,76 +0,0 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import Anthropic from '@anthropic-ai/sdk';
import { _InternalSendLLMChatMessageFnType } from '../../common/llmMessageTypes.js';
import { anthropicMaxPossibleTokens } from '../../common/voidSettingsTypes.js';
import { InternalToolInfo } from '../../common/toolsService.js';
export const toAnthropicTool = (toolName: string, toolInfo: InternalToolInfo) => {
const { description, params, required } = toolInfo
return {
name: toolName,
description: description,
input_schema: {
type: 'object',
properties: params,
required: required,
}
} satisfies Anthropic.Messages.Tool
}
export const sendAnthropicChat: _InternalSendLLMChatMessageFnType = ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter }) => {
const thisConfig = settingsOfProvider.anthropic
const maxTokens = anthropicMaxPossibleTokens(modelName)
if (maxTokens === undefined) {
onError({ message: `Please set a value for Max Tokens.`, fullError: null })
return
}
const anthropic = new Anthropic({ apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true });
const stream = anthropic.messages.stream({
// system: systemMessage,
messages: messages,
model: modelName,
max_tokens: maxTokens,
});
// when receive text
stream.on('text', (newText, fullText) => {
onText({ newText, fullText })
})
// when we get the final message on this stream (or when error/fail)
stream.on('finalMessage', (claude_response) => {
// stringify the response's content
const content = claude_response.content.map(c => c.type === 'text' ? c.text : c.type).join('\n');
onFinalMessage({ fullText: content })
})
stream.on('error', (error) => {
// the most common error will be invalid API key (401), so we handle this with a nice message
if (error instanceof Anthropic.APIError && error.status === 401) {
onError({ message: 'Invalid API key.', fullError: error })
}
else {
onError({ message: error + '', fullError: error }) // anthropic errors can be stringified nicely like this
}
})
// TODO need to test this to make sure it works, it might throw an error
_setAborter(() => stream.controller.abort())
};
@@ -1,43 +0,0 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { Content, GoogleGenerativeAI } from '@google/generative-ai';
import { _InternalSendLLMChatMessageFnType } from '../../common/llmMessageTypes.js';
// Gemini
export const sendGeminiChat: _InternalSendLLMChatMessageFnType = async ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter }) => {
let fullText = ''
const thisConfig = settingsOfProvider.gemini
const genAI = new GoogleGenerativeAI(thisConfig.apiKey);
const model = genAI.getGenerativeModel({ model: modelName });
// Convert messages to Gemini format
const geminiMessages: Content[] = messages
.map((msg, i) => ({
parts: [{ text: msg.content }],
role: msg.role === 'assistant' ? 'model' : 'user'
}))
model.generateContentStream({
// systemInstruction: systemMessage,
contents: geminiMessages,
})
.then(async response => {
_setAborter(() => response.stream.return(fullText))
for await (const chunk of response.stream) {
const newText = chunk.text();
fullText += newText;
onText({ newText, fullText });
}
onFinalMessage({ fullText });
})
.catch((error) => {
onError({ message: error + '', fullError: error })
})
}
@@ -1,42 +0,0 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import Groq from 'groq-sdk';
import { _InternalSendLLMChatMessageFnType } from '../../common/llmMessageTypes.js';
// Groq
export const sendGroqChat: _InternalSendLLMChatMessageFnType = async ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter }) => {
let fullText = '';
const thisConfig = settingsOfProvider.groq
const groq = new Groq({
apiKey: thisConfig.apiKey,
dangerouslyAllowBrowser: true
});
await groq.chat.completions
.create({
messages: messages,
model: modelName,
stream: true,
})
.then(async response => {
_setAborter(() => response.controller.abort())
// when receive text
for await (const chunk of response) {
const newText = chunk.choices[0]?.delta?.content || '';
fullText += newText;
onText({ newText, fullText });
}
onFinalMessage({ fullText });
})
.catch(error => {
onError({ message: error + '', fullError: error });
})
};
@@ -1,44 +0,0 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { Mistral } from '@mistralai/mistralai';
import { _InternalSendLLMChatMessageFnType } from '../../common/llmMessageTypes.js';
// Mistral
export const sendMistralChat: _InternalSendLLMChatMessageFnType = async ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter }) => {
let fullText = '';
const thisConfig = settingsOfProvider.mistral;
const mistral = new Mistral({
apiKey: thisConfig.apiKey,
})
await mistral.chat
.stream({
messages: messages,
model: modelName,
stream: true,
})
.then(async response => {
// Mistral has a really nonstandard API - no interrupt and weird stream types
_setAborter(() => { console.log('Mistral does not support interrupts! Further messages will just be ignored.') });
// when receive text
for await (const chunk of response) {
const c = chunk.data.choices[0].delta.content || ''
const newText = (
typeof c === 'string' ? c
: c?.map(c => c.type === 'text' ? c.text : c.type).join('\n')
)
fullText += newText;
onText({ newText, fullText });
}
onFinalMessage({ fullText });
})
.catch(error => {
onError({ message: error + '', fullError: error });
})
}
@@ -1,117 +0,0 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { Ollama } from 'ollama';
import { _InternalModelListFnType, _InternalSendLLMFIMMessageFnType, _InternalSendLLMChatMessageFnType, OllamaModelResponse } from '../../common/llmMessageTypes.js';
import { defaultProviderSettings } from '../../common/voidSettingsTypes.js';
export const ollamaList: _InternalModelListFnType<OllamaModelResponse> = async ({ onSuccess: onSuccess_, onError: onError_, settingsOfProvider }) => {
const onSuccess = ({ models }: { models: OllamaModelResponse[] }) => {
onSuccess_({ models })
}
const onError = ({ error }: { error: string }) => {
onError_({ error })
}
try {
const thisConfig = settingsOfProvider.ollama
// if endpoint is empty, normally ollama will send to 11434, but we want it to fail - the user should type it in
if (!thisConfig.endpoint) throw new Error(`Ollama Endpoint was empty (please enter ${defaultProviderSettings.ollama.endpoint} in Void if you want the default url).`)
const ollama = new Ollama({ host: thisConfig.endpoint })
ollama.list()
.then((response) => {
const { models } = response
onSuccess({ models })
})
.catch((error) => {
onError({ error: error + '' })
})
}
catch (error) {
onError({ error: error + '' })
}
}
export const sendOllamaFIM: _InternalSendLLMFIMMessageFnType = ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter }) => {
const thisConfig = settingsOfProvider.ollama
// if endpoint is empty, normally ollama will send to 11434, but we want it to fail - the user should type it in
if (!thisConfig.endpoint) throw new Error(`Ollama Endpoint was empty (please enter ${defaultProviderSettings.ollama.endpoint} if you want the default).`)
let fullText = ''
const ollama = new Ollama({ host: thisConfig.endpoint })
ollama.generate({
model: modelName,
prompt: messages.prefix,
suffix: messages.suffix,
options: {
stop: messages.stopTokens,
num_predict: 300, // max tokens
// repeat_penalty: 1,
},
raw: true,
stream: true,
})
.then(async stream => {
_setAborter(() => stream.abort())
// iterate through the stream
for await (const chunk of stream) {
const newText = chunk.response;
fullText += newText;
onText({ newText, fullText });
}
onFinalMessage({ fullText });
})
// when error/fail
.catch((error) => {
onError({ message: error + '', fullError: error })
})
};
// Ollama
export const sendOllamaChat: _InternalSendLLMChatMessageFnType = ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter }) => {
const thisConfig = settingsOfProvider.ollama
// if endpoint is empty, normally ollama will send to 11434, but we want it to fail - the user should type it in
if (!thisConfig.endpoint) throw new Error(`Ollama Endpoint was empty (please enter ${defaultProviderSettings.ollama.endpoint} if you want the default).`)
let fullText = ''
const ollama = new Ollama({ host: thisConfig.endpoint })
ollama.chat({
model: modelName,
messages: messages,
stream: true,
// options: { num_predict: parseMaxTokensStr(thisConfig.maxTokens) } // this is max_tokens
})
.then(async stream => {
_setAborter(() => stream.abort())
// iterate through the stream
for await (const chunk of stream) {
const newText = chunk.message.content;
fullText += newText;
onText({ newText, fullText });
}
onFinalMessage({ fullText });
})
// when error/fail
.catch((error) => {
onError({ message: error + '', fullError: error })
})
};
// ['codestral', 'qwen2.5-coder', 'qwen2.5-coder:0.5b', 'qwen2.5-coder:1.5b', 'qwen2.5-coder:3b', 'qwen2.5-coder:7b', 'qwen2.5-coder:14b', 'qwen2.5-coder:32b', 'codegemma', 'codegemma:2b', 'codegemma:7b', 'codellama', 'codellama:7b', 'codellama:13b', 'codellama:34b', 'codellama:70b', 'codellama:code', 'codellama:python', 'command-r', 'command-r:35b', 'command-r-plus', 'command-r-plus:104b', 'deepseek-coder-v2', 'deepseek-coder-v2:16b', 'deepseek-coder-v2:236b', 'falcon2', 'falcon2:11b', 'firefunction-v2', 'firefunction-v2:70b', 'gemma', 'gemma:2b', 'gemma:7b', 'gemma2', 'gemma2:2b', 'gemma2:9b', 'gemma2:27b', 'llama2', 'llama2:7b', 'llama2:13b', 'llama2:70b', 'llama3', 'llama3:8b', 'llama3:70b', 'llama3-chatqa', 'llama3-chatqa:8b', 'llama3-chatqa:70b', 'llama3-gradient', 'llama3-gradient:8b', 'llama3-gradient:70b', 'llama3.1', 'llama3.1:8b', 'llama3.1:70b', 'llama3.1:405b', 'llava', 'llava:7b', 'llava:13b', 'llava:34b', 'llava-llama3', 'llava-llama3:8b', 'llava-phi3', 'llava-phi3:3.8b', 'mistral', 'mistral:7b', 'mistral-large', 'mistral-large:123b', 'mistral-nemo', 'mistral-nemo:12b', 'mixtral', 'mixtral:8x7b', 'mixtral:8x22b', 'moondream', 'moondream:1.8b', 'openhermes', 'openhermes:v2.5', 'phi3', 'phi3:3.8b', 'phi3:14b', 'phi3.5', 'phi3.5:3.8b', 'qwen', 'qwen:7b', 'qwen:14b', 'qwen:32b', 'qwen:72b', 'qwen:110b', 'qwen2', 'qwen2:0.5b', 'qwen2:1.5b', 'qwen2:7b', 'qwen2:72b', 'smollm', 'smollm:135m', 'smollm:360m', 'smollm:1.7b',]
@@ -1,159 +0,0 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import OpenAI from 'openai';
import { _InternalModelListFnType, _InternalSendLLMFIMMessageFnType, _InternalSendLLMChatMessageFnType } from '../../common/llmMessageTypes.js';
import { Model } from 'openai/resources/models.js';
import { InternalToolInfo } from '../../common/toolsService.js';
// import { parseMaxTokensStr } from './util.js';
// developer command - https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command
// prompting - https://platform.openai.com/docs/guides/reasoning#advice-on-prompting
export const toOpenAITool = (toolName: string, toolInfo: InternalToolInfo) => {
const { description, params, required } = toolInfo
return {
type: 'function',
function: {
name: toolName,
description: description,
parameters: {
type: 'object',
properties: params,
required: required,
}
}
} satisfies OpenAI.Chat.Completions.ChatCompletionTool
}
type NewParams = Pick<Parameters<_InternalSendLLMChatMessageFnType>[0] & Parameters<_InternalSendLLMFIMMessageFnType>[0], 'settingsOfProvider' | 'providerName'>
const newOpenAI = ({ settingsOfProvider, providerName }: NewParams) => {
if (providerName === 'openAI') {
const thisConfig = settingsOfProvider.openAI
return new OpenAI({ apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true });
}
else if (providerName === 'openRouter') {
const thisConfig = settingsOfProvider.openRouter
return new OpenAI({
baseURL: 'https://openrouter.ai/api/v1', apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true,
defaultHeaders: {
'HTTP-Referer': 'https://voideditor.com', // Optional, for including your app on openrouter.ai rankings.
'X-Title': 'Void Editor', // Optional. Shows in rankings on openrouter.ai.
},
})
}
else if (providerName === 'deepseek') {
const thisConfig = settingsOfProvider.deepseek
return new OpenAI({
baseURL: 'https://api.deepseek.com/v1', apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true,
})
}
else if (providerName === 'openAICompatible') {
const thisConfig = settingsOfProvider.openAICompatible
return new OpenAI({
baseURL: thisConfig.endpoint, apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true
})
}
else {
console.error(`sendOpenAIMsg: invalid providerName: ${providerName}`)
throw new Error(`providerName was invalid: ${providerName}`)
}
}
// might not currently be used in the code
export const openaiCompatibleList: _InternalModelListFnType<Model> = async ({ onSuccess: onSuccess_, onError: onError_, settingsOfProvider }) => {
const onSuccess = ({ models }: { models: Model[] }) => {
onSuccess_({ models })
}
const onError = ({ error }: { error: string }) => {
onError_({ error })
}
try {
const openai = newOpenAI({ providerName: 'openAICompatible', settingsOfProvider })
openai.models.list()
.then(async (response) => {
const models: Model[] = []
models.push(...response.data)
while (response.hasNextPage()) {
models.push(...(await response.getNextPage()).data)
}
onSuccess({ models })
})
.catch((error) => {
onError({ error: error + '' })
})
}
catch (error) {
onError({ error: error + '' })
}
}
export const sendOpenAIFIM: _InternalSendLLMFIMMessageFnType = ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }) => {
// openai.completions has a FIM parameter called `suffix`, but it's deprecated and only works for ~GPT 3 era models
onFinalMessage({ fullText: 'TODO' })
}
// OpenAI, OpenRouter, OpenAICompatible
export const sendOpenAIChat: _InternalSendLLMChatMessageFnType = ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }) => {
let fullText = ''
const openai: OpenAI = newOpenAI({ providerName, settingsOfProvider })
const options: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model: modelName,
messages: messages,
stream: true,
// tools: Object.keys(contextTools).map(name => toOpenAITool(name, contextTools[name as ContextToolName])),
}
openai.chat.completions
.create(options)
.then(async response => {
_setAborter(() => response.controller.abort())
// when receive text
for await (const chunk of response) {
let newText = ''
newText += chunk.choices[0]?.delta?.tool_calls?.[0]?.function?.name ?? ''
newText += chunk.choices[0]?.delta?.tool_calls?.[0]?.function?.arguments ?? ''
newText += chunk.choices[0]?.delta?.content ?? ''
fullText += newText;
onText({ newText, fullText });
}
onFinalMessage({ fullText });
})
// when error/fail - this catches errors of both .create() and .then(for await)
.catch(error => {
if (error instanceof OpenAI.APIError && error.status === 401) {
onError({ message: 'Invalid API key.', fullError: error });
}
else {
onError({ message: error + '', fullError: error });
}
})
};
@@ -0,0 +1,363 @@
import { LLMChatMessage, LLMFIMMessage } from '../../common/llmMessageTypes.js';
import { deepClone } from '../../../../../base/common/objects.js';
export const parseObject = (args: unknown) => {
if (typeof args === 'object')
return args
if (typeof args === 'string')
try { return JSON.parse(args) }
catch (e) { return { args } }
return {}
}
const prepareMessages_normalize = ({ messages: messages_ }: { messages: LLMChatMessage[] }) => {
const messages = deepClone(messages_)
const newMessages: LLMChatMessage[] = []
for (let i = 1; i < messages.length; i += 1) {
const curr = messages[i]
const prev = messages[i - 1]
// if found a repeated role, put the current content in the prev
if ((curr.role === 'user' && prev.role === 'user') || (curr.role === 'assistant' && prev.role === 'assistant')) {
prev.content += '\n' + curr.content
continue
}
// add the message
newMessages.push(curr)
}
const finalMessages = newMessages.map(m => ({ ...m, content: m.content.trim() }))
return { messages: finalMessages }
}
// no matter whether the model supports a system message or not (or what format it supports), add it in some way
const prepareMessages_systemMessage = ({
messages,
aiInstructions,
supportsSystemMessage,
}: {
messages: LLMChatMessage[],
aiInstructions: string,
supportsSystemMessage: false | 'system-role' | 'developer-role' | 'separated',
})
: { separateSystemMessageStr?: string, messages: any[] } => {
// find system messages and concatenate them
let systemMessageStr = messages
.filter(msg => msg.role === 'system')
.map(msg => msg.content)
.join('\n') || undefined;
if (aiInstructions)
systemMessageStr = `${(systemMessageStr ? `${systemMessageStr}\n\n` : '')}GUIDELINES\n${aiInstructions}`
let separateSystemMessageStr: string | undefined = undefined
// remove all system messages
const newMessages: (LLMChatMessage | { role: 'developer', content: string })[] = messages.filter(msg => msg.role !== 'system')
// if (!supportsTools) {
// if (!systemMessageStr) systemMessageStr = ''
// systemMessageStr += '' // TODO!!! add tool use system message here
// }
if (systemMessageStr) {
// if supports system message
if (supportsSystemMessage) {
if (supportsSystemMessage === 'separated')
separateSystemMessageStr = systemMessageStr
else if (supportsSystemMessage === 'system-role')
newMessages.unshift({ role: 'system', content: systemMessageStr }) // add new first message
else if (supportsSystemMessage === 'developer-role')
newMessages.unshift({ role: 'developer', content: systemMessageStr }) // add new first message
}
// if does not support system message
else {
if (supportsSystemMessage) {
if (newMessages.length === 0)
newMessages.push({ role: 'user', content: systemMessageStr })
// add system mesasges to first message (should be a user message)
else {
const newFirstMessage = {
role: 'user',
content: (''
+ '<SYSTEM_MESSAGE>\n'
+ systemMessageStr
+ '\n'
+ '</SYSTEM_MESSAGE>\n'
+ newMessages[0].content
)
} as const
newMessages.splice(0, 1) // delete first message
newMessages.unshift(newFirstMessage) // add new first message
}
}
}
}
return { messages: newMessages, separateSystemMessageStr }
}
// convert messages as if about to send to openai
/*
reference - https://platform.openai.com/docs/guides/function-calling#function-calling-steps
openai MESSAGE (role=assistant):
"tool_calls":[{
"type": "function",
"id": "call_12345xyz",
"function": {
"name": "get_weather",
"arguments": "{\"latitude\":48.8566,\"longitude\":2.3522}"
}]
openai RESPONSE (role=user):
{ "role": "tool",
"tool_call_id": tool_call.id,
"content": str(result) }
also see
openai on prompting - https://platform.openai.com/docs/guides/reasoning#advice-on-prompting
openai on developer system message - https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command
*/
const prepareMessages_tools_openai = ({ messages }: { messages: LLMChatMessage[], }) => {
const newMessages: (
Exclude<LLMChatMessage, { role: 'assistant' | 'tool' }> | {
role: 'assistant',
content: string;
tool_calls?: {
type: 'function';
id: string;
function: {
name: string;
arguments: string;
}
}[]
} | {
role: 'tool',
id: string; // old val
tool_call_id: string; // new val
content: string;
}
)[] = [];
for (let i = 0; i < messages.length; i += 1) {
const currMsg = messages[i]
if (currMsg.role !== 'tool') {
newMessages.push(currMsg)
continue
}
// edit previous assistant message to have called the tool
const prevMsg = 0 <= i - 1 && i - 1 <= newMessages.length ? newMessages[i - 1] : undefined
if (prevMsg?.role === 'assistant') {
prevMsg.tool_calls = [{
type: 'function',
id: currMsg.id,
function: {
name: currMsg.name,
arguments: JSON.stringify(currMsg.params)
}
}]
}
// add the tool
newMessages.push({
role: 'tool',
id: currMsg.id,
content: currMsg.content,
tool_call_id: currMsg.id,
})
}
return { messages: newMessages }
}
// convert messages as if about to send to anthropic
/*
https://docs.anthropic.com/en/docs/build-with-claude/tool-use#tool-use-examples
anthropic MESSAGE (role=assistant):
"content": [{
"type": "text",
"text": "<thinking>I need to call the get_weather function, and the user wants SF, which is likely San Francisco, CA.</thinking>"
}, {
"type": "tool_use",
"id": "toolu_01A09q90qw90lq917835lq9",
"name": "get_weather",
"input": { "location": "San Francisco, CA", "unit": "celsius" }
}]
anthropic RESPONSE (role=user):
"content": [{
"type": "tool_result",
"tool_use_id": "toolu_01A09q90qw90lq917835lq9",
"content": "15 degrees"
}]
*/
const prepareMessages_tools_anthropic = ({ messages }: { messages: LLMChatMessage[], }) => {
const newMessages: (
Exclude<LLMChatMessage, { role: 'assistant' | 'user' }> | {
role: 'assistant',
content: string | ({
type: 'text';
text: string;
} | {
type: 'tool_use';
name: string;
input: Record<string, any>;
id: string;
})[]
} | {
role: 'user',
content: string | ({
type: 'text';
text: string;
} | {
type: 'tool_result';
tool_use_id: string;
content: string;
})[]
}
)[] = messages;
for (let i = 0; i < newMessages.length; i += 1) {
const currMsg = newMessages[i]
if (currMsg.role !== 'tool') continue
const prevMsg = 0 <= i - 1 && i - 1 <= newMessages.length ? newMessages[i - 1] : undefined
if (prevMsg?.role === 'assistant') {
if (typeof prevMsg.content === 'string') prevMsg.content = [{ type: 'text', text: prevMsg.content }]
prevMsg.content.push({ type: 'tool_use', id: currMsg.id, name: currMsg.name, input: parseObject(currMsg.params) })
}
// turn each tool into a user message with tool results at the end
newMessages[i] = {
role: 'user',
content: [
...[{ type: 'tool_result', tool_use_id: currMsg.id, content: currMsg.content }] as const,
...currMsg.content ? [{ type: 'text', text: currMsg.content }] as const : [],
]
}
}
return { messages: newMessages }
}
const prepareMessages_tools = ({ messages, supportsTools }: { messages: LLMChatMessage[], supportsTools: false | 'anthropic-style' | 'openai-style' }) => {
if (!supportsTools) {
return { messages: messages }
}
else if (supportsTools === 'anthropic-style') {
return prepareMessages_tools_anthropic({ messages })
}
else if (supportsTools === 'openai-style') {
return prepareMessages_tools_openai({ messages })
}
else {
throw 1
}
}
/*
Gemini has this, but they're openai-compat so we don't need to implement this
gemini request:
{ "role": "assistant",
"content": null,
"function_call": {
"name": "get_weather",
"arguments": {
"latitude": 48.8566,
"longitude": 2.3522
}
}
}
gemini response:
{ "role": "assistant",
"function_response": {
"name": "get_weather",
"response": {
"temperature": "15°C",
"condition": "Cloudy"
}
}
}
*/
export const prepareMessages = ({
messages,
aiInstructions,
supportsSystemMessage,
supportsTools,
}: {
messages: LLMChatMessage[],
aiInstructions: string,
supportsSystemMessage: false | 'system-role' | 'developer-role' | 'separated',
supportsTools: false | 'anthropic-style' | 'openai-style',
}) => {
const { messages: messages1 } = prepareMessages_normalize({ messages })
const { messages: messages2, separateSystemMessageStr } = prepareMessages_systemMessage({ messages: messages1, aiInstructions, supportsSystemMessage })
const { messages: messages3 } = prepareMessages_tools({ messages: messages2, supportsTools })
return {
messages: messages3 as any,
separateSystemMessageStr
} as const
}
export const prepareFIMMessage = ({
messages,
aiInstructions,
}: {
messages: LLMFIMMessage,
aiInstructions: string,
}) => {
let prefix = `\
${!aiInstructions ? '' : `\
// Instructions:
// Do not output an explanation. Try to avoid outputting comments. Only output the middle code.
${aiInstructions.split('\n').map(line => `//${line}`).join('\n')}`}
${messages.prefix}`
const suffix = messages.suffix
const stopTokens = messages.stopTokens
const ret = { prefix, suffix, stopTokens, maxTokens: 300 } as const
console.log('ret', ret)
return ret
}
@@ -3,50 +3,10 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { SendLLMMessageParams, OnText, OnFinalMessage, OnError, LLMChatMessage, _InternalLLMChatMessage } from '../../common/llmMessageTypes.js';
import { SendLLMMessageParams, OnText, OnFinalMessage, OnError } from '../../common/llmMessageTypes.js';
import { IMetricsService } from '../../common/metricsService.js';
import { sendAnthropicChat } from './anthropic.js';
import { sendOllamaFIM, sendOllamaChat } from './ollama.js';
import { sendOpenAIChat, sendOpenAIFIM } from './openai.js';
import { sendGeminiChat } from './gemini.js';
import { sendGroqChat } from './groq.js';
import { sendMistralChat } from './mistral.js';
import { displayInfoOfProviderName } from '../../common/voidSettingsTypes.js';
const cleanChatMessages = (messages: LLMChatMessage[]): _InternalLLMChatMessage[] => {
// trim message content (Anthropic and other providers give an error if there is trailing whitespace)
messages = messages.map(m => ({ ...m, content: m.content.trim() }))
// find system messages and concatenate them
const systemMessage = messages
.filter(msg => msg.role === 'system')
.map(msg => msg.content)
.join('\n') || undefined;
// remove all system messages
const noSystemMessages = messages
.filter(msg => msg.role !== 'system') as _InternalLLMChatMessage[]
// add system mesasges to first message (should be a user message)
if (systemMessage && (noSystemMessages.length !== 0)) {
const newFirstMessage = {
role: noSystemMessages[0].role,
content: (''
+ '<SYSTEM_MESSAGE>\n'
+ systemMessage
+ '\n'
+ '</SYSTEM_MESSAGE>\n'
+ noSystemMessages[0].content
)
}
noSystemMessages.splice(0, 1) // delete first message
noSystemMessages.unshift(newFirstMessage) // add new first message
}
return noSystemMessages
}
import { sendLLMMessageToProviderImplementation } from './MODELS.js';
export const sendLLMMessage = ({
@@ -61,27 +21,23 @@ export const sendLLMMessage = ({
settingsOfProvider,
providerName,
modelName,
tools,
}: SendLLMMessageParams,
metricsService: IMetricsService
) => {
let messagesArr: _InternalLLMChatMessage[] = []
if (messagesType === 'chatMessages') {
messagesArr = cleanChatMessages([
{ role: 'system', content: aiInstructions },
...messages_
])
}
// only captures number of messages and message "shape", no actual code, instructions, prompts, etc
const captureLLMEvent = (eventId: string, extras?: object) => {
metricsService.capture(eventId, {
providerName,
modelName,
customEndpointURL: settingsOfProvider[providerName]?.endpoint,
numModelsAtEndpoint: settingsOfProvider[providerName]?.models?.length,
...messagesType === 'chatMessages' ? {
numMessages: messagesArr?.length,
messagesShape: messagesArr?.map(msg => ({ role: msg.role, length: msg.content.length })),
numMessages: messages_?.length,
messagesShape: messages_?.map(msg => ({ role: msg.role, length: msg.content.length })),
origNumMessages: messages_?.length,
origMessagesShape: messages_?.map(msg => ({ role: msg.role, length: msg.content.length })),
@@ -100,28 +56,30 @@ export const sendLLMMessage = ({
let _setAborter = (fn: () => void) => { _aborter = fn }
let _didAbort = false
const onText: OnText = ({ newText, fullText }) => {
const onText: OnText = (params) => {
const { fullText } = params
if (_didAbort) return
onText_({ newText, fullText })
onText_(params)
_fullTextSoFar = fullText
}
const onFinalMessage: OnFinalMessage = ({ fullText }) => {
const onFinalMessage: OnFinalMessage = (params) => {
const { fullText, fullReasoning } = params
if (_didAbort) return
captureLLMEvent(`${loggingName} - Received Full Message`, { messageLength: fullText.length, duration: new Date().getMilliseconds() - submit_time.getMilliseconds() })
onFinalMessage_({ fullText })
captureLLMEvent(`${loggingName} - Received Full Message`, { messageLength: fullText.length, reasoningLength: fullReasoning?.length, duration: new Date().getMilliseconds() - submit_time.getMilliseconds() })
onFinalMessage_(params)
}
const onError: OnError = ({ message: error, fullError }) => {
const onError: OnError = ({ message: errorMessage, fullError }) => {
if (_didAbort) return
console.error('sendLLMMessage onError:', error)
console.error('sendLLMMessage onError:', errorMessage)
// handle failed to fetch errors, which give 0 information by design
if (error === 'TypeError: fetch failed')
error = `Failed to fetch from ${displayInfoOfProviderName(providerName).title}. This likely means you specified the wrong endpoint in Void Settings, or your local model provider like Ollama is powered off.`
if (errorMessage === 'TypeError: fetch failed')
errorMessage = `Failed to fetch from ${displayInfoOfProviderName(providerName).title}. This likely means you specified the wrong endpoint in Void's Settings, or your local model provider like Ollama is powered off.`
captureLLMEvent(`${loggingName} - Error`, { error })
onError_({ message: error, fullError })
captureLLMEvent(`${loggingName} - Error`, { error: errorMessage })
onError_({ message: errorMessage, fullError })
}
const onAbort = () => {
@@ -132,37 +90,32 @@ export const sendLLMMessage = ({
}
abortRef_.current = onAbort
captureLLMEvent(`${loggingName} - Sending Message`, { messageLength: messagesArr[messagesArr.length - 1]?.content.length })
if (messagesType === 'chatMessages')
captureLLMEvent(`${loggingName} - Sending Message`, { messageLength: messages_[messages_.length - 1]?.content.length })
else if (messagesType === 'FIMMessage')
captureLLMEvent(`${loggingName} - Sending FIM`, {}) // TODO!!! add more metrics
try {
switch (providerName) {
case 'anthropic':
sendAnthropicChat({ messages: messagesArr, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName });
break;
case 'openAI':
case 'openRouter':
case 'deepseek':
case 'openAICompatible':
if (messagesType === 'FIMMessage') sendOpenAIFIM({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName });
else /* */ sendOpenAIChat({ messages: messagesArr, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName });
break;
case 'ollama':
if (messagesType === 'FIMMessage') sendOllamaFIM({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName })
else /* */ sendOllamaChat({ messages: messagesArr, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName })
break;
case 'gemini':
sendGeminiChat({ messages: messagesArr, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName });
break;
case 'groq':
sendGroqChat({ messages: messagesArr, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName });
break;
case 'mistral':
sendMistralChat({ messages: messagesArr, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName });
break;
default:
onError({ message: `Error: Void provider was "${providerName}", which is not recognized.`, fullError: null })
break;
const implementation = sendLLMMessageToProviderImplementation[providerName]
if (!implementation) {
onError({ message: `Error: Provider "${providerName}" not recognized.`, fullError: null })
return
}
const { sendFIM, sendChat } = implementation
if (messagesType === 'chatMessages') {
sendChat({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName, aiInstructions, tools })
return
}
if (messagesType === 'FIMMessage') {
if (sendFIM) {
sendFIM({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName, aiInstructions })
return
}
onError({ message: `Error: This provider does not support Autocomplete yet.`, fullError: null })
return
}
onError({ message: `Error: Message type "${messagesType}" not recognized.`, fullError: null })
}
catch (error) {
@@ -8,30 +8,42 @@
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, MainModelListParams, ModelListParams, EventModelListOnSuccessParams, EventModelListOnErrorParams, OllamaModelResponse, OpenaiCompatibleModelResponse, } from '../common/llmMessageTypes.js';
import { EventLLMMessageOnTextParams, EventLLMMessageOnErrorParams, EventLLMMessageOnFinalMessageParams, MainSendLLMMessageParams, AbortRef, SendLLMMessageParams, MainLLMMessageAbortParams, ModelListParams, EventModelListOnSuccessParams, EventModelListOnErrorParams, OllamaModelResponse, VLLMModelResponse, MainModelListParams, } from '../common/llmMessageTypes.js';
import { sendLLMMessage } from './llmMessage/sendLLMMessage.js'
import { IMetricsService } from '../common/metricsService.js';
import { ollamaList } from './llmMessage/ollama.js';
import { openaiCompatibleList } from './llmMessage/openai.js';
import { sendLLMMessageToProviderImplementation } from './llmMessage/MODELS.js';
// NODE IMPLEMENTATION - calls actual sendLLMMessage() and returns listeners to it
export class LLMMessageChannel implements IServerChannel {
// sendLLMMessage
private readonly _onText_llm = new Emitter<EventLLMMessageOnTextParams>();
private readonly _onFinalMessage_llm = new Emitter<EventLLMMessageOnFinalMessageParams>();
private readonly _onError_llm = new Emitter<EventLLMMessageOnErrorParams>();
private readonly llmMessageEmitters = {
onText: new Emitter<EventLLMMessageOnTextParams>(),
onFinalMessage: new Emitter<EventLLMMessageOnFinalMessageParams>(),
onError: new Emitter<EventLLMMessageOnErrorParams>(),
}
// abort
private readonly _abortRefOfRequestId_llm: Record<string, AbortRef> = {}
// aborters for above
private readonly abortRefOfRequestId: Record<string, AbortRef> = {}
// ollamaList
private readonly _onSuccess_ollama = new Emitter<EventModelListOnSuccessParams<OllamaModelResponse>>();
private readonly _onError_ollama = new Emitter<EventModelListOnErrorParams<OllamaModelResponse>>();
// openaiCompatibleList
private readonly _onSuccess_openAICompatible = new Emitter<EventModelListOnSuccessParams<OpenaiCompatibleModelResponse>>();
private readonly _onError_openAICompatible = new Emitter<EventModelListOnErrorParams<OpenaiCompatibleModelResponse>>();
// list
private readonly listEmitters = {
ollama: {
success: new Emitter<EventModelListOnSuccessParams<OllamaModelResponse>>(),
error: new Emitter<EventModelListOnErrorParams<OllamaModelResponse>>(),
},
vLLM: {
success: new Emitter<EventModelListOnSuccessParams<VLLMModelResponse>>(),
error: new Emitter<EventModelListOnErrorParams<VLLMModelResponse>>(),
}
} satisfies {
[providerName: string]: {
success: Emitter<EventModelListOnSuccessParams<any>>,
error: Emitter<EventModelListOnErrorParams<any>>,
}
}
// stupidly, channels can't take in @IService
constructor(
@@ -40,30 +52,17 @@ export class LLMMessageChannel implements IServerChannel {
// browser uses this to listen for changes
listen(_: unknown, event: string): Event<any> {
if (event === 'onText_llm') {
return this._onText_llm.event;
}
else if (event === 'onFinalMessage_llm') {
return this._onFinalMessage_llm.event;
}
else if (event === 'onError_llm') {
return this._onError_llm.event;
}
else if (event === 'onSuccess_ollama') {
return this._onSuccess_ollama.event;
}
else if (event === 'onError_ollama') {
return this._onError_ollama.event;
}
else if (event === 'onSuccess_openAICompatible') {
return this._onSuccess_openAICompatible.event;
}
else if (event === 'onError_openAICompatible') {
return this._onError_openAICompatible.event;
}
else {
throw new Error(`Event not found: ${event}`);
}
// text
if (event === 'onText_sendLLMMessage') return this.llmMessageEmitters.onText.event;
else if (event === 'onFinalMessage_sendLLMMessage') return this.llmMessageEmitters.onFinalMessage.event;
else if (event === 'onError_sendLLMMessage') return this.llmMessageEmitters.onError.event;
// 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 throw new Error(`Event not found: ${event}`);
}
// browser uses this to call (see this.channel.call() in llmMessageService.ts for all usages)
@@ -78,8 +77,8 @@ export class LLMMessageChannel implements IServerChannel {
else if (command === 'ollamaList') {
this._callOllamaList(params)
}
else if (command === 'openAICompatibleList') {
this._callOpenAICompatibleList(params)
else if (command === 'vLLMList') {
this._callVLLMList(params)
}
else {
throw new Error(`Void sendLLM: command "${command}" not recognized.`)
@@ -94,47 +93,50 @@ export class LLMMessageChannel implements IServerChannel {
private async _callSendLLMMessage(params: MainSendLLMMessageParams) {
const { requestId } = params;
if (!(requestId in this._abortRefOfRequestId_llm))
this._abortRefOfRequestId_llm[requestId] = { current: null }
if (!(requestId in this.abortRefOfRequestId))
this.abortRefOfRequestId[requestId] = { current: null }
const mainThreadParams: SendLLMMessageParams = {
...params,
onText: ({ newText, fullText }) => { this._onText_llm.fire({ requestId, newText, fullText }); },
onFinalMessage: ({ fullText }) => { this._onFinalMessage_llm.fire({ requestId, fullText }); },
onError: ({ message: error, fullError }) => { console.log('sendLLM: firing err'); this._onError_llm.fire({ requestId, message: error, fullError }); },
abortRef: this._abortRefOfRequestId_llm[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.abortRefOfRequestId[requestId],
}
sendLLMMessage(mainThreadParams, this.metricsService);
}
private _callAbort(params: MainLLMMessageAbortParams) {
const { requestId } = params;
if (!(requestId in this._abortRefOfRequestId_llm)) return
this._abortRefOfRequestId_llm[requestId].current?.()
delete this._abortRefOfRequestId_llm[requestId]
}
private _callOllamaList(params: MainModelListParams<OllamaModelResponse>) {
const { requestId } = params;
_callOllamaList = (params: MainModelListParams<OllamaModelResponse>) => {
const { requestId } = params
const emitters = this.listEmitters.ollama
const mainThreadParams: ModelListParams<OllamaModelResponse> = {
...params,
onSuccess: ({ models }) => { this._onSuccess_ollama.fire({ requestId, models }); },
onError: ({ error }) => { this._onError_ollama.fire({ requestId, error }); },
onSuccess: (p) => { emitters.success.fire({ requestId, ...p }); },
onError: (p) => { emitters.error.fire({ requestId, ...p }); },
}
ollamaList(mainThreadParams)
sendLLMMessageToProviderImplementation.ollama.list(mainThreadParams)
}
private _callOpenAICompatibleList(params: MainModelListParams<OpenaiCompatibleModelResponse>) {
const { requestId } = params;
const mainThreadParams: ModelListParams<OpenaiCompatibleModelResponse> = {
_callVLLMList = (params: MainModelListParams<VLLMModelResponse>) => {
const { requestId } = params
const emitters = this.listEmitters.vLLM
const mainThreadParams: ModelListParams<VLLMModelResponse> = {
...params,
onSuccess: ({ models }) => { this._onSuccess_openAICompatible.fire({ requestId, models }); },
onError: ({ error }) => { this._onError_openAICompatible.fire({ requestId, error }); },
onSuccess: (p) => { emitters.success.fire({ requestId, ...p }); },
onError: (p) => { emitters.error.fire({ requestId, ...p }); },
}
openaiCompatibleList(mainThreadParams)
sendLLMMessageToProviderImplementation.vLLM.list(mainThreadParams)
}
private _callAbort(params: MainLLMMessageAbortParams) {
const { requestId } = params;
if (!(requestId in this.abortRefOfRequestId)) return
this.abortRefOfRequestId[requestId].current?.()
delete this.abortRefOfRequestId[requestId]
}
}
@@ -1,13 +0,0 @@
/*
modelName -> {
system_message_type: 'system' | 'developer' (openai) | null // if null, we will just do a string of system message
supports_tools: boolean // we will just do a string of tool use if it doesn't support
supports_autocomplete_FIM (suffix) // we will just do a description of FIM if it doens't support <|fim_hole|>
supports_streaming: boolean // (o1 does NOT) we will just dump the final result if doesn't support it
max_tokens: number // required, DEFAULT is Infinity
}
*/