diff --git a/.gitignore b/.gitignore index 763b9623822..92cb4b8523b 100644 --- a/.gitignore +++ b/.gitignore @@ -157,11 +157,15 @@ Icon? notes.md manual-testing-sandbox/.idea/** +manual-testing-sandbox/.continue/** extensions/intellij/.idea/** **/.idea/workspace.xml **/.idea/usage.statistics.xml **/.idea/shelf/ +**/.idea/inspectionProfiles/Project_Default.xml +**/.idea/php.xml + extensions/intellij/bin extensions/.continue-debug/ diff --git a/binary/package-lock.json b/binary/package-lock.json index c823d785e4e..5ce095e373c 100644 --- a/binary/package-lock.json +++ b/binary/package-lock.json @@ -51,7 +51,7 @@ "@continuedev/config-yaml": "^1.0.63", "@continuedev/fetch": "^1.0.4", "@continuedev/llm-info": "^1.0.2", - "@continuedev/openai-adapters": "^1.0.10", + "@continuedev/openai-adapters": "^1.0.18", "@modelcontextprotocol/sdk": "^1.5.0", "@mozilla/readability": "^0.5.0", "@octokit/rest": "^20.1.1", diff --git a/core/commands/index.ts b/core/commands/index.ts index 72719d832fd..486eaccf294 100644 --- a/core/commands/index.ts +++ b/core/commands/index.ts @@ -10,6 +10,7 @@ export function slashFromCustomCommand( return { name: customCommand.name, description: customCommand.description ?? "", + prompt: customCommand.prompt, run: async function* ({ input, llm, history, ide, completionOptions }) { // Remove slash command prefix from input let userInput = input; diff --git a/core/commands/util.ts b/core/commands/util.ts index 1cd493fad7f..5a0ea32e4ef 100644 --- a/core/commands/util.ts +++ b/core/commands/util.ts @@ -1,6 +1,7 @@ +import { v4 as uuidv4 } from "uuid"; + import { ContextItemWithId, RangeInFileWithContents } from "../"; import { findUriInDirs, getUriPathBasename } from "../util/uri"; -import { v4 as uuidv4 } from "uuid"; export function rifWithContentsToContextItem( rif: RangeInFileWithContents, diff --git a/core/config/ConfigHandler.ts b/core/config/ConfigHandler.ts index ddff9bd40aa..7db61161a4b 100644 --- a/core/config/ConfigHandler.ts +++ b/core/config/ConfigHandler.ts @@ -387,16 +387,6 @@ export class ConfigHandler { this.ideSettingsPromise, ); - // After login, default to the first org as the selected org - try { - const orgs = await this.controlPlaneClient.listOrganizations(); - if (orgs.length) { - await this.setSelectedOrgId(orgs[0].id); - } - } catch (e) { - console.error("Failed to fetch control plane profiles: ", e); - } - this.fetchControlPlaneProfiles().catch(async (e) => { console.error("Failed to fetch control plane profiles: ", e); await this.loadLocalProfilesOnly(); diff --git a/core/config/load.ts b/core/config/load.ts index a198f37bf52..4c98e772856 100644 --- a/core/config/load.ts +++ b/core/config/load.ts @@ -28,7 +28,6 @@ import { IdeType, ILLM, LLMOptions, - MCPOptions, ModelDescription, RerankerDescription, SerializedContinueConfig, @@ -45,7 +44,6 @@ import ContinueProxyContextProvider from "../context/providers/ContinueProxyCont import CustomContextProviderClass from "../context/providers/CustomContextProvider"; import FileContextProvider from "../context/providers/FileContextProvider"; import { contextProviderClassFromName } from "../context/providers/index"; -import PromptFilesContextProvider from "../context/providers/PromptFilesContextProvider"; import { useHub } from "../control-plane/env"; import { allEmbeddingsProviders } from "../indexing/allEmbeddingsProviders"; import { BaseLLM } from "../llm"; @@ -401,8 +399,6 @@ async function intermediateToFinalConfig( ...(!config.disableIndexing ? [new CodebaseContextProvider(codebaseContextParams)] : []), - // Add prompt files provider if enabled - ...(loadPromptFiles ? [new PromptFilesContextProvider({})] : []), ]; const DEFAULT_CONTEXT_PROVIDERS_TITLES = DEFAULT_CONTEXT_PROVIDERS.map( @@ -521,6 +517,8 @@ async function intermediateToFinalConfig( contextProviders, models, tools: allTools, + mcpServerStatuses: [], + slashCommands: config.slashCommands ?? [], modelsByRole: { chat: models, edit: models, @@ -541,53 +539,18 @@ async function intermediateToFinalConfig( }, }; - // Apply MCP if specified + // Trigger MCP server refreshes (Config is reloaded again once connected!) const mcpManager = MCPManagerSingleton.getInstance(); - function getMcpId(options: MCPOptions) { - return JSON.stringify(options); - } - if (config.experimental?.modelContextProtocolServers) { - await mcpManager.removeUnusedConnections( - config.experimental.modelContextProtocolServers.map(getMcpId), - ); - } - - if (config.experimental?.modelContextProtocolServers) { - const abortController = new AbortController(); - const mcpConnectionTimeout = setTimeout( - () => abortController.abort(), - 5000, - ); - - await Promise.allSettled( - config.experimental.modelContextProtocolServers?.map( - async (server, index) => { - try { - const mcpId = getMcpId(server); - const mcpConnection = mcpManager.createConnection(mcpId, server); - await mcpConnection.modifyConfig( - continueConfig, - mcpId, - abortController.signal, - "MCP Server", - server.faviconUrl, - ); - } catch (e) { - let errorMessage = "Failed to load MCP server"; - if (e instanceof Error) { - errorMessage += ": " + e.message; - } - errors.push({ - fatal: false, - message: errorMessage, - }); - } finally { - clearTimeout(mcpConnectionTimeout); - } - }, - ) || [], - ); - } + mcpManager.setConnections( + (config.experimental?.modelContextProtocolServers ?? []).map( + (server, index) => ({ + id: `continue-mcp-server-${index + 1}`, + name: `MCP Server ${index + 1}`, + ...server, + }), + ), + false, + ); // Handle experimental modelRole config values for apply and edit const inlineEditModel = getModelByRole(continueConfig, "inlineEdit")?.title; @@ -667,11 +630,9 @@ async function finalToBrowserConfig( models: final.models.map(llmToSerializedModelDescription), systemMessage: final.systemMessage, completionOptions: final.completionOptions, - slashCommands: final.slashCommands?.map((s) => ({ - name: s.name, - description: s.description, - params: s.params, // TODO: is this why params aren't referenced properly by slash commands? - })), + slashCommands: final.slashCommands?.map( + ({ run, ...slashCommandDescription }) => slashCommandDescription, + ), contextProviders: final.contextProviders?.map((c) => c.description), disableIndexing: final.disableIndexing, disableSessionTitles: final.disableSessionTitles, @@ -681,6 +642,7 @@ async function finalToBrowserConfig( rules: final.rules, docs: final.docs, tools: final.tools, + mcpServerStatuses: final.mcpServerStatuses, tabAutocompleteOptions: final.tabAutocompleteOptions, usePlatform: await useHub(ide.getIdeSettings()), modelsByRole: Object.fromEntries( diff --git a/core/config/profile/ControlPlaneProfileLoader.ts b/core/config/profile/ControlPlaneProfileLoader.ts index 8d12e771f82..09a1846a403 100644 --- a/core/config/profile/ControlPlaneProfileLoader.ts +++ b/core/config/profile/ControlPlaneProfileLoader.ts @@ -2,6 +2,7 @@ import { ConfigJson } from "@continuedev/config-types"; import { ConfigResult } from "@continuedev/config-yaml"; import { ControlPlaneClient } from "../../control-plane/client.js"; +import { PRODUCTION_ENV } from "../../control-plane/env.js"; import { ContinueConfig, IDE, @@ -10,7 +11,6 @@ import { } from "../../index.js"; import { ProfileDescription } from "../ProfileLifecycleManager.js"; -import { PRODUCTION_ENV } from "../../control-plane/env.js"; import doLoadConfig from "./doLoadConfig.js"; import { IProfileLoader } from "./IProfileLoader.js"; diff --git a/core/config/profile/PlatformProfileLoader.ts b/core/config/profile/PlatformProfileLoader.ts index 49fd2d0c10c..1eddedb6abe 100644 --- a/core/config/profile/PlatformProfileLoader.ts +++ b/core/config/profile/PlatformProfileLoader.ts @@ -1,11 +1,10 @@ import { AssistantUnrolled, ConfigResult } from "@continuedev/config-yaml"; import { ControlPlaneClient } from "../../control-plane/client.js"; +import { getControlPlaneEnv } from "../../control-plane/env.js"; import { ContinueConfig, IDE, IdeSettings } from "../../index.js"; - import { ProfileDescription } from "../ProfileLifecycleManager.js"; -import { getControlPlaneEnv } from "../../control-plane/env.js"; import doLoadConfig from "./doLoadConfig.js"; import { IProfileLoader } from "./IProfileLoader.js"; diff --git a/core/config/profile/doLoadConfig.ts b/core/config/profile/doLoadConfig.ts index 224b7b64e30..28371a220cf 100644 --- a/core/config/profile/doLoadConfig.ts +++ b/core/config/profile/doLoadConfig.ts @@ -6,18 +6,24 @@ import { ConfigValidationError, ModelRole, } from "@continuedev/config-yaml"; + import { ContinueConfig, ContinueRcJson, IDE, IdeSettings, SerializedContinueConfig, + Tool, } from "../../"; +import { constructMcpSlashCommand } from "../../commands/slash/mcp"; +import { MCPManagerSingleton } from "../../context/mcp"; +import MCPContextProvider from "../../context/providers/MCPContextProvider"; import { ControlPlaneProxyInfo } from "../../control-plane/analytics/IAnalyticsProvider.js"; import { ControlPlaneClient } from "../../control-plane/client.js"; import { getControlPlaneEnv } from "../../control-plane/env.js"; import { TeamAnalytics } from "../../control-plane/TeamAnalytics.js"; import ContinueProxy from "../../llm/llms/stubs/ContinueProxy"; +import { encodeMCPToolUri } from "../../tools/callTool"; import { getConfigJsonPath, getConfigYamlPath } from "../../util/paths"; import { localPathOrUriToPath } from "../../util/pathToUri"; import { Telemetry } from "../../util/posthog"; @@ -26,6 +32,7 @@ import { loadContinueConfigFromJson } from "../load"; import { migrateJsonSharedConfig } from "../migrateSharedConfig"; import { rectifySelectedModelsFromGlobalContext } from "../selectedModels"; import { loadContinueConfigFromYaml } from "../yaml/loadYaml"; + import { PlatformConfigMetadata } from "./PlatformProfileLoader"; export default async function doLoadConfig( @@ -93,15 +100,90 @@ export default async function doLoadConfig( configLoadInterrupted = result.configLoadInterrupted; } - // Rectify model selections for each role - if (newConfig) { - newConfig = rectifySelectedModelsFromGlobalContext(newConfig, profileId); - } - if (configLoadInterrupted || !newConfig) { return { errors, config: newConfig, configLoadInterrupted: true }; } + // TODO using config result but result with non-fatal errors is an antipattern? + // Remove ability have undefined errors, just have an array + errors = [...(errors ?? [])]; + + // Rectify model selections for each role + newConfig = rectifySelectedModelsFromGlobalContext(newConfig, profileId); + + // Add things from MCP servers + const mcpManager = MCPManagerSingleton.getInstance(); + const mcpServerStatuses = mcpManager.getStatuses(); + + // Slightly hacky just need connection's client to make slash command for now + const serializableStatuses = mcpServerStatuses.map((server) => { + const { client, ...rest } = server; + return rest; + }); + newConfig.mcpServerStatuses = serializableStatuses; + + for (const server of mcpServerStatuses) { + if (server.status === "connected") { + const serverTools: Tool[] = server.tools.map((tool) => ({ + displayTitle: server.name + " " + tool.name, + function: { + description: tool.description, + name: tool.name, + parameters: tool.inputSchema, + }, + faviconUrl: server.faviconUrl, + readonly: false, + type: "function" as const, + wouldLikeTo: "", + uri: encodeMCPToolUri(server.id, tool.name), + group: server.name, + })); + newConfig.tools.push(...serverTools); + + const serverSlashCommands = server.prompts.map((prompt) => + constructMcpSlashCommand( + server.client, + prompt.name, + prompt.description, + prompt.arguments?.map((a: any) => a.name), + ), + ); + newConfig.slashCommands.push(...serverSlashCommands); + + const submenuItems = server.resources.map((resource) => ({ + title: resource.name, + description: resource.description ?? resource.name, + id: resource.uri, + icon: server.faviconUrl, + })); + if (submenuItems.length > 0) { + const serverContextProvider = new MCPContextProvider({ + submenuItems, + mcpId: server.id, + }); + newConfig.contextProviders.push(serverContextProvider); + } + } + } + + // Detect duplicate tool names + const counts: Record = {}; + newConfig.tools.forEach((tool) => { + if (counts[tool.function.name]) { + counts[tool.function.name] = counts[tool.function.name] + 1; + } else { + counts[tool.function.name] = 1; + } + }); + Object.entries(counts).forEach(([toolName, count]) => { + if (count > 1) { + errors!.push({ + fatal: false, + message: `Duplicate (${count}) tools named "${toolName}" detected. Permissions will conflict and usage may be unpredictable`, + }); + } + }); + newConfig.allowAnonymousTelemetry = newConfig.allowAnonymousTelemetry && (await ide.isTelemetryEnabled()); diff --git a/core/config/validation.ts b/core/config/validation.ts index d28bb7809ac..a3bc400999d 100644 --- a/core/config/validation.ts +++ b/core/config/validation.ts @@ -1,4 +1,5 @@ import { ConfigValidationError } from "@continuedev/config-yaml"; + import { ModelDescription, SerializedContinueConfig } from "../"; import { Telemetry } from "../util/posthog"; diff --git a/core/config/yaml/loadYaml.ts b/core/config/yaml/loadYaml.ts index d946b475424..1ca54337bca 100644 --- a/core/config/yaml/loadYaml.ts +++ b/core/config/yaml/loadYaml.ts @@ -28,7 +28,6 @@ import CodebaseContextProvider from "../../context/providers/CodebaseContextProv import DocsContextProvider from "../../context/providers/DocsContextProvider"; import FileContextProvider from "../../context/providers/FileContextProvider"; import { contextProviderClassFromName } from "../../context/providers/index"; -import PromptFilesContextProvider from "../../context/providers/PromptFilesContextProvider"; import { ControlPlaneClient } from "../../control-plane/client"; import { allEmbeddingsProviders } from "../../indexing/allEmbeddingsProviders"; import FreeTrial from "../../llm/llms/FreeTrial"; @@ -111,7 +110,8 @@ async function configYamlToContinueConfig( const continueConfig: ContinueConfig = { slashCommands: [], models: [], - tools: allTools, + tools: [...allTools], + mcpServerStatuses: [], systemMessage: config.rules?.join("\n"), experimental: { modelContextProtocolServers: config.mcpServers?.map((mcpServer) => ({ @@ -334,7 +334,6 @@ async function configYamlToContinueConfig( const DEFAULT_CONTEXT_PROVIDERS = [ new FileContextProvider({}), new CodebaseContextProvider(codebaseContextParams), - new PromptFilesContextProvider({}), ]; const DEFAULT_CONTEXT_PROVIDERS_TITLES = DEFAULT_CONTEXT_PROVIDERS.map( @@ -368,52 +367,19 @@ async function configYamlToContinueConfig( continueConfig.contextProviders.push(new DocsContextProvider({})); } - // Apply MCP if specified + // Trigger MCP server refreshes (Config is reloaded again once connected!) const mcpManager = MCPManagerSingleton.getInstance(); - if (config.mcpServers) { - await mcpManager.removeUnusedConnections( - config.mcpServers.map((s) => s.name), - ); - } - - await Promise.allSettled( - config.mcpServers?.map(async (server) => { - const abortController = new AbortController(); - const mcpConnectionTimeout = setTimeout( - () => abortController.abort(), - 5000, - ); - - try { - const mcpId = server.name; - const mcpConnection = mcpManager.createConnection(mcpId, { - transport: { - type: "stdio", - args: [], - ...server, - }, - }); - - await mcpConnection.modifyConfig( - continueConfig, - mcpId, - abortController.signal, - server.name, - server.faviconUrl, - ); - } catch (e) { - let errorMessage = `Failed to load MCP server ${server.name}`; - if (e instanceof Error) { - errorMessage += ": " + e.message; - } - localErrors.push({ - fatal: false, - message: errorMessage, - }); - } finally { - clearTimeout(mcpConnectionTimeout); - } - }) ?? [], + mcpManager.setConnections( + (config.mcpServers ?? []).map((server) => ({ + id: server.name, + name: server.name, + transport: { + type: "stdio", + args: [], + ...server, + }, + })), + false, ); return { config: continueConfig, errors: localErrors }; diff --git a/core/context/mcp/index.ts b/core/context/mcp/index.ts index ec6d3903a2d..7e77561bf24 100644 --- a/core/context/mcp/index.ts +++ b/core/context/mcp/index.ts @@ -4,16 +4,23 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { WebSocketClientTransport } from "@modelcontextprotocol/sdk/client/websocket.js"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import { ContinueConfig, MCPOptions, SlashCommand, Tool } from "../.."; -import { constructMcpSlashCommand } from "../../commands/slash/mcp"; -import { encodeMCPToolUri } from "../../tools/callTool"; -import MCPContextProvider from "../providers/MCPContextProvider"; +import { + MCPConnectionStatus, + MCPOptions, + MCPPrompt, + MCPResource, + MCPServerStatus, + MCPTool, +} from "../.."; export class MCPManagerSingleton { private static instance: MCPManagerSingleton; + public onConnectionsRefreshed?: () => void; private connections: Map = new Map(); + private abortController: AbortController = new AbortController(); + private constructor() {} public static getInstance(): MCPManagerSingleton { @@ -46,18 +53,90 @@ export class MCPManagerSingleton { this.connections.delete(id); } - async removeUnusedConnections(keepIds: string[]) { - const toRemove = Array.from(this.connections.keys()).filter( - (k) => !keepIds.includes(k), - ); - await Promise.all(toRemove.map((id) => this.removeConnection(id))); + setConnections(servers: MCPOptions[], forceRefresh: boolean) { + let refresh = false; + + // Remove any connections that are no longer in config + Array.from(this.connections.entries()).forEach(([id, connection]) => { + if (!servers.find((s) => s.id === id)) { + refresh = true; + connection.abortController.abort(); + void connection.client.close(); + this.connections.delete(id); + } + }); + + // Add any connections that are not yet in manager + servers.forEach((server) => { + if (!this.connections.has(server.id)) { + refresh = true; + this.connections.set(server.id, new MCPConnection(server)); + } + }); + + // NOTE the id is made by stringifying the options + if (refresh) { + void this.refreshConnections(forceRefresh); + } + } + + async refreshConnection(serverId: string) { + const connection = this.connections.get(serverId); + if (!connection) { + throw new Error(`MCP Connection ${serverId} not found`); + } + await connection.connectClient(true, this.abortController.signal); + if (this.onConnectionsRefreshed) { + this.onConnectionsRefreshed(); + } + } + + async refreshConnections(force: boolean) { + this.abortController.abort(); + this.abortController = new AbortController(); + await Promise.race([ + new Promise((resolve) => { + this.abortController.signal.addEventListener("abort", () => { + resolve(undefined); + }); + }), + (async () => { + await Promise.all( + Array.from(this.connections.values()).map(async (connection) => { + await connection.connectClient(force, this.abortController.signal); + }), + ); + if (this.onConnectionsRefreshed) { + this.onConnectionsRefreshed(); + } + })(), + ]); + } + + getStatuses(): (MCPServerStatus & { client: Client })[] { + return Array.from(this.connections.values()).map((connection) => ({ + ...connection.getStatus(), + client: connection.client, + })); } } +const DEFAULT_MCP_TIMEOUT = 20_000; // 10 seconds + class MCPConnection { public client: Client; private transport: Transport; + private connectionPromise: Promise | null = null; + public abortController: AbortController; + + public status: MCPConnectionStatus = "not-connected"; + public errors: string[] = []; + + public prompts: MCPPrompt[] = []; + public tools: MCPTool[] = []; + public resources: MCPResource[] = []; + constructor(private readonly options: MCPOptions) { this.transport = this.constructTransport(options); @@ -70,6 +149,8 @@ class MCPConnection { capabilities: {}, }, ); + + this.abortController = new AbortController(); } private constructTransport(options: MCPOptions): Transport { @@ -95,131 +176,170 @@ class MCPConnection { } } - private isConnected: boolean = false; - private connectPromise: Promise | null = null; + getStatus(): MCPServerStatus { + return { + ...this.options, + errors: this.errors, + prompts: this.prompts, + resources: this.resources, + tools: this.tools, + status: this.status, + }; + } - private async connectClient() { - if (this.isConnected) { + async connectClient(forceRefresh: boolean, externalSignal: AbortSignal) { + if (!forceRefresh) { // Already connected - return; - } + if (this.status === "connected") { + return; + } - if (this.connectPromise) { // Connection is already in progress; wait for it to complete - await this.connectPromise; - return; + if (this.connectionPromise) { + await this.connectionPromise; + return; + } } - this.connectPromise = (async () => { - await this.client.connect(this.transport); - this.isConnected = true; - })(); + this.status = "connecting"; + this.tools = []; + this.prompts = []; + this.resources = []; + this.errors = []; - try { - await this.connectPromise; - } finally { - // Reset the promise so future attempts can try again if necessary - this.connectPromise = null; - } - } + this.abortController.abort(); + this.abortController = new AbortController(); - async modifyConfig( - config: ContinueConfig, - mcpId: string, - signal: AbortSignal, - name: string, - faviconUrl: string | undefined, - ) { - try { - await Promise.race([ - this.connectClient(), - new Promise((_, reject) => { - signal.addEventListener("abort", () => - reject(new Error("Connection timed out")), - ); - }), - ]); - } catch (error) { - if (error instanceof Error) { - const msg = error.message.toLowerCase(); - if (msg.includes("spawn") && msg.includes("enoent")) { - const command = msg.split(" ")[1]; - throw new Error( - `command "${command}" not found. To use this MCP server, install the ${command} CLI.`, - ); - } else if ( - !error.message.startsWith("StdioClientTransport already started") - ) { - // don't throw error if it's just a "server already running" case - throw error; - } - } else { - throw error; - } - } + this.connectionPromise = Promise.race([ + // If aborted by a refresh or other, cancel and don't do anything + new Promise((resolve) => { + externalSignal.addEventListener("abort", () => { + resolve(undefined); + }); + }), + new Promise((resolve) => { + this.abortController.signal.addEventListener("abort", () => { + resolve(undefined); + }); + }), + (async () => { + const timeoutController = new AbortController(); + const connectionTimeout = setTimeout( + () => timeoutController.abort(), + this.options.timeout ?? DEFAULT_MCP_TIMEOUT, + ); - const capabilities = this.client.getServerCapabilities(); + try { + await Promise.race([ + new Promise((_, reject) => { + timeoutController.signal.addEventListener("abort", () => { + reject(new Error("Connection timed out")); + }); + }), + (async () => { + this.transport = this.constructTransport(this.options); + try { + await this.client.connect(this.transport); + } catch (error) { + // Allow the case where for whatever reason is already connected + if ( + error instanceof Error && + error.message.startsWith( + "StdioClientTransport already started", + ) + ) { + await this.client.close(); + await this.client.connect(this.transport); + } else { + throw error; + } + } - // Resources <—> Context Provider - if (capabilities?.resources) { - const { resources } = await this.client.listResources({}, { signal }); - const submenuItems = resources.map((resource: any) => ({ - title: resource.name, - description: resource.description, - id: resource.uri, - icon: faviconUrl, - })); + // TODO register server notification handlers + // this.client.transport?.onmessage(msg => console.log()) + // this.client.setNotificationHandler(, notification => { + // console.log(notification) + // }) - if (!config.contextProviders) { - config.contextProviders = []; - } + const capabilities = this.client.getServerCapabilities(); - config.contextProviders.push( - new MCPContextProvider({ - submenuItems, - mcpId, - }), - ); - } + // Resources <—> Context Provider + if (capabilities?.resources) { + try { + const { resources } = await this.client.listResources( + {}, + { signal: timeoutController.signal }, + ); + this.resources = resources; + } catch (e) { + let errorMessage = `Error loading resources for MCP Server ${this.options.name}`; + if (e instanceof Error) { + errorMessage += `: ${e.message}`; + } + this.errors.push(errorMessage); + } + } - // Tools <—> Tools - if (capabilities?.tools) { - const { tools } = await this.client.listTools({}, { signal }); - const continueTools: Tool[] = tools.map((tool: any) => ({ - displayTitle: name + " " + tool.name, - function: { - description: tool.description, - name: tool.name, - parameters: tool.inputSchema, - }, - faviconUrl, - readonly: false, - type: "function", - wouldLikeTo: `use the ${name} ${tool.name} tool`, - uri: encodeMCPToolUri(mcpId, tool.name), - })); - - config.tools = [...continueTools, ...config.tools]; - } + // Tools <—> Tools + if (capabilities?.tools) { + try { + const { tools } = await this.client.listTools( + {}, + { signal: timeoutController.signal }, + ); + this.tools = tools; + } catch (e) { + let errorMessage = `Error loading tools for MCP Server ${this.options.name}`; + if (e instanceof Error) { + errorMessage += `: ${e.message}`; + } + this.errors.push(errorMessage); + } + } - // Prompts <—> Slash commands - if (capabilities?.prompts) { - const { prompts } = await this.client.listPrompts({}, { signal }); - if (!config.slashCommands) { - config.slashCommands = []; - } + // Prompts <—> Slash commands + if (capabilities?.prompts) { + try { + const { prompts } = await this.client.listPrompts( + {}, + { signal: timeoutController.signal }, + ); + this.prompts = prompts; + } catch (e) { + let errorMessage = `Error loading prompts for MCP Server ${this.options.name}`; + if (e instanceof Error) { + errorMessage += `: ${e.message}`; + } + this.errors.push(errorMessage); + } + } - const slashCommands: SlashCommand[] = prompts.map((prompt: any) => - constructMcpSlashCommand( - this.client, - prompt.name, - prompt.description, - prompt.arguments?.map((a: any) => a.name), - ), - ); + this.status = "connected"; + })(), + ]); + } catch (error) { + // Otherwise it's a connection error + let errorMessage = `Failed to connect to MCP server ${this.options.name}`; + if (error instanceof Error) { + const msg = error.message.toLowerCase(); + if (msg.includes("spawn") && msg.includes("enoent")) { + const command = msg.split(" ")[1]; + errorMessage += `command "${command}" not found. To use this MCP server, install the ${command} CLI.`; + } else { + errorMessage += ": " + error.message; + } + } - config.slashCommands.push(...slashCommands); - } + this.status = "error"; + this.errors.push(errorMessage); + } finally { + this.connectionPromise = null; + clearTimeout(connectionTimeout); + } + })(), + ]); + + await this.connectionPromise; } } diff --git a/core/context/providers/PromptFilesContextProvider.ts b/core/context/providers/PromptFilesContextProvider.ts deleted file mode 100644 index c3b4381f401..00000000000 --- a/core/context/providers/PromptFilesContextProvider.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { BaseContextProvider } from ".."; -import { - ContextItem, - ContextProviderDescription, - ContextProviderExtras, - ContextSubmenuItem, - LoadSubmenuItemsArgs, -} from "../../"; -import { getAllPromptFiles } from "../../promptFiles/v2/getPromptFiles"; -import { parsePreamble } from "../../promptFiles/v2/parse"; -import { renderPromptFileV2 } from "../../promptFiles/v2/renderPromptFile"; - -class PromptFilesContextProvider extends BaseContextProvider { - static description: ContextProviderDescription = { - title: "prompt-files", - displayTitle: "Prompt Files", - description: ".prompt files", - type: "submenu", - }; - - async getContextItems( - query: string, - extras: ContextProviderExtras, - ): Promise { - const rawContent = await extras.ide.readFile(query); - const preamble = parsePreamble(query, rawContent); - const [contextItems, body] = await renderPromptFileV2(rawContent, extras); - return [ - ...contextItems, - { - content: body, - name: preamble.name, - description: preamble.description, - }, - ]; - } - - async loadSubmenuItems( - args: LoadSubmenuItemsArgs, - ): Promise { - const promptFiles = await getAllPromptFiles( - args.ide, - args.config.experimental?.promptPath, - // Note, NOT checking v1 default folder here, deprecated for context provider - ); - return promptFiles.map((file) => { - const preamble = parsePreamble(file.path, file.content); - return { - id: file.path, - title: preamble.name, - description: preamble.description, - }; - }); - } -} - -export default PromptFilesContextProvider; diff --git a/core/core.ts b/core/core.ts index 629aa84ca7b..5fa42c5b67b 100644 --- a/core/core.ts +++ b/core/core.ts @@ -22,7 +22,6 @@ import { DataLogger } from "./data/log"; import { streamDiffLines } from "./edit/streamDiffLines"; import { CodebaseIndexer, PauseToken } from "./indexing/CodebaseIndexer"; import DocsService from "./indexing/docs/DocsService"; -import { getAllSuggestedDocs } from "./indexing/docs/suggestions"; import Ollama from "./llm/llms/Ollama"; import { createNewPromptFileV2 } from "./promptFiles/v2/createNewPromptFile"; import { callTool } from "./tools/callTool"; @@ -43,6 +42,7 @@ import { } from "."; import { isLocalAssistantFile } from "./config/loadLocalAssistants"; +import { MCPManagerSingleton } from "./context/mcp"; import { shouldIgnore } from "./indexing/shouldIgnore"; import { walkDirCache } from "./indexing/walkDir"; import { llmStreamChat } from "./llm/streamChat"; @@ -120,6 +120,13 @@ export class Core { this.messenger, ); + const mcpManager = MCPManagerSingleton.getInstance(); + mcpManager.onConnectionsRefreshed = async () => { + // This ensures that it triggers a NEW load after waiting for config promise to finish + await this.configHandler.loadConfig(); + await this.configHandler.reloadConfig(); + }; + this.configHandler.onConfigUpdate(async (result) => { const serializedResult = await this.configHandler.getSerializedConfig(); this.messenger.send("configUpdate", { @@ -344,6 +351,9 @@ export class Core { return await this.configHandler.listOrganizations(); }); + on("mcp/reloadServer", async (msg) => { + mcpManager.refreshConnection(msg.data.id); + }); // Context providers on("context/addDocs", async (msg) => { void this.docsService.indexAndAdd(msg.data); @@ -796,14 +806,6 @@ export class Core { // this.docsService.setPaused(msg.data.id, msg.data.paused); // not supported yet } }); - on("docs/getSuggestedDocs", async (msg) => { - if (hasRequestedDocs) { - return; - } // TODO, remove, hack because of rerendering - hasRequestedDocs = true; - const suggestedDocs = await getAllSuggestedDocs(this.ide); - this.messenger.send("docs/suggestions", suggestedDocs); - }); on("docs/initStatuses", async (msg) => { void this.docsService.initStatuses(); }); @@ -978,4 +980,3 @@ export class Core { // private } -let hasRequestedDocs = false; diff --git a/core/index.d.ts b/core/index.d.ts index 6926179bf79..c7ac930cad3 100644 --- a/core/index.d.ts +++ b/core/index.d.ts @@ -43,13 +43,13 @@ export interface IndexingProgressUpdate { desc: string; shouldClearIndexes?: boolean; status: - | "loading" - | "indexing" - | "done" - | "failed" - | "paused" - | "disabled" - | "cancelled"; + | "loading" + | "indexing" + | "done" + | "failed" + | "paused" + | "disabled" + | "cancelled"; debugInfo?: string; } @@ -316,7 +316,12 @@ export interface CompletionOptions extends BaseCompletionOptions { model: string; } -export type ChatMessageRole = "user" | "assistant" | "thinking" | "system" | "tool"; +export type ChatMessageRole = + | "user" + | "assistant" + | "thinking" + | "system" + | "tool"; export type TextMessagePart = { type: "text"; @@ -434,7 +439,7 @@ export interface PromptLog { completion: string; } -export type MessageModes = "chat" | "edit"; +export type MessageModes = "chat" | "edit" | "agent"; export type ToolStatus = | "generating" @@ -692,10 +697,10 @@ export interface IDE { getCurrentFile(): Promise< | undefined | { - isUntitled: boolean; - path: string; - contents: string; - } + isUntitled: boolean; + path: string; + contents: string; + } >; getLastFileSaveTimestamp?(): number; @@ -761,6 +766,7 @@ export interface ContinueSDK { export interface SlashCommand { name: string; description: string; + prompt?: string; params?: { [key: string]: any }; run: (sdk: ContinueSDK) => AsyncGenerator; } @@ -864,11 +870,7 @@ export interface ContextProviderWithParams { params: { [key: string]: any }; } -export interface SlashCommandDescription { - name: string; - description: string; - params?: { [key: string]: any }; -} +export type SlashCommandDescription = Omit; export interface CustomCommand { name: string; @@ -879,11 +881,11 @@ export interface CustomCommand { export interface Prediction { type: "content"; content: - | string - | { - type: "text"; - text: string; - }[]; + | string + | { + type: "text"; + text: string; + }[]; } export interface ToolExtras { @@ -907,6 +909,7 @@ export interface Tool { readonly: boolean; uri?: string; faviconUrl?: string; + group: string; } interface ToolChoice { @@ -1042,8 +1045,54 @@ export interface SSEOptions { export type TransportOptions = StdioOptions | WebSocketOptions | SSEOptions; export interface MCPOptions { + name: string; + id: string; transport: TransportOptions; faviconUrl?: string; + timeout?: number; +} + +export type MCPConnectionStatus = + | "connecting" + | "connected" + | "error" + | "not-connected"; + +export interface MCPPrompt { + name: string; + description?: string; + arguments?: { + name: string; + description?: string; + required?: boolean; + }[]; +} + +// Leaving here to ideate on +// export type ContinueConfigSource = "local-yaml" | "local-json" | "hub-assistant" | "hub" + +export interface MCPResource { + name: string; + uri: string; + description?: string; + mimeType?: string; +} +export interface MCPTool { + name: string; + description?: string; + inputSchema: { + type: "object"; + properties?: Record; + }; +} + +export interface MCPServerStatus extends MCPOptions { + status: MCPConnectionStatus; + errors: string[]; + + prompts: MCPPrompt[]; + tools: MCPTool[]; + resources: MCPResource[]; } export interface ContinueUIConfig { @@ -1069,6 +1118,11 @@ export interface ExperimentalModelRoles { applyCodeBlock?: string; } +export interface ExperimentalMCPOptions { + transport: TransportOptions; + faviconUrl?: string; +} + export type EditStatus = | "not-started" | "streaming" @@ -1152,7 +1206,7 @@ export interface ExperimentalConfig { * This is needed to crawl a large number of documentation sites that are dynamically rendered. */ useChromiumForDocsCrawling?: boolean; - modelContextProtocolServers?: MCPOptions[]; + modelContextProtocolServers?: ExperimentalMCPOptions[]; } export interface AnalyticsConfig { @@ -1223,9 +1277,9 @@ export interface Config { embeddingsProvider?: EmbeddingsProviderDescription | ILLM; /** The model that Continue will use for tab autocompletions. */ tabAutocompleteModel?: - | CustomLLM - | ModelDescription - | (CustomLLM | ModelDescription)[]; + | CustomLLM + | ModelDescription + | (CustomLLM | ModelDescription)[]; /** Options for tab autocomplete */ tabAutocompleteOptions?: Partial; /** UI styles customization */ @@ -1246,8 +1300,8 @@ export interface ContinueConfig { systemMessage?: string; completionOptions?: BaseCompletionOptions; requestOptions?: RequestOptions; - slashCommands?: SlashCommand[]; - contextProviders?: IContextProvider[]; + slashCommands: SlashCommand[]; + contextProviders: IContextProvider[]; disableSessionTitles?: boolean; disableIndexing?: boolean; userToken?: string; @@ -1257,6 +1311,7 @@ export interface ContinueConfig { analytics?: AnalyticsConfig; docs?: SiteIndexingConfig[]; tools: Tool[]; + mcpServerStatuses: MCPServerStatus[]; rules?: string[]; modelsByRole: Record; selectedModelByRole: Record; @@ -1269,8 +1324,8 @@ export interface BrowserSerializedContinueConfig { systemMessage?: string; completionOptions?: BaseCompletionOptions; requestOptions?: RequestOptions; - slashCommands?: SlashCommandDescription[]; - contextProviders?: ContextProviderDescription[]; + slashCommands: SlashCommandDescription[]; + contextProviders: ContextProviderDescription[]; disableIndexing?: boolean; disableSessionTitles?: boolean; userToken?: string; @@ -1279,6 +1334,7 @@ export interface BrowserSerializedContinueConfig { analytics?: AnalyticsConfig; docs?: SiteIndexingConfig[]; tools: Tool[]; + mcpServerStatuses: MCPServerStatus[]; rules?: string[]; usePlatform: boolean; tabAutocompleteOptions?: Partial; @@ -1319,9 +1375,9 @@ export type PackageDetailsSuccess = PackageDetails & { export type PackageDocsResult = { packageInfo: ParsedPackageInfo; } & ( - | { error: string; details?: never } - | { details: PackageDetailsSuccess; error?: never } - ); + | { error: string; details?: never } + | { details: PackageDetailsSuccess; error?: never } +); export interface TerminalOptions { reuseTerminal?: boolean; diff --git a/core/indexing/docs/suggestions/index.ts b/core/indexing/docs/suggestions/index.ts deleted file mode 100644 index fac5091fd84..00000000000 --- a/core/indexing/docs/suggestions/index.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { - PackageDocsResult, - FilePathAndName, - PackageFilePathAndName, - IDE, - PackageDetails, - ParsedPackageInfo, -} from "../../.."; -import { getUriPathBasename } from "../../../util/uri"; -import { walkDir, walkDirs } from "../../walkDir"; - -import { PythonPackageCrawler } from "./packageCrawlers/Python"; -import { NodePackageCrawler } from "./packageCrawlers/TsJs"; - -const PACKAGE_CRAWLERS = [NodePackageCrawler, PythonPackageCrawler]; - -export interface PackageCrawler { - packageRegistry: string; - getPackageFiles(files: FilePathAndName[]): PackageFilePathAndName[]; - parsePackageFile( - file: PackageFilePathAndName, - contents: string, - ): ParsedPackageInfo[]; - getPackageDetails(packageInfo: ParsedPackageInfo): Promise; -} - -export async function getAllSuggestedDocs(ide: IDE) { - const allFileUris = await walkDirs(ide); - const allFiles = allFileUris.map((uri) => ({ - path: uri, - name: getUriPathBasename(uri), - })); - - // Build map of language -> package files - const packageFilesByRegistry: Record = {}; - for (const Crawler of PACKAGE_CRAWLERS) { - const crawler = new Crawler(); - const packageFilePaths = crawler.getPackageFiles(allFiles); - packageFilesByRegistry[crawler.packageRegistry] = packageFilePaths; - } - - // Get file contents for all unique package files - const uniqueFilePaths = Array.from( - new Set( - Object.values(packageFilesByRegistry).flatMap((files) => - files.map((file) => file.path), - ), - ), - ); - const fileContentsArray = await Promise.all( - uniqueFilePaths.map(async (path) => { - const contents = await ide.readFile(path); - return { path, contents }; - }), - ); - const fileContents = new Map( - fileContentsArray.map(({ path, contents }) => [path, contents]), - ); - - // Parse package files and build map of language -> packages - const packagesByCrawler: Record = {}; - PACKAGE_CRAWLERS.forEach((Crawler) => { - const crawler = new Crawler(); - packagesByCrawler[crawler.packageRegistry] = []; - const packageFiles = packageFilesByRegistry[crawler.packageRegistry]; - packageFiles.forEach((file) => { - const contents = fileContents.get(file.path); - if (!contents) { - return; - } - const packages = crawler.parsePackageFile(file, contents); - packagesByCrawler[crawler.packageRegistry].push(...packages); - }); - }); - - // Deduplicate packages per language - // TODO - this is where you would allow docs for different versions - // by e.g. using "name-version" as the map key instead of just name - // For now have not allowed - const registries = Object.keys(packagesByCrawler); - registries.forEach((registry) => { - const packages = packagesByCrawler[registry]; - const uniquePackages = Array.from( - new Map(packages.map((pkg) => [pkg.name, pkg])).values(), - ); - packagesByCrawler[registry] = uniquePackages; - }); - - // Get documentation links for all packages - const allDocsResults: PackageDocsResult[] = []; - await Promise.all( - PACKAGE_CRAWLERS.map(async (Crawler) => { - const crawler = new Crawler(); - const packages = packagesByCrawler[crawler.packageRegistry]; - const docsByRegistry = await Promise.all( - packages.map(async (packageInfo) => { - try { - const details = await crawler.getPackageDetails(packageInfo); - if (!details.docsLink) { - return { - packageInfo, - error: `No documentation link found for ${packageInfo.name}`, - }; - } - return { - packageInfo, - details: { - ...details, - docsLink: details.docsLink, - docsLinkWarning: details.docsLink.includes("github.com") - ? "Github docs not supported, find the docs site" - : details.docsLink.includes("docs") - ? undefined - : "May not be a docs site, check the URL", - }, - }; - } catch (error) { - return { - packageInfo, - error: `Error getting package details for ${packageInfo.name}`, - }; - } - }), - ); - allDocsResults.push(...docsByRegistry); - }), - ); - return allDocsResults; -} diff --git a/core/indexing/docs/suggestions/packageCrawlers/Python.ts b/core/indexing/docs/suggestions/packageCrawlers/Python.ts deleted file mode 100644 index 3a617568f4f..00000000000 --- a/core/indexing/docs/suggestions/packageCrawlers/Python.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { PackageCrawler } from ".."; -import { - FilePathAndName, - PackageDetails, - PackageFilePathAndName, - ParsedPackageInfo, -} from "../../../.."; - -export class PythonPackageCrawler implements PackageCrawler { - packageRegistry = "pypi"; - - getPackageFiles(files: FilePathAndName[]): PackageFilePathAndName[] { - // For Python, we typically look for files like requirements.txt or Pipfile - return files - .filter( - (file) => file.name === "requirements.txt" || file.name === "Pipfile", - ) - .map((file) => ({ - ...file, - packageRegistry: "pypi", - })); - } - - parsePackageFile( - file: PackageFilePathAndName, - contents: string, - ): ParsedPackageInfo[] { - // Assume the fileContent is a string from a requirements.txt formatted file - return contents - .split("\n") - .map((line) => { - const [name, version] = line.split("=="); - return { name, version, packageFile: file, language: "py" }; - }) - .filter((pkg) => pkg.name && pkg.version); - } - - async getPackageDetails( - packageInfo: ParsedPackageInfo, - ): Promise { - // Fetch metadata from PyPI to find the documentation link - const response = await fetch( - `https://pypi.org/pypi/${packageInfo.name}/json`, - ); - if (!response.ok) { - throw new Error(`Could not fetch data for package ${packageInfo.name}`); - } - const data = await response.json(); - const homePage = data?.info?.home_page as string | undefined; - - return { - docsLink: - (data?.info?.project_urls?.Documentation as string | undefined) ?? - homePage, - title: data?.info?.name as string | undefined, - description: data?.info?.summary as string | undefined, - repo: - (data?.info?.project_urls?.Repository as string | undefined) ?? - homePage, - license: data?.info?.license as string | undefined, - }; - } -} diff --git a/core/indexing/docs/suggestions/packageCrawlers/TsJs.ts b/core/indexing/docs/suggestions/packageCrawlers/TsJs.ts deleted file mode 100644 index 3a9ef96c2b8..00000000000 --- a/core/indexing/docs/suggestions/packageCrawlers/TsJs.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { PackageCrawler } from ".."; -import { - FilePathAndName, - PackageDetails, - PackageFilePathAndName, - ParsedPackageInfo, -} from "../../../.."; - -export class NodePackageCrawler implements PackageCrawler { - packageRegistry = "npm"; - - getPackageFiles(files: FilePathAndName[]): PackageFilePathAndName[] { - // For Javascript/TypeScript, we look for package.json file - return files - .filter((file) => file.name === "package.json") - .map((file) => ({ - ...file, - packageRegistry: this.packageRegistry, - })); - } - - parsePackageFile( - file: PackageFilePathAndName, - contents: string, - ): ParsedPackageInfo[] { - // Parse the package.json content - const jsonData = JSON.parse(contents) as Record; - const dependencies = Object.entries(jsonData.dependencies || {}).concat( - Object.entries(jsonData.devDependencies || {}), - ); - - // Filter out types packages and check if typescript is present - let foundTypes = false; - const filtered = dependencies.filter(([name, _]) => { - if (name.startsWith("@types/")) { - foundTypes = true; - return false; - } - if (name.includes("typescript")) { - foundTypes = true; - } - return true; - }); - return filtered.map(([name, version]) => ({ - name, - version, - packageFile: file, - language: foundTypes ? "ts" : "js", - })); - } - - async getPackageDetails( - packageInfo: ParsedPackageInfo, - ): Promise { - const { name } = packageInfo; - // Fetch metadata from the NPM registry to find the documentation link - const response = await fetch(`https://registry.npmjs.org/${name}`); - if (!response.ok) { - throw new Error(`Could not fetch data for package ${name}`); - } - const data = await response.json(); - - // const dependencies = Object.keys(packageContentData.dependencies || {}) - // .concat(Object.keys(packageContentData.devDependencies || {})); - // const usesTypescript = dependencies.includes("typescript"); - - return { - docsLink: data.homepage as string | undefined, - title: name, // package.json doesn't have specific title field - description: data.description as string | undefined, - repo: Array.isArray(data.repository) - ? (data.respository[0]?.url as string | undefined) - : undefined, - license: data.license as string | undefined, - }; - } -} diff --git a/core/indexing/docs/suggestions/suggestions.test.ts b/core/indexing/docs/suggestions/suggestions.test.ts deleted file mode 100644 index 60b58caa6ae..00000000000 --- a/core/indexing/docs/suggestions/suggestions.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -// Generated by continue - -import { getAllSuggestedDocs } from "./index"; -import { testIde } from "../../../test/fixtures"; -import { - setUpTestDir, - tearDownTestDir, - addToTestDir, -} from "../../../test/testDir"; -import fetch, { RequestInfo } from "node-fetch"; - -jest.mock("node-fetch", undefined, { - virtual: false, -}); - -describe.skip("getAllSuggestedDocs", () => { - beforeEach(() => { - setUpTestDir(); - jest.clearAllMocks(); - }); - - afterEach(() => { - tearDownTestDir(); - jest.restoreAllMocks(); - }); - - it("should return package docs for JavaScript and Python projects", async () => { - // Set up test files - addToTestDir([ - [ - "package.json", - JSON.stringify({ - dependencies: { - express: "^4.17.1", - lodash: "^4.17.21", - }, - devDependencies: { - typescript: "^4.0.0", - }, - }), - ], - ["requirements.txt", "requests==2.25.1\nflask==1.1.2"], - ]); - - const mockedFetch = fetch as jest.MockedFunction; - mockedFetch.mockImplementation((url: URL | RequestInfo) => { - if (url.toString().includes("registry.npmjs.org/express")) { - return Promise.resolve({ - ok: true, - json: jest.fn().mockResolvedValue({ - homepage: "https://expressjs.com/", - name: "express", - description: "Fast, unopinionated, minimalist web framework", - license: "MIT", - }), - }) as any; - } else if (url.toString().includes("registry.npmjs.org/lodash")) { - return Promise.resolve({ - ok: true, - json: jest.fn().mockResolvedValue({ - homepage: "https://lodash.com/", - name: "lodash", - description: "Lodash modular utilities.", - license: "MIT", - }), - }) as any; - } else if (url.toString().includes("pypi.org/pypi/requests/json")) { - return Promise.resolve({ - ok: true, - json: jest.fn().mockResolvedValue({ - info: { - home_page: "https://docs.python-requests.org/", - name: "requests", - summary: "Python HTTP for Humans.", - license: "Apache 2.0", - }, - }), - }) as any; - } else if (url.toString().includes("pypi.org/pypi/flask/json")) { - return Promise.resolve({ - ok: true, - json: jest.fn().mockResolvedValue({ - info: { - home_page: "https://palletsprojects.com/p/flask/", - name: "flask", - summary: - "A simple framework for building complex web applications.", - license: "BSD-3-Clause", - }, - }), - }) as any; - } else { - return Promise.reject(new Error(`Unhandled URL: ${url}`)); - } - }); - - const results = await getAllSuggestedDocs(testIde); - - expect(results).toHaveLength(4); - expect(results).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - packageInfo: expect.objectContaining({ name: "express" }), - details: expect.objectContaining({ - docsLink: "https://expressjs.com/", - }), - }), - expect.objectContaining({ - packageInfo: expect.objectContaining({ name: "lodash" }), - details: expect.objectContaining({ - docsLink: "https://lodash.com/", - }), - }), - expect.objectContaining({ - packageInfo: expect.objectContaining({ name: "requests" }), - details: expect.objectContaining({ - docsLink: "https://docs.python-requests.org/", - }), - }), - expect.objectContaining({ - packageInfo: expect.objectContaining({ name: "flask" }), - details: expect.objectContaining({ - docsLink: "https://palletsprojects.com/p/flask/", - }), - }), - ]), - ); - - // Verify no errors are present - results.forEach((result) => { - expect(result.error).toBeUndefined(); - }); - }); - - it("should handle packages without documentation links", async () => { - addToTestDir([ - [ - "package.json", - JSON.stringify({ - dependencies: { - "no-docs-package": "^1.0.0", - }, - }), - ], - ]); - - // Mock fetch - const mockedFetch = fetch as jest.MockedFunction; - mockedFetch.mockImplementation((url: RequestInfo | URL) => { - if (url.toString().includes("registry.npmjs.org/no-docs-package")) { - return Promise.resolve({ - ok: true, - json: jest.fn().mockResolvedValue({ - name: "no-docs-package", - description: "A package without docs", - license: "MIT", - // No homepage provided - }), - }) as any; - } else { - return Promise.reject(new Error(`Unhandled URL: ${url}`)); - } - }); - - const results = await getAllSuggestedDocs(testIde); - - expect(results).toHaveLength(1); - expect(results[0].error).toContain( - "No documentation link found for no-docs-package", - ); - expect(results[0].details).toBeUndefined(); - }); - - it("should handle errors when fetching package details", async () => { - addToTestDir([ - [ - "package.json", - JSON.stringify({ - dependencies: { - "error-package": "^1.0.0", - }, - }), - ], - ]); - - // Mock fetch - const mockedFetch = fetch as jest.MockedFunction; - mockedFetch.mockImplementation((url: URL | RequestInfo) => { - if (url.toString().includes("registry.npmjs.org/error-package")) { - return Promise.resolve({ - ok: false, - }) as any; - } else { - return Promise.reject(new Error(`Unhandled URL: ${url}`)); - } - }); - - const results = await getAllSuggestedDocs(testIde); - - expect(results).toHaveLength(1); - expect(results[0].error).toContain( - "Error getting package details for error-package", - ); - expect(results[0].details).toBeUndefined(); - }); - - it("should handle workspaces with no package files", async () => { - const results = await getAllSuggestedDocs(testIde); - - expect(results).toEqual([]); - }); - - it("should handle packages with GitHub documentation links", async () => { - addToTestDir([ - [ - "package.json", - JSON.stringify({ - dependencies: { - "github-docs-package": "^1.0.0", - }, - }), - ], - ]); - - // Mock fetch - const mockedFetch = fetch as jest.MockedFunction; - mockedFetch.mockImplementation((url: URL | RequestInfo) => { - if (url.toString().includes("registry.npmjs.org/github-docs-package")) { - return Promise.resolve({ - ok: true, - json: jest.fn().mockResolvedValue({ - homepage: "https://github.com/user/repo", - name: "github-docs-package", - description: "A package with GitHub docs", - license: "MIT", - }), - }) as any; - } else { - return Promise.reject(new Error(`Unhandled URL: ${url}`)); - } - }); - - const results = await getAllSuggestedDocs(testIde); - - expect(results).toHaveLength(1); - expect(results[0].details?.docsLink).toBe("https://github.com/user/repo"); - expect(results[0].details?.docsLinkWarning).toBe( - "Github docs not supported, find the docs site", - ); - }); - - it("should handle packages with non-docs links", async () => { - addToTestDir([["requirements.txt", "non-docs-package==1.0.0"]]); - - // Mock fetch - const mockedFetch = fetch as jest.MockedFunction; - mockedFetch.mockImplementation((url: URL | RequestInfo) => { - if (url.toString().includes("pypi.org/pypi/non-docs-package/json")) { - return Promise.resolve({ - ok: true, - json: jest.fn().mockResolvedValue({ - info: { - home_page: "https://somewebsite.com", - name: "non-docs-package", - summary: "A package with non-docs homepage", - license: "MIT", - }, - }), - }) as any; - } else { - return Promise.reject(new Error(`Unhandled URL: ${url}`)); - } - }); - - const results = await getAllSuggestedDocs(testIde); - - expect(results).toHaveLength(1); - expect(results[0].details?.docsLink).toBe("https://somewebsite.com"); - expect(results[0].details?.docsLinkWarning).toBe( - "May not be a docs site, check the URL", - ); - }); -}); diff --git a/core/llm/llm.test.ts b/core/llm/llm.test.ts index ff897d5a34c..efd98ae00c6 100644 --- a/core/llm/llm.test.ts +++ b/core/llm/llm.test.ts @@ -142,6 +142,7 @@ function testLLM( type: "function", wouldLikeTo: "Say hello", readonly: true, + group: "Hello", }, ], toolChoice: { diff --git a/core/package-lock.json b/core/package-lock.json index dc398cd54b3..765c3541eaf 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -4309,16 +4309,18 @@ } }, "node_modules/@octokit/openapi-types": { - "version": "22.2.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", - "integrity": "sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==" + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" }, "node_modules/@octokit/plugin-paginate-rest": { - "version": "11.3.1", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.3.1.tgz", - "integrity": "sha512-ryqobs26cLtM1kQxqeZui4v8FeznirUsksiA+RYemMPJ7Micju0WSkv50dBksTuZks9O5cg4wp+t8fZ/cLY56g==", + "version": "11.4.4-cjs.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.4.4-cjs.2.tgz", + "integrity": "sha512-2dK6z8fhs8lla5PaOTgqfCGBxgAv/le+EhPs27KklPhm1bKObpu6lXzwfUEQ16ajXzqNrKMujsFyo9K2eaoISw==", + "license": "MIT", "dependencies": { - "@octokit/types": "^13.5.0" + "@octokit/types": "^13.7.0" }, "engines": { "node": ">= 18" @@ -4339,11 +4341,12 @@ } }, "node_modules/@octokit/plugin-rest-endpoint-methods": { - "version": "13.2.2", - "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.2.2.tgz", - "integrity": "sha512-EI7kXWidkt3Xlok5uN43suK99VWqc8OaIMktY9d9+RNKl69juoTyxmLoWPIZgJYzi41qj/9zU7G/ljnNOJ5AFA==", + "version": "13.3.2-cjs.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.3.2-cjs.1.tgz", + "integrity": "sha512-VUjIjOOvF2oELQmiFpWA1aOPdawpyaCUqcEBc/UOUnj3Xp6DJGrJ1+bjUIIDzdHjnFNO6q57ODMfdEZnoBkCwQ==", + "license": "MIT", "dependencies": { - "@octokit/types": "^13.5.0" + "@octokit/types": "^13.8.0" }, "engines": { "node": ">= 18" @@ -4380,25 +4383,27 @@ } }, "node_modules/@octokit/rest": { - "version": "20.1.1", - "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-20.1.1.tgz", - "integrity": "sha512-MB4AYDsM5jhIHro/dq4ix1iWTLGToIGk6cWF5L6vanFaMble5jTX/UBQyiv05HsWnwUtY8JrfHy2LWfKwihqMw==", + "version": "20.1.2", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-20.1.2.tgz", + "integrity": "sha512-GmYiltypkHHtihFwPRxlaorG5R9VAHuk/vbszVoRTGXnAsY60wYLkh/E2XiFmdZmqrisw+9FaazS1i5SbdWYgA==", + "license": "MIT", "dependencies": { "@octokit/core": "^5.0.2", - "@octokit/plugin-paginate-rest": "11.3.1", + "@octokit/plugin-paginate-rest": "11.4.4-cjs.2", "@octokit/plugin-request-log": "^4.0.0", - "@octokit/plugin-rest-endpoint-methods": "13.2.2" + "@octokit/plugin-rest-endpoint-methods": "13.3.2-cjs.1" }, "engines": { "node": ">= 18" } }, "node_modules/@octokit/types": { - "version": "13.6.2", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.6.2.tgz", - "integrity": "sha512-WpbZfZUcZU77DrSW4wbsSgTPfKcp286q3ItaIgvSbBpZJlu6mnYXAkjZz6LVZPXkEvLIM8McanyZejKTYUHipA==", + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^22.2.0" + "@octokit/openapi-types": "^24.2.0" } }, "node_modules/@pkgjs/parseargs": { diff --git a/core/promptFiles/v1/slashCommandFromPromptFile.ts b/core/promptFiles/v1/slashCommandFromPromptFile.ts index b6cbd05fb2a..b9d4e88c866 100644 --- a/core/promptFiles/v1/slashCommandFromPromptFile.ts +++ b/core/promptFiles/v1/slashCommandFromPromptFile.ts @@ -53,16 +53,15 @@ export function slashCommandFromPromptFileV1( path: string, content: string, ): SlashCommand | null { - const { name, description, systemMessage, prompt, version } = - parsePromptFileV1V2(path, content); - - if (version !== 1) { - return null; - } + const { name, description, systemMessage, prompt } = parsePromptFileV1V2( + path, + content, + ); return { name, description, + prompt, run: async function* (context) { const originalSystemMessage = context.llm.systemMessage; context.llm.systemMessage = systemMessage; diff --git a/core/promptFiles/v2/parse.ts b/core/promptFiles/v2/parse.ts index 95e60230e33..ec543cbdc82 100644 --- a/core/promptFiles/v2/parse.ts +++ b/core/promptFiles/v2/parse.ts @@ -1,4 +1,5 @@ import * as YAML from "yaml"; + import { getLastNPathParts } from "../../util/uri"; export function extractName(preamble: { name?: string }, path: string): string { diff --git a/core/promptFiles/v2/parsePromptFileV1V2.ts b/core/promptFiles/v2/parsePromptFileV1V2.ts index 11e2b10be1d..a883c5e7bd8 100644 --- a/core/promptFiles/v2/parsePromptFileV1V2.ts +++ b/core/promptFiles/v2/parsePromptFileV1V2.ts @@ -1,4 +1,5 @@ import * as YAML from "yaml"; + import { getLastNPathParts } from "../../util/uri"; export function parsePromptFileV1V2(path: string, content: string) { diff --git a/core/promptFiles/v2/renderPromptFile.ts b/core/promptFiles/v2/renderPromptFile.ts index 4f6bb1534d3..1f08011a8e9 100644 --- a/core/promptFiles/v2/renderPromptFile.ts +++ b/core/promptFiles/v2/renderPromptFile.ts @@ -3,6 +3,7 @@ import { contextProviderClassFromName } from "../../context/providers"; import URLContextProvider from "../../context/providers/URLContextProvider"; import { resolveRelativePathInDir } from "../../util/ideUtils"; import { getUriPathBasename } from "../../util/uri"; + import { getPreambleAndBody } from "./parse"; async function resolveAttachment( diff --git a/core/protocol/core.ts b/core/protocol/core.ts index 3ab9f14487a..d0d12e2ecae 100644 --- a/core/protocol/core.ts +++ b/core/protocol/core.ts @@ -1,11 +1,15 @@ -import { ConfigResult, ModelRole } from "@continuedev/config-yaml"; +import { + ConfigResult, + DevDataLogEvent, + ModelRole, +} from "@continuedev/config-yaml"; import { AutocompleteInput } from "../autocomplete/util/types"; import { ProfileDescription } from "../config/ConfigHandler"; import { OrganizationDescription } from "../config/ProfileLifecycleManager"; import { SharedConfigSchema } from "../config/sharedConfig"; +import { GlobalContextModelSelections } from "../util/GlobalContext"; -import { DevDataLogEvent } from "@continuedev/config-yaml"; import type { BrowserSerializedContinueConfig, ChatMessage, @@ -29,7 +33,6 @@ import type { SlashCommandDescription, ToolCall, } from "../"; -import { GlobalContextModelSelections } from "../util/GlobalContext"; export type OnboardingModes = "Local" | "Best" | "Custom" | "Quickstart"; @@ -94,6 +97,12 @@ export type ToCoreFromIdeOrWebviewProtocol = { }, ContextItemWithId[], ]; + "mcp/reloadServer": [ + { + id: string; + }, + void, + ]; "context/getSymbolsForFiles": [{ uris: string[] }, FileSymbolMap]; "context/loadSubmenuItems": [{ title: string }, ContextSubmenuItem[]]; "autocomplete/complete": [AutocompleteInput, string[]]; diff --git a/core/protocol/passThrough.ts b/core/protocol/passThrough.ts index 30cf5417c29..9fcbeda83f2 100644 --- a/core/protocol/passThrough.ts +++ b/core/protocol/passThrough.ts @@ -26,6 +26,7 @@ export const WEBVIEW_TO_CORE_PASS_THROUGH: (keyof ToCoreFromWebviewProtocol)[] = "config/openProfile", "config/updateSharedConfig", "config/updateSelectedModel", + "mcp/reloadServer", "context/getContextItems", "context/getSymbolsForFiles", "context/loadSubmenuItems", @@ -51,7 +52,6 @@ export const WEBVIEW_TO_CORE_PASS_THROUGH: (keyof ToCoreFromWebviewProtocol)[] = "indexing/reindex", "indexing/abort", "indexing/setPaused", - "docs/getSuggestedDocs", "docs/initStatuses", "docs/getDetails", // @@ -80,7 +80,6 @@ export const CORE_TO_WEBVIEW_PASS_THROUGH: (keyof ToWebviewFromCoreProtocol)[] = "setTTSActive", "getWebviewHistoryLength", "getCurrentSessionId", - "docs/suggestions", "didCloseFiles", "didSelectOrganization", ]; diff --git a/core/protocol/webview.ts b/core/protocol/webview.ts index 45c3fb424bf..329f71bdca4 100644 --- a/core/protocol/webview.ts +++ b/core/protocol/webview.ts @@ -37,6 +37,5 @@ export type ToWebviewFromIdeOrCoreProtocol = { setTTSActive: [boolean, void]; getWebviewHistoryLength: [undefined, number]; getCurrentSessionId: [undefined, string]; - "docs/suggestions": [PackageDocsResult[], void]; "jetbrains/setColors": [Record, void]; }; diff --git a/core/tools/builtIn.ts b/core/tools/builtIn.ts index d15db456c46..268cbb9a857 100644 --- a/core/tools/builtIn.ts +++ b/core/tools/builtIn.ts @@ -8,4 +8,5 @@ export enum BuiltInToolNames { ExactSearch = "builtin_exact_search", SearchWeb = "builtin_search_web", ViewDiff = "builtin_view_diff", -} \ No newline at end of file +} +export const BUILT_IN_GROUP_NAME = "Built-In"; diff --git a/core/tools/definitions/createNewFile.ts b/core/tools/definitions/createNewFile.ts index 7af42d6a87d..f932ae87b1e 100644 --- a/core/tools/definitions/createNewFile.ts +++ b/core/tools/definitions/createNewFile.ts @@ -1,10 +1,11 @@ import { Tool } from "../.."; -import { BuiltInToolNames } from "../builtIn"; +import { BUILT_IN_GROUP_NAME, BuiltInToolNames } from "../builtIn"; export const createNewFileTool: Tool = { type: "function", displayTitle: "Create New File", wouldLikeTo: "create a new file", + group: BUILT_IN_GROUP_NAME, readonly: false, function: { name: BuiltInToolNames.CreateNewFile, diff --git a/core/tools/definitions/exactSearch.ts b/core/tools/definitions/exactSearch.ts index 8358b55da10..400adbd9ef3 100644 --- a/core/tools/definitions/exactSearch.ts +++ b/core/tools/definitions/exactSearch.ts @@ -1,11 +1,12 @@ import { Tool } from "../.."; -import { BuiltInToolNames } from "../builtIn"; +import { BUILT_IN_GROUP_NAME, BuiltInToolNames } from "../builtIn"; export const exactSearchTool: Tool = { type: "function", displayTitle: "Exact Search", wouldLikeTo: 'search for "{{{ query }}}" in the repository', readonly: true, + group: BUILT_IN_GROUP_NAME, function: { name: BuiltInToolNames.ExactSearch, description: "Perform an exact search over the repository using ripgrep.", diff --git a/core/tools/definitions/readCurrentlyOpenFile.ts b/core/tools/definitions/readCurrentlyOpenFile.ts index 5abc2edb1b9..75a0e8169e2 100644 --- a/core/tools/definitions/readCurrentlyOpenFile.ts +++ b/core/tools/definitions/readCurrentlyOpenFile.ts @@ -1,11 +1,12 @@ import { Tool } from "../.."; -import { BuiltInToolNames } from "../builtIn"; +import { BUILT_IN_GROUP_NAME, BuiltInToolNames } from "../builtIn"; export const readCurrentlyOpenFileTool: Tool = { type: "function", displayTitle: "Read Currently Open File", wouldLikeTo: "read the current file", readonly: true, + group: BUILT_IN_GROUP_NAME, function: { name: BuiltInToolNames.ReadCurrentlyOpenFile, description: diff --git a/core/tools/definitions/readFile.ts b/core/tools/definitions/readFile.ts index 4f5366e23e4..53d381e62fa 100644 --- a/core/tools/definitions/readFile.ts +++ b/core/tools/definitions/readFile.ts @@ -1,11 +1,12 @@ import { Tool } from "../.."; -import { BuiltInToolNames } from "../builtIn"; +import { BUILT_IN_GROUP_NAME, BuiltInToolNames } from "../builtIn"; export const readFileTool: Tool = { type: "function", displayTitle: "Read File", wouldLikeTo: "read {{{ filepath }}}", readonly: true, + group: BUILT_IN_GROUP_NAME, function: { name: BuiltInToolNames.ReadFile, description: diff --git a/core/tools/definitions/runTerminalCommand.ts b/core/tools/definitions/runTerminalCommand.ts index 1b526009aaa..2b7882167c6 100644 --- a/core/tools/definitions/runTerminalCommand.ts +++ b/core/tools/definitions/runTerminalCommand.ts @@ -1,11 +1,12 @@ import { Tool } from "../.."; -import { BuiltInToolNames } from "../builtIn"; +import { BUILT_IN_GROUP_NAME, BuiltInToolNames } from "../builtIn"; export const runTerminalCommandTool: Tool = { type: "function", displayTitle: "Run Terminal Command", wouldLikeTo: "run a terminal command", readonly: false, + group: BUILT_IN_GROUP_NAME, function: { name: BuiltInToolNames.RunTerminalCommand, description: diff --git a/core/tools/definitions/searchWeb.ts b/core/tools/definitions/searchWeb.ts index dfbcb04f760..7e2724a7cb0 100644 --- a/core/tools/definitions/searchWeb.ts +++ b/core/tools/definitions/searchWeb.ts @@ -1,12 +1,13 @@ import { Tool } from "../.."; -import { BuiltInToolNames } from "../builtIn"; +import { BUILT_IN_GROUP_NAME, BuiltInToolNames } from "../builtIn"; export const searchWebTool: Tool = { type: "function", displayTitle: "Search Web", wouldLikeTo: 'search the web for "{{{ query }}}"', readonly: true, + group: BUILT_IN_GROUP_NAME, function: { name: BuiltInToolNames.SearchWeb, description: diff --git a/core/tools/definitions/viewDiff.ts b/core/tools/definitions/viewDiff.ts index e54086d052c..3da0c71a908 100644 --- a/core/tools/definitions/viewDiff.ts +++ b/core/tools/definitions/viewDiff.ts @@ -1,12 +1,13 @@ import { Tool } from "../.."; -import { BuiltInToolNames } from "../builtIn"; +import { BUILT_IN_GROUP_NAME, BuiltInToolNames } from "../builtIn"; export const viewDiffTool: Tool = { type: "function", displayTitle: "View Diff", wouldLikeTo: "view a diff", readonly: true, + group: BUILT_IN_GROUP_NAME, function: { name: BuiltInToolNames.ViewDiff, description: "View the current diff of working changes", diff --git a/core/tools/definitions/viewRepoMap.ts b/core/tools/definitions/viewRepoMap.ts index 9cf3d319d2f..60d671177cf 100644 --- a/core/tools/definitions/viewRepoMap.ts +++ b/core/tools/definitions/viewRepoMap.ts @@ -1,12 +1,13 @@ import { Tool } from "../.."; -import { BuiltInToolNames } from "../builtIn"; +import { BUILT_IN_GROUP_NAME, BuiltInToolNames } from "../builtIn"; export const viewRepoMapTool: Tool = { type: "function", displayTitle: "View Repo Map", wouldLikeTo: "view the repository map", readonly: true, + group: BUILT_IN_GROUP_NAME, function: { name: BuiltInToolNames.ViewRepoMap, description: "View the repository map", diff --git a/core/tools/definitions/viewSubdirectory.ts b/core/tools/definitions/viewSubdirectory.ts index 70b7524bd88..b78daba769f 100644 --- a/core/tools/definitions/viewSubdirectory.ts +++ b/core/tools/definitions/viewSubdirectory.ts @@ -1,11 +1,12 @@ import { Tool } from "../.."; -import { BuiltInToolNames } from "../builtIn"; +import { BUILT_IN_GROUP_NAME, BuiltInToolNames } from "../builtIn"; export const viewSubdirectoryTool: Tool = { type: "function", displayTitle: "View Subdirectory", wouldLikeTo: 'view the contents of "{{{ directory_path }}}"', readonly: true, + group: BUILT_IN_GROUP_NAME, function: { name: BuiltInToolNames.ViewSubdirectory, description: "View the contents of a subdirectory", diff --git a/core/util/paths.ts b/core/util/paths.ts index 3bc2ee3cff3..0fa64d1094c 100644 --- a/core/util/paths.ts +++ b/core/util/paths.ts @@ -2,10 +2,10 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; +import { DevEventName } from "@continuedev/config-yaml"; import * as JSONC from "comment-json"; import dotenv from "dotenv"; -import { DevEventName } from "@continuedev/config-yaml"; import { IdeType, SerializedContinueConfig } from "../"; import { defaultConfig, defaultConfigJetBrains } from "../config/default"; import Types from "../config/types"; diff --git a/docs/docs/customize/deep-dives/prompt-files.md b/docs/docs/customize/deep-dives/prompt-files.md index f5c834c175a..68995746fa3 100644 --- a/docs/docs/customize/deep-dives/prompt-files.md +++ b/docs/docs/customize/deep-dives/prompt-files.md @@ -2,11 +2,11 @@ title: Prompt files --- -Prompt files provide a convenient way to standardize common patterns and share a collection of LLM prompts with your team. They make it easy to build and use these prompts. +Prompt files allow you to build and use local prompts, and provide a convenient way to test and iterate on prompts before publishing them to the Hub. -On the [hub](../../hub/introduction.md), prompt files are stored within [prompt blocks](../../hub/blocks/block-types.md#prompts), which show up as [slash commands](../slash-commands.mdx) in Chat. Visit the hub to [explore prompts](https://hub.continue.dev/explore/prompts) or [create your own](https://hub.continue.dev/new?type=block&blockType=rules). - -Prompt files can also be stored within your project's root directory as `.prompt` files. See below. +:::info +Visit the Hub to [explore prompts](https://hub.continue.dev/explore/prompts) or [create your own](https://hub.continue.dev/new?type=block&blockType=prompts) +::: ## Quick start @@ -25,7 +25,7 @@ Attached is a summary of the current Ruby on Rails application, including the @G Now to use this prompt, you can highlight code and use cmd/ctrl + L to select it in the Continue sidebar. -Then, type "@", select "Prompt files", and choose the one called "Rails Project". You can now ask any question as usual and the LLM will have the information from your .prompt file. +Then, type / and choose the "Rails Project" prompt. You can now ask any question as usual and the LLM will have the information from your .prompt file. ## Format diff --git a/docs/docs/hub/blocks/block-types.md b/docs/docs/hub/blocks/block-types.md index 51ec97b10cf..9091184d4c3 100644 --- a/docs/docs/hub/blocks/block-types.md +++ b/docs/docs/hub/blocks/block-types.md @@ -43,8 +43,8 @@ Prompts blocks are pre-written, reusable prompts that can be referenced at any t Prompt blocks have the same syntax as [prompt files](../../customize/deep-dives/prompt-files.md). There are two important differences between prompt blocks and prompt files: -1. Prompt blocks are stored within `config.yaml` rather than `.continue/prompts` in project directory and -2. Prompt blocks only show up as slash commands in Chat, not under the `@Prompt Files` context provider +1. Currently, prompt blocks cannot use context providers +2. Prompt blocks are stored within `config.yaml` rather than `.continue/prompts` in project directory and The `config.yaml` spec for `prompts` can be found [here](../../yaml-reference.md#prompts). @@ -52,4 +52,4 @@ The `config.yaml` spec for `prompts` can be found [here](../../yaml-reference.md Data blocks allow you send your development data to custom destinations of your choice. Development data can be used for a variety of purposes, including analyzing usage, gathering insights, or fine-tuning models. You can read more about development data [here](../../customize/deep-dives/development-data.md). Explore data block examples [here](https://hub.continue.dev/explore/data). -Data destinations are configured in the[`data`](../../yaml-reference.md#data) section of `config.yaml`. \ No newline at end of file +Data destinations are configured in the [`data`](../../yaml-reference.md#data) section of `config.yaml`. diff --git a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/actions/ContinuePluginActions.kt b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/actions/ContinuePluginActions.kt index 3c6b27f0566..a4e060265a4 100644 --- a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/actions/ContinuePluginActions.kt +++ b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/actions/ContinuePluginActions.kt @@ -126,12 +126,3 @@ class OpenConfigAction : AnAction() { continuePluginService.sendToWebview("navigateTo", params) } } - -class OpenMorePageAction : AnAction() { - override fun actionPerformed(e: AnActionEvent) { - val continuePluginService = getContinuePluginService(e.project) ?: return - continuePluginService.continuePluginWindow?.content?.components?.get(0)?.requestFocus() - val params = mapOf("path" to "/more", "toggle" to true) - continuePluginService.sendToWebview("navigateTo", params) - } -} \ No newline at end of file diff --git a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/constants/MessageTypes.kt b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/constants/MessageTypes.kt index fcf9a468b3a..665b393c041 100644 --- a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/constants/MessageTypes.kt +++ b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/constants/MessageTypes.kt @@ -69,7 +69,6 @@ class MessageTypes { "setTTSActive", "getWebviewHistoryLength", "getCurrentSessionId", - "docs/suggestions", "didCloseFiles", "didSelectOrganization" ) @@ -94,6 +93,7 @@ class MessageTypes { "config/openProfile", "config/updateSharedConfig", "config/updateSelectedModel", + "mcp/reloadServer", "context/getContextItems", "context/getSymbolsForFiles", "context/loadSubmenuItems", @@ -120,7 +120,6 @@ class MessageTypes { "indexing/reindex", "indexing/abort", "indexing/setPaused", - "docs/getSuggestedDocs", "docs/initStatuses", "docs/getDetails", // diff --git a/extensions/intellij/src/main/resources/META-INF/plugin.xml b/extensions/intellij/src/main/resources/META-INF/plugin.xml index abc50a09204..5911365514c 100644 --- a/extensions/intellij/src/main/resources/META-INF/plugin.xml +++ b/extensions/intellij/src/main/resources/META-INF/plugin.xml @@ -140,19 +140,10 @@ - - - - - { diff --git a/extensions/vscode/src/commands.ts b/extensions/vscode/src/commands.ts index de8f5cd1ed2..6a1e354243e 100644 --- a/extensions/vscode/src/commands.ts +++ b/extensions/vscode/src/commands.ts @@ -284,6 +284,9 @@ async function processDiff( } await sidebar.webviewProtocol.request("exitEditMode", undefined); + + // Save the file + await ide.saveFile(newOrCurrentUri); } function waitForSidebarReady( @@ -991,9 +994,6 @@ const getCommandsMap: ( vscode.commands.executeCommand("continue.focusContinueInput"); } else if (selectedOption === "$(screen-full) Open full screen chat") { vscode.commands.executeCommand("continue.toggleFullScreen"); - } else if (selectedOption === "$(question) Open help center") { - focusGUI(); - vscode.commands.executeCommand("continue.navigateTo", "/more", true); } quickPick.dispose(); }); @@ -1016,9 +1016,6 @@ const getCommandsMap: ( client.sendFeedback(feedback, lastLines); } }, - "continue.openMorePage": () => { - vscode.commands.executeCommand("continue.navigateTo", "/more", true); - }, "continue.navigateTo": (path: string, toggle: boolean) => { sidebar.webviewProtocol?.request("navigateTo", { path, toggle }); focusGUI(); diff --git a/gui/editorInset/index.html b/gui/editorInset/index.html deleted file mode 100644 index 1e6607ff2fe..00000000000 --- a/gui/editorInset/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Continue - - -
- - - diff --git a/gui/editorInset/vite.config.ts b/gui/editorInset/vite.config.ts deleted file mode 100644 index fdc63bbdb84..00000000000 --- a/gui/editorInset/vite.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import react from "@vitejs/plugin-react-swc"; -import tailwindcss from "tailwindcss"; -import { defineConfig } from "vite"; - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react(), tailwindcss()], - build: { - // Change the output .js filename to not include a hash - rollupOptions: { - // external: ["vscode-webview"], - output: { - entryFileNames: `assets/[name].js`, - chunkFileNames: `assets/[name].js`, - assetFileNames: `assets/[name].[ext]`, - }, - }, - }, -}); diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 73d6672927c..deb1e376f2a 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -1,19 +1,16 @@ -import { useDispatch } from "react-redux"; import { RouterProvider, createMemoryRouter } from "react-router-dom"; import Layout from "./components/Layout"; +import { SubmenuContextProvidersProvider } from "./context/SubmenuContextProviders"; import { VscThemeProvider } from "./context/VscTheme"; import useSetup from "./hooks/useSetup"; import { AddNewModel, ConfigureProvider } from "./pages/AddNewModel"; +import ConfigPage from "./pages/config"; import ConfigErrorPage from "./pages/config-error"; import ErrorPage from "./pages/error"; import Chat from "./pages/gui"; import History from "./pages/history"; -import MigrationPage from "./pages/migration"; -import MorePage from "./pages/More"; import Stats from "./pages/stats"; import { ROUTES } from "./util/navigation"; -import { SubmenuContextProvidersProvider } from "./context/SubmenuContextProviders"; -import ConfigPage from "./pages/config"; const router = createMemoryRouter([ { @@ -45,10 +42,6 @@ const router = createMemoryRouter([ path: "/addModel/provider/:providerName", element: , }, - { - path: "/more", - element: , - }, { path: ROUTES.CONFIG_ERROR, element: , @@ -57,10 +50,6 @@ const router = createMemoryRouter([ path: ROUTES.CONFIG, element: , }, - { - path: "/migration", - element: , - }, ], }, ]); diff --git a/gui/src/components/CodeToEditCard/AddFileCombobox.tsx b/gui/src/components/CodeToEditCard/AddFileCombobox.tsx index 07ab68a2cb6..9450ba48066 100644 --- a/gui/src/components/CodeToEditCard/AddFileCombobox.tsx +++ b/gui/src/components/CodeToEditCard/AddFileCombobox.tsx @@ -1,9 +1,9 @@ -import { useState, useEffect, useRef } from "react"; -import { useSubmenuContextProviders } from "../../context/SubmenuContextProviders"; import { Combobox } from "@headlessui/react"; -import FileIcon from "../FileIcon"; -import { useAppSelector } from "../../redux/hooks"; import { ContextSubmenuItemWithProvider } from "core"; +import { useEffect, useRef, useState } from "react"; +import { useSubmenuContextProviders } from "../../context/SubmenuContextProviders"; +import { useAppSelector } from "../../redux/hooks"; +import FileIcon from "../FileIcon"; export interface AddFileComboboxProps { onSelect: (filepaths: string[]) => void | Promise; diff --git a/gui/src/components/ConversationStarters/ConversationStarterCard.tsx b/gui/src/components/ConversationStarters/ConversationStarterCard.tsx new file mode 100644 index 00000000000..f0428a72328 --- /dev/null +++ b/gui/src/components/ConversationStarters/ConversationStarterCard.tsx @@ -0,0 +1,39 @@ +import { ChatBubbleLeftIcon } from "@heroicons/react/24/outline"; +import { SlashCommandDescription } from "core"; +import { useState } from "react"; +import { defaultBorderRadius, vscInputBackground } from ".."; + +interface ConversationStarterCardProps { + command: SlashCommandDescription; + onClick: (command: SlashCommandDescription) => void; +} + +export function ConversationStarterCard({ + command, + onClick, +}: ConversationStarterCardProps) { + const [isHovered, setIsHovered] = useState(false); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={() => onClick(command)} + > +
+
+ +
+
+
{command.name}
+
{command.description}
+
+
+
+ ); +} diff --git a/gui/src/components/ConversationStarters/ConversationStarterCards.tsx b/gui/src/components/ConversationStarters/ConversationStarterCards.tsx new file mode 100644 index 00000000000..2a29d07498c --- /dev/null +++ b/gui/src/components/ConversationStarters/ConversationStarterCards.tsx @@ -0,0 +1,37 @@ +import { SlashCommandDescription } from "core"; +import { useAppDispatch } from "../../redux/hooks"; +import { setMainEditorContentTrigger } from "../../redux/slices/sessionSlice"; +import { getParagraphNodeFromString } from "../mainInput/utils"; +import { ConversationStarterCard } from "./ConversationStarterCard"; +import { useBookmarkedSlashCommands } from "./useBookmarkedSlashCommands"; + +const NUM_CARDS_TO_RENDER = 3; + +export function ConversationStarterCards() { + const dispatch = useAppDispatch(); + const { cmdsSortedByBookmark } = useBookmarkedSlashCommands(); + + function onClick(command: SlashCommandDescription) { + if (command.prompt) { + dispatch( + setMainEditorContentTrigger(getParagraphNodeFromString(command.prompt)), + ); + } + } + + if (!cmdsSortedByBookmark || cmdsSortedByBookmark.length === 0) { + return null; + } + + return ( +
+ {cmdsSortedByBookmark.slice(0, NUM_CARDS_TO_RENDER).map((command, i) => ( + + ))} +
+ ); +} diff --git a/gui/src/components/ConversationStarters/__tests__/utils.test.ts b/gui/src/components/ConversationStarters/__tests__/utils.test.ts new file mode 100644 index 00000000000..3e705098514 --- /dev/null +++ b/gui/src/components/ConversationStarters/__tests__/utils.test.ts @@ -0,0 +1,45 @@ +import { SlashCommandDescription } from "core"; +import { sortCommandsByBookmarkStatus } from "../utils"; + +describe("sortCommandsByBookmarkStatus", () => { + const mockCommands: SlashCommandDescription[] = [ + { + name: "command1", + description: "First command", + prompt: "This is command 1", + }, + { + name: "command2", + description: "Second command", + prompt: "This is command 2", + }, + { + name: "command3", + description: "Third command", + prompt: "This is command 3", + }, + { + name: "command4", + description: "Fourth command", + prompt: "This is command 4", + }, + ]; + + it("should return commands in the same order when no bookmarks exist", () => { + const result = sortCommandsByBookmarkStatus(mockCommands, []); + expect(result).toEqual(mockCommands); + // Ensure it's a new array, not the original + expect(result).not.toBe(mockCommands); + }); + + it("should put bookmarked commands first", () => { + const bookmarkedCommands = ["command3", "command1"]; + const result = sortCommandsByBookmarkStatus( + mockCommands, + bookmarkedCommands, + ); + + expect(result[0].name).toBe("command1"); + expect(result[1].name).toBe("command3"); + }); +}); diff --git a/gui/src/components/ConversationStarters/index.ts b/gui/src/components/ConversationStarters/index.ts new file mode 100644 index 00000000000..72b23e0b777 --- /dev/null +++ b/gui/src/components/ConversationStarters/index.ts @@ -0,0 +1 @@ +export * from "./ConversationStarterCards"; diff --git a/gui/src/components/ConversationStarters/useBookmarkedSlashCommands.ts b/gui/src/components/ConversationStarters/useBookmarkedSlashCommands.ts new file mode 100644 index 00000000000..786ae379a09 --- /dev/null +++ b/gui/src/components/ConversationStarters/useBookmarkedSlashCommands.ts @@ -0,0 +1,69 @@ +import { SlashCommandDescription } from "core"; +import { usePostHog } from "posthog-js/react"; +import { useMemo } from "react"; +import { + bookmarkSlashCommand, + selectBookmarkedSlashCommands, + selectSelectedProfileId, + unbookmarkSlashCommand, +} from "../../redux"; +import { useAppDispatch, useAppSelector } from "../../redux/hooks"; +import { isDeprecatedCommandName, sortCommandsByBookmarkStatus } from "./utils"; + +export function useBookmarkedSlashCommands() { + const dispatch = useAppDispatch(); + const posthog = usePostHog(); + + const slashCommands = + useAppSelector((state) => state.config.config.slashCommands) ?? []; + const selectedProfileId = useAppSelector(selectSelectedProfileId); + const bookmarkedCommands = useAppSelector(selectBookmarkedSlashCommands); + + const filteredSlashCommands = slashCommands.filter(isDeprecatedCommandName); + + // Create a map of command names to bookmark status + const bookmarkStatuses = useMemo(() => { + const statuses: Record = {}; + if (selectedProfileId) { + filteredSlashCommands.forEach((command) => { + statuses[command.name] = bookmarkedCommands.includes(command.name); + }); + } + return statuses; + }, [filteredSlashCommands, bookmarkedCommands, selectedProfileId]); + + // Sort commands by bookmark status + const cmdsSortedByBookmark = useMemo( + () => + sortCommandsByBookmarkStatus(filteredSlashCommands, bookmarkedCommands), + [filteredSlashCommands, bookmarkedCommands], + ); + + const toggleBookmark = (command: SlashCommandDescription) => { + const isBookmarked = bookmarkStatuses[command.name]; + + posthog.capture("toggle_bookmarked_slash_command", { + isBookmarked, + }); + + if (isBookmarked) { + dispatch( + unbookmarkSlashCommand({ + commandName: command.name, + }), + ); + } else { + dispatch( + bookmarkSlashCommand({ + commandName: command.name, + }), + ); + } + }; + + return { + cmdsSortedByBookmark, + bookmarkStatuses, + toggleBookmark, + }; +} diff --git a/gui/src/components/ConversationStarters/utils.ts b/gui/src/components/ConversationStarters/utils.ts new file mode 100644 index 00000000000..d37b4541666 --- /dev/null +++ b/gui/src/components/ConversationStarters/utils.ts @@ -0,0 +1,49 @@ +import { SlashCommandDescription } from "core"; +import { + defaultSlashCommandsJetBrains, + defaultSlashCommandsVscode, +} from "core/config/default"; +import { isJetBrains } from "../../util"; + +/** + * The commands filtered here are currently inserted into the slash commands array during + * intermediary config loading, but once we get the actual prompts for an assistant, + * they are overwritten. + * + * Additionally, these default commands are all deprecated. + * + * If we don't manually filter them out, then they are displayed in the UI + * while the assistant is still loading. + * + * Once these commands are no longer inserted during intermediary config loading, + * this function can be removed. + */ +export function isDeprecatedCommandName(command: SlashCommandDescription) { + const defaultCommands = isJetBrains() + ? defaultSlashCommandsJetBrains + : defaultSlashCommandsVscode; + + return !defaultCommands.find( + (defaultCommand) => defaultCommand.name === command.name, + ); +} + +/** + * Sorts commands with bookmarked ones first + * @param commands The list of commands to sort + * @param bookmarkedCommands An array of bookmarked command names + * @returns A new sorted array with bookmarked commands first + */ +export function sortCommandsByBookmarkStatus( + commands: SlashCommandDescription[], + bookmarkedCommands: string[], +): SlashCommandDescription[] { + return [...commands].sort((a, b) => { + const aIsBookmarked = bookmarkedCommands.includes(a.name); + const bIsBookmarked = bookmarkedCommands.includes(b.name); + + if (aIsBookmarked && !bIsBookmarked) return -1; + if (!aIsBookmarked && bIsBookmarked) return 1; + return 0; + }); +} diff --git a/gui/src/components/Layout.tsx b/gui/src/components/Layout.tsx index b098bda707c..20ecc776109 100644 --- a/gui/src/components/Layout.tsx +++ b/gui/src/components/Layout.tsx @@ -17,7 +17,7 @@ import { import { setShowDialog } from "../redux/slices/uiSlice"; import { exitEditMode } from "../redux/thunks"; import { loadLastSession, saveCurrentSession } from "../redux/thunks/session"; -import { getFontSize, isMetaEquivalentKeyPressed } from "../util"; +import { fontSize, isMetaEquivalentKeyPressed } from "../util"; import { incrementFreeTrialCount } from "../util/freeTrial"; import { ROUTES } from "../util/navigation"; import TextDialog from "./dialogs"; @@ -305,10 +305,7 @@ const Layout = () => {