From 875ab4cdcc231aca729335f240b57ebc6bc92e22 Mon Sep 17 00:00:00 2001 From: Nate Sesti Date: Mon, 8 Jul 2024 21:49:49 -0700 Subject: [PATCH 01/20] update package.json version --- extensions/vscode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 6a742115e6..cd5b1989c5 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -1,7 +1,7 @@ { "name": "continue", "icon": "media/icon.png", - "version": "0.9.178", + "version": "0.9.179", "repository": { "type": "git", "url": "https://github.com/continuedev/continue" From ff57b0080d3c9ef5d8ed8f36601f48317913fe9a Mon Sep 17 00:00:00 2001 From: Nate Sesti <33237525+sestinj@users.noreply.github.com> Date: Mon, 8 Jul 2024 22:05:21 -0700 Subject: [PATCH 02/20] =?UTF-8?q?=F0=9F=AA=84=20model=20dropdown,=20contro?= =?UTF-8?q?l=20plane=20(#1692)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Nate/control plane client (#1691) * docs: add docs and schema for "OS" provider (#1536) * ignore .env * fix(gui): ctx rendering w/ renderInlineAs: "" (#1541) * ✨ use and cache imports for autocomplete (#1456) * ✨ use and cache imports for autocomplete * fix tsc * fix codeqwen autocomplete leading space * add voyage rerank-1 * feat: `--noEmit` for tsc checks in CI (#1559) * docs: update CustomContextProvider docs (#1557) * add stop tokens to qwen prompt * update docs to reflect 3.5 sonnet being best * docs: comment out unused providers (#1561) * import Handlebars * feat: toast notification for config updates (#1560) * feat: toast notification for config updates * feat: only trigger toast on config.json save * displayRawMarkdown option * feat: open pane on install (#1564) * feat: open pane on activation * comment out testing code * update to reflect 16 stop words limit for deepseek * feat: only trigger config update toast in vscode (#1571) * docs(prompt-files): fix typos + clarify (#1575) * doc: prompt file typo + clarifications * fix: add back correct docs * chore: add telemetry for pageviews (#1576) * feat: update onboarding w/ embeddings model (#1570) * chore(gui): remove unused pages * feat: add embeddings step * feat: update styles * feat: copy button updates * fix: correct pull command for embed model * fix: remove commented code * fix: remove commented code * feat: simplify copy btn props * chore: rename onboarding selection event * feat: add provider config * fix: undo msg name * remove dead code * fix: invalid mode check * fix: remove testing logic * docs(telemetry): add pageviews to tracking list (#1581) * Add reranker configuration options to codebase embedding docs (#1584) - Introduce reranker concept - List available reranker options - Provide configuration instructions - Update keywords to include "reranker" * chore: update pr template with screenshots (#1590) * Refactor ConfirmationDialog to use SecondaryButton for cancel action (#1586) * Added instructions for running docs server locally (#1578) - Added NPM script method - Added VS Code task method - Update contributing guidelines * Update branch policy (#1577) - Change PR target to `dev` branch - Update `CONTRIBUTING.md` instructions * Consolidate example configurations into the main configuration guide (#1579) - Moved examples to configuration.md - Deleted the separate examples.md file - Updated sidebar order and links - Improved readability and structure in configuration.md * fix: fullscreen gui retains context when hidden, fixed fullscreen focusing (#1582) * Update completionProvider.ts (warning tab-autocomplete models) (#1566) * feat: enhanced IndexingProgressBar with blinking dot feature - Integrated BlinkingDot component - Added STATUS_COLORS for various states - Replaced CircleDiv with BlinkingDot in UI - Updated status messages and layout * small UI tweaks * feat(gui): enhance ModelCard, ModelProviderTag, and Toggle components (#1595) - add styling and adjustments to ModelCard - update ModelProviderTag font size - remove box shadow from Toggle component - tweak icon styles in ModelCard - improve alignment and spacing * media query * feat: add best experience onboarding * fix: file rename * stop movement on button hover by keeping same border thickness * fix mistake in setting cursor: pointer * fix when free trial option is shown * Support Node.js versions below 20 for streaming response handling (#1591) - Add fallback for Node < 20 - Implement toAsyncIterable for streaming - Use TextDecoder for manual decoding - Maintain existing streaming for Node 20+ * small fixes * feat: add free trial card to onboarding (#1600) * feat: add free trial card to onboarding * add import * fix hasPassedFTL * fix /edit cancellation from UI * feat: add `applyCodeBlock` experimental prop (#1601) * feat: add new model styling improvements (#1609) * feat: add new model styling improvements * better gap size * feat: update bug_report.yml (#1610) * chore: update bug_report.yml * typo fix * feat: add labels to "Add docs" dialog (#1612) * feat: add labels to "Add docs" dialog * remove autofocus * don't double load config * small fixes * speed up directory traversal, and use correct native path module * option not to show config update toast * merge air-gapped and recommended setup pages * chore: add telemetry for full screen toggle (#1618) * Fix headings in codebase-embeddings.md (#1617) * mention jetbrains * docs: update changie (#1619) * feat: updated changie config * hide toc and autogenerate * Update changelog.mdx * link to deeper explanation of embeddings models * ensure target="_blank" for all links in sidebar * fix gif links in intellij README.md * don't require rust in dependency installation * chore: fix padding on gh button (#1620) * chore: adjust button padding * Update tasks.json * escape colons in diff path * smoother lancedb indexing reporting * smooth progress updates for indexing * fix tsc err * rerank-lite-1 * remove doccs * basic tests for VS Code extension * improved testing of VS Code extension * docs: add docs and schema for "OS" provider (#1536) * ignore .env * πŸš‘ fix constant warnings when onboarding with Ollama * ✨ use and cache imports for autocomplete (#1456) * ✨ use and cache imports for autocomplete * fix tsc * team analytics * apply control plane settings * workos auth * ide protocol get session info * UI for auth * profile switching * small fixes * updates * refresh tokens * updates * fix tsc errs * model select in toolbar to make room for profile selector * prod client id * link to prod URL * internal beta option * profiles change listener --------- Co-authored-by: Patrick Erichsen Co-authored-by: Priyash <38959321+priyashpatil@users.noreply.github.com> Co-authored-by: Jonah Wagner Co-authored-by: YohannZe <99359799+YohannZe@users.noreply.github.com> Co-authored-by: Dan Dascalescu * temporary patch for type errs --------- Co-authored-by: Patrick Erichsen Co-authored-by: Priyash <38959321+priyashpatil@users.noreply.github.com> Co-authored-by: Jonah Wagner Co-authored-by: YohannZe <99359799+YohannZe@users.noreply.github.com> Co-authored-by: Dan Dascalescu --- .github/workflows/main.yaml | 26 +- .github/workflows/preview.yaml | 10 +- .vscode/tasks.json | 4 +- binary/test/binary.test.ts | 6 +- core/autocomplete/completionProvider.ts | 33 +- core/config/ConfigHandler.ts | 269 ++++++++--- core/config/IConfigHandler.ts | 17 - core/config/load.ts | 82 ++-- .../profile/ControlPlaneProfileLoader.ts | 101 +++++ core/config/profile/IProfileLoader.ts | 11 + core/config/profile/LocalProfileLoader.ts | 53 +++ core/control-plane/TeamAnalytics.ts | 48 ++ core/control-plane/client.ts | 94 ++++ core/control-plane/schema.ts | 128 ++++++ core/core.ts | 55 ++- core/index.d.ts | 1 + core/indexing/CodebaseIndexer.ts | 4 +- core/llm/index.ts | 16 +- core/package-lock.json | 11 +- core/package.json | 3 +- core/protocol/core.ts | 9 +- core/protocol/coreWebview.ts | 9 +- core/protocol/ide.ts | 13 + core/protocol/index.ts | 13 +- core/protocol/passThrough.ts | 6 +- core/test/indexing/CodebaseIndexer.skip.ts | 1 + core/util/GlobalContext.ts | 1 + core/util/filesystem.ts | 1 + core/util/paths.ts | 5 + core/util/posthog.ts | 11 +- core/util/treeSitter.ts | 8 +- core/util/verticalEdit.ts | 12 +- extensions/intellij/CHANGELOG.md | 41 +- .../continue/CoreMessenger.kt | 3 +- .../editor/InlineEditAction.kt | 2 +- .../toolWindow/ContinueBrowser.kt | 2 +- extensions/vscode/.continueignore | 2 +- extensions/vscode/package-lock.json | 3 +- extensions/vscode/package.json | 2 +- extensions/vscode/src/activation/activate.ts | 16 +- .../vscode/src/activation/languageClient.ts | 3 +- .../src/autocomplete/completionProvider.ts | 4 +- extensions/vscode/src/commands.ts | 6 +- extensions/vscode/src/debugPanel.ts | 4 +- .../src/diff/verticalPerLine/manager.ts | 4 +- extensions/vscode/src/extension.ts | 10 +- .../vscode/src/extension/VsCodeExtension.ts | 15 +- .../vscode/src/extension/VsCodeMessenger.ts | 27 +- extensions/vscode/src/ideProtocol.ts | 6 + extensions/vscode/src/quickEdit/QuickEdit.ts | 4 +- .../vscode/src/stubs/WorkOsAuthProvider.ts | 418 ++++++++++++++++++ extensions/vscode/src/stubs/promiseUtils.ts | 57 +++ extensions/vscode/src/util/cleanSlate.ts | 3 + .../vscode/src/util/loadAutocompleteModel.ts | 6 +- extensions/vscode/src/webviewProtocol.ts | 43 +- gui/package-lock.json | 3 +- gui/src/components/Layout.tsx | 34 +- gui/src/components/PosthogPageView.ts | 4 +- gui/src/components/ProfileSwitcher.tsx | 259 +++++++++++ gui/src/components/loaders/BlinkingDot.tsx | 34 ++ gui/src/components/mainInput/InputToolbar.tsx | 2 + .../modelSelection/LegacyModelSelect.tsx | 305 +++++++++++++ .../components/modelSelection/ModelSelect.tsx | 246 ++++------- gui/src/hooks/useAuth.tsx | 54 +++ gui/src/hooks/useSetup.ts | 6 +- .../pages/onboarding/ApiKeysOnboarding.tsx | 6 +- gui/src/pages/onboarding/LocalOnboarding.tsx | 6 +- gui/src/redux/slices/stateSlice.ts | 9 + .../control-plane-types/package-lock.json | 29 ++ packages/control-plane-types/package.json | 15 + packages/control-plane-types/src/index.d.ts | 4 + packages/control-plane-types/tsconfig.json | 108 +++++ 72 files changed, 2393 insertions(+), 473 deletions(-) delete mode 100644 core/config/IConfigHandler.ts create mode 100644 core/config/profile/ControlPlaneProfileLoader.ts create mode 100644 core/config/profile/IProfileLoader.ts create mode 100644 core/config/profile/LocalProfileLoader.ts create mode 100644 core/control-plane/TeamAnalytics.ts create mode 100644 core/control-plane/client.ts create mode 100644 core/control-plane/schema.ts create mode 100644 extensions/vscode/src/stubs/WorkOsAuthProvider.ts create mode 100644 extensions/vscode/src/stubs/promiseUtils.ts create mode 100644 gui/src/components/ProfileSwitcher.tsx create mode 100644 gui/src/components/loaders/BlinkingDot.tsx create mode 100644 gui/src/components/modelSelection/LegacyModelSelect.tsx create mode 100644 gui/src/hooks/useAuth.tsx create mode 100644 packages/control-plane-types/package-lock.json create mode 100644 packages/control-plane-types/package.json create mode 100644 packages/control-plane-types/src/index.d.ts create mode 100644 packages/control-plane-types/tsconfig.json diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 166fe5c953..414b52f7d3 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -125,19 +125,19 @@ jobs: # 4. Run tests for the extension # - name: Install Xvfb for Linux and run tests - # run: | - # sudo apt-get install -y xvfb # Install Xvfb - # Xvfb :99 & # Start Xvfb - # export DISPLAY=:99 # Export the display number to the environment - # cd extensions/vscode - # npm run test - # if: matrix.os == 'ubuntu-latest' - - # - name: Run extension tests - # run: | - # cd extensions/vscode - # npm run test - # if: matrix.os != 'ubuntu-latest' + run: | + sudo apt-get install -y xvfb # Install Xvfb + Xvfb :99 & # Start Xvfb + export DISPLAY=:99 # Export the display number to the environment + cd extensions/vscode + npm run test + if: matrix.os == 'ubuntu-latest' + + - name: Run extension tests + run: | + cd extensions/vscode + npm run test + if: matrix.os != 'ubuntu-latest' # 5. Package the extension - name: Package the extension diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml index 9c5c50c336..17b771a138 100644 --- a/.github/workflows/preview.yaml +++ b/.github/workflows/preview.yaml @@ -124,11 +124,11 @@ jobs: # npm run test # if: matrix.os == 'ubuntu-latest' - # - name: Run extension tests - # run: | - # cd extensions/vscode - # npm run test - # if: matrix.os != 'ubuntu-latest' + - name: Run extension tests + run: | + cd extensions/vscode + npm run test + if: matrix.os != 'ubuntu-latest' # 5. Package the extension - name: Package the extension diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 49dbba82e3..3850dd66b3 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -12,7 +12,9 @@ // To bundle the code the same way we do for publishing "vscode-extension:esbuild", // Start the React app that is used in the extension - "gui:dev" + "gui:dev", + // Start the docs site, without opening the browser + "docs:start" ], "group": { "kind": "build", diff --git a/binary/test/binary.test.ts b/binary/test/binary.test.ts index 5db4ff52ff..fdd9f2ced2 100644 --- a/binary/test/binary.test.ts +++ b/binary/test/binary.test.ts @@ -107,7 +107,7 @@ describe("Test Suite", () => { // Many of the files are only created when trying to load the config const config = await messenger.request( - "config/getBrowserSerialized", + "config/getSerializedProfileInfo", undefined, ); @@ -128,8 +128,8 @@ describe("Test Suite", () => { }); it("should properly edit config", async () => { - const config = await messenger.request( - "config/getBrowserSerialized", + const { config } = await messenger.request( + "config/getSerializedProfileInfo", undefined, ); expect(config).toHaveProperty("models"); diff --git a/core/autocomplete/completionProvider.ts b/core/autocomplete/completionProvider.ts index ef73d9b58d..a44d1b19f4 100644 --- a/core/autocomplete/completionProvider.ts +++ b/core/autocomplete/completionProvider.ts @@ -1,10 +1,9 @@ -import Handlebars from "handlebars"; import ignore from "ignore"; import OpenAI from "openai"; import path from "path"; import { v4 as uuidv4 } from "uuid"; import { RangeInFileWithContents } from "../commands/util.js"; -import { IConfigHandler } from "../config/IConfigHandler.js"; +import { ConfigHandler } from "../config/ConfigHandler.js"; import { TRIAL_FIM_MODEL } from "../config/onboarding.js"; import { streamLines } from "../diff/util.js"; import { @@ -145,7 +144,7 @@ export class CompletionProvider { private static lastUUID: string | undefined = undefined; constructor( - private readonly configHandler: IConfigHandler, + private readonly configHandler: ConfigHandler, private readonly ide: IDE, private readonly getLlm: () => Promise, private readonly _onError: (e: any) => void, @@ -201,13 +200,17 @@ export class CompletionProvider { const outcome = this._outcomes.get(completionId)!; outcome.accepted = true; logDevData("autocomplete", outcome); - Telemetry.capture("autocomplete", { - accepted: outcome.accepted, - modelName: outcome.modelName, - modelProvider: outcome.modelProvider, - time: outcome.time, - cacheHit: outcome.cacheHit, - }); + Telemetry.capture( + "autocomplete", + { + accepted: outcome.accepted, + modelName: outcome.modelName, + modelProvider: outcome.modelProvider, + time: outcome.time, + cacheHit: outcome.cacheHit, + }, + true, + ); this._outcomes.delete(completionId); this.bracketMatchingService.handleAcceptedCompletion( @@ -358,9 +361,13 @@ export class CompletionProvider { outcome.accepted = false; logDevData("autocomplete", outcome); const { prompt, completion, ...restOfOutcome } = outcome; - Telemetry.capture("autocomplete", { - ...restOfOutcome, - }); + Telemetry.capture( + "autocomplete", + { + ...restOfOutcome, + }, + true, + ); this._logRejectionTimeouts.delete(completionId); }, COUNT_COMPLETION_REJECTED_AFTER); this._outcomes.set(completionId, outcome); diff --git a/core/config/ConfigHandler.ts b/core/config/ConfigHandler.ts index 1d9a8c958f..6b37539e45 100644 --- a/core/config/ConfigHandler.ts +++ b/core/config/ConfigHandler.ts @@ -1,116 +1,251 @@ +import { ControlPlaneClient } from "../control-plane/client.js"; import { BrowserSerializedContinueConfig, ContinueConfig, - ContinueRcJson, IContextProvider, IDE, IdeSettings, ILLM, } from "../index.js"; -import { Telemetry } from "../util/posthog.js"; -import { IConfigHandler } from "./IConfigHandler.js"; -import { finalToBrowserConfig, loadFullConfigNode } from "./load.js"; +import { GlobalContext } from "../util/GlobalContext.js"; +import { finalToBrowserConfig } from "./load.js"; +import ControlPlaneProfileLoader from "./profile/ControlPlaneProfileLoader.js"; +import { IProfileLoader } from "./profile/IProfileLoader.js"; +import LocalProfileLoader from "./profile/LocalProfileLoader.js"; -export class ConfigHandler implements IConfigHandler { +export interface ProfileDescription { + title: string; + id: string; +} + +// Separately manages saving/reloading each profile +class ProfileLifecycleManager { private savedConfig: ContinueConfig | undefined; private savedBrowserConfig?: BrowserSerializedContinueConfig; + private pendingConfigPromise?: Promise; + + constructor(private readonly profileLoader: IProfileLoader) {} + + get profileId() { + return this.profileLoader.profileId; + } + + get profileTitle() { + return this.profileLoader.profileTitle; + } + + get profileDescription(): ProfileDescription { + return { + title: this.profileTitle, + id: this.profileId, + }; + } + + clearConfig() { + this.savedConfig = undefined; + this.savedBrowserConfig = undefined; + this.pendingConfigPromise = undefined; + } + + // Clear saved config and reload + reloadConfig(): Promise { + this.savedConfig = undefined; + this.savedBrowserConfig = undefined; + this.pendingConfigPromise = undefined; + + return this.profileLoader.doLoadConfig(); + } + + async loadConfig( + additionalContextProviders: IContextProvider[], + ): Promise { + // If we already have a config, return it + if (this.savedConfig) { + return this.savedConfig; + } else if (this.pendingConfigPromise) { + return this.pendingConfigPromise; + } + + // Set pending config promise + this.pendingConfigPromise = new Promise(async (resolve, reject) => { + const newConfig = await this.profileLoader.doLoadConfig(); + + // Add registered context providers + newConfig.contextProviders = (newConfig.contextProviders ?? []).concat( + additionalContextProviders, + ); + + this.savedConfig = newConfig; + resolve(newConfig); + }); + + // Wait for the config promise to resolve + this.savedConfig = await this.pendingConfigPromise; + this.pendingConfigPromise = undefined; + return this.savedConfig; + } + + async getSerializedConfig( + additionalContextProviders: IContextProvider[], + ): Promise { + if (!this.savedBrowserConfig) { + const continueConfig = await this.loadConfig(additionalContextProviders); + this.savedBrowserConfig = finalToBrowserConfig(continueConfig); + } + return this.savedBrowserConfig; + } +} + +export class ConfigHandler { + private readonly globalContext = new GlobalContext(); private additionalContextProviders: IContextProvider[] = []; + private profiles: ProfileLifecycleManager[]; + private selectedProfileId: string; + + // This will be the local profile + private get fallbackProfile() { + return this.profiles[0]; + } + + get currentProfile() { + return ( + this.profiles.find((p) => p.profileId === this.selectedProfileId) ?? + this.fallbackProfile + ); + } + + get inactiveProfiles() { + return this.profiles.filter((p) => p.profileId !== this.selectedProfileId); + } constructor( private readonly ide: IDE, private ideSettingsPromise: Promise, private readonly writeLog: (text: string) => Promise, + private readonly controlPlaneClient: ControlPlaneClient, ) { this.ide = ide; this.ideSettingsPromise = ideSettingsPromise; this.writeLog = writeLog; + + // Set local profile as default + const localProfileLoader = new LocalProfileLoader( + ide, + ideSettingsPromise, + writeLog, + ); + this.profiles = [new ProfileLifecycleManager(localProfileLoader)]; + this.selectedProfileId = localProfileLoader.profileId; + + // Always load local profile immediately in case control plane doesn't load try { this.loadConfig(); } catch (e) { console.error("Failed to load config: ", e); } + + // Load control plane profiles + // TODO + // Get the profiles and create their lifecycle managers + this.controlPlaneClient.listWorkspaces().then(async (workspaces) => { + workspaces.forEach((workspace) => { + const profileLoader = new ControlPlaneProfileLoader( + workspace.id, + workspace.name, + this.controlPlaneClient, + ide, + ideSettingsPromise, + writeLog, + this.reloadConfig.bind(this), + ); + this.profiles.push(new ProfileLifecycleManager(profileLoader)); + }); + + this.notifyProfileListeners( + this.profiles.map((profile) => profile.profileDescription), + ); + + // Check the last selected workspace, and reload if it isn't local + const workspaceId = await this.getWorkspaceId(); + const lastSelectedWorkspaceIds = + this.globalContext.get("lastSelectedProfileForWorkspace") ?? {}; + const selectedWorkspaceId = lastSelectedWorkspaceIds[workspaceId]; + if (selectedWorkspaceId) { + this.selectedProfileId = selectedWorkspaceId; + this.loadConfig(); + } else { + // Otherwise we stick with local profile, and record choice + lastSelectedWorkspaceIds[workspaceId] = this.selectedProfileId; + this.globalContext.update( + "lastSelectedProfileForWorkspace", + lastSelectedWorkspaceIds, + ); + } + }); } + async setSelectedProfile(profileId: string) { + this.selectedProfileId = profileId; + const newConfig = await this.loadConfig(); + this.notifyConfigListerners(newConfig); + } + + // A unique ID for the current workspace, built from folder names + private async getWorkspaceId(): Promise { + const dirs = await this.ide.getWorkspaceDirs(); + return dirs.join("&"); + } + + // Automatically refresh config when Continue-related IDE (e.g. VS Code) settings are changed updateIdeSettings(ideSettings: IdeSettings) { this.ideSettingsPromise = Promise.resolve(ideSettings); this.reloadConfig(); } - private updateListeners: ((newConfig: ContinueConfig) => void)[] = []; - onConfigUpdate(listener: (newConfig: ContinueConfig) => void) { - this.updateListeners.push(listener); + private profilesListeners: ((profiles: ProfileDescription[]) => void)[] = []; + onDidChangeAvailableProfiles( + listener: (profiles: ProfileDescription[]) => void, + ) { + this.profilesListeners.push(listener); } - async reloadConfig() { - this.savedConfig = undefined; - this.savedBrowserConfig = undefined; - this._pendingConfigPromise = undefined; - - const newConfig = await this.loadConfig(); + private notifyProfileListeners(profiles: ProfileDescription[]) { + for (const listener of this.profilesListeners) { + listener(profiles); + } + } + private notifyConfigListerners(newConfig: ContinueConfig) { + // Notify listeners that config changed for (const listener of this.updateListeners) { listener(newConfig); } } - async getSerializedConfig(): Promise { - if (!this.savedBrowserConfig) { - this.savedConfig = await this.loadConfig(); - this.savedBrowserConfig = finalToBrowserConfig(this.savedConfig); - } - return this.savedBrowserConfig; + private updateListeners: ((newConfig: ContinueConfig) => void)[] = []; + onConfigUpdate(listener: (newConfig: ContinueConfig) => void) { + this.updateListeners.push(listener); } - private _pendingConfigPromise?: Promise; - async loadConfig(): Promise { - if (this.savedConfig) { - return this.savedConfig; - } else if (this._pendingConfigPromise) { - return this._pendingConfigPromise; - } - - this._pendingConfigPromise = new Promise(async (resolve, reject) => { - let workspaceConfigs: ContinueRcJson[] = []; - try { - workspaceConfigs = await this.ide.getWorkspaceConfigs(); - } catch (e) { - console.warn("Failed to load workspace configs"); - } - - const ideInfo = await this.ide.getIdeInfo(); - const uniqueId = await this.ide.getUniqueId(); - const ideSettings = await this.ideSettingsPromise; - - const newConfig = await loadFullConfigNode( - this.ide, - workspaceConfigs, - ideSettings, - ideInfo.ideType, - uniqueId, - this.writeLog, - ); - newConfig.allowAnonymousTelemetry = - newConfig.allowAnonymousTelemetry && - (await this.ide.isTelemetryEnabled()); - - // Setup telemetry only after (and if) we know it is enabled - await Telemetry.setup( - newConfig.allowAnonymousTelemetry ?? true, - await this.ide.getUniqueId(), - ideInfo.extensionVersion, - ); + async reloadConfig() { + // TODO: this isn't right, there are two different senses in which you want to "reload" + const newConfig = await this.currentProfile.reloadConfig(); + this.inactiveProfiles.forEach((profile) => profile.clearConfig()); + this.notifyConfigListerners(newConfig); + } - (newConfig.contextProviders ?? []).push( - ...this.additionalContextProviders, - ); + getSerializedConfig(): Promise { + return this.currentProfile.getSerializedConfig( + this.additionalContextProviders, + ); + } - this.savedConfig = newConfig; - resolve(newConfig); - }); + listProfiles(): ProfileDescription[] { + return this.profiles.map((p) => p.profileDescription); + } - this.savedConfig = await this._pendingConfigPromise; - this._pendingConfigPromise = undefined; - return this.savedConfig; + async loadConfig(): Promise { + return this.currentProfile.loadConfig(this.additionalContextProviders); } async llmFromTitle(title?: string): Promise { diff --git a/core/config/IConfigHandler.ts b/core/config/IConfigHandler.ts deleted file mode 100644 index 8177e7e03e..0000000000 --- a/core/config/IConfigHandler.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { - BrowserSerializedContinueConfig, - ContinueConfig, - IContextProvider, - IdeSettings, - ILLM, -} from "../index.js"; - -export interface IConfigHandler { - updateIdeSettings(ideSettings: IdeSettings): void; - onConfigUpdate(listener: (newConfig: ContinueConfig) => void): void; - reloadConfig(): Promise; - getSerializedConfig(): Promise; - loadConfig(): Promise; - llmFromTitle(title?: string): Promise; - registerCustomContextProvider(contextProvider: IContextProvider): void; -} diff --git a/core/config/load.ts b/core/config/load.ts index 69521ddaa6..3609fd2415 100644 --- a/core/config/load.ts +++ b/core/config/load.ts @@ -46,7 +46,6 @@ import { getConfigJsonPathForRemote, getConfigTsPath, getContinueDotEnv, - migrate, readAllGlobalPromptFiles, } from "../util/paths.js"; import { @@ -108,19 +107,6 @@ function loadSerializedConfig( config.allowAnonymousTelemetry = true; } - migrate("codeContextProvider", () => { - const gpt = config.models.find( - (model) => - model.model.startsWith("gpt-4") && model.provider === "free-trial", - ); - if (gpt) { - gpt.systemMessage = - "You are an expert software developer. You give helpful and concise responses."; - } - - fs.writeFileSync(configPath, JSON.stringify(config, undefined, 2), "utf8"); - }); - if (ideSettings.remoteConfigServerUrl) { try { const remoteConfigJson = resolveSerializedConfig( @@ -157,6 +143,7 @@ function loadSerializedConfig( async function serializedToIntermediateConfig( initial: SerializedContinueConfig, ide: IDE, + loadPromptFiles: boolean = true, ): Promise { const slashCommands: SlashCommand[] = []; for (const command of initial.slashCommands || []) { @@ -172,25 +159,27 @@ async function serializedToIntermediateConfig( const workspaceDirs = await ide.getWorkspaceDirs(); const promptFolder = initial.experimental?.promptPath; - let promptFiles: { path: string; content: string }[] = []; - promptFiles = ( - await Promise.all( - workspaceDirs.map((dir) => - getPromptFiles( - ide, - path.join(dir, promptFolder ?? DEFAULT_PROMPTS_FOLDER), + if (loadPromptFiles) { + let promptFiles: { path: string; content: string }[] = []; + promptFiles = ( + await Promise.all( + workspaceDirs.map((dir) => + getPromptFiles( + ide, + path.join(dir, promptFolder ?? DEFAULT_PROMPTS_FOLDER), + ), ), - ), + ) ) - ) - .flat() - .filter(({ path }) => path.endsWith(".prompt")); + .flat() + .filter(({ path }) => path.endsWith(".prompt")); - // Also read from ~/.continue/.prompts - promptFiles.push(...readAllGlobalPromptFiles()); + // Also read from ~/.continue/.prompts + promptFiles.push(...readAllGlobalPromptFiles()); - for (const file of promptFiles) { - slashCommands.push(slashCommandFromPromptFile(file.path, file.content)); + for (const file of promptFiles) { + slashCommands.push(slashCommandFromPromptFile(file.path, file.content)); + } } const config: Config = { @@ -221,9 +210,10 @@ async function intermediateToFinalConfig( ideSettings: IdeSettings, uniqueId: string, writeLog: (log: string) => Promise, + allowFreeTrial: boolean = true, ): Promise { // Auto-detect models - const models: BaseLLM[] = []; + let models: BaseLLM[] = []; for (const desc of config.models) { if (isModelDescription(desc)) { const llm = await llmFromDescription( @@ -304,15 +294,20 @@ async function intermediateToFinalConfig( }; } - // Obtain auth token (only if free trial being used) - const freeTrialModels = models.filter( - (model) => model.providerName === "free-trial", - ); - if (freeTrialModels.length > 0) { - const ghAuthToken = await ide.getGitHubAuthToken(); - for (const model of freeTrialModels) { - (model as FreeTrial).setupGhAuthToken(ghAuthToken); + if (allowFreeTrial) { + // Obtain auth token (iff free trial being used) + const freeTrialModels = models.filter( + (model) => model.providerName === "free-trial", + ); + if (freeTrialModels.length > 0) { + const ghAuthToken = await ide.getGitHubAuthToken(); + for (const model of freeTrialModels) { + (model as FreeTrial).setupGhAuthToken(ghAuthToken); + } } + } else { + // Remove free trial models + models = models.filter((model) => model.providerName !== "free-trial"); } // Tab autocomplete model @@ -336,6 +331,10 @@ async function intermediateToFinalConfig( ); if (llm?.providerName === "free-trial") { + if (!allowFreeTrial) { + // This shouldn't happen + throw new Error("Free trial cannot be used with control plane"); + } const ghAuthToken = await ide.getGitHubAuthToken(); (llm as FreeTrial).setupGhAuthToken(ghAuthToken); } @@ -538,9 +537,13 @@ async function loadFullConfigNode( uniqueId: string, writeLog: (log: string) => Promise, ): Promise { + // Serialized config let serialized = loadSerializedConfig(workspaceConfigs, ideSettings, ideType); + + // Convert serialized to intermediate config let intermediate = await serializedToIntermediateConfig(serialized, ide); + // Apply config.ts to modify intermediate config const configJsContents = await buildConfigTs(); if (configJsContents) { try { @@ -557,7 +560,7 @@ async function loadFullConfigNode( } } - // Remote config.js + // Apply remote config.js to modify intermediate config if (ideSettings.remoteConfigServerUrl) { try { const configJsPathForRemote = getConfigJsPathForRemote( @@ -574,6 +577,7 @@ async function loadFullConfigNode( } } + // Convert to final config format const finalConfig = await intermediateToFinalConfig( intermediate, ide, diff --git a/core/config/profile/ControlPlaneProfileLoader.ts b/core/config/profile/ControlPlaneProfileLoader.ts new file mode 100644 index 0000000000..b3e1e3a4fc --- /dev/null +++ b/core/config/profile/ControlPlaneProfileLoader.ts @@ -0,0 +1,101 @@ +import { + ContinueConfig, + IDE, + IdeSettings, + SerializedContinueConfig, +} from "../.."; +import { ControlPlaneClient } from "../../control-plane/client"; +import { ControlPlaneSettings } from "../../control-plane/schema"; +import { TeamAnalytics } from "../../control-plane/TeamAnalytics"; +import { Telemetry } from "../../util/posthog"; +import { + defaultContextProvidersJetBrains, + defaultContextProvidersVsCode, + defaultSlashCommandsJetBrains, + defaultSlashCommandsVscode, +} from "../default"; +import { + intermediateToFinalConfig, + serializedToIntermediateConfig, +} from "../load"; +import { IProfileLoader } from "./IProfileLoader"; + +export default class ControlPlaneProfileLoader implements IProfileLoader { + private static RELOAD_INTERVAL = 1000 * 60 * 15; // every 15 minutes + + readonly profileId: string; + profileTitle: string; + + workspaceSettings: ControlPlaneSettings | undefined; + + constructor( + private readonly workspaceId: string, + private workspaceTitle: string, + private readonly controlPlaneClient: ControlPlaneClient, + private readonly ide: IDE, + private ideSettingsPromise: Promise, + private writeLog: (message: string) => Promise, + private readonly onReload: () => void, + ) { + this.profileId = workspaceId; + this.profileTitle = workspaceTitle; + + setInterval(async () => { + this.workspaceSettings = + await this.controlPlaneClient.getSettingsForWorkspace(this.profileId); + this.onReload(); + }, ControlPlaneProfileLoader.RELOAD_INTERVAL); + } + + async doLoadConfig(): Promise { + const ideInfo = await this.ide.getIdeInfo(); + const settings = + this.workspaceSettings ?? + ((await this.controlPlaneClient.getSettingsForWorkspace( + this.profileId, + )) as any); + + // First construct a SerializedContinueConfig from the ControlPlaneSettings + const serializedConfig: SerializedContinueConfig = { + models: settings.models, + tabAutocompleteModel: settings.tabAutocompleteModel, + embeddingsProvider: settings.embeddingsModel, + reranker: settings.reranker, + }; + + serializedConfig.contextProviders ??= + ideInfo.ideType === "vscode" + ? defaultContextProvidersVsCode + : defaultContextProvidersJetBrains; + serializedConfig.slashCommands ??= + ideInfo.ideType === "vscode" + ? defaultSlashCommandsVscode + : defaultSlashCommandsJetBrains; + + const intermediateConfig = await serializedToIntermediateConfig( + serializedConfig, + this.ide, + ); + + const uniqueId = await this.ide.getUniqueId(); + const finalConfig = await intermediateToFinalConfig( + intermediateConfig, + this.ide, + await this.ideSettingsPromise, + uniqueId, + this.writeLog, + ); + + // Set up team analytics/telemetry + await Telemetry.setup(true, uniqueId, ideInfo.extensionVersion); + await TeamAnalytics.setup( + settings.analytics, + uniqueId, + ideInfo.extensionVersion, + ); + + return finalConfig; + } + + setIsActive(isActive: boolean): void {} +} diff --git a/core/config/profile/IProfileLoader.ts b/core/config/profile/IProfileLoader.ts new file mode 100644 index 0000000000..ffd6e94fb3 --- /dev/null +++ b/core/config/profile/IProfileLoader.ts @@ -0,0 +1,11 @@ +// ProfileHandlers manage the loading of a config, allowing us to abstract over different ways of getting to a ContinueConfig + +import { ContinueConfig } from "../.."; + +// After we have the ContinueConfig, the ConfigHandler takes care of everything else (loading models, lifecycle, etc.) +export interface IProfileLoader { + profileTitle: string; + profileId: string; + doLoadConfig(): Promise; + setIsActive(isActive: boolean): void; +} diff --git a/core/config/profile/LocalProfileLoader.ts b/core/config/profile/LocalProfileLoader.ts new file mode 100644 index 0000000000..822ea826ce --- /dev/null +++ b/core/config/profile/LocalProfileLoader.ts @@ -0,0 +1,53 @@ +import { ContinueConfig, ContinueRcJson, IDE, IdeSettings } from "../.."; +import { Telemetry } from "../../util/posthog"; +import { loadFullConfigNode } from "../load"; +import { IProfileLoader } from "./IProfileLoader"; + +export default class LocalProfileLoader implements IProfileLoader { + static ID = "local"; + profileId = LocalProfileLoader.ID; + profileTitle = "config.json"; + + constructor( + private ide: IDE, + private ideSettingsPromise: Promise, + // private controlPlaneClient: ControlPlaneClient, + private writeLog: (message: string) => Promise, + ) {} + + async doLoadConfig(): Promise { + let workspaceConfigs: ContinueRcJson[] = []; + try { + workspaceConfigs = await this.ide.getWorkspaceConfigs(); + } catch (e) { + console.warn("Failed to load workspace configs"); + } + + const ideInfo = await this.ide.getIdeInfo(); + const uniqueId = await this.ide.getUniqueId(); + const ideSettings = await this.ideSettingsPromise; + + const newConfig = await loadFullConfigNode( + this.ide, + workspaceConfigs, + ideSettings, + ideInfo.ideType, + uniqueId, + this.writeLog, + ); + newConfig.allowAnonymousTelemetry = + newConfig.allowAnonymousTelemetry && + (await this.ide.isTelemetryEnabled()); + + // Setup telemetry only after (and if) we know it is enabled + await Telemetry.setup( + newConfig.allowAnonymousTelemetry ?? true, + await this.ide.getUniqueId(), + ideInfo.extensionVersion, + ); + + return newConfig; + } + + setIsActive(isActive: boolean): void {} +} diff --git a/core/control-plane/TeamAnalytics.ts b/core/control-plane/TeamAnalytics.ts new file mode 100644 index 0000000000..74a1c2b990 --- /dev/null +++ b/core/control-plane/TeamAnalytics.ts @@ -0,0 +1,48 @@ +import os from "node:os"; +import { ControlPlaneAnalytics } from "./schema"; + +export class TeamAnalytics { + static client: any = undefined; + static uniqueId = "NOT_UNIQUE"; + static os: string | undefined = undefined; + static extensionVersion: string | undefined = undefined; + + static async capture(event: string, properties: { [key: string]: any }) { + TeamAnalytics.client?.capture({ + distinctId: TeamAnalytics.uniqueId, + event, + properties: { + ...properties, + os: TeamAnalytics.os, + extensionVersion: TeamAnalytics.extensionVersion, + }, + }); + } + + static shutdownPosthogClient() { + TeamAnalytics.client?.shutdown(); + } + + static async setup( + config: ControlPlaneAnalytics, + uniqueId: string, + extensionVersion: string, + ) { + TeamAnalytics.uniqueId = uniqueId; + TeamAnalytics.os = os.platform(); + TeamAnalytics.extensionVersion = extensionVersion; + + if (!config || !config.clientKey || !config.url) { + TeamAnalytics.client = undefined; + } else { + try { + const { PostHog } = await import("posthog-node"); + TeamAnalytics.client = new PostHog(config.clientKey, { + host: config.url, + }); + } catch (e) { + console.error(`Failed to setup telemetry: ${e}`); + } + } + } +} diff --git a/core/control-plane/client.ts b/core/control-plane/client.ts new file mode 100644 index 0000000000..e687b6e477 --- /dev/null +++ b/core/control-plane/client.ts @@ -0,0 +1,94 @@ +import fetch, { RequestInit, Response } from "node-fetch"; +import { ModelDescription } from ".."; +import { ControlPlaneSettings } from "./schema"; + +export interface ControlPlaneSessionInfo { + accessToken: string; + account: { + label: string; + id: string; + }; +} + +export interface ControlPlaneWorkspace { + id: string; + name: string; + settings: ControlPlaneSettings; +} + +export interface ControlPlaneModelDescription extends ModelDescription {} + +// export const CONTROL_PLANE_URL = "http://localhost:3001"; +export const CONTROL_PLANE_URL = + "https://control-plane-api-service-i3dqylpbqa-uc.a.run.app"; + +export class ControlPlaneClient { + private static URL = CONTROL_PLANE_URL; + private static ACCESS_TOKEN_VALID_FOR_MS = 1000 * 60 * 5; // 5 minutes + + private lastAccessTokenRefresh = 0; + + constructor( + private readonly sessionInfoPromise: Promise< + ControlPlaneSessionInfo | undefined + >, + ) {} + + get userId(): Promise { + return this.sessionInfoPromise.then( + (sessionInfo) => sessionInfo?.account.id, + ); + } + + private async getAccessToken(): Promise { + return (await this.sessionInfoPromise)?.accessToken; + } + + private async request(path: string, init: RequestInit): Promise { + const accessToken = await this.getAccessToken(); + if (!accessToken) { + throw new Error("No access token"); + } + const resp = await fetch(new URL(path, ControlPlaneClient.URL).toString(), { + ...init, + headers: { + ...init.headers, + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!resp.ok) { + throw new Error( + `Control plane request failed: ${resp.status} ${await resp.text()}`, + ); + } + + return resp; + } + + public async listWorkspaces(): Promise { + const userId = await this.userId; + if (!userId) { + return []; + } + + const resp = await this.request(`/workspaces`, { + method: "GET", + }); + return (await resp.json()) as any; + } + + async getSettingsForWorkspace( + workspaceId: string, + ): Promise { + const userId = await this.userId; + if (!userId) { + throw new Error("No user id"); + } + + const resp = await this.request(`/workspaces/${workspaceId}`, { + method: "GET", + }); + return ((await resp.json()) as any).settings; + } +} diff --git a/core/control-plane/schema.ts b/core/control-plane/schema.ts new file mode 100644 index 0000000000..5557eff131 --- /dev/null +++ b/core/control-plane/schema.ts @@ -0,0 +1,128 @@ +import { z } from "zod"; + +const modelDescriptionSchema = z.object({ + title: z.string(), + provider: z.enum([ + "openai", + "anthropic", + "cohere", + "ollama", + "huggingface-tgi", + "huggingface-inference-api", + "replicate", + "gemini", + "mistral", + "bedrock", + "cloudflare", + "azure", + ]), + model: z.string(), + apiKey: z.string().optional(), + apiBase: z.string().optional(), + contextLength: z.number().optional(), + template: z + .enum([ + "llama2", + "alpaca", + "zephyr", + "phi2", + "phind", + "anthropic", + "chatml", + "none", + "openchat", + "deepseek", + "xwin-coder", + "neural-chat", + "codellama-70b", + "llava", + "gemma", + "llama3", + ]) + .optional(), + completionOptions: z + .object({ + temperature: z.number().optional(), + topP: z.number().optional(), + topK: z.number().optional(), + minP: z.number().optional(), + presencePenalty: z.number().optional(), + frequencyPenalty: z.number().optional(), + mirostat: z.number().optional(), + stop: z.array(z.string()).optional(), + maxTokens: z.number().optional(), + numThreads: z.number().optional(), + keepAlive: z.number().optional(), + raw: z.boolean().optional(), + stream: z.boolean().optional(), + }) + .optional(), + systemMessage: z.string().optional(), + requestOptions: z + .object({ + timeout: z.number().optional(), + verifySsl: z.boolean().optional(), + caBundlePath: z.union([z.string(), z.array(z.string())]).optional(), + proxy: z.string().optional(), + headers: z.record(z.string()).optional(), + extraBodyProperties: z.record(z.any()).optional(), + noProxy: z.array(z.string()).optional(), + }) + .optional(), + promptTemplates: z.record(z.string()).optional(), +}); + +const embeddingsProviderSchema = z.object({ + provider: z.enum([ + "transformers.js", + "ollama", + "openai", + "cohere", + "free-trial", + "gemini", + ]), + apiBase: z.string().optional(), + apiKey: z.string().optional(), + model: z.string().optional(), + engine: z.string().optional(), + apiType: z.string().optional(), + apiVersion: z.string().optional(), + requestOptions: z + .object({ + timeout: z.number().optional(), + verifySsl: z.boolean().optional(), + caBundlePath: z.union([z.string(), z.array(z.string())]).optional(), + proxy: z.string().optional(), + headers: z.record(z.string()).optional(), + extraBodyProperties: z.record(z.any()).optional(), + noProxy: z.array(z.string()).optional(), + }) + .optional(), +}); + +const rerankerSchema = z.object({ + name: z.enum(["cohere", "voyage", "llm"]), + params: z.record(z.any()).optional(), +}); + +const analyticsSchema = z.object({ + url: z.string().optional(), + clientKey: z.string().optional(), +}); + +export type ControlPlaneAnalytics = z.infer; + +const devDataSchema = z.object({ + url: z.string().optional(), +}); + +export const controlPlaneSettingsSchema = z.object({ + models: z.array(modelDescriptionSchema), + tabAutocompleteModel: modelDescriptionSchema, + embeddingsModel: embeddingsProviderSchema, + reranker: rerankerSchema, + analytics: analyticsSchema, + devData: devDataSchema, +}); + +export type ControlPlaneSettings = z.infer; diff --git a/core/core.ts b/core/core.ts index 6e6b8b997e..abb47f3e57 100644 --- a/core/core.ts +++ b/core/core.ts @@ -7,7 +7,6 @@ import type { } from "."; import { CompletionProvider } from "./autocomplete/completionProvider.js"; import { ConfigHandler } from "./config/ConfigHandler.js"; -import { IConfigHandler } from "./config/IConfigHandler"; import { setupApiKeysMode, setupFreeTrialMode, @@ -17,6 +16,7 @@ import { import { createNewPromptFile } from "./config/promptFile.js"; import { addModel, addOpenAIKey, deleteModel } from "./config/util.js"; import { ContinueServerClient } from "./continueServer/stubs/client.js"; +import { ControlPlaneClient } from "./control-plane/client"; import { CodebaseIndexer, PauseToken } from "./indexing/CodebaseIndexer.js"; import { DocsService } from "./indexing/docs/DocsService"; import TransformersJsEmbeddingsProvider from "./indexing/embeddings/TransformersJsEmbeddingsProvider.js"; @@ -34,11 +34,12 @@ import { streamDiffLines } from "./util/verticalEdit.js"; export class Core { // implements IMessenger - configHandler: IConfigHandler; + configHandler: ConfigHandler; codebaseIndexerPromise: Promise; completionProvider: CompletionProvider; continueServerClientPromise: Promise; indexingState: IndexingProgressUpdate; + controlPlaneClient: ControlPlaneClient; private globalContext = new GlobalContext(); private docsService = DocsService.getInstance(); private readonly indexingPauseToken = new PauseToken( @@ -73,14 +74,22 @@ export class Core { ) { this.indexingState = { status: "loading", desc: "loading", progress: 0 }; const ideSettingsPromise = messenger.request("getIdeSettings", undefined); + const sessionInfoPromise = messenger.request("getControlPlaneSessionInfo", { + silent: true, + }); + this.controlPlaneClient = new ControlPlaneClient(sessionInfoPromise); this.configHandler = new ConfigHandler( this.ide, ideSettingsPromise, this.onWrite, + this.controlPlaneClient, ); this.configHandler.onConfigUpdate( (() => this.messenger.send("configUpdate", undefined)).bind(this), ); + this.configHandler.onDidChangeAvailableProfiles((profiles) => + this.messenger.send("didChangeAvailableProfiles", { profiles }), + ); // Codebase Indexer and ContinueServerClient depend on IdeSettings let codebaseIndexerResolve: (_: any) => void | undefined; @@ -206,6 +215,9 @@ export class Core { on("config/ideSettingsUpdate", (msg) => { this.configHandler.updateIdeSettings(msg.data); }); + on("config/listProfiles", (msg) => { + return this.configHandler.listProfiles(); + }); // Context providers on("context/addDocs", async (msg) => { @@ -268,9 +280,13 @@ export class Core { fetchwithRequestOptions(url, init, config.requestOptions), }); - Telemetry.capture("useContextProvider", { - name: provider.description.title, - }); + Telemetry.capture( + "useContextProvider", + { + name: provider.description.title, + }, + true, + ); return items.map((item) => ({ ...item, @@ -282,12 +298,15 @@ export class Core { } }); - on("config/getBrowserSerialized", (msg) => { - return this.configHandler.getSerializedConfig(); + on("config/getSerializedProfileInfo", async (msg) => { + return { + config: await this.configHandler.getSerializedConfig(), + profileId: this.configHandler.currentProfile.profileId, + }; }); async function* llmStreamChat( - configHandler: IConfigHandler, + configHandler: ConfigHandler, abortedMessageIds: Set, msg: Message, ) { @@ -322,7 +341,7 @@ export class Core { ); async function* llmStreamComplete( - configHandler: IConfigHandler, + configHandler: ConfigHandler, abortedMessageIds: Set, msg: Message, @@ -388,7 +407,7 @@ export class Core { }); async function* runNodeJsSlashCommand( - configHandler: IConfigHandler, + configHandler: ConfigHandler, abortedMessageIds: Set, msg: Message, messenger: IMessenger, @@ -413,9 +432,13 @@ export class Core { throw new Error(`Unknown slash command ${slashCommandName}`); } - Telemetry.capture("useSlashCommand", { - name: slashCommandName, - }); + Telemetry.capture( + "useSlashCommand", + { + name: slashCommandName, + }, + true, + ); const checkActiveInterval = setInterval(() => { if (abortedMessageIds.has(msg.messageId)) { @@ -477,7 +500,7 @@ export class Core { }); async function* streamDiffLinesGenerator( - configHandler: IConfigHandler, + configHandler: ConfigHandler, abortedMessageIds: Set, msg: Message, ) { @@ -579,6 +602,10 @@ export class Core { this.messenger.request("indexProgress", this.indexingState); } }); + + on("didChangeSelectedProfile", (msg) => { + this.configHandler.setSelectedProfile(msg.data.id); + }); } private indexingCancellationController: AbortController | undefined; diff --git a/core/index.d.ts b/core/index.d.ts index 99c6ad7558..e6b4e307af 100644 --- a/core/index.d.ts +++ b/core/index.d.ts @@ -419,6 +419,7 @@ export interface IdeSettings { remoteConfigServerUrl: string | undefined; remoteConfigSyncPeriod: number; userToken: string; + enableControlServerBeta: boolean; } export interface IDE { diff --git a/core/indexing/CodebaseIndexer.ts b/core/indexing/CodebaseIndexer.ts index 5b90e0775f..a9145aa5bc 100644 --- a/core/indexing/CodebaseIndexer.ts +++ b/core/indexing/CodebaseIndexer.ts @@ -1,4 +1,4 @@ -import { IConfigHandler } from "../config/IConfigHandler.js"; +import { ConfigHandler } from "../config/ConfigHandler.js"; import { IContinueServerClient } from "../continueServer/interface.js"; import { IDE, IndexTag, IndexingProgressUpdate } from "../index.js"; import { CodeSnippetsCodebaseIndex } from "./CodeSnippetsIndex.js"; @@ -23,7 +23,7 @@ export class PauseToken { export class CodebaseIndexer { constructor( - private readonly configHandler: IConfigHandler, + private readonly configHandler: ConfigHandler, private readonly ide: IDE, private readonly pauseToken: PauseToken, private readonly continueServerClient: IContinueServerClient, diff --git a/core/llm/index.ts b/core/llm/index.ts index 728bdeef42..cddf6c7f6a 100644 --- a/core/llm/index.ts +++ b/core/llm/index.ts @@ -235,12 +235,16 @@ ${prompt}`; ) { let promptTokens = this.countTokens(prompt); let generatedTokens = this.countTokens(completion); - Telemetry.capture("tokens_generated", { - model: model, - provider: this.providerName, - promptTokens: promptTokens, - generatedTokens: generatedTokens, - }); + Telemetry.capture( + "tokens_generated", + { + model: model, + provider: this.providerName, + promptTokens: promptTokens, + generatedTokens: generatedTokens, + }, + true, + ); DevDataSqliteDb.logTokensGenerated( model, this.providerName, diff --git a/core/package-lock.json b/core/package-lock.json index e4587f56bd..477642d9a6 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -56,7 +56,8 @@ "vectordb": "^0.4.20", "web-tree-sitter": "^0.21.0", "win-ca": "^3.5.1", - "yaml": "^2.4.2" + "yaml": "^2.4.2", + "zod": "^3.23.8" }, "devDependencies": { "@babel/preset-env": "^7.24.7", @@ -14413,6 +14414,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/core/package.json b/core/package.json index 37fdb0c16f..d3ebd9c457 100644 --- a/core/package.json +++ b/core/package.json @@ -76,7 +76,8 @@ "vectordb": "^0.4.20", "web-tree-sitter": "^0.21.0", "win-ca": "^3.5.1", - "yaml": "^2.4.2" + "yaml": "^2.4.2", + "zod": "^3.23.8" }, "puppeteer": { "chromium_revision": "119.0.6045.105" diff --git a/core/protocol/core.ts b/core/protocol/core.ts index df143b9baf..c250228260 100644 --- a/core/protocol/core.ts +++ b/core/protocol/core.ts @@ -15,6 +15,7 @@ import type { SiteIndexingConfig, } from ".."; import type { AutocompleteInput } from "../autocomplete/completionProvider"; +import { ProfileDescription } from "../config/ConfigHandler"; export type ProtocolGeneratorType = AsyncGenerator<{ done?: boolean; @@ -47,9 +48,13 @@ export type ToCoreFromIdeOrWebviewProtocol = { ]; "config/newPromptFile": [undefined, void]; "config/ideSettingsUpdate": [IdeSettings, void]; - "config/getBrowserSerialized": [undefined, BrowserSerializedContinueConfig]; + "config/getSerializedProfileInfo": [ + undefined, + { config: BrowserSerializedContinueConfig; profileId: string }, + ]; "config/deleteModel": [{ title: string }, void]; "config/reload": [undefined, BrowserSerializedContinueConfig]; + "config/listProfiles": [undefined, ProfileDescription[]]; "context/getContextItems": [ { name: string; @@ -138,4 +143,6 @@ export type ToCoreFromIdeOrWebviewProtocol = { void, ]; addAutocompleteModel: [{ model: ModelDescription }, void]; + + "profiles/switch": [{ id: string }, undefined]; }; diff --git a/core/protocol/coreWebview.ts b/core/protocol/coreWebview.ts index 6aa130112a..f124cf2a6c 100644 --- a/core/protocol/coreWebview.ts +++ b/core/protocol/coreWebview.ts @@ -1,5 +1,10 @@ +import { ProfileDescription } from "../config/ConfigHandler.js"; import { ToCoreFromIdeOrWebviewProtocol } from "./core.js"; import { ToWebviewFromIdeOrCoreProtocol } from "./webview.js"; -export type ToCoreFromWebviewProtocol = ToCoreFromIdeOrWebviewProtocol; -export type ToWebviewFromCoreProtocol = ToWebviewFromIdeOrCoreProtocol; +export type ToCoreFromWebviewProtocol = ToCoreFromIdeOrWebviewProtocol & { + didChangeSelectedProfile: [{ id: string }, void]; +}; +export type ToWebviewFromCoreProtocol = ToWebviewFromIdeOrCoreProtocol & { + didChangeAvailableProfiles: [{ profiles: ProfileDescription[] }, void]; +}; diff --git a/core/protocol/ide.ts b/core/protocol/ide.ts index 84a5fc1ab1..400906af57 100644 --- a/core/protocol/ide.ts +++ b/core/protocol/ide.ts @@ -11,6 +11,7 @@ import type { RangeInFile, Thread, } from ".."; +import { ControlPlaneSessionInfo } from "../control-plane/client"; export type ToIdeFromWebviewOrCoreProtocol = { // Methods from IDE type @@ -75,5 +76,17 @@ export type ToIdeFromWebviewOrCoreProtocol = { gotoDefinition: [{ location: Location }, RangeInFile[]]; getGitHubAuthToken: [undefined, string | undefined]; + getControlPlaneSessionInfo: [ + { silent: boolean }, + ControlPlaneSessionInfo | undefined, + ]; pathSep: [undefined, string]; }; + +export type ToWebviewOrCoreFromIdeProtocol = { + didChangeActiveTextEditor: [{ filepath: string }, void]; + didChangeControlPlaneSessionInfo: [ + { sessionInfo: ControlPlaneSessionInfo | undefined }, + void, + ]; +}; diff --git a/core/protocol/index.ts b/core/protocol/index.ts index ac89c3b421..495c2fb902 100644 --- a/core/protocol/index.ts +++ b/core/protocol/index.ts @@ -2,6 +2,7 @@ import { ToCoreFromWebviewProtocol, ToWebviewFromCoreProtocol, } from "./coreWebview.js"; +import { ToWebviewOrCoreFromIdeProtocol } from "./ide.js"; import { ToCoreFromIdeProtocol, ToIdeFromCoreProtocol } from "./ideCore.js"; import { ToIdeFromWebviewProtocol, @@ -13,17 +14,19 @@ export type IProtocol = Record; // IDE export type ToIdeProtocol = ToIdeFromWebviewProtocol & ToIdeFromCoreProtocol; export type FromIdeProtocol = ToWebviewFromIdeProtocol & - ToCoreFromIdeProtocol & { - didChangeActiveTextEditor: [{ filepath: string }, void]; - }; + ToCoreFromIdeProtocol & + ToWebviewOrCoreFromIdeProtocol; // Webview export type ToWebviewProtocol = ToWebviewFromIdeProtocol & - ToWebviewFromCoreProtocol; + ToWebviewFromCoreProtocol & + ToWebviewOrCoreFromIdeProtocol; export type FromWebviewProtocol = ToIdeFromWebviewProtocol & ToCoreFromWebviewProtocol; // Core -export type ToCoreProtocol = ToCoreFromIdeProtocol | ToCoreFromWebviewProtocol; +export type ToCoreProtocol = ToCoreFromIdeProtocol & + ToCoreFromWebviewProtocol & + ToWebviewOrCoreFromIdeProtocol; export type FromCoreProtocol = ToWebviewFromCoreProtocol & ToIdeFromCoreProtocol; diff --git a/core/protocol/passThrough.ts b/core/protocol/passThrough.ts index f0734bcac8..1315311081 100644 --- a/core/protocol/passThrough.ts +++ b/core/protocol/passThrough.ts @@ -18,7 +18,7 @@ export const WEBVIEW_TO_CORE_PASS_THROUGH: (keyof ToCoreFromWebviewProtocol)[] = "config/addModel", "config/newPromptFile", "config/ideSettingsUpdate", - "config/getBrowserSerialized", + "config/getSerializedProfileInfo", "config/deleteModel", "config/reload", "context/getContextItems", @@ -41,6 +41,9 @@ export const WEBVIEW_TO_CORE_PASS_THROUGH: (keyof ToCoreFromWebviewProtocol)[] = "index/indexingProgressBarInitialized", "completeOnboarding", "addAutocompleteModel", + "config/listProfiles", + "profiles/switch", + "didChangeSelectedProfile", ]; // Message types to pass through from core to webview @@ -51,4 +54,5 @@ export const CORE_TO_WEBVIEW_PASS_THROUGH: (keyof ToWebviewFromCoreProtocol)[] = "indexProgress", "addContextItem", "refreshSubmenuItems", + "didChangeAvailableProfiles", ]; diff --git a/core/test/indexing/CodebaseIndexer.skip.ts b/core/test/indexing/CodebaseIndexer.skip.ts index db4c5107e4..404ef96fdb 100644 --- a/core/test/indexing/CodebaseIndexer.skip.ts +++ b/core/test/indexing/CodebaseIndexer.skip.ts @@ -56,6 +56,7 @@ describe.skip("CodebaseIndexer", () => { ide, ideSettingsPromise, async (text) => {}, + undefined as any, // TODO ); const pauseToken = new PauseToken(false); const continueServerClient = new ContinueServerClient(undefined, undefined); diff --git a/core/util/GlobalContext.ts b/core/util/GlobalContext.ts index 5f293204a0..d3b931c872 100644 --- a/core/util/GlobalContext.ts +++ b/core/util/GlobalContext.ts @@ -4,6 +4,7 @@ import { getGlobalContextFilePath } from "./paths.js"; export type GlobalContextType = { indexingPaused: boolean; selectedTabAutocompleteModel: string; + lastSelectedProfileForWorkspace: { [workspaceIdentifier: string]: string }; }; /** diff --git a/core/util/filesystem.ts b/core/util/filesystem.ts index b9d35cdb92..dd8e152a99 100644 --- a/core/util/filesystem.ts +++ b/core/util/filesystem.ts @@ -37,6 +37,7 @@ class FileSystemIde implements IDE { remoteConfigServerUrl: undefined, remoteConfigSyncPeriod: 60, userToken: "", + enableControlServerBeta: false, }; } async getGitHubAuthToken(): Promise { diff --git a/core/util/paths.ts b/core/util/paths.ts index f7c7fc1d3a..2764fa8be4 100644 --- a/core/util/paths.ts +++ b/core/util/paths.ts @@ -235,6 +235,11 @@ export function getPathToRemoteConfig(remoteConfigServerUrl: string): string { return dir; } +export function internalBetaPathExists(): boolean { + const sPath = path.join(getContinueGlobalPath(), ".internal_beta"); + return fs.existsSync(sPath); +} + export function getConfigJsonPathForRemote( remoteConfigServerUrl: string, ): string { diff --git a/core/util/posthog.ts b/core/util/posthog.ts index 182758edca..c164a03493 100644 --- a/core/util/posthog.ts +++ b/core/util/posthog.ts @@ -1,4 +1,5 @@ import os from "node:os"; +import { TeamAnalytics } from "../control-plane/TeamAnalytics"; export class Telemetry { // Set to undefined whenever telemetry is disabled @@ -7,7 +8,11 @@ export class Telemetry { static os: string | undefined = undefined; static extensionVersion: string | undefined = undefined; - static async capture(event: string, properties: { [key: string]: any }) { + static async capture( + event: string, + properties: { [key: string]: any }, + sendToTeam: boolean = false, + ) { Telemetry.client?.capture({ distinctId: Telemetry.uniqueId, event, @@ -17,6 +22,10 @@ export class Telemetry { extensionVersion: Telemetry.extensionVersion, }, }); + + if (sendToTeam) { + TeamAnalytics.capture(event, properties); + } } static shutdownPosthogClient() { diff --git a/core/util/treeSitter.ts b/core/util/treeSitter.ts index cff56a21c2..428c90d948 100644 --- a/core/util/treeSitter.ts +++ b/core/util/treeSitter.ts @@ -111,8 +111,8 @@ export async function getLanguageForFile( } let language = nameToLanguage.get(languageName); if (!language) { - language = await loadLanguageForFileExt(extension); - nameToLanguage.set(languageName, language); + language = await loadLanguageForFileExt(extension); + nameToLanguage.set(languageName, language); } return language; } catch (e) { @@ -152,7 +152,9 @@ export async function getQueryForFile( return query; } -async function loadLanguageForFileExt(fileExtension: string): Promise { +async function loadLanguageForFileExt( + fileExtension: string, +): Promise { const wasmPath = path.join( __dirname, ...(process.env.NODE_ENV === "test" diff --git a/core/util/verticalEdit.ts b/core/util/verticalEdit.ts index e018d1be41..0a32a6a5bb 100644 --- a/core/util/verticalEdit.ts +++ b/core/util/verticalEdit.ts @@ -60,10 +60,14 @@ export async function* streamDiffLines( language: string | undefined, onlyOneInsertion?: boolean, ): AsyncGenerator { - Telemetry.capture("inlineEdit", { - model: llm.model, - provider: llm.providerName, - }); + Telemetry.capture( + "inlineEdit", + { + model: llm.model, + provider: llm.providerName, + }, + true, + ); // Strip common indentation for the LLM, then add back after generation let oldLines = diff --git a/extensions/intellij/CHANGELOG.md b/extensions/intellij/CHANGELOG.md index 4168587515..7117d96935 100644 --- a/extensions/intellij/CHANGELOG.md +++ b/extensions/intellij/CHANGELOG.md @@ -4,34 +4,49 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), and is generated by [Changie](https://github.com/miniscruff/changie). - ## 0.0.42 - 2024-04-12 + ### Added -* Inline cmd/ctrl+I in JetBrains + +- Inline cmd/ctrl+I in JetBrains + ### Fixed -* Fixed character encoding error causing display issues -* Fixed error causing input to constantly demand focus -* Fixed automatic reloading of config.json + +- Fixed character encoding error causing display issues +- Fixed error causing input to constantly demand focus +- Fixed automatic reloading of config.json ## 0.0.38 - 2024-03-15 + ### Added -* Remote config server support -* Autocomplete support in JetBrains + +- Remote config server support +- Autocomplete support in JetBrains ## 0.0.34 - 2024-03-03 + ### Added -* diff context provider + +- diff context provider + ### Changed -* Allow LLM servers to handle templating + +- Allow LLM servers to handle templating + ### Fixed -* Fix a few context providers / slash commands -* Fixed issues preventing proper extension startup + +- Fix a few context providers / slash commands +- Fixed issues preventing proper extension startup ## v0.0.26 - 2023-12-28 + ### Added -* auto-reloading of config on save + +- auto-reloading of config on save + ### Fixed -* Fixed /edit bug for versions without Python server + +- Fixed /edit bug for versions without Python server ## v0.0.25 - 2023-12-25 diff --git a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/CoreMessenger.kt b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/CoreMessenger.kt index 68972b15d9..505a53bbdc 100644 --- a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/CoreMessenger.kt +++ b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/CoreMessenger.kt @@ -154,7 +154,8 @@ class CoreMessenger(private val project: Project, esbuildPath: String, continueC "configUpdate", "getDefaultModelTitle", "indexProgress", - "refreshSubmenuItems" + "refreshSubmenuItems", + "didChangeAvailableProfiles" ) private fun setPermissions(destination: String) { diff --git a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/editor/InlineEditAction.kt b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/editor/InlineEditAction.kt index 6dddcb582f..73a48e7bae 100644 --- a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/editor/InlineEditAction.kt +++ b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/editor/InlineEditAction.kt @@ -64,7 +64,7 @@ class InlineEditAction : AnAction(), DumbAware { // Get list of model titles val continuePluginService = project.service() val modelTitles = mutableListOf() - continuePluginService.coreMessenger?.request("config/getBrowserSerialized", null, null) { response -> + continuePluginService.coreMessenger?.request("config/getSerializedProfileInfo", null, null) { response -> val config = response as Map val models = config["models"] as List> modelTitles.addAll(models.map { it["title"] as String }) diff --git a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/toolWindow/ContinueBrowser.kt b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/toolWindow/ContinueBrowser.kt index e230658fa1..815b0f34f6 100644 --- a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/toolWindow/ContinueBrowser.kt +++ b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/toolWindow/ContinueBrowser.kt @@ -46,7 +46,7 @@ class ContinueBrowser(val project: Project, url: String, useOsr: Boolean = false "config/addOpenAiKey", "config/addModel", "config/ideSettingsUpdate", - "config/getBrowserSerialized", + "config/getSerializedProfileInfo", "config/deleteModel", "config/newPromptFile", "config/reload", diff --git a/extensions/vscode/.continueignore b/extensions/vscode/.continueignore index 2a5d96ea4b..7f9f9b9cd3 100644 --- a/extensions/vscode/.continueignore +++ b/extensions/vscode/.continueignore @@ -3,4 +3,4 @@ media models/**/* builtin-themes/ **/textmate-syntaxes/ -**/*.scm \ No newline at end of file +**/*.scm diff --git a/extensions/vscode/package-lock.json b/extensions/vscode/package-lock.json index d6decbb5a6..3064e57cb3 100644 --- a/extensions/vscode/package-lock.json +++ b/extensions/vscode/package-lock.json @@ -141,7 +141,8 @@ "vectordb": "^0.4.20", "web-tree-sitter": "^0.21.0", "win-ca": "^3.5.1", - "yaml": "^2.4.2" + "yaml": "^2.4.2", + "zod": "^3.23.8" }, "devDependencies": { "@babel/preset-env": "^7.24.7", diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index cd5b1989c5..192e324b7c 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -559,6 +559,7 @@ "cors": "^2.8.5", "dbinfoz": "^0.1.4", "downshift": "^7.6.0", + "esbuild": "^0.17.19", "express": "^4.18.2", "fkill": "^8.1.0", "follow-redirects": "^1.15.4", @@ -589,7 +590,6 @@ "uuid": "^9.0.1", "uuidv4": "^6.2.13", "vectordb": "^0.4.20", - "esbuild": "^0.17.19", "vscode-languageclient": "^8.0.2", "ws": "^8.13.0", "yarn": "^1.22.21" diff --git a/extensions/vscode/src/activation/activate.ts b/extensions/vscode/src/activation/activate.ts index 4994feb0f7..f4ba3e34b8 100644 --- a/extensions/vscode/src/activation/activate.ts +++ b/extensions/vscode/src/activation/activate.ts @@ -4,6 +4,7 @@ import path from "node:path"; import * as vscode from "vscode"; import { VsCodeExtension } from "../extension/VsCodeExtension"; import registerQuickFixProvider from "../lang-server/codeActions"; +import { WorkOsAuthProvider } from "../stubs/WorkOsAuthProvider"; import { getExtensionVersion } from "../util/util"; import { getExtensionUri } from "../util/vscode"; import { VsCodeContinueApi } from "./api"; @@ -17,6 +18,11 @@ export async function activateExtension(context: vscode.ExtensionContext) { registerQuickFixProvider(); setupInlineTips(context); + // Register auth provider + const workOsAuthProvider = new WorkOsAuthProvider(context); + await workOsAuthProvider.initialize(); + context.subscriptions.push(workOsAuthProvider); + const vscodeExtension = new VsCodeExtension(context); migrate("showWelcome_1", () => { @@ -33,9 +39,13 @@ export async function activateExtension(context: vscode.ExtensionContext) { // Load Continue configuration if (!context.globalState.get("hasBeenInstalled")) { context.globalState.update("hasBeenInstalled", true); - Telemetry.capture("install", { - extensionVersion: getExtensionVersion(), - }); + Telemetry.capture( + "install", + { + extensionVersion: getExtensionVersion(), + }, + true, + ); } const api = new VsCodeContinueApi(vscodeExtension); diff --git a/extensions/vscode/src/activation/languageClient.ts b/extensions/vscode/src/activation/languageClient.ts index f32e7dd7ec..98663bdc3e 100644 --- a/extensions/vscode/src/activation/languageClient.ts +++ b/extensions/vscode/src/activation/languageClient.ts @@ -65,7 +65,7 @@ function startPythonLanguageServer(context: ExtensionContext): LanguageClient { configurationSection: "pyls", }, }; - return new LanguageClient(command, serverOptions, clientOptions); + return new LanguageClient(command, serverOptions, clientOptions) } async function startPylance(context: ExtensionContext) { @@ -112,3 +112,4 @@ async function startPylance(context: ExtensionContext) { ); return client; } + diff --git a/extensions/vscode/src/autocomplete/completionProvider.ts b/extensions/vscode/src/autocomplete/completionProvider.ts index 3befe838e0..8e44af08eb 100644 --- a/extensions/vscode/src/autocomplete/completionProvider.ts +++ b/extensions/vscode/src/autocomplete/completionProvider.ts @@ -4,7 +4,7 @@ import { CompletionProvider, type AutocompleteInput, } from "core/autocomplete/completionProvider"; -import { IConfigHandler } from "core/config/IConfigHandler"; +import { ConfigHandler } from "core/config/ConfigHandler"; import { v4 as uuidv4 } from "uuid"; import * as vscode from "vscode"; import type { TabAutocompleteModel } from "../util/loadAutocompleteModel"; @@ -48,7 +48,7 @@ export class ContinueCompletionProvider private recentlyEditedTracker = new RecentlyEditedTracker(); constructor( - private readonly configHandler: IConfigHandler, + private readonly configHandler: ConfigHandler, private readonly ide: IDE, private readonly tabAutocompleteModel: TabAutocompleteModel, ) { diff --git a/extensions/vscode/src/commands.ts b/extensions/vscode/src/commands.ts index 226f5ed4ef..df9cde8084 100644 --- a/extensions/vscode/src/commands.ts +++ b/extensions/vscode/src/commands.ts @@ -6,7 +6,7 @@ import * as vscode from "vscode"; import { ContextMenuConfig, IDE } from "core"; import { CompletionProvider } from "core/autocomplete/completionProvider"; -import { IConfigHandler } from "core/config/IConfigHandler"; +import { ConfigHandler } from "core/config/ConfigHandler"; import { ContinueServerClient } from "core/continueServer/stubs/client"; import { GlobalContext } from "core/util/GlobalContext"; import { getConfigJsonPath, getDevDataFilePath } from "core/util/paths"; @@ -165,7 +165,7 @@ const commandsMap: ( ide: IDE, extensionContext: vscode.ExtensionContext, sidebar: ContinueGUIWebviewViewProvider, - configHandler: IConfigHandler, + configHandler: ConfigHandler, diffManager: DiffManager, verticalDiffManager: VerticalPerLineDiffManager, continueServerClientPromise: Promise, @@ -675,7 +675,7 @@ export function registerAllCommands( ide: IDE, extensionContext: vscode.ExtensionContext, sidebar: ContinueGUIWebviewViewProvider, - configHandler: IConfigHandler, + configHandler: ConfigHandler, diffManager: DiffManager, verticalDiffManager: VerticalPerLineDiffManager, continueServerClientPromise: Promise, diff --git a/extensions/vscode/src/debugPanel.ts b/extensions/vscode/src/debugPanel.ts index 2c22158c6f..7a125585ec 100644 --- a/extensions/vscode/src/debugPanel.ts +++ b/extensions/vscode/src/debugPanel.ts @@ -1,5 +1,5 @@ import type { FileEdit } from "core"; -import { IConfigHandler } from "core/config/IConfigHandler"; +import { ConfigHandler } from "core/config/ConfigHandler"; import * as vscode from "vscode"; import { getTheme } from "./util/getTheme"; import { getExtensionVersion } from "./util/util"; @@ -46,7 +46,7 @@ export class ContinueGUIWebviewViewProvider } constructor( - private readonly configHandlerPromise: Promise, + private readonly configHandlerPromise: Promise, private readonly windowId: string, private readonly extensionContext: vscode.ExtensionContext, ) { diff --git a/extensions/vscode/src/diff/verticalPerLine/manager.ts b/extensions/vscode/src/diff/verticalPerLine/manager.ts index f6c4eafb43..1238326111 100644 --- a/extensions/vscode/src/diff/verticalPerLine/manager.ts +++ b/extensions/vscode/src/diff/verticalPerLine/manager.ts @@ -1,4 +1,4 @@ -import { IConfigHandler } from "core/config/IConfigHandler"; +import { ConfigHandler } from "core/config/ConfigHandler"; import { pruneLinesFromBottom, pruneLinesFromTop } from "core/llm/countTokens"; import { getMarkdownLanguageTagForFile } from "core/util"; import { streamDiffLines } from "core/util/verticalEdit"; @@ -21,7 +21,7 @@ export class VerticalPerLineDiffManager { private userChangeListener: vscode.Disposable | undefined; - constructor(private readonly configHandler: IConfigHandler) { + constructor(private readonly configHandler: ConfigHandler) { this.userChangeListener = undefined; } diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts index 089cf27f1b..e3967bdb24 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -36,9 +36,13 @@ export function activate(context: vscode.ExtensionContext) { } export function deactivate() { - Telemetry.capture("deactivate", { - extensionVersion: getExtensionVersion(), - }); + Telemetry.capture( + "deactivate", + { + extensionVersion: getExtensionVersion(), + }, + true, + ); Telemetry.shutdownPosthogClient(); } diff --git a/extensions/vscode/src/extension/VsCodeExtension.ts b/extensions/vscode/src/extension/VsCodeExtension.ts index 557f6b17d6..935d4492ce 100644 --- a/extensions/vscode/src/extension/VsCodeExtension.ts +++ b/extensions/vscode/src/extension/VsCodeExtension.ts @@ -1,5 +1,5 @@ import { IContextProvider } from "core"; -import { IConfigHandler } from "core/config/IConfigHandler"; +import { ConfigHandler } from "core/config/ConfigHandler"; import { Core } from "core/core"; import { FromCoreProtocol, ToCoreProtocol } from "core/protocol"; import { InProcessMessenger } from "core/util/messenger"; @@ -21,16 +21,16 @@ import { VerticalPerLineDiffManager } from "../diff/verticalPerLine/manager"; import { VsCodeIde } from "../ideProtocol"; import { registerAllCodeLensProviders } from "../lang-server/codeLens"; import { setupRemoteConfigSync } from "../stubs/activation"; +import { getControlPlaneSessionInfo } from "../stubs/WorkOsAuthProvider"; import { Battery } from "../util/battery"; import { TabAutocompleteModel } from "../util/loadAutocompleteModel"; import type { VsCodeWebviewProtocol } from "../webviewProtocol"; import { VsCodeMessenger } from "./VsCodeMessenger"; -import { CONTINUE_WORKSPACE_KEY } from "../util/workspaceConfig"; export class VsCodeExtension { // Currently some of these are public so they can be used in testing (test/test-suites) - private configHandler: IConfigHandler; + private configHandler: ConfigHandler; private extensionContext: vscode.ExtensionContext; private ide: VsCodeIde; private tabAutocompleteModel: TabAutocompleteModel; @@ -66,7 +66,7 @@ export class VsCodeExtension { }, ); let resolveConfigHandler: any = undefined; - const configHandlerPromise = new Promise((resolve) => { + const configHandlerPromise = new Promise((resolve) => { resolveConfigHandler = resolve; }); this.sidebar = new ContinueGUIWebviewViewProvider( @@ -243,6 +243,13 @@ export class VsCodeExtension { vscode.authentication.onDidChangeSessions((e) => { if (e.provider.id === "github") { this.configHandler.reloadConfig(); + } else if (e.provider.id === "continue") { + this.webviewProtocolPromise.then(async (webviewProtocol) => { + const sessionInfo = await getControlPlaneSessionInfo(true); + webviewProtocol.request("didChangeControlPlaneSessionInfo", { + sessionInfo, + }); + }); } }); diff --git a/extensions/vscode/src/extension/VsCodeMessenger.ts b/extensions/vscode/src/extension/VsCodeMessenger.ts index 8cd14dfae0..7865d49be5 100644 --- a/extensions/vscode/src/extension/VsCodeMessenger.ts +++ b/extensions/vscode/src/extension/VsCodeMessenger.ts @@ -1,5 +1,9 @@ -import { IConfigHandler } from "core/config/IConfigHandler"; -import { FromCoreProtocol, ToCoreProtocol } from "core/protocol"; +import { ConfigHandler } from "core/config/ConfigHandler"; +import { + FromCoreProtocol, + FromWebviewProtocol, + ToCoreProtocol, +} from "core/protocol"; import { ToWebviewFromCoreProtocol } from "core/protocol/coreWebview"; import { ToIdeFromWebviewOrCoreProtocol } from "core/protocol/ide"; import { ToIdeFromCoreProtocol } from "core/protocol/ideCore"; @@ -14,11 +18,9 @@ import * as path from "node:path"; import * as vscode from "vscode"; import { VerticalPerLineDiffManager } from "../diff/verticalPerLine/manager"; import { VsCodeIde } from "../ideProtocol"; +import { getControlPlaneSessionInfo } from "../stubs/WorkOsAuthProvider"; import { getExtensionUri } from "../util/vscode"; -import { - ToCoreOrIdeFromWebviewProtocol, - VsCodeWebviewProtocol, -} from "../webviewProtocol"; +import { VsCodeWebviewProtocol } from "../webviewProtocol"; /** * A shared messenger class between Core and Webview @@ -28,13 +30,11 @@ type TODO = any; type ToIdeOrWebviewFromCoreProtocol = ToIdeFromCoreProtocol & ToWebviewFromCoreProtocol; export class VsCodeMessenger { - onWebview( + onWebview( messageType: T, handler: ( - message: Message, - ) => - | Promise - | ToCoreOrIdeFromWebviewProtocol[T][1], + message: Message, + ) => Promise | FromWebviewProtocol[T][1], ): void { this.webviewProtocol.on(messageType, handler); } @@ -70,7 +70,7 @@ export class VsCodeMessenger { private readonly webviewProtocol: VsCodeWebviewProtocol, private readonly ide: VsCodeIde, private readonly verticalDiffManagerPromise: Promise, - private readonly configHandlerPromise: Promise, + private readonly configHandlerPromise: Promise, ) { /** WEBVIEW ONLY LISTENERS **/ this.onWebview("showFile", (msg) => { @@ -291,5 +291,8 @@ export class VsCodeMessenger { this.onWebviewOrCore("getGitHubAuthToken", (msg) => ide.getGitHubAuthToken(), ); + this.onWebviewOrCore("getControlPlaneSessionInfo", async (msg) => { + return getControlPlaneSessionInfo(msg.data.silent); + }); } } diff --git a/extensions/vscode/src/ideProtocol.ts b/extensions/vscode/src/ideProtocol.ts index c284a33563..815919ff9f 100644 --- a/extensions/vscode/src/ideProtocol.ts +++ b/extensions/vscode/src/ideProtocol.ts @@ -20,6 +20,7 @@ import { editConfigJson, getConfigJsonPath, getContinueGlobalPath, + internalBetaPathExists, } from "core/util/paths"; import * as vscode from "vscode"; import { executeGotoProvider } from "./autocomplete/lsp"; @@ -520,6 +521,11 @@ class VsCodeIde implements IDE { 60, ), userToken: settings.get("userToken", ""), + enableControlServerBeta: internalBetaPathExists(), + // settings.get( + // "enableControlServerBeta", + // false, + // ), }; return ideSettings; } diff --git a/extensions/vscode/src/quickEdit/QuickEdit.ts b/extensions/vscode/src/quickEdit/QuickEdit.ts index e5c4e69330..61165032fd 100644 --- a/extensions/vscode/src/quickEdit/QuickEdit.ts +++ b/extensions/vscode/src/quickEdit/QuickEdit.ts @@ -1,5 +1,5 @@ import { IDE } from "core"; -import { IConfigHandler } from "core/config/IConfigHandler"; +import { ConfigHandler } from "core/config/ConfigHandler"; import { fetchwithRequestOptions } from "core/util/fetchWithOptions"; import * as vscode from "vscode"; import { VerticalPerLineDiffManager } from "../diff/verticalPerLine/manager"; @@ -17,7 +17,7 @@ interface QuickEditFlowStuff { export class QuickEdit { constructor( private readonly verticalDiffManager: VerticalPerLineDiffManager, - private readonly configHandler: IConfigHandler, + private readonly configHandler: ConfigHandler, private readonly webviewProtocol: VsCodeWebviewProtocol, private readonly ide: IDE, private readonly context: vscode.ExtensionContext, diff --git a/extensions/vscode/src/stubs/WorkOsAuthProvider.ts b/extensions/vscode/src/stubs/WorkOsAuthProvider.ts new file mode 100644 index 0000000000..3ffb3a8af5 --- /dev/null +++ b/extensions/vscode/src/stubs/WorkOsAuthProvider.ts @@ -0,0 +1,418 @@ +import fetch from "node-fetch"; +import { v4 as uuidv4 } from "uuid"; +import { + authentication, + AuthenticationProvider, + AuthenticationProviderAuthenticationSessionsChangeEvent, + AuthenticationSession, + Disposable, + env, + EventEmitter, + ExtensionContext, + ProgressLocation, + Uri, + UriHandler, + window, +} from "vscode"; +import { PromiseAdapter, promiseFromEvent } from "./promiseUtils"; + +export const AUTH_TYPE = "continue"; +const AUTH_NAME = "Continue"; +const CLIENT_ID = "client_01J0FW6XN8N2XJAECF7NE0Y65J"; +const SESSIONS_SECRET_KEY = `${AUTH_TYPE}.sessions`; + +class UriEventHandler extends EventEmitter implements UriHandler { + public handleUri(uri: Uri) { + this.fire(uri); + } +} + +import { + CONTROL_PLANE_URL, + ControlPlaneSessionInfo, +} from "core/control-plane/client"; +import crypto from "crypto"; + +// Function to generate a random string of specified length +function generateRandomString(length: number): string { + const possibleCharacters = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + let randomString = ""; + for (let i = 0; i < length; i++) { + const randomIndex = Math.floor(Math.random() * possibleCharacters.length); + randomString += possibleCharacters[randomIndex]; + } + return randomString; +} + +// Function to generate a code challenge from the code verifier + +async function generateCodeChallenge(verifier: string) { + // Create a SHA-256 hash of the verifier + const hash = crypto.createHash("sha256").update(verifier).digest(); + + // Convert the hash to a base64 URL-encoded string + const base64String = hash + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + return base64String; +} + +interface ContinueAuthenticationSession extends AuthenticationSession { + accessToken: string; + refreshToken: string; + expiresIn: number; +} + +export class WorkOsAuthProvider implements AuthenticationProvider, Disposable { + private _sessionChangeEmitter = + new EventEmitter(); + private _disposable: Disposable; + private _pendingStates: string[] = []; + private _codeExchangePromises = new Map< + string, + { promise: Promise; cancel: EventEmitter } + >(); + private _uriHandler = new UriEventHandler(); + private _sessions: ContinueAuthenticationSession[] = []; + + private static EXPIRATION_TIME_MS = 1000 * 60 * 5; // 5 minutes + + constructor(private readonly context: ExtensionContext) { + this._disposable = Disposable.from( + authentication.registerAuthenticationProvider( + AUTH_TYPE, + AUTH_NAME, + this, + { supportsMultipleAccounts: false }, + ), + window.registerUriHandler(this._uriHandler), + ); + } + + get onDidChangeSessions() { + return this._sessionChangeEmitter.event; + } + + get redirectUri() { + const publisher = this.context.extension.packageJSON.publisher; + const name = this.context.extension.packageJSON.name; + return `${env.uriScheme}://${publisher}.${name}`; + } + + async initialize() { + let sessions = await this.context.secrets.get(SESSIONS_SECRET_KEY); + this._sessions = sessions ? JSON.parse(sessions) : []; + await this._refreshSessions(); + } + + private async _refreshSessions(): Promise { + if (!this._sessions.length) { + return; + } + for (const session of this._sessions) { + try { + const newSession = await this._refreshSession(session.refreshToken); + session.accessToken = newSession.accessToken; + session.refreshToken = newSession.refreshToken; + session.expiresIn = newSession.expiresIn; + } catch (e: any) { + if (e.message === "Network failure") { + setTimeout(() => this._refreshSessions(), 60 * 1000); + return; + } + } + } + await this.context.secrets.store( + SESSIONS_SECRET_KEY, + JSON.stringify(this._sessions), + ); + this._sessionChangeEmitter.fire({ + added: [], + removed: [], + changed: this._sessions, + }); + + if (this._sessions[0].expiresIn) { + setTimeout( + () => this._refreshSessions(), + (this._sessions[0].expiresIn * 1000 * 2) / 3, + ); + } + } + + private async _refreshSession( + refreshToken: string, + ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> { + const response = await fetch(new URL("/auth/refresh", CONTROL_PLANE_URL), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + refreshToken, + }), + }); + if (!response.ok) { + throw new Error("Network failure"); + } + const data = (await response.json()) as any; + return { + accessToken: data.accessToken, + refreshToken: data.refreshToken, + expiresIn: WorkOsAuthProvider.EXPIRATION_TIME_MS, + }; + } + + /** + * Get the existing sessions + * @param scopes + * @returns + */ + public async getSessions( + scopes?: string[], + ): Promise { + const allSessions = await this.context.secrets.get(SESSIONS_SECRET_KEY); + + if (allSessions) { + return JSON.parse(allSessions) as ContinueAuthenticationSession[]; + } + + return []; + } + + /** + * Create a new auth session + * @param scopes + * @returns + */ + public async createSession( + scopes: string[], + ): Promise { + try { + const codeVerifier = generateRandomString(64); + const codeChallenge = await generateCodeChallenge(codeVerifier); + const token = await this.login(codeChallenge, scopes); + if (!token) { + throw new Error(`Continue login failure`); + } + + const userInfo = (await this.getUserInfo(token, codeVerifier)) as any; + const { user, access_token, refresh_token } = userInfo; + + const session: ContinueAuthenticationSession = { + id: uuidv4(), + accessToken: access_token, + refreshToken: refresh_token, + expiresIn: WorkOsAuthProvider.EXPIRATION_TIME_MS, + account: { + label: user.first_name + " " + user.last_name, + id: user.email, + }, + scopes: [], + }; + + await this.context.secrets.store( + SESSIONS_SECRET_KEY, + JSON.stringify([session]), + ); + + this._sessionChangeEmitter.fire({ + added: [session], + removed: [], + changed: [], + }); + + return session; + } catch (e) { + window.showErrorMessage(`Sign in failed: ${e}`); + throw e; + } + } + + /** + * Remove an existing session + * @param sessionId + */ + public async removeSession(sessionId: string): Promise { + const allSessions = await this.context.secrets.get(SESSIONS_SECRET_KEY); + if (allSessions) { + let sessions = JSON.parse(allSessions) as ContinueAuthenticationSession[]; + const sessionIdx = sessions.findIndex((s) => s.id === sessionId); + const session = sessions[sessionIdx]; + sessions.splice(sessionIdx, 1); + + await this.context.secrets.store( + SESSIONS_SECRET_KEY, + JSON.stringify(sessions), + ); + + if (session) { + this._sessionChangeEmitter.fire({ + added: [], + removed: [session], + changed: [], + }); + } + } + } + + /** + * Dispose the registered services + */ + public async dispose() { + this._disposable.dispose(); + } + + /** + * Log in to Continue + */ + private async login(codeChallenge: string, scopes: string[] = []) { + return await window.withProgress( + { + location: ProgressLocation.Notification, + title: "Signing in to Continue...", + cancellable: true, + }, + async (_, token) => { + const stateId = uuidv4(); + + this._pendingStates.push(stateId); + + const scopeString = scopes.join(" "); + + const url = new URL("https://api.workos.com/user_management/authorize"); + const params = { + response_type: "code", + client_id: CLIENT_ID, + redirect_uri: this.redirectUri, + state: stateId, + code_challenge: codeChallenge, + code_challenge_method: "S256", + provider: "authkit", + }; + + Object.keys(params).forEach((key) => + url.searchParams.append(key, params[key as keyof typeof params]), + ); + + const oauthUrl = url; + if (oauthUrl) { + await env.openExternal(Uri.parse(oauthUrl.toString())); + } else { + return; + } + + let codeExchangePromise = this._codeExchangePromises.get(scopeString); + if (!codeExchangePromise) { + codeExchangePromise = promiseFromEvent( + this._uriHandler.event, + this.handleUri(scopes), + ); + this._codeExchangePromises.set(scopeString, codeExchangePromise); + } + + try { + return await Promise.race([ + codeExchangePromise.promise, + new Promise((_, reject) => + setTimeout(() => reject("Cancelled"), 60000), + ), + promiseFromEvent( + token.onCancellationRequested, + (_, __, reject) => { + reject("User Cancelled"); + }, + ).promise, + ]); + } finally { + this._pendingStates = this._pendingStates.filter( + (n) => n !== stateId, + ); + codeExchangePromise?.cancel.fire(); + this._codeExchangePromises.delete(scopeString); + } + }, + ); + } + + /** + * Handle the redirect to VS Code (after sign in from Continue) + * @param scopes + * @returns + */ + private handleUri: ( + scopes: readonly string[], + ) => PromiseAdapter = + (scopes) => async (uri, resolve, reject) => { + const query = new URLSearchParams(uri.query); + const access_token = query.get("code"); + const state = query.get("state"); + + if (!access_token) { + reject(new Error("No token")); + return; + } + if (!state) { + reject(new Error("No state")); + return; + } + + // Check if it is a valid auth request started by the extension + if (!this._pendingStates.some((n) => n === state)) { + reject(new Error("State not found")); + return; + } + + resolve(access_token); + }; + + /** + * Get the user info from WorkOS + * @param token + * @returns + */ + private async getUserInfo(token: string, codeVerifier: string) { + const resp = await fetch( + "https://api.workos.com/user_management/authenticate", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: CLIENT_ID, + code_verifier: codeVerifier, + grant_type: "authorization_code", + code: token, + }), + }, + ); + const text = await resp.text(); + const data = JSON.parse(text); + return data; + } +} + +export async function getControlPlaneSessionInfo( + silent: boolean, +): Promise { + const session = await authentication.getSession( + "continue", + [], + silent ? { silent: true } : { createIfNone: true }, + ); + if (!session) { + return undefined; + } + return { + accessToken: session.accessToken, + account: { + id: session.account.id, + label: session.account.label, + }, + }; +} diff --git a/extensions/vscode/src/stubs/promiseUtils.ts b/extensions/vscode/src/stubs/promiseUtils.ts new file mode 100644 index 0000000000..fd126d8488 --- /dev/null +++ b/extensions/vscode/src/stubs/promiseUtils.ts @@ -0,0 +1,57 @@ +import { Disposable, Event, EventEmitter } from "vscode"; + +export interface PromiseAdapter { + ( + value: T, + resolve: (value: U | PromiseLike) => void, + reject: (reason: any) => void, + ): any; +} + +const passthrough = (value: any, resolve: (value?: any) => void) => + resolve(value); + +/** + * Return a promise that resolves with the next emitted event, or with some future + * event as decided by an adapter. + * + * If specified, the adapter is a function that will be called with + * `(event, resolve, reject)`. It will be called once per event until it resolves or + * rejects. + * + * The default adapter is the passthrough function `(value, resolve) => resolve(value)`. + * + * @param event the event + * @param adapter controls resolution of the returned promise + * @returns a promise that resolves or rejects as specified by the adapter + */ +export function promiseFromEvent( + event: Event, + adapter: PromiseAdapter = passthrough, +): { promise: Promise; cancel: EventEmitter } { + let subscription: Disposable; + let cancel = new EventEmitter(); + + return { + promise: new Promise((resolve, reject) => { + cancel.event((_) => reject("Cancelled")); + subscription = event((value: T) => { + try { + Promise.resolve(adapter(value, resolve, reject)).catch(reject); + } catch (error) { + reject(error); + } + }); + }).then( + (result: U) => { + subscription.dispose(); + return result; + }, + (error) => { + subscription.dispose(); + throw error; + }, + ), + cancel, + }; +} diff --git a/extensions/vscode/src/util/cleanSlate.ts b/extensions/vscode/src/util/cleanSlate.ts index 837d93fecb..09703f481a 100644 --- a/extensions/vscode/src/util/cleanSlate.ts +++ b/extensions/vscode/src/util/cleanSlate.ts @@ -1,4 +1,7 @@ +import { getContinueGlobalPath } from "core/util/paths"; import { ExtensionContext } from "vscode"; +import fs from "fs"; + /** * Clear all Continue-related artifacts to simulate a brand new user */ diff --git a/extensions/vscode/src/util/loadAutocompleteModel.ts b/extensions/vscode/src/util/loadAutocompleteModel.ts index db56c7e31c..c21837f6cb 100644 --- a/extensions/vscode/src/util/loadAutocompleteModel.ts +++ b/extensions/vscode/src/util/loadAutocompleteModel.ts @@ -1,5 +1,5 @@ import type { ILLM } from "core"; -import { IConfigHandler } from "core/config/IConfigHandler"; +import { ConfigHandler } from "core/config/ConfigHandler"; import Ollama from "core/llm/llms/Ollama"; import { GlobalContext } from "core/util/GlobalContext"; import * as vscode from "vscode"; @@ -13,9 +13,9 @@ export class TabAutocompleteModel { private shownOllamaWarning = false; private shownDeepseekWarning = false; - private configHandler: IConfigHandler; + private configHandler: ConfigHandler; - constructor(configHandler: IConfigHandler) { + constructor(configHandler: ConfigHandler) { this.configHandler = configHandler; } diff --git a/extensions/vscode/src/webviewProtocol.ts b/extensions/vscode/src/webviewProtocol.ts index 0c3a3c4016..3496691c57 100644 --- a/extensions/vscode/src/webviewProtocol.ts +++ b/extensions/vscode/src/webviewProtocol.ts @@ -1,16 +1,9 @@ +import { FromWebviewProtocol, ToWebviewProtocol } from "core/protocol"; import { Message } from "core/util/messenger"; import fs from "node:fs"; import path from "path"; import { v4 as uuidv4 } from "uuid"; import * as vscode from "vscode"; -import { - ToCoreFromWebviewProtocol, - ToWebviewFromCoreProtocol, -} from "../../../core/protocol/coreWebview"; -import { - ToIdeFromWebviewProtocol, - ToWebviewFromIdeProtocol, -} from "../../../core/protocol/ideWebview"; import { IMessenger } from "../../../core/util/messenger"; import { getExtensionUri } from "./util/vscode"; @@ -32,19 +25,11 @@ export async function showTutorial() { await vscode.window.showTextDocument(doc, { preview: false }); } -export type ToCoreOrIdeFromWebviewProtocol = ToCoreFromWebviewProtocol & - ToIdeFromWebviewProtocol; -type FullToWebviewFromIdeOrCoreProtocol = ToWebviewFromIdeProtocol & - ToWebviewFromCoreProtocol; export class VsCodeWebviewProtocol - implements - IMessenger< - ToCoreOrIdeFromWebviewProtocol, - FullToWebviewFromIdeOrCoreProtocol - > + implements IMessenger { listeners = new Map< - keyof ToCoreOrIdeFromWebviewProtocol, + keyof FromWebviewProtocol, ((message: Message) => any)[] >(); @@ -58,13 +43,11 @@ export class VsCodeWebviewProtocol return id; } - on( + on( messageType: T, handler: ( - message: Message, - ) => - | Promise - | ToCoreOrIdeFromWebviewProtocol[T][1], + message: Message, + ) => Promise | FromWebviewProtocol[T][1], ): void { if (!this.listeners.has(messageType)) { this.listeners.set(messageType, []); @@ -196,11 +179,11 @@ export class VsCodeWebviewProtocol } constructor(private readonly reloadConfig: () => void) {} - invoke( + invoke( messageType: T, - data: ToCoreOrIdeFromWebviewProtocol[T][0], + data: FromWebviewProtocol[T][0], messageId?: string, - ): ToCoreOrIdeFromWebviewProtocol[T][1] { + ): FromWebviewProtocol[T][1] { throw new Error("Method not implemented."); } @@ -208,10 +191,10 @@ export class VsCodeWebviewProtocol throw new Error("Method not implemented."); } - public request( + public request( messageType: T, - data: FullToWebviewFromIdeOrCoreProtocol[T][0], - ): Promise { + data: ToWebviewProtocol[T][0], + ): Promise { const messageId = uuidv4(); return new Promise(async (resolve) => { let i = 0; @@ -227,7 +210,7 @@ export class VsCodeWebviewProtocol this.send(messageType, data, messageId); const disposable = this.webview.onDidReceiveMessage( - (msg: Message) => { + (msg: Message) => { if (msg.messageId === messageId) { resolve(msg.data); disposable?.dispose(); diff --git a/gui/package-lock.json b/gui/package-lock.json index d652bc5610..91f4adfe0a 100644 --- a/gui/package-lock.json +++ b/gui/package-lock.json @@ -133,7 +133,8 @@ "vectordb": "^0.4.20", "web-tree-sitter": "^0.21.0", "win-ca": "^3.5.1", - "yaml": "^2.4.2" + "yaml": "^2.4.2", + "zod": "^3.23.8" }, "devDependencies": { "@babel/preset-env": "^7.24.7", diff --git a/gui/src/components/Layout.tsx b/gui/src/components/Layout.tsx index e4e41e963b..1df23324ed 100644 --- a/gui/src/components/Layout.tsx +++ b/gui/src/components/Layout.tsx @@ -1,7 +1,4 @@ -import { - Cog6ToothIcon, - QuestionMarkCircleIcon, -} from "@heroicons/react/24/outline"; +import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline"; import { IndexingProgressUpdate } from "core"; import { useContext, useEffect, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; @@ -30,8 +27,8 @@ import TextDialog from "./dialogs"; import HeaderButtonWithText from "./HeaderButtonWithText"; import IndexingProgressBar from "./loaders/IndexingProgressBar"; import ProgressBar from "./loaders/ProgressBar"; -import ModelSelect from "./modelSelection/ModelSelect"; import PostHogPageView from "./PosthogPageView"; +import ProfileSwitcher from "./ProfileSwitcher"; // #region Styled Components const FOOTER_HEIGHT = "1.8em"; @@ -80,7 +77,7 @@ const GridDiv = styled.div` overflow-x: visible; `; -const DropdownPortalDiv = styled.div` +const ModelDropdownPortalDiv = styled.div` background-color: ${vscInputBackground}; position: relative; margin-left: 8px; @@ -88,6 +85,14 @@ const DropdownPortalDiv = styled.div` font-size: ${getFontSize()}; `; +const ProfileDropdownPortalDiv = styled.div` + background-color: ${vscInputBackground}; + position: relative; + margin-left: calc(100% - 190px); + z-index: 200; + font-size: ${getFontSize() - 2}; +`; + // #endregion const HIDE_FOOTER_ON_PAGES = [ @@ -251,13 +256,11 @@ const Layout = () => { - + + {HIDE_FOOTER_ON_PAGES.includes(location.pathname) || (
-
- -
{indexingState.status !== "indexing" && // Would take up too much space together with indexing progress defaultModel?.provider === "free-trial" && ( { )}
+ + { @@ -279,15 +284,6 @@ const Layout = () => { > - { - // navigate("/settings"); - ideMessenger.post("openConfigJson", undefined); - }} - text="Configure Continue" - > - -
)}
diff --git a/gui/src/components/PosthogPageView.ts b/gui/src/components/PosthogPageView.ts index 18eb37e9b5..3a9445d1d8 100644 --- a/gui/src/components/PosthogPageView.ts +++ b/gui/src/components/PosthogPageView.ts @@ -1,6 +1,6 @@ -import { useEffect } from "react"; import { usePostHog } from "posthog-js/react"; -import { useSearchParams, useLocation } from "react-router-dom"; +import { useEffect } from "react"; +import { useLocation, useSearchParams } from "react-router-dom"; /** * This is copied from here: https://posthog.com/tutorials/single-page-app-pageviews#tracking-pageviews-in-nextjs-app-router diff --git a/gui/src/components/ProfileSwitcher.tsx b/gui/src/components/ProfileSwitcher.tsx new file mode 100644 index 0000000000..fbb2e4838a --- /dev/null +++ b/gui/src/components/ProfileSwitcher.tsx @@ -0,0 +1,259 @@ +import { Listbox, Transition } from "@headlessui/react"; +import { + ChevronUpDownIcon, + Cog6ToothIcon, + UserCircleIcon, +} from "@heroicons/react/24/outline"; +import { ProfileDescription } from "core/config/ConfigHandler"; +import { Fragment, useContext, useEffect, useState } from "react"; +import ReactDOM from "react-dom"; +import { useDispatch, useSelector } from "react-redux"; +import styled from "styled-components"; +import { + defaultBorderRadius, + lightGray, + vscBackground, + vscForeground, + vscInputBackground, + vscListActiveBackground, + vscListActiveForeground, +} from "."; +import { IdeMessengerContext } from "../context/IdeMessenger"; +import { useAuth } from "../hooks/useAuth"; +import { useWebviewListener } from "../hooks/useWebviewListener"; +import { RootState } from "../redux/store"; +import { getFontSize, isJetBrains } from "../util"; +import HeaderButtonWithText from "./HeaderButtonWithText"; + +const StyledListbox = styled(Listbox)` + background-color: ${vscBackground}; + min-width: 80px; +`; + +const StyledListboxButton = styled(Listbox.Button)` + position: relative; + cursor: pointer; + background-color: ${vscBackground}; + text-align: left; + border: none; + margin: 0; + height: 100%; + width: 100%; + max-width: 180px; + white-space: nowrap; + overflow: hidden; + + border: 0.5px solid ${lightGray}; + border-radius: ${defaultBorderRadius}; + + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + + color: ${vscForeground}; + + padding: 3px 6px; + + &:focus { + outline: none; + } + + &:hover { + background-color: ${vscInputBackground}; + } + + font-size: ${getFontSize() - 2}px; +`; + +const StyledListboxOptions = styled(Listbox.Options)` + background-color: ${vscInputBackground}; + padding: 0; + + position: absolute; + bottom: calc(100% - 16px); + max-width: 100%; + max-height: 80vh; + + border-radius: ${defaultBorderRadius}; + overflow-y: scroll; +`; + +const StyledListboxOption = styled(Listbox.Option)<{ selected: boolean }>` + background-color: ${({ selected }) => + selected ? vscListActiveBackground : vscInputBackground}; + cursor: pointer; + padding: 6px 8px; + + &:hover { + background-color: ${vscListActiveBackground}; + color: ${vscListActiveForeground}; + } +`; + +function ListBoxOption({ + option, + idx, + showDelete, + selected, +}: { + option: ProfileDescription; + idx: number; + showDelete?: boolean; + selected: boolean; +}) { + const ideMessenger = useContext(IdeMessengerContext); + + const dispatch = useDispatch(); + const [hovered, setHovered] = useState(false); + + return ( + { + setHovered(true); + }} + onMouseLeave={() => { + setHovered(false); + }} + > +
+ {option.title} +
+
+ ); +} + +function ProfileSwitcher(props: {}) { + const ideMessenger = useContext(IdeMessengerContext); + const { session, logout, login } = useAuth(); + const [profiles, setProfiles] = useState([]); + + const selectedProfileId = useSelector( + (store: RootState) => store.state.selectedProfileId, + ); + + const [controlServerBetaEnabled, setControlServerBetaEnabled] = + useState(false); + + useEffect(() => { + ideMessenger.ide.getIdeSettings().then((settings) => { + setControlServerBetaEnabled(settings.enableControlServerBeta); + }); + }, []); + + useEffect(() => { + ideMessenger.request("config/listProfiles", undefined).then(setProfiles); + }, []); + + useWebviewListener( + "didChangeAvailableProfiles", + async (data) => { + setProfiles(data.profiles); + }, + [], + ); + + const topDiv = document.getElementById("profile-select-top-div"); + + function selectedProfile() { + return profiles.find((p) => p.id === selectedProfileId); + } + + return ( + <> + {controlServerBetaEnabled && profiles.length > 0 && ( + { + ideMessenger.request("didChangeSelectedProfile", { id }); + }} + > +
+ +
{selectedProfile()?.title}
+
+
+
+ {topDiv && + ReactDOM.createPortal( + + + {profiles.map((option, idx) => ( + 1} + /> + ))} +
+ {profiles.length === 0 ? ( + No profiles found + ) : ( + "Select profile" + )} +
+
+
, + topDiv, + )} +
+
+ )} + + {/* Settings button (either opens config.json or /settings page in control plane) */} + { + if (selectedProfileId === "local") { + ideMessenger.post("openConfigJson", undefined); + } else { + ideMessenger.post( + "openUrl", + `http://app.continue.dev/workspaces/${selectedProfileId}/settings`, + ); + } + }} + text="Configure Continue" + > + + + + {/* Only show login if beta explicitly enabled */} + {!isJetBrains() && controlServerBetaEnabled && ( + { + if (session.account) { + logout(); + } else { + login(); + } + }} + > + + + )} + + ); +} + +export default ProfileSwitcher; diff --git a/gui/src/components/loaders/BlinkingDot.tsx b/gui/src/components/loaders/BlinkingDot.tsx new file mode 100644 index 0000000000..db8feb5a5a --- /dev/null +++ b/gui/src/components/loaders/BlinkingDot.tsx @@ -0,0 +1,34 @@ +import styled, { css, keyframes } from "styled-components"; + +const DEFAULT_DIAMETER = 6; + +const blink = keyframes` + 0%, 100% { + transform: scale(1); + opacity: 1; + } + 50% { + opacity: 0.25; + } +`; + +const blinkAnimation = css` + animation: ${blink} 3s infinite; +`; + +const BlinkingDot = styled.div<{ + color: string; + diameter?: number; + shouldBlink?: boolean; +}>` + background-color: ${(props) => props.color}; + box-shadow: 0px 0px 2px 1px ${(props) => props.color}; + width: ${(props) => props.diameter ?? DEFAULT_DIAMETER}px; + height: ${(props) => props.diameter ?? DEFAULT_DIAMETER}px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.75); + margin: 0 2px; + ${(props) => (props.shouldBlink ?? false) && blinkAnimation}; +`; + +export default BlinkingDot; diff --git a/gui/src/components/mainInput/InputToolbar.tsx b/gui/src/components/mainInput/InputToolbar.tsx index e85a629f16..1b2dde1885 100644 --- a/gui/src/components/mainInput/InputToolbar.tsx +++ b/gui/src/components/mainInput/InputToolbar.tsx @@ -21,6 +21,7 @@ import { getMetaKeyLabel, isMetaEquivalentKeyPressed, } from "../../util"; +import ModelSelect from "../modelSelection/ModelSelect"; const StyledDiv = styled.div<{ hidden?: boolean }>` position: absolute; @@ -85,6 +86,7 @@ function InputToolbar(props: InputToolbarProps) { return (