From bafbcbbe8b2a23056daec649cee64c970b119552 Mon Sep 17 00:00:00 2001 From: Jacob MacDonald Date: Fri, 7 Nov 2025 15:17:23 -0800 Subject: [PATCH] [feat]: Add `/extensions restart` command (#12739) --- .../src/ui/commands/extensionsCommand.test.ts | 190 +++++++++++++++++- .../cli/src/ui/commands/extensionsCommand.ts | 134 +++++++++++- .../src/ui/components/HistoryItemDisplay.tsx | 6 +- .../ui/components/messages/InfoMessage.tsx | 15 +- packages/cli/src/ui/state/extensions.test.ts | 75 +++++++ packages/cli/src/ui/state/extensions.ts | 17 +- packages/cli/src/ui/types.ts | 2 + .../core/src/utils/extensionLoader.test.ts | 23 +++ packages/core/src/utils/extensionLoader.ts | 5 + 9 files changed, 457 insertions(+), 10 deletions(-) create mode 100644 packages/cli/src/ui/state/extensions.test.ts diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index db947b3968..ffbd4bb722 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -4,7 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { GeminiCLIExtension } from '@google/gemini-cli-core'; +import type { + ExtensionLoader, + GeminiCLIExtension, +} from '@google/gemini-cli-core'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; import { @@ -13,12 +16,21 @@ import { extensionsCommand, } from './extensionsCommand.js'; import { type CommandContext, type SlashCommand } from './types.js'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type MockedFunction, +} from 'vitest'; import { type ExtensionUpdateAction } from '../state/extensions.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { SettingScope } from '../../config/settings.js'; import open from 'open'; + vi.mock('open', () => ({ default: vi.fn(), })); @@ -572,4 +584,178 @@ describe('extensionsCommand', () => { ); }); }); + + describe('restart', () => { + let restartAction: SlashCommand['action']; + let mockRestartExtension: MockedFunction< + typeof ExtensionLoader.prototype.restartExtension + >; + + beforeEach(() => { + restartAction = extensionsCommand().subCommands?.find( + (c) => c.name === 'restart', + )?.action; + expect(restartAction).not.toBeNull(); + + mockRestartExtension = vi.fn(); + mockContext.services.config!.getExtensionLoader = vi + .fn() + .mockImplementation(() => ({ + getExtensions: mockGetExtensions, + restartExtension: mockRestartExtension, + })); + mockContext.invocation!.name = 'restart'; + }); + + it('restarts all active extensions when --all is provided', async () => { + const mockExtensions = [ + { name: 'ext1', isActive: true }, + { name: 'ext2', isActive: true }, + { name: 'ext3', isActive: false }, + ] as GeminiCLIExtension[]; + mockGetExtensions.mockReturnValue(mockExtensions); + + await restartAction!(mockContext, '--all'); + + expect(mockRestartExtension).toHaveBeenCalledTimes(2); + expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]); + expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[1]); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Restarting 2 extensions...', + }), + expect.any(Number), + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: '2 extensions restarted successfully.', + }), + expect.any(Number), + ); + expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({ + type: 'RESTARTED', + payload: { name: 'ext1' }, + }); + expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({ + type: 'RESTARTED', + payload: { name: 'ext2' }, + }); + }); + + it('restarts only specified active extensions', async () => { + const mockExtensions = [ + { name: 'ext1', isActive: false }, + { name: 'ext2', isActive: true }, + { name: 'ext3', isActive: true }, + ] as GeminiCLIExtension[]; + mockGetExtensions.mockReturnValue(mockExtensions); + + await restartAction!(mockContext, 'ext1 ext3'); + + expect(mockRestartExtension).toHaveBeenCalledTimes(1); + expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[2]); + expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({ + type: 'RESTARTED', + payload: { name: 'ext3' }, + }); + }); + + it('shows an error if no extension loader is available', async () => { + mockContext.services.config!.getExtensionLoader = vi.fn(); + + await restartAction!(mockContext, '--all'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: "Extensions are not yet loaded, can't restart yet", + }), + expect.any(Number), + ); + expect(mockRestartExtension).not.toHaveBeenCalled(); + }); + + it('shows usage error for no arguments', async () => { + await restartAction!(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Usage: /extensions restart |--all', + }), + expect.any(Number), + ); + expect(mockRestartExtension).not.toHaveBeenCalled(); + }); + + it('handles errors during extension restart', async () => { + const mockExtensions = [ + { name: 'ext1', isActive: true }, + ] as GeminiCLIExtension[]; + mockGetExtensions.mockReturnValue(mockExtensions); + mockRestartExtension.mockRejectedValue(new Error('Failed to restart')); + + await restartAction!(mockContext, '--all'); + + expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Failed to restart some extensions:\n ext1: Failed to restart', + }), + expect.any(Number), + ); + }); + + it('shows a warning if an extension is not found', async () => { + const mockExtensions = [ + { name: 'ext1', isActive: true }, + ] as GeminiCLIExtension[]; + mockGetExtensions.mockReturnValue(mockExtensions); + + await restartAction!(mockContext, 'ext1 ext2'); + + expect(mockRestartExtension).toHaveBeenCalledTimes(1); + expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.WARNING, + text: 'Extension(s) not found or not active: ext2', + }), + expect.any(Number), + ); + }); + + it('does not restart any extensions if none are found', async () => { + const mockExtensions = [ + { name: 'ext1', isActive: true }, + ] as GeminiCLIExtension[]; + mockGetExtensions.mockReturnValue(mockExtensions); + + await restartAction!(mockContext, 'ext2 ext3'); + + expect(mockRestartExtension).not.toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.WARNING, + text: 'Extension(s) not found or not active: ext2, ext3', + }), + expect.any(Number), + ); + }); + + it('should suggest only enabled extension names for the restart command', async () => { + mockContext.invocation!.name = 'restart'; + const mockExtensions = [ + { name: 'ext1', isActive: true }, + { name: 'ext2', isActive: false }, + ] as GeminiCLIExtension[]; + mockGetExtensions.mockReturnValue(mockExtensions); + + const suggestions = completeExtensions(mockContext, 'ext'); + expect(suggestions).toEqual(['ext1']); + }); + }); }); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 2cb823543a..7d570a1941 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -7,7 +7,11 @@ import { debugLogger, listExtensions } from '@google/gemini-cli-core'; import type { ExtensionUpdateInfo } from '../../config/extension.js'; import { getErrorMessage } from '../../utils/errors.js'; -import { MessageType, type HistoryItemExtensionsList } from '../types.js'; +import { + MessageType, + type HistoryItemExtensionsList, + type HistoryItemInfo, +} from '../types.js'; import { type CommandContext, type SlashCommand, @@ -17,6 +21,7 @@ import open from 'open'; import process from 'node:process'; import { ExtensionManager } from '../../config/extension-manager.js'; import { SettingScope } from '../../config/settings.js'; +import { theme } from '../semantic-colors.js'; async function listAction(context: CommandContext) { const historyItem: HistoryItemExtensionsList = { @@ -116,6 +121,118 @@ function updateAction(context: CommandContext, args: string): Promise { return updateComplete.then((_) => {}); } +async function restartAction( + context: CommandContext, + args: string, +): Promise { + const extensionLoader = context.services.config?.getExtensionLoader(); + if (!extensionLoader) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: "Extensions are not yet loaded, can't restart yet", + }, + Date.now(), + ); + return; + } + + const restartArgs = args.split(' ').filter((value) => value.length > 0); + const all = restartArgs.length === 1 && restartArgs[0] === '--all'; + const names = all ? null : restartArgs; + if (!all && names?.length === 0) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: 'Usage: /extensions restart |--all', + }, + Date.now(), + ); + return Promise.resolve(); + } + + let extensionsToRestart = extensionLoader + .getExtensions() + .filter((extension) => extension.isActive); + if (names) { + extensionsToRestart = extensionsToRestart.filter((extension) => + names.includes(extension.name), + ); + if (names.length !== extensionsToRestart.length) { + const notFound = names.filter( + (name) => + !extensionsToRestart.some((extension) => extension.name === name), + ); + if (notFound.length > 0) { + context.ui.addItem( + { + type: MessageType.WARNING, + text: `Extension(s) not found or not active: ${notFound.join( + ', ', + )}`, + }, + Date.now(), + ); + } + } + } + if (extensionsToRestart.length === 0) { + // We will have logged a different message above already. + return; + } + + const s = extensionsToRestart.length > 1 ? 's' : ''; + + const restartingMessage = { + type: MessageType.INFO, + text: `Restarting ${extensionsToRestart.length} extension${s}...`, + color: theme.text.primary, + }; + context.ui.addItem(restartingMessage, Date.now()); + + const results = await Promise.allSettled( + extensionsToRestart.map(async (extension) => { + if (extension.isActive) { + await extensionLoader.restartExtension(extension); + context.ui.dispatchExtensionStateUpdate({ + type: 'RESTARTED', + payload: { + name: extension.name, + }, + }); + } + }), + ); + + const failures = results.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + + if (failures.length > 0) { + const errorMessages = failures + .map((failure, index) => { + const extensionName = extensionsToRestart[index].name; + return `${extensionName}: ${getErrorMessage(failure.reason)}`; + }) + .join('\n '); + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Failed to restart some extensions:\n ${errorMessages}`, + }, + Date.now(), + ); + } else { + const infoItem: HistoryItemInfo = { + type: MessageType.INFO, + text: `${extensionsToRestart.length} extension${s} restarted successfully.`, + icon: ' ', + color: theme.text.primary, + }; + context.ui.addItem(infoItem, Date.now()); + } +} + async function exploreAction(context: CommandContext) { const extensionsUrl = 'https://geminicli.com/extensions/'; @@ -284,10 +401,14 @@ export function completeExtensions( partialArg: string, ) { let extensions = context.services.config?.getExtensions() ?? []; + if (context.invocation?.name === 'enable') { extensions = extensions.filter((ext) => !ext.isActive); } - if (context.invocation?.name === 'disable') { + if ( + context.invocation?.name === 'disable' || + context.invocation?.name === 'restart' + ) { extensions = extensions.filter((ext) => ext.isActive); } const extensionNames = extensions.map((ext) => ext.name); @@ -351,6 +472,14 @@ const exploreExtensionsCommand: SlashCommand = { action: exploreAction, }; +const restartCommand: SlashCommand = { + name: 'restart', + description: 'Restart all extensions', + kind: CommandKind.BUILT_IN, + action: restartAction, + completion: completeExtensions, +}; + export function extensionsCommand( enableExtensionReloading?: boolean, ): SlashCommand { @@ -365,6 +494,7 @@ export function extensionsCommand( listExtensionsCommand, updateExtensionsCommand, exploreExtensionsCommand, + restartCommand, ...conditionalCommands, ], action: (context, args) => diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 7ec1f2da80..ad51f91a8a 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -86,7 +86,11 @@ export const HistoryItemDisplay: React.FC = ({ /> )} {itemForDisplay.type === 'info' && ( - + )} {itemForDisplay.type === 'warning' && ( diff --git a/packages/cli/src/ui/components/messages/InfoMessage.tsx b/packages/cli/src/ui/components/messages/InfoMessage.tsx index 2d8fd2564b..d092e292b1 100644 --- a/packages/cli/src/ui/components/messages/InfoMessage.tsx +++ b/packages/cli/src/ui/components/messages/InfoMessage.tsx @@ -11,21 +11,28 @@ import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; interface InfoMessageProps { text: string; + icon?: string; + color?: string; } -export const InfoMessage: React.FC = ({ text }) => { - const prefix = 'ℹ '; +export const InfoMessage: React.FC = ({ + text, + icon, + color, +}) => { + color ??= theme.status.warning; + const prefix = icon ?? 'ℹ '; const prefixWidth = prefix.length; return ( - {prefix} + {prefix} {text.split('\n').map((line, index) => ( - + ))} diff --git a/packages/cli/src/ui/state/extensions.test.ts b/packages/cli/src/ui/state/extensions.test.ts new file mode 100644 index 0000000000..57a018f07b --- /dev/null +++ b/packages/cli/src/ui/state/extensions.test.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + extensionUpdatesReducer, + type ExtensionUpdatesState, + ExtensionUpdateState, +} from './extensions.js'; + +describe('extensionUpdatesReducer', () => { + it('should handle RESTARTED action', () => { + const initialState: ExtensionUpdatesState = { + extensionStatuses: new Map([ + [ + 'ext1', + { + status: ExtensionUpdateState.UPDATED_NEEDS_RESTART, + lastUpdateTime: 0, + lastUpdateCheck: 0, + notified: true, + }, + ], + ]), + batchChecksInProgress: 0, + scheduledUpdate: null, + }; + + const action = { + type: 'RESTARTED' as const, + payload: { name: 'ext1' }, + }; + + const newState = extensionUpdatesReducer(initialState, action); + + const expectedStatus = { + status: ExtensionUpdateState.UPDATED, + lastUpdateTime: 0, + lastUpdateCheck: 0, + notified: true, + }; + + expect(newState.extensionStatuses.get('ext1')).toEqual(expectedStatus); + }); + + it('should not change state for RESTARTED action if status is not UPDATED_NEEDS_RESTART', () => { + const initialState: ExtensionUpdatesState = { + extensionStatuses: new Map([ + [ + 'ext1', + { + status: ExtensionUpdateState.UPDATED, + lastUpdateTime: 0, + lastUpdateCheck: 0, + notified: true, + }, + ], + ]), + batchChecksInProgress: 0, + scheduledUpdate: null, + }; + + const action = { + type: 'RESTARTED' as const, + payload: { name: 'ext1' }, + }; + + const newState = extensionUpdatesReducer(initialState, action); + + expect(newState).toEqual(initialState); + }); +}); diff --git a/packages/cli/src/ui/state/extensions.ts b/packages/cli/src/ui/state/extensions.ts index 353cf79668..7e7cc83c74 100644 --- a/packages/cli/src/ui/state/extensions.ts +++ b/packages/cli/src/ui/state/extensions.ts @@ -63,7 +63,8 @@ export type ExtensionUpdateAction = | { type: 'BATCH_CHECK_START' } | { type: 'BATCH_CHECK_END' } | { type: 'SCHEDULE_UPDATE'; payload: ScheduleUpdateArgs } - | { type: 'CLEAR_SCHEDULED_UPDATE' }; + | { type: 'CLEAR_SCHEDULED_UPDATE' } + | { type: 'RESTARTED'; payload: { name: string } }; export function extensionUpdatesReducer( state: ExtensionUpdatesState, @@ -125,6 +126,20 @@ export function extensionUpdatesReducer( ...state, scheduledUpdate: null, }; + case 'RESTARTED': { + const existing = state.extensionStatuses.get(action.payload.name); + if (existing?.status !== ExtensionUpdateState.UPDATED_NEEDS_RESTART) { + return state; + } + + const newStatuses = new Map(state.extensionStatuses); + newStatuses.set(action.payload.name, { + ...existing, + status: ExtensionUpdateState.UPDATED, + }); + + return { ...state, extensionStatuses: newStatuses }; + } default: checkExhaustive(action); } diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index f870ed6fb4..6931185c06 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -103,6 +103,8 @@ export type HistoryItemGeminiContent = HistoryItemBase & { export type HistoryItemInfo = HistoryItemBase & { type: 'info'; text: string; + icon?: string; + color?: string; }; export type HistoryItemError = HistoryItemBase & { diff --git a/packages/core/src/utils/extensionLoader.test.ts b/packages/core/src/utils/extensionLoader.test.ts index 38b4a60223..a9328ccd70 100644 --- a/packages/core/src/utils/extensionLoader.test.ts +++ b/packages/core/src/utils/extensionLoader.test.ts @@ -169,4 +169,27 @@ describe('SimpleExtensionLoader', () => { }, ); }); + + describe('restartExtension', () => { + it('should stop and then start the extension', async () => { + const loader = new TestingSimpleExtensionLoader([activeExtension]); + vi.spyOn(loader, 'stopExtension'); + vi.spyOn(loader, 'startExtension'); + await loader.start(mockConfig); + await loader.restartExtension(activeExtension); + expect(loader.stopExtension).toHaveBeenCalledWith(activeExtension); + expect(loader.startExtension).toHaveBeenCalledWith(activeExtension); + }); + }); }); + +// Adding these overrides allows us to access the protected members. +class TestingSimpleExtensionLoader extends SimpleExtensionLoader { + override async startExtension(extension: GeminiCLIExtension): Promise { + await super.startExtension(extension); + } + + override async stopExtension(extension: GeminiCLIExtension): Promise { + await super.stopExtension(extension); + } +} diff --git a/packages/core/src/utils/extensionLoader.ts b/packages/core/src/utils/extensionLoader.ts index ab8bed2ce9..707f30cb4a 100644 --- a/packages/core/src/utils/extensionLoader.ts +++ b/packages/core/src/utils/extensionLoader.ts @@ -200,6 +200,11 @@ export abstract class ExtensionLoader { } return; } + + async restartExtension(extension: GeminiCLIExtension): Promise { + await this.stopExtension(extension); + await this.startExtension(extension); + } } export interface ExtensionEvents {