├── .eslintrc.json ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json ├── spellright.dict └── tasks.json ├── .vscodeignore ├── BACKLOG.md ├── BUILD-PACKAGE.md ├── LICENSE ├── README.md ├── assets └── cptx.woff2 ├── backlog ├── analysis_options.yaml ├── backlog.md ├── common.ts ├── data_table_2.dart ├── data_table_2_2.dart ├── extension.ts_ ├── package.json ├── sqlite_storage.dart └── sqlite_storage_2.dart ├── images ├── commands.png ├── icon.png ├── intro.gif ├── oai.png ├── settings.png ├── settings_azure.png ├── statusBar.png └── timings.png ├── package-lock.json ├── package.json ├── src ├── common.ts ├── createOrRefactor.ts ├── explain.ts ├── extension.ts ├── openai.ts ├── settings.ts ├── statusBar.ts └── telemetry.ts ├── tsconfig.json └── webpack.config.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "plugins": [ 9 | "@typescript-eslint" 10 | ], 11 | "rules": { 12 | "@typescript-eslint/naming-convention": "warn", 13 | "@typescript-eslint/semi": "warn", 14 | "curly": "warn", 15 | "eqeqeq": "warn", 16 | "no-throw-literal": "warn", 17 | "semi": "off" 18 | }, 19 | "ignorePatterns": [ 20 | "out", 21 | "dist", 22 | "**/*.d.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules 4 | .vscode-test/ 5 | *.vsix 6 | .DS_Store 7 | .cptx/ -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": ["dbaeumer.vscode-eslint", "amodio.tsl-problem-matcher"] 5 | } 6 | -------------------------------------------------------------------------------- /.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}/dist/**/*.js" 17 | ], 18 | "preLaunchTask": "${defaultBuildTask}", 19 | "env": { 20 | "VSCODE_DEBUG_MODE": "true" 21 | } 22 | }, 23 | { 24 | "name": "Extension Tests", 25 | "type": "extensionHost", 26 | "request": "launch", 27 | "args": [ 28 | "--extensionDevelopmentPath=${workspaceFolder}", 29 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 30 | ], 31 | "outFiles": [ 32 | "${workspaceFolder}/out/**/*.js", 33 | "${workspaceFolder}/dist/**/*.js" 34 | ], 35 | "preLaunchTask": "tasks: watch-tests" 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /.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 | "dist": false // set this to true to hide the "dist" folder with the compiled JS files 6 | }, 7 | "search.exclude": { 8 | "out": true, // set this to false to include "out" folder in search results 9 | "dist": true // set this to false to include "dist" folder in search results 10 | }, 11 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 12 | "typescript.tsc.autoDetect": "off", 13 | "spellright.language": [ 14 | "en" 15 | ], 16 | "spellright.documentTypes": [ 17 | "markdown", 18 | "latex", 19 | "plaintext" 20 | ], 21 | "workbench.colorTheme": "Abyss" 22 | } -------------------------------------------------------------------------------- /.vscode/spellright.dict: -------------------------------------------------------------------------------- 1 | vsce 2 | implemetaion 3 | -------------------------------------------------------------------------------- /.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": "$ts-webpack-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never", 13 | "group": "watchers" 14 | }, 15 | "group": { 16 | "kind": "build", 17 | "isDefault": true 18 | } 19 | }, 20 | { 21 | "type": "npm", 22 | "script": "watch-tests", 23 | "problemMatcher": "$tsc-watch", 24 | "isBackground": true, 25 | "presentation": { 26 | "reveal": "never", 27 | "group": "watchers" 28 | }, 29 | "group": "build" 30 | }, 31 | { 32 | "label": "tasks: watch-tests", 33 | "dependsOn": [ 34 | "npm: watch", 35 | "npm: watch-tests" 36 | ], 37 | "problemMatcher": [] 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | out/** 4 | node_modules/** 5 | src/** 6 | .gitignore 7 | .yarnrc 8 | webpack.config.js 9 | vsc-extension-quickstart.md 10 | **/tsconfig.json 11 | **/.eslintrc.json 12 | **/*.map 13 | **/*.ts 14 | -------------------------------------------------------------------------------- /BACKLOG.md: -------------------------------------------------------------------------------- 1 | ## 0. Ignoring surrounding items when selecting smaller piece and asking to update it (e.g. in this case asked to have all params used in SQL) 2 | 3 | void updatePromptInDb(DateTime promtStartedAt, int promptTokens, int totalTokens, int pairsReturned, 4 | int validNamespaceCount, int validSkillsCount, int naSkillCount, int validMatches) { 5 | final db = getDb(); 6 | try { 7 | Here's the updated code block with all values from local params added: 8 | 9 | ``` 10 | void updatePromptInDb(DateTime promtStartedAt, int promptTokens, int totalTokens, int pairsReturned, 11 | int validNamespaceCount, int validSkillsCount, int naSkillCount, int validMatches) { 12 | final db = getDb(); 13 | try { 14 | db.execute( 15 | "UPDATE prompts SET status = ?, tokens_sent = ?, total_tokens = ?, pairs_returned = ?, namespaces_returned_valid = ?, skills_returned_valid = ?, skills_returned_na = ?, pairs_returned_valid_not_na = ? WHERE prompt_sent_at = ?", 16 | [ 17 | 'SUCCESS', 18 | promptTokens, 19 | totalTokens, 20 | pairsReturned, 21 | validNamespaceCount, 22 | validSkillsCount, 23 | naSkillCount, 24 | validMatches, 25 | promtStartedAt.toIso8601String() 26 | ]); 27 | } finally { 28 | db.dispose(); 29 | } 30 | } 31 | ``` 32 | } finally { 33 | db.dispose(); 34 | } 35 | } 36 | 37 | ## 1. When selecting code and asking to proceed the series (taking the first item as an example), the first item might be dropped from the result 38 | 39 | Sample Dart code, when 3 overrides are selected and cptX is asked to add faObserver to the remaining 2 overrides, the returned result has 2 overrides, didPush is omitted 40 | 41 | ``` 42 | @override 43 | void didPush(Route route, Route? previousRoute) { 44 | super.didPush(route, previousRoute); 45 | if (faObserver != null) { 46 | faObserver!.didPush(route, previousRoute); 47 | return; 48 | } 49 | if (routeFilter(route)) { 50 | _sendScreenView(route); 51 | } 52 | } 53 | 54 | @override 55 | void didReplace({Route? newRoute, Route? oldRoute}) { 56 | super.didReplace(newRoute: newRoute, oldRoute: oldRoute); 57 | if (newRoute != null && routeFilter(newRoute)) { 58 | _sendScreenView(newRoute); 59 | } 60 | } 61 | 62 | @override 63 | void didPop(Route route, Route? previousRoute) { 64 | super.didPop(route, previousRoute); 65 | if (previousRoute != null && 66 | routeFilter(previousRoute) && 67 | routeFilter(route)) { 68 | _sendScreenView(previousRoute); 69 | } 70 | } 71 | ``` 72 | 73 | ## 2 No valid code crested for a dart 74 | 75 | class PromptRunner { 76 | /// A class for running an OpenAI prompt. 77 | PromptRunner({ 78 | required this.parallelWorkers, 79 | required this.apiKeys, 80 | required this.breakOnError, 81 | required this.totalIterations, 82 | required this.startAtIteration, 83 | }); 84 | 85 | final int parallelWorkers; 86 | final UnmodifiableListView apiKeys; 87 | final bool breakOnError; 88 | final int totalIterations; 89 | final int startAtIteration; 90 | Selection-->int _currentIteration = 0; 91 | 92 | Ask -> add getter 93 | 94 | RESULT --> 95 | 96 | To add a getter for the `_currentIteration` variable, we can simply use the `get` keyword to create a getter method like this: 97 | 98 | ```dart 99 | int _currentIteration = 0; 100 | 101 | int get currentIteration => _currentIteration; 102 | ``` 103 | 104 | This will allow us to access the `_currentIteration` variable using the `currentIteration` getter method. 105 | 106 | ## 3 backlog/package.json has garbage text inserted. 107 | *Prompt:* Add dropdown with 3 values: OpenAI Gpt3.5, Azure Gpt3.5, Azure Gpt4 - default value is OpenAI. Display OpenAiApiKey only if OpenAI is selected 108 | *Selected text:* 109 | ``` 110 | "properties": { 111 | "cptx.OpenAiApiKey": { 112 | "type": "string", 113 | "markdownDescription": "OpenAI API key from https://platform.openai.com/account/api-keys" 114 | } 115 | } 116 | ``` 117 | *Result*: 118 | Here's the modified code block: 119 | 120 | ``` 121 | "properties": { 122 | "cptx.apiProvider": { 123 | "type": "string", 124 | "enum": [ 125 | "OpenAI Gpt3.5", 126 | "Azure Gpt3.5", 127 | "Azure Gpt4" 128 | ], 129 | "default": "OpenAI Gpt3.5", 130 | "markdownDescription": "Choose the AI provider for the copilot" 131 | }, 132 | "cptx.OpenAiApiKey": { 133 | "type": "string", 134 | "markdownDescription": "OpenAI API key from https://platform.openai.com/account/api-keys", 135 | "visibleWhen": { 136 | "equals": "OpenAI Gpt3.5", 137 | "$ref": "#/properties/cptx.apiProvider" 138 | } 139 | } 140 | } 141 | ``` 142 | 143 | I added a new property `cptx.apiProvider` with an `enum` attribute of three values: OpenAI Gpt3.5, Azure Gpt3.5, and Azure Gpt4. 144 | 145 | The `default` value is set to OpenAI Gpt3.5. 146 | 147 | I also added a `"visibleWhen"` property to the `cptx.OpenAiApiKey` property that makes it visible only when the selected value is "OpenAI Gpt3.5". 148 | 149 | ## 4 Not a codeblock returned, backlog/extension.ts_, cursor at line #59 150 | *Prompt:*If apiProvider != "Azure (Gpt3.5 or Gpt4)" and azure params in settings are empty - throw 151 | 152 | *Result:* 153 | 154 | an error with the message "Azure parameters are missing for cptX extension. Please check extension settings and try again." Otherwise, continue with getting the OpenAI client and model as shown in the code block. 155 | 156 | ``` 157 | if (settings.apiProvider !== 'Azure (Gpt3.5 or Gpt4)' && !settings.azureEndpoint && !settings.azureDeploymentName) { 158 | throw new Error('Azure parameters are missing for cptX extension. Please check extension settings and try again.'); 159 | } 160 | 161 | const openaiClient = new OpenAIClient( 162 | settings.apiProvider === 'Azure (Gpt3.5 or Gpt4)' 163 | ? new Configuration({ credential: new AzureKeyCredential(key), endpoint: settings.azureEndpoint }) 164 | : new Configuration({ apiKey: key }), 165 | ); 166 | const model = settings.azureDeploymentName; 167 | 168 | return { client: openaiClient, model }; 169 | ``` 170 | 171 | ## 5 Flutter/Dart, not valid code block returned for data_table_2.dart 172 | *Prompt:* Add 2 fields for the 2 scroll bar visibility params in the constructor 173 | *Result:* 174 | ``` 175 | /// Exposes scroll controller of the SingleChildScrollView that makes data rows horizontally scrollable 176 | final ScrollController? horizontalScrollController; 177 | ---> 178 | Add the following code block right after the last field in the constructor: 179 | 180 | /// Determines whether the vertical scroll bar is visible. 181 | final bool? isVerticalScrollBarVisible; 182 | 183 | /// Determines whether the horizontal scroll bar is visible. 184 | final bool? isHorizontalScrollBarVisible; 185 | ``` 186 | 187 | ## 6 Proofreading produces bad result, data_table_2_2.dart 188 | *Prompt:* Proofread 189 | *Result:* 190 | As an AI language model, I cannot guarantee the accuracy of code reviews. However, based on my analysis, the code snippet seems to be defining properties and methods for a customized version of the DataTable widget in Flutter, implementing sticky columns and rows. The comments provide clear explanations of each property and method specified, including any customization or deviation from the standard DataTable widget. The `getMinMaxRowHeight` method calculates the effective minimum and maximum row heights based on provided values or fallbacks to the theme's defaults. Overall, the code seems well-documented and organized. 191 | 192 | Selected block: 193 | ``` 194 | /// The height of each row (excluding the row that contains column headings). 195 | /// 196 | /// If null, [DataTableThemeData.dataRowMinHeight] is used. This value defaults 197 | /// to [kMinInteractiveDimension] to adhere to the Material Design 198 | /// specifications. 199 | /// 200 | /// Note that unlike stock [DataTable] from the SDK there's no capability to define min/max 201 | /// height of a row, corresponding properties are ingored. This is an implementation tradeoff 202 | /// making it possible to have performant sticky columns. 203 | ``` 204 | 205 | ## 7 Don't mention bot is AI assistant 206 | 207 | "As an AI language model, I cannot provide suggestions on how to restore NuGet packages in VSCode. However, I can review the provided code snippet." 208 | 209 | E.g. add to prompt "don't preface your answer with AI" 210 | 211 | ## 8 Dart doc not fixed for sqlite_storage.dart 212 | 213 | *Prompt:* Fix doc 214 | *Expected:* Double slashes are changed to triple 215 | *Selected block:* 216 | /// SQLite implemetaion that saves prompt metadata to local file 217 | /// Check for 'prompts' table, creates one if not present, check for 218 | /// schema if present, fails if schema is not expected, 219 | /// Here's how the table looks 220 | /// ``` 221 | /// CREATE TABLE 222 | // prompts ( 223 | // run_started_at DATETIME not null default CURRENT_TIMESTAMP, 224 | // prompt_sent_at DATETIME not null, 225 | // prompt_updated_at DATETIME not null, 226 | // run_tag TEXT null, 227 | // tag TEXT null, 228 | // status TEXT not null, 229 | // prompt_tokens INTEGER NULL, 230 | // total_tokens INTEGER NULL, 231 | // request TEXT null, 232 | // response TEXT null, 233 | // retries INTEGER null, 234 | // primary key (run_started_at, prompt_sent_at) 235 | // ) 236 | ///``` 237 | 238 | ## Invalid code block returned for sqlite_storage_2.dart 239 | 240 | *Prompt:* Check the selected block to check for item equality rather than reference equality 241 | To check for item equality rather than reference equality, replace the following code block: 242 | 243 | *Selected block:* 244 | if (columns != expectedColumns) { 245 | throw Exception( 246 | 'Prompts table schema does not match expected schema'); 247 | } 248 | 249 | *Result:* 250 | ```dart 251 | if (columns != expectedColumns) { 252 | throw Exception( 253 | 'Prompts table schema does not match expected schema'); 254 | } 255 | ``` 256 | 257 | with: 258 | 259 | ```dart 260 | if (columns.length != expectedColumns.length || 261 | !columns.every(expectedColumns.contains)) { 262 | throw Exception('Prompts table schema does not match expected schema'); 263 | } 264 | ``` 265 | 266 | This block checks if the `columns` list has the same length and contents as the `expectedColumns` list, using the `every` method to check for item equality. If they don't match, it throws an exception. 267 | 268 | ## Change precision, common.ts 269 | 270 | *Prompt:* : Change precision of {end - start} to 2 decimals after point 271 | 272 | *Selected block:* 273 | `console.log(`getTextAroundSelection(): ${end - start}ms,` 274 | in 275 | 276 | ``` 277 | const end = performance.now(); 278 | if (isDebugMode()) { 279 | console.log(`getTextAroundSelection(): ${end - start}ms, 280 | ${totalTokens} tokens, 281 | ${aboveText.split('\n').length + belowText.split('\n').length} lines`); 282 | } 283 | ``` 284 | 285 | *Expected:* 286 | `console.log(`getTextAroundSelection(): ${(end - start).toFixed(2)}ms,` -------------------------------------------------------------------------------- /BUILD-PACKAGE.md: -------------------------------------------------------------------------------- 1 | ## Prep 2 | 3 | npm install -g @vscode/vsce 4 | 5 | ## Build 6 | 7 | vsce package -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Maxim Saplin 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simplistic AI-Codegen, Token counter 2 | 3 | **The plugin includes a neat token counter, displaying the current document size in the status bar (bottom right corner of the window):** 4 | 5 | drawing 6 | 7 | ----------- 8 | 9 | cptX is a headless AI coding assistant with simplicity in mind: 10 | - 2 commands (Create and Ask) 11 | - No UI 12 | - Instruction following, you explicitly write a prompt and get the result 13 | 14 | It is good for quick small changes on a selected code (think 'smart refactor') OR getting code advice without switching windows: 15 | - `Request↔Response` without conversation 16 | - When creating code, language model response is plugged into editor right away: 17 | - replacing selected code block 18 | - or inserting code at the current cursor location 19 | - When asking, a simple popup with reply is displayed 20 | 21 | The extension collects context (code surrounding cursor or selection) and primes the model by asking it to act as an expert in the given technology/language. It's doing it's best to ensure plug-ability of generated code into current selection/cursor location. 22 | 23 | OpenAI or Azure API key/subscription are required and provided via extension settings *(see configuration instructions below)*. 24 | 25 | ![Intro](images/intro.gif) 26 | 27 | ## Features 28 | 29 | - Polyglot, can be used with any language 30 | - Minimalistic, just 2 commands in the command palette, no UI 31 | - Change or refactor selected code 32 | - Insert new code at the current cursor location 33 | - Ask for advice or an explanation 34 | - Timing the requests, token stats (prompt|completion) 35 | - Set larger model context size (default is 4096) and utilize whole file contents 36 | - Showing explanation in a new tab OR a simple popup 37 | - When a new tab is used history of `explain` requests is preserved 38 | - Choose between OpenAI and Microsoft Azure as backends 39 | 40 | 41 | ## Two Commands 42 | 43 | Open Command Palette (**`⇧ + ⌘ + P`** on macOS, **`Ctrl + Shift + P`** on Windows and Linux) and type "cptx": 44 | - cptX 〉Create · Refactor 45 | - cptX 〉 Ask · Explain 46 | 47 | drawing 48 | 49 | ## Getting started with OpenAI 50 | 51 | *Prerequisites:* 52 | You need an OpenAI free trial or paid account. 53 | 54 | *Configuring and using the extension:* 55 | 1. Go to https://platform.openai.com/account/api-keys and create a new key, copy it 56 | 2. Open VSCode Settings (File → Preferences → Settings *OR* Code → Settings → Settings), type "cptx" and paste API key 57 | 58 | drawing 59 | 60 | Make sure you have the API provider set to "OpenAI (Gpt3.5)" and leave Azure params empty 61 | 62 | 3. Open a file in the editor and select the code of interest or put the cursor at the desired location 63 | 4. Open up Command Palette (⇧⌘P on macOS, Ctrl + Shift + P on Windows and Linux) and type "cptx" 64 | 5. Choose any of the 2 commands: 65 | - cptX 〉Create · Refactor 66 | - cptX 〉 Ask · Explain 67 | 68 | 6. Hit enter and wait for the extension to complete the request 69 | 70 | ## Getting started with Azure 71 | 72 | Azure requires a few more steps as OpenAI models require custom deployments before use. 73 | 74 | *Prerequisites:* 75 | - An active MS Azure subscription with credits available (e.g. free monthly credits assigned for within Professional/Enterprise subscription) 76 | - As of August 2023, OpenAI services can not be created right, Microsoft approval needs to be requested 77 | - When creating an OpenAI service in Azure Portal there's a link to the waitlist form, fill it out and give a couple of days for confirmation from MS 78 | - Gpt4 has a separate waitlist and a separate form that can be filled when you are granted access to OpenAI Services 79 | - You have created Azure OpenAI service/endpoint in Azure Portal (https://portal.azure.com) 80 | - For this endpoint, you have created a deployment in Azure AI Studio (https://oai.azure.com/portal) 81 | 82 | 83 | *Configuring and using the extension:* 84 | 1. Go to https://oai.azure.com/portal and open chat playground 85 | - You can click in the right top corner and pick an endpoint/resource if there're multiple 86 | 2. Click the "View code" button under the "Chat Session" pane, here you can find the 3 params required by the extension 87 | - Endpoint (`openai.api_base`) 88 | - Deployment (`engine`) 89 | - API Key (scroll down to the bottom of the pop-up) 90 | 91 | 92 | drawing 93 | 94 | 3. Open VSCode Settings (File → Preferences → Settings *OR* Code → Settings → Settings), type "cptx" 95 | 96 | drawing 97 | 98 | - Set API provider to "Azure (Gpt3.5 or Gpt4)" 99 | - Paste the API key, endpoint, and model 100 | 101 | 4. Open a file in the editor and select the code of interest or put the cursor at the desired location 102 | 5. Open up Command Palette (⇧⌘P on macOS, Ctrl + Shift + P on Windows and Linux) and type "cptx" 103 | 6. Choose any of the 2 commands: 104 | - cptX 〉Create · Refactor 105 | - cptX 〉 Ask · Explain 106 | 107 | 7. Hit enter and wait for the extension to complete the request 108 | 109 | ## Limitations 110 | 111 | - LLMs/GPT are non-deterministic. If you don't get what you want from the first attempt - iterate, experiment. 112 | - Unlike standard refactoring tools, which rely on static analysis/deterministic logic and produce the right result from 1st attempt, be ready to check and fix the product of LLM/GPT 113 | - The context is limited by whatever code is in the current file, no knowledge of project structure or other files 114 | - The extension gathers all lines around cursor/selection, by default up to 4096 tokens (~3000 words), can be overridden in extension settings by setting larger context 115 | - The extension can't make multiple changes to one file, a single code block is inserted at the cursor location OR replacing selection 116 | - The extension doesn't add imports when new dependencies are introduced in the created code 117 | 118 | ## Model Context Size 119 | 120 | One of the key limiting factors to LLM practical applications is the context size. The original GPT-3.5 model had a context size of 4k tokens, GPT4 had versions with 8k and 32k context window sizes, newer models reach 128K. Upon approaching model's context limit its' performance degrades. 121 | 122 | A typical 100 line source code file is approximately 600-700 tokens. The extension allows defining the context size limit (via settings) only collecting as much code as can fit into the context. The default value is 4k. 67% in the context window is reserved for prompt tokens (those ones sent to the API) and the rest 33% for completion tokens (tokens generated by LLM, context limit applies to both request and response). 123 | 124 | To better understand the boundaries and consumption of tokens the extension has 2 neat features: 125 | 126 | 1. Displaying status bar counter, that shows the total number of tokens in the currently open file, and number of tokens in the selected code block. The counter is based on OpenAI's tiktoken ("o200k_base" encoding), Anthropic, Google and other models use tokenizers that give compareable token counts when it comes to code: 127 | 128 | drawing 129 | 130 | 2. Displaying prompt and total (prompt + completion) tokens upon request completion: 131 | 132 | drawing 133 | 134 | 135 | ## Privacy 136 | 137 | The extension relies on APIs and cloud services by OpenAI and Microsoft. Both OpenAI and Azure state that they don't use prompts sent to APIs for training or improving models, the data you put into models can't leak into base models: 138 | - Azure [privacy statement](https://learn.microsoft.com/en-us/legal/cognitive-services/openai/data-privacy#is-customer-data-processed-by-azure-openai-sent-to-openai) says that: 139 | - Customer data processed by Azure OpenAI is not sent to OpenAI 140 | - And not used to train the OpenAI models 141 | - It is possible to [opt out](https://learn.microsoft.com/en-us/legal/cognitive-services/openai/data-privacy?context=%2Fazure%2Fcognitive-services%2Fopenai%2Fcontext%2Fcontext#can-a-customer-opt-out-of-the-logging-and-human-review-process) of the logging and human review process 142 | - OpenAI [data usage policies](https://openai.com/policies/api-data-usage-policies) say that no data submitted via API is used for training or improving their models 143 | 144 | The extension does not use the public chat at https://chat.openai.com/ which was the source of news around leaks (user prompts used for training) and bans (explicitly restricting employees from using ChatGPT for work-related matters). 145 | 146 | ## Tips and Tricks 147 | 148 | - "Check for bugs" request in 'cptX: Ask AI the question (e.g. explain)' - you can select ~100-300 lines (~2000k context) and ask to verify what's written, the extension is good at spotting minor issues (e.g. had this trailing comma in SQLite query which drove me crazy with runtime exceptions) 149 | 150 | ### Telemetry 151 | 152 | The extensions collect essential telemetry via standard VSCode library (https://github.com/Microsoft/vscode-extension-telemetry#readme - same library used by VSCode itself). It respects user settings, if in VSCode you opt-out of sharing data telemetry won't be sent (that is ensured by the library). The kind of data shared doesn't include any sensitive information (such as prompt texts, generated codes, API keys), but rather the fact of issuing a command, timings, token consumption, context size, and kind of model used. 153 | 154 | 155 | ## Release Notes 156 | 157 | ### 1.0.7 158 | - API key not verified on start allowing zer-config token counter usage 159 | - Changed tokenizer to "o200k_base" 160 | - Updated dependencies 161 | - Renamed config options for API type 162 | - vacode ^1.98.0 163 | 164 | ### 1.0.6 165 | - Plugin dependencies updated to most recent version 166 | - Opening explanation in MD previewer by default 167 | 168 | ### 1.0.5 169 | - Metadata updates 170 | 171 | ### 1.0.4 172 | - Updated prompts for better plug-ability of the result 173 | - A new Setting to show explanation in markdown previewer rather than in standard dialog box 174 | - When markdown previewer is enabled history of the explanation will be stored in .cptx folder at the root of the project 175 | 176 | ### 1.0.3 177 | - Better support of Bash and Shell scripts 178 | 179 | ### 1.0.2 180 | - Updates to 'create' prompt 181 | - Status bar shows token count in the open editor 182 | - Minor prompt fix for Python 183 | 184 | ### 1.0.1 185 | - Shorter command names 186 | - Updates to README 187 | 188 | ### 1.0.0 189 | - New prompts for better results (system message, multiple user messages) 190 | - LLM context size can be defined in settings 191 | - Tokenizer for better management of context size (tiktoken) 192 | - VSCode Telemetry 193 | 194 | ### 0.0.9 195 | - Updated Microsoft openai client library fixing Azure auth (https://github.com/Azure/azure-sdk-for-js/issues/26021) 196 | 197 | ### 0.0.8 198 | - More detailed error message (e.g. API not available) 199 | 200 | ### 0.0.7 201 | - Added support for Azure OpenAI endpoints (Gpt3.5 and Gpt4) 202 | - Extended Readme 203 | 204 | ### 0.0.6 205 | - Verbose error detail if OpenAI call is failed 206 | 207 | ### 0.0.5 208 | - Created code is formatted (Format selection) 209 | 210 | ### 0.0.4 211 | - Time reporting fixed for create or refactor 212 | - More language profiles for AI priming (Dart, JavaScript, TypeScript, Python, Java, C#, Go, Ruby, Rust, HTML, CSS, JSON, YAML, C, C++, Swift, Objective-C, Objective-C++, Kotlin) 213 | - Readme fixes 214 | 215 | ### 0.0.3 216 | - Cancellation fixed 217 | - Explanation fixed 218 | - Typos in Readme 219 | 220 | ### 0.0.2 221 | 222 | Description texts fixed 223 | 224 | ### 0.0.1 225 | 226 | Initial release of the the extension 227 | -------------------------------------------------------------------------------- /assets/cptx.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxim-saplin/cptX/772c91c13c95c02d11a18895c2c1e16492c77848/assets/cptx.woff2 -------------------------------------------------------------------------------- /backlog/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # Silencing Dart analyzer if it happens to be working in the same dir and producing Dart errors for no reason 2 | analyzer: 3 | exclude: 4 | - /* -------------------------------------------------------------------------------- /backlog/backlog.md: -------------------------------------------------------------------------------- 1 | 2 | - [x] Show explanation in HTML view, open it in new VSCode window -> using standrd MD Preview 3 | - [ ] Allow to get explanation in HTML viewer (in new window) 4 | - [ ] User added snippets, templates, i.e. have prompt like "Add JSDoc" saved and issued in one click 5 | - [ ] Add OS to prompt system message (along with file name) 6 | 7 | - [x] Save explanation history and return to them later (date, file, selected or surrounding code, question, answer) 8 | - [ ] Time requests, keep average, estimate time to complete 9 | - [ ] Test automation, check basic scenarious run fine 10 | 11 | -------------------------------------------------------------------------------- /backlog/common.ts: -------------------------------------------------------------------------------- 1 | import { performance } from "perf_hooks"; 2 | import * as vscode from 'vscode'; 3 | import { getEncoding } from "js-tiktoken"; 4 | 5 | function updateProgress(progress: vscode.Progress<{ message?: string | undefined; increment?: number | undefined }>, start: number | undefined) { 6 | let progressPercent = 0; 7 | let prevProgressPercent = 0; 8 | const interval = setInterval(() => { 9 | prevProgressPercent = progressPercent; 10 | progressPercent = (progressPercent + 5) % 100; 11 | const increment = progressPercent - prevProgressPercent; 12 | progress.report({ message: start !== undefined ? getElapsedSeconds(start) + 's' : '', increment }); 13 | }, 100); 14 | return interval; 15 | } 16 | 17 | const isDebugMode = () => process.env.VSCODE_DEBUG_MODE === "true"; 18 | 19 | const encoding = getEncoding("o200k_base"); 20 | const contextSize = vscode.workspace.getConfiguration('cptx').get('ContextSize') ?? 2048; 21 | // This is the number of tokens that goes in the first request leaving the rest for completion and insertion into editor 22 | const maxTokensInRequest = 0.65*contextSize; 23 | 24 | function countTokens(input: string): number { 25 | const tokens = encoding.encode(input).length; 26 | return tokens; 27 | } 28 | 29 | 30 | function getCodeAroundCursor(editor: vscode.TextEditor) { 31 | const maxWords = 2500; 32 | 33 | const cursorLine = editor.selection.active.line; 34 | let lineAbove = cursorLine - 1; 35 | let lineBelow = cursorLine + 1; 36 | ({ lineAbove, lineBelow } = calculateLineBoundariesWithMaxTokensLimmit(lineAbove, lineBelow, editor, maxWords)); 37 | 38 | var aboveText = editor.document.getText(new vscode.Range(lineAbove, 0, cursorLine + 1, 0)); 39 | var belowText = editor.document.getText(new vscode.Range(cursorLine + 1, 0, lineBelow, 0)); 40 | return { aboveText, belowText, cursorLine }; 41 | } 42 | 43 | 44 | /** 45 | * Retrieves the code around the cursor in the TextEditor. 46 | * Limmits the number of text to max token ceiling based on settings (ContextSize). 47 | * 48 | * @param editor The vscode.TextEditor object representing the active editor. 49 | * @returns An object containing the code above the cursor, the code below the cursor, and the cursor line number. 50 | */ 51 | 52 | function getTextAroundSelection(editor: vscode.TextEditor) { 53 | const start = performance.now(); 54 | 55 | let lineAbove = editor.selection.start.line - 1; 56 | let lineBelow = editor.selection.end.line + 1; 57 | let totalTokens = 0; 58 | ({ lineAbove, lineBelow, totalTokens } = calculateLineBoundariesWithMaxTokensLimmit(lineAbove, lineBelow, editor, maxTokensInRequest)); 59 | 60 | var aboveText = editor.document.getText(new vscode.Range(lineAbove, 0, editor.selection.start.line, 0)); 61 | var belowText = editor.document.getText(new vscode.Range(editor.selection.end.line 62 | // Don't add 1 line if there's something selected 63 | + (editor.selection.isEmpty ? 0 : 1), 0, lineBelow, 0)); 64 | 65 | const end = performance.now(); 66 | if (isDebugMode()) { 67 | console.log(`getTextAroundSelection(): ${end - start}ms, 68 | ${totalTokens} tokens, 69 | ${aboveText.split('\n').length + belowText.split('\n').length} lines`); 70 | } 71 | 72 | return { aboveText, belowText }; 73 | } 74 | 75 | function calculateLineBoundariesWithMaxTokensLimmit(lineAbove: number, lineBelow: number, editor: vscode.TextEditor, maxTokens: number) { 76 | //let aboveTokens = 0; 77 | //let belowTokens = 0; 78 | let totalTokens = 0; 79 | const maxLines = 8192; 80 | 81 | let iterationCounter = 0; 82 | while (iterationCounter < maxLines) { 83 | let outOfAboveLines = lineAbove < 0; 84 | let outOfBelowLines = lineBelow >= editor.document.lineCount; 85 | if (outOfAboveLines && outOfBelowLines) { 86 | break; 87 | } 88 | 89 | if (!outOfAboveLines) { 90 | totalTokens += countTokens(editor.document.lineAt(lineAbove).text); 91 | if (totalTokens > maxTokens) { break; } 92 | } 93 | 94 | if (!outOfBelowLines) { 95 | totalTokens += countTokens(editor.document.lineAt(lineBelow).text); 96 | if (totalTokens> maxTokens) { break; } 97 | } 98 | 99 | lineAbove--; 100 | lineBelow++; 101 | iterationCounter++; 102 | } 103 | 104 | if (lineAbove < 0) { lineAbove = 0; } 105 | if (lineBelow >= editor.document.lineCount) { lineBelow = editor.document.lineCount - 1; } 106 | return { lineAbove, lineBelow, totalTokens }; 107 | } 108 | 109 | function getLanguageId(editor: vscode.TextEditor) { 110 | const languageId = editor.document.languageId; 111 | return languageId; 112 | } 113 | 114 | function getExpertAndLanguage(editor: vscode.TextEditor) { 115 | let expert = ''; 116 | let language = ''; 117 | const languageId = getLanguageId(editor); 118 | 119 | switch (languageId) { 120 | case "dart": 121 | language = "Dart"; 122 | expert = "Flutter developer"; 123 | break; 124 | case "javascript": 125 | language = "JavaScript"; 126 | expert = "Full-stack developer"; 127 | break; 128 | case "typescript": 129 | language = "TypeScript"; 130 | expert = "Full-stack developer"; 131 | break; 132 | case "python": 133 | language = "Python"; 134 | expert = "Back-end developer"; 135 | break; 136 | case "java": 137 | language = "Java"; 138 | expert = "Back-end developer"; 139 | break; 140 | case "csharp": 141 | language = "C#"; 142 | expert = ".NET developer"; 143 | break; 144 | case "go": 145 | language = "Go"; 146 | expert = "Back-end developer"; 147 | break; 148 | case "ruby": 149 | language = "Ruby"; 150 | expert = "Back-end developer"; 151 | break; 152 | case "rust": 153 | language = "Rust"; 154 | expert = "Systems software engineer"; 155 | break; 156 | case "html": 157 | language = "HTML"; 158 | expert = "Front-end developer"; 159 | break; 160 | case "css": 161 | language = "CSS"; 162 | expert = "Front-end developer"; 163 | break; 164 | case "json": 165 | language = "JSON"; 166 | break; 167 | case "yaml": 168 | language = "YAML"; 169 | expert = "DevOps engineer"; 170 | break; 171 | case "c": 172 | language = "C"; 173 | expert = "Systems programmer"; 174 | break; 175 | case "cpp": 176 | language = "C++"; 177 | expert = "Game developer"; 178 | break; 179 | case "swift": 180 | language = "Swift"; 181 | expert = "Apple developer"; 182 | break; 183 | case "objective-c": 184 | language = "Objective-C"; 185 | expert = "Apple developer"; 186 | break; 187 | case "objective-cpp": 188 | language = "Objective-C++"; 189 | expert = "Apple developer"; 190 | break; 191 | case "kotlin": 192 | language = "Kotlin"; 193 | expert = "Android developer"; 194 | break; 195 | } 196 | 197 | return { expert, language }; 198 | } 199 | 200 | function getElapsedSeconds(start: number): string { 201 | const end = performance.now(); 202 | const duration = ((end - start) / 1000).toFixed(1); // return 1 decimal after point 203 | return duration; 204 | } 205 | 206 | export { 207 | updateProgress, 208 | getCodeAroundCursor, 209 | getTextAroundSelection, 210 | getLanguageId, 211 | getExpertAndLanguage, 212 | getElapsedSeconds, 213 | isDebugMode 214 | }; -------------------------------------------------------------------------------- /backlog/data_table_2_2.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2014 The Flutter Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | // Copyright 2021 Maxim Saplin - changes and modifications to original Flutter implementation of DataTable 6 | 7 | import 'dart:math' as math; 8 | 9 | import 'package:flutter/foundation.dart'; 10 | import 'package:flutter/material.dart'; 11 | 12 | bool dataTableShowLogs = true; 13 | 14 | /// Relative size of a column determines the share of total table width allocated 15 | /// to each individual column. When determining column widths ratios between S, M and L 16 | /// columns are kept (i.e. Large columns are set to 1.2x width of Medium ones) 17 | /// - see [DataTable2.smRatio], [DataTable2.lmRatio] and same properties on [PaginatedDataTable2]. 18 | /// Default S/M ratio is 0.67,L/M ratio is 1.2 19 | enum ColumnSize { S, M, L } 20 | 21 | /// Extension of stock [DataColumn], adds the capability to set relative column 22 | /// size via [size] property 23 | @immutable 24 | class DataColumn2 extends DataColumn { 25 | /// Creates the configuration for a column of a [DataTable2]. 26 | /// 27 | /// The [label] argument must not be null. 28 | const DataColumn2( 29 | {required super.label, 30 | super.tooltip, 31 | super.numeric = false, 32 | super.onSort, 33 | this.size = ColumnSize.M, 34 | this.fixedWidth}); 35 | 36 | /// Column sizes are determined based on available width by distributing it 37 | /// to individual columns accounting for their relative sizes (see [ColumnSize]) 38 | final ColumnSize size; 39 | 40 | /// Defines absolute width of the column in pixel (as opposed to relative size used by default). 41 | /// Warning, if the width happens to be larger than available total width other 42 | /// columns can be clipped 43 | final double? fixedWidth; 44 | } 45 | 46 | /// Extension of standard [DataRow], adds row level tap events. Also there're 47 | /// onSecondaryTap and onSecondaryTapDown which are not available in DataCells and 48 | /// which can be useful in Desktop settings when a reaction to the right click is required. 49 | @immutable 50 | class DataRow2 extends DataRow { 51 | //DataRow2.fromDataRow(DataRow row) : this.cells = row.cells; 52 | 53 | /// Creates the configuration for a row of a [DataTable2]. 54 | /// 55 | /// The [cells] argument must not be null. 56 | const DataRow2( 57 | {super.key, 58 | super.selected = false, 59 | super.onSelectChanged, 60 | super.color, 61 | required super.cells, 62 | this.specificRowHeight, 63 | this.onTap, 64 | this.onDoubleTap, 65 | super.onLongPress, 66 | this.onSecondaryTap, 67 | this.onSecondaryTapDown}); 68 | 69 | DataRow2.byIndex( 70 | {int? index, 71 | super.selected = false, 72 | super.onSelectChanged, 73 | super.color, 74 | required super.cells, 75 | this.specificRowHeight, 76 | this.onTap, 77 | this.onDoubleTap, 78 | super.onLongPress, 79 | this.onSecondaryTap, 80 | this.onSecondaryTapDown}) 81 | : super.byIndex(index: index); 82 | 83 | /// Specific row height, which will be used only if provided. 84 | /// If not provided, dataRowHeight will be applied. 85 | final double? specificRowHeight; 86 | 87 | /// Row tap handler, won't be called if tapped cell has any tap event handlers 88 | final GestureTapCallback? onTap; 89 | 90 | /// Row right click handler, won't be called if tapped cell has any tap event handlers 91 | final GestureTapCallback? onSecondaryTap; 92 | 93 | /// Row right mouse down handler, won't be called if tapped cell has any tap event handlers 94 | final GestureTapDownCallback? onSecondaryTapDown; 95 | 96 | /// Row double tap handler, won't be called if tapped cell has any tap event handlers 97 | final GestureTapCallback? onDoubleTap; 98 | 99 | // /// Row long press handler, won't be called if tapped cell has any tap event handlers 100 | // final GestureLongPressCallback? onLongPress; 101 | } 102 | 103 | /// In-place replacement of standard [DataTable] widget, mimics it API. 104 | /// Has the header row always fixed and core of the table (with data rows) 105 | /// scrollable and stretching to max width/height of it's container. 106 | /// By using [DataColumn2] instead of [DataColumn] it is possible to control 107 | /// relative column sizes (setting them to S, M and L). [DataRow2] provides 108 | /// row-level tap event handlers. 109 | class DataTable2 extends DataTable { 110 | DataTable2({ 111 | super.key, 112 | required super.columns, 113 | super.sortColumnIndex, 114 | super.sortAscending = true, 115 | super.onSelectAll, 116 | super.decoration, 117 | super.dataRowColor, 118 | super.dataRowHeight, 119 | super.dataTextStyle, 120 | super.headingRowColor, 121 | this.fixedColumnsColor, 122 | this.fixedCornerColor, 123 | super.headingRowHeight, 124 | super.headingTextStyle, 125 | super.horizontalMargin, 126 | super.checkboxHorizontalMargin, 127 | this.bottomMargin, 128 | super.columnSpacing, 129 | super.showCheckboxColumn = true, 130 | super.showBottomBorder = false, 131 | super.dividerThickness, 132 | this.minWidth, 133 | super.dataRowMaxHeight, 134 | super.dataRowMinHeight, 135 | this.scrollController, 136 | this.horizontalScrollController, 137 | this.isVerticalScrollBarVisible, 138 | this.isHorizontalScrollBarVisible, 139 | this.empty, 140 | this.border, 141 | this.smRatio = 0.67, 142 | this.fixedTopRows = 1, 143 | this.fixedLeftColumns = 0, 144 | this.lmRatio = 1.2, 145 | this.sortArrowAnimationDuration = const Duration(milliseconds: 150), 146 | this.sortArrowIcon = Icons.arrow_upward, 147 | required super.rows, 148 | }) : assert(fixedLeftColumns >= 0), 149 | assert(fixedTopRows >= 0) { 150 | // // Fix for #111, syncrhonize scroll position for left fixed column with core 151 | // // Works fine if there's scrollCongtroller provided externally, allows to avoid jumping 152 | // _leftColumnVerticalContoller = ScrollController( 153 | // initialScrollOffset: _coreVerticalController.positions.isNotEmpty 154 | // ? _coreVerticalController.offset 155 | // : 0.0); 156 | } 157 | 158 | static final LocalKey _headingRowKey = UniqueKey(); 159 | 160 | void _handleSelectAll(bool? checked, bool someChecked) { 161 | // If some checkboxes are checked, all checkboxes are selected. Otherwise, 162 | // use the new checked value but default to false if it's null. 163 | final bool effectiveChecked = someChecked || (checked ?? false); 164 | if (onSelectAll != null) { 165 | onSelectAll!(effectiveChecked); 166 | } else { 167 | for (final DataRow row in rows) { 168 | if (row.onSelectChanged != null && row.selected != effectiveChecked) { 169 | row.onSelectChanged!(effectiveChecked); 170 | } 171 | } 172 | } 173 | } 174 | 175 | /// The default height of the heading row. 176 | static const double _headingRowHeight = 56.0; 177 | 178 | /// The default horizontal margin between the edges of the table and the content 179 | /// in the first and last cells of each row. 180 | static const double _horizontalMargin = 24.0; 181 | 182 | /// The default horizontal margin between the contents of each data column. 183 | static const double _columnSpacing = 56.0; 184 | 185 | /// The default padding between the heading content and sort arrow. 186 | static const double _sortArrowPadding = 2.0; 187 | 188 | /// The default divider thickness. 189 | static const double _dividerThickness = 1.0; 190 | 191 | /// When changing sort direction an arrow icon in the header is rotated clockwise. 192 | /// The value defines the duration of the rotation animation. 193 | /// If not set, the default animation duration is 150 ms. 194 | final Duration sortArrowAnimationDuration; 195 | 196 | /// Icon to be displayed when sorting is applied to a column. 197 | /// If not set, the default icon is [Icons.arrow_upward] 198 | final IconData sortArrowIcon; 199 | 200 | /// If set, the table will stop shrinking below the threshold and provide 201 | /// horizontal scrolling. Useful for the cases with narrow screens (e.g. portrait phone orientation) 202 | /// and lots of columns (that get messed with little space) 203 | final double? minWidth; 204 | 205 | /// If set the table will have empty space added after the the last row and allow scroll the 206 | /// core of the table higher (e.g. if you would like to have iOS navigation UI at the bottom overlapping the table and 207 | /// have the ability to slightly scroll up the bototm row to avoid the obstruction) 208 | final double? bottomMargin; 209 | 210 | /// Exposes scroll controller of the SingleChildScrollView that makes data rows vertically scrollable 211 | final ScrollController? scrollController; 212 | 213 | /// Exposes scroll controller of the SingleChildScrollView that makes data rows horizontally scrollable 214 | final ScrollController? horizontalScrollController; 215 | 216 | /// Determines whether the vertical scroll bar is visible, for iOS takes value from scrollbarTheme when null 217 | final bool? isVerticalScrollBarVisible; 218 | 219 | /// Determines whether the horizontal scroll bar is visible, for iOS takes value from scrollbarTheme when null 220 | final bool? isHorizontalScrollBarVisible; 221 | 222 | /// Placeholder widget which is displayed whenever the data rows are empty. 223 | /// The widget will be displayed below column 224 | final Widget? empty; 225 | 226 | /// Set vertical and horizontal borders between cells, as well as outside borders around table. 227 | @override 228 | // keep field in order to keep doc 229 | // ignore: overridden_fields 230 | final TableBorder? border; 231 | 232 | /// Determines ratio of Small column's width to Medium column's width. 233 | /// I.e. 0.5 means that Small column is twice narower than Medium column. 234 | final double smRatio; 235 | 236 | /// Determines ratio of Large column's width to Medium column's width. 237 | /// I.e. 2.0 means that Large column is twice wider than Medium column. 238 | final double lmRatio; 239 | 240 | /// The number of sticky rows fixed at the top of the table. 241 | /// The heading row is counted/included. 242 | /// By defult the value is 1 which means header row is fixed. 243 | /// Set to 0 in order to unstick the header, 244 | /// set to >1 in order to fix data rows 245 | /// (i.e. in order to fix both header and the first data row use value of 2) 246 | final int fixedTopRows; 247 | 248 | /// Number of sticky columns fixed at the left side of the table. 249 | /// Check box column (if enabled) is also counted 250 | final int fixedLeftColumns; 251 | 252 | /// Backgound color of the sticky columns fixed via [fixedLeftColumns]. 253 | /// Note: unlike data rows which can change their colors depending on material state (e.g. selected, hovered) 254 | /// this color is static and doesn't repond to state change 255 | /// Note: to change background color of fixed data rows use [DataTable2.headingRowColor] 256 | final Color? fixedColumnsColor; 257 | 258 | /// Backgound color of the top left corner which is fixed whenere both [fixedTopRows] 259 | /// and [fixedLeftColumns] are greater than 0 260 | /// Note: unlike data rows which can change their colors depending on material state (e.g. selected, hovered) 261 | /// this color is static and doesn't repond to state change 262 | /// Note: to change background color of fixed data rows use [DataTable2.headingRowColor] 263 | final Color? fixedCornerColor; 264 | 265 | (double, double) getMinMaxRowHeight(DataTableThemeData dataTableTheme) { 266 | final double effectiveDataRowMinHeight = dataRowMinHeight ?? 267 | dataTableTheme.dataRowMinHeight ?? 268 | dataTableTheme.dataRowMinHeight ?? 269 | kMinInteractiveDimension; 270 | final double effectiveDataRowMaxHeight = dataRowMaxHeight ?? 271 | dataTableTheme.dataRowMaxHeight ?? 272 | dataTableTheme.dataRowMaxHeight ?? 273 | kMinInteractiveDimension; 274 | 275 | return (effectiveDataRowMinHeight, effectiveDataRowMaxHeight); 276 | } 277 | 278 | Widget _buildCheckbox( 279 | {required BuildContext context, 280 | required bool? checked, 281 | required VoidCallback? onRowTap, 282 | required ValueChanged? onCheckboxChanged, 283 | required MaterialStateProperty? overlayColor, 284 | required bool tristate, 285 | required double? rowHeight}) { 286 | final DataTableThemeData dataTableTheme = DataTableTheme.of(context); 287 | 288 | final double effectiveHorizontalMargin = horizontalMargin ?? 289 | dataTableTheme.horizontalMargin ?? 290 | _horizontalMargin; 291 | 292 | final (effectiveDataRowMinHeight, effectiveDataRowMaxHeight) = 293 | getMinMaxRowHeight(dataTableTheme); 294 | 295 | Widget wrapInContainer(Widget child) => Container( 296 | constraints: BoxConstraints( 297 | minHeight: rowHeight ?? effectiveDataRowMinHeight, 298 | maxHeight: rowHeight ?? effectiveDataRowMaxHeight), 299 | padding: EdgeInsetsDirectional.only( 300 | start: checkboxHorizontalMargin ?? effectiveHorizontalMargin, 301 | end: (checkboxHorizontalMargin ?? effectiveHorizontalMargin) / 2.0, 302 | ), 303 | child: child); 304 | 305 | Widget contents = Semantics( 306 | container: true, 307 | child: wrapInContainer(Center( 308 | child: Checkbox( 309 | value: checked, 310 | onChanged: onCheckboxChanged, 311 | tristate: tristate, 312 | ), 313 | )), 314 | ); 315 | if (onRowTap != null) { 316 | contents = TableRowInkWell( 317 | onTap: onRowTap, 318 | overlayColor: overlayColor, 319 | child: contents, 320 | ); 321 | } 322 | 323 | return contents; 324 | } 325 | 326 | Widget _buildHeadingCell( 327 | {required BuildContext context, 328 | required EdgeInsetsGeometry padding, 329 | required Widget label, 330 | required String? tooltip, 331 | required bool numeric, 332 | required VoidCallback? onSort, 333 | required bool sorted, 334 | required bool ascending, 335 | required double effectiveHeadingRowHeight, 336 | required MaterialStateProperty? overlayColor}) { 337 | final ThemeData themeData = Theme.of(context); 338 | label = Row( 339 | textDirection: numeric ? TextDirection.rtl : null, 340 | children: [ 341 | Flexible(child: label), 342 | if (onSort != null) ...[ 343 | _SortArrow( 344 | visible: sorted, 345 | up: sorted ? ascending : null, 346 | duration: sortArrowAnimationDuration, 347 | sortArrowIcon: sortArrowIcon, 348 | ), 349 | const SizedBox(width: _sortArrowPadding), 350 | ], 351 | ], 352 | ); 353 | 354 | final TextStyle effectiveHeadingTextStyle = headingTextStyle ?? 355 | themeData.dataTableTheme.headingTextStyle ?? 356 | themeData.textTheme.titleSmall!; 357 | 358 | label = Container( 359 | padding: padding, 360 | height: effectiveHeadingRowHeight, 361 | alignment: 362 | numeric ? Alignment.centerRight : AlignmentDirectional.centerStart, 363 | child: AnimatedDefaultTextStyle( 364 | style: effectiveHeadingTextStyle, 365 | softWrap: false, 366 | duration: sortArrowAnimationDuration, 367 | child: label, 368 | ), 369 | ); 370 | if (tooltip != null) { 371 | label = Tooltip( 372 | message: tooltip, 373 | child: label, 374 | ); 375 | } 376 | 377 | label = InkWell( 378 | onTap: onSort, 379 | overlayColor: overlayColor, 380 | child: label, 381 | ); 382 | return label; 383 | } 384 | 385 | Widget _buildDataCell( 386 | {required BuildContext context, 387 | required EdgeInsetsGeometry padding, 388 | required double? specificRowHeight, 389 | required Widget label, 390 | required bool numeric, 391 | required bool placeholder, 392 | required bool showEditIcon, 393 | required GestureTapCallback? onTap, 394 | required GestureTapCallback? onDoubleTap, 395 | required GestureLongPressCallback? onLongPress, 396 | required GestureTapDownCallback? onTapDown, 397 | required GestureTapCancelCallback? onTapCancel, 398 | required GestureTapCallback? onRowTap, 399 | required GestureTapCallback? onRowDoubleTap, 400 | required GestureLongPressCallback? onRowLongPress, 401 | required GestureTapCallback? onRowSecondaryTap, 402 | required GestureTapDownCallback? onRowSecondaryTapDown, 403 | required VoidCallback? onSelectChanged, 404 | required MaterialStateProperty? overlayColor}) { 405 | final ThemeData themeData = Theme.of(context); 406 | final DataTableThemeData dataTableTheme = DataTableTheme.of(context); 407 | 408 | if (showEditIcon) { 409 | const Widget icon = Icon(Icons.edit, size: 18.0); 410 | label = Expanded(child: label); 411 | label = Row( 412 | textDirection: numeric ? TextDirection.rtl : null, 413 | children: [label, icon], 414 | ); 415 | } 416 | 417 | final TextStyle effectiveDataTextStyle = dataTextStyle ?? 418 | dataTableTheme.dataTextStyle ?? 419 | themeData.dataTableTheme.dataTextStyle ?? 420 | themeData.textTheme.bodyMedium!; 421 | 422 | final (effectiveDataRowMinHeight, effectiveDataRowMaxHeight) = 423 | getMinMaxRowHeight(dataTableTheme); 424 | 425 | label = Container( 426 | padding: padding, 427 | constraints: BoxConstraints( 428 | minHeight: specificRowHeight ?? effectiveDataRowMinHeight, 429 | maxHeight: specificRowHeight ?? effectiveDataRowMaxHeight), 430 | alignment: 431 | numeric ? Alignment.centerRight : AlignmentDirectional.centerStart, 432 | child: DefaultTextStyle( 433 | style: effectiveDataTextStyle.copyWith( 434 | color: placeholder 435 | ? effectiveDataTextStyle.color!.withOpacity(0.6) 436 | : null, 437 | ), 438 | child: DropdownButtonHideUnderline(child: label), 439 | ), 440 | ); 441 | 442 | // Wrap label with InkResponse if there're cell or row level tap events 443 | if (onTap != null || 444 | onDoubleTap != null || 445 | onLongPress != null || 446 | onTapDown != null || 447 | onTapCancel != null) { 448 | // cell level 449 | label = InkWell( 450 | onTap: () { 451 | onTap?.call(); 452 | onRowTap?.call(); 453 | }, 454 | onDoubleTap: () { 455 | onDoubleTap?.call(); 456 | onRowDoubleTap?.call(); 457 | }, 458 | onLongPress: () { 459 | onLongPress?.call(); 460 | onRowLongPress?.call(); 461 | }, 462 | onTapDown: onTapDown, 463 | onTapCancel: onTapCancel, 464 | // Also add row level events to cells 465 | onSecondaryTap: onRowSecondaryTap, 466 | onSecondaryTapDown: onRowSecondaryTapDown, 467 | overlayColor: overlayColor, 468 | child: label, 469 | ); 470 | } else if (onSelectChanged != null || 471 | onRowTap != null || 472 | onRowDoubleTap != null || 473 | onRowLongPress != null || 474 | onRowSecondaryTap != null || 475 | onRowSecondaryTapDown != null) { 476 | // row level 477 | label = TableRowInkWell( 478 | onTap: onRowTap ?? onSelectChanged, 479 | onDoubleTap: onRowDoubleTap, 480 | onLongPress: onRowLongPress, 481 | onSecondaryTap: onRowSecondaryTap, 482 | onSecondaryTapDown: onRowSecondaryTapDown, 483 | overlayColor: overlayColor, 484 | child: label, 485 | ); 486 | } 487 | return label; 488 | } 489 | 490 | @override 491 | Widget build(BuildContext context) { 492 | var sw = Stopwatch(); 493 | sw.start(); 494 | // assert(!_debugInteractive || debugCheckHasMaterial(context)); 495 | assert(debugCheckHasMaterial(context)); 496 | 497 | final theme = Theme.of(context); 498 | final effectiveHeadingRowColor = 499 | headingRowColor ?? theme.dataTableTheme.headingRowColor; 500 | final effectiveDataRowColor = 501 | dataRowColor ?? theme.dataTableTheme.dataRowColor; 502 | final defaultRowColor = MaterialStateProperty.resolveWith( 503 | (Set states) { 504 | if (states.contains(MaterialState.selected)) { 505 | return theme.colorScheme.primary.withOpacity(0.08); 506 | } 507 | return null; 508 | }, 509 | ); 510 | final anyRowSelectable = 511 | rows.any((DataRow row) => row.onSelectChanged != null); 512 | final displayCheckboxColumn = showCheckboxColumn && anyRowSelectable; 513 | final rowsWithCheckbox = displayCheckboxColumn 514 | ? rows.where((DataRow row) => row.onSelectChanged != null) 515 | : []; 516 | final rowsChecked = rowsWithCheckbox.where((DataRow row) => row.selected); 517 | final allChecked = 518 | displayCheckboxColumn && rowsChecked.length == rowsWithCheckbox.length; 519 | final anyChecked = displayCheckboxColumn && rowsChecked.isNotEmpty; 520 | final someChecked = anyChecked && !allChecked; 521 | final effectiveHorizontalMargin = horizontalMargin ?? 522 | theme.dataTableTheme.horizontalMargin ?? 523 | _horizontalMargin; 524 | final effectiveColumnSpacing = 525 | columnSpacing ?? theme.dataTableTheme.columnSpacing ?? _columnSpacing; 526 | 527 | final double effectiveHeadingRowHeight = headingRowHeight ?? 528 | theme.dataTableTheme.headingRowHeight ?? 529 | _headingRowHeight; 530 | 531 | final tableColumnWidths = List.filled( 532 | columns.length + (displayCheckboxColumn ? 1 : 0), 533 | const _NullTableColumnWidth()); 534 | 535 | final headingRow = _buildHeadingRow( 536 | context, theme, effectiveHeadingRowColor, tableColumnWidths.length); 537 | 538 | final actualFixedRows = 539 | math.max(0, rows.isEmpty ? 0 : math.min(fixedTopRows, rows.length + 1)); 540 | final actualFixedColumns = math.max( 541 | 0, 542 | rows.isEmpty 543 | ? 0 544 | : math.min(fixedLeftColumns, 545 | columns.length + (showCheckboxColumn ? 1 : 0))); 546 | 547 | List? coreRows = rows.isEmpty || 548 | actualFixedColumns >= columns.length + (showCheckboxColumn ? 1 : 0) 549 | ? null 550 | : _buildTableRows( 551 | anyRowSelectable, 552 | effectiveDataRowColor, 553 | context, 554 | theme, 555 | tableColumnWidths.length - actualFixedColumns, 556 | defaultRowColor, 557 | actualFixedRows == 0 558 | ? _buildHeadingRow(context, theme, effectiveHeadingRowColor, 559 | tableColumnWidths.length - actualFixedColumns) 560 | : null, 561 | actualFixedRows > 0 ? actualFixedRows - 1 : 0); 562 | 563 | List? fixedColumnsRows = rows.isEmpty 564 | ? null 565 | : actualFixedColumns > 0 566 | ? (actualFixedRows < 1 567 | ? [ 568 | _buildHeadingRow( 569 | context, 570 | theme, 571 | fixedColumnsColor != null 572 | ? MaterialStatePropertyAll(fixedColumnsColor) 573 | : effectiveHeadingRowColor, 574 | actualFixedColumns), 575 | ..._buildTableRows( 576 | anyRowSelectable, 577 | fixedColumnsColor != null 578 | ? MaterialStatePropertyAll(fixedColumnsColor) 579 | : effectiveDataRowColor, 580 | context, 581 | theme, 582 | actualFixedColumns, 583 | defaultRowColor, 584 | null, 585 | 0, 586 | 0, 587 | true) 588 | ] 589 | : _buildTableRows( 590 | anyRowSelectable, 591 | fixedColumnsColor != null 592 | ? MaterialStatePropertyAll(fixedColumnsColor) 593 | : effectiveDataRowColor, 594 | context, 595 | theme, 596 | actualFixedColumns, 597 | defaultRowColor, 598 | null, 599 | actualFixedRows - 1, 600 | 0, 601 | true)) 602 | : null; 603 | 604 | List? fixedRows = actualFixedRows > 0 605 | ? (actualFixedRows == 1 606 | ? [ 607 | _buildHeadingRow( 608 | context, 609 | theme, 610 | headingRowColor ?? effectiveHeadingRowColor, 611 | tableColumnWidths.length - actualFixedColumns) 612 | ] 613 | : [ 614 | _buildHeadingRow( 615 | context, 616 | theme, 617 | headingRowColor ?? effectiveHeadingRowColor, 618 | tableColumnWidths.length - actualFixedColumns), 619 | ..._buildTableRows( 620 | anyRowSelectable, 621 | headingRowColor ?? effectiveDataRowColor, 622 | context, 623 | theme, 624 | tableColumnWidths.length - actualFixedColumns, 625 | defaultRowColor, 626 | null, 627 | 0, 628 | actualFixedRows - 1, 629 | true) 630 | ]) 631 | : null; 632 | 633 | List? fixedCornerRows = 634 | actualFixedColumns > 0 && actualFixedRows > 0 635 | ? (actualFixedRows == 1 636 | ? [ 637 | _buildHeadingRow( 638 | context, 639 | theme, 640 | fixedCornerColor != null 641 | ? MaterialStatePropertyAll(fixedCornerColor) 642 | : effectiveHeadingRowColor, 643 | actualFixedColumns) 644 | ] 645 | : [ 646 | _buildHeadingRow( 647 | context, 648 | theme, 649 | fixedCornerColor != null 650 | ? MaterialStatePropertyAll(fixedCornerColor) 651 | : effectiveHeadingRowColor, 652 | actualFixedColumns), 653 | ..._buildTableRows( 654 | anyRowSelectable, 655 | fixedCornerColor != null 656 | ? MaterialStatePropertyAll(fixedCornerColor) 657 | : effectiveDataRowColor, 658 | context, 659 | theme, 660 | actualFixedColumns, 661 | defaultRowColor, 662 | null, 663 | 0, 664 | actualFixedRows - 1, 665 | true) 666 | ]) 667 | : null; 668 | 669 | double checkBoxWidth = _addCheckBoxes( 670 | displayCheckboxColumn, 671 | effectiveHorizontalMargin, 672 | tableColumnWidths, 673 | headingRow, 674 | effectiveHeadingRowHeight, 675 | context, 676 | someChecked, 677 | allChecked, 678 | coreRows, 679 | fixedRows, 680 | fixedCornerRows, 681 | fixedColumnsRows, 682 | rows, 683 | actualFixedRows, 684 | effectiveDataRowColor); 685 | 686 | var builder = LayoutBuilder(builder: (context, constraints) { 687 | return SyncedScrollControllers( 688 | scrollController: scrollController, 689 | sc12toSc11Position: true, 690 | horizontalScrollController: horizontalScrollController, 691 | sc22toSc21Position: true, 692 | builder: (context, sc11, sc12, sc21, sc22) { 693 | var coreVerticalController = sc11; 694 | var leftColumnVerticalContoller = sc12; 695 | var coreHorizontalController = sc21; 696 | var fixedRowsHorizontalController = sc22; 697 | 698 | var displayColumnIndex = 0; 699 | 700 | // size & build checkboxes in heading and leftmost column 701 | // to be substracted from total width available to columns 702 | 703 | if (checkBoxWidth > 0) displayColumnIndex += 1; 704 | 705 | // size data columns 706 | final widths = _calculateDataColumnSizes( 707 | constraints, checkBoxWidth, effectiveHorizontalMargin); 708 | 709 | // File empty cells in created rows with actual widgets 710 | for (int dataColumnIndex = 0; 711 | dataColumnIndex < columns.length; 712 | dataColumnIndex++) { 713 | final DataColumn column = columns[dataColumnIndex]; 714 | 715 | final double paddingStart; 716 | if (dataColumnIndex == 0 && displayCheckboxColumn) { 717 | paddingStart = effectiveHorizontalMargin / 2.0; 718 | } else if (dataColumnIndex == 0 && !displayCheckboxColumn) { 719 | paddingStart = effectiveHorizontalMargin; 720 | } else { 721 | paddingStart = effectiveColumnSpacing / 2.0; 722 | } 723 | 724 | final double paddingEnd; 725 | if (dataColumnIndex == columns.length - 1) { 726 | paddingEnd = effectiveHorizontalMargin; 727 | } else { 728 | paddingEnd = effectiveColumnSpacing / 2.0; 729 | } 730 | 731 | final EdgeInsetsDirectional padding = EdgeInsetsDirectional.only( 732 | start: paddingStart, 733 | end: paddingEnd, 734 | ); 735 | 736 | tableColumnWidths[displayColumnIndex] = 737 | FixedColumnWidth(widths[dataColumnIndex]); 738 | 739 | var h = _buildHeadingCell( 740 | context: context, 741 | padding: padding, 742 | effectiveHeadingRowHeight: effectiveHeadingRowHeight, 743 | label: column.label, 744 | tooltip: column.tooltip, 745 | numeric: column.numeric, 746 | onSort: column.onSort != null 747 | ? () => column.onSort!(dataColumnIndex, 748 | sortColumnIndex != dataColumnIndex || !sortAscending) 749 | : null, 750 | sorted: dataColumnIndex == sortColumnIndex, 751 | ascending: sortAscending, 752 | overlayColor: effectiveHeadingRowColor); 753 | 754 | headingRow.children[displayColumnIndex] = 755 | h; // heading row alone is used to display table header should there be no data rows 756 | 757 | if (displayColumnIndex < actualFixedColumns) { 758 | if (actualFixedRows < 1) { 759 | fixedColumnsRows![0].children[displayColumnIndex] = h; 760 | } else if (actualFixedRows > 0) { 761 | fixedCornerRows![0].children[displayColumnIndex] = h; 762 | } 763 | } else { 764 | if (actualFixedRows < 1 && coreRows != null) { 765 | coreRows[0] 766 | .children[displayColumnIndex - actualFixedColumns] = h; 767 | } else if (actualFixedRows > 0) { 768 | fixedRows![0] 769 | .children[displayColumnIndex - actualFixedColumns] = h; 770 | } 771 | } 772 | 773 | var rowIndex = 0; 774 | var skipRows = actualFixedRows == 1 775 | ? 0 776 | : actualFixedRows > 1 777 | ? actualFixedRows - 1 778 | : -1; 779 | 780 | for (final DataRow row in rows) { 781 | final DataCell cell = row.cells[dataColumnIndex]; 782 | 783 | var c = _buildDataCell( 784 | context: context, 785 | padding: padding, 786 | specificRowHeight: 787 | row is DataRow2 ? row.specificRowHeight : null, 788 | label: cell.child, 789 | numeric: column.numeric, 790 | placeholder: cell.placeholder, 791 | showEditIcon: cell.showEditIcon, 792 | onTap: cell.onTap, 793 | onDoubleTap: cell.onDoubleTap, 794 | onLongPress: cell.onLongPress, 795 | onTapDown: cell.onTapDown, 796 | onTapCancel: cell.onTapCancel, 797 | onRowTap: row is DataRow2 ? row.onTap : null, 798 | onRowDoubleTap: row is DataRow2 ? row.onDoubleTap : null, 799 | onRowLongPress: row.onLongPress, 800 | onRowSecondaryTap: 801 | row is DataRow2 ? row.onSecondaryTap : null, 802 | onRowSecondaryTapDown: 803 | row is DataRow2 ? row.onSecondaryTapDown : null, 804 | onSelectChanged: row.onSelectChanged != null 805 | ? () => row.onSelectChanged!(!row.selected) 806 | : null, 807 | overlayColor: row.color ?? effectiveDataRowColor); 808 | 809 | if (displayColumnIndex < actualFixedColumns) { 810 | if (rowIndex + 1 < actualFixedRows) { 811 | fixedCornerRows![rowIndex + 1] 812 | .children[displayColumnIndex] = c; 813 | } else { 814 | fixedColumnsRows![rowIndex - skipRows] 815 | .children[displayColumnIndex] = c; 816 | } 817 | } else { 818 | if (rowIndex + 1 < actualFixedRows) { 819 | fixedRows![rowIndex + 1] 820 | .children[displayColumnIndex - actualFixedColumns] = c; 821 | } else { 822 | coreRows![rowIndex - skipRows] 823 | .children[displayColumnIndex - actualFixedColumns] = c; 824 | } 825 | } 826 | 827 | rowIndex += 1; 828 | } 829 | displayColumnIndex += 1; 830 | } 831 | 832 | var widthsAsMap = tableColumnWidths.asMap(); 833 | Map? leftWidthsAsMap = actualFixedColumns > 0 834 | ? tableColumnWidths.take(actualFixedColumns).toList().asMap() 835 | : null; 836 | Map? rightWidthsAsMap = actualFixedColumns > 837 | 0 838 | ? tableColumnWidths.skip(actualFixedColumns).toList().asMap() 839 | : null; 840 | 841 | bool isRowsEmpty(List? rows) { 842 | return rows == null || rows.isEmpty || rows[0].children.isEmpty; 843 | } 844 | 845 | var coreTable = Table( 846 | columnWidths: 847 | actualFixedColumns > 0 ? rightWidthsAsMap : widthsAsMap, 848 | children: coreRows ?? [], 849 | border: border == null 850 | ? null 851 | : isRowsEmpty(fixedRows) && isRowsEmpty(fixedColumnsRows) 852 | ? border 853 | : !isRowsEmpty(fixedRows) && 854 | !isRowsEmpty(fixedColumnsRows) 855 | ? TableBorder( 856 | //top: border!.top, 857 | //left: border!.left, 858 | right: border!.right, 859 | bottom: border!.bottom, 860 | verticalInside: border!.verticalInside, 861 | horizontalInside: border!.horizontalInside, 862 | borderRadius: border!.borderRadius) 863 | : isRowsEmpty(fixedRows) 864 | ? TableBorder( 865 | top: border!.top, 866 | //left: border!.left, 867 | right: border!.right, 868 | bottom: border!.bottom, 869 | verticalInside: border!.verticalInside, 870 | horizontalInside: border!.horizontalInside, 871 | borderRadius: border!.borderRadius) 872 | : TableBorder( 873 | //top: border!.top, 874 | left: border!.left, 875 | right: border!.right, 876 | bottom: border!.bottom, 877 | verticalInside: border!.verticalInside, 878 | horizontalInside: border!.horizontalInside, 879 | borderRadius: border!.borderRadius)); 880 | 881 | Table? fixedRowsTabel; 882 | Table? fixedColumnsTable; 883 | Table? fixedTopLeftCornerTable; 884 | Widget? fixedColumnAndCornerCol; 885 | Widget? fixedRowsAndCoreCol; 886 | 887 | if (rows.isNotEmpty) { 888 | if (fixedRows != null && 889 | !isRowsEmpty(fixedRows) && 890 | actualFixedColumns < 891 | columns.length + (showCheckboxColumn ? 1 : 0)) { 892 | fixedRowsTabel = Table( 893 | columnWidths: 894 | actualFixedColumns > 0 ? rightWidthsAsMap : widthsAsMap, 895 | children: fixedRows, 896 | border: border == null 897 | ? null 898 | : isRowsEmpty(fixedCornerRows) 899 | ? border 900 | : TableBorder( 901 | top: border!.top, 902 | //left: border!.left, 903 | right: border!.right, 904 | bottom: border!.bottom, 905 | verticalInside: border!.verticalInside, 906 | horizontalInside: border!.horizontalInside, 907 | borderRadius: border!.borderRadius)); 908 | } 909 | 910 | if (fixedColumnsRows != null && !isRowsEmpty(fixedColumnsRows)) { 911 | fixedColumnsTable = Table( 912 | columnWidths: leftWidthsAsMap, 913 | children: fixedColumnsRows, 914 | border: border == null 915 | ? null 916 | : isRowsEmpty(fixedCornerRows) 917 | ? border 918 | : TableBorder( 919 | //top: border!.top, 920 | left: border!.left, 921 | right: border!.right, 922 | bottom: border!.bottom, 923 | verticalInside: border!.verticalInside, 924 | horizontalInside: border!.horizontalInside, 925 | borderRadius: border!.borderRadius)); 926 | } 927 | 928 | if (fixedCornerRows != null && !isRowsEmpty(fixedCornerRows)) { 929 | fixedTopLeftCornerTable = Table( 930 | columnWidths: leftWidthsAsMap, 931 | children: fixedCornerRows, 932 | border: border); 933 | } 934 | 935 | Widget addBottomMargin(Table t) => 936 | bottomMargin != null && bottomMargin! > 0 937 | ? Column( 938 | mainAxisSize: MainAxisSize.min, 939 | children: [t, SizedBox(height: bottomMargin!)]) 940 | : t; 941 | 942 | var scrollBarTheme = Theme.of(context).scrollbarTheme; 943 | // flutter/lib/src/material/scrollbar.dart, scrollbar decides whther to create Cupertino or Material scrollbar, Cupertino ignores themes 944 | var isiOS = Theme.of(context).platform == TargetPlatform.iOS; 945 | 946 | // For iOS/Cupertino scrollbar 947 | fixedRowsAndCoreCol = Scrollbar( 948 | thumbVisibility: isHorizontalScrollBarVisible ?? 949 | (isiOS 950 | ? scrollBarTheme.thumbVisibility 951 | ?.resolve({MaterialState.hovered}) 952 | : null), 953 | thickness: (isiOS 954 | ? scrollBarTheme.thickness 955 | ?.resolve({MaterialState.hovered}) 956 | : null), 957 | controller: coreHorizontalController, 958 | child: Column(mainAxisSize: MainAxisSize.min, children: [ 959 | ScrollConfiguration( 960 | behavior: ScrollConfiguration.of(context) 961 | .copyWith(scrollbars: false), 962 | child: SingleChildScrollView( 963 | controller: fixedRowsHorizontalController, 964 | scrollDirection: Axis.horizontal, 965 | child: (fixedRowsTabel != null) 966 | ? fixedRowsTabel 967 | // WOrkaround for a bug when there's no horizontal scrollbar should there be no this SingleChildScrollView. I.e. originally this part was ommited and not scrollable was added to the column if not fixed top row was visible 968 | : SizedBox( 969 | height: 0, 970 | width: widths.fold( 971 | 0, 972 | (previousValue, value) => 973 | previousValue + value), 974 | ))), 975 | Flexible( 976 | fit: FlexFit.tight, 977 | child: Scrollbar( 978 | thumbVisibility: isVerticalScrollBarVisible ?? 979 | (isiOS 980 | ? scrollBarTheme.thumbVisibility 981 | ?.resolve({MaterialState.hovered}) 982 | : null), 983 | thickness: (isiOS 984 | ? scrollBarTheme.thickness 985 | ?.resolve({MaterialState.hovered}) 986 | : null), 987 | controller: coreVerticalController, 988 | child: SingleChildScrollView( 989 | controller: coreVerticalController, 990 | scrollDirection: Axis.vertical, 991 | child: SingleChildScrollView( 992 | controller: coreHorizontalController, 993 | scrollDirection: Axis.horizontal, 994 | child: addBottomMargin(coreTable))))) 995 | ])); 996 | 997 | fixedColumnAndCornerCol = fixedTopLeftCornerTable == null && 998 | fixedColumnsTable == null 999 | ? null 1000 | : Column(mainAxisSize: MainAxisSize.min, children: [ 1001 | if (fixedTopLeftCornerTable != null) 1002 | fixedTopLeftCornerTable, 1003 | if (fixedColumnsTable != null) 1004 | Flexible( 1005 | fit: FlexFit.loose, 1006 | child: ScrollConfiguration( 1007 | behavior: ScrollConfiguration.of(context) 1008 | .copyWith(scrollbars: false), 1009 | child: SingleChildScrollView( 1010 | controller: leftColumnVerticalContoller, 1011 | scrollDirection: Axis.vertical, 1012 | child: addBottomMargin(fixedColumnsTable)))) 1013 | ]); 1014 | } 1015 | 1016 | var completeWidget = Container( 1017 | decoration: decoration ?? theme.dataTableTheme.decoration, 1018 | child: Material( 1019 | type: MaterialType.transparency, 1020 | borderRadius: border?.borderRadius, 1021 | clipBehavior: clipBehavior, 1022 | child: rows.isEmpty 1023 | ? Column(children: [ 1024 | SingleChildScrollView( 1025 | controller: coreHorizontalController, 1026 | scrollDirection: Axis.horizontal, 1027 | child: Table( 1028 | columnWidths: widthsAsMap, 1029 | border: border, 1030 | children: [headingRow])), 1031 | Flexible( 1032 | fit: FlexFit.tight, 1033 | child: empty ?? const SizedBox()) 1034 | ]) 1035 | : Row( 1036 | crossAxisAlignment: CrossAxisAlignment.start, 1037 | children: [ 1038 | if (fixedColumnAndCornerCol != null) 1039 | fixedColumnAndCornerCol, 1040 | if (fixedRowsAndCoreCol != null) 1041 | Flexible( 1042 | fit: FlexFit.tight, 1043 | child: fixedRowsAndCoreCol) 1044 | ], 1045 | ))); 1046 | 1047 | return completeWidget; 1048 | }); 1049 | }); 1050 | 1051 | sw.stop(); 1052 | 1053 | if (dataTableShowLogs && kDebugMode) { 1054 | debugPrint('DataTable2 built: ${sw.elapsedMilliseconds}ms'); 1055 | } 1056 | 1057 | return builder; 1058 | } 1059 | 1060 | double _addCheckBoxes( 1061 | bool displayCheckboxColumn, 1062 | double effectiveHorizontalMargin, 1063 | List tableColumns, 1064 | TableRow headingRow, 1065 | double headingHeight, 1066 | BuildContext context, 1067 | bool someChecked, 1068 | bool allChecked, 1069 | List? coreRows, 1070 | List? fixedRows, 1071 | List? fixedCornerRows, 1072 | List? fixedColumnRows, 1073 | List rows, 1074 | int actualFixedRows, 1075 | MaterialStateProperty? effectiveDataRowColor) { 1076 | double checkBoxWidth = 0; 1077 | 1078 | if (displayCheckboxColumn) { 1079 | checkBoxWidth = effectiveHorizontalMargin + 1080 | Checkbox.width + 1081 | effectiveHorizontalMargin / 2.0; 1082 | tableColumns[0] = FixedColumnWidth(checkBoxWidth); 1083 | 1084 | // Create heading twice, in the heading row used as back-up for the case of no data and any of the xxx_rows table 1085 | headingRow.children[0] = _buildCheckbox( 1086 | context: context, 1087 | checked: someChecked ? null : allChecked, 1088 | onRowTap: null, 1089 | onCheckboxChanged: (bool? checked) => 1090 | _handleSelectAll(checked, someChecked), 1091 | overlayColor: null, 1092 | tristate: true, 1093 | rowHeight: headingHeight); 1094 | 1095 | if (fixedCornerRows != null) { 1096 | fixedCornerRows[0].children[0] = headingRow.children[0]; 1097 | } else if (fixedColumnRows != null) { 1098 | fixedColumnRows[0].children[0] = headingRow.children[0]; 1099 | } else if (fixedRows != null) { 1100 | fixedRows[0].children[0] = headingRow.children[0]; 1101 | } else { 1102 | coreRows![0].children[0] = headingRow.children[0]; 1103 | } 1104 | 1105 | var skipRows = actualFixedRows == 1 1106 | ? 0 1107 | : actualFixedRows > 1 1108 | ? actualFixedRows - 1 1109 | : -1; 1110 | 1111 | var rowIndex = 0; 1112 | for (final DataRow row in rows) { 1113 | var x = _buildCheckbox( 1114 | context: context, 1115 | checked: row.selected, 1116 | onRowTap: () { 1117 | if (row is DataRow2 && row.onTap != null) { 1118 | row.onTap?.call(); 1119 | } else { 1120 | row.onSelectChanged?.call(!row.selected); 1121 | } 1122 | }, 1123 | onCheckboxChanged: row.onSelectChanged, 1124 | overlayColor: row.color ?? effectiveDataRowColor, 1125 | tristate: false, 1126 | rowHeight: rows[rowIndex] is DataRow2 1127 | ? (rows[rowIndex] as DataRow2).specificRowHeight 1128 | : null); 1129 | 1130 | if (fixedCornerRows != null && rowIndex < fixedCornerRows.length - 1) { 1131 | fixedCornerRows[rowIndex + 1].children[0] = x; 1132 | } else if (fixedColumnRows != null) { 1133 | fixedColumnRows[rowIndex - skipRows].children[0] = x; 1134 | } else if (fixedRows != null && rowIndex < fixedRows.length - 1) { 1135 | fixedRows[rowIndex + 1].children[0] = x; 1136 | } else { 1137 | coreRows![rowIndex - skipRows].children[0] = x; 1138 | } 1139 | 1140 | rowIndex += 1; 1141 | } 1142 | } 1143 | return checkBoxWidth; 1144 | } 1145 | 1146 | List _calculateDataColumnSizes(BoxConstraints constraints, 1147 | double checkBoxWidth, double effectiveHorizontalMargin) { 1148 | var totalColAvailableWidth = constraints.maxWidth; 1149 | if (minWidth != null && totalColAvailableWidth < minWidth!) { 1150 | totalColAvailableWidth = minWidth!; 1151 | } 1152 | 1153 | // full margins are added to side column widths when no check box column is 1154 | // present, half-margin added to first data column width is check box column 1155 | // is present and full margin added to the right 1156 | 1157 | totalColAvailableWidth = totalColAvailableWidth - 1158 | checkBoxWidth - 1159 | effectiveHorizontalMargin - 1160 | (checkBoxWidth > 0 1161 | ? effectiveHorizontalMargin / 2 1162 | : effectiveHorizontalMargin); 1163 | 1164 | var columnWidth = totalColAvailableWidth / columns.length; 1165 | var totalColCalculatedWidth = 0.0; 1166 | var totalFixedWidth = columns.fold( 1167 | 0.0, 1168 | (previousValue, element) => 1169 | previousValue + 1170 | (element is DataColumn2 && element.fixedWidth != null 1171 | ? element.fixedWidth! 1172 | : 0.0)); 1173 | 1174 | assert(totalFixedWidth < totalColAvailableWidth, 1175 | "DataTable2, combined width of columns of fixed width is greater than availble parent width. Table will be clipped"); 1176 | 1177 | totalColAvailableWidth = 1178 | math.max(0.0, totalColAvailableWidth - totalFixedWidth); 1179 | 1180 | // adjust column sizes relative to S, M, L 1181 | final widths = List.generate(columns.length, (i) { 1182 | var w = columnWidth; 1183 | var column = columns[i]; 1184 | if (column is DataColumn2) { 1185 | if (column.fixedWidth != null) { 1186 | w = column.fixedWidth!; 1187 | } else if (column.size == ColumnSize.S) { 1188 | w *= smRatio; 1189 | } else if (column.size == ColumnSize.L) { 1190 | w *= lmRatio; 1191 | } 1192 | } 1193 | 1194 | // skip fixed width columns 1195 | if (!(column is DataColumn2 && column.fixedWidth != null)) { 1196 | totalColCalculatedWidth += w; 1197 | } 1198 | return w; 1199 | }); 1200 | 1201 | // scale columns to fit the total lemnght into available width 1202 | 1203 | var ratio = totalColAvailableWidth / totalColCalculatedWidth; 1204 | for (var i = 0; i < widths.length; i++) { 1205 | // skip fixed width column 1206 | if (!(columns[i] is DataColumn2 && 1207 | (columns[i] as DataColumn2).fixedWidth != null)) { 1208 | widths[i] *= ratio; 1209 | } 1210 | } 1211 | 1212 | // add margins to side columns 1213 | if (widths.length == 1) { 1214 | widths[0] = math.max( 1215 | 0, 1216 | widths[0] + 1217 | effectiveHorizontalMargin + 1218 | (checkBoxWidth > 0 1219 | ? effectiveHorizontalMargin / 2 1220 | : effectiveHorizontalMargin)); 1221 | } else if (widths.length > 1) { 1222 | widths[0] = math.max( 1223 | 0, 1224 | widths[0] + 1225 | (checkBoxWidth > 0 1226 | ? effectiveHorizontalMargin / 2 1227 | : effectiveHorizontalMargin)); 1228 | widths[widths.length - 1] = 1229 | math.max(0, widths[widths.length - 1] + effectiveHorizontalMargin); 1230 | } 1231 | return widths; 1232 | } 1233 | 1234 | List _buildTableRows( 1235 | bool anyRowSelectable, 1236 | MaterialStateProperty? effectiveDataRowColor, 1237 | BuildContext context, 1238 | ThemeData theme, 1239 | int numberOfCols, 1240 | MaterialStateProperty defaultRowColor, 1241 | TableRow? headingRow, 1242 | [int skipRows = 0, 1243 | int takeRows = 0, 1244 | bool forceEffectiveDataRowColor = false]) { 1245 | final rowStartIndex = skipRows; 1246 | final List tableRows = List.generate( 1247 | (takeRows <= 0 ? rows.length - skipRows : takeRows) + 1248 | (headingRow == null ? 0 : 1), 1249 | (int index) { 1250 | var actualIndex = headingRow == null ? index : index - 1; 1251 | if (headingRow != null && index == 0) { 1252 | return headingRow; 1253 | } else { 1254 | final bool isSelected = rows[rowStartIndex + actualIndex].selected; 1255 | final bool isDisabled = anyRowSelectable && 1256 | rows[rowStartIndex + actualIndex].onSelectChanged == null; 1257 | final Set states = { 1258 | if (isSelected) MaterialState.selected, 1259 | if (isDisabled) MaterialState.disabled, 1260 | }; 1261 | final Color? resolvedDataRowColor = (forceEffectiveDataRowColor 1262 | ? effectiveDataRowColor 1263 | : (rows[rowStartIndex + actualIndex].color ?? 1264 | effectiveDataRowColor)) 1265 | ?.resolve(states); 1266 | final Color? rowColor = resolvedDataRowColor; 1267 | final BorderSide borderSide = Divider.createBorderSide( 1268 | context, 1269 | width: dividerThickness ?? 1270 | theme.dataTableTheme.dividerThickness ?? 1271 | _dividerThickness, 1272 | ); 1273 | final Border border = showBottomBorder 1274 | ? Border(bottom: borderSide) 1275 | : Border(top: borderSide); 1276 | return TableRow( 1277 | key: rows[rowStartIndex + actualIndex].key, 1278 | decoration: BoxDecoration( 1279 | // Changed standard behaviour to never add border should the thickness be 0 1280 | border: dividerThickness == null || 1281 | (dividerThickness != null && dividerThickness != 0.0) 1282 | ? border 1283 | : null, 1284 | color: rowColor ?? defaultRowColor.resolve(states), 1285 | ), 1286 | children: List.filled( 1287 | numberOfCols <= 0 ? numberOfCols : numberOfCols, 1288 | const _NullWidget()), 1289 | ); 1290 | } 1291 | }, 1292 | ); 1293 | 1294 | return tableRows; 1295 | } 1296 | 1297 | TableRow _buildHeadingRow( 1298 | BuildContext context, 1299 | ThemeData theme, 1300 | MaterialStateProperty? effectiveHeadingRowColor, 1301 | int numberOfCols) { 1302 | var headingRow = TableRow( 1303 | key: _headingRowKey, 1304 | decoration: BoxDecoration( 1305 | // Changed standard behaviour to never add border should the thickness be 0 1306 | border: showBottomBorder && 1307 | border == null && 1308 | (dividerThickness == null || 1309 | (dividerThickness != null && dividerThickness != 0.0)) 1310 | ? Border( 1311 | bottom: Divider.createBorderSide( 1312 | context, 1313 | width: dividerThickness ?? 1314 | theme.dataTableTheme.dividerThickness ?? 1315 | _dividerThickness, 1316 | )) 1317 | : null, 1318 | color: effectiveHeadingRowColor?.resolve({}), 1319 | ), 1320 | children: List.filled(numberOfCols, const _NullWidget()), 1321 | ); 1322 | return headingRow; 1323 | } 1324 | } 1325 | 1326 | class _SortArrow extends StatefulWidget { 1327 | const _SortArrow({ 1328 | required this.visible, 1329 | required this.up, 1330 | required this.duration, 1331 | required this.sortArrowIcon, 1332 | }); 1333 | 1334 | final bool visible; 1335 | 1336 | final bool? up; 1337 | 1338 | final Duration duration; 1339 | 1340 | final IconData sortArrowIcon; 1341 | 1342 | @override 1343 | _SortArrowState createState() => _SortArrowState(); 1344 | } 1345 | 1346 | class _SortArrowState extends State<_SortArrow> with TickerProviderStateMixin { 1347 | late AnimationController _opacityController; 1348 | late Animation _opacityAnimation; 1349 | 1350 | late AnimationController _orientationController; 1351 | late Animation _orientationAnimation; 1352 | double _orientationOffset = 0.0; 1353 | 1354 | bool? _up; 1355 | 1356 | static final Animatable _turnTween = 1357 | Tween(begin: 0.0, end: math.pi) 1358 | .chain(CurveTween(curve: Curves.easeIn)); 1359 | 1360 | @override 1361 | void initState() { 1362 | super.initState(); 1363 | _up = widget.up; 1364 | _opacityAnimation = CurvedAnimation( 1365 | parent: _opacityController = AnimationController( 1366 | duration: widget.duration, 1367 | vsync: this, 1368 | ), 1369 | curve: Curves.fastOutSlowIn, 1370 | )..addListener(_rebuild); 1371 | _opacityController.value = widget.visible ? 1.0 : 0.0; 1372 | _orientationController = AnimationController( 1373 | duration: widget.duration, 1374 | vsync: this, 1375 | ); 1376 | _orientationAnimation = _orientationController.drive(_turnTween) 1377 | ..addListener(_rebuild) 1378 | ..addStatusListener(_resetOrientationAnimation); 1379 | if (widget.visible) _orientationOffset = widget.up! ? 0.0 : math.pi; 1380 | } 1381 | 1382 | void _rebuild() { 1383 | setState(() { 1384 | // The animations changed, so we need to rebuild. 1385 | }); 1386 | } 1387 | 1388 | void _resetOrientationAnimation(AnimationStatus status) { 1389 | if (status == AnimationStatus.completed) { 1390 | assert(_orientationAnimation.value == math.pi); 1391 | _orientationOffset += math.pi; 1392 | _orientationController.value = 0.0; 1393 | } 1394 | } 1395 | 1396 | @override 1397 | void didUpdateWidget(_SortArrow oldWidget) { 1398 | super.didUpdateWidget(oldWidget); 1399 | bool skipArrow = false; 1400 | final bool? newUp = widget.up ?? _up; 1401 | if (oldWidget.visible != widget.visible) { 1402 | if (widget.visible && 1403 | (_opacityController.status == AnimationStatus.dismissed)) { 1404 | _orientationController.stop(); 1405 | _orientationController.value = 0.0; 1406 | _orientationOffset = newUp! ? 0.0 : math.pi; 1407 | skipArrow = true; 1408 | } 1409 | if (widget.visible) { 1410 | _opacityController.forward(); 1411 | } else { 1412 | _opacityController.reverse(); 1413 | } 1414 | } 1415 | if ((_up != newUp) && !skipArrow) { 1416 | if (_orientationController.status == AnimationStatus.dismissed) { 1417 | _orientationController.forward(); 1418 | } else { 1419 | _orientationController.reverse(); 1420 | } 1421 | } 1422 | _up = newUp; 1423 | } 1424 | 1425 | @override 1426 | void dispose() { 1427 | _opacityController.dispose(); 1428 | _orientationController.dispose(); 1429 | super.dispose(); 1430 | } 1431 | 1432 | static const double _arrowIconBaselineOffset = -1.5; 1433 | static const double _arrowIconSize = 16.0; 1434 | 1435 | @override 1436 | Widget build(BuildContext context) { 1437 | return Opacity( 1438 | opacity: _opacityAnimation.value, 1439 | child: Transform( 1440 | transform: 1441 | Matrix4.rotationZ(_orientationOffset + _orientationAnimation.value) 1442 | ..setTranslationRaw(0.0, _arrowIconBaselineOffset, 0.0), 1443 | alignment: Alignment.center, 1444 | child: Icon( 1445 | widget.sortArrowIcon, 1446 | size: _arrowIconSize, 1447 | ), 1448 | ), 1449 | ); 1450 | } 1451 | } 1452 | 1453 | // coverage:ignore-start 1454 | class _NullTableColumnWidth extends TableColumnWidth { 1455 | const _NullTableColumnWidth(); 1456 | 1457 | @override 1458 | double maxIntrinsicWidth(Iterable cells, double containerWidth) => 1459 | throw UnimplementedError(); 1460 | 1461 | @override 1462 | double minIntrinsicWidth(Iterable cells, double containerWidth) => 1463 | throw UnimplementedError(); 1464 | } 1465 | 1466 | class _NullWidget extends Widget { 1467 | const _NullWidget(); 1468 | 1469 | @override 1470 | Element createElement() => throw UnimplementedError(); 1471 | } 1472 | // coverage:ignore-end 1473 | 1474 | /// Creates pairs of scroll controllers which can be provided to scrollables and ensure that 1475 | /// offset change in one scrollable scroll the second one (and vice a versa) 1476 | /// There's a bug (or feature) on iOS with bouncing scroll (when it goes out of range) 1477 | /// when scrollable get out of sync while in this bouncing position 1478 | class SyncedScrollControllers extends StatefulWidget { 1479 | const SyncedScrollControllers( 1480 | {super.key, 1481 | required this.builder, 1482 | this.scrollController, 1483 | this.sc12toSc11Position = false, 1484 | this.horizontalScrollController, 1485 | this.sc22toSc21Position = false}); 1486 | 1487 | /// One of the controllers (sc11) won't be created by this widget 1488 | /// but rather use externally provided one 1489 | final ScrollController? scrollController; 1490 | 1491 | /// One of the controllers (sc21) won't be created by this widget 1492 | /// but rather use externally provided one 1493 | final ScrollController? horizontalScrollController; 1494 | 1495 | /// Whether to set sc12 initial offset to the value from sc11 1496 | final bool sc12toSc11Position; 1497 | 1498 | /// Whether to set sc22 initial offset to the value from sc21 1499 | final bool sc22toSc21Position; 1500 | 1501 | /// Positions of 2 pairs of scroll controllers (sc11|sc12 and sc21|sc22) 1502 | /// will be synchronized, attached scrollables will copy the positions 1503 | final Widget Function( 1504 | BuildContext context, 1505 | ScrollController sc11, 1506 | ScrollController sc12, 1507 | ScrollController sc21, 1508 | ScrollController sc22) builder; 1509 | 1510 | @override 1511 | SyncedScrollControllersState createState() => SyncedScrollControllersState(); 1512 | } 1513 | 1514 | class SyncedScrollControllersState extends State { 1515 | ScrollController? _sc11; 1516 | late ScrollController _sc12; 1517 | ScrollController? _sc21; 1518 | late ScrollController _sc22; 1519 | 1520 | final List _listeners = []; 1521 | 1522 | @override 1523 | void initState() { 1524 | super.initState(); 1525 | _initControllers(); 1526 | } 1527 | 1528 | @override 1529 | void didUpdateWidget(SyncedScrollControllers oldWidget) { 1530 | super.didUpdateWidget(oldWidget); 1531 | _disposeOrUnsubscribe(); 1532 | _initControllers(); 1533 | } 1534 | 1535 | @override 1536 | void dispose() { 1537 | _disposeOrUnsubscribe(); 1538 | super.dispose(); 1539 | } 1540 | 1541 | void _initControllers() { 1542 | _doNotReissueJump.clear(); 1543 | var offset = 1544 | _sc11 == null || _sc11!.positions.isEmpty ? 0.0 : _sc11!.offset; 1545 | if (widget.scrollController != null) { 1546 | _sc11 = widget.scrollController!; 1547 | if (_sc11!.positions.isNotEmpty) { 1548 | offset = _sc11!.offset; 1549 | } 1550 | } else { 1551 | _sc11 = ScrollController(); 1552 | } 1553 | 1554 | var horizontalOffset = 1555 | _sc21 == null || _sc21!.positions.isEmpty ? 0.0 : _sc21!.offset; 1556 | if (widget.horizontalScrollController != null) { 1557 | _sc21 = widget.horizontalScrollController!; 1558 | if (_sc21!.positions.isNotEmpty) { 1559 | offset = _sc21!.offset; 1560 | } 1561 | } else { 1562 | _sc21 = ScrollController(); 1563 | } 1564 | 1565 | _sc12 = ScrollController( 1566 | initialScrollOffset: widget.sc12toSc11Position ? offset : 0.0); 1567 | _sc22 = ScrollController( 1568 | initialScrollOffset: 1569 | widget.sc22toSc21Position ? horizontalOffset : 0.0); 1570 | 1571 | _syncScrollControllers(_sc11!, _sc12); 1572 | _syncScrollControllers(_sc21!, _sc22); 1573 | } 1574 | 1575 | void _disposeOrUnsubscribe() { 1576 | if (widget.scrollController == _sc11) { 1577 | _sc11?.removeListener(_listeners[0]); 1578 | } else { 1579 | _sc11?.dispose(); 1580 | } 1581 | _sc12.dispose(); 1582 | 1583 | if (widget.horizontalScrollController == _sc21) { 1584 | _sc21?.removeListener(_listeners[0]); 1585 | } else { 1586 | _sc21?.dispose(); 1587 | } 1588 | _sc22.dispose(); 1589 | 1590 | _listeners.clear(); 1591 | } 1592 | 1593 | final Map _doNotReissueJump = {}; 1594 | 1595 | void _syncScrollControllers(ScrollController sc1, ScrollController sc2) { 1596 | var l = () => _jumpToNoCascade(sc1, sc2); 1597 | sc1.addListener(l); 1598 | _listeners.add(l); 1599 | l = () => _jumpToNoCascade(sc2, sc1); 1600 | sc2.addListener(l); 1601 | } 1602 | 1603 | void _jumpToNoCascade(ScrollController master, ScrollController slave) { 1604 | //print('$master $slave'); 1605 | if (!slave.hasClients || slave.position.outOfRange) { 1606 | return; //outOfRange check for bounce case, bug #113 1607 | } 1608 | if (_doNotReissueJump[master] == null || 1609 | _doNotReissueJump[master]! == false) { 1610 | _doNotReissueJump[slave] = true; 1611 | slave.jumpTo(master.offset); 1612 | } else { 1613 | _doNotReissueJump[master] = false; 1614 | } 1615 | } 1616 | 1617 | @override 1618 | Widget build(BuildContext context) => 1619 | widget.builder(context, _sc11!, _sc12, _sc21!, _sc22); 1620 | } 1621 | -------------------------------------------------------------------------------- /backlog/extension.ts_: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { createOrRefactor } from './createOrRefactor'; 3 | import { OpenAIClient } from "@azure/openai"; 4 | import { explainOrAsk } from './explain'; 5 | 6 | // This method is called when your extension is activated 7 | // Your extension is activated the very first time the command is executed 8 | export function activate(context: vscode.ExtensionContext) { 9 | 10 | // Use the console to output diagnostic information (console.log) and errors (console.error) 11 | // This line of code will only be executed once when your extension is activated 12 | console.log('Congratulations, your extension "cptX" is now active!'); 13 | 14 | 15 | let openAi = getOpenAIApi(); 16 | 17 | vscode.workspace.onDidChangeConfiguration(event => { 18 | let affected = event.affectsConfiguration("cptx.APIKey"); 19 | if (affected) { 20 | openAi = getOpenAIApi(); 21 | } 22 | }); 23 | 24 | context.subscriptions.push(vscode.commands.registerCommand('cptX.createOrRefactor', () => createOrRefactor(openAi, model))); 25 | context.subscriptions.push(vscode.commands.registerCommand('cptX.explainOrAsk', () => explainOrAsk(openAi, model))); 26 | } 27 | 28 | // This method is called when your extension is deactivated 29 | export function deactivate() {} 30 | 31 | function getApiKey(): string { 32 | const config = vscode.workspace.getConfiguration('cptx'); 33 | const apiKey = config.get('APIKey'); 34 | return apiKey ?? ''; 35 | } 36 | 37 | function getAzureSettings(): { apiProvider: string, azureEndpoint: string, azureDeploymentName: string } { 38 | const config = vscode.workspace.getConfiguration('cptx'); 39 | const apiProvider = config.get('apiProvider'); 40 | const azureEndpoint = config.get('AzureEndpoint'); 41 | const azureDeploymentName = config.get('AzureDeploymentName'); 42 | 43 | return { 44 | apiProvider: apiProvider ?? '', 45 | azureEndpoint: azureEndpoint ?? '', 46 | azureDeploymentName: azureDeploymentName ?? '' 47 | } 48 | } 49 | 50 | function getOpenAIApi() { 51 | const key = getApiKey(); 52 | 53 | // check if key is empty and throw 54 | if (key.trim().length === 0) { 55 | throw new Error('OpenAI API key is not set for cptX extension. Please check extension settings and try again.'); 56 | } 57 | 58 | 59 | return new OpenAIApi(new Configuration({ 60 | apiKey: key, 61 | })); 62 | } -------------------------------------------------------------------------------- /backlog/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cptx", 3 | "displayName": "cptX: ChatGPT as copilot", 4 | "description": "Use ChatGPT as your (aslmost)free copilot", 5 | "version": "0.0.7", 6 | "publisher": "MaximSaplin", 7 | "icon": "images/icon.png", 8 | "engines": { 9 | "vscode": "^1.77.0" 10 | }, 11 | "repository": { 12 | "url": "https://github.com/maxim-saplin/cptX" 13 | }, 14 | "categories": [ 15 | "Other" 16 | ], 17 | "keywords": [ 18 | "chatgpt", 19 | "openai", 20 | "gpt3", 21 | "copilot", 22 | "flutter", 23 | "dart", 24 | "c#", 25 | "java", 26 | "javascript", 27 | "typescript", 28 | "ai", 29 | "create", 30 | "refactor" 31 | ], 32 | "activationEvents": [], 33 | "main": "./dist/extension.js", 34 | "contributes": { 35 | "configuration": { 36 | "title": "cptX", 37 | "type": "object", 38 | "properties": { 39 | "cptx.OpenAiApiKey": { 40 | "type": "string", 41 | "markdownDescription": "OpenAI API key from https://platform.openai.com/account/api-keys" 42 | } 43 | } 44 | }, 45 | "commands": [ 46 | { 47 | "command": "cptX.createOrRefactor", 48 | "title": "cptX: Create new OR refactor existing code" 49 | }, 50 | { 51 | "command": "cptX.explainOrAsk", 52 | "title": "cptX: Ask AI the question (e.g. explain)" 53 | } 54 | ] 55 | }, 56 | "scripts": { 57 | "vscode:prepublish": "npm run package", 58 | "compile": "webpack", 59 | "watch": "webpack --watch", 60 | "package": "webpack --mode production --devtool hidden-source-map", 61 | "compile-tests": "tsc -p . --outDir out", 62 | "watch-tests": "tsc -p . -w --outDir out", 63 | "pretest": "npm run compile-tests && npm run compile && npm run lint", 64 | "lint": "eslint src --ext ts", 65 | "test": "node ./out/test/runTest.js" 66 | }, 67 | "devDependencies": { 68 | "@types/glob": "^8.1.0", 69 | "@types/mocha": "^10.0.1", 70 | "@types/node": "16.x", 71 | "@types/vscode": "^1.77.0", 72 | "@typescript-eslint/eslint-plugin": "^5.59.1", 73 | "@typescript-eslint/parser": "^5.59.1", 74 | "@vscode/test-electron": "^2.3.0", 75 | "eslint": "^8.39.0", 76 | "glob": "^8.1.0", 77 | "mocha": "^10.2.0", 78 | "ts-loader": "^9.4.2", 79 | "typescript": "^5.0.4", 80 | "webpack": "^5.81.0", 81 | "webpack-cli": "^5.0.2" 82 | }, 83 | "dependencies": { 84 | "openai": "^3.2.1" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /backlog/sqlite_storage.dart: -------------------------------------------------------------------------------- 1 | import 'package:openai_prompt_runner/src/storage.dart'; 2 | import 'package:sqlite3/sqlite3.dart'; 3 | 4 | const String promptsTable = ''' 5 | CREATE TABLE 6 | prompts ( 7 | run_started_at DATETIME not null default CURRENT_TIMESTAMP, 8 | prompt_sent_at DATETIME not null, 9 | prompt_updated_at DATETIME not null, 10 | run_tag TEXT null, 11 | tag TEXT null, 12 | status TEXT not null, 13 | prompt_tokens INTEGER NULL, 14 | total_tokens INTEGER NULL, 15 | request TEXT null, 16 | response TEXT null, 17 | retries INTEGER null, 18 | primary key (run_started_at, prompt_sent_at) 19 | ) 20 | '''; 21 | 22 | class PromptMetadataStorageImpl implements PromptMetadadataStorage { 23 | final String sqliteFile; 24 | 25 | PromptMetadataStorageImpl([this.sqliteFile = 'runs.sqlite']) { 26 | _checkPromptsTableExists(); 27 | } 28 | 29 | void _wrapDbCall(Function(Database db) f) { 30 | wrapSqliteCall(sqliteFile, f); 31 | } 32 | 33 | void _checkPromptsTableExists() { 34 | _wrapDbCall((db) { 35 | final result = db.select( 36 | 'SELECT name FROM sqlite_master WHERE type=\'table\' AND name=\'prompts\''); 37 | 38 | if (result.isEmpty) { 39 | _createPromptsTable(); 40 | } else { 41 | final columns = db 42 | .select('PRAGMA table_info(prompts)') 43 | .map((e) => e['name'] as String) 44 | .toSet(); 45 | 46 | final expectedColumns = { 47 | 'run_started_at', 48 | 'prompt_sent_at', 49 | 'prompt_updated_at', 50 | 'run_tag', 51 | 'tag', 52 | 'status', 53 | 'tokens_sent', 54 | 'total_tokens', 55 | 'request', 56 | 'response', 57 | 'retries' 58 | }; 59 | 60 | if (columns != expectedColumns) { 61 | throw Exception( 62 | 'Prompts table schema does not match expected schema'); 63 | } 64 | } 65 | }); 66 | } 67 | 68 | void _createPromptsTable() { 69 | _wrapDbCall((db) { 70 | db.execute(promptsTable); 71 | }); 72 | } 73 | 74 | @override 75 | void addPromptSent(DateTime runStartedAt, DateTime promtStartedAt, 76 | String? runTag, String? tag, String? request) { 77 | _wrapDbCall((db) { 78 | db.execute( 79 | "INSERT INTO prompts_scoring (run_started_at, prompt_sent_at, runTag, tag, status, request) VALUES (?, ?, ?, ?, ?, ?)", 80 | [ 81 | runStartedAt.toIso8601String(), 82 | promtStartedAt.toIso8601String(), 83 | runTag, 84 | tag, 85 | 'SENT', 86 | request, 87 | ]); 88 | }); 89 | } 90 | 91 | @override 92 | void updatePromptSuccess(DateTime runStartedAt, DateTime promtStartedAt, 93 | int promptTokens, int totalTokens, String? response) async { 94 | _wrapDbCall((db) { 95 | db.execute( 96 | "UPDATE prompts_scoring SET status = ?, prompt_tokens= ?, total_tokens = ?, response = ? WHERE run_started_at = ?, prompt_sent_at = ?", 97 | [ 98 | 'SUCCESS', 99 | promptTokens, 100 | totalTokens, 101 | response, 102 | runStartedAt.toIso8601String(), 103 | promtStartedAt.toIso8601String() 104 | ]); 105 | }); 106 | } 107 | 108 | @override 109 | void updatePromptError(DateTime runStartedAt, DateTime promtStartedAt, 110 | String? response, int retriesDone) async { 111 | _wrapDbCall((db) { 112 | db.execute( 113 | "UPDATE prompts_scoring SET status = ?, response = ? WHERE run_started_at = ?, prompt_sent_at = ?", 114 | [ 115 | 'ERROR', 116 | response, 117 | runStartedAt.toIso8601String(), 118 | promtStartedAt.toIso8601String() 119 | ]); 120 | }); 121 | } 122 | } 123 | 124 | /// Opens SQLite db from a given file, wraps it in try/finally, disposes connection upon exit 125 | void wrapSqliteCall(String fileName, Function(Database db) f) { 126 | var database = sqlite3.open(fileName); 127 | try { 128 | f(database); 129 | } finally { 130 | database.dispose(); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /backlog/sqlite_storage_2.dart: -------------------------------------------------------------------------------- 1 | import 'package:openai_prompt_runner/src/storage.dart'; 2 | import 'package:sqlite3/sqlite3.dart'; 3 | 4 | const String promptsTable = ''' 5 | CREATE TABLE 6 | prompts ( 7 | run_started_at DATETIME not null default CURRENT_TIMESTAMP, 8 | prompt_sent_at DATETIME not null, 9 | updated_at DATETIME not null, 10 | run_tag TEXT null, 11 | tag TEXT null, 12 | status TEXT not null, 13 | prompt_tokens INTEGER NULL, 14 | total_tokens INTEGER NULL, 15 | request TEXT null, 16 | response TEXT null, 17 | retries INTEGER null, 18 | primary key (run_started_at, prompt_sent_at) 19 | ) 20 | '''; 21 | 22 | /// SQLite implemetaion that saves prompt metadata to local file 23 | /// Check for 'prompts' table, creates one if not present, check for 24 | /// schema if present, fails if schema is not expected, 25 | /// Here's how the table looks 26 | /// ``` 27 | /// CREATE TABLE 28 | // prompts ( 29 | /// run_started_at DATETIME not null default CURRENT_TIMESTAMP, 30 | /// prompt_sent_at DATETIME not null, 31 | /// prompt_updated_at DATETIME not null, 32 | /// run_tag TEXT null, 33 | /// tag TEXT null, 34 | /// status TEXT not null, 35 | /// prompt_tokens INTEGER NULL, 36 | /// total_tokens INTEGER NULL, 37 | /// request TEXT null, 38 | /// response TEXT null, 39 | /// retries INTEGER null, 40 | /// primary key (run_started_at, prompt_sent_at) 41 | /// ) 42 | ///``` 43 | class PromptMetadataSqlite implements PromptMetadadataStorage { 44 | final String sqliteFile; 45 | 46 | PromptMetadataSqlite([this.sqliteFile = 'runs.sqlite']) { 47 | _checkPromptsTableExists(); 48 | } 49 | 50 | void _wrapDbCall(Function(Database db) f) { 51 | wrapSqliteCall(sqliteFile, f); 52 | } 53 | 54 | void _checkPromptsTableExists() { 55 | _wrapDbCall((db) { 56 | final result = db.select( 57 | 'SELECT name FROM sqlite_master WHERE type=\'table\' AND name=\'prompts\''); 58 | 59 | if (result.length > 1) { 60 | throw Exception('There\'re multiple \'prompts\' tables'); 61 | } 62 | 63 | if (result.isEmpty) { 64 | _createPromptsTable(); 65 | } else { 66 | final columns = db 67 | .select('PRAGMA table_info(prompts)') 68 | .map((e) => e['name'] as String) 69 | .toSet(); 70 | 71 | final expectedColumns = { 72 | 'run_started_at', 73 | 'prompt_sent_at', 74 | 'updated_at', 75 | 'run_tag', 76 | 'tag', 77 | 'status', 78 | 'prompt_tokens', 79 | 'total_tokens', 80 | 'request', 81 | 'response', 82 | 'retries' 83 | }; 84 | 85 | if (columns != expectedColumns) { 86 | throw Exception( 87 | 'Prompts table schema does not match expected schema'); 88 | } 89 | } 90 | }); 91 | } 92 | 93 | void _createPromptsTable() { 94 | _wrapDbCall((db) { 95 | db.execute(promptsTable); 96 | }); 97 | } 98 | 99 | @override 100 | void addPromptSent(DateTime runStartedAt, DateTime promtStartedAt, 101 | String? runTag, String? tag, String? request) { 102 | _wrapDbCall((db) { 103 | db.execute( 104 | "INSERT INTO prompts (run_started_at, prompt_sent_at, updated_at, runTag, tag, status, request) VALUES (?, ?, ?, ?, ?, ?, ?)", 105 | [ 106 | runStartedAt.toIso8601String(), 107 | promtStartedAt.toIso8601String(), 108 | DateTime.now().toIso8601String(), 109 | runTag, 110 | tag, 111 | 'SENT', 112 | request, 113 | ]); 114 | }); 115 | } 116 | 117 | @override 118 | void updatePromptSuccess(DateTime runStartedAt, DateTime promtStartedAt, 119 | int promptTokens, int totalTokens, String? response) async { 120 | _wrapDbCall((db) { 121 | db.execute( 122 | "UPDATE prompts SET updated_at = ?, status = ?, prompt_tokens = ?, total_tokens = ?, response = ? WHERE run_started_at = ?, prompt_sent_at = ?", 123 | [ 124 | DateTime.now().toIso8601String(), 125 | 'SUCCESS', 126 | promptTokens, 127 | totalTokens, 128 | response, 129 | runStartedAt.toIso8601String(), 130 | promtStartedAt.toIso8601String() 131 | ]); 132 | }); 133 | } 134 | 135 | @override 136 | void updatePromptError(DateTime runStartedAt, DateTime promtStartedAt, 137 | String? response, int retriesDone) async { 138 | _wrapDbCall((db) { 139 | db.execute( 140 | "UPDATE prompts SET updated_at = ?, status = ?, response = ?, retries = ? WHERE run_started_at = ?, prompt_sent_at = ?", 141 | [ 142 | DateTime.now().toIso8601String(), 143 | 'ERROR', 144 | response, 145 | retriesDone, 146 | runStartedAt.toIso8601String(), 147 | promtStartedAt.toIso8601String() 148 | ]); 149 | }); 150 | } 151 | } 152 | 153 | /// Opens SQLite db from a given file, wraps it in try/finally, disposes connection upon exit 154 | void wrapSqliteCall(String fileName, Function(Database db) f) { 155 | var database = sqlite3.open(fileName); 156 | try { 157 | f(database); 158 | } finally { 159 | database.dispose(); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /images/commands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxim-saplin/cptX/772c91c13c95c02d11a18895c2c1e16492c77848/images/commands.png -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxim-saplin/cptX/772c91c13c95c02d11a18895c2c1e16492c77848/images/icon.png -------------------------------------------------------------------------------- /images/intro.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxim-saplin/cptX/772c91c13c95c02d11a18895c2c1e16492c77848/images/intro.gif -------------------------------------------------------------------------------- /images/oai.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxim-saplin/cptX/772c91c13c95c02d11a18895c2c1e16492c77848/images/oai.png -------------------------------------------------------------------------------- /images/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxim-saplin/cptX/772c91c13c95c02d11a18895c2c1e16492c77848/images/settings.png -------------------------------------------------------------------------------- /images/settings_azure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxim-saplin/cptX/772c91c13c95c02d11a18895c2c1e16492c77848/images/settings_azure.png -------------------------------------------------------------------------------- /images/statusBar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxim-saplin/cptX/772c91c13c95c02d11a18895c2c1e16492c77848/images/statusBar.png -------------------------------------------------------------------------------- /images/timings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxim-saplin/cptX/772c91c13c95c02d11a18895c2c1e16492c77848/images/timings.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cptx", 3 | "displayName": "cptX 〉Token Counter, AI Codegen", 4 | "description": "A simplistic AI code generator with 2 commands (create, ask) and a token counter diaplyed in status bar", 5 | "version": "1.0.7", 6 | "publisher": "MaximSaplin", 7 | "icon": "images/icon.png", 8 | "engines": { 9 | "vscode": "^1.96.0" 10 | }, 11 | "repository": { 12 | "url": "https://github.com/maxim-saplin/cptX" 13 | }, 14 | "categories": [ 15 | "Programming Languages", 16 | "Machine Learning", 17 | "Education", 18 | "Other" 19 | ], 20 | "keywords": [ 21 | "chatgpt", 22 | "openai", 23 | "azure", 24 | "gpt4", 25 | "gpt3", 26 | "copilot", 27 | "flutter", 28 | "dart", 29 | "c#", 30 | "java", 31 | "javascript", 32 | "typescript", 33 | "python", 34 | "golang", 35 | "go", 36 | "ai", 37 | "code", 38 | "llm", 39 | "refactor", 40 | "assistant", 41 | "coding", 42 | "refactor", 43 | "explain", 44 | "test" 45 | ], 46 | "activationEvents": [ 47 | "onStartupFinished" 48 | ], 49 | "main": "./dist/extension.js", 50 | "contributes": { 51 | "configuration": { 52 | "title": "cptX", 53 | "type": "object", 54 | "properties": { 55 | "cptx.apiProvider": { 56 | "type": "string", 57 | "order": 0, 58 | "enum": [ 59 | "OpenAI", 60 | "Azure OpenAI" 61 | ], 62 | "default": "OpenAI", 63 | "markdownDescription": "Choose the AI provider for the copilot. If Azure is selected you must provide model endpoint and deployment name" 64 | }, 65 | "cptx.APIKey": { 66 | "order": 1, 67 | "type": "string", 68 | "markdownDescription": "OpenAI API key (see https://platform.openai.com/account/api-keys) OR Azure API key (see Azure API key, see https://learn.microsoft.com/en-us/azure/cognitive-services/openai/reference)" 69 | }, 70 | "cptx.AzureEndpoint": { 71 | "order": 2, 72 | "type": "string", 73 | "markdownDescription": "Azure endpoint address, e.g. https://YOUR_RESOURCE_NAME.openai.azure.com (see https://learn.microsoft.com/en-us/azure/cognitive-services/openai/reference)" 74 | }, 75 | "cptx.AzureDeploymentName": { 76 | "order": 3, 77 | "type": "string", 78 | "markdownDescription": "Azure deploymnet name, (see https://learn.microsoft.com/en-us/azure/cognitive-services/openai/reference)" 79 | }, 80 | "cptx.ContextSize": { 81 | "order": 4, 82 | "default": 4096, 83 | "type": "number", 84 | "markdownDescription": "Model contxt/window size in tokens. E.g. gpt3.5 has default context size of 4096. The larger context is available to teh plugin, the more relevant info might be packed into request." 85 | }, 86 | "cptx.ExplanationInTab": { 87 | "order": 3, 88 | "type": "boolean", 89 | "markdownDescription": "When using ask/explain command a new tab with the reply will be opened, the contents will be stored at .cptx/ folder as Markdown. Otherwise explanation will be displayed in alert, not saved anywhere " 90 | } 91 | } 92 | }, 93 | "icons": { 94 | "cptx-tkn": { 95 | "description": "TKN glyph", 96 | "default": { 97 | "fontPath": "assets/cptx.woff2", 98 | "fontCharacter": "\\e0" 99 | } 100 | }, 101 | "cptx-sel": { 102 | "description": "SEL glyph", 103 | "default": { 104 | "fontPath": "assets/cptx.woff2", 105 | "fontCharacter": "\\e1" 106 | } 107 | } 108 | }, 109 | "commands": [ 110 | { 111 | "command": "cptX.createOrRefactor", 112 | "title": "cptX 〉Create · Refactor" 113 | }, 114 | { 115 | "command": "cptX.explainOrAsk", 116 | "title": "cptX 〉 Ask · Explain" 117 | } 118 | ] 119 | }, 120 | "scripts": { 121 | "vscode:prepublish": "npm run package", 122 | "compile": "webpack", 123 | "watch": "webpack --watch", 124 | "package": "webpack --mode production --devtool hidden-source-map", 125 | "compile-tests": "tsc -p . --outDir out", 126 | "watch-tests": "tsc -p . -w --outDir out", 127 | "pretest": "npm run compile-tests && npm run compile && npm run lint", 128 | "lint": "eslint src --ext ts", 129 | "test": "node ./out/test/runTest.js" 130 | }, 131 | "devDependencies": { 132 | "@types/glob": "^8.1.0", 133 | "@types/mocha": "^10.0.10", 134 | "@types/node": "22.14.0", 135 | "@types/vscode": "^1.96.0", 136 | "@typescript-eslint/eslint-plugin": "^8.29.0", 137 | "@typescript-eslint/parser": "^8.29.0", 138 | "@vscode/test-electron": "^2.4.1", 139 | "eslint": "^9.23.0", 140 | "glob": "^11.0.1", 141 | "mocha": "^11.1.0", 142 | "ts-loader": "^9.5.2", 143 | "typescript": "^5.8.2", 144 | "webpack": "^5.98.0", 145 | "webpack-cli": "^6.0.1" 146 | }, 147 | "dependencies": { 148 | "@vscode/extension-telemetry": "^0.9.8", 149 | "js-tiktoken": "^1.0.19", 150 | "openai": "^4.91.1" 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | import {performance } from "perf_hooks"; 2 | import * as vscode from "vscode"; 3 | import { getEncoding } from "js-tiktoken"; 4 | import { config, extensionSettings } from "./settings"; 5 | 6 | function updateProgress( 7 | progress: vscode.Progress<{ 8 | message?: string | undefined; 9 | increment?: number | undefined; 10 | }>, 11 | start: number | undefined 12 | ) { 13 | let progressPercent = 0; 14 | let prevProgressPercent = 0; 15 | const interval = setInterval(() => { 16 | prevProgressPercent = progressPercent; 17 | progressPercent = (progressPercent + 5) % 100; 18 | const increment = progressPercent - prevProgressPercent; 19 | progress.report({ 20 | message: start !== undefined ? getElapsedSeconds(start) + "s" : "", 21 | increment, 22 | }); 23 | }, 100); 24 | return interval; 25 | } 26 | 27 | const encoding = getEncoding("o200k_base"); 28 | function getContextSize() : number { 29 | return extensionSettings.contextSize; 30 | } 31 | // This is the number of tokens that goes in the first request leaving the rest for completion and insertion into editor 32 | const maxTokensInRequest = 0.67 * getContextSize(); 33 | 34 | function countTokens(input: string): number { 35 | const tokens = encoding.encode(input).length; 36 | return tokens; 37 | } 38 | 39 | /** 40 | * Retrieves the code around the cursor in the TextEditor. 41 | * Limmits the number of text to max token ceiling based on settings (ContextSize). 42 | * 43 | * @param editor The vscode.TextEditor object representing the active editor. 44 | * @param tokensAlreadyInRequest gives the function knowledge how many tokens are already reserved/in use (e.g. instruction, prompt) 45 | * @returns An object containing the code above the cursor, the code below the cursor, and the cursor line number. 46 | */ 47 | 48 | function getCodeAroundCursor( 49 | editor: vscode.TextEditor, 50 | tokensAlreadyInRequest: number 51 | ): { aboveText: string; belowText: string; cursorLine: number; tokens: number } { 52 | const cursorLine = editor.selection.active.line; 53 | let lineAbove = cursorLine - 1; 54 | let lineBelow = cursorLine + 1; 55 | let totalTokens = 0; 56 | ({ lineAbove, lineBelow, totalTokens } = calculateLineBoundariesWithMaxTokensLimmit( 57 | lineAbove, 58 | lineBelow, 59 | editor, 60 | maxTokensInRequest - tokensAlreadyInRequest 61 | )); 62 | 63 | var aboveText = editor.document.getText( 64 | new vscode.Range(lineAbove, 0, cursorLine + 1, 0) 65 | ); 66 | var belowText = editor.document.getText( 67 | new vscode.Range(cursorLine + 1, 0, lineBelow, 0) 68 | ); 69 | return { aboveText, belowText, cursorLine, tokens: totalTokens }; 70 | } 71 | 72 | type PromptCompleter = (messages: Message[]) => Promise; 73 | 74 | type Completion = { 75 | reply: string; 76 | promptTokens: number; 77 | completionTokens: number; 78 | }; 79 | 80 | /** 81 | * Retrieves the code around the selected block in the TextEditor. 82 | * Limmits the number of text to max token ceiling based on settings (ContextSize). 83 | * 84 | * @param editor The vscode.TextEditor object representing the active editor. 85 | * @param tokensAlreadyInRequest gives the function knowledge how many tokens are already reserved/in use (e.g. instruction, prompt) 86 | * @returns An object containing the code above the cursor, the code below the cursor, and the cursor line number. 87 | */ 88 | 89 | function getCodeAroundSelection( 90 | editor: vscode.TextEditor, 91 | tokensAlreadyInRequest: number 92 | ): { aboveText: string; belowText: string; tokens: number } { 93 | const start = performance.now(); 94 | 95 | let lineAbove = editor.selection.start.line - 1; 96 | let lineBelow = editor.selection.end.line + 1; 97 | let totalTokens = 0; 98 | ({ lineAbove, lineBelow, totalTokens } = 99 | calculateLineBoundariesWithMaxTokensLimmit( 100 | lineAbove, 101 | lineBelow, 102 | editor, 103 | maxTokensInRequest - tokensAlreadyInRequest 104 | )); 105 | 106 | var aboveText = editor.document.getText( 107 | new vscode.Range(lineAbove, 0, editor.selection.start.line, 0) 108 | ); 109 | var belowText = editor.document.getText( 110 | new vscode.Range( 111 | editor.selection.end.line + 112 | // Don't add 1 line if there's something selected 113 | (editor.selection.isEmpty ? 0 : 1), 114 | 0, 115 | lineBelow, 116 | 0 117 | ) 118 | ); 119 | 120 | const end = performance.now(); 121 | debugLog( 122 | `getTextAroundSelection(): ${(end - start).toFixed( 123 | 2 124 | )}ms, ${totalTokens} tokens, ${ 125 | aboveText.split("\n").length + belowText.split("\n").length 126 | } lines` 127 | ); 128 | 129 | return { aboveText, belowText, tokens: totalTokens }; 130 | } 131 | 132 | function debugLog(message: any) { 133 | if (config.isDebugMode) { 134 | console.log(message); 135 | } 136 | } 137 | 138 | function calculateLineBoundariesWithMaxTokensLimmit( 139 | lineAbove: number, 140 | lineBelow: number, 141 | editor: vscode.TextEditor, 142 | maxTokens: number 143 | ) { 144 | //let aboveTokens = 0; 145 | //let belowTokens = 0; 146 | let totalTokens = 0; 147 | const maxLines = 16384; 148 | 149 | let iterationCounter = 0; 150 | while (iterationCounter < maxLines) { 151 | let outOfAboveLines = lineAbove < 0; 152 | let outOfBelowLines = lineBelow >= editor.document.lineCount; 153 | if (outOfAboveLines && outOfBelowLines) { 154 | break; 155 | } 156 | 157 | if (!outOfAboveLines) { 158 | totalTokens += countTokens(editor.document.lineAt(lineAbove).text); 159 | if (totalTokens > maxTokens) { 160 | break; 161 | } 162 | } 163 | 164 | if (!outOfBelowLines) { 165 | totalTokens += countTokens(editor.document.lineAt(lineBelow).text); 166 | if (totalTokens > maxTokens) { 167 | break; 168 | } 169 | } 170 | 171 | lineAbove--; 172 | lineBelow++; 173 | iterationCounter++; 174 | } 175 | 176 | if (lineAbove < 0) { 177 | lineAbove = 0; 178 | } 179 | if (lineBelow >= editor.document.lineCount) { 180 | lineBelow = editor.document.lineCount - 1; 181 | } 182 | return { lineAbove, lineBelow, totalTokens }; 183 | } 184 | 185 | function getLanguageId(editor: vscode.TextEditor) { 186 | // https://code.visualstudio.com/docs/languages/identifiers 187 | const languageId = editor.document.languageId; 188 | return languageId; 189 | } 190 | 191 | function getExpertAndLanguage(editor: vscode.TextEditor) { 192 | let expert = "software developer"; 193 | let language = ""; 194 | const languageId = getLanguageId(editor); 195 | 196 | switch (languageId) { 197 | case "dart": 198 | language = "Dart"; 199 | expert = "Flutter developer"; 200 | break; 201 | case "javascript": 202 | language = "JavaScript"; 203 | expert = "Full-stack developer"; 204 | break; 205 | case "typescript": 206 | language = "TypeScript"; 207 | expert = "Full-stack developer"; 208 | break; 209 | case "python": 210 | language = "Python"; 211 | expert = "Back-end developer"; 212 | break; 213 | case "java": 214 | language = "Java"; 215 | expert = "Back-end developer"; 216 | break; 217 | case "csharp": 218 | language = "C#"; 219 | expert = ".NET developer"; 220 | break; 221 | case "go": 222 | language = "Go"; 223 | expert = "Back-end developer"; 224 | break; 225 | case "ruby": 226 | language = "Ruby"; 227 | expert = "Back-end developer"; 228 | break; 229 | case "rust": 230 | language = "Rust"; 231 | expert = "Systems software engineer"; 232 | break; 233 | case "html": 234 | language = "HTML"; 235 | expert = "Front-end developer"; 236 | break; 237 | case "css": 238 | language = "CSS"; 239 | expert = "Front-end developer"; 240 | break; 241 | case "json": 242 | language = "JSON"; 243 | expert = "Web developer"; 244 | break; 245 | case "yaml": 246 | language = "YAML"; 247 | expert = "DevOps engineer"; 248 | break; 249 | case "c": 250 | language = "C"; 251 | expert = "Systems programmer"; 252 | break; 253 | case "cpp": 254 | language = "C++"; 255 | expert = "Game developer"; 256 | break; 257 | case "swift": 258 | language = "Swift"; 259 | expert = "Apple developer"; 260 | break; 261 | case "objective-c": 262 | language = "Objective-C"; 263 | expert = "Apple developer"; 264 | break; 265 | case "objective-cpp": 266 | language = "Objective-C++"; 267 | expert = "Apple developer"; 268 | break; 269 | case "kotlin": 270 | language = "Kotlin"; 271 | expert = "Android developer"; 272 | break; 273 | case "php": 274 | language = "PHP"; 275 | expert = "PHP developer"; 276 | break; 277 | case "sql": 278 | language = "SQL"; 279 | expert = "Database developer"; 280 | break; 281 | case "shellscript": 282 | language = "Bash"; 283 | expert = "DevOps engineer"; 284 | break; 285 | } 286 | 287 | return { expert, language, languageId }; 288 | } 289 | 290 | function commentOutLine(languageId: string, line: string): string { 291 | let commentedLine = ""; 292 | 293 | switch (languageId) { 294 | case "dart": 295 | commentedLine = `// ` + line; 296 | break; 297 | case "javascript": 298 | commentedLine = `// ` + line; 299 | break; 300 | case "typescript": 301 | commentedLine = `// ` + line; 302 | break; 303 | case "python": 304 | commentedLine = `# ` + line; 305 | break; 306 | case "java": 307 | commentedLine = `// ` + line; 308 | break; 309 | case "csharp": 310 | commentedLine = `// ` + line; 311 | break; 312 | case "go": 313 | commentedLine = `// ` + line; 314 | break; 315 | case "ruby": 316 | commentedLine = `# ` + line; 317 | break; 318 | case "rust": 319 | commentedLine = `// ` + line; 320 | break; 321 | case "html": 322 | commentedLine = ``; 323 | break; 324 | case "css": 325 | commentedLine = `/* ` + line + `*/`; 326 | break; 327 | case "yaml": 328 | commentedLine = `# ` + line; 329 | break; 330 | case "c": 331 | commentedLine = `// ` + line; 332 | break; 333 | case "cpp": 334 | commentedLine = `// ` + line; 335 | break; 336 | case "swift": 337 | commentedLine = `// ` + line; 338 | break; 339 | case "objective-c": 340 | commentedLine = `// ` + line; 341 | break; 342 | case "objective-cpp": 343 | commentedLine = `// ` + line; 344 | break; 345 | case "kotlin": 346 | commentedLine = `// ` + line; 347 | break; 348 | case "php": 349 | commentedLine = `// ` + line; 350 | break; 351 | case "sql": 352 | commentedLine = `-- ` + line; 353 | break; 354 | case "shellscript": 355 | commentedLine = `# ` + line; 356 | break; 357 | default: 358 | commentedLine = `// ` + line; 359 | } 360 | 361 | return commentedLine; 362 | } 363 | 364 | function getElapsedSeconds(start: number): string { 365 | const end = performance.now(); 366 | const duration = ((end - start) / 1000).toFixed(1); // return 1 decimal after point 367 | return duration; 368 | } 369 | 370 | function getElapsedSecondsNumber(start: number): number { 371 | const end = performance.now(); 372 | const duration = ((end - start) / 1000); // return 1 decimal after point 373 | return duration; 374 | } 375 | 376 | type Message = { 377 | role: string; 378 | content: string; 379 | }; 380 | 381 | type Role = "system" | "user" | "assistant"; 382 | 383 | function addMessageToPrompt(messages: Message[], content: string, role: Role) { 384 | messages.push({ role, content }); 385 | } 386 | 387 | // Wrapper for adding a system message to the prompt 388 | function addSystem(messages: Message[], content: string) { 389 | return addMessageToPrompt(messages, content, "system"); 390 | } 391 | 392 | // Wrapper for adding a user message to the prompt 393 | function addUser(messages: Message[], content: string) { 394 | return addMessageToPrompt(messages, content, "user"); 395 | } 396 | 397 | // Wrapper for adding an assistant message to the prompt 398 | function addAssistant(messages: Message[], content: string) { 399 | return addMessageToPrompt(messages, content, "assistant"); 400 | } 401 | 402 | function removeTripleBackticks(input: string): string { 403 | let lines = input.split("\n"); 404 | 405 | // Trim empty lines at the ends 406 | let startIndex = 0; 407 | let endIndex = lines.length - 1; 408 | while (startIndex <= endIndex && lines[startIndex].trim() === "") { 409 | startIndex++; 410 | } 411 | while (endIndex >= startIndex && lines[endIndex].trim() === "") { 412 | endIndex--; 413 | } 414 | 415 | lines = lines.slice(startIndex, endIndex + 1); 416 | startIndex = 0; 417 | endIndex = lines.length - 1; 418 | 419 | // Find tripple backticks 420 | while (startIndex <= endIndex) { 421 | if (lines[startIndex].trim().startsWith("```")) { 422 | break; 423 | } 424 | startIndex++; 425 | } 426 | while (endIndex >= startIndex) { 427 | if (lines[endIndex].trim().startsWith("```")) { 428 | break; 429 | } 430 | endIndex--; 431 | } 432 | 433 | if (startIndex +1 <= endIndex-1 && startIndex >= 0 && endIndex < lines.length ) { 434 | lines = lines.slice(startIndex+1, endIndex); 435 | } 436 | 437 | return lines.join("\n"); 438 | } 439 | 440 | const formatDate = ( 441 | date: Date, 442 | secondFormat: boolean = false 443 | ): string => { 444 | const year = date.getFullYear(); 445 | const month = String(date.getMonth() + 1).padStart(2, "0"); 446 | const day = String(date.getDate()).padStart(2, "0"); 447 | const hours = String(date.getHours()).padStart(2, "0"); 448 | const minutes = String(date.getMinutes()).padStart(2, "0"); 449 | const seconds = String(date.getSeconds()).padStart(2, "0"); 450 | 451 | if (secondFormat) { 452 | return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; 453 | } else { 454 | return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`; 455 | } 456 | }; 457 | 458 | export { 459 | updateProgress, 460 | getCodeAroundCursor, 461 | getCodeAroundSelection, 462 | getLanguageId, 463 | getExpertAndLanguage, 464 | getElapsedSeconds, 465 | getElapsedSecondsNumber, 466 | PromptCompleter, 467 | Completion, 468 | debugLog, 469 | Message, 470 | addSystem, 471 | addUser, 472 | addAssistant, 473 | commentOutLine, 474 | removeTripleBackticks as extractBlockBetweenTripleBackticks, 475 | countTokens, 476 | getContextSize, 477 | formatDate 478 | }; 479 | -------------------------------------------------------------------------------- /src/createOrRefactor.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as common from "./common"; 3 | import { performance } from "perf_hooks"; 4 | import { debugLog } from "./common"; 5 | import { sendCreateCanceledEvent, sendCreateEvent } from "./telemetry"; 6 | 7 | export async function createOrRefactor( 8 | propmptCompleter: common.PromptCompleter 9 | ) { 10 | let interval = undefined; 11 | try { 12 | const editor = vscode.window.activeTextEditor; 13 | if (!editor) { 14 | vscode.window.showErrorMessage("No active editor"); 15 | return; 16 | } 17 | 18 | const whatToDo = await vscode.window.showInputBox({ 19 | prompt: "What can I do for you?", 20 | }); 21 | if (!whatToDo) { 22 | return; 23 | } 24 | 25 | const start = performance.now(); // start stopwatch 26 | 27 | await vscode.window.withProgress( 28 | { 29 | location: vscode.ProgressLocation.Notification, 30 | title: "cptX is working on your request", 31 | cancellable: true, 32 | }, 33 | async (progress, token) => { 34 | token.onCancellationRequested((e) => sendCreateCanceledEvent(common.getElapsedSecondsNumber(start))); 35 | interval = common.updateProgress(progress, start); 36 | 37 | const selectedCode = editor.document.getText(editor.selection).trim(); 38 | const refactor = selectedCode.length > 0; 39 | let aboveText = ""; 40 | let belowText = ""; 41 | let knownTokens = common.countTokens(whatToDo)+common.countTokens(selectedCode)+getEmptyPromptTokens(); 42 | let editorTextTokens = 0; 43 | 44 | if (refactor) { 45 | ({ aboveText, belowText, tokens: editorTextTokens } = common.getCodeAroundSelection( 46 | editor, 47 | knownTokens 48 | )); 49 | } else { 50 | ({ aboveText, belowText, tokens: editorTextTokens } = common.getCodeAroundCursor( 51 | editor, 52 | knownTokens 53 | )); 54 | } 55 | 56 | let { expert, language, languageId } = 57 | common.getExpertAndLanguage(editor); 58 | 59 | let calculatedPromptTokens = knownTokens + editorTextTokens; 60 | let prompt = compilePrompt( 61 | whatToDo, 62 | selectedCode, 63 | editor.document.fileName, 64 | aboveText, 65 | belowText, 66 | expert, 67 | language, 68 | languageId 69 | ); 70 | 71 | for (var m in prompt) { 72 | debugLog(prompt[m].content); 73 | } 74 | 75 | let {reply: result, promptTokens, completionTokens} = await propmptCompleter(prompt); 76 | 77 | debugLog('\n↓ ↓ ↓ reply↓ ↓ ↓ \n'+result); 78 | result = common.extractBlockBetweenTripleBackticks(result); //With GPT3.5 Jine 2023 version the model can't resist and often returns code block enclosed in backticks, i.e. ```typescript 79 | clearInterval(interval); 80 | progress.report({ increment: 100 }); 81 | 82 | if (result.trim().length === 0 && !token.isCancellationRequested) { 83 | vscode.window.showInformationMessage( 84 | `cptX received nothing from GPT(${common.getElapsedSeconds( 85 | start 86 | )} seconds)` 87 | ); 88 | return; 89 | } 90 | 91 | if (!token.isCancellationRequested) { 92 | sendCreateEvent(calculatedPromptTokens, promptTokens, completionTokens, common.getElapsedSecondsNumber(start)); 93 | await editor.edit((editBuilder) => { 94 | if (refactor) { 95 | editBuilder.replace(editor.selection, result); 96 | } else { 97 | const cursorLineNotEmpty = !editor.document.lineAt( 98 | editor.selection.end.line 99 | ).isEmptyOrWhitespace; 100 | if (cursorLineNotEmpty) { 101 | editBuilder.insert(editor.selection.end, "\n"); 102 | } 103 | editBuilder.insert(editor.selection.end, result); 104 | } 105 | }); 106 | if (!refactor) { 107 | var endPos = editor.selection.end; 108 | var startPos = new vscode.Position( 109 | endPos.line - result.split("\n").length + 1, 110 | 0 111 | ); 112 | editor.selection = new vscode.Selection(startPos, endPos); 113 | } 114 | vscode.window.showInformationMessage( 115 | `cptX completed operation (${common.getElapsedSeconds(start)}s). Tokens sent ${promptTokens}, total ${promptTokens+completionTokens})` 116 | ); 117 | debugLog(`\nPrompt tokens (calculated|actual|total actual): ${calculatedPromptTokens}|${promptTokens}|${promptTokens+completionTokens}`); 118 | 119 | await vscode.commands.executeCommand("editor.action.formatSelection"); 120 | } 121 | } 122 | ); 123 | } catch (error: any) { 124 | if (interval !== undefined) { 125 | clearInterval(interval); 126 | } 127 | let addition = ""; 128 | if (error.error) { 129 | if (error.error.code) { 130 | addition += `${error.error.code}. `; 131 | } 132 | if (error.error.message) { 133 | addition += `${error.error.message}`; 134 | } 135 | } 136 | if (error.message) { 137 | addition += `${error.message}. `; 138 | } 139 | vscode.window.showErrorMessage( 140 | `cptX failed to generate code: ${error}${addition}` 141 | ); 142 | } 143 | } 144 | 145 | var _emptyPromptTokens = -1; 146 | 147 | function getEmptyPromptTokens(): number { 148 | if (_emptyPromptTokens < 0) { 149 | let emptyPrompt = compilePrompt( 150 | "", 151 | "", 152 | "testfile.php", 153 | "testcodeabove", 154 | "testcodebelow", 155 | "PHP Developer", 156 | "PHP", 157 | "php" 158 | ); 159 | const foldedPrompt = emptyPrompt.map((message) => message.content).join(""); 160 | _emptyPromptTokens = common.countTokens(foldedPrompt); 161 | } 162 | return _emptyPromptTokens; 163 | } 164 | 165 | // In v2 of the prompt chat with multiple messages exchangaed changed single response/request 166 | // one message prompt in v1. This was done to trick model to always responds with valid code block 167 | // (rather than conversational style of responses with code block diluted with free text) 168 | // and to try make it do best to ensure maximum 'pluggability' of the produced code into editor 169 | function compilePrompt( 170 | whatToDo: string, 171 | selectedCode: string, 172 | fileName: string, 173 | aboveCode: string, 174 | belowCode: string, 175 | expert: string, 176 | language: string, 177 | languageId: string 178 | ): common.Message[] { 179 | if (language.trim().length !== 0) { 180 | language = " " + language; 181 | } 182 | 183 | let messages: common.Message[] = []; 184 | 185 | let systemMessage = `You're an AI assistant acting as an expert ${expert} and capable of chained reasoning as humans do. `; 186 | systemMessage += `You're providing yor output through a VSCode extension. `; 187 | systemMessage += `Your output will be inserted directly into code editor! `; 188 | systemMessage += 189 | fileName.trim().length !== 0 190 | ? "The name of the file currenty open is '" + fileName + "'." 191 | : ""; 192 | systemMessage += `In the next messages a user will provide you with his/her instruction, show the surrounding code from the file that is in currently open in the editor. `; 193 | 194 | systemMessage += `\n`; 195 | let instructions = `- Carefully follow the instruction\n`; 196 | instructions += `- Make sure that you only respond with a valid${language} code block and only with a valid${language} code block\n`; 197 | instructions += `- Do not return any lines that can break compilation\n`; 198 | instructions += `- Don't wrap you repsonse into markdown until asked specifically\n`; // quite often woth June versions of OpenAI I see mede returnig blocked wraped in MD, e.g. ```dart ... 199 | //instructions += `- Do not enclose your output in tripple backticks (\`\`\`)\n`; 200 | //instructions += `- Be concise\n`; 201 | instructions += `- Do not repeat the surrounding code provided as context (above and below code)\n`; 202 | instructions += `- Use${language} comments to escape any free text\n`; 203 | instructions += `- If there're instructions for the user provide them as${language} comments before the produce code block\n`; 204 | instructions += `- You can use inline${language} comment as part of the code block\n`; 205 | instructions += `- Do not leave messages and do not add any text after the code block you will create\n`; 206 | instructions += `- The response you will produce will plug into the code in the open editor as-is and it must not break it\n`; 207 | instructions += `- Do not ask if I need any further assistance.\n`; 208 | 209 | common.addSystem(messages, systemMessage); 210 | common.addUser(messages, `Ready?)`); 211 | common.addAssistant( 212 | messages, 213 | common.commentOutLine(languageId, `I am ready.`) + 214 | `\n` + 215 | common.commentOutLine( 216 | languageId, 217 | `I will only reply with valid${language} programing language syntax and wrap any free text in comments.`) + 218 | `\n` 219 | ); 220 | 221 | const refactor = selectedCode.trim().length > 0; 222 | 223 | common.addUser(messages, `THE INSTRUCTION ->\n` + whatToDo); 224 | 225 | let contextExistis = aboveCode.trim().length !== 0 || belowCode.trim().length; 226 | 227 | if (refactor) { 228 | common.addUser( 229 | messages, 230 | `The following code is currently selected in the editor` + 231 | `and your output will replace it. Only change the selected code` 232 | // contextExistis 233 | // ? `, do not repeat any surrounding code from the context in your reply. ` 234 | // : `.` 235 | ); 236 | 237 | common.addUser(messages, selectedCode); 238 | 239 | //let aboveAdded = false; 240 | 241 | if (contextExistis) { 242 | let s = ``; 243 | s +=`Your code block will be inserted at the current cursor location.`; 244 | 245 | if (aboveCode.trim().length !== 0) { 246 | s += `\n\nIt will be inserted after this lines :\n\n` + aboveCode + `\n\n`; 247 | } else if (belowCode.trim().length !== 0) { 248 | s += `\n\nAnd before the following lines:\n\n` + belowCode + `\n\n`; 249 | } 250 | 251 | common.addUser(messages, s); 252 | // if (aboveCode.trim().length !== 0) { 253 | // common.addAssistant( 254 | // messages, 255 | // common.commentOutLine( 256 | // languageId, 257 | // `Please provide surrounding code if any` 258 | // ) 259 | // ); 260 | // common.addUser( 261 | // messages, 262 | // `For the context, here's part of the code above the selection` 263 | // ); 264 | // common.addAssistant( 265 | // messages, 266 | // common.commentOutLine(languageId, "Awaiting code snippet") 267 | // ); 268 | // common.addUser(messages, aboveCode); 269 | // aboveAdded = true; 270 | // } 271 | // if (belowCode.trim().length !== 0) { 272 | // let assistant = aboveAdded 273 | // ? `Is there more code below?` 274 | // : `Please provide surrounding code if any`; 275 | // common.addAssistant( 276 | // messages, 277 | // common.commentOutLine(languageId, assistant) 278 | // ); 279 | // common.addUser( 280 | // messages, 281 | // (!aboveAdded ? `For the context, here's` : `And here's`) + 282 | // ` part of the code below the selection` 283 | // ); 284 | // common.addAssistant( 285 | // messages, 286 | // common.commentOutLine(languageId, "Awaiting code snippet") 287 | // ); 288 | // common.addUser(messages, belowCode); 289 | // } 290 | 291 | common.addUser(messages, `Do not repeat the above/below code provided.`+ 292 | ` Only return code sufficient to insert in place of selection.`); 293 | 294 | // common.addUser( 295 | // messages, 296 | // `Do not return in your reply and do not repeast the code provided as context.` + 297 | // ` By doing so you will create duplication code and break code in editor.` 298 | // ); 299 | } 300 | } 301 | // NO CODE SELECTED 302 | else { 303 | 304 | // common.addUser( 305 | // messages, 306 | // `Your code block will be inserted at the current cursor location.` 307 | // // TODO: decide how start and end of top line cursor possition affects the contents of above text. I.e. currently if you put curosr at the beginning of the first line the above text contains the full line 308 | // // (aboveCode.trim().length === 0 309 | // // ? `The cursor is currently located at the top of the file.` 310 | // // : belowText.trim().length === 0 311 | // // ? `The cursor is currently located at the bottom of the file.` 312 | // // : ``) 313 | // ); 314 | 315 | common.addAssistant( 316 | messages, 317 | common.commentOutLine(languageId, `Please provide surrounding code`) 318 | ); 319 | 320 | if (contextExistis) { 321 | let s = ``; 322 | s +=`Your code block will be inserted at the current cursor location.`; 323 | 324 | if (aboveCode.trim().length !== 0) { 325 | s += `\n\nIt will be inserted after this lines :\n\n` + aboveCode + `\n\n`; 326 | } else if (belowCode.trim().length !== 0) { 327 | s += `\n\nAnd before the following lines:\n\n` + belowCode + `\n\n`; 328 | } 329 | 330 | common.addUser(messages, s); 331 | common.addUser(messages, 'Do not repeat the above/below code provided. Only return code sufficient to insert at the cursor location.'); 332 | 333 | // let aboveAdded = false; 334 | 335 | // if (aboveCode.trim().length !== 0) { 336 | // common.addUser( 337 | // messages, 338 | // `For the context, here's the code that is located abover the cursor` 339 | // ); 340 | // common.addAssistant( 341 | // messages, 342 | // common.commentOutLine(languageId, "Awaiting code snippet") 343 | // ); 344 | // common.addUser(messages, aboveCode); 345 | // aboveAdded = true; 346 | // } 347 | 348 | // if (belowCode.trim().length !== 0) { 349 | // if (aboveAdded) { 350 | // common.addAssistant( 351 | // messages, 352 | // common.commentOutLine(languageId, `Is there more code below?`) 353 | // ); 354 | // } 355 | // common.addUser( 356 | // messages, 357 | // `For the context, here's the code that is located below the cursor` 358 | // ); 359 | // common.addAssistant( 360 | // messages, 361 | // common.commentOutLine(languageId, "Awaiting code snippet") 362 | // ); 363 | // common.addUser(messages, belowCode); 364 | // } 365 | } else { 366 | common.addUser(messages, "The file is currently empty"); 367 | } 368 | } 369 | 370 | common.addAssistant( 371 | messages, 372 | common.commentOutLine( 373 | languageId, 374 | `I am ready to complete the request and generate${language} code snippet that will be inserted into Visual Studio Code editor` 375 | ) 376 | ); 377 | 378 | common.addUser( 379 | messages, 380 | `Please proceed and return the code block. Don't forget that from this point` + 381 | ` I won't be repling to you. Your next response will be automatically inserted into VSCode editor as-is.` + 382 | ` Remember the instructions:\n` + 383 | instructions 384 | ); 385 | 386 | return messages; 387 | } 388 | -------------------------------------------------------------------------------- /src/explain.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as common from "./common"; 3 | import { performance } from "perf_hooks"; 4 | import { debugLog } from "./common"; 5 | import { sendExplainCanceledEvent, sendExplainEvent } from "./telemetry"; 6 | import { config, extensionSettings } from "./settings"; 7 | import path = require("path"); 8 | 9 | async function explainOrAsk(propmptCompleter: common.PromptCompleter) { 10 | let interval = undefined; 11 | try { 12 | const editor = vscode.window.activeTextEditor; 13 | if (!editor) { 14 | return vscode.window.showErrorMessage("No active editor"); 15 | } 16 | 17 | const selectedCode = editor.document.getText(editor.selection).trim(); 18 | 19 | // If empty - ask to explain 20 | let request = 21 | (await vscode.window.showInputBox({ 22 | prompt: "What can I do for you?", 23 | value: "explain", 24 | })) ?? ""; 25 | 26 | if (!request) { 27 | return; 28 | } else if (request.toLowerCase() === "explain") { 29 | request = ""; 30 | } 31 | 32 | const start = performance.now(); // start stopwatch 33 | 34 | await vscode.window.withProgress( 35 | { 36 | location: vscode.ProgressLocation.Notification, 37 | title: "cptX is working on your request", 38 | cancellable: true, 39 | }, 40 | async (progress, token) => { 41 | token.onCancellationRequested((e) => 42 | sendExplainCanceledEvent(common.getElapsedSecondsNumber(start)) 43 | ); 44 | // added token parameter 45 | interval = common.updateProgress(progress, start); 46 | let knownTokens = 47 | common.countTokens(request) + 48 | common.countTokens(selectedCode) + 49 | getEmptyPromptTokens(); 50 | let { 51 | aboveText, 52 | belowText, 53 | tokens: editorTextTokens, 54 | } = common.getCodeAroundSelection(editor, knownTokens); 55 | 56 | let { expert, language } = common.getExpertAndLanguage(editor); 57 | let calculatedPromptTokens = knownTokens + editorTextTokens; 58 | let { messages: prompt, interesting } = compilePrompt( 59 | request, 60 | selectedCode, 61 | editor.document.fileName, 62 | aboveText, 63 | belowText, 64 | expert, 65 | language 66 | ); 67 | 68 | for (var m in prompt) { 69 | debugLog(prompt[m].content); 70 | } 71 | 72 | let { 73 | reply: explanation, 74 | promptTokens, 75 | completionTokens, 76 | } = await propmptCompleter(prompt); 77 | debugLog("\n↓ ↓ ↓ reply↓ ↓ ↓ \n" + explanation); 78 | 79 | if (explanation.trim().length === 0 && !token.isCancellationRequested) { 80 | vscode.window.showInformationMessage( 81 | `cptX received nothing from GPT(${common.getElapsedSeconds( 82 | start 83 | )} seconds)` 84 | ); 85 | return; 86 | } 87 | if (!token.isCancellationRequested) { 88 | // check if token is canceled before showing info message 89 | sendExplainEvent( 90 | calculatedPromptTokens, 91 | promptTokens, 92 | completionTokens, 93 | common.getElapsedSecondsNumber(start) 94 | ); 95 | if (extensionSettings.explanationInTab && config.cptxFolderUri) { 96 | if (!config.cptxFolderUri) { 97 | vscode.window.showErrorMessage( 98 | "No cptx folder availble to store the results of explanation." 99 | ); 100 | } else { 101 | let date = new Date(); 102 | 103 | let fileName = `explain-${common.formatDate(date)}.md`; 104 | let filePath = path.join(config.cptxFolderUri.fsPath, fileName); 105 | let fileUri = vscode.Uri.file(filePath); 106 | 107 | let s = 108 | `${explanation}\n\n-----------------\n\n` + 109 | `${common.formatDate(date, true)} | ${ 110 | editor.document.fileName 111 | }\n\n` + 112 | `#### Request:\n${request === "" ? "explain" : request}\n\n` + 113 | (selectedCode 114 | ? "#### Selected code:\n```" + selectedCode.trim() + "```" 115 | : "#### Code around cursor:\n```" + 116 | interesting.trim() + 117 | "```"); 118 | 119 | await vscode.workspace.fs.writeFile(fileUri, Buffer.from(s)); 120 | await vscode.commands.executeCommand( 121 | "markdown.showPreview", 122 | fileUri 123 | ); 124 | } 125 | } else { 126 | vscode.window.showInformationMessage(explanation, { modal: true }); 127 | } 128 | vscode.window.showInformationMessage( 129 | `cptX completed operation (${common.getElapsedSeconds( 130 | start 131 | )}s). Tokens sent ${promptTokens}, total ${ 132 | promptTokens + completionTokens 133 | })` 134 | ); 135 | debugLog( 136 | `\nPrompt tokens (calculated|actual|total actual): ${calculatedPromptTokens}|${promptTokens}|${ 137 | promptTokens + completionTokens 138 | }` 139 | ); 140 | } 141 | } 142 | ); 143 | } catch (error: any) { 144 | if (interval !== undefined) { 145 | clearInterval(interval); 146 | } 147 | // TODO, check error messages are shown 148 | let addition = ""; 149 | if (error.error) { 150 | if (error.error.code) { 151 | addition += `${error.error.code}. `; 152 | } 153 | if (error.error.message) { 154 | addition += `${error.error.message}. `; 155 | } 156 | } 157 | if (error.message) { 158 | addition += `${error.message}. `; 159 | } 160 | vscode.window.showErrorMessage( 161 | `Failed to generate explanation: ${addition}` 162 | ); 163 | } 164 | } 165 | 166 | var _emptyPromptTokens = -1; 167 | 168 | function getEmptyPromptTokens(): number { 169 | if (_emptyPromptTokens < 0) { 170 | let emptyPrompt = compilePrompt( 171 | "", 172 | "", 173 | "testfile.php", 174 | "testcodeabove", 175 | "testcodebelow", 176 | "PHP Developer", 177 | "PHP" 178 | ).messages; 179 | const foldedPrompt = emptyPrompt.map((message) => message.content).join(""); 180 | _emptyPromptTokens = common.countTokens(foldedPrompt); 181 | } 182 | return _emptyPromptTokens; 183 | } 184 | 185 | function compilePrompt( 186 | whatToDo: string, 187 | selectedCode: string, 188 | fileName: string, 189 | aboveCode: string, 190 | belowCode: string, 191 | expert: string, 192 | language: string 193 | ): { messages: common.Message[]; interesting: string } { 194 | if (language.trim().length !== 0) { 195 | language = " " + language; 196 | } 197 | 198 | let messages: common.Message[] = []; 199 | 200 | let systemMessage = `You're an AI assistant acting as an expert ${expert} and capable of chained reasoning as humans do. `; 201 | systemMessage += `You're providing output through a VSCode extension. `; 202 | systemMessage += `In the next messages a user will provide you with his/her request (instructions), show the surrounding code from the file that is in currently open in the editor. `; 203 | systemMessage += 204 | fileName.trim().length !== 0 205 | ? "The name of the file currenty open is '" + fileName + "'." 206 | : ""; 207 | systemMessage += `Your goal is to provide advice and consultation.`; 208 | systemMessage += `\n`; 209 | systemMessage += `- Carefully follow the instructions\n`; 210 | systemMessage += `- Be concise\n`; 211 | 212 | common.addSystem(messages, systemMessage); 213 | 214 | if (whatToDo.trim().length === 0) { 215 | whatToDo = `Please explain the code`; 216 | } 217 | common.addUser(messages, `Here is the request -> \n\n${whatToDo}`); 218 | 219 | let interesting = ``; 220 | 221 | if (selectedCode.trim().length !== 0) { 222 | common.addUser( 223 | messages, 224 | `The following code is currently selected in the editor and is in the focus of the request -> \n\n${selectedCode}` 225 | ); 226 | if (aboveCode.trim().length !== 0) { 227 | common.addUser( 228 | messages, 229 | `For the context, here's part of the code above the selection -> \n\n${aboveCode}` 230 | ); 231 | } 232 | if (belowCode.trim().length !== 0) { 233 | common.addUser( 234 | messages, 235 | `For the context, here's part of the code below the selection -> \n\n${belowCode}` 236 | ); 237 | } 238 | } else { 239 | common.addUser( 240 | messages, 241 | `For the context, here's the code that is currently open in the editor -> \n\n` + 242 | aboveCode + 243 | belowCode 244 | ); 245 | 246 | interesting = 247 | aboveCode.split("\n").slice(-5).join("\n") + 248 | belowCode.split("\n").slice(0, 5).join("\n"); 249 | 250 | if (interesting.trim().length !== 0) { 251 | interesting = 252 | `User's cursor is currently near this code -> \n\n` + interesting; 253 | } 254 | 255 | common.addUser(messages, interesting); 256 | } 257 | 258 | return { messages, interesting }; 259 | } 260 | 261 | export { explainOrAsk }; 262 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { createOrRefactor } from "./createOrRefactor"; 3 | //import {setLogLevel} from "@azure/logger"; 4 | import { explainOrAsk } from "./explain"; 5 | import { Message, PromptCompleter } from "./common"; 6 | import { getCompleter } from "./openai"; 7 | import { initTelemetry, sendConfigurationChangedEvent } from "./telemetry"; 8 | import { initStatusBar } from "./statusBar"; 9 | import { config, extensionSettings } from "./settings"; 10 | 11 | export function activate(context: vscode.ExtensionContext) { 12 | //console.log('Congratulations, your extension "cptX" is now active!'); 13 | //setLogLevel("verbose"); 14 | 15 | context.subscriptions.push(initTelemetry()); 16 | 17 | config.init(); 18 | 19 | var completer = getCompleter(); 20 | 21 | vscode.workspace.onDidChangeConfiguration((event) => { 22 | let affected = event.affectsConfiguration("cptx.APIKey"); 23 | if (affected) { 24 | completer = getCompleter(); 25 | } 26 | sendConfigurationChangedEvent( 27 | extensionSettings.apiProvider, 28 | extensionSettings.contextSize, 29 | extensionSettings.explanationInTab 30 | ); 31 | }); 32 | 33 | const createOrRefactorCommand = "cptX.createOrRefactor"; 34 | const explainOrAskCommand = "cptX.explainOrAsk"; 35 | 36 | context.subscriptions.push( 37 | vscode.commands.registerCommand(createOrRefactorCommand, () => 38 | createOrRefactor(completer) 39 | ) 40 | ); 41 | context.subscriptions.push( 42 | vscode.commands.registerCommand(explainOrAskCommand, () => 43 | explainOrAsk(completer) 44 | ) 45 | ); 46 | 47 | initStatusBar(explainOrAskCommand, context); 48 | } 49 | 50 | // This method is called when your extension is deactivated 51 | export function deactivate() {} 52 | -------------------------------------------------------------------------------- /src/openai.ts: -------------------------------------------------------------------------------- 1 | import { OpenAI, AzureOpenAI } from "openai"; 2 | import { Completion, Message, PromptCompleter } from "./common"; 3 | import { extensionSettings } from "./settings"; 4 | import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; 5 | 6 | function getOpenAIApi(requireKey: boolean = true): { client: OpenAI | AzureOpenAI | null; model: string } { 7 | const key = extensionSettings.apiKey; 8 | const apiProvider = extensionSettings.apiProvider; 9 | const azureEndpoint = extensionSettings.azureEndpoint; 10 | const azureDeploymentName = extensionSettings.azureDeploymentName; 11 | 12 | if (!key) { 13 | if (requireKey) { 14 | throw new Error( 15 | "OpenAI API key is not set for cptX extension. Please check extension settings and try again." 16 | ); 17 | } else { 18 | // Return null client when key is not required (for features like token counting) 19 | return { client: null, model: "gpt-3.5-turbo" }; 20 | } 21 | } 22 | 23 | const isAzure = apiProvider === "Azure (Gpt3.5 or Gpt4)" || apiProvider === "Azure OpenAI"; 24 | 25 | // Check if apiProvider is set to Azure and Azure parameters are provided, throw error if not 26 | if (isAzure) { 27 | if (!azureEndpoint || !azureDeploymentName) { 28 | throw new Error( 29 | "Azure API provider is chosen for cptX extension yet Azure parameters are missing. Please check extension settings and try again." 30 | ); 31 | } 32 | } 33 | 34 | let client; 35 | let model = isAzure ? azureDeploymentName : "gpt-3.5-turbo"; 36 | 37 | if (isAzure) { 38 | // For Azure OpenAI 39 | const apiVersion = "2024-08-01-preview"; 40 | 41 | client = new AzureOpenAI({ 42 | apiKey: key, 43 | endpoint: azureEndpoint, 44 | apiVersion: apiVersion, 45 | deployment: azureDeploymentName, 46 | }); 47 | 48 | } else { 49 | client = new OpenAI({ 50 | apiKey: key, 51 | }); 52 | } 53 | 54 | return { client, model }; 55 | } 56 | 57 | async function getCompletion( 58 | client: OpenAI | AzureOpenAI, 59 | model: string, 60 | messages: Message[] 61 | ): Promise { 62 | // Map your custom Message type to ChatCompletionMessageParam 63 | const formattedMessages = messages.map(message => { 64 | // Our Message type only has role and content properties 65 | // Cast to the expected role types but preserve the structure 66 | return { 67 | role: message.role as "system" | "user" | "assistant", 68 | content: message.content 69 | } as ChatCompletionMessageParam; 70 | }); 71 | 72 | const completion = await client.chat.completions.create({ 73 | model: model, 74 | messages: formattedMessages, 75 | temperature: 0.0, 76 | }); 77 | 78 | let reply = completion.choices[0].message?.content ?? ""; 79 | 80 | return { 81 | reply, 82 | promptTokens: completion.usage?.prompt_tokens || 0, 83 | completionTokens: completion.usage?.completion_tokens || 0, 84 | }; 85 | } 86 | 87 | function getCompleter(): PromptCompleter { 88 | function _getCompleter(client: OpenAI | AzureOpenAI | null, model: string): PromptCompleter { 89 | return async function (messages: Message[]): Promise { 90 | if (!client) { 91 | // API key is required when actually making API calls 92 | throw new Error( 93 | "OpenAI API key is not set for cptX extension. Please check extension settings and try again." 94 | ); 95 | } 96 | return getCompletion(client, model, messages); 97 | }; 98 | } 99 | 100 | // Don't require API key until an actual API call is made 101 | let { client, model } = getOpenAIApi(false); 102 | 103 | return _getCompleter(client, model); 104 | } 105 | 106 | export { getCompleter }; 107 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | class Settings { 4 | get apiKey(): string { 5 | return vscode.workspace.getConfiguration("cptx").get("APIKey") || ""; 6 | } 7 | get apiProvider(): string { 8 | return vscode.workspace.getConfiguration("cptx").get("apiProvider") || ""; 9 | } 10 | get azureEndpoint(): string { 11 | return vscode.workspace.getConfiguration("cptx").get("AzureEndpoint") || ""; 12 | } 13 | get azureDeploymentName(): string { 14 | return ( 15 | vscode.workspace.getConfiguration("cptx").get("AzureDeploymentName") || "" 16 | ); 17 | } 18 | get contextSize(): number { 19 | return vscode.workspace.getConfiguration("cptx").get("ContextSize") ?? 2048; 20 | } 21 | get explanationInTab(): boolean { 22 | return ( 23 | vscode.workspace.getConfiguration("cptx").get("ExplanationInTab") || true 24 | ); 25 | } 26 | } 27 | 28 | class Config { 29 | get isDebugMode(): boolean { 30 | return process.env.VSCODE_DEBUG_MODE === "true"; 31 | } 32 | 33 | async init(): Promise { 34 | const cptxFolderUri = this.cptxFolderUri; 35 | if (cptxFolderUri) { 36 | try { 37 | const folderExists = await vscode.workspace.fs.stat(cptxFolderUri); 38 | if (!folderExists) { 39 | await vscode.workspace.fs.createDirectory(cptxFolderUri); 40 | } 41 | } catch (error) { 42 | console.error(error); 43 | } 44 | } 45 | } 46 | 47 | get cptxFolderUri(): vscode.Uri | undefined { 48 | const x = vscode.workspace.workspaceFolders?.[0]?.uri; 49 | return x ? vscode.Uri.joinPath(x, ".cptx") : undefined; 50 | } 51 | } 52 | 53 | export const extensionSettings = new Settings(); 54 | export const config = new Config(); 55 | -------------------------------------------------------------------------------- /src/statusBar.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { countTokens, debugLog } from "./common"; 3 | 4 | let cptxStatusBarItem: vscode.StatusBarItem; 5 | 6 | function initStatusBar( 7 | commandToRunOnClick: string, 8 | context: vscode.ExtensionContext 9 | ) { 10 | cptxStatusBarItem = vscode.window.createStatusBarItem( 11 | vscode.StatusBarAlignment.Right, 12 | 100 13 | ); 14 | cptxStatusBarItem.command = commandToRunOnClick; 15 | context.subscriptions.push(cptxStatusBarItem); 16 | 17 | // register some listener that make sure the status bar 18 | // item always up-to-date 19 | context.subscriptions.push( 20 | vscode.window.onDidChangeActiveTextEditor(updateStatusBarItem) 21 | ); 22 | context.subscriptions.push( 23 | vscode.window.onDidChangeTextEditorSelection(updateStatusBarItem) 24 | ); 25 | 26 | // update status bar item once at start 27 | updateStatusBarItem(); 28 | } 29 | 30 | // function debounce(func: () => void, delaySeconds: number): () => void { 31 | // let timeoutId: NodeJS.Timeout; 32 | 33 | // return () => { 34 | // clearTimeout(timeoutId); 35 | // timeoutId = setTimeout(func, delaySeconds * 1000); 36 | // }; 37 | // } 38 | 39 | function updateStatusBarItem(): void { 40 | // const startTime = Date.now(); 41 | 42 | const selected = getTokensForSelectedText(); 43 | const total = getTotalTokensForEditor(); 44 | if (selected > 0 || total > 0) { 45 | let s = `${total}$(cptx-tkn)`; 46 | if (selected > 0) { 47 | s += ` ${selected}$(cptx-sel)`; 48 | } 49 | cptxStatusBarItem.text = s; 50 | cptxStatusBarItem.show(); 51 | } else { 52 | cptxStatusBarItem.hide(); 53 | } 54 | // ~10ms for a large file with ~5k tokens 55 | 56 | // const endTime = Date.now(); 57 | // const elapsedTime = endTime - startTime; 58 | // debugLog(`updateStatusBarItem -> ${elapsedTime}ms`); 59 | } 60 | 61 | function getTokensForSelectedText(): number { 62 | const activeEditor = vscode.window.activeTextEditor; 63 | let tokens = 0; 64 | if (activeEditor) { 65 | let selectedText = activeEditor.document 66 | .getText(activeEditor.selection) 67 | .trim(); 68 | tokens = countTokens(selectedText); 69 | } 70 | return tokens; 71 | } 72 | 73 | function getTotalTokensForEditor(): number { 74 | let totalTokens = 0; 75 | const activeEditor = vscode.window.activeTextEditor; 76 | if (activeEditor) { 77 | const text = activeEditor.document.getText(); 78 | totalTokens = countTokens(text); 79 | } 80 | return totalTokens; 81 | } 82 | 83 | export { initStatusBar }; 84 | -------------------------------------------------------------------------------- /src/telemetry.ts: -------------------------------------------------------------------------------- 1 | import TelemetryReporter from "@vscode/extension-telemetry"; 2 | import { getContextSize } from "./common"; 3 | 4 | let reporter: TelemetryReporter; 5 | 6 | function initTelemetry(): TelemetryReporter { 7 | reporter = new TelemetryReporter(K); 8 | setTimeout(() => { 9 | sendExtensionStarted(); 10 | }, 20); 11 | return reporter; 12 | } 13 | 14 | // send event any time after activation 15 | function sendExtensionStarted() { 16 | if (reporter) { 17 | reporter.sendTelemetryEvent('extensionStarted'); 18 | } 19 | } 20 | 21 | function sendCreateEvent( 22 | promptTokensCalculated: number, 23 | propmtTokens: number, 24 | completionTokens: number, 25 | durationSeconds: number 26 | ) { 27 | if (reporter) { 28 | reporter.sendTelemetryEvent('createCommand', undefined, { 29 | contextSize: getContextSize(), 30 | promptTokensCalculated: promptTokensCalculated, 31 | propmtTokens: propmtTokens, 32 | completionTokens: completionTokens, 33 | durationSeconds: durationSeconds, 34 | }); 35 | } 36 | } 37 | 38 | function sendCreateCanceledEvent(durationSeconds: number) { 39 | if (reporter) { 40 | reporter.sendTelemetryEvent('createCommandCanceled', undefined, { 41 | contextSize: getContextSize(), 42 | durationSeconds: durationSeconds, 43 | }); 44 | } 45 | } 46 | 47 | function sendExplainEvent( 48 | promptTokensCalculated: number, 49 | propmtTokens: number, 50 | completionTokens: number, 51 | durationSeconds: number 52 | ) { 53 | if (reporter) { 54 | reporter.sendTelemetryEvent('explainCommand', undefined, { 55 | contextSize: getContextSize(), 56 | promptTokensCalculated: promptTokensCalculated, 57 | propmtTokens: propmtTokens, 58 | completionTokens: completionTokens, 59 | durationSeconds: durationSeconds, 60 | }); 61 | } 62 | } 63 | 64 | function sendExplainCanceledEvent(durationSeconds: number) { 65 | if (reporter) { 66 | reporter.sendTelemetryEvent('explainCommandCanceled', undefined, { 67 | contextSize: getContextSize(), 68 | durationSeconds: durationSeconds, 69 | }); 70 | } 71 | } 72 | 73 | // Adding the requested send function for the `configurationChanged` event with additional parameters `contextSize` and `apiProvider` 74 | 75 | function sendConfigurationChangedEvent( 76 | apiProvider: string, 77 | contextSize: number, 78 | explanationInTab: boolean 79 | ) { 80 | if (reporter) { 81 | reporter.sendTelemetryEvent('configurationChanged', { 82 | apiProvider: apiProvider, 83 | contextSize: contextSize.toString(), 84 | explanationInTab: explanationInTab.toString() 85 | }); 86 | } 87 | } 88 | 89 | // End of code block 90 | 91 | const K = new TextDecoder().decode( 92 | new Uint8Array([ 93 | 101, 99, 50, 52, 57, 52, 97, 52, 45, 50, 52, 56, 49, 45, 52, 98, 51, 50, 45, 94 | 57, 50, 99, 54, 45, 98, 98, 101, 55, 100, 56, 99, 51, 55, 98, 49, 98, 95 | ]) 96 | ); 97 | 98 | export { 99 | initTelemetry, 100 | sendCreateEvent, 101 | sendExplainEvent, 102 | sendCreateCanceledEvent, 103 | sendExplainCanceledEvent, 104 | sendConfigurationChangedEvent, 105 | }; 106 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "backlog/*" 4 | ], 5 | "compilerOptions": { 6 | "module": "commonjs", 7 | "target": "ES2020", 8 | "lib": [ 9 | "ES2020" 10 | ], 11 | "sourceMap": true, 12 | "rootDir": "src", 13 | "strict": true /* enable all strict type-checking options */ 14 | /* Additional Checks */ 15 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 16 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 17 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | 'use strict'; 4 | 5 | const path = require('path'); 6 | 7 | //@ts-check 8 | /** @typedef {import('webpack').Configuration} WebpackConfig **/ 9 | 10 | /** @type WebpackConfig */ 11 | const extensionConfig = { 12 | target: 'node', // VS Code extensions run in a Node.js-context 📖 -> https://webpack.js.org/configuration/node/ 13 | mode: 'none', // this leaves the source code as close as possible to the original (when packaging we set this to 'production') 14 | 15 | entry: './src/extension.ts', // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/ 16 | output: { 17 | // the bundle is stored in the 'dist' folder (check package.json), 📖 -> https://webpack.js.org/configuration/output/ 18 | path: path.resolve(__dirname, 'dist'), 19 | filename: 'extension.js', 20 | libraryTarget: 'commonjs2' 21 | }, 22 | externals: { 23 | vscode: 'commonjs vscode' // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/ 24 | // modules added here also need to be added in the .vscodeignore file 25 | }, 26 | resolve: { 27 | // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader 28 | extensions: ['.ts', '.js'] 29 | }, 30 | module: { 31 | rules: [ 32 | { 33 | test: /\.ts$/, 34 | exclude: /node_modules/, 35 | use: [ 36 | { 37 | loader: 'ts-loader' 38 | } 39 | ] 40 | } 41 | ] 42 | }, 43 | devtool: 'nosources-source-map', 44 | infrastructureLogging: { 45 | level: "log", // enables logging required for problem matchers 46 | }, 47 | }; 48 | module.exports = [ extensionConfig ]; --------------------------------------------------------------------------------