├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── media ├── icons │ ├── dark │ │ ├── clear-all.svg │ │ ├── openpilot.svg │ │ ├── refresh.svg │ │ ├── replace-all.svg │ │ └── request-changes.svg │ ├── icon.png │ └── light │ │ ├── clear-all.svg │ │ ├── openpilot.svg │ │ ├── refresh.svg │ │ ├── replace-all.svg │ │ └── request-changes.svg ├── reset.css └── vscode.css ├── package-lock.json ├── package.json ├── src ├── MessageService.ts ├── OpenpilotView.ts ├── extension.ts ├── helpers │ └── Extension.ts ├── search │ ├── VectorStore.ts │ └── indexWorkspace.ts ├── shared │ ├── ApiKeys.ts │ ├── ChatMessage.ts │ ├── Configuration.ts │ ├── File.ts │ ├── Model.ts │ └── OpenpilotMessage.ts ├── test │ ├── runTest.ts │ └── suite │ │ ├── extension.test.ts │ │ └── index.ts └── utils │ ├── editor.ts │ ├── file.ts │ ├── getNonce.ts │ ├── getUri.ts │ └── workspace.ts ├── tsconfig.json ├── using_openpilot.gif └── webview-ui ├── __mocks__ ├── vitest-env.d.ts └── zustand.ts ├── __tests__ ├── components │ ├── App.test.tsx │ ├── ChatList.test.tsx │ ├── FilePicker.test.tsx │ ├── PromptInput.test.tsx │ └── __snapshots__ │ │ ├── ChatList.test.tsx.snap │ │ ├── FileChangeList.test.tsx.snap │ │ ├── FilePicker.test.tsx.snap │ │ └── PromptInput.test.tsx.snap ├── setup.ts └── utils │ └── parseDiff.test.ts ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── src ├── components │ ├── App.css │ ├── App.tsx │ ├── ChatList.tsx │ ├── FilePicker.tsx │ ├── PromptInput.tsx │ └── SyntaxHighlighterWrapper.tsx ├── hooks │ └── useMessageListener.ts ├── index.tsx ├── prompts.ts ├── proxies │ ├── GoogleProxy.ts │ └── OpenaiProxy.ts ├── services │ ├── GoogleService.ts │ ├── LlmFactory.ts │ ├── LlmService.ts │ ├── OpenaiService.ts │ └── OpenpilotService.ts ├── stores │ ├── useAppStateStore.ts │ ├── useChatStore.ts │ ├── useErrorStore.ts │ ├── useFileContextStore.ts │ └── useSettingsStore.ts ├── types │ └── AppState.ts ├── utils │ ├── decodeAiStreamChunk.ts │ ├── parseDiff.ts │ ├── truncate.ts │ └── vscode.ts └── vite-env.d.ts ├── tailwind-vscode.js ├── tailwind.config.js ├── tsconfig.json ├── vite.config.ts └── vitest.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "plugins": ["@typescript-eslint"], 9 | "rules": { 10 | "@typescript-eslint/semi": "off", 11 | "curly": "warn", 12 | "eqeqeq": "warn", 13 | "no-throw-literal": "warn", 14 | "semi": "off" 15 | }, 16 | "ignorePatterns": ["out", "dist", "**/*.d.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | build 4 | node_modules 5 | .vscode-test/ 6 | *.vsix 7 | **/.DS_Store 8 | .openpilot 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@trivago/prettier-plugin-sort-imports"], 3 | "semi": false, 4 | "trailingComma": "none", 5 | "importOrder": ["^[./]"], 6 | "importOrderSeparation": true, 7 | "importOrderSortSpecifiers": true 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": [ 13 | "--extensionDevelopmentPath=${workspaceFolder}" 14 | ], 15 | "outFiles": [ 16 | "${workspaceFolder}/out/**/*.js" 17 | ], 18 | "preLaunchTask": "${defaultBuildTask}" 19 | }, 20 | { 21 | "name": "Extension Tests", 22 | "type": "extensionHost", 23 | "request": "launch", 24 | "args": [ 25 | "--extensionDevelopmentPath=${workspaceFolder}", 26 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 27 | ], 28 | "outFiles": [ 29 | "${workspaceFolder}/out/test/**/*.js" 30 | ], 31 | "preLaunchTask": "${defaultBuildTask}" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off", 11 | "openpilot.produceDiffs": true, 12 | "openpilot.includeFiles": true, 13 | "openpilot.lastIndexed": 1691973919170 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | src/** 4 | .gitignore 5 | .yarnrc 6 | **/tsconfig.json 7 | **/.eslintrc.json 8 | **/*.map 9 | **/*.ts 10 | node_modules 11 | webview-ui -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [0.1.1] - 2023-08-14 4 | 5 | ### Added 6 | 7 | - Icon for VS Code Marketplace 8 | - Setting for indexing batch size (the number of files to process at once). Default 20. 9 | 10 | ### Fixed 11 | 12 | - No longer indexing binary files. 13 | 14 | ## [0.1.0] - 2023-08-13 15 | 16 | - Initial release 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Justin Allen. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenPilot 2 | 3 | OpenPilot is an open-source AI programming assistant as an [extension to Visual Studio Code](https://marketplace.visualstudio.com/items?itemName=jallen-dev.openpilot). Connect your editor to a variety of different Large Language Models including those from OpenAI and Google. Ask the LLM questions about your code base, generate new code snippets, or make changes to your existing files. 4 | 5 | ![Using OpenPilot](using_openpilot.gif) 6 | 7 | ## Context is automatically included 8 | 9 | No need to copy & paste code into your chat. OpenPilot creates a vector store of the files in your workspace to match them based on the semantic content of your prompt. OpenPilot then sends the files along with your request so that the LLM has the context it needs to provide a better response. 10 | 11 | ## Choose your LLM 12 | 13 | Switch between any models you have access to from OpenAI or Google. More to come soon! 14 | 15 | ## Get started 16 | 17 | > [!WARNING] 18 | > Using OpenAI via this extension will incur charges. It can be a good idea to set a [usage limit](https://platform.openai.com/account/billing/limits) to avoid overspending. 19 | 20 | > [!WARNING] 21 | > Indexing workspace and chatting with GPT requires sending source code to OpenAI. Do not use this extension for work unless you have permission from your employer. 22 | 23 | ### Bring your own keys 24 | 25 | Generate an [OpenAI key](https://platform.openai.com/account/api-keys) or [Google PaLM key](https://makersuite.google.com/app/apikey). Your keys are stored securely inside of VS Code's Secret Storage. 26 | 27 | ### Index workspace 28 | 29 | Indexing your workspace allows OpenPilot to automatically find files that are relevant to your chat. This requires an OpenAI key, even if you are chatting with a model that isn't GPT. 30 | 31 | #### Install Chroma 32 | 33 | [Chroma](https://github.com/chroma-core/chroma) is a vector store that stores the embeddings generated from your workspace files. 34 | 35 | ```sh 36 | git clone https://github.com/chroma-core/chroma.git 37 | cd chroma 38 | docker-compose up -d --build 39 | ``` 40 | 41 | #### Run the Index Workspace command 42 | 43 | Use the `...` menu in the top-right corner of the OpenPilot pane and choose `Index Workspace` 44 | 45 | > [!NOTE] 46 | > The vector store doesn't (yet) automatically update when your files change. Re-running the index command will incrementally update only those files that have changed since the last time you indexed. 47 | 48 | You can still chat even if you choose not to index, you'll just have to copy & paste any code that might be needed. 49 | 50 | ## Roadmap 51 | 52 | ### Local LLM 53 | 54 | An option to connect to a locally-running LLM for better privacy. 55 | 56 | ### Local Embeddings 57 | 58 | Generate embeddings using a local model instead of OpenAI for better privacy and zero-cost file indexing and lookup. 59 | 60 | ### Better diffs 61 | 62 | GPT 3.5 is bad at following instructions about how to format diffs, at least with the prompts that have been tried so far. A different approach might be needed. 63 | 64 | ### Support more 3rd-party LLMs 65 | 66 | I've been sitting on the waitlist for other companies' APIs for quite a while. Maybe if you work for one of these companies you can hook me up 😉 67 | -------------------------------------------------------------------------------- /media/icons/dark/clear-all.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /media/icons/dark/openpilot.svg: -------------------------------------------------------------------------------- 1 | 2 | < > 3 | 4 | / 5 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /media/icons/dark/refresh.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /media/icons/dark/replace-all.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /media/icons/dark/request-changes.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /media/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jallen-dev/openpilot/7024422f123e8002876694e0a95857c25f981790/media/icons/icon.png -------------------------------------------------------------------------------- /media/icons/light/clear-all.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /media/icons/light/openpilot.svg: -------------------------------------------------------------------------------- 1 | 2 | < > 3 | 4 | / 5 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /media/icons/light/refresh.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /media/icons/light/replace-all.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /media/icons/light/request-changes.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /media/reset.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | font-size: 13px; 4 | } 5 | 6 | *, 7 | *:before, 8 | *:after { 9 | box-sizing: inherit; 10 | } 11 | 12 | body, 13 | h1, 14 | h2, 15 | h3, 16 | h4, 17 | h5, 18 | h6, 19 | p, 20 | ol, 21 | ul { 22 | margin: 0; 23 | padding: 0; 24 | font-weight: normal; 25 | } 26 | 27 | img { 28 | max-width: 100%; 29 | height: auto; 30 | } -------------------------------------------------------------------------------- /media/vscode.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --container-paddding: 20px; 3 | --input-padding-vertical: 6px; 4 | --input-padding-horizontal: 4px; 5 | --input-margin-vertical: 4px; 6 | --input-margin-horizontal: 0; 7 | } 8 | 9 | body { 10 | padding: 0 var(--container-paddding); 11 | color: var(--vscode-foreground); 12 | font-size: var(--vscode-font-size); 13 | font-weight: var(--vscode-font-weight); 14 | font-family: var(--vscode-font-family); 15 | background-color: var(--vscode-editor-background); 16 | } 17 | 18 | ol, 19 | ul { 20 | padding-left: var(--container-paddding); 21 | } 22 | 23 | body > *, 24 | form > * { 25 | margin-block-start: var(--input-margin-vertical); 26 | margin-block-end: var(--input-margin-vertical); 27 | } 28 | 29 | *:focus { 30 | outline-color: var(--vscode-focusBorder) !important; 31 | } 32 | 33 | a { 34 | color: var(--vscode-textLink-foreground); 35 | } 36 | 37 | a:hover, 38 | a:active { 39 | color: var(--vscode-textLink-activeForeground); 40 | } 41 | 42 | code { 43 | font-size: var(--vscode-editor-font-size); 44 | font-family: var(--vscode-editor-font-family); 45 | } 46 | 47 | button { 48 | border: none; 49 | padding: var(--input-padding-vertical) var(--input-padding-horizontal); 50 | width: 100%; 51 | text-align: center; 52 | outline: 1px solid transparent; 53 | outline-offset: 2px !important; 54 | color: var(--vscode-button-foreground); 55 | background: var(--vscode-button-background); 56 | } 57 | 58 | button:hover { 59 | cursor: pointer; 60 | background: var(--vscode-button-hoverBackground); 61 | } 62 | 63 | button:focus { 64 | outline-color: var(--vscode-focusBorder); 65 | } 66 | 67 | button.secondary { 68 | color: var(--vscode-button-secondaryForeground); 69 | background: var(--vscode-button-secondaryBackground); 70 | } 71 | 72 | button.secondary:hover { 73 | background: var(--vscode-button-secondaryHoverBackground); 74 | } 75 | 76 | input:not([type='checkbox']), 77 | textarea { 78 | display: block; 79 | width: 100%; 80 | border: none; 81 | font-family: var(--vscode-font-family); 82 | padding: var(--input-padding-vertical) var(--input-padding-horizontal); 83 | color: var(--vscode-input-foreground); 84 | outline-color: var(--vscode-input-border); 85 | background-color: var(--vscode-input-background); 86 | } 87 | 88 | input::placeholder, 89 | textarea::placeholder { 90 | color: var(--vscode-input-placeholderForeground); 91 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openpilot", 3 | "displayName": "OpenPilot", 4 | "description": "VSCode coding assistant. Connect to a variety of chat models.", 5 | "icon": "media/icons/icon.png", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/jallen-dev/openpilot" 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/jallen-dev/openpilot/issues" 12 | }, 13 | "author": { 14 | "name": "Justin Allen", 15 | "email": "jallen@jallen.dev" 16 | }, 17 | "publisher": "jallen-dev", 18 | "version": "0.1.1", 19 | "engines": { 20 | "vscode": "^1.79.0" 21 | }, 22 | "categories": [ 23 | "Other" 24 | ], 25 | "main": "./out/main.js", 26 | "activationEvents": [], 27 | "contributes": { 28 | "configuration": { 29 | "title": "OpenPilot", 30 | "properties": { 31 | "openpilot.includeFiles": { 32 | "type": "boolean", 33 | "default": true, 34 | "description": "Automatically include files for context to the LLM." 35 | }, 36 | "openpilot.produceDiffs": { 37 | "type": "boolean", 38 | "default": false, 39 | "description": "Generate diffs from the LLM's suggested code changes." 40 | }, 41 | "openpilot.lastIndexed": { 42 | "type": "number", 43 | "default": 0, 44 | "description": "Last time the workspace was indexed." 45 | }, 46 | "openpilot.index.filesBatchSize": { 47 | "type": "number", 48 | "default": 20, 49 | "description": "Maximum number of files to process at a time." 50 | } 51 | } 52 | }, 53 | "commands": [ 54 | { 55 | "command": "openpilot.clearChat", 56 | "title": "Clear", 57 | "icon": { 58 | "light": "./media/icons/light/clear-all.svg", 59 | "dark": "./media/icons/dark/clear-all.svg" 60 | } 61 | }, 62 | { 63 | "command": "openpilot.getCurrentFile", 64 | "title": "Get Current File" 65 | }, 66 | { 67 | "command": "openpilot.indexWorkspace", 68 | "title": "Index Workspace", 69 | "enablement": "openpilot:hasSetOpenaiKey" 70 | }, 71 | { 72 | "command": "openpilot.deleteVectorDb", 73 | "title": "Delete Vector DB" 74 | }, 75 | { 76 | "command": "openpilot.selectModel", 77 | "title": "Switch Model" 78 | }, 79 | { 80 | "command": "openpilot.setApiKey", 81 | "title": "Set API Key" 82 | }, 83 | { 84 | "command": "openpilot.includeFiles", 85 | "title": "Include Files for Context" 86 | }, 87 | { 88 | "command": "openpilot.excludeFiles", 89 | "title": "✓ Include Files for Context" 90 | }, 91 | { 92 | "command": "openpilot.produceDiffs", 93 | "title": "Produce Diffs (experimental)" 94 | }, 95 | { 96 | "command": "openpilot.dontProduceDiffs", 97 | "title": "✓ Produce Diffs (experimental)" 98 | } 99 | ], 100 | "menus": { 101 | "view/title": [ 102 | { 103 | "command": "openpilot.clearChat", 104 | "group": "navigation", 105 | "when": "view == openpilotView" 106 | }, 107 | { 108 | "command": "openpilot.selectModel", 109 | "group": "openpilot.actions@1", 110 | "when": "view == openpilotView" 111 | }, 112 | { 113 | "command": "openpilot.setApiKey", 114 | "group": "openpilot.settings@1", 115 | "when": "view == openpilotView" 116 | }, 117 | { 118 | "command": "openpilot.indexWorkspace", 119 | "group": "openpilot.settings@2", 120 | "when": "view == openpilotView" 121 | }, 122 | { 123 | "command": "openpilot.includeFiles", 124 | "group": "openpilot.toggles@1", 125 | "when": "view == openpilotView && !openpilot:includingFiles" 126 | }, 127 | { 128 | "command": "openpilot.excludeFiles", 129 | "group": "openpilot.toggles@1", 130 | "when": "view == openpilotView && openpilot:includingFiles" 131 | }, 132 | { 133 | "command": "openpilot.produceDiffs", 134 | "group": "openpilot.toggles@2", 135 | "when": "view == openpilotView && !openpilot:producingDiffs" 136 | }, 137 | { 138 | "command": "openpilot.dontProduceDiffs", 139 | "group": "openpilot.toggles@2", 140 | "when": "view == openpilotView && openpilot:producingDiffs" 141 | } 142 | ] 143 | }, 144 | "views": { 145 | "openpilot": [ 146 | { 147 | "id": "openpilotView", 148 | "type": "webview", 149 | "name": "OpenPilot" 150 | } 151 | ] 152 | }, 153 | "viewsContainers": { 154 | "activitybar": [ 155 | { 156 | "id": "openpilot", 157 | "title": "OpenPilot", 158 | "icon": "media/icons/dark/openpilot.svg" 159 | } 160 | ] 161 | } 162 | }, 163 | "scripts": { 164 | "install:all": "npm install && cd webview-ui && npm install", 165 | "start:webview": "cd webview-ui && npm run start", 166 | "build:webview": "cd webview-ui && npm run build", 167 | "test:webview": "cd webview-ui && npm run test", 168 | "vscode:prepublish": "npm run esbuild-base -- --minify", 169 | "esbuild-base": "esbuild ./src/extension.ts --bundle --outfile=out/main.js --external:vscode --format=cjs --platform=node", 170 | "esbuild": "npm run esbuild-base -- --sourcemap", 171 | "esbuild-watch": "npm run esbuild-base -- --sourcemap --watch", 172 | "compile": "tsc -p ./", 173 | "watch": "tsc -watch -p ./", 174 | "pretest": "npm run compile && npm run lint", 175 | "lint": "eslint src --ext ts", 176 | "test": "node ./out/test/runTest.js", 177 | "prettier": "prettier --write ./src --write ./webview-ui" 178 | }, 179 | "devDependencies": { 180 | "@trivago/prettier-plugin-sort-imports": "^4.2.0", 181 | "@types/glob": "^8.1.0", 182 | "@types/lodash": "^4.14.197", 183 | "@types/mocha": "^10.0.1", 184 | "@types/node": "16.x", 185 | "@types/vscode": "^1.79.0", 186 | "@typescript-eslint/eslint-plugin": "^5.59.1", 187 | "@typescript-eslint/parser": "^5.59.1", 188 | "@vscode/test-electron": "^2.3.0", 189 | "esbuild": "^0.19.1", 190 | "eslint": "^8.39.0", 191 | "glob": "^8.1.0", 192 | "mocha": "^10.2.0", 193 | "prettier": "^3.0.1", 194 | "typescript": "^5.0.4" 195 | }, 196 | "dependencies": { 197 | "@cjs-exporter/globby": "^13.1.3", 198 | "chromadb": "^1.5.6", 199 | "faiss-node": "^0.2.2", 200 | "fastest-levenshtein": "^1.0.16", 201 | "isbinaryfile": "^5.0.0", 202 | "langchain": "^0.0.100", 203 | "lodash": "^4.17.21" 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/MessageService.ts: -------------------------------------------------------------------------------- 1 | import { Webview, commands, window, workspace } from "vscode" 2 | 3 | import vectorStore from "./search/VectorStore" 4 | import { ApiKeys } from "./shared/ApiKeys" 5 | import { ExtensionMessage, WebviewMessage } from "./shared/OpenpilotMessage" 6 | import { getCurrentFile, showFile } from "./utils/editor" 7 | import { editFile, getFileContents } from "./utils/file" 8 | 9 | class MessageService { 10 | private webview?: Webview 11 | 12 | setWebview(webview: Webview) { 13 | this.webview = webview 14 | } 15 | 16 | postMessageToWebview(message: ExtensionMessage) { 17 | if (this.webview) { 18 | this.webview.postMessage(message) 19 | } else { 20 | console.error("Error posting message to webview: Webview not set") 21 | } 22 | } 23 | 24 | handleMessageFromWebview = async (message: WebviewMessage) => { 25 | const type = message.type 26 | switch (type) { 27 | case "getCurrentFile": 28 | const file = getCurrentFile() 29 | this.postMessageToWebview({ type: "currentFile", file }) 30 | break 31 | case "getFilesMatchingPrompt": 32 | try { 33 | const files = await vectorStore.search(message.prompt) 34 | 35 | this.postMessageToWebview({ 36 | type: "matchingFiles", 37 | files 38 | }) 39 | } catch (e: any) { 40 | console.error(e) 41 | window.showErrorMessage("Failed to get matching files. " + e.message) 42 | } 43 | break 44 | case "showFile": 45 | showFile(message.path) 46 | break 47 | case "editFile": 48 | editFile(message.path, message.oldContent, message.newContent) 49 | break 50 | case "appLoaded": 51 | const keys = await commands.executeCommand( 52 | "openpilot.getApiKeys" 53 | ) 54 | const { includeFiles, produceDiffs } = 55 | workspace.getConfiguration("openpilot") 56 | const indexedDocCount = await vectorStore.count() 57 | 58 | this.postMessageToWebview({ 59 | type: "appState", 60 | keys, 61 | configuration: { includeFiles, produceDiffs }, 62 | workspaceIndexed: Boolean(indexedDocCount) 63 | }) 64 | break 65 | case "getFileContents": 66 | const fileContents = await getFileContents(message.paths) 67 | this.postMessageToWebview({ 68 | type: "fileContents", 69 | files: fileContents 70 | }) 71 | break 72 | } 73 | } 74 | } 75 | 76 | export default new MessageService() 77 | -------------------------------------------------------------------------------- /src/OpenpilotView.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CancellationToken, 3 | Disposable, 4 | Uri, 5 | Webview, 6 | WebviewView, 7 | WebviewViewProvider, 8 | WebviewViewResolveContext 9 | } from "vscode" 10 | 11 | import MessageService from "./MessageService" 12 | import { Extension } from "./helpers/Extension" 13 | import { getNonce } from "./utils/getNonce" 14 | import { getUri } from "./utils/getUri" 15 | 16 | class OpenpilotView implements WebviewViewProvider { 17 | public static readonly viewType = "openpilotView" 18 | private static instance: OpenpilotView 19 | private _disposables: Disposable[] = [] 20 | 21 | private _view?: WebviewView 22 | 23 | constructor(private readonly _extensionUri: Uri) {} 24 | 25 | public static getInstance(_extensionUri: Uri): OpenpilotView { 26 | if (!OpenpilotView.instance) { 27 | OpenpilotView.instance = new OpenpilotView(_extensionUri) 28 | } 29 | 30 | return OpenpilotView.instance 31 | } 32 | 33 | public resolveWebviewView( 34 | webviewView: WebviewView, 35 | _context: WebviewViewResolveContext, 36 | _token: CancellationToken 37 | ) { 38 | this._view = webviewView 39 | 40 | this._view.webview.options = { 41 | enableScripts: true, 42 | localResourceRoots: [this._extensionUri] 43 | } 44 | 45 | this._view.webview.html = this._getHtmlForWebview(this._view.webview) 46 | this._setWebviewMessageListener(this._view.webview) 47 | MessageService.setWebview(this._view.webview) 48 | } 49 | 50 | /** 51 | * Cleans up and disposes of webview resources when the webview panel is closed. 52 | */ 53 | public dispose() { 54 | while (this._disposables.length) { 55 | const disposable = this._disposables.pop() 56 | if (disposable) { 57 | disposable.dispose() 58 | } 59 | } 60 | } 61 | 62 | private _getHtmlForWebview(webview: Webview) { 63 | const file = "src/index.tsx" 64 | const localPort = "5173" 65 | const localServerUrl = `localhost:${localPort}` 66 | 67 | const styleResetUri = getUri(webview, this._extensionUri, [ 68 | "media", 69 | "reset.css" 70 | ]) 71 | const styleVscodeUri = getUri(webview, this._extensionUri, [ 72 | "media", 73 | "vscode.css" 74 | ]) 75 | // The CSS file from the React build output 76 | const stylesUri = getUri(webview, this._extensionUri, [ 77 | "out", 78 | "webview-ui", 79 | "assets", 80 | "index.css" 81 | ]) 82 | 83 | let scriptUri 84 | const isProd = Extension.getInstance().isProductionMode 85 | if (isProd) { 86 | scriptUri = getUri(webview, this._extensionUri, [ 87 | "out", 88 | "webview-ui", 89 | "assets", 90 | "index.js" 91 | ]) 92 | } else { 93 | scriptUri = `http://${localServerUrl}/${file}` 94 | } 95 | 96 | const nonce = getNonce() 97 | 98 | const reactRefresh = /*html*/ ` 99 | ` 106 | 107 | const reactRefreshHash = 108 | "sha256-YmMpkm5ow6h+lfI3ZRp0uys+EUCt6FOyLkJERkfVnTY=" 109 | 110 | const csp = [ 111 | `default-src 'none';`, 112 | `script-src 'unsafe-eval' https://* ${ 113 | isProd 114 | ? `'nonce-${nonce}'` 115 | : `http://${localServerUrl} http://0.0.0.0:${localPort} '${reactRefreshHash}'` 116 | }`, 117 | `style-src ${webview.cspSource} 'self' 'unsafe-inline' https://*`, 118 | `font-src ${webview.cspSource}`, 119 | `connect-src https://* ${ 120 | isProd 121 | ? `` 122 | : `ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}` 123 | }` 124 | ] 125 | 126 | return /*html*/ ` 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | OpenPilot 136 | 137 | 138 |
139 | ${isProd ? "" : reactRefresh} 140 | 141 | 142 | ` 143 | } 144 | 145 | /** 146 | * Sets up an event listener to listen for messages passed from the webview context and 147 | * executes code based on the message that is recieved. 148 | * 149 | * @param webview A reference to the extension webview 150 | * @param context A reference to the extension context 151 | */ 152 | private _setWebviewMessageListener(webview: Webview) { 153 | webview.onDidReceiveMessage( 154 | MessageService.handleMessageFromWebview, 155 | undefined, 156 | this._disposables 157 | ) 158 | } 159 | } 160 | 161 | export default OpenpilotView 162 | 163 | /* 164 | Substantial portions of this code were taken from vscode-front-matter 165 | https://github.com/estruyf/vscode-front-matter/blob/3a0fe7b4db18ef10285f908a3a4d4efe9503afeb/src/explorerView/ExplorerView.ts 166 | 167 | MIT License 168 | 169 | Copyright (c) 2019 Elio Struyf 170 | 171 | Permission is hereby granted, free of charge, to any person obtaining a copy 172 | of this software and associated documentation files (the "Software"), to deal 173 | in the Software without restriction, including without limitation the rights 174 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 175 | copies of the Software, and to permit persons to whom the Software is 176 | furnished to do so, subject to the following conditions: 177 | 178 | The above copyright notice and this permission notice shall be included in all 179 | copies or substantial portions of the Software. 180 | 181 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 182 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 183 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 184 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 185 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 186 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 187 | SOFTWARE. 188 | */ 189 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode" 2 | 3 | import MessageService from "./MessageService" 4 | import OpenpilotView from "./OpenpilotView" 5 | import { Extension } from "./helpers/Extension" 6 | import vectorStore from "./search/VectorStore" 7 | import { indexWorkspace } from "./search/indexWorkspace" 8 | import { GOOGLE_MODELS, Model, OPENAI_MODELS } from "./shared/Model" 9 | import { getCurrentFile } from "./utils/editor" 10 | import { getNumberOfFilesInWorkspace } from "./utils/workspace" 11 | 12 | export function activate(context: vscode.ExtensionContext) { 13 | Extension.getInstance(context) 14 | 15 | const configuration = vscode.workspace.getConfiguration("openpilot") 16 | 17 | // Set context variables for displaying the correct menu items 18 | if (configuration.get("includeFiles")) { 19 | vscode.commands.executeCommand( 20 | "setContext", 21 | "openpilot:includingFiles", 22 | true 23 | ) 24 | } 25 | 26 | if (configuration.get("produceDiffs")) { 27 | vscode.commands.executeCommand( 28 | "setContext", 29 | "openpilot:producingDiffs", 30 | true 31 | ) 32 | } 33 | 34 | context.secrets.get("OpenAI.apiKey").then((key) => { 35 | if (key) { 36 | vscode.commands.executeCommand( 37 | "setContext", 38 | "openpilot:hasSetOpenaiKey", 39 | true 40 | ) 41 | } 42 | }) 43 | 44 | // Register commands 45 | const clearChatCommand = vscode.commands.registerCommand( 46 | "openpilot.clearChat", 47 | () => { 48 | MessageService.postMessageToWebview({ type: "clearChat" }) 49 | } 50 | ) 51 | 52 | const getCurrentFileCommand = vscode.commands.registerCommand( 53 | "openpilot.getCurrentFile", 54 | () => { 55 | MessageService.postMessageToWebview({ 56 | type: "currentFile", 57 | file: getCurrentFile() 58 | }) 59 | } 60 | ) 61 | 62 | const indexWorkspaceCommand = vscode.commands.registerCommand( 63 | "openpilot.indexWorkspace", 64 | async () => { 65 | const confirm = await vscode.window.showWarningMessage( 66 | `Indexing workspace requires sending source files to OpenAI to be transformed into embeddings.\n\nThis incurs a cost of $0.0004 per 1k tokens (or about $1 per 3000 "pages" of text).`, 67 | { 68 | modal: true 69 | }, 70 | "OK" 71 | ) 72 | 73 | if (!confirm) { 74 | return 75 | } 76 | 77 | const result = await vscode.window.withProgress( 78 | { 79 | location: vscode.ProgressLocation.Notification, 80 | title: "Indexing workspace", 81 | cancellable: true 82 | }, 83 | async (progress, token) => { 84 | token.onCancellationRequested(() => { 85 | console.log("User canceled indexing") 86 | }) 87 | 88 | const count = await vectorStore.count() 89 | // If there are no files in the db, force a full reindex 90 | const lastModified = count 91 | ? (vscode.workspace 92 | .getConfiguration("openpilot") 93 | .get("lastIndexed") as number) 94 | : undefined 95 | 96 | const total = await getNumberOfFilesInWorkspace({ 97 | modifiedSince: lastModified 98 | }) 99 | 100 | progress.report({ increment: 0 }) 101 | 102 | let totalProcessed = 0 103 | return indexWorkspace( 104 | token, 105 | (filesProcessed) => { 106 | totalProcessed += filesProcessed 107 | progress.report({ 108 | increment: (filesProcessed / total) * 100, 109 | message: `Processed ${totalProcessed} of ${total} files` 110 | }) 111 | }, 112 | { modifiedSince: lastModified } 113 | ) 114 | } 115 | ) 116 | 117 | if (result.success) { 118 | const lastIndexed = new Date().valueOf() 119 | vscode.workspace 120 | .getConfiguration("openpilot") 121 | .update("lastIndexed", lastIndexed) 122 | 123 | MessageService.postMessageToWebview({ 124 | type: "workspaceIndexStatus", 125 | indexed: true 126 | }) 127 | } else { 128 | vscode.window.showErrorMessage( 129 | result.message ?? "There was an error indexing the workspace" 130 | ) 131 | } 132 | } 133 | ) 134 | 135 | const deleteVectorDbCommand = vscode.commands.registerCommand( 136 | "openpilot.deleteVectorDb", 137 | async () => { 138 | const confirm = await vscode.window.showWarningMessage( 139 | `Deleting the vector database will remove all indexed files from the database.`, 140 | { 141 | modal: true 142 | }, 143 | "OK" 144 | ) 145 | if (!confirm) { 146 | return 147 | } 148 | 149 | await vectorStore.deleteCollection() 150 | } 151 | ) 152 | 153 | const selectModelCommand = vscode.commands.registerCommand( 154 | "openpilot.selectModel", 155 | async () => { 156 | const openAiItems = OPENAI_MODELS.map((model) => ({ label: model })) 157 | const googleItems = GOOGLE_MODELS.map((model) => ({ label: model })) 158 | const items: vscode.QuickPickItem[] = [ 159 | { label: "OpenAI", kind: vscode.QuickPickItemKind.Separator }, 160 | ...openAiItems, 161 | { label: "Google PaLM 2", kind: vscode.QuickPickItemKind.Separator }, 162 | ...googleItems 163 | ] 164 | const quickPick = vscode.window.createQuickPick() 165 | quickPick.items = items 166 | quickPick.onDidChangeSelection((selection) => { 167 | const model = selection[0].label as Model 168 | 169 | MessageService.postMessageToWebview({ 170 | type: "modelChanged", 171 | model 172 | }) 173 | quickPick.dispose() 174 | }) 175 | quickPick.onDidHide(() => quickPick.dispose()) 176 | quickPick.show() 177 | } 178 | ) 179 | 180 | const setApiKeyCommand = vscode.commands.registerCommand( 181 | "openpilot.setApiKey", 182 | async () => { 183 | const service = await vscode.window.showQuickPick(["OpenAI", "Google"], { 184 | title: "Select service to set API key for" 185 | }) 186 | 187 | if (!service) { 188 | return 189 | } 190 | 191 | const key = `${service}.apiKey` 192 | 193 | const existingValue = await context.secrets.get(key) 194 | const value = await vscode.window.showInputBox({ 195 | title: "Enter your API key", 196 | password: true, 197 | ignoreFocusOut: true, 198 | value: existingValue 199 | }) 200 | if (value !== undefined) { 201 | context.secrets.store(key, value) 202 | 203 | if (service === "OpenAI") { 204 | vscode.commands.executeCommand( 205 | "setContext", 206 | "openpilot:hasSetOpenaiKey", 207 | true 208 | ) 209 | } 210 | 211 | MessageService.postMessageToWebview({ 212 | type: "apiKeys", 213 | keys: { [service]: value } 214 | }) 215 | 216 | const model = service === "OpenAI" ? OPENAI_MODELS[0] : GOOGLE_MODELS[0] 217 | MessageService.postMessageToWebview({ 218 | type: "modelChanged", 219 | model 220 | }) 221 | } 222 | } 223 | ) 224 | 225 | const getApiKeysCommand = vscode.commands.registerCommand( 226 | "openpilot.getApiKeys", 227 | async () => { 228 | const OpenAI = await context.secrets.get("OpenAI.apiKey") 229 | const Google = await context.secrets.get("Google.apiKey") 230 | return { OpenAI, Google } 231 | } 232 | ) 233 | 234 | // Following commands are for toggling config options 235 | const includeFilesCommand = vscode.commands.registerCommand( 236 | "openpilot.includeFiles", 237 | async () => { 238 | configuration.update("includeFiles", true) 239 | vscode.commands.executeCommand( 240 | "setContext", 241 | "openpilot:includingFiles", 242 | true 243 | ) 244 | MessageService.postMessageToWebview({ 245 | type: "configuration", 246 | configuration: { 247 | includeFiles: true, 248 | produceDiffs: configuration.get("produceDiffs") as boolean 249 | } 250 | }) 251 | } 252 | ) 253 | 254 | const excludeFilesCommand = vscode.commands.registerCommand( 255 | "openpilot.excludeFiles", 256 | async () => { 257 | configuration.update("includeFiles", false) 258 | vscode.commands.executeCommand( 259 | "setContext", 260 | "openpilot:includingFiles", 261 | false 262 | ) 263 | MessageService.postMessageToWebview({ 264 | type: "configuration", 265 | configuration: { 266 | includeFiles: false, 267 | produceDiffs: configuration.get("produceDiffs") as boolean 268 | } 269 | }) 270 | } 271 | ) 272 | 273 | const produceDiffsCommand = vscode.commands.registerCommand( 274 | "openpilot.produceDiffs", 275 | async () => { 276 | configuration.update("produceDiffs", true) 277 | vscode.commands.executeCommand( 278 | "setContext", 279 | "openpilot:producingDiffs", 280 | true 281 | ) 282 | MessageService.postMessageToWebview({ 283 | type: "configuration", 284 | configuration: { 285 | includeFiles: configuration.get("includeFiles") as boolean, 286 | produceDiffs: true 287 | } 288 | }) 289 | } 290 | ) 291 | 292 | const dontProduceDiffsCommand = vscode.commands.registerCommand( 293 | "openpilot.dontProduceDiffs", 294 | async () => { 295 | configuration.update("produceDiffs", false) 296 | vscode.commands.executeCommand( 297 | "setContext", 298 | "openpilot:producingDiffs", 299 | false 300 | ) 301 | MessageService.postMessageToWebview({ 302 | type: "configuration", 303 | configuration: { 304 | includeFiles: configuration.get("includeFiles") as boolean, 305 | produceDiffs: false 306 | } 307 | }) 308 | } 309 | ) 310 | 311 | // Register webview 312 | const openpilotView = vscode.window.registerWebviewViewProvider( 313 | OpenpilotView.viewType, 314 | OpenpilotView.getInstance(context.extensionUri) 315 | ) 316 | 317 | context.subscriptions.push( 318 | clearChatCommand, 319 | getCurrentFileCommand, 320 | indexWorkspaceCommand, 321 | deleteVectorDbCommand, 322 | selectModelCommand, 323 | setApiKeyCommand, 324 | getApiKeysCommand, 325 | openpilotView, 326 | includeFilesCommand, 327 | excludeFilesCommand, 328 | produceDiffsCommand, 329 | dontProduceDiffsCommand 330 | ) 331 | } 332 | 333 | export function deactivate() {} 334 | -------------------------------------------------------------------------------- /src/helpers/Extension.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext, ExtensionMode } from "vscode" 2 | 3 | export class Extension { 4 | private static instance: Extension 5 | 6 | private constructor(private ctx: ExtensionContext) {} 7 | 8 | public static getInstance(ctx?: ExtensionContext): Extension { 9 | if (!Extension.instance && ctx) { 10 | Extension.instance = new Extension(ctx) 11 | } 12 | 13 | return Extension.instance 14 | } 15 | 16 | /** 17 | * Check if the extension is in production/development mode 18 | */ 19 | public get isProductionMode(): boolean { 20 | return this.ctx.extensionMode === ExtensionMode.Production 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/search/VectorStore.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChromaClient, 3 | Collection, 4 | OpenAIEmbeddingFunction 5 | } from "chromadb/dist/module" 6 | import { Metadata } from "chromadb/dist/module/types" 7 | import { range } from "lodash" 8 | import { commands } from "vscode" 9 | 10 | import { ApiKeys } from "../shared/ApiKeys" 11 | import { FileSearchResult } from "../shared/File" 12 | import { getWorkspacePath } from "../utils/workspace" 13 | 14 | class VectorStore { 15 | private client = new ChromaClient() 16 | private _collection?: Collection 17 | 18 | async embeddings() { 19 | const { OpenAI } = await commands.executeCommand( 20 | "openpilot.getApiKeys" 21 | ) 22 | if (!OpenAI) { 23 | throw new Error("OpenAI API key not set") 24 | } 25 | 26 | return new OpenAIEmbeddingFunction({ 27 | openai_api_key: OpenAI 28 | }) 29 | } 30 | 31 | get collectionName() { 32 | const workspacePath = getWorkspacePath() 33 | if (!workspacePath) { 34 | throw new Error("No workspace") 35 | } 36 | 37 | return workspacePath.slice(1, -1).replace(/\//g, "_") 38 | } 39 | 40 | async getCollection() { 41 | if (!this._collection) { 42 | const embeddingFunction = await this.embeddings() 43 | this._collection = await this.client.getOrCreateCollection({ 44 | name: this.collectionName, 45 | embeddingFunction 46 | }) 47 | } 48 | 49 | return this._collection 50 | } 51 | 52 | async addDocuments(documents: string[], metadatas: Metadata[]) { 53 | const collection = await this.getCollection() 54 | 55 | if (!collection) { 56 | console.error("Failed to get the collection.") 57 | return 58 | } 59 | 60 | try { 61 | const existing = await collection.count() 62 | const ids = range(existing, existing + documents.length).map((i) => 63 | i.toString() 64 | ) 65 | await collection.add({ ids, documents, metadatas }) 66 | } catch (e: any) { 67 | console.error("Failed to add documents to vector store.", e) 68 | // there might be something wrong with the collection, like the OpenAI key was not correct 69 | // set collection to undefined so when the user retries it will be recreated 70 | this._collection = undefined 71 | 72 | throw new Error("Failed to add documents to vector store. " + e.message) 73 | } 74 | } 75 | 76 | async search(query: string) { 77 | if (!query) { 78 | return [] 79 | } 80 | 81 | const collection = await this.getCollection() 82 | 83 | const queryResult = await collection.query({ 84 | nResults: 10, 85 | queryTexts: query 86 | }) 87 | 88 | // de-dupe and also transform to a more useful format 89 | const results = new Map() 90 | for (let i = 0; i < queryResult.ids[0].length; i++) { 91 | const metadata = queryResult.metadatas[0][i] 92 | const path = metadata ? (metadata["path"] as string) : "" 93 | const relativePath = path.replace(getWorkspacePath() ?? "", "") 94 | 95 | if (results.has(relativePath)) { 96 | // skip this file (duplicate) 97 | continue 98 | } 99 | 100 | const name = metadata ? (metadata["name"] as string) : "" 101 | 102 | const score = queryResult.distances ? queryResult.distances[0][i] : 0 103 | const content = queryResult.documents 104 | ? queryResult.documents[0][i] ?? "" 105 | : "" 106 | 107 | results.set(relativePath, { 108 | path: relativePath, 109 | score, 110 | name, 111 | content 112 | }) 113 | } 114 | 115 | return [...results.values()] 116 | } 117 | 118 | async deleteCollection() { 119 | await this.client.deleteCollection({ name: this.collectionName }) 120 | this._collection = undefined 121 | } 122 | 123 | async count() { 124 | try { 125 | const collection = await this.getCollection() 126 | return collection.count() 127 | } catch (e) { 128 | console.error("Failed to count documents in vector store.", e) 129 | return 0 130 | } 131 | } 132 | } 133 | 134 | const vectorStore = new VectorStore() 135 | 136 | export default vectorStore 137 | -------------------------------------------------------------------------------- /src/search/indexWorkspace.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from "fs/promises" 2 | import { 3 | RecursiveCharacterTextSplitter, 4 | SupportedTextSplitterLanguage, 5 | SupportedTextSplitterLanguages 6 | } from "langchain/text_splitter" 7 | import path from "path" 8 | import * as vscode from "vscode" 9 | 10 | import { getAllFilesInDirectory, getWorkspacePath } from "../utils/workspace" 11 | import vectorStore from "./VectorStore" 12 | 13 | export async function indexWorkspace( 14 | token: vscode.CancellationToken, 15 | onProcessed: (total: number) => void, 16 | options: { modifiedSince?: number } = {} 17 | ) { 18 | let canceled = false 19 | token.onCancellationRequested(() => { 20 | canceled = true 21 | }) 22 | 23 | const workspacePath = getWorkspacePath() 24 | if (!workspacePath) { 25 | return { 26 | success: false, 27 | message: 28 | "There was an error indexing the workspace: No workspace found. Please open a folder." 29 | } 30 | } 31 | 32 | const filesPaths = await getAllFilesInDirectory(workspacePath, options) 33 | const groups = await groupFilesByExtension(filesPaths) 34 | 35 | for (const [language, filesMetadata] of Object.entries(groups)) { 36 | if (filesMetadata.length === 0) { 37 | continue 38 | } 39 | 40 | const splitter = splitterForLanguage(language) 41 | 42 | const filesBatchSize = 43 | (vscode.workspace 44 | .getConfiguration("openpilot") 45 | .get("index.filesBatchSize") as number) ?? 20 46 | 47 | for (let i = 0; i < filesMetadata.length; i += filesBatchSize) { 48 | if (canceled) { 49 | canceled = false 50 | return { success: false, message: "Canceled" } 51 | } 52 | 53 | const batchedFilesMetadata = filesMetadata.slice(i, i + filesBatchSize) 54 | onProcessed(batchedFilesMetadata.length) 55 | 56 | const filesContents = await Promise.all( 57 | batchedFilesMetadata.map((file) => 58 | readFile(file.path, { encoding: "utf8" }) 59 | ) 60 | ) 61 | 62 | try { 63 | const output = await splitter.createDocuments( 64 | filesContents, 65 | batchedFilesMetadata 66 | ) 67 | const contents = output.map((doc) => doc.pageContent) 68 | const metaDatas = output.map((doc) => doc.metadata) 69 | await vectorStore.addDocuments(contents, metaDatas) 70 | } catch (e: any) { 71 | return { success: false, message: e.message } 72 | } 73 | } 74 | } 75 | 76 | return { success: true } 77 | } 78 | 79 | function splitterForLanguage(language: string) { 80 | if (isSupportedLanguage(language)) { 81 | return RecursiveCharacterTextSplitter.fromLanguage(language, { 82 | chunkSize: 1000, 83 | chunkOverlap: 0 84 | }) 85 | } else { 86 | return new RecursiveCharacterTextSplitter({ 87 | chunkSize: 1000, 88 | chunkOverlap: 200 89 | }) 90 | } 91 | } 92 | 93 | function isSupportedLanguage( 94 | language: string 95 | ): language is SupportedTextSplitterLanguage { 96 | return SupportedTextSplitterLanguages.includes( 97 | language as SupportedTextSplitterLanguage 98 | ) 99 | } 100 | 101 | async function groupFilesByExtension( 102 | entries: { name: string; path: string }[] 103 | ) { 104 | type GroupedFiles = { 105 | [key in SupportedTextSplitterLanguage | "other"]: { 106 | name: string 107 | path: string 108 | }[] 109 | } 110 | 111 | const groups: GroupedFiles = { 112 | java: [], 113 | js: [], 114 | python: [], 115 | ruby: [], 116 | cpp: [], 117 | go: [], 118 | php: [], 119 | proto: [], 120 | rst: [], 121 | html: [], 122 | latex: [], 123 | sol: [], 124 | swift: [], 125 | rust: [], 126 | scala: [], 127 | markdown: [], 128 | other: [] 129 | } 130 | for (const entry of entries) { 131 | const extension = path.extname(entry.name) 132 | let bucket: SupportedTextSplitterLanguage | "other" 133 | switch (extension) { 134 | case ".js": 135 | case ".jsx": 136 | case ".ts": 137 | case ".tsx": 138 | case ".json": 139 | case ".cjs": 140 | case ".mjs": 141 | bucket = "js" 142 | break 143 | case ".css": 144 | case ".scss": 145 | case ".sass": 146 | case ".html": 147 | case ".htm": 148 | bucket = "html" 149 | break 150 | case ".md": 151 | case ".markdown": 152 | bucket = "markdown" 153 | break 154 | case ".py": 155 | bucket = "python" 156 | break 157 | case ".rst": 158 | bucket = "rst" 159 | break 160 | case ".rb": 161 | bucket = "ruby" 162 | break 163 | case ".go": 164 | bucket = "go" 165 | break 166 | case ".java": 167 | bucket = "java" 168 | break 169 | case ".php": 170 | bucket = "php" 171 | break 172 | case ".c": 173 | case ".cpp": 174 | case ".h": 175 | case ".hpp": 176 | case ".cs": 177 | bucket = "cpp" 178 | break 179 | case ".swift": 180 | bucket = "swift" 181 | break 182 | case ".rs": 183 | bucket = "rust" 184 | break 185 | case ".scala": 186 | bucket = "scala" 187 | break 188 | case ".sol": 189 | bucket = "sol" 190 | break 191 | case ".proto": 192 | bucket = "proto" 193 | break 194 | case ".tex": 195 | bucket = "latex" 196 | break 197 | default: 198 | bucket = "other" 199 | break 200 | } 201 | 202 | groups[bucket].push(entry) 203 | } 204 | return groups 205 | } 206 | -------------------------------------------------------------------------------- /src/shared/ApiKeys.ts: -------------------------------------------------------------------------------- 1 | export type ApiKeys = { 2 | OpenAI?: string 3 | Google?: string 4 | } 5 | -------------------------------------------------------------------------------- /src/shared/ChatMessage.ts: -------------------------------------------------------------------------------- 1 | import { Model } from "./Model" 2 | 3 | export type ChatMessage = UserMessage | ModelMessage 4 | 5 | export type UserMessage = { 6 | content: string 7 | participant: "user" 8 | } 9 | 10 | export type ModelMessage = { 11 | content: string 12 | participant: Model 13 | } 14 | 15 | export type GoogleRequestMessage = { 16 | content: string 17 | author: "user" | "assistant" 18 | } 19 | -------------------------------------------------------------------------------- /src/shared/Configuration.ts: -------------------------------------------------------------------------------- 1 | export type Configuration = { 2 | includeFiles: boolean 3 | produceDiffs: boolean 4 | } 5 | -------------------------------------------------------------------------------- /src/shared/File.ts: -------------------------------------------------------------------------------- 1 | export type File = { 2 | path: string 3 | name: string 4 | content: string 5 | } 6 | 7 | export type FileSearchResult = File & { 8 | score: number 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/Model.ts: -------------------------------------------------------------------------------- 1 | export const OPENAI_MODELS = [ 2 | "gpt-3.5-turbo", 3 | "gpt-3.5-turbo-16k", 4 | "gpt-4", 5 | "gpt-4-32k" 6 | ] as const 7 | export const GOOGLE_MODELS = ["chat-bison-001"] as const 8 | export const OPENPILOT_MODEL = "openpilot" as const 9 | export const ALL_MODELS = [ 10 | ...OPENAI_MODELS, 11 | ...GOOGLE_MODELS, 12 | OPENPILOT_MODEL 13 | ] as const 14 | 15 | export type OpenaiModel = (typeof OPENAI_MODELS)[number] 16 | export type GoogleModel = (typeof GOOGLE_MODELS)[number] 17 | export type OpenpilotModel = typeof OPENPILOT_MODEL 18 | export type Model = (typeof ALL_MODELS)[number] 19 | 20 | export function displayNameForModel(model: Model): string { 21 | return MODEL_DISPLAY_NAMES[model] 22 | } 23 | 24 | const MODEL_DISPLAY_NAMES: { [key in Model]: string } = { 25 | "gpt-3.5-turbo": "GPT 3.5 Turbo", 26 | "gpt-3.5-turbo-16k": "GPT 3.5 Turbo 16k", 27 | "gpt-4": "GPT 4", 28 | "gpt-4-32k": "GPT 4 32k", 29 | "chat-bison-001": "PaLM 2 Bison", 30 | openpilot: "OpenPilot" 31 | } 32 | -------------------------------------------------------------------------------- /src/shared/OpenpilotMessage.ts: -------------------------------------------------------------------------------- 1 | import { ApiKeys } from "./ApiKeys" 2 | import { Configuration } from "./Configuration" 3 | import { File, FileSearchResult } from "./File" 4 | import { Model } from "./Model" 5 | 6 | export type WebviewMessage = 7 | | GetCurrentFileMessage 8 | | GetFilesMatchingPromptMessage 9 | | GetFileContentsMessage 10 | | ShowFileMessage 11 | | EditFileMessage 12 | | AppLoadedMessage 13 | 14 | export type ExtensionMessage = 15 | | ClearChatMessage 16 | | CurrentFileMessage 17 | | MatchingFilesMessage 18 | | FileContentsMessage 19 | | ApiKeysMessage 20 | | ModelChangedMessage 21 | | ConfigurationMessage 22 | | WorkspaceIndexStatusMessage 23 | | AppStateMessage 24 | 25 | export type ClearChatMessage = { 26 | type: "clearChat" 27 | } 28 | 29 | export type GetCurrentFileMessage = { 30 | type: "getCurrentFile" 31 | } 32 | 33 | export type CurrentFileMessage = { 34 | type: "currentFile" 35 | file?: File 36 | } 37 | 38 | export type GetFilesMatchingPromptMessage = { 39 | type: "getFilesMatchingPrompt" 40 | prompt: string 41 | } 42 | 43 | export type MatchingFilesMessage = { 44 | type: "matchingFiles" 45 | files: FileSearchResult[] 46 | } 47 | 48 | export type ShowFileMessage = { 49 | type: "showFile" 50 | path: string 51 | } 52 | 53 | export type EditFileMessage = { 54 | type: "editFile" 55 | path: string 56 | oldContent: string 57 | newContent: string 58 | } 59 | 60 | export type AppLoadedMessage = { 61 | type: "appLoaded" 62 | } 63 | 64 | export type ApiKeysMessage = { 65 | type: "apiKeys" 66 | keys: ApiKeys 67 | } 68 | 69 | export type ModelChangedMessage = { 70 | type: "modelChanged" 71 | model: Model 72 | } 73 | 74 | export type ConfigurationMessage = { 75 | type: "configuration" 76 | configuration: Configuration 77 | } 78 | 79 | export type WorkspaceIndexStatusMessage = { 80 | type: "workspaceIndexStatus" 81 | indexed: boolean 82 | } 83 | 84 | export type AppStateMessage = { 85 | type: "appState" 86 | keys: ApiKeys 87 | configuration: Configuration 88 | workspaceIndexed: boolean 89 | } 90 | 91 | export type GetFileContentsMessage = { 92 | type: "getFileContents" 93 | paths: string[] 94 | } 95 | 96 | export type FileContentsMessage = { 97 | type: "fileContents" 98 | files: File[] 99 | } 100 | -------------------------------------------------------------------------------- /src/test/runTest.ts: -------------------------------------------------------------------------------- 1 | import { runTests } from "@vscode/test-electron" 2 | import * as path from "path" 3 | 4 | async function main() { 5 | try { 6 | // The folder containing the Extension Manifest package.json 7 | // Passed to `--extensionDevelopmentPath` 8 | const extensionDevelopmentPath = path.resolve(__dirname, "../../") 9 | 10 | // The path to test runner 11 | // Passed to --extensionTestsPath 12 | const extensionTestsPath = path.resolve(__dirname, "./suite/index") 13 | 14 | // Download VS Code, unzip it and run the integration test 15 | await runTests({ extensionDevelopmentPath, extensionTestsPath }) 16 | } catch (err) { 17 | console.error("Failed to run tests", err) 18 | process.exit(1) 19 | } 20 | } 21 | 22 | main() 23 | -------------------------------------------------------------------------------- /src/test/suite/extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert" 2 | // You can import and use all API from the 'vscode' module 3 | // as well as import your extension to test it 4 | import * as vscode from "vscode" 5 | 6 | // import * as myExtension from '../../extension'; 7 | 8 | suite("Extension Test Suite", () => { 9 | vscode.window.showInformationMessage("Start all tests.") 10 | 11 | test("Sample test", () => { 12 | assert.strictEqual(-1, [1, 2, 3].indexOf(5)) 13 | assert.strictEqual(-1, [1, 2, 3].indexOf(0)) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/test/suite/index.ts: -------------------------------------------------------------------------------- 1 | import glob from "glob" 2 | import Mocha from "mocha" 3 | import path from "path" 4 | 5 | export function run(): Promise { 6 | // Create the mocha test 7 | const mocha = new Mocha({ 8 | ui: "tdd", 9 | color: true 10 | }) 11 | 12 | const testsRoot = path.resolve(__dirname, "..") 13 | 14 | return new Promise((c, e) => { 15 | glob("**/**.test.js", { cwd: testsRoot }, (err, files) => { 16 | if (err) { 17 | return e(err) 18 | } 19 | 20 | // Add files to the test suite 21 | files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))) 22 | 23 | try { 24 | // Run the mocha test 25 | mocha.run((failures) => { 26 | if (failures > 0) { 27 | e(new Error(`${failures} tests failed.`)) 28 | } else { 29 | c() 30 | } 31 | }) 32 | } catch (err) { 33 | console.error(err) 34 | e(err) 35 | } 36 | }) 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/editor.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import { window, workspace } from "vscode" 3 | 4 | import { File } from "../shared/File" 5 | import { getWorkspacePath } from "./workspace" 6 | 7 | export function getCurrentFile(): File | undefined { 8 | if (!window.activeTextEditor) { 9 | console.warn("No active text editor") 10 | return 11 | } 12 | 13 | const workspacePath = getWorkspacePath() 14 | if (!workspacePath) { 15 | return 16 | } 17 | 18 | // get just the part after the workspace 19 | const filePath = window.activeTextEditor.document.uri.fsPath.replace( 20 | workspacePath, 21 | "" 22 | ) 23 | 24 | const name = path.basename(window.activeTextEditor.document.uri.fsPath) 25 | const content = window.activeTextEditor.document.getText() 26 | 27 | return { path: filePath, name, content } 28 | } 29 | 30 | export async function showFile(path: string) { 31 | const workspacePath = getWorkspacePath() 32 | if (!workspacePath) { 33 | return 34 | } 35 | 36 | const filePath = workspacePath + path 37 | const document = await workspace.openTextDocument(filePath) 38 | window.showTextDocument(document) 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/file.ts: -------------------------------------------------------------------------------- 1 | import { distance } from "fastest-levenshtein" 2 | import path from "path" 3 | import { TextEncoder } from "util" 4 | import { Uri, WorkspaceEdit, workspace } from "vscode" 5 | 6 | import { File } from "../shared/File" 7 | import { getWorkspacePath } from "./workspace" 8 | 9 | export async function editFile( 10 | relativePath: string, 11 | oldContent: string, 12 | newContent: string 13 | ) { 14 | const fileUri = Uri.file(getWorkspacePath() + relativePath) 15 | const document = await workspace.openTextDocument(fileUri) 16 | const fileContent = document.getText() 17 | const fileLines = fileContent.split("\n") 18 | 19 | let start = 0 20 | let end = fileLines.length 21 | let smallestDistance = distance( 22 | oldContent, 23 | fileLines.slice(start, end).join("\n") 24 | ) 25 | while (end > 0) { 26 | const newDistance = distance( 27 | oldContent, 28 | fileLines.slice(start, end - 1).join("\n") 29 | ) 30 | if (newDistance > smallestDistance) { 31 | break 32 | } 33 | smallestDistance = newDistance 34 | end-- 35 | } 36 | while (start < end) { 37 | const newDistance = distance( 38 | oldContent, 39 | fileLines.slice(start + 1, end).join("\n") 40 | ) 41 | if (newDistance > smallestDistance) { 42 | break 43 | } 44 | smallestDistance = newDistance 45 | start++ 46 | } 47 | 48 | const newFileContent = fileContent.replace( 49 | fileLines.slice(start, end).join("\n"), 50 | newContent 51 | ) 52 | 53 | const edit = new WorkspaceEdit() 54 | edit.createFile(Uri.file(getWorkspacePath() + relativePath), { 55 | overwrite: true, 56 | contents: new TextEncoder().encode(newFileContent) 57 | }) 58 | 59 | workspace.applyEdit(edit) 60 | document.save() 61 | } 62 | 63 | export async function getFileContents(filePaths: string[]) { 64 | const files = await Promise.all( 65 | filePaths.map(async (filePath) => { 66 | try { 67 | const fileUri = Uri.file(getWorkspacePath() + filePath) 68 | const document = await workspace.openTextDocument(fileUri) 69 | return { 70 | path: filePath, 71 | name: path.basename(filePath), 72 | content: document.getText() 73 | } 74 | } catch (e) { 75 | console.error(e) 76 | } 77 | }) 78 | ) 79 | 80 | return files.filter((file) => file !== undefined) as File[] 81 | } 82 | -------------------------------------------------------------------------------- /src/utils/getNonce.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A helper function that returns a unique alphanumeric identifier called a nonce. 3 | * 4 | * @remarks This function is primarily used to help enforce content security 5 | * policies for resources/scripts being executed in a webview context. 6 | * 7 | * @returns A nonce 8 | */ 9 | export function getNonce() { 10 | let text = "" 11 | const possible = 12 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" 13 | for (let i = 0; i < 32; i++) { 14 | text += possible.charAt(Math.floor(Math.random() * possible.length)) 15 | } 16 | return text 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/getUri.ts: -------------------------------------------------------------------------------- 1 | import { Uri, Webview } from "vscode" 2 | 3 | /** 4 | * A helper function which will get the webview URI of a given file or resource. 5 | * 6 | * @remarks This URI can be used within a webview's HTML as a link to the 7 | * given file/resource. 8 | * 9 | * @param webview A reference to the extension webview 10 | * @param extensionUri The URI of the directory containing the extension 11 | * @param pathList An array of strings representing the path to a file/resource 12 | * @returns A URI pointing to the file/resource 13 | */ 14 | export function getUri( 15 | webview: Webview, 16 | extensionUri: Uri, 17 | pathList: string[] 18 | ) { 19 | return webview.asWebviewUri(Uri.joinPath(extensionUri, ...pathList)) 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/workspace.ts: -------------------------------------------------------------------------------- 1 | import { globby } from "@cjs-exporter/globby" 2 | import { isBinaryFileSync } from "isbinaryfile" 3 | import { workspace } from "vscode" 4 | 5 | const FILES_TO_IGNORE = ["**/package-lock.json"] 6 | 7 | export async function getNumberOfFilesInWorkspace( 8 | options: { modifiedSince?: number } = {} 9 | ) { 10 | const workspacePath = getWorkspacePath() 11 | if (!workspacePath) { 12 | return 0 13 | } 14 | 15 | const filesPaths = await getAllFilesInDirectory(workspacePath, options) 16 | return filesPaths.length 17 | } 18 | 19 | export async function getAllFilesInDirectory( 20 | directory: string, 21 | { modifiedSince }: { modifiedSince?: number } = {} 22 | ) { 23 | const allFiles = await globby("**/*", { 24 | gitignore: true, 25 | objectMode: true, 26 | cwd: directory, 27 | absolute: true, 28 | ignore: FILES_TO_IGNORE, 29 | stats: Boolean(modifiedSince) 30 | }) 31 | const allTextFiles = allFiles.filter((file) => !isBinaryFileSync(file.path)) 32 | 33 | if (modifiedSince) { 34 | return allTextFiles.filter((file) => file.stats!.mtimeMs > modifiedSince) 35 | } 36 | 37 | return allTextFiles 38 | } 39 | 40 | export function getWorkspacePath() { 41 | const workspace = getWorkspaceUri() 42 | if (!workspace) { 43 | return 44 | } 45 | 46 | return workspace.fsPath + "/" 47 | } 48 | 49 | function getWorkspaceUri() { 50 | if (!workspace.workspaceFolders) { 51 | console.warn("No workspace") 52 | return 53 | } 54 | 55 | return workspace.workspaceFolders[0].uri 56 | } 57 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "skipLibCheck": true, 6 | "target": "ESNext", 7 | "outDir": "out", 8 | "lib": ["ES2020"], 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | "strict": true /* enable all strict type-checking options */ 12 | /* Additional Checks */ 13 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 14 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 15 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 16 | }, 17 | "exclude": ["webview-ui"] 18 | } 19 | -------------------------------------------------------------------------------- /using_openpilot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jallen-dev/openpilot/7024422f123e8002876694e0a95857c25f981790/using_openpilot.gif -------------------------------------------------------------------------------- /webview-ui/__mocks__/vitest-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /webview-ui/__mocks__/zustand.ts: -------------------------------------------------------------------------------- 1 | import { act } from "@testing-library/react" 2 | import * as zustand from "zustand" 3 | 4 | const { create: actualCreate } = await vi.importActual( 5 | "zustand" 6 | ) 7 | 8 | // a variable to hold reset functions for all stores declared in the app 9 | export const storeResetFns = new Set<() => void>() 10 | 11 | // when creating a store, we get its initial state, create a reset function and add it in the set 12 | export const create = (() => { 13 | return (stateCreator: zustand.StateCreator) => { 14 | const store = actualCreate(stateCreator) 15 | const initialState = store.getState() 16 | storeResetFns.add(() => { 17 | store.setState(initialState, true) 18 | }) 19 | return store 20 | } 21 | }) as typeof zustand.create 22 | 23 | // reset all stores after each test run 24 | afterEach(() => { 25 | act(() => { 26 | storeResetFns.forEach((resetFn) => { 27 | resetFn() 28 | }) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /webview-ui/__tests__/components/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render } from "@testing-library/react" 2 | 3 | import App from "../../src/components/App" 4 | import { useAppStateStore } from "../../src/stores/useAppStateStore" 5 | import { useSettingsStore } from "../../src/stores/useSettingsStore" 6 | import { vscode } from "../../src/utils/vscode" 7 | 8 | const postMessage = vi.spyOn(vscode, "postMessage") 9 | afterEach(() => { 10 | vi.resetAllMocks() 11 | }) 12 | 13 | test("posts an appLoaded message on init", () => { 14 | render() 15 | 16 | expect(postMessage).toHaveBeenCalledWith({ 17 | type: "appLoaded" 18 | }) 19 | }) 20 | 21 | test("sets the correct state when it receives an appState message", () => { 22 | render() 23 | 24 | const data = { 25 | type: "appState", 26 | keys: { 27 | OpenAI: "openai-key", 28 | Google: "google-key" 29 | }, 30 | configuration: { 31 | produceDiffs: false, 32 | includeFiles: true 33 | }, 34 | workspaceIndexed: true 35 | } 36 | fireEvent(window, new MessageEvent("message", { data })) 37 | 38 | expect(useSettingsStore.getState().apiKeys).toEqual({ 39 | OpenAI: "openai-key", 40 | Google: "google-key" 41 | }) 42 | expect(useSettingsStore.getState().produceDiffs).toEqual(false) 43 | expect(useSettingsStore.getState().includeFiles).toEqual(true) 44 | expect(useAppStateStore.getState().workspaceIndexed).toEqual(true) 45 | }) 46 | -------------------------------------------------------------------------------- /webview-ui/__tests__/components/ChatList.test.tsx: -------------------------------------------------------------------------------- 1 | import { screen } from "@testing-library/dom" 2 | import { render } from "@testing-library/react" 3 | 4 | import ChatList from "../../src/components/ChatList" 5 | import { useChatStore } from "../../src/stores/useChatStore" 6 | 7 | test("renders just the starting message when there's no chats", () => { 8 | render() 9 | 10 | expect(screen.getByText(/welcome/i)).toBeInTheDocument() 11 | }) 12 | 13 | test("renders user chats and model chats", () => { 14 | useChatStore.getState().setChatMessages([ 15 | { content: "user chat", participant: "user" }, 16 | { content: "model chat", participant: "gpt-3.5-turbo" } 17 | ]) 18 | 19 | render() 20 | 21 | expect(screen.getByText(/user chat/i)).toBeInTheDocument() 22 | expect(screen.getByText(/model chat/i)).toBeInTheDocument() 23 | }) 24 | 25 | test("renders markdown in model chats", () => { 26 | useChatStore.getState().setChatMessages([ 27 | { 28 | content: "# Heading\n\n```js\nconst test = 'hello world'\n```", 29 | participant: "gpt-3.5-turbo" 30 | } 31 | ]) 32 | 33 | render() 34 | 35 | expect(screen.getByText("Heading").tagName).toBe("H1") 36 | expect(document.querySelector("code")).toBeInTheDocument() 37 | }) 38 | 39 | test("matches snapshot", () => { 40 | useChatStore.getState().setChatMessages([ 41 | { content: "user chat", participant: "user" }, 42 | { content: "model chat", participant: "gpt-3.5-turbo" } 43 | ]) 44 | 45 | const { asFragment } = render() 46 | 47 | expect(asFragment()).toMatchSnapshot() 48 | }) 49 | -------------------------------------------------------------------------------- /webview-ui/__tests__/components/FilePicker.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react" 2 | 3 | import FilePicker from "../../src/components/FilePicker" 4 | import { useFileContextStore } from "../../src/stores/useFileContextStore" 5 | import { useSettingsStore } from "../../src/stores/useSettingsStore" 6 | 7 | const testFileSearchResults = [ 8 | { path: "src/file1.ts", name: "file1.ts", content: "content1", score: 0.5 }, 9 | { path: "src/file2.ts", name: "file1.ts", content: "content2", score: 0.5 } 10 | ] 11 | 12 | test("renders nothing when includeFiles is false", () => { 13 | useSettingsStore.getState().setIncludeFiles(false) 14 | useFileContextStore.getState().setSuggestedFiles(testFileSearchResults) 15 | render() 16 | 17 | expect(screen.queryByText(/Files to include/i)).not.toBeInTheDocument() 18 | }) 19 | 20 | test("renders nothing when there are no files", () => { 21 | useSettingsStore.getState().setIncludeFiles(false) 22 | render() 23 | 24 | expect(screen.queryByText(/Files to include/i)).not.toBeInTheDocument() 25 | }) 26 | 27 | test("renders a list of files", () => { 28 | useSettingsStore.getState().setIncludeFiles(true) 29 | useFileContextStore.getState().setSuggestedFiles(testFileSearchResults) 30 | render() 31 | 32 | expect(screen.queryByText(/Files to include/i)).toBeInTheDocument() 33 | }) 34 | 35 | test("matches snapshot", () => { 36 | useSettingsStore.getState().setIncludeFiles(true) 37 | useFileContextStore.getState().setSuggestedFiles(testFileSearchResults) 38 | const { asFragment } = render() 39 | 40 | expect(asFragment()).toMatchSnapshot() 41 | }) 42 | -------------------------------------------------------------------------------- /webview-ui/__tests__/components/PromptInput.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react" 2 | 3 | import PromptInput from "../../src/components/PromptInput" 4 | import { useSettingsStore } from "../../src/stores/useSettingsStore" 5 | import { vscode } from "../../src/utils/vscode" 6 | 7 | const postMessage = vi.spyOn(vscode, "postMessage") 8 | afterEach(() => { 9 | vi.resetAllMocks() 10 | }) 11 | 12 | beforeAll(() => { 13 | vi.useFakeTimers({ shouldAdvanceTime: true }) 14 | }) 15 | 16 | test("textbox is disabled when there are no keys", () => { 17 | useSettingsStore.getState().setApiKeys({ OpenAI: "" }) 18 | useSettingsStore.getState().setIncludeFiles(true) 19 | render() 20 | 21 | expect(screen.getByRole("textbox")).toBeDisabled() 22 | }) 23 | 24 | test("placeholder is correct when there are no keys", () => { 25 | useSettingsStore.getState().setApiKeys({ OpenAI: "" }) 26 | render() 27 | 28 | expect(screen.getByRole("textbox")).toHaveAttribute( 29 | "placeholder", 30 | "Set your API keys in the settings." 31 | ) 32 | }) 33 | 34 | test("placeholder is correct when there are keys", () => { 35 | useSettingsStore.getState().setApiKeys({ OpenAI: "test" }) 36 | render() 37 | 38 | expect(screen.getByRole("textbox")).toHaveAttribute( 39 | "placeholder", 40 | "Type your prompt and hit Enter. Shift+Enter to add a new line." 41 | ) 42 | }) 43 | 44 | test("matches snapshot", () => { 45 | const { asFragment } = render() 46 | expect(asFragment()).toMatchSnapshot() 47 | }) 48 | -------------------------------------------------------------------------------- /webview-ui/__tests__/components/__snapshots__/ChatList.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`matches snapshot 1`] = ` 4 | 5 |
8 |
9 |
12 | 13 | OpenPilot: 14 | 15 |

