diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 8e2ec109e62..d18506575bc 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -11,9 +11,7 @@ body: attributes: label: Before submitting your bug report options: - - label: I've tried using the "Ask AI" feature on the [Continue docs site](https://docs.continue.dev/) to see if the docs have an answer - required: false - - label: I'm not able to find a related conversation on [GitHub discussions](https://github.com/continuedev/continue/discussions) that reports the same bug + - label: I've tried finding an answer on the [Continue docs site](https://docs.continue.dev/) required: false - label: I'm not able to find an [open issue](https://github.com/continuedev/continue/issues?q=is%3Aopen+is%3Aissue) that reports the same bug required: false diff --git a/core/commands/slash/built-in-legacy/draftIssue.ts b/core/commands/slash/built-in-legacy/draftIssue.ts deleted file mode 100644 index 9a1c5bdf9bb..00000000000 --- a/core/commands/slash/built-in-legacy/draftIssue.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { ChatMessage, SlashCommand } from "../../../index.js"; -import { removeQuotesAndEscapes } from "../../../util/index.js"; -import { renderChatMessage } from "../../../util/messageContent.js"; - -const PROMPT = ( - input: string, - title: string, -) => `You will be asked to generate the body of a GitHub issue given a user request. You should follow these rules: -- Be descriptive but do not make up details -- If the the user request includes any code snippets that are relevant, reference them in code blocks -- Describe step by step how to reproduce the problem -- Describe the ideal solution to the problem -- Describe the expected behavior after the issue has been resolved -- This issue will be read by a team member -- Use markdown formatting, but you do not need to surround the entire body with triple backticks -{additional_instructions} - -Here is the user request: '${input}' - -Title: ${title} - -Body:\n\n`; - -const DraftIssueCommand: SlashCommand = { - name: "issue", - description: "Draft a GitHub issue", - run: async function* ({ input, llm, history, params, abortController }) { - if (params?.repositoryUrl === undefined) { - yield "This command requires a repository URL to be set in the config file."; - return; - } - let title = await llm.complete( - `Generate a title for the GitHub issue requested in this user input: '${input}'. Use no more than 20 words and output nothing other than the title. Do not surround it with quotes. The title is: `, - new AbortController().signal, - { maxTokens: 30 }, - ); - - title = `${removeQuotesAndEscapes(title.trim())}\n\n`; - yield title; - - let body = ""; - const messages: ChatMessage[] = [ - ...history.filter((msg) => msg.role !== "system"), - { role: "user", content: PROMPT(input, title) }, - ]; - - for await (const chunk of llm.streamChat( - messages, - abortController.signal, - )) { - body += chunk.content; - yield renderChatMessage(chunk); - } - - const url = `${params.repositoryUrl}/issues/new?title=${encodeURIComponent( - title, - )}&body=${encodeURIComponent(body)}`; - yield `\n\n[Link to draft of issue](${url})`; - }, -}; - -export default DraftIssueCommand; diff --git a/core/commands/slash/built-in-legacy/index.ts b/core/commands/slash/built-in-legacy/index.ts index 3e57f67a66d..fcefc66dced 100644 --- a/core/commands/slash/built-in-legacy/index.ts +++ b/core/commands/slash/built-in-legacy/index.ts @@ -5,14 +5,12 @@ import { } from "../../.."; import GenerateTerminalCommand from "./cmd"; import CommitMessageCommand from "./commit"; -import DraftIssueCommand from "./draftIssue"; import HttpSlashCommand from "./http"; import OnboardSlashCommand from "./onboard"; import ReviewMessageCommand from "./review"; import ShareSlashCommand from "./share"; const LegacyBuiltInSlashCommands: SlashCommand[] = [ - DraftIssueCommand, ShareSlashCommand, GenerateTerminalCommand, HttpSlashCommand, diff --git a/core/config/createNewAssistantFile.ts b/core/config/createNewAssistantFile.ts index dc6ecceff1b..acbc275eca7 100644 --- a/core/config/createNewAssistantFile.ts +++ b/core/config/createNewAssistantFile.ts @@ -15,15 +15,28 @@ models: provider: openai model: gpt-5 apiKey: YOUR_OPENAI_API_KEY_HERE - - uses: ollama/qwen2.5-coder-7b - - uses: anthropic/claude-4-sonnet - with: - ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }} - -# MCP Servers that Continue can access -# https://docs.continue.dev/customization/mcp-tools -mcpServers: - - uses: anthropic/memory-mcp + - name: qwen2.5-coder 7b + provider: ollama + model: qwen2.5-coder:7b + roles: + - apply + - autocomplete + - chat + - edit + - name: Claude 4 Sonnet + provider: anthropic + model: claude-sonnet-4-20250514 + apiKey: \${{ secrets.ANTHROPIC_API_KEY }} + roles: + - chat + - edit + - apply + defaultCompletionOptions: + contextLength: 200000 + maxTokens: 64000 + capabilities: + - tool_use + - image_input `; export async function createNewAssistantFile( diff --git a/core/config/onboarding.ts b/core/config/onboarding.ts index ed5f0019828..c4fff708be0 100644 --- a/core/config/onboarding.ts +++ b/core/config/onboarding.ts @@ -8,20 +8,84 @@ export const LOCAL_ONBOARDING_CHAT_TITLE = "Llama 3.1 8B"; export const LOCAL_ONBOARDING_EMBEDDINGS_MODEL = "nomic-embed-text:latest"; export const LOCAL_ONBOARDING_EMBEDDINGS_TITLE = "Nomic Embed"; -const ANTHROPIC_MODEL_CONFIG = { - slugs: ["anthropic/claude-sonnet-4-6", "anthropic/claude-opus-4-6"], - apiKeyInputName: "ANTHROPIC_API_KEY", -}; -const OPENAI_MODEL_CONFIG = { - slugs: ["openai/gpt-4.1", "openai/o3", "openai/gpt-4.1-mini"], - apiKeyInputName: "OPENAI_API_KEY", -}; +type OnboardingModel = NonNullable[number]; -// TODO: These need updating on the hub -const GEMINI_MODEL_CONFIG = { - slugs: ["google/gemini-3.1-pro-preview", "google/gemini-3-flash-preview"], - apiKeyInputName: "GEMINI_API_KEY", -}; +// These model definitions are inlined copies of the corresponding Continue Hub +// blocks (e.g. anthropic/claude-sonnet-4-6) that onboarding previously resolved +// via `uses:` slugs. Since Hub/slug resolution has been removed, we reproduce +// the exact block contents here, with `apiKey` substituted for the block's +// `${{ inputs.*_API_KEY }}` placeholder. Keep these in sync with the Hub blocks. +const ANTHROPIC_ONBOARDING_MODELS = (apiKey: string): OnboardingModel[] => [ + { + name: "Claude Sonnet 4.6", + provider: "anthropic", + model: "claude-sonnet-4-6", + apiKey, + roles: ["chat", "edit", "apply"], + defaultCompletionOptions: { contextLength: 200000, maxTokens: 64000 }, + capabilities: ["tool_use", "image_input"], + }, + { + name: "Claude Opus 4.6", + provider: "anthropic", + model: "claude-opus-4-6", + apiKey, + roles: ["chat", "edit", "apply"], + defaultCompletionOptions: { contextLength: 200000, maxTokens: 64000 }, + capabilities: ["tool_use", "image_input"], + }, +]; + +const OPENAI_ONBOARDING_MODELS = (apiKey: string): OnboardingModel[] => [ + { + name: "OpenAI GPT-4.1", + provider: "openai", + model: "gpt-4.1-2025-04-14", + apiKey, + roles: ["chat", "edit", "apply"], + defaultCompletionOptions: { contextLength: 1047576, maxTokens: 32768 }, + useLegacyCompletionsEndpoint: false, + }, + { + name: "o3", + provider: "openai", + model: "o3", + apiKey, + roles: ["chat"], + defaultCompletionOptions: { contextLength: 200000, maxTokens: 100000 }, + capabilities: ["image_input"], + }, + { + name: "OpenAI GPT-4.1 mini", + provider: "openai", + model: "gpt-4.1-mini-2025-04-14", + apiKey, + roles: ["chat", "edit", "apply"], + defaultCompletionOptions: { contextLength: 1047576, maxTokens: 32768 }, + useLegacyCompletionsEndpoint: false, + }, +]; + +const GEMINI_ONBOARDING_MODELS = (apiKey: string): OnboardingModel[] => [ + { + name: "Gemini 3 Pro Preview", + provider: "gemini", + model: "gemini-3-pro-preview", + apiKey, + roles: ["chat", "edit", "apply"], + defaultCompletionOptions: { contextLength: 1048576, maxTokens: 65536 }, + capabilities: ["tool_use", "image_input"], + }, + { + name: "Gemini 3 Flash Preview", + provider: "gemini", + model: "gemini-3-flash-preview", + apiKey, + roles: ["chat", "edit", "apply"], + defaultCompletionOptions: { contextLength: 1048576, maxTokens: 65536 }, + capabilities: ["tool_use", "image_input"], + }, +]; /** * We set the "best" chat + autocopmlete models by default @@ -70,32 +134,17 @@ export function setupProviderConfig( provider: string, apiKey: string, ): ConfigYaml { - let newModels; + let newModels: OnboardingModel[]; switch (provider) { case "openai": - newModels = OPENAI_MODEL_CONFIG.slugs.map((slug) => ({ - uses: slug, - with: { - [OPENAI_MODEL_CONFIG.apiKeyInputName]: apiKey, - }, - })); + newModels = OPENAI_ONBOARDING_MODELS(apiKey); break; case "anthropic": - newModels = ANTHROPIC_MODEL_CONFIG.slugs.map((slug) => ({ - uses: slug, - with: { - [ANTHROPIC_MODEL_CONFIG.apiKeyInputName]: apiKey, - }, - })); + newModels = ANTHROPIC_ONBOARDING_MODELS(apiKey); break; case "gemini": - newModels = GEMINI_MODEL_CONFIG.slugs.map((slug) => ({ - uses: slug, - with: { - [GEMINI_MODEL_CONFIG.apiKeyInputName]: apiKey, - }, - })); + newModels = GEMINI_ONBOARDING_MODELS(apiKey); break; default: throw new Error(`Unknown provider: ${provider}`); @@ -103,14 +152,19 @@ export function setupProviderConfig( const existingModels = config.models ?? []; - // Update API key on existing models; add new entries for any missing slugs + const isSameModel = (m: OnboardingModel, n: OnboardingModel) => + "provider" in m && + "provider" in n && + m.provider === n.provider && + m.model === n.model; + + // Update API key on existing models; add new entries for any missing models const updatedModels = existingModels.map((m) => { - if (!("uses" in m)) return m; - const match = newModels.find((n) => n.uses === m.uses); - return match ? { ...m, with: { ...m.with, ...match.with } } : m; + const match = newModels.find((n) => isSameModel(m, n)); + return match ? { ...m, apiKey } : m; }); const modelsToAdd = newModels.filter( - (n) => !existingModels.some((m) => "uses" in m && m.uses === n.uses), + (n) => !existingModels.some((m) => isSameModel(m, n)), ); return { ...config, models: [...updatedModels, ...modelsToAdd] }; diff --git a/core/protocol/ideWebview.ts b/core/protocol/ideWebview.ts index 79e7de10b4f..11db483a6fd 100644 --- a/core/protocol/ideWebview.ts +++ b/core/protocol/ideWebview.ts @@ -78,6 +78,5 @@ export type ToWebviewFromIdeProtocol = ToWebviewFromIdeOrCoreProtocol & { updateApplyState: [ApplyState, void]; exitEditMode: [undefined, void]; focusEdit: [undefined, void]; - generateRule: [undefined, void]; addToChat: [AddToChatPayload, void]; }; diff --git a/docs-site/app/favicon.ico b/docs-site/app/favicon.ico new file mode 100644 index 00000000000..a266b35c3fb Binary files /dev/null and b/docs-site/app/favicon.ico differ diff --git a/docs-site/components/docs/DocsSearch.tsx b/docs-site/components/docs/DocsSearch.tsx index ccad24674ae..3f9df375559 100644 --- a/docs-site/components/docs/DocsSearch.tsx +++ b/docs-site/components/docs/DocsSearch.tsx @@ -12,6 +12,7 @@ import { CommandList, } from "@/components/ui/command"; import { create, load, search, type AnyOrama } from "@orama/orama"; +import { withBasePath } from "@/lib/basePath"; interface SearchResult { title: string; @@ -44,7 +45,7 @@ export function DocsSearch({ resolve }: { resolve: (path: string) => string }) { useEffect(() => { if (!open || dbRef.current) return; setLoading(true); - fetch("/search-index.json") + fetch(withBasePath("/search-index.json")) .then((res) => res.json()) .then((data) => { const db = create({ diff --git a/docs-site/components/docs/DocsShell.tsx b/docs-site/components/docs/DocsShell.tsx index b1e60180c72..bd42340a4a3 100644 --- a/docs-site/components/docs/DocsShell.tsx +++ b/docs-site/components/docs/DocsShell.tsx @@ -8,6 +8,7 @@ import { type NavGroup, type NavItem, type NavTab } from "@/config/docsNav"; import { TableOfContents } from "./TableOfContents"; import { DocsSearch } from "./DocsSearch"; import { resolveHref } from "@/lib/resolveHref"; +import { withBasePath } from "@/lib/basePath"; import type { Heading } from "@/lib/docs"; /* ------------------------------------------------------------------ */ @@ -29,7 +30,7 @@ function findActiveTab(nav: NavTab[], slug: string) { } } } - return nav[nav.length - 1]; + return nav[0]; } function slugToLabel(slug: string, titleMap?: Record) { @@ -235,7 +236,7 @@ export function DocsShell({ > Continue @@ -245,7 +246,7 @@ export function DocsShell({
Continue @@ -271,9 +272,6 @@ export function DocsShell({ {link.label} ))} - - Sign in -
{/* Mobile menu button */} @@ -302,13 +300,6 @@ export function DocsShell({ {link.label} ))} - setMobileMenuOpen(false)} - > - Sign in - )} diff --git a/docs-site/components/docs/mdx/index.tsx b/docs-site/components/docs/mdx/index.tsx index 9f13d40b7ad..79a128eca5b 100644 --- a/docs-site/components/docs/mdx/index.tsx +++ b/docs-site/components/docs/mdx/index.tsx @@ -10,6 +10,7 @@ import { OSAutoDetect } from "./OSAutoDetect"; import { CodeBlock } from "./CodeBlock"; import { CodeGroup } from "./CodeGroup"; import { MdxLink } from "./MdxLink"; +import { withBasePath } from "@/lib/basePath"; export const mdxComponents: MDXComponents = { // Mintlify callout variants @@ -62,9 +63,13 @@ export const mdxComponents: MDXComponents = { // Rewrite image paths — images are copied to public/images/docs/ at build time img: ({ src, alt, ...props }: any) => { - if (src && src.startsWith("/images/")) { + if (src && src.startsWith("/images/") && !src.startsWith("/images/docs/")) { src = `/images/docs${src.slice("/images".length)}`; } + // Prefix the deploy base path for raw absolute asset paths (GitHub Pages). + if (src && src.startsWith("/")) { + src = withBasePath(src); + } return {alt; }, }; diff --git a/docs-site/lib/basePath.ts b/docs-site/lib/basePath.ts new file mode 100644 index 00000000000..afea87186b7 --- /dev/null +++ b/docs-site/lib/basePath.ts @@ -0,0 +1,18 @@ +/** + * Base path for the deployed docs site. + * + * On GitHub Pages the site lives under `/continue/`, so raw absolute asset + * references (search index, src, etc.) must be prefixed manually. Next + * applies basePath automatically to , next/image and /_next assets, but + * NOT to plain string paths, so use `withBasePath` for those. + * + * `NEXT_PUBLIC_BASE_PATH` is inlined at build time (see next.config.js) and is + * therefore safe to read in both server and client/browser code. + */ +export const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || ""; + +/** Prefix an absolute (`/`-rooted) path with the deploy base path. */ +export function withBasePath(path: string): string { + if (!path.startsWith("/")) return path; + return `${BASE_PATH}${path}`; +} diff --git a/docs-site/next.config.js b/docs-site/next.config.js index 91dc0cf3273..8f0641ea978 100644 --- a/docs-site/next.config.js +++ b/docs-site/next.config.js @@ -1,6 +1,19 @@ /** @type {import('next').NextConfig} */ + +// Served at the root of the custom domain docs.continue.dev (via GitHub Pages), +// so there is no base path. (public/CNAME sets the custom domain.) +const basePath = ""; + const nextConfig = { output: "export", + basePath, + assetPrefix: basePath || undefined, + // Expose the base path to client/runtime code so raw string asset paths + // (search index fetch, src, etc.) can be prefixed manually — Next only + // applies basePath automatically to , next/image and /_next assets. + env: { + NEXT_PUBLIC_BASE_PATH: basePath, + }, typescript: { ignoreBuildErrors: true, }, diff --git a/docs-site/public/CNAME b/docs-site/public/CNAME new file mode 100644 index 00000000000..bef8da5384f --- /dev/null +++ b/docs-site/public/CNAME @@ -0,0 +1 @@ +docs.continue.dev diff --git a/extensions/cli/src/commands/login.ts b/extensions/cli/src/commands/login.ts deleted file mode 100644 index 7ce139f7ace..00000000000 --- a/extensions/cli/src/commands/login.ts +++ /dev/null @@ -1,10 +0,0 @@ -import chalk from "chalk"; - -import { gracefulExit } from "../util/exit.js"; - -export async function login() { - console.error( - chalk.red("Login is not available. Hub authentication has been removed."), - ); - await gracefulExit(1); -} diff --git a/extensions/cli/src/commands/logout.ts b/extensions/cli/src/commands/logout.ts deleted file mode 100644 index 1348bebd651..00000000000 --- a/extensions/cli/src/commands/logout.ts +++ /dev/null @@ -1,3 +0,0 @@ -export async function logout() { - // no-op: Hub authentication has been removed -} diff --git a/extensions/cli/src/e2e/headless-anthropic-api-key.test.ts b/extensions/cli/src/e2e/headless-anthropic-api-key.test.ts index 2cc86157432..964e1556d35 100644 --- a/extensions/cli/src/e2e/headless-anthropic-api-key.test.ts +++ b/extensions/cli/src/e2e/headless-anthropic-api-key.test.ts @@ -94,10 +94,12 @@ models: timeout: 15000, }); - // The CLI should fail because auto-config from ANTHROPIC_API_KEY no longer creates a model + // The CLI auto-creates an explicit Anthropic model from ANTHROPIC_API_KEY, + // so it now reaches the provider and fails on the invalid key rather than + // failing earlier with "no model specified". expect(result.exitCode).toBe(1); - // Should contain error about no model being specified - expect(result.stderr).toContain("No model specified in headless mode"); + // Should contain an authentication error from the invalid API key + expect(result.stderr).toContain("invalid x-api-key"); }, 20000); }); diff --git a/extensions/cli/src/ui/TUIChat.tsx b/extensions/cli/src/ui/TUIChat.tsx index 98c3ee0ddd4..6eaca42cc34 100644 --- a/extensions/cli/src/ui/TUIChat.tsx +++ b/extensions/cli/src/ui/TUIChat.tsx @@ -31,11 +31,7 @@ import { useNavigation } from "./context/NavigationContext.js"; import { useChat } from "./hooks/useChat.js"; import { useContextPercentage } from "./hooks/useContextPercentage.js"; import { useMessageRenderer } from "./hooks/useMessageRenderer.js"; -import { - useIntroMessage, - useLoginHandlers, - useSelectors, -} from "./hooks/useTUIChatHooks.js"; +import { useIntroMessage, useSelectors } from "./hooks/useTUIChatHooks.js"; interface TUIChatProps { // Remote mode props @@ -181,13 +177,6 @@ const TUIChat: React.FC = ({ allServicesReady, ); - // Use login handlers - const { handleLoginTokenSubmit } = useLoginHandlers( - navigateTo, - navState, - closeCurrentScreen, - ); - // State to trigger static content refresh for /clear command const [staticRefreshTrigger, setStaticRefreshTrigger] = useState(0); @@ -460,9 +449,7 @@ const TUIChat: React.FC = ({ {/* All screen-specific content */} boolean; - navState: any; services: any; - handleLoginTokenSubmit: (token: string) => void; handleConfigSelect: (config: ConfigOption) => Promise; handleModelSelect: (model: ModelOption) => Promise; handleSessionSelect: (sessionId: string) => Promise; @@ -68,9 +64,7 @@ function hideScreenContent(state?: UpdateServiceState) { export const ScreenContent: React.FC = ({ isScreenActive, - navState, services, - handleLoginTokenSubmit, handleConfigSelect, handleModelSelect, handleSessionSelect, @@ -100,33 +94,6 @@ export const ScreenContent: React.FC = ({ return null; } - // Login prompt - if (isScreenActive("login") && navState.screenData) { - return ( - - - Login Required - - {navState.screenData.text} - - - ); - } - // Config selector if (isScreenActive("config")) { return ( diff --git a/extensions/cli/src/ui/context/NavigationContext.tsx b/extensions/cli/src/ui/context/NavigationContext.tsx index 81e3f25e5da..e75ccc69b54 100644 --- a/extensions/cli/src/ui/context/NavigationContext.tsx +++ b/extensions/cli/src/ui/context/NavigationContext.tsx @@ -14,7 +14,6 @@ export type NavigationScreen = | "chat" // Normal chat interface | "config" // Config selector | "model" // Model selector - | "login" // Login prompt | "mcp" // MCP selector | "session" // Session selector | "diff" // Full-screen diff overlay @@ -25,7 +24,7 @@ export type NavigationScreen = interface NavigationState { currentScreen: NavigationScreen; - // Screen-specific data (e.g., login prompt details) + // Screen-specific data passed to the active screen screenData?: any; } diff --git a/extensions/cli/src/ui/context/__tests__/NavigationContext.test.tsx b/extensions/cli/src/ui/context/__tests__/NavigationContext.test.tsx index b1098628130..d09fbc15f8e 100644 --- a/extensions/cli/src/ui/context/__tests__/NavigationContext.test.tsx +++ b/extensions/cli/src/ui/context/__tests__/NavigationContext.test.tsx @@ -62,26 +62,19 @@ describe("NavigationContext", () => { it("navigates to a new screen with data", () => { const { result } = renderHook(() => useNavigation(), { wrapper }); - const testData = { text: "Login required", resolve: vi.fn() }; + const testData = { text: "Some data", resolve: vi.fn() }; act(() => { - result.current.navigateTo("login", testData); + result.current.navigateTo("config", testData); }); - expect(result.current.state.currentScreen).toBe("login"); + expect(result.current.state.currentScreen).toBe("config"); expect(result.current.state.screenData).toEqual(testData); }); it("can navigate to all valid screens", () => { const { result } = renderHook(() => useNavigation(), { wrapper }); - const screens: NavigationScreen[] = [ - "chat", - "config", - "model", - - "login", - "mcp", - ]; + const screens: NavigationScreen[] = ["chat", "config", "model", "mcp"]; screens.forEach((screen) => { act(() => { @@ -97,12 +90,12 @@ describe("NavigationContext", () => { const secondData = { value: 2 }; act(() => { - result.current.navigateTo("login", firstData); + result.current.navigateTo("config", firstData); }); expect(result.current.state.screenData).toEqual(firstData); act(() => { - result.current.navigateTo("login", secondData); + result.current.navigateTo("config", secondData); }); expect(result.current.state.screenData).toEqual(secondData); }); @@ -112,7 +105,7 @@ describe("NavigationContext", () => { const testData = { value: "test" }; act(() => { - result.current.navigateTo("login", testData); + result.current.navigateTo("model", testData); }); expect(result.current.state.screenData).toEqual(testData); @@ -143,7 +136,7 @@ describe("NavigationContext", () => { const testData = { text: "Test data" }; act(() => { - result.current.navigateTo("login", testData); + result.current.navigateTo("config", testData); }); expect(result.current.state.screenData).toEqual(testData); @@ -167,10 +160,9 @@ describe("NavigationContext", () => { it("closes from any screen back to chat", () => { const { result } = renderHook(() => useNavigation(), { wrapper }); - const screens: Array<"config" | "model" | "login" | "mcp"> = [ + const screens: Array<"config" | "model" | "mcp"> = [ "config", "model", - "login", "mcp", ]; @@ -208,7 +200,7 @@ describe("NavigationContext", () => { expect(result.current.isScreenActive("config")).toBe(false); expect(result.current.isScreenActive("model")).toBe(false); expect(result.current.isScreenActive("mcp")).toBe(false); - expect(result.current.isScreenActive("login")).toBe(false); + expect(result.current.isScreenActive("session")).toBe(false); }); it("updates correctly when navigating", () => { @@ -250,13 +242,13 @@ describe("NavigationContext", () => { const { result } = renderHook(() => useNavigation(), { wrapper }); act(() => { - result.current.navigateTo("login", { custom: "data" }); + result.current.navigateTo("config", { custom: "data" }); }); const stateWithData = result.current.state; act(() => { - result.current.navigateTo("login", { different: "data" }); + result.current.navigateTo("config", { different: "data" }); }); expect(result.current.state.currentScreen).toBe( @@ -269,23 +261,23 @@ describe("NavigationContext", () => { }); describe("Integration Scenarios", () => { - it("handles login flow correctly", () => { + it("handles a screen flow with screen data correctly", () => { const { result } = renderHook(() => useNavigation(), { wrapper }); const mockResolve = vi.fn(); - // Navigate to login with resolve callback + // Navigate to a screen with screen data act(() => { - result.current.navigateTo("login", { - text: "Please log in", + result.current.navigateTo("config", { + text: "Some data", resolve: mockResolve, }); }); - expect(result.current.state.currentScreen).toBe("login"); - expect(result.current.state.screenData?.text).toBe("Please log in"); + expect(result.current.state.currentScreen).toBe("config"); + expect(result.current.state.screenData?.text).toBe("Some data"); expect(result.current.state.screenData?.resolve).toBe(mockResolve); - // Close login screen (simulating successful login) + // Close the screen act(() => { result.current.closeCurrentScreen(); }); diff --git a/extensions/cli/src/ui/hooks/useTUIChatHooks.ts b/extensions/cli/src/ui/hooks/useTUIChatHooks.ts index b4b55fa4acb..ff63b8e93e5 100644 --- a/extensions/cli/src/ui/hooks/useTUIChatHooks.ts +++ b/extensions/cli/src/ui/hooks/useTUIChatHooks.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { getGitBranch, getGitRemoteUrl, isGitRepo } from "../../util/git.js"; import type { ConfigOption, ModelOption } from "../types/selectorTypes.js"; @@ -120,34 +120,6 @@ export function useIntroMessage( return [showIntroMessage, setShowIntroMessage] as const; } -// Custom hook for login handling -export function useLoginHandlers( - navigateTo: any, - navState: any, - closeCurrentScreen: () => void, -) { - const handleLoginPrompt = useCallback( - (promptText: string): Promise => { - return new Promise((resolve) => { - navigateTo("login", { text: promptText, resolve }); - }); - }, - [navigateTo], - ); - - const handleLoginTokenSubmit = useCallback( - (token: string) => { - if (navState.screenData?.resolve) { - navState.screenData.resolve(token); - closeCurrentScreen(); - } - }, - [navState.screenData, closeCurrentScreen], - ); - - return { handleLoginPrompt, handleLoginTokenSubmit }; -} - // Custom hook to combine all selector logic export function useSelectors( configPath: string | undefined, diff --git a/extensions/cli/src/util/yamlConfigUpdater.test.ts b/extensions/cli/src/util/yamlConfigUpdater.test.ts index dea0b3cfcec..c548590b0f3 100644 --- a/extensions/cli/src/util/yamlConfigUpdater.test.ts +++ b/extensions/cli/src/util/yamlConfigUpdater.test.ts @@ -12,8 +12,10 @@ describe("updateAnthropicModelInYaml", () => { expect(result).toContain("name: Main Config"); expect(result).toContain("version: 1.0.0"); expect(result).toContain("schema: v1"); - expect(result).toContain("uses: anthropic/claude-sonnet-4-6"); - expect(result).toContain("ANTHROPIC_API_KEY: sk-ant-test123456789"); + expect(result).toContain("provider: anthropic"); + expect(result).toContain("model: claude-sonnet-4-6"); + expect(result).toContain("apiKey: sk-ant-test123456789"); + expect(result).not.toContain("uses:"); }); it("should create new config from invalid YAML", () => { @@ -21,8 +23,9 @@ describe("updateAnthropicModelInYaml", () => { const result = updateAnthropicModelInYaml(invalidYaml, testApiKey); expect(result).toContain("name: Main Config"); - expect(result).toContain("uses: anthropic/claude-sonnet-4-6"); - expect(result).toContain("ANTHROPIC_API_KEY: sk-ant-test123456789"); + expect(result).toContain("model: claude-sonnet-4-6"); + expect(result).toContain("apiKey: sk-ant-test123456789"); + expect(result).not.toContain("uses:"); }); }); @@ -34,18 +37,19 @@ version: 1.0.0 schema: v1 # List of available models models: - - uses: openai/gpt-4 - with: - OPENAI_API_KEY: TEST-openai-test + - name: GPT-4 + provider: openai + model: gpt-4 + apiKey: TEST-openai-test `; const result = updateAnthropicModelInYaml(yamlWithComments, testApiKey); expect(result).toContain("# My Continue config"); expect(result).toContain("# List of available models"); - expect(result).toContain("uses: openai/gpt-4"); - expect(result).toContain("uses: anthropic/claude-sonnet-4-6"); - expect(result).toContain("ANTHROPIC_API_KEY: sk-ant-test123456789"); + expect(result).toContain("model: gpt-4"); + expect(result).toContain("model: claude-sonnet-4-6"); + expect(result).toContain("apiKey: sk-ant-test123456789"); }); it("should preserve comments when updating existing model", () => { @@ -55,19 +59,39 @@ version: 1.0.0 schema: v1 # List of available models models: - - uses: anthropic/claude-sonnet-4-6 - with: - ANTHROPIC_API_KEY: old-key + - name: Claude Sonnet 4.6 + provider: anthropic + model: claude-sonnet-4-6 + apiKey: old-key `; const result = updateAnthropicModelInYaml(yamlWithComments, testApiKey); expect(result).toContain("# My Continue config"); expect(result).toContain("# List of available models"); - expect(result).toContain("uses: anthropic/claude-sonnet-4-6"); - expect(result).toContain("ANTHROPIC_API_KEY: sk-ant-test123456789"); + expect(result).toContain("model: claude-sonnet-4-6"); + expect(result).toContain("apiKey: sk-ant-test123456789"); expect(result).not.toContain("old-key"); }); + + it("should replace legacy slug-based anthropic blocks", () => { + const yamlWithSlug = `name: Main Config +version: 1.0.0 +schema: v1 +models: + - uses: anthropic/claude-sonnet-4-6 + with: + ANTHROPIC_API_KEY: old-key +`; + + const result = updateAnthropicModelInYaml(yamlWithSlug, testApiKey); + + expect(result).not.toContain("uses:"); + expect(result).not.toContain("old-key"); + expect(result).toContain("provider: anthropic"); + expect(result).toContain("model: claude-sonnet-4-6"); + expect(result).toContain("apiKey: sk-ant-test123456789"); + }); }); describe("model management", () => { @@ -76,17 +100,18 @@ models: version: 1.0.0 schema: v1 models: - - uses: openai/gpt-4 - with: - OPENAI_API_KEY: TEST-openai-test + - name: GPT-4 + provider: openai + model: gpt-4 + apiKey: TEST-openai-test `; const result = updateAnthropicModelInYaml(existingConfig, testApiKey); - expect(result).toContain("uses: openai/gpt-4"); - expect(result).toContain("uses: anthropic/claude-sonnet-4-6"); - expect(result).toContain("ANTHROPIC_API_KEY: sk-ant-test123456789"); - expect(result).toContain("OPENAI_API_KEY: TEST-openai-test"); + expect(result).toContain("model: gpt-4"); + expect(result).toContain("model: claude-sonnet-4-6"); + expect(result).toContain("apiKey: sk-ant-test123456789"); + expect(result).toContain("apiKey: TEST-openai-test"); }); it("should update existing anthropic model", () => { @@ -94,27 +119,27 @@ models: version: 1.0.0 schema: v1 models: - - uses: anthropic/claude-sonnet-4-6 - with: - ANTHROPIC_API_KEY: old-anthropic-key - - uses: openai/gpt-4 - with: - OPENAI_API_KEY: TEST-openai-test + - name: Claude Sonnet 4.6 + provider: anthropic + model: claude-sonnet-4-6 + apiKey: old-anthropic-key + - name: GPT-4 + provider: openai + model: gpt-4 + apiKey: TEST-openai-test `; const result = updateAnthropicModelInYaml(existingConfig, testApiKey); - expect(result).toContain("uses: anthropic/claude-sonnet-4-6"); - expect(result).toContain("uses: openai/gpt-4"); - expect(result).toContain("ANTHROPIC_API_KEY: sk-ant-test123456789"); - expect(result).toContain("OPENAI_API_KEY: TEST-openai-test"); + expect(result).toContain("model: claude-sonnet-4-6"); + expect(result).toContain("model: gpt-4"); + expect(result).toContain("apiKey: sk-ant-test123456789"); + expect(result).toContain("apiKey: TEST-openai-test"); expect(result).not.toContain("old-anthropic-key"); - // Should only have one anthropic model - const anthropicMatches = result.match( - /uses: anthropic\/claude-sonnet-4-6/g, - ); - expect(anthropicMatches).toHaveLength(1); + // Should only have one Claude Sonnet model + const sonnetMatches = result.match(/model: claude-sonnet-4-6/g); + expect(sonnetMatches).toHaveLength(1); }); it("should handle config with no models array", () => { @@ -130,8 +155,8 @@ schema: v1 expect(result).toContain("name: Main Config"); expect(result).toContain("models:"); - expect(result).toContain("uses: anthropic/claude-sonnet-4-6"); - expect(result).toContain("ANTHROPIC_API_KEY: sk-ant-test123456789"); + expect(result).toContain("model: claude-sonnet-4-6"); + expect(result).toContain("apiKey: sk-ant-test123456789"); }); it("should handle config with empty models array", () => { @@ -147,8 +172,8 @@ models: [] ); expect(result).toContain("name: Main Config"); - expect(result).toContain("uses: anthropic/claude-sonnet-4-6"); - expect(result).toContain("ANTHROPIC_API_KEY: sk-ant-test123456789"); + expect(result).toContain("model: claude-sonnet-4-6"); + expect(result).toContain("apiKey: sk-ant-test123456789"); }); }); @@ -157,7 +182,10 @@ models: [] const input = `# Test config name: Test models: - - uses: existing/model + - name: Existing + provider: openai + model: existing-model + apiKey: test `; const result = updateAnthropicModelInYaml(input, testApiKey); @@ -175,9 +203,9 @@ models: expect(result).toMatch(/^version: /m); expect(result).toMatch(/^schema: /m); expect(result).toMatch(/^models:/m); - expect(result).toMatch(/^\s+- uses: /m); - expect(result).toMatch(/^\s+with:/m); - expect(result).toMatch(/^\s+ANTHROPIC_API_KEY: /m); + expect(result).toMatch(/^\s+- name: /m); + expect(result).toMatch(/^\s+provider: anthropic/m); + expect(result).toMatch(/^\s+apiKey: /m); }); }); @@ -189,8 +217,8 @@ models: "not an array" const result = updateAnthropicModelInYaml(malformedConfig, testApiKey); - expect(result).toContain("uses: anthropic/claude-sonnet-4-6"); - expect(result).toContain("ANTHROPIC_API_KEY: sk-ant-test123456789"); + expect(result).toContain("model: claude-sonnet-4-6"); + expect(result).toContain("apiKey: sk-ant-test123456789"); }); it("should handle different API key formats", () => { @@ -202,7 +230,7 @@ models: "not an array" differentKeys.forEach((key) => { const result = updateAnthropicModelInYaml("", key); - expect(result).toContain(`ANTHROPIC_API_KEY: ${key}`); + expect(result).toContain(`apiKey: ${key}`); }); }); }); diff --git a/extensions/cli/src/util/yamlConfigUpdater.ts b/extensions/cli/src/util/yamlConfigUpdater.ts index 2b749b93c76..966cdeedf68 100644 --- a/extensions/cli/src/util/yamlConfigUpdater.ts +++ b/extensions/cli/src/util/yamlConfigUpdater.ts @@ -1,10 +1,16 @@ import { parseDocument } from "yaml"; export interface ModelConfig { - uses: string; - with: { - ANTHROPIC_API_KEY: string; + name: string; + provider: string; + model: string; + apiKey: string; + roles: string[]; + defaultCompletionOptions?: { + contextLength: number; + maxTokens: number; }; + capabilities?: string[]; } export interface ConfigStructure { @@ -14,9 +20,54 @@ export interface ConfigStructure { models: ModelConfig[]; } +// These model definitions are inlined copies of the corresponding Continue Hub +// blocks (e.g. anthropic/claude-sonnet-4-6) that onboarding previously resolved +// via `uses:` slugs. Since Hub/slug resolution has been removed, we reproduce +// the exact block contents here, with `apiKey` substituted for the block's +// `${{ inputs.*_API_KEY }}` placeholder. Keep these in sync with the explicit +// Anthropic models in core/config/onboarding.ts. +function getAnthropicModels(apiKey: string): ModelConfig[] { + return [ + { + name: "Claude Sonnet 4.6", + provider: "anthropic", + model: "claude-sonnet-4-6", + apiKey, + roles: ["chat", "edit", "apply"], + defaultCompletionOptions: { contextLength: 200000, maxTokens: 64000 }, + capabilities: ["tool_use", "image_input"], + }, + { + name: "Claude Opus 4.6", + provider: "anthropic", + model: "claude-opus-4-6", + apiKey, + roles: ["chat", "edit", "apply"], + defaultCompletionOptions: { contextLength: 200000, maxTokens: 64000 }, + capabilities: ["tool_use", "image_input"], + }, + ]; +} + +function isManagedAnthropicModel(model: any): boolean { + if (!model || typeof model !== "object") { + return false; + } + // Drop legacy slug-based blocks (e.g. `uses: anthropic/claude-sonnet-4-6`)... + if (typeof model.uses === "string" && model.uses.startsWith("anthropic/")) { + return true; + } + // ...as well as the explicit Anthropic models we manage here. + return ( + model.provider === "anthropic" && + (model.model === "claude-sonnet-4-6" || model.model === "claude-opus-4-6") + ); +} + /** - * Updates or adds an Anthropic Claude model configuration in a YAML string while preserving comments and formatting. - * This is a pure function that takes a YAML string and returns a modified YAML string. + * Updates or adds explicit Anthropic Claude model configurations in a YAML + * string while preserving comments and formatting. This is a pure function that + * takes a YAML string and returns a modified YAML string. * * @param yamlContent - The original YAML content as a string (can be empty) * @param apiKey - The Anthropic API key to set @@ -26,12 +77,7 @@ export function updateAnthropicModelInYaml( yamlContent: string, apiKey: string, ): string { - const newModel: ModelConfig = { - uses: "anthropic/claude-sonnet-4-6", - with: { - ANTHROPIC_API_KEY: apiKey, - }, - }; + const newModels = getAnthropicModels(apiKey); try { const doc = parseDocument(yamlContent); @@ -42,7 +88,7 @@ export function updateAnthropicModelInYaml( name: "Main Config", version: "1.0.0", schema: "v1", - models: [newModel], + models: newModels, }; const newDoc = parseDocument(""); @@ -56,17 +102,17 @@ export function updateAnthropicModelInYaml( const config = doc.toJS() as any; // Make sure models array exists - if (!config.models) { + if (!config.models || !Array.isArray(config.models)) { config.models = []; } - // Filter out existing anthropic models + // Filter out existing Anthropic models (legacy slug blocks + managed models) config.models = config.models.filter( - (model: any) => !model || model.uses !== "anthropic/claude-sonnet-4-6", + (model: any) => !isManagedAnthropicModel(model), ); - // Add the new anthropic model - config.models.push(newModel); + // Add the new explicit Anthropic models + config.models.push(...newModels); // Update the models array while preserving comments and structure doc.set("models", config.models); @@ -78,7 +124,7 @@ export function updateAnthropicModelInYaml( name: "Main Config", version: "1.0.0", schema: "v1", - models: [newModel], + models: newModels, }; const doc = parseDocument(""); diff --git a/extensions/cli/tsconfig.json b/extensions/cli/tsconfig.json index 334aa713711..68f006718ab 100644 --- a/extensions/cli/tsconfig.json +++ b/extensions/cli/tsconfig.json @@ -26,5 +26,5 @@ "core": ["../../core/dist/index.js"] } }, - "include": ["src/**/*", "vitest.setup.ts"] + "include": ["src/**/*", "vitest.setup.ts", "vitest.global-dir-setup.ts"] } diff --git a/extensions/cli/vitest.config.ts b/extensions/cli/vitest.config.ts index 7bf2f2b722c..bcd0af577ee 100644 --- a/extensions/cli/vitest.config.ts +++ b/extensions/cli/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ test: { globals: true, environment: "node", - setupFiles: ["./vitest.setup.ts"], + setupFiles: ["./vitest.global-dir-setup.ts", "./vitest.setup.ts"], exclude: ["**/node_modules/**", "**/dist/**", "**/*.e2e.*", "**/e2e/**"], coverage: { reporter: ["text", "json", "html"], diff --git a/extensions/cli/vitest.global-dir-setup.ts b/extensions/cli/vitest.global-dir-setup.ts new file mode 100644 index 00000000000..b7dccec38c6 --- /dev/null +++ b/extensions/cli/vitest.global-dir-setup.ts @@ -0,0 +1,26 @@ +// IMPORTANT: This file must run BEFORE any module that imports +// `core/util/paths.ts`, because that module resolves `CONTINUE_GLOBAL_DIR` +// into a constant at import time. If multiple test files (which run in +// parallel worker processes) all share the same global dir, they race on the +// same `globalContext.json` file on disk, causing flaky failures in the +// model-persistence tests. +// +// To guarantee isolation, give each worker process its own unique global dir. +// This file is intentionally dependency-free (no imports that transitively +// load `paths.ts`) and is listed first in `setupFiles` so the env var is set +// before any other module captures it. +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const globalDir = fs.mkdtempSync(path.join(os.tmpdir(), "continue-cli-test-")); +process.env.CONTINUE_GLOBAL_DIR = globalDir; + +// Best-effort cleanup of this worker's temp dir when the process exits. +process.on("exit", () => { + try { + fs.rmSync(globalDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors on exit. + } +}); diff --git a/extensions/cli/vitest.setup.ts b/extensions/cli/vitest.setup.ts index 7487990f6b7..efbec2ab8db 100644 --- a/extensions/cli/vitest.setup.ts +++ b/extensions/cli/vitest.setup.ts @@ -58,8 +58,9 @@ vi.mock("./src/systemMessage.js", () => ({ loadMarkdownRulesWithMetadata: vi.fn().mockReturnValue([]), })); -// Mock environment for tests -process.env.CONTINUE_GLOBAL_DIR = "/tmp/continue-test"; +// NOTE: CONTINUE_GLOBAL_DIR is set to a unique per-worker temp dir in +// ./vitest.global-dir-setup.ts (which runs first) to isolate the shared +// GlobalContext store across parallel test files. Do not override it here. // Set up global afterEach hook to clear all timers and reset console afterEach(() => { diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 5a6dbd4a2c9..0030d3ea59c 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -2,7 +2,7 @@ "name": "continue", "icon": "media/icon.png", "author": "Continue Dev, Inc", - "version": "1.3.39", + "version": "1.3.40", "repository": { "type": "git", "url": "https://github.com/continuedev/continue" @@ -370,12 +370,6 @@ "category": "Continue", "title": "Continue: Reject Jump Suggestion" }, - { - "command": "continue.generateRule", - "category": "Continue", - "title": "Generate Rule", - "group": "Continue" - }, { "command": "continue.openInNewWindow", "category": "Continue", @@ -524,9 +518,6 @@ { "command": "continue.enterEnterpriseLicenseKey" }, - { - "command": "continue.generateRule" - }, { "command": "continue.openInNewWindow" } diff --git a/extensions/vscode/src/commands.ts b/extensions/vscode/src/commands.ts index effd2044179..f9bf3254807 100644 --- a/extensions/vscode/src/commands.ts +++ b/extensions/vscode/src/commands.ts @@ -323,10 +323,6 @@ const getCommandsMap: ( editDecorationManager.clear(); void sidebar.webviewProtocol?.request("exitEditMode", undefined); }, - "continue.generateRule": async () => { - focusGUI(); - void sidebar.webviewProtocol?.request("generateRule", undefined); - }, "continue.writeCommentsForCode": async () => { streamInlineEdit( "comment", diff --git a/gui/src/components/CliInstallBanner.test.tsx b/gui/src/components/CliInstallBanner.test.tsx deleted file mode 100644 index 134b5aae214..00000000000 --- a/gui/src/components/CliInstallBanner.test.tsx +++ /dev/null @@ -1,491 +0,0 @@ -import { - act, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { IdeMessengerContext } from "../context/IdeMessenger"; -import { MockIdeMessenger } from "../context/MockIdeMessenger"; -import * as util from "../util"; -import * as localStorage from "../util/localStorage"; -import { CliInstallBanner } from "./CliInstallBanner"; - -vi.mock("../util", async () => { - const actual = await vi.importActual("../util"); - return { - ...actual, - getPlatform: vi.fn(), - }; -}); - -vi.mock("../util/localStorage", async () => { - const actual = await vi.importActual("../util/localStorage"); - return { - ...actual, - getLocalStorage: vi.fn(), - setLocalStorage: vi.fn(), - }; -}); - -describe("CliInstallBanner", () => { - let mockIdeMessenger: MockIdeMessenger; - - beforeEach(() => { - vi.clearAllMocks(); - mockIdeMessenger = new MockIdeMessenger(); - vi.mocked(util.getPlatform).mockReturnValue("mac"); - vi.mocked(localStorage.getLocalStorage).mockReturnValue(undefined); - }); - - const renderComponent = async (subprocessResponse: [string, string]) => { - // Mock the subprocess call on the IDE - vi.spyOn(mockIdeMessenger.ide, "subprocess").mockResolvedValue( - subprocessResponse, - ); - - return act(async () => - render( - - - , - ), - ); - }; - - describe("CLI detection", () => { - it("does not render when CLI is installed (subprocess returns path)", async () => { - await renderComponent(["/usr/local/bin/cn", ""]); - - await waitFor(() => { - expect( - screen.queryByText("Try out the Continue CLI"), - ).not.toBeInTheDocument(); - }); - }); - - it("renders when CLI is not installed (subprocess returns empty)", async () => { - await renderComponent(["", "command not found"]); - - await waitFor(() => { - expect( - screen.getByText("Try out the Continue CLI"), - ).toBeInTheDocument(); - }); - }); - - it("renders when CLI is not installed (subprocess returns empty stdout)", async () => { - await renderComponent(["", ""]); - - await waitFor(() => { - expect( - screen.getByText("Try out the Continue CLI"), - ).toBeInTheDocument(); - }); - }); - - it("uses 'which cn' command on mac platform", async () => { - vi.mocked(util.getPlatform).mockReturnValue("mac"); - const subprocessSpy = vi - .spyOn(mockIdeMessenger.ide, "subprocess") - .mockResolvedValue(["", ""]); - - await act(async () => - render( - - - , - ), - ); - - await waitFor(() => { - expect(subprocessSpy).toHaveBeenCalledWith("which cn"); - }); - }); - - it("uses 'which cn' command on linux platform", async () => { - vi.mocked(util.getPlatform).mockReturnValue("linux"); - const subprocessSpy = vi - .spyOn(mockIdeMessenger.ide, "subprocess") - .mockResolvedValue(["", ""]); - - await act(async () => - render( - - - , - ), - ); - - await waitFor(() => { - expect(subprocessSpy).toHaveBeenCalledWith("which cn"); - }); - }); - - it("uses 'where cn' command on windows platform", async () => { - vi.mocked(util.getPlatform).mockReturnValue("windows"); - const subprocessSpy = vi - .spyOn(mockIdeMessenger.ide, "subprocess") - .mockResolvedValue(["", ""]); - - await act(async () => - render( - - - , - ), - ); - - await waitFor(() => { - expect(subprocessSpy).toHaveBeenCalledWith("where cn"); - }); - }); - - it("handles subprocess errors gracefully", async () => { - vi.spyOn(mockIdeMessenger.ide, "subprocess").mockRejectedValue( - new Error("Command failed"), - ); - - await act(async () => - render( - - - , - ), - ); - - await waitFor(() => { - expect( - screen.getByText("Try out the Continue CLI"), - ).toBeInTheDocument(); - }); - }); - }); - - describe("Banner content", () => { - beforeEach(async () => { - await renderComponent(["", "not found"]); - await waitFor(() => { - expect( - screen.getByText("Try out the Continue CLI"), - ).toBeInTheDocument(); - }); - }); - - it("displays the title", () => { - expect(screen.getByText("Try out the Continue CLI")).toBeInTheDocument(); - }); - - it("displays the description with 'cn' code element", () => { - const description = screen.getByText(/Use/); - expect(description).toBeInTheDocument(); - expect(screen.getByText("cn")).toBeInTheDocument(); - }); - - it("displays the installation command", () => { - expect(screen.getByText("npm i -g @continuedev/cli")).toBeInTheDocument(); - }); - - it("displays the Learn more link", () => { - expect(screen.getByText("Learn more.")).toBeInTheDocument(); - }); - - it("displays the close button", () => { - // Get the styled close button (it doesn't have a text label) - const buttons = screen.getAllByRole("button"); - // There should be multiple buttons (close, copy, run) - expect(buttons.length).toBeGreaterThan(0); - }); - - it("displays the CommandLine icon", () => { - // The icon should be present in the component - const banner = screen - .getByText("Try out the Continue CLI") - .closest("div"); - expect(banner).toBeInTheDocument(); - }); - }); - - describe("User interactions", () => { - beforeEach(async () => { - await renderComponent(["", "not found"]); - await waitFor(() => { - expect( - screen.getByText("Try out the Continue CLI"), - ).toBeInTheDocument(); - }); - }); - - it("dismisses banner when close button is clicked", async () => { - const buttons = screen.getAllByRole("button"); - // First button should be the close button (CloseButton component) - const closeButton = buttons[0]; - fireEvent.click(closeButton); - - await waitFor(() => { - expect( - screen.queryByText("Try out the Continue CLI"), - ).not.toBeInTheDocument(); - }); - }); - - it("opens documentation URL when Learn more link is clicked", async () => { - const postSpy = vi.spyOn(mockIdeMessenger, "post"); - const learnMoreLink = screen.getByText("Learn more."); - - fireEvent.click(learnMoreLink); - - expect(postSpy).toHaveBeenCalledWith( - "openUrl", - "https://docs.continue.dev/guides/cli", - ); - }); - - it("displays the installation command with interactive controls", async () => { - // The installation command should be visible - expect(screen.getByText("npm i -g @continuedev/cli")).toBeInTheDocument(); - // The "Run" text should be visible for the run button - expect(screen.getByText(/Run/i)).toBeInTheDocument(); - }); - - it("runs installation command in terminal when run button is clicked", async () => { - const postSpy = vi.spyOn(mockIdeMessenger, "post"); - - // Find the "Run" text or CommandLineIcon - const runButton = screen.getByText(/Run/i).closest("div"); - if (runButton) { - fireEvent.click(runButton); - - expect(postSpy).toHaveBeenCalledWith("runCommand", { - command: `npm i -g @continuedev/cli && cn "Explore this repo and provide a concise summary of it's contents"`, - }); - } - }); - }); - - describe("Banner visibility states", () => { - it("does not render while CLI check is loading", async () => { - vi.spyOn(mockIdeMessenger.ide, "subprocess").mockImplementation( - () => - new Promise((resolve) => setTimeout(() => resolve(["", ""]), 100)), - ); - - await act(async () => - render( - - - , - ), - ); - - // Should not be visible immediately - expect( - screen.queryByText("Try out the Continue CLI"), - ).not.toBeInTheDocument(); - }); - - it("remains hidden after dismissal even on re-render", async () => { - const { rerender } = await renderComponent(["", "not found"]); - - await waitFor(() => { - expect( - screen.getByText("Try out the Continue CLI"), - ).toBeInTheDocument(); - }); - - // Dismiss the banner - const buttons = screen.getAllByRole("button"); - const closeButton = buttons[0]; - fireEvent.click(closeButton); - - await waitFor(() => { - expect( - screen.queryByText("Try out the Continue CLI"), - ).not.toBeInTheDocument(); - }); - - // Re-render the component - rerender( - - - , - ); - - // Should still be hidden - expect( - screen.queryByText("Try out the Continue CLI"), - ).not.toBeInTheDocument(); - }); - }); - - describe("Edge cases", () => { - it("handles whitespace in subprocess output", async () => { - await renderComponent([" \n ", ""]); - - await waitFor(() => { - expect( - screen.getByText("Try out the Continue CLI"), - ).toBeInTheDocument(); - }); - }); - - it("detects CLI when path has trailing newline", async () => { - await renderComponent(["/usr/local/bin/cn\n", ""]); - - await waitFor(() => { - expect( - screen.queryByText("Try out the Continue CLI"), - ).not.toBeInTheDocument(); - }); - }); - - it("renders banner when stderr contains 'not found'", async () => { - await renderComponent(["", "cn: command not found"]); - - await waitFor(() => { - expect( - screen.getByText("Try out the Continue CLI"), - ).toBeInTheDocument(); - }); - }); - }); - - describe("Session threshold logic", () => { - const renderWithSessionCount = async ( - sessionCount?: number, - sessionThreshold?: number, - ) => { - vi.spyOn(mockIdeMessenger.ide, "subprocess").mockResolvedValue([ - "", - "not found", - ]); - - return act(async () => - render( - - - , - ), - ); - }; - - it("shows banner when no threshold is set", async () => { - await renderWithSessionCount(0, undefined); - - await waitFor(() => { - expect( - screen.getByText("Try out the Continue CLI"), - ).toBeInTheDocument(); - }); - }); - - it("does not show banner when session count is below threshold", async () => { - await renderWithSessionCount(2, 3); - - await waitFor(() => { - expect( - screen.queryByText("Try out the Continue CLI"), - ).not.toBeInTheDocument(); - }); - }); - - it("shows banner when session count meets threshold", async () => { - await renderWithSessionCount(3, 3); - - await waitFor(() => { - expect( - screen.getByText("Try out the Continue CLI"), - ).toBeInTheDocument(); - }); - }); - - it("shows banner when session count exceeds threshold", async () => { - await renderWithSessionCount(5, 3); - - await waitFor(() => { - expect( - screen.getByText("Try out the Continue CLI"), - ).toBeInTheDocument(); - }); - }); - }); - - describe("Permanent dismissal with localStorage", () => { - const renderWithPermanentDismissal = async () => { - vi.spyOn(mockIdeMessenger.ide, "subprocess").mockResolvedValue([ - "", - "not found", - ]); - - return act(async () => - render( - - - , - ), - ); - }; - - it("does not show banner when previously dismissed permanently", async () => { - vi.mocked(localStorage.getLocalStorage).mockReturnValue(true); - - await renderWithPermanentDismissal(); - - await waitFor(() => { - expect( - screen.queryByText("Try out the Continue CLI"), - ).not.toBeInTheDocument(); - }); - }); - - it("sets localStorage when dismissed with permanentDismissal=true", async () => { - await renderWithPermanentDismissal(); - - await waitFor(() => { - expect( - screen.getByText("Try out the Continue CLI"), - ).toBeInTheDocument(); - }); - - const buttons = screen.getAllByRole("button"); - const closeButton = buttons[0]; - fireEvent.click(closeButton); - - expect(localStorage.setLocalStorage).toHaveBeenCalledWith( - "hasDismissedCliInstallBanner", - true, - ); - }); - - it("does not set localStorage when dismissed with permanentDismissal=false", async () => { - vi.spyOn(mockIdeMessenger.ide, "subprocess").mockResolvedValue([ - "", - "not found", - ]); - - await act(async () => - render( - - - , - ), - ); - - await waitFor(() => { - expect( - screen.getByText("Try out the Continue CLI"), - ).toBeInTheDocument(); - }); - - const buttons = screen.getAllByRole("button"); - const closeButton = buttons[0]; - fireEvent.click(closeButton); - - expect(localStorage.setLocalStorage).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/gui/src/components/CliInstallBanner.tsx b/gui/src/components/CliInstallBanner.tsx deleted file mode 100644 index 36662309afb..00000000000 --- a/gui/src/components/CliInstallBanner.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { CommandLineIcon, XMarkIcon } from "@heroicons/react/24/outline"; -import { useContext, useEffect, useRef, useState } from "react"; -import { CloseButton } from "."; -import { IdeMessengerContext } from "../context/IdeMessenger"; -import useCopy from "../hooks/useCopy"; -import { getPlatform } from "../util"; -import { getLocalStorage, setLocalStorage } from "../util/localStorage"; -import { CopyButton } from "./StyledMarkdownPreview/StepContainerPreToolbar/CopyButton"; -import { RunInTerminalButton } from "./StyledMarkdownPreview/StepContainerPreToolbar/RunInTerminalButton"; -import { Card } from "./ui"; - -interface CliInstallBannerProps { - /** Number of sessions user has had - banner shows only if >= sessionThreshold */ - sessionCount?: number; - /** Minimum sessions before showing banner (default: always show) */ - sessionThreshold?: number; - /** If true, dismissal is permanent via localStorage (default: session only) */ - permanentDismissal?: boolean; -} - -export function CliInstallBanner({ - sessionCount, - sessionThreshold, - permanentDismissal = false, -}: CliInstallBannerProps = {}) { - const ideMessenger = useContext(IdeMessengerContext); - const [cliInstalled, setCliInstalled] = useState(null); - const [dismissed, setDismissed] = useState(false); - const commandTextRef = useRef(null); - const { copyText } = useCopy("npm i -g @continuedev/cli"); - const [showCopiedMessage, setShowCopiedMessage] = useState(false); - - const handleCommandClick = () => { - // Select the text - if (commandTextRef.current) { - const selection = window.getSelection(); - const range = document.createRange(); - range.selectNodeContents(commandTextRef.current); - selection?.removeAllRanges(); - selection?.addRange(range); - } - // Copy to clipboard - copyText(); - - // Show "Copied!" message for 3 seconds - setShowCopiedMessage(true); - setTimeout(() => setShowCopiedMessage(false), 3000); - }; - - useEffect(() => { - // Check if user has permanently dismissed the banner - if (permanentDismissal) { - const hasDismissed = getLocalStorage("hasDismissedCliInstallBanner"); - if (hasDismissed) { - setDismissed(true); - return; - } - } - - const checkCliInstallation = async () => { - try { - const platform = getPlatform(); - // Use 'which' on mac/linux, 'where' on windows - const command = platform === "windows" ? "where cn" : "which cn"; - - const [stdout, stderr] = await ideMessenger.ide.subprocess(command); - - // If stdout has content (path to cn), it's installed - // If empty or stderr has "not found", it's not installed - const isInstalled = - stdout.trim().length > 0 && !stderr.includes("not found"); - setCliInstalled(isInstalled); - } catch (error) { - // If subprocess throws an error, assume CLI is not installed - setCliInstalled(false); - } - }; - - void checkCliInstallation(); - }, [ideMessenger, permanentDismissal]); - - const handleDismiss = () => { - setDismissed(true); - if (permanentDismissal) { - setLocalStorage("hasDismissedCliInstallBanner", true); - } - }; - - // Don't show if: - // - Still loading CLI status - // - CLI is already installed - // - User has dismissed it - // - Session threshold not met (if threshold is set) - if ( - cliInstalled === null || - cliInstalled === true || - dismissed || - (sessionThreshold !== undefined && - (sessionCount === undefined || sessionCount < sessionThreshold)) - ) { - return null; - } - - return ( -
- - - - -
-
-
- - Try out the Continue CLI -
-
- Use{" "} - - cn - {" "} - in your terminal interactively and then deploy Continuous AI - workflows.{" "} - - ideMessenger.post( - "openUrl", - "https://docs.continue.dev/guides/cli", - ) - } - className="cursor-pointer underline hover:brightness-125" - > - Learn more. - -
-
-
-
-
- - npm i -g @continuedev/cli - - {showCopiedMessage && ( - - Copied! - - )} -
-
- - -
-
-
-
-
-
- ); -} diff --git a/gui/src/components/DeprecationBanner.tsx b/gui/src/components/DeprecationBanner.tsx index cbfeff77f08..75f362fed76 100644 --- a/gui/src/components/DeprecationBanner.tsx +++ b/gui/src/components/DeprecationBanner.tsx @@ -6,7 +6,7 @@ import { varWithFallback } from "../styles/theme"; import { getLocalStorage, setLocalStorage } from "../util/localStorage"; const EXPIRATION_DATE = new Date("2026-09-09"); -const EXPORT_URL = "https://continue.dev/settings/export"; +const EXPORT_URL = "https://continue.dev/export"; const REPO_URL = "https://github.com/continuedev/continue/blob/main/README.md"; interface DeprecationBannerProps { diff --git a/gui/src/components/GenerateRuleDialog/GenerationScreen.tsx b/gui/src/components/GenerateRuleDialog/GenerationScreen.tsx deleted file mode 100644 index 3868b5a3b7b..00000000000 --- a/gui/src/components/GenerateRuleDialog/GenerationScreen.tsx +++ /dev/null @@ -1,297 +0,0 @@ -import { - createRuleMarkdown, - getRuleType, - RuleType, - RuleTypeDescriptions, -} from "@continuedev/config-yaml"; -import { InformationCircleIcon } from "@heroicons/react/24/outline"; -import { createRuleFilePath } from "core/config/markdown/utils"; -import { CreateRuleBlockArgs } from "core/tools/implementations/createRuleBlock"; -import { useContext, useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { IdeMessengerContext } from "../../context/IdeMessenger"; -import Spinner from "../gui/Spinner"; -import { ToolTip } from "../gui/Tooltip"; -import { Button } from "../ui"; -import { useRuleGeneration } from "./useRuleGeneration"; - -interface GenerationScreenProps { - inputPrompt: string; - onBack: () => void; - onSuccess: () => void; - isManualMode?: boolean; -} - -export function GenerationScreen({ - inputPrompt, - onBack, - onSuccess, - isManualMode = false, -}: GenerationScreenProps) { - const ideMessenger = useContext(IdeMessengerContext); - - const { register, watch, setValue, reset } = useForm({ - defaultValues: { - name: "", - description: "", - globs: "", - alwaysApply: undefined, - rule: "", - }, - }); - - const formData = watch(); - - // Track rule type separately from form data - const [selectedRuleType, setSelectedRuleType] = useState( - RuleType.Always, - ); - const [formError, setFormError] = useState(null); - - // Use the generation hook with the input prompt - const { generateRule, isGenerating, error } = useRuleGeneration( - inputPrompt, - (args) => { - // Streaming causes a lot of jank, so wait until done generating - if (!isGenerating) { - reset(args); - handleRuleTypeChange(getRuleType(args)); - } - }, - ); - - // Start generation once when component mounts (only if not in manual mode) - useEffect(() => { - if (!isManualMode) { - void generateRule(); - } - }, [isManualMode]); - - const handleRuleTypeChange = (newRuleType: RuleType) => { - setSelectedRuleType(newRuleType); - - // Update alwaysApply based on rule type - const alwaysApply = newRuleType === RuleType.Always; - setValue("alwaysApply", alwaysApply); - - // Don't clear optional fields - preserve their state - // Users can manually clear them if needed - }; - - const handleContinue = async () => { - // Clear any previous errors - setFormError(null); - - if (!formData.name) { - setFormError("Rule name is required"); - return; - } - - if (!formData.rule) { - setFormError("Rule content is required"); - return; - } - - try { - const options: any = { - alwaysApply: formData.alwaysApply, - }; - - if (formData.description) { - options.description = formData.description; - } - - if (formData.globs) { - options.globs = formData.globs; - } - - const fileContent = createRuleMarkdown( - formData.name, - formData.rule, - options, - ); - - const workspaceDirs = await ideMessenger.request( - "getWorkspaceDirs", - undefined, - ); - - if (workspaceDirs.status !== "success") { - setFormError("Failed to get workspace directory"); - return; - } - - const localContinueDir = workspaceDirs.content[0]; - const ruleFilePath = createRuleFilePath(localContinueDir, formData.name); - - await ideMessenger.request("writeFile", { - path: ruleFilePath, - contents: fileContent, - }); - ideMessenger.post("openFile", { path: ruleFilePath }); - - onSuccess(); - } catch (err) { - const errorMessage = - err instanceof Error ? err.message : "Unknown error occurred"; - setFormError(`Failed to create rule file: ${errorMessage}`); - } - }; - - const showNameSpinner = isGenerating && !formData.name && !isManualMode; - - return ( -
-
-
-

Your rule

-

- Review and edit your generated rule below -

-
-
-
- {/* Rule metadata form */} -
- {/* Rule Name - Always visible */} -
- -
- - {showNameSpinner && ( -
- -
- )} -
-
- - {/* Rule Type Selector - Always visible */} -
-
- - - - -
-
- -
-
- - {/* Description (for Agent Requested only) */} -
- -