diff --git a/core/util/GlobalContext.ts b/core/util/GlobalContext.ts index 59daf21c9cb..0a1778395b8 100644 --- a/core/util/GlobalContext.ts +++ b/core/util/GlobalContext.ts @@ -44,9 +44,8 @@ export type GlobalContextType = { isSupportedLanceDbCpuTargetForLinux: boolean; sharedConfig: SharedConfigSchema; failedDocs: SiteIndexingConfig[]; - shownDeprecatedProviderWarnings: { - [providerTitle: string]: boolean; - }; + shownDeprecatedProviderWarnings: { [providerTitle: string]: boolean }; + autoUpdateCli: boolean; mcpOauthStorage: { [serverUrl: string]: { clientInformation?: OAuthClientInformationFull; @@ -66,16 +65,7 @@ export class GlobalContext { ) { const filepath = getGlobalContextFilePath(); if (!fs.existsSync(filepath)) { - fs.writeFileSync( - filepath, - JSON.stringify( - { - [key]: value, - }, - null, - 2, - ), - ); + fs.writeFileSync(filepath, JSON.stringify({ [key]: value }, null, 2)); } else { const data = fs.readFileSync(filepath, "utf-8"); @@ -113,10 +103,7 @@ export class GlobalContext { } // Recreate the file with salvaged values plus the new value - const newData = { - ...salvaged, - [key]: value, - }; + const newData = { ...salvaged, [key]: value }; fs.writeFileSync(filepath, JSON.stringify(newData, null, 2)); return; @@ -174,10 +161,7 @@ export class GlobalContext { newValues: Partial, ): SharedConfigSchema { const currentSharedConfig = this.getSharedConfig(); - const updatedSharedConfig = { - ...currentSharedConfig, - ...newValues, - }; + const updatedSharedConfig = { ...currentSharedConfig, ...newValues }; this.update("sharedConfig", updatedSharedConfig); return updatedSharedConfig; } @@ -189,10 +173,7 @@ export class GlobalContext { ): GlobalContextModelSelections { const currentSelections = this.get("selectedModelsByProfileId") ?? {}; const forProfile = currentSelections[profileId] ?? {}; - const newSelections = { - ...forProfile, - [role]: title, - }; + const newSelections = { ...forProfile, [role]: title }; this.update("selectedModelsByProfileId", { ...currentSelections, diff --git a/extensions/cli/src/commands/commands.ts b/extensions/cli/src/commands/commands.ts index 82e4a8a718e..fe88d1a5eb4 100644 --- a/extensions/cli/src/commands/commands.ts +++ b/extensions/cli/src/commands/commands.ts @@ -40,6 +40,11 @@ export const SYSTEM_SLASH_COMMANDS: SystemCommand[] = [ description: "Sign out of your current session", category: "system", }, + { + name: "update", + description: "Update the Continue CLI", + category: "system", + }, { name: "whoami", description: "Check who you're currently logged in as", diff --git a/extensions/cli/src/services/UpdateService.ts b/extensions/cli/src/services/UpdateService.ts new file mode 100644 index 00000000000..6d1c893bc58 --- /dev/null +++ b/extensions/cli/src/services/UpdateService.ts @@ -0,0 +1,241 @@ +import { exec, spawn } from "child_process"; +import { promisify } from "util"; + +import { GlobalContext } from "core/util/GlobalContext.js"; + +import { logger } from "src/util/logger.js"; + +import { compareVersions, getLatestVersion, getVersion } from "../version.js"; + +import { BaseService } from "./BaseService.js"; +import { serviceContainer } from "./ServiceContainer.js"; +import { UpdateServiceState, UpdateStatus } from "./types.js"; +const execAsync = promisify(exec); + +/** + * Service for checking and performing CLI updates + */ +export class UpdateService extends BaseService { + constructor() { + super("update", { + autoUpdate: true, + isAutoUpdate: true, + status: UpdateStatus.IDLE, + message: "", + error: null, + isUpdateAvailable: false, + latestVersion: null, + currentVersion: getVersion(), + }); + } + + /** + * Initialize the update service + */ + async doInitialize(headless?: boolean) { + // Don't automatically check in tests/headless + if (!headless && process.env.NODE_ENV !== "test") { + void this.checkAndAutoUpdate(); + } + + return this.currentState; + } + + private async checkAndAutoUpdate() { + // First get auto update setting from global context + const globalContext = new GlobalContext(); + const autoUpdate = globalContext.get("autoUpdateCli") ?? true; + this.setState({ + autoUpdate, + }); + + try { + // Check for updates + this.setState({ + status: UpdateStatus.CHECKING, + message: "Checking for updates", + }); + + const latestVersion = await getLatestVersion(); + this.setState({ + latestVersion, + }); + + if (!latestVersion) { + this.setState({ + status: UpdateStatus.IDLE, + message: "Continue CLI", + isUpdateAvailable: false, + }); + return; + } + + const comparison = compareVersions( + this.currentState.currentVersion, + latestVersion, + ); + const isUpdateAvailable = comparison === "older"; + this.setState({ + isUpdateAvailable, + }); + + if (this.currentState.currentVersion === "0.0.0-dev") { + this.setState({ + status: UpdateStatus.IDLE, + message: `Continue CLI`, + isUpdateAvailable, + latestVersion, + }); + return; // Uncomment to test auto-update behavior in dev + } + + // If update is available, automatically update + if ( + autoUpdate && + isUpdateAvailable && + this.currentState.status !== "updating" && + !process.env.CONTINUE_CLI_AUTO_UPDATED //Already auto updated, preventing sequential auto-update + ) { + await this.performUpdate(true); + } else { + this.setState({ + status: UpdateStatus.IDLE, + message: isUpdateAvailable + ? `Update available: v${latestVersion}` + : `Continue CLI v${this.currentState.currentVersion}`, + isUpdateAvailable, + latestVersion, + }); + } + } catch (error: any) { + logger.error("Error checking for updates:", error); + this.setState({ + status: UpdateStatus.ERROR, + message: `Continue CLI v${this.currentState.currentVersion}`, + error, + }); + } + } + + public async setAutoUpdate(value: boolean) { + const globalContext = new GlobalContext(); + globalContext.update("autoUpdateCli", value); + this.setState({ + autoUpdate: value, + }); + } + + // TODO this is a hack because our service state update code is broken + // Currently all things that need update use serviceContainer.set manually + // Rather than actually using the stateChanged event + setState(newState: Partial): void { + super.setState(newState); + serviceContainer.set("update", this.currentState); + } + + async performUpdate(isAutoUpdate?: boolean) { + if (this.currentState.status === "updating") { + return; + } + + try { + this.setState({ + isAutoUpdate, + status: UpdateStatus.UPDATING, + message: `${isAutoUpdate ? "Auto-updating" : "Updating"} to v${this.currentState.latestVersion}`, + }); + + // Install the update + const { stdout, stderr } = await execAsync("npm i -g @continuedev/cli"); + logger.debug("Update output:", { stdout, stderr }); + + if (stderr) { + const errLines = stderr.split("\n"); + for (const line of errLines) { + const lower = line.toLowerCase().trim(); + if ( + !line || + lower.includes("debugger") || + lower.includes("npm warn") + ) { + continue; + } + this.setState({ + status: UpdateStatus.ERROR, + message: `Error updating to v${this.currentState.latestVersion}`, + error: new Error(stderr), + }); + return; + } + } + + this.setState({ + status: UpdateStatus.UPDATED, + message: `${isAutoUpdate ? "Auto-updated to" : "Restart for"} v${this.currentState.latestVersion}`, + isUpdateAvailable: false, + }); + if (isAutoUpdate) { + this.restartCLI(); + } + } catch (error: any) { + logger.error("Error updating CLI:", error); + this.setState({ + status: UpdateStatus.ERROR, + message: isAutoUpdate ? "Auto-update failed" : "Update failed", + error, + }); + setTimeout(() => { + this.setState({ + status: UpdateStatus.IDLE, + message: `/update to v${this.currentState.latestVersion}`, + }); + }, 4000); + } + } + + private restartCLI(): void { + try { + const entryPoint = process.argv[1]; + const cliArgs = process.argv.slice(2); + const nodeExecutable = process.execPath; + + logger.debug( + `Preparing for CLI restart with: ${nodeExecutable} ${entryPoint} ${cliArgs.join( + " ", + )}`, + ); + + // Halt/clean up parent cn process + try { + // Remove all input listeners + global.clearTimeout = () => {}; + global.clearInterval = () => {}; + process.stdin.removeAllListeners(); + process.stdin.pause(); + // console.clear(); // Don't want to clear things that were in console before cn started + } catch (e) { + logger.debug("Error cleaning up terminal:", e); + } + + // Spawn a new detached cn process + const child = spawn(nodeExecutable, [entryPoint, ...cliArgs], { + detached: true, + stdio: "inherit", + env: { + ...process.env, + CONTINUE_CLI_AUTO_UPDATED: "true", + }, + }); + + // I did not find a way on existing to avoid a bug where next process has input glitches without leaving parent in place + // So instead of existing, parent will exit when child exits + // process.exit(0); + child.on("exit", (code) => { + process.exit(code); + }); + child.unref(); + } catch (error) { + logger.error("Failed to restart CLI:", error); + } + } +} diff --git a/extensions/cli/src/services/index.ts b/extensions/cli/src/services/index.ts index ae53965abc3..068f535424d 100644 --- a/extensions/cli/src/services/index.ts +++ b/extensions/cli/src/services/index.ts @@ -21,6 +21,7 @@ import { ServiceInitOptions, ServiceInitResult, } from "./types.js"; +import { UpdateService } from "./UpdateService.js"; // Service instances const authService = new AuthService(); @@ -31,6 +32,7 @@ const mcpService = new MCPService(); const fileIndexService = new FileIndexService(); const resourceMonitoringService = new ResourceMonitoringService(); const chatHistoryService = new ChatHistoryService(); +const updateService = new UpdateService(); /** * Initialize all services and register them with the service container @@ -132,6 +134,12 @@ export async function initializeServices( [], // No dependencies ); + serviceContainer.register( + SERVICE_NAMES.UPDATE, + () => updateService.initialize(), + [], // No dependencies + ); + serviceContainer.register( SERVICE_NAMES.API_CLIENT, async () => { @@ -283,17 +291,9 @@ export function reloadService(serviceName: string) { * Check if all core services are ready */ export function areServicesReady(): boolean { - return [ - SERVICE_NAMES.TOOL_PERMISSIONS, - SERVICE_NAMES.AUTH, - SERVICE_NAMES.API_CLIENT, - SERVICE_NAMES.CONFIG, - SERVICE_NAMES.MODEL, - SERVICE_NAMES.MCP, - SERVICE_NAMES.FILE_INDEX, - SERVICE_NAMES.RESOURCE_MONITORING, - SERVICE_NAMES.CHAT_HISTORY, - ].every((name) => serviceContainer.isReady(name)); + return Object.values(SERVICE_NAMES).every((name) => + serviceContainer.isReady(name), + ); } /** @@ -317,6 +317,7 @@ export const services = { resourceMonitoring: resourceMonitoringService, systemMessage: systemMessageService, chatHistory: chatHistoryService, + updateService: updateService, } as const; // Export the service container for advanced usage diff --git a/extensions/cli/src/services/types.ts b/extensions/cli/src/services/types.ts index 18fb2cbae90..ae1c80b5321 100644 --- a/extensions/cli/src/services/types.ts +++ b/extensions/cli/src/services/types.ts @@ -81,6 +81,25 @@ export interface MCPServiceState { prompts: MCPPrompt[]; } +export enum UpdateStatus { + IDLE = "idle", + CHECKING = "checking", + UPDATING = "updating", + UPDATED = "updated", + ERROR = "error", +} + +export interface UpdateServiceState { + autoUpdate: boolean; + isAutoUpdate: boolean; + status: UpdateStatus; + message: string; + error?: Error | null; + isUpdateAvailable: boolean; + latestVersion: string | null; + currentVersion: string; +} + export interface ApiClientServiceState { apiClient: DefaultApiInterface | null; } @@ -92,8 +111,8 @@ export interface ToolPermissionServiceState { originalPolicies?: ToolPermissions; } -export type { FileIndexServiceState } from "./FileIndexService.js"; export type { ChatHistoryState } from "./ChatHistoryService.js"; +export type { FileIndexServiceState } from "./FileIndexService.js"; /** * Service names as constants to prevent typos @@ -109,6 +128,7 @@ export const SERVICE_NAMES = { RESOURCE_MONITORING: "resourceMonitoring", SYSTEM_MESSAGE: "systemMessage", CHAT_HISTORY: "chatHistory", + UPDATE: "update", } as const; /** diff --git a/extensions/cli/src/slashCommands.ts b/extensions/cli/src/slashCommands.ts index d708a1c7d77..2594cfb14d1 100644 --- a/extensions/cli/src/slashCommands.ts +++ b/extensions/cli/src/slashCommands.ts @@ -177,6 +177,10 @@ const commandHandlers: Record = { return { openSessionSelector: true }; }, fork: handleFork, + update: () => { + posthogService.capture("useSlashCommand", { name: "update" }); + return { openUpdateSelector: true }; + }, init: (args, assistant) => { posthogService.capture("useSlashCommand", { name: "init" }); return handleInit(args, assistant); @@ -186,17 +190,7 @@ const commandHandlers: Record = { export async function handleSlashCommands( input: string, assistant: AssistantConfig, -): Promise<{ - output?: string; - exit?: boolean; - newInput?: string; - clear?: boolean; - openConfigSelector?: boolean; - openModelSelector?: boolean; - openMCPSelector?: boolean; - openSessionSelector?: boolean; - compact?: boolean; -} | null> { +): Promise { // Only trigger slash commands if slash is the very first character if (!input.startsWith("/") || !input.trim().startsWith("/")) { return null; diff --git a/extensions/cli/src/ui/LoadingText.tsx b/extensions/cli/src/ui/LoadingText.tsx new file mode 100644 index 00000000000..9d19cc5c4ca --- /dev/null +++ b/extensions/cli/src/ui/LoadingText.tsx @@ -0,0 +1,33 @@ +import { Text } from "ink"; +import React, { useEffect, useState } from "react"; + +interface LoadingTextProps { + text: string; + color?: string; + interval?: number; +} + +export const LoadingText: React.FC = ({ + text, + color = "gray", + interval = 300, +}) => { + const [dotsCount, setDotsCount] = useState(1); + + useEffect(() => { + const timer = setInterval(() => { + setDotsCount((current) => (current % 3) + 1); + }, interval); + + return () => clearInterval(timer); + }, [interval]); + + const dots = ".".repeat(dotsCount); + + return ( + + {text} + {dots} + + ); +}; diff --git a/extensions/cli/src/ui/TUIChat.tsx b/extensions/cli/src/ui/TUIChat.tsx index 4efede92bd4..c310b50ed8b 100644 --- a/extensions/cli/src/ui/TUIChat.tsx +++ b/extensions/cli/src/ui/TUIChat.tsx @@ -14,6 +14,7 @@ import { ConfigServiceState, MCPServiceState, ModelServiceState, + UpdateServiceState, } from "../services/types.js"; import { logger } from "../util/logger.js"; @@ -93,7 +94,8 @@ function useTUIChatServices(remoteUrl?: string) { model: ModelServiceState; mcp: MCPServiceState; apiClient: ApiClientServiceState; - }>(["auth", "config", "model", "mcp", "apiClient"]); + update: UpdateServiceState; + }>(["auth", "config", "model", "mcp", "apiClient", "update"]); return { services, allServicesReady, isRemoteMode }; } @@ -207,6 +209,7 @@ const TUIChat: React.FC = ({ onShowConfigSelector: () => navigateTo("config"), onShowModelSelector: () => navigateTo("model"), onShowMCPSelector: () => navigateTo("mcp"), + onShowUpdateSelector: () => navigateTo("update"), onShowSessionSelector: () => navigateTo("session"), onLoginPrompt: handleLoginPrompt, onReload: handleReload, diff --git a/extensions/cli/src/ui/UpdateNotification.test.tsx b/extensions/cli/src/ui/UpdateNotification.test.tsx index f4670dbdad0..5dc4c89a0c8 100644 --- a/extensions/cli/src/ui/UpdateNotification.test.tsx +++ b/extensions/cli/src/ui/UpdateNotification.test.tsx @@ -1,10 +1,3 @@ -// Mock the version module before any other imports -vi.mock("../version.js", () => ({ - getVersion: vi.fn(), - getLatestVersion: vi.fn(), - compareVersions: vi.fn(), -})); - // Mock useTerminalSize hook vi.mock("./hooks/useTerminalSize.js", () => ({ useTerminalSize: () => ({ columns: 80, rows: 24 }), @@ -30,93 +23,202 @@ import { render } from "ink-testing-library"; import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import * as versionModule from "../version.js"; +import { UpdateStatus } from "../services/types.js"; import { UpdateNotification } from "./UpdateNotification.js"; -const mockVersionModule = vi.mocked(versionModule); +// Mock useServices hook +const mockUseServices = vi.fn(); +vi.mock("../hooks/useService.js", () => ({ + useServices: () => mockUseServices(), +})); describe("UpdateNotification", () => { - let originalNodeEnv: string | undefined; - beforeEach(() => { vi.clearAllMocks(); - originalNodeEnv = process.env.NODE_ENV; - // Default mock implementations - mockVersionModule.getLatestVersion.mockResolvedValue("1.0.1"); - mockVersionModule.compareVersions.mockReturnValue("older"); + // Default mock implementation - no update available + mockUseServices.mockReturnValue({ + services: { + update: { + autoUpdate: true, + status: UpdateStatus.IDLE, + message: "Continue CLI v1.0.0", + error: null, + isUpdateAvailable: false, + latestVersion: null, + currentVersion: "1.0.0", + }, + }, + }); }); afterEach(() => { - // Restore original NODE_ENV - if (originalNodeEnv === undefined) { - delete process.env.NODE_ENV; - } else { - process.env.NODE_ENV = originalNodeEnv; - } + vi.clearAllMocks(); }); - it("should not check for updates when version ends with -dev", async () => { - // Set NODE_ENV to non-test value to enable update checks - process.env.NODE_ENV = "development"; - mockVersionModule.getVersion.mockReturnValue("1.0.0-dev"); + it("should show default message when no update is available", () => { + const { lastFrame } = render(); - render(); - - // Wait a bit to ensure useEffect has run - await new Promise((resolve) => setTimeout(resolve, 50)); - - // getLatestVersion should not have been called for dev versions - expect(mockVersionModule.getLatestVersion).not.toHaveBeenCalled(); + expect(lastFrame()).toContain("◉ Continue CLI v1.0.0"); }); - it("should check for updates when version does not end with -dev", async () => { - // Set NODE_ENV to non-test value to enable update checks - process.env.NODE_ENV = "development"; - mockVersionModule.getVersion.mockReturnValue("1.0.0"); - - render(); - - // Wait a bit to ensure useEffect has run - await new Promise((resolve) => setTimeout(resolve, 50)); - - // getLatestVersion should have been called for non-dev versions - expect(mockVersionModule.getLatestVersion).toHaveBeenCalled(); + it("should show update available message when update is available", () => { + mockUseServices.mockReturnValue({ + services: { + update: { + autoUpdate: true, + status: UpdateStatus.IDLE, + message: "Update available: v1.0.1", + error: null, + isUpdateAvailable: true, + latestVersion: "1.0.1", + currentVersion: "1.0.0", + }, + }, + }); + + const { lastFrame } = render(); + + expect(lastFrame()).toContain("◉ Update available: v1.0.1"); }); - it("should handle various dev version formats", async () => { - // Set NODE_ENV to non-test value to enable update checks - process.env.NODE_ENV = "development"; - const devVersions = ["1.0.0-dev", "2.1.3-dev", "0.5.0-dev"]; - - for (const version of devVersions) { - vi.clearAllMocks(); - mockVersionModule.getVersion.mockReturnValue(version); - - render(); - - // Wait a bit to ensure useEffect has run - await new Promise((resolve) => setTimeout(resolve, 50)); + it("should show checking message when checking for updates", () => { + mockUseServices.mockReturnValue({ + services: { + update: { + autoUpdate: true, + status: UpdateStatus.CHECKING, + message: "Checking for updates", + error: null, + isUpdateAvailable: false, + latestVersion: null, + currentVersion: "1.0.0", + }, + }, + }); + + const { lastFrame } = render(); + + expect(lastFrame()).toContain("◉ Checking for updates"); + }); - expect(mockVersionModule.getLatestVersion).not.toHaveBeenCalled(); - } + it("should show updating message when updating", () => { + mockUseServices.mockReturnValue({ + services: { + update: { + autoUpdate: true, + status: UpdateStatus.UPDATING, + message: "Updating to v1.0.1", + error: null, + isUpdateAvailable: false, + latestVersion: "1.0.1", + currentVersion: "1.0.0", + }, + }, + }); + + const { lastFrame } = render(); + + expect(lastFrame()).toContain("◉ Updating to v1.0.1"); }); - it("should still check for updates with other version suffixes", async () => { - // Set NODE_ENV to non-test value to enable update checks - process.env.NODE_ENV = "development"; - const nonDevVersions = ["1.0.0-beta", "1.0.0-alpha", "1.0.0-rc1"]; + it("should show updated message when update completes", () => { + mockUseServices.mockReturnValue({ + services: { + update: { + autoUpdate: true, + status: UpdateStatus.UPDATED, + message: "Auto-updated to v1.0.1", + error: null, + isUpdateAvailable: false, + latestVersion: "1.0.1", + currentVersion: "1.0.0", + }, + }, + }); + + const { lastFrame } = render(); + + expect(lastFrame()).toContain("◉ Auto-updated to v1.0.1"); + }); - for (const version of nonDevVersions) { - vi.clearAllMocks(); - mockVersionModule.getVersion.mockReturnValue(version); + it("should show error message when update fails", () => { + mockUseServices.mockReturnValue({ + services: { + update: { + autoUpdate: true, + status: UpdateStatus.ERROR, + message: "Update failed", + error: new Error("Update error"), + isUpdateAvailable: true, + latestVersion: "1.0.1", + currentVersion: "1.0.0", + }, + }, + }); + + const { lastFrame } = render(); + + expect(lastFrame()).toContain("◉ Update failed"); + }); - render(); + it("should show remote mode when in remote mode and no update available", () => { + mockUseServices.mockReturnValue({ + services: { + update: { + autoUpdate: true, + status: UpdateStatus.IDLE, + message: "Continue CLI v1.0.0", + error: null, + isUpdateAvailable: false, + latestVersion: null, + currentVersion: "1.0.0", + }, + }, + }); + + const { lastFrame } = render(); + + expect(lastFrame()).toContain("◉ Remote Mode"); + }); - // Wait a bit to ensure useEffect has run - await new Promise((resolve) => setTimeout(resolve, 50)); + it("should show update message even in remote mode when update is available", () => { + mockUseServices.mockReturnValue({ + services: { + update: { + autoUpdate: true, + status: UpdateStatus.IDLE, + message: "Update available: v1.0.1", + error: null, + isUpdateAvailable: true, + latestVersion: "1.0.1", + currentVersion: "1.0.0", + }, + }, + }); + + const { lastFrame } = render(); + + expect(lastFrame()).toContain("◉ Update available: v1.0.1"); + }); - expect(mockVersionModule.getLatestVersion).toHaveBeenCalled(); - } + it("should show default message when update service has no message", () => { + mockUseServices.mockReturnValue({ + services: { + update: { + autoUpdate: true, + status: UpdateStatus.IDLE, + message: "", + error: null, + isUpdateAvailable: false, + latestVersion: null, + currentVersion: "1.0.0", + }, + }, + }); + + const { lastFrame } = render(); + + expect(lastFrame()).toContain("◉ Continue CLI"); }); }); diff --git a/extensions/cli/src/ui/UpdateNotification.tsx b/extensions/cli/src/ui/UpdateNotification.tsx index 8f1c477384f..833422692c6 100644 --- a/extensions/cli/src/ui/UpdateNotification.tsx +++ b/extensions/cli/src/ui/UpdateNotification.tsx @@ -1,79 +1,54 @@ import { Text } from "ink"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useMemo } from "react"; -import { compareVersions, getLatestVersion, getVersion } from "../version.js"; +import { useServices } from "../hooks/useService.js"; +import { + SERVICE_NAMES, + UpdateServiceState, + UpdateStatus, +} from "../services/types.js"; import { useTerminalSize } from "./hooks/useTerminalSize.js"; interface UpdateNotificationProps { isRemoteMode?: boolean; } - const UpdateNotification: React.FC = ({ isRemoteMode = false, }) => { - const [updateAvailable, setUpdateAvailable] = useState(false); - const [latestVersion, setLatestVersion] = useState(""); - const [currentVersion] = useState(getVersion()); const { columns } = useTerminalSize(); - useEffect(() => { - // Skip update check in test environment - if (process.env.NODE_ENV === "test") { - return; - } - - // Skip update check for development versions - if (currentVersion.endsWith("-dev")) { - return; + const { services } = useServices<{ + update: UpdateServiceState; + }>([SERVICE_NAMES.UPDATE]); + + const color = useMemo(() => { + switch (services.update?.status) { + case UpdateStatus.UPDATING: + case UpdateStatus.CHECKING: + return "yellow"; + case UpdateStatus.UPDATED: + return "green"; + case UpdateStatus.ERROR: + return "red"; + default: + return "dim"; } - - const abortController = new AbortController(); - - const checkForUpdate = async () => { - try { - const latest = await getLatestVersion(abortController.signal); - if (latest) { - setLatestVersion(latest); - const comparison = compareVersions(currentVersion, latest); - setUpdateAvailable(comparison === "older"); - } - } catch (error) { - // Silently fail - we don't want to interrupt the user experience - if (error instanceof Error && error.name !== "AbortError") { - console.debug("Failed to check for updates:", error); - } - } - }; - - // Check for updates but don't block - checkForUpdate(); - - // Cleanup function to abort the request when component unmounts - return () => { - abortController.abort(); - }; - }, [currentVersion]); + }, [services.update?.status]); const text = useMemo(() => { - if (columns < 75) { - return `v${latestVersion} available`; + if (!services.update?.message) { + return "Continue CLI"; } - return `Update available: v${latestVersion} (npm i -g @continuedev/cli)`; - }, [columns, latestVersion]); - if (!updateAvailable) { - if (isRemoteMode) { - return ◉ Remote Mode; - } - return ( - - ● Continue CLI - - ); + return services.update.message; + }, [columns, services.update?.message]); + + if (!services.update?.isUpdateAvailable && isRemoteMode) { + return ◉ Remote Mode; } - return {text}; + return {`◉ ${text}`}; }; export { UpdateNotification }; diff --git a/extensions/cli/src/ui/UpdateSelector.tsx b/extensions/cli/src/ui/UpdateSelector.tsx new file mode 100644 index 00000000000..ce795046fae --- /dev/null +++ b/extensions/cli/src/ui/UpdateSelector.tsx @@ -0,0 +1,93 @@ +import React, { useMemo, useState } from "react"; + +import { useService } from "../hooks/useService.js"; +import { services } from "../services/index.js"; +import type { UpdateServiceState } from "../services/types.js"; +import { SERVICE_NAMES } from "../services/types.js"; + +import { Selector, SelectorOption } from "./Selector.js"; + +interface UpdateSelectorProps { + onCancel: () => void; +} + +interface UpdateOption extends SelectorOption { + action: "run" | "toggle-auto" | "back"; +} + +export const UpdateSelector: React.FC = ({ onCancel }) => { + const [selectedIndex, setSelectedIndex] = useState(0); + const updateServiceState = useService( + SERVICE_NAMES.UPDATE, + ); + + const updateState = updateServiceState.value; + + const isWorking = + updateServiceState.state === "loading" || + updateState?.status === "checking" || + updateState?.status === "updating"; + + const error: string | null = useMemo(() => { + if (updateServiceState.state === "error") { + return updateServiceState.error?.message || "Update service error"; + } + if (updateState?.status === "error") { + return updateState.message || "Update failed"; + } + return null; + }, [updateServiceState.state, updateServiceState.error, updateState]); + + const loadingMessage = useMemo(() => { + if (!updateState) return "Preparing update..."; + return updateState.message || "Working..."; + }, [updateState]); + + const options: UpdateOption[] = useMemo(() => { + const runLabel = updateState?.latestVersion + ? `Run update to v${updateState.latestVersion}` + : "Run update"; + + const autoUpdateLabel = updateState?.autoUpdate + ? "Turn off auto-updates" + : "Turn on auto-updates"; + + return [ + { id: "run-update", name: runLabel, action: "run" }, + { id: "toggle-auto", name: autoUpdateLabel, action: "toggle-auto" }, + { id: "back", name: "Back", action: "back" }, + ]; + }, [updateState?.latestVersion, updateState?.autoUpdate]); + + const handleSelect = async (option: UpdateOption) => { + switch (option.action) { + case "run": + // Perform a manual update (not auto-update) + await services.updateService.performUpdate(false); + return; // Keep selector open to show status changes + case "toggle-auto": + // Toggle auto-update setting + const newValue = !updateState?.autoUpdate; + await services.updateService.setAutoUpdate(newValue); + return; // Keep selector open to show updated label + case "back": + onCancel(); + return; + } + }; + + return ( + + ); +}; diff --git a/extensions/cli/src/ui/components/ScreenContent.tsx b/extensions/cli/src/ui/components/ScreenContent.tsx index 0cbd602accb..b163c946e35 100644 --- a/extensions/cli/src/ui/components/ScreenContent.tsx +++ b/extensions/cli/src/ui/components/ScreenContent.tsx @@ -1,6 +1,8 @@ import { Box, Text } from "ink"; import React from "react"; +import { UpdateServiceState } from "src/services/types.js"; + import { listSessions } from "../../session.js"; import { ConfigSelector } from "../ConfigSelector.js"; import type { NavigationScreen } from "../context/NavigationContext.js"; @@ -9,6 +11,7 @@ import { MCPSelector } from "../MCPSelector.js"; import { ModelSelector } from "../ModelSelector.js"; import { SessionSelector } from "../SessionSelector.js"; import type { ConfigOption, ModelOption } from "../types/selectorTypes.js"; +import { UpdateSelector } from "../UpdateSelector.js"; import { UserInput } from "../UserInput.js"; import { ToolPermissionSelector } from "./ToolPermissionSelector.js"; @@ -42,6 +45,14 @@ interface ScreenContentProps { onImageInClipboardChange?: (hasImage: boolean) => void; } +function hideScreenContent(state?: UpdateServiceState) { + return ( + (state?.status === "checking" && state?.autoUpdate) || + (state?.isAutoUpdate && + (state?.status === "updating" || state?.status === "updated")) + ); +} + export const ScreenContent: React.FC = ({ isScreenActive, navState, @@ -65,6 +76,10 @@ export const ScreenContent: React.FC = ({ isRemoteMode, onImageInClipboardChange, }) => { + if (hideScreenContent(services.update)) { + return null; + } + // Login prompt if (isScreenActive("login") && navState.screenData) { return ( @@ -106,6 +121,10 @@ export const ScreenContent: React.FC = ({ return ; } + if (isScreenActive("update")) { + return ; + } + // Model selector if (isScreenActive("model")) { return ( diff --git a/extensions/cli/src/ui/context/NavigationContext.tsx b/extensions/cli/src/ui/context/NavigationContext.tsx index 1742d8ee80f..a13dc99ffa8 100644 --- a/extensions/cli/src/ui/context/NavigationContext.tsx +++ b/extensions/cli/src/ui/context/NavigationContext.tsx @@ -1,9 +1,9 @@ import React, { createContext, + ReactNode, + useCallback, useContext, useReducer, - useCallback, - ReactNode, } from "react"; /** @@ -17,6 +17,7 @@ export type NavigationScreen = | "free-trial" // Free trial transition UI | "login" // Login prompt | "mcp" // MCP selector + | "update" // Update selector | "session"; // Session selector interface NavigationState { diff --git a/extensions/cli/src/ui/hooks/useChat.helpers.ts b/extensions/cli/src/ui/hooks/useChat.helpers.ts index 58be45d6084..83fcadd219a 100644 --- a/extensions/cli/src/ui/hooks/useChat.helpers.ts +++ b/extensions/cli/src/ui/hooks/useChat.helpers.ts @@ -48,6 +48,7 @@ interface ProcessSlashCommandResultOptions { setChatHistory: React.Dispatch>; onShowConfigSelector: () => void; onShowModelSelector?: () => void; + onShowUpdateSelector?: () => void; onShowMCPSelector?: () => void; onShowSessionSelector?: () => void; onClear?: () => void; @@ -61,6 +62,7 @@ export function processSlashCommandResult({ chatHistory, setChatHistory, onShowConfigSelector, + onShowUpdateSelector, onShowModelSelector, onShowMCPSelector, onShowSessionSelector, @@ -75,6 +77,11 @@ export function processSlashCommandResult({ return null; } + if (result.openUpdateSelector) { + onShowUpdateSelector?.(); + return null; + } + if (result.openConfigSelector) { onShowConfigSelector(); return null; diff --git a/extensions/cli/src/ui/hooks/useChat.ts b/extensions/cli/src/ui/hooks/useChat.ts index 1bb2cd10e3f..32a2e657094 100644 --- a/extensions/cli/src/ui/hooks/useChat.ts +++ b/extensions/cli/src/ui/hooks/useChat.ts @@ -55,6 +55,7 @@ export function useChat({ additionalPrompts, onShowConfigSelector, onShowModelSelector, + onShowUpdateSelector, onShowMCPSelector, onShowSessionSelector, onLoginPrompt: _onLoginPrompt, @@ -419,6 +420,7 @@ export function useChat({ onShowModelSelector, onShowMCPSelector, onShowSessionSelector, + onShowUpdateSelector, onClear, }); diff --git a/extensions/cli/src/ui/hooks/useChat.types.ts b/extensions/cli/src/ui/hooks/useChat.types.ts index a9a84018437..8e937802bdb 100644 --- a/extensions/cli/src/ui/hooks/useChat.types.ts +++ b/extensions/cli/src/ui/hooks/useChat.types.ts @@ -15,6 +15,7 @@ export interface UseChatProps { additionalPrompts?: string[]; onShowConfigSelector: () => void; onShowMCPSelector: () => void; + onShowUpdateSelector: () => void; onShowModelSelector?: () => void; onShowSessionSelector?: () => void; onLoginPrompt?: (promptText: string) => Promise; @@ -57,6 +58,7 @@ export interface SlashCommandResult { openConfigSelector?: boolean; openModelSelector?: boolean; openMcpSelector?: boolean; + openUpdateSelector?: boolean; openSessionSelector?: boolean; compact?: boolean; }