16 | Welcome to OpenPilot! You first need to set your API keys. 17 |

18 | 19 | 20 |

21 | Click the ••• menu in the top right of this panel and select "Set API Key." 22 |

23 | 24 | 25 |

26 | To create a new OpenAI key, visit 27 | 30 | https://platform.openai.com/account/api-keys 31 | 32 |

33 |
34 |
35 |
36 |
39 | 40 | You: 41 | 42 | 43 | user chat 44 | 45 |
46 |
47 |
48 |
51 | 52 | GPT 3.5 Turbo: 53 | 54 |

55 | model chat 56 |

57 |
58 |
59 |
60 |
61 | `; 62 | -------------------------------------------------------------------------------- /webview-ui/__tests__/components/__snapshots__/FileChangeList.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`matches snapshot 1`] = ` 4 | 5 |
6 |

7 | Apply suggested file changes: 8 |

9 | 12 | 15 |
16 |
17 | `; 18 | -------------------------------------------------------------------------------- /webview-ui/__tests__/components/__snapshots__/FilePicker.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`matches snapshot 1`] = ` 4 | 5 |
8 | 9 | Files to include: 10 | 11 |
14 | 22 | 28 | 29 | 39 |
40 |
43 | 51 | 57 | 58 | 68 |
69 |
70 |
71 | `; 72 | -------------------------------------------------------------------------------- /webview-ui/__tests__/components/__snapshots__/PromptInput.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`matches snapshot 1`] = ` 4 | 5 |