From e89012efa8fe40db4bb72d72d8875a9c30e4145a Mon Sep 17 00:00:00 2001 From: Jacob MacDonald Date: Fri, 12 Sep 2025 09:20:04 -0700 Subject: [PATCH] Make a stateful extensions list component, with update statuses (#8301) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../cli/src/commands/extensions/update.ts | 39 ++- packages/cli/src/config/extension.test.ts | 279 +++++++++++++++--- packages/cli/src/config/extension.ts | 212 +++++++------ packages/cli/src/ui/AppContainer.tsx | 15 + .../src/ui/commands/extensionsCommand.test.ts | 84 ++---- .../cli/src/ui/commands/extensionsCommand.ts | 93 +++--- packages/cli/src/ui/commands/types.ts | 7 +- .../src/ui/components/HistoryItemDisplay.tsx | 2 + .../components/views/ExtensionsList.test.tsx | 110 +++++++ .../ui/components/views/ExtensionsList.tsx | 67 +++++ .../cli/src/ui/contexts/UIStateContext.tsx | 2 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 27 +- packages/cli/src/ui/state/extensions.ts | 15 + packages/cli/src/ui/types.ts | 8 +- packages/core/src/config/config.ts | 4 + 15 files changed, 714 insertions(+), 250 deletions(-) create mode 100644 packages/cli/src/ui/components/views/ExtensionsList.test.tsx create mode 100644 packages/cli/src/ui/components/views/ExtensionsList.tsx create mode 100644 packages/cli/src/ui/state/extensions.ts diff --git a/packages/cli/src/commands/extensions/update.ts b/packages/cli/src/commands/extensions/update.ts index 42d6fdfefe..03fa4aa7f3 100644 --- a/packages/cli/src/commands/extensions/update.ts +++ b/packages/cli/src/commands/extensions/update.ts @@ -9,6 +9,9 @@ import { updateExtensionByName, updateAllUpdatableExtensions, type ExtensionUpdateInfo, + loadExtensions, + annotateActiveExtensions, + checkForAllExtensionUpdates, } from '../../config/extension.js'; import { getErrorMessage } from '../../utils/errors.js'; @@ -21,9 +24,25 @@ const updateOutput = (info: ExtensionUpdateInfo) => `Extension "${info.name}" successfully updated: ${info.originalVersion} → ${info.updatedVersion}.`; export async function handleUpdate(args: UpdateArgs) { + const workingDir = process.cwd(); + const allExtensions = loadExtensions(); + const extensions = annotateActiveExtensions( + allExtensions, + allExtensions.map((e) => e.config.name), + workingDir, + ); + if (args.all) { try { - const updateInfos = await updateAllUpdatableExtensions(); + let updateInfos = await updateAllUpdatableExtensions( + workingDir, + extensions, + await checkForAllExtensionUpdates(extensions, (_) => {}), + () => {}, + ); + updateInfos = updateInfos.filter( + (info) => info.originalVersion !== info.updatedVersion, + ); if (updateInfos.length === 0) { console.log('No extensions to update.'); return; @@ -36,10 +55,22 @@ export async function handleUpdate(args: UpdateArgs) { if (args.name) try { // TODO(chrstnb): we should list extensions if the requested extension is not installed. - const updatedExtensionInfo = await updateExtensionByName(args.name); - console.log( - `Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion} → ${updatedExtensionInfo.updatedVersion}.`, + const updatedExtensionInfo = await updateExtensionByName( + args.name, + workingDir, + extensions, + () => {}, ); + if ( + updatedExtensionInfo.originalVersion !== + updatedExtensionInfo.updatedVersion + ) { + console.log( + `Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion} → ${updatedExtensionInfo.updatedVersion}.`, + ); + } else { + console.log(`Extension "${args.name}" already up to date.`); + } } catch (error) { console.error(getErrorMessage(error)); } diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index aa8416a574..2835c9fff8 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -12,10 +12,10 @@ import { EXTENSIONS_CONFIG_FILENAME, INSTALL_METADATA_FILENAME, annotateActiveExtensions, - checkForExtensionUpdates, + checkForAllExtensionUpdates, + checkForExtensionUpdate, disableExtension, enableExtension, - ExtensionUpdateStatus, installExtension, loadExtension, loadExtensions, @@ -23,6 +23,7 @@ import { uninstallExtension, updateExtension, type Extension, + type ExtensionInstallMetadata, } from './extension.js'; import { GEMINI_DIR, @@ -34,6 +35,7 @@ import { import { execSync } from 'node:child_process'; import { SettingScope, loadSettings } from './settings.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; +import { ExtensionUpdateState } from '../ui/state/extensions.js'; const mockGit = { clone: vi.fn(), @@ -829,6 +831,7 @@ function createExtension({ addContextFile = false, contextFileName = undefined as string | undefined, mcpServers = {} as Record, + installMetadata = undefined as ExtensionInstallMetadata | undefined, } = {}): string { const extDir = path.join(extensionsDir, name); fs.mkdirSync(extDir, { recursive: true }); @@ -842,9 +845,14 @@ function createExtension({ } if (contextFileName) { - const contextPath = path.join(extDir, contextFileName); - fs.mkdirSync(path.dirname(contextPath), { recursive: true }); - fs.writeFileSync(contextPath, 'context'); + fs.writeFileSync(path.join(extDir, contextFileName), 'context'); + } + + if (installMetadata) { + fs.writeFileSync( + path.join(extDir, INSTALL_METADATA_FILENAME), + JSON.stringify(installMetadata), + ); } return extDir; } @@ -897,8 +905,12 @@ describe('updateExtension', () => { ); }); mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); - - const updateInfo = await updateExtension(loadExtension(targetExtDir)!); + const extension = annotateActiveExtensions( + [loadExtension(targetExtDir)!], + [], + process.cwd(), + )[0]; + const updateInfo = await updateExtension(extension, tempHomeDir, () => {}); expect(updateInfo).toEqual({ name: 'gemini-extensions', @@ -914,9 +926,80 @@ describe('updateExtension', () => { ); expect(updatedConfig.version).toBe('1.1.0'); }); + + it('should call setExtensionUpdateState with UPDATING and then UPDATED_NEEDS_RESTART on success', async () => { + const extensionName = 'test-extension'; + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: extensionName, + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + + mockGit.clone.mockImplementation(async (_, destination) => { + fs.mkdirSync(path.join(mockGit.path(), destination), { recursive: true }); + fs.writeFileSync( + path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: extensionName, version: '1.1.0' }), + ); + }); + mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); + + const setExtensionUpdateState = vi.fn(); + + const extension = annotateActiveExtensions( + [loadExtension(extensionDir)!], + [], + process.cwd(), + )[0]; + await updateExtension(extension, tempHomeDir, setExtensionUpdateState); + + expect(setExtensionUpdateState).toHaveBeenCalledWith( + ExtensionUpdateState.UPDATING, + ); + expect(setExtensionUpdateState).toHaveBeenCalledWith( + ExtensionUpdateState.UPDATED_NEEDS_RESTART, + ); + }); + + it('should call setExtensionUpdateState with ERROR on failure', async () => { + const extensionName = 'test-extension'; + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: extensionName, + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + + mockGit.clone.mockRejectedValue(new Error('Git clone failed')); + mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); + + const setExtensionUpdateState = vi.fn(); + const extension = annotateActiveExtensions( + [loadExtension(extensionDir)!], + [], + process.cwd(), + )[0]; + await expect( + updateExtension(extension, tempHomeDir, setExtensionUpdateState), + ).rejects.toThrow(); + + expect(setExtensionUpdateState).toHaveBeenCalledWith( + ExtensionUpdateState.UPDATING, + ); + expect(setExtensionUpdateState).toHaveBeenCalledWith( + ExtensionUpdateState.ERROR, + ); + }); }); -describe('checkForExtensionUpdates', () => { +describe('checkForAllExtensionUpdates', () => { let tempHomeDir: string; let userExtensionsDir: string; @@ -939,12 +1022,16 @@ describe('checkForExtensionUpdates', () => { extensionsDir: userExtensionsDir, name: 'test-extension', version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, }); - const extension = loadExtension(extensionDir)!; - extension.installMetadata = { - source: 'https://some.git/repo', - type: 'git', - }; + const extension = annotateActiveExtensions( + [loadExtension(extensionDir)!], + [], + process.cwd(), + )[0]; mockGit.getRemotes.mockResolvedValue([ { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, @@ -952,9 +1039,9 @@ describe('checkForExtensionUpdates', () => { mockGit.listRemote.mockResolvedValue('remoteHash HEAD'); mockGit.revparse.mockResolvedValue('localHash'); - const results = await checkForExtensionUpdates([extension]); + const results = await checkForAllExtensionUpdates([extension], () => {}); const result = results.get('test-extension'); - expect(result?.status).toBe(ExtensionUpdateStatus.UpdateAvailable); + expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); }); it('should return UpToDate for a git extension with no updates', async () => { @@ -962,12 +1049,16 @@ describe('checkForExtensionUpdates', () => { extensionsDir: userExtensionsDir, name: 'test-extension', version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, }); - const extension = loadExtension(extensionDir)!; - extension.installMetadata = { - source: 'https://some.git/repo', - type: 'git', - }; + const extension = annotateActiveExtensions( + [loadExtension(extensionDir)!], + [], + process.cwd(), + )[0]; mockGit.getRemotes.mockResolvedValue([ { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, @@ -975,9 +1066,121 @@ describe('checkForExtensionUpdates', () => { mockGit.listRemote.mockResolvedValue('sameHash HEAD'); mockGit.revparse.mockResolvedValue('sameHash'); - const results = await checkForExtensionUpdates([extension]); + const results = await checkForAllExtensionUpdates([extension], () => {}); const result = results.get('test-extension'); - expect(result?.status).toBe(ExtensionUpdateStatus.UpToDate); + expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); + }); + + it('should return NotUpdatable for a non-git extension', async () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'local-extension', + version: '1.0.0', + installMetadata: { source: '/local/path', type: 'local' }, + }); + const extension = annotateActiveExtensions( + [loadExtension(extensionDir)!], + [], + process.cwd(), + )[0]; + + const results = await checkForAllExtensionUpdates([extension], () => {}); + const result = results.get('local-extension'); + expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); + }); + + it('should return Error when git check fails', async () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'error-extension', + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + const extension = annotateActiveExtensions( + [loadExtension(extensionDir)!], + [], + process.cwd(), + )[0]; + + mockGit.getRemotes.mockRejectedValue(new Error('Git error')); + + const results = await checkForAllExtensionUpdates([extension], () => {}); + const result = results.get('error-extension'); + expect(result).toBe(ExtensionUpdateState.ERROR); + }); +}); + +describe('checkForExtensionUpdate', () => { + let tempHomeDir: string; + let userExtensionsDir: string; + + beforeEach(() => { + tempHomeDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-home-'), + ); + vi.mocked(os.homedir).mockReturnValue(tempHomeDir); + userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); + fs.mkdirSync(userExtensionsDir, { recursive: true }); + Object.values(mockGit).forEach((fn) => fn.mockReset()); + }); + + afterEach(() => { + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + }); + + it('should return UpdateAvailable for a git extension with updates', async () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + const extension = annotateActiveExtensions( + [loadExtension(extensionDir)!], + [], + process.cwd(), + )[0]; + + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, + ]); + mockGit.listRemote.mockResolvedValue('remoteHash HEAD'); + mockGit.revparse.mockResolvedValue('localHash'); + + const result = await checkForExtensionUpdate(extension); + expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); + }); + + it('should return UpToDate for a git extension with no updates', async () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + const extension = annotateActiveExtensions( + [loadExtension(extensionDir)!], + [], + process.cwd(), + )[0]; + + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, + ]); + mockGit.listRemote.mockResolvedValue('sameHash HEAD'); + mockGit.revparse.mockResolvedValue('sameHash'); + + const result = await checkForExtensionUpdate(extension); + expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); }); it('should return NotUpdatable for a non-git extension', async () => { @@ -986,12 +1189,14 @@ describe('checkForExtensionUpdates', () => { name: 'local-extension', version: '1.0.0', }); - const extension = loadExtension(extensionDir)!; - extension.installMetadata = { source: '/local/path', type: 'local' }; + const extension = annotateActiveExtensions( + [loadExtension(extensionDir)!], + [], + process.cwd(), + )[0]; - const results = await checkForExtensionUpdates([extension]); - const result = results.get('local-extension'); - expect(result?.status).toBe(ExtensionUpdateStatus.NotUpdatable); + const result = await checkForExtensionUpdate(extension); + expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); }); it('should return Error when git check fails', async () => { @@ -999,19 +1204,21 @@ describe('checkForExtensionUpdates', () => { extensionsDir: userExtensionsDir, name: 'error-extension', version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, }); - const extension = loadExtension(extensionDir)!; - extension.installMetadata = { - source: 'https://some.git/repo', - type: 'git', - }; + const extension = annotateActiveExtensions( + [loadExtension(extensionDir)!], + [], + process.cwd(), + )[0]; mockGit.getRemotes.mockRejectedValue(new Error('Git error')); - const results = await checkForExtensionUpdates([extension]); - const result = results.get('error-extension'); - expect(result?.status).toBe(ExtensionUpdateStatus.Error); - expect(result?.error).toContain('Failed to check for updates'); + const result = await checkForExtensionUpdate(extension); + expect(result).toBe(ExtensionUpdateState.ERROR); }); }); diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 8cdfaba52c..459c5f24b3 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -25,6 +25,7 @@ import { recursivelyHydrateStrings } from './extensions/variables.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { randomUUID } from 'node:crypto'; +import { ExtensionUpdateState } from '../ui/state/extensions.js'; export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); @@ -291,6 +292,9 @@ export function annotateActiveExtensions( version: extension.config.version, isActive: !disabledExtensions.includes(extension.config.name), path: extension.path, + source: extension.installMetadata?.source, + type: extension.installMetadata?.type, + ref: extension.installMetadata?.ref, })); } @@ -307,6 +311,9 @@ export function annotateActiveExtensions( version: extension.config.version, isActive: false, path: extension.path, + source: extension.installMetadata?.source, + type: extension.installMetadata?.type, + ref: extension.installMetadata?.ref, })); } @@ -609,46 +616,63 @@ export function toOutputString(extension: Extension): string { export async function updateExtensionByName( extensionName: string, cwd: string = process.cwd(), + extensions: GeminiCLIExtension[], + setExtensionUpdateState: (updateState: ExtensionUpdateState) => void, ): Promise { - const installedExtensions = loadUserExtensions(); - const extension = installedExtensions.find( - (installed) => installed.config.name === extensionName, + const extension = extensions.find( + (installed) => installed.name === extensionName, ); if (!extension) { throw new Error( `Extension "${extensionName}" not found. Run gemini extensions list to see available extensions.`, ); } - return await updateExtension(extension, cwd); + return await updateExtension(extension, cwd, setExtensionUpdateState); } export async function updateExtension( - extension: Extension, + extension: GeminiCLIExtension, cwd: string = process.cwd(), + setExtensionUpdateState: (updateState: ExtensionUpdateState) => void, ): Promise { - if (!extension.installMetadata) { - throw new Error(`Extension ${extension.config.name} cannot be updated.`); + if (!extension.type) { + setExtensionUpdateState(ExtensionUpdateState.ERROR); + throw new Error( + `Extension ${extension.name} cannot be updated, type is unknown.`, + ); } - if (extension.installMetadata.type === 'link') { + if (extension.type === 'link') { + setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE); throw new Error(`Extension is linked so does not need to be updated`); } - const originalVersion = extension.config.version; + setExtensionUpdateState(ExtensionUpdateState.UPDATING); + const originalVersion = extension.version; const tempDir = await ExtensionStorage.createTmpDir(); try { await copyExtension(extension.path, tempDir); - await uninstallExtension(extension.config.name, cwd); - await installExtension(extension.installMetadata, cwd); - const updatedExtensionStorage = new ExtensionStorage(extension.config.name); + await uninstallExtension(extension.name, cwd); + await installExtension( + { + source: extension.source!, + type: extension.type, + ref: extension.ref, + }, + cwd, + ); + + const updatedExtensionStorage = new ExtensionStorage(extension.name); const updatedExtension = loadExtension( updatedExtensionStorage.getExtensionDir(), ); if (!updatedExtension) { + setExtensionUpdateState(ExtensionUpdateState.ERROR); throw new Error('Updated extension not found after installation.'); } const updatedVersion = updatedExtension.config.version; + setExtensionUpdateState(ExtensionUpdateState.UPDATED_NEEDS_RESTART); return { - name: extension.config.name, + name: extension.name, originalVersion, updatedVersion, }; @@ -656,6 +680,7 @@ export async function updateExtension( console.error( `Error updating extension, rolling back. ${getErrorMessage(e)}`, ); + setExtensionUpdateState(ExtensionUpdateState.ERROR); await copyExtension(tempDir, extension.path); throw e; } finally { @@ -714,98 +739,97 @@ function removeFromDisabledExtensions( export async function updateAllUpdatableExtensions( cwd: string = process.cwd(), + extensions: GeminiCLIExtension[], + extensionsState: Map, + setExtensionsUpdateState: ( + updateState: Map, + ) => void, ): Promise { - const extensions = loadExtensions(cwd).filter( - (extension) => !!extension.installMetadata, - ); return await Promise.all( - extensions.map((extension) => updateExtension(extension, cwd)), + extensions + .filter( + (extension) => + extensionsState.get(extension.name) === + ExtensionUpdateState.UPDATE_AVAILABLE, + ) + .map((extension) => + updateExtension(extension, cwd, (updateState) => { + const newState = new Map(extensionsState); + newState.set(extension.name, updateState); + setExtensionsUpdateState(newState); + }), + ), ); } -export enum ExtensionUpdateStatus { - UpdateAvailable, - UpToDate, - Error, - NotUpdatable, -} - export interface ExtensionUpdateCheckResult { - status: ExtensionUpdateStatus; + state: ExtensionUpdateState; error?: string; } -export async function checkForExtensionUpdates( - extensions: Extension[], -): Promise> { - const results = new Map(); - +export async function checkForAllExtensionUpdates( + extensions: GeminiCLIExtension[], + setExtensionsUpdateState: ( + updateState: Map, + ) => void, +): Promise> { + const finalState = new Map(); for (const extension of extensions) { - if (extension.installMetadata?.type !== 'git') { - results.set(extension.config.name, { - status: ExtensionUpdateStatus.NotUpdatable, - }); - continue; - } + finalState.set(extension.name, await checkForExtensionUpdate(extension)); + } + setExtensionsUpdateState(finalState); + return finalState; +} - try { - const git = simpleGit(extension.path); - const remotes = await git.getRemotes(true); - if (remotes.length === 0) { - results.set(extension.config.name, { - status: ExtensionUpdateStatus.Error, - error: 'No git remotes found.', - }); - continue; - } - const remoteUrl = remotes[0].refs.fetch; - if (!remoteUrl) { - results.set(extension.config.name, { - status: ExtensionUpdateStatus.Error, - error: `No fetch URL found for git remote ${remotes[0].name}.`, - }); - continue; - } - - // Determine the ref to check on the remote. - const refToCheck = extension.installMetadata.ref || 'HEAD'; - - const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]); - - if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') { - results.set(extension.config.name, { - status: ExtensionUpdateStatus.Error, - error: `Git ref ${refToCheck} not found.`, - }); - continue; - } - - const remoteHash = lsRemoteOutput.split('\t')[0]; - const localHash = await git.revparse(['HEAD']); - - if (!remoteHash) { - results.set(extension.config.name, { - status: ExtensionUpdateStatus.Error, - error: `Unable to parse hash from git ls-remote output "${lsRemoteOutput}"`, - }); - } else if (remoteHash === localHash) { - results.set(extension.config.name, { - status: ExtensionUpdateStatus.UpToDate, - }); - } else { - results.set(extension.config.name, { - status: ExtensionUpdateStatus.UpdateAvailable, - }); - } - } catch (error) { - results.set(extension.config.name, { - status: ExtensionUpdateStatus.Error, - error: `Failed to check for updates for extension "${ - extension.config.name - }": ${getErrorMessage(error)}`, - }); - } +export async function checkForExtensionUpdate( + extension: GeminiCLIExtension, +): Promise { + if (extension.type !== 'git') { + return ExtensionUpdateState.NOT_UPDATABLE; } - return results; + try { + const git = simpleGit(extension.path); + const remotes = await git.getRemotes(true); + if (remotes.length === 0) { + console.error('No git remotes found.'); + return ExtensionUpdateState.ERROR; + } + const remoteUrl = remotes[0].refs.fetch; + if (!remoteUrl) { + console.error(`No fetch URL found for git remote ${remotes[0].name}.`); + return ExtensionUpdateState.ERROR; + } + + // Determine the ref to check on the remote. + const refToCheck = extension.ref || 'HEAD'; + + const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]); + + if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') { + console.error(`Git ref ${refToCheck} not found.`); + return ExtensionUpdateState.ERROR; + } + + const remoteHash = lsRemoteOutput.split('\t')[0]; + const localHash = await git.revparse(['HEAD']); + + if (!remoteHash) { + console.error( + `Unable to parse hash from git ls-remote output "${lsRemoteOutput}"`, + ); + return ExtensionUpdateState.ERROR; + } else if (remoteHash === localHash) { + return ExtensionUpdateState.UP_TO_DATE; + } else { + return ExtensionUpdateState.UPDATE_AVAILABLE; + } + } catch (error) { + console.error( + `Failed to check for updates for extension "${ + extension.name + }": ${getErrorMessage(error)}`, + ); + return ExtensionUpdateState.ERROR; + } } diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index c780b5b448..37e000490f 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -76,6 +76,8 @@ import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js'; import { useSessionStats } from './contexts/SessionContext.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; +import type { ExtensionUpdateState } from './state/extensions.js'; +import { checkForAllExtensionUpdates } from '../config/extension.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -136,6 +138,9 @@ export const AppContainer = (props: AppContainerProps) => { const [isTrustedFolder, setIsTrustedFolder] = useState( config.isTrustedFolder(), ); + const [extensionsUpdateState, setExtensionsUpdateState] = useState( + new Map(), + ); // Helper to determine the effective model, considering the fallback state. const getEffectiveModel = useCallback(() => { @@ -419,6 +424,7 @@ Logging in with Google... Please restart Gemini CLI to continue. }, setDebugMessage, toggleCorgiMode: () => setCorgiMode((prev) => !prev), + setExtensionsUpdateState, }), [ setAuthState, @@ -429,6 +435,7 @@ Logging in with Google... Please restart Gemini CLI to continue. setDebugMessage, setShowPrivacyNotice, setCorgiMode, + setExtensionsUpdateState, ], ); @@ -450,6 +457,7 @@ Logging in with Google... Please restart Gemini CLI to continue. setIsProcessing, setGeminiMdFileCount, slashCommandActions, + extensionsUpdateState, isConfigInitialized, ); @@ -1040,6 +1048,7 @@ Logging in with Google... Please restart Gemini CLI to continue. updateInfo, showIdeRestartPrompt, isRestarting, + extensionsUpdateState, activePtyId, shellFocused, }), @@ -1115,6 +1124,7 @@ Logging in with Google... Please restart Gemini CLI to continue. showIdeRestartPrompt, isRestarting, currentModel, + extensionsUpdateState, activePtyId, shellFocused, ], @@ -1168,6 +1178,11 @@ Logging in with Google... Please restart Gemini CLI to continue. ], ); + const extensions = config.getExtensions(); + useEffect(() => { + checkForAllExtensionUpdates(extensions, setExtensionsUpdateState); + }, [extensions, setExtensionsUpdateState]); + return ( diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index 191c2a343d..85a717eb77 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -44,51 +44,20 @@ describe('extensionsCommand', () => { services: { config: { getExtensions: () => [], + getWorkingDir: () => '/test/dir', }, }, }); }); describe('list', () => { - it('should display "No active extensions." when none are found', async () => { + it('should add an EXTENSIONS_LIST item to the UI', async () => { if (!extensionsCommand.action) throw new Error('Action not defined'); await extensionsCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { - type: MessageType.INFO, - text: 'No active extensions.', - }, - expect.any(Number), - ); - }); - - it('should list active extensions when they are found', async () => { - const mockExtensions = [ - { name: 'ext-one', version: '1.0.0', isActive: true }, - { name: 'ext-two', version: '2.1.0', isActive: true }, - { name: 'ext-three', version: '3.0.0', isActive: false }, - ]; - mockContext = createMockCommandContext({ - services: { - config: { - getExtensions: () => mockExtensions, - }, - }, - }); - - if (!extensionsCommand.action) throw new Error('Action not defined'); - await extensionsCommand.action(mockContext, ''); - - const expectedMessage = - 'Active extensions:\n\n' + - ` - \u001b[36mext-one (v1.0.0)\u001b[0m\n` + - ` - \u001b[36mext-two (v2.1.0)\u001b[0m\n`; - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: expectedMessage, + type: MessageType.EXTENSIONS_LIST, }, expect.any(Number), ); @@ -127,7 +96,7 @@ describe('extensionsCommand', () => { ); }); - it('should update all extensions with --all', async () => { + it('should call setPendingItem and addItem in a finally block on success', async () => { mockUpdateAllUpdatableExtensions.mockResolvedValue([ { name: 'ext-one', @@ -141,23 +110,33 @@ describe('extensionsCommand', () => { }, ]); await updateAction(mockContext, '--all'); + expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ + type: MessageType.EXTENSIONS_LIST, + }); + expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { - type: MessageType.INFO, - text: - 'Extension "ext-one" successfully updated: 1.0.0 → 1.0.1.\n' + - 'Extension "ext-two" successfully updated: 2.0.0 → 2.0.1.\n' + - 'Restart gemini-cli to see the changes.', + type: MessageType.EXTENSIONS_LIST, }, expect.any(Number), ); }); - it('should handle errors when updating all extensions', async () => { + it('should call setPendingItem and addItem in a finally block on failure', async () => { mockUpdateAllUpdatableExtensions.mockRejectedValue( new Error('Something went wrong'), ); await updateAction(mockContext, '--all'); + expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ + type: MessageType.EXTENSIONS_LIST, + }); + expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.EXTENSIONS_LIST, + }, + expect.any(Number), + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.ERROR, @@ -174,14 +153,11 @@ describe('extensionsCommand', () => { updatedVersion: '1.0.1', }); await updateAction(mockContext, 'ext-one'); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: - 'Extension "ext-one" successfully updated: 1.0.0 → 1.0.1.\n' + - 'Restart gemini-cli to see the changes.', - }, - expect.any(Number), + expect(mockUpdateExtensionByName).toHaveBeenCalledWith( + 'ext-one', + '/test/dir', + [], + expect.any(Function), ); }); @@ -213,13 +189,13 @@ describe('extensionsCommand', () => { }); await updateAction(mockContext, 'ext-one ext-two'); expect(mockUpdateExtensionByName).toHaveBeenCalledTimes(2); + expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ + type: MessageType.EXTENSIONS_LIST, + }); + expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { - type: MessageType.INFO, - text: - 'Extension "ext-one" successfully updated: 1.0.0 → 1.0.1.\n' + - 'Extension "ext-two" successfully updated: 2.0.0 → 2.0.1.\n' + - 'Restart gemini-cli to see the changes.', + type: MessageType.EXTENSIONS_LIST, }, expect.any(Number), ); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index d0b81ad2a1..8552a49237 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -18,65 +18,59 @@ import { } from './types.js'; async function listAction(context: CommandContext) { - const activeExtensions = context.services.config - ?.getExtensions() - .filter((ext) => ext.isActive); - if (!activeExtensions || activeExtensions.length === 0) { - context.ui.addItem( - { - type: MessageType.INFO, - text: 'No active extensions.', - }, - Date.now(), - ); - return; - } - - const extensionLines = activeExtensions.map( - (ext) => ` - \u001b[36m${ext.name} (v${ext.version})\u001b[0m`, - ); - const message = `Active extensions:\n\n${extensionLines.join('\n')}\n`; - context.ui.addItem( { - type: MessageType.INFO, - text: message, + type: MessageType.EXTENSIONS_LIST, }, Date.now(), ); } -const updateOutput = (info: ExtensionUpdateInfo) => - `Extension "${info.name}" successfully updated: ${info.originalVersion} → ${info.updatedVersion}.`; - async function updateAction(context: CommandContext, args: string) { const updateArgs = args.split(' ').filter((value) => value.length > 0); const all = updateArgs.length === 1 && updateArgs[0] === '--all'; const names = all ? undefined : updateArgs; let updateInfos: ExtensionUpdateInfo[] = []; + + if (!all && names?.length === 0) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: 'Usage: /extensions update |--all', + }, + Date.now(), + ); + return; + } + try { + context.ui.setPendingItem({ + type: MessageType.EXTENSIONS_LIST, + }); if (all) { - updateInfos = await updateAllUpdatableExtensions(); + updateInfos = await updateAllUpdatableExtensions( + context.services.config!.getWorkingDir(), + context.services.config!.getExtensions(), + context.ui.extensionsUpdateState, + context.ui.setExtensionsUpdateState, + ); } else if (names?.length) { for (const name of names) { - updateInfos.push(await updateExtensionByName(name)); + updateInfos.push( + await updateExtensionByName( + name, + context.services.config!.getWorkingDir(), + context.services.config!.getExtensions(), + (updateState) => { + const newState = new Map(context.ui.extensionsUpdateState); + newState.set(name, updateState); + context.ui.setExtensionsUpdateState(newState); + }, + ), + ); } - } else { - context.ui.addItem( - { - type: MessageType.ERROR, - text: 'Usage: /extensions update |--all', - }, - Date.now(), - ); - return; } - // Filter to the actually updated ones. - updateInfos = updateInfos.filter( - (info) => info.originalVersion !== info.updatedVersion, - ); - if (updateInfos.length === 0) { context.ui.addItem( { @@ -87,17 +81,6 @@ async function updateAction(context: CommandContext, args: string) { ); return; } - - context.ui.addItem( - { - type: MessageType.INFO, - text: [ - ...updateInfos.map((info) => updateOutput(info)), - 'Restart gemini-cli to see the changes.', - ].join('\n'), - }, - Date.now(), - ); } catch (error) { context.ui.addItem( { @@ -106,6 +89,14 @@ async function updateAction(context: CommandContext, args: string) { }, Date.now(), ); + } finally { + context.ui.addItem( + { + type: MessageType.EXTENSIONS_LIST, + }, + Date.now(), + ); + context.ui.setPendingItem(null); } } diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index c6cc5f1d5e..8a5d35ad54 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -4,13 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { type ReactNode } from 'react'; +import type { ReactNode } from 'react'; import type { Content, PartListUnion } from '@google/genai'; import type { HistoryItemWithoutId, HistoryItem } from '../types.js'; import type { Config, GitService, Logger } from '@google/gemini-cli-core'; import type { LoadedSettings } from '../../config/settings.js'; import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import type { SessionStatsState } from '../contexts/SessionContext.js'; +import type { ExtensionUpdateState } from '../state/extensions.js'; // Grouped dependencies for clarity and easier mocking export interface CommandContext { @@ -61,6 +62,10 @@ export interface CommandContext { toggleVimEnabled: () => Promise; setGeminiMdFileCount: (count: number) => void; reloadCommands: () => void; + extensionsUpdateState: Map; + setExtensionsUpdateState: ( + updateState: Map, + ) => void; }; // Session-specific data session: { diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index bb68b49550..b14443e3c6 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -22,6 +22,7 @@ import { ToolStatsDisplay } from './ToolStatsDisplay.js'; import { SessionSummaryDisplay } from './SessionSummaryDisplay.js'; import { Help } from './Help.js'; import type { SlashCommand } from '../commands/types.js'; +import { ExtensionsList } from './views/ExtensionsList.js'; interface HistoryItemDisplayProps { item: HistoryItem; @@ -96,5 +97,6 @@ export const HistoryItemDisplay: React.FC = ({ {item.type === 'compression' && ( )} + {item.type === 'extensions_list' && } ); diff --git a/packages/cli/src/ui/components/views/ExtensionsList.test.tsx b/packages/cli/src/ui/components/views/ExtensionsList.test.tsx new file mode 100644 index 0000000000..ddce0e3f59 --- /dev/null +++ b/packages/cli/src/ui/components/views/ExtensionsList.test.tsx @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { vi } from 'vitest'; +import { useUIState } from '../../contexts/UIStateContext.js'; +import { ExtensionUpdateState } from '../../state/extensions.js'; +import { ExtensionsList } from './ExtensionsList.js'; +import { createMockCommandContext } from '../../../test-utils/mockCommandContext.js'; + +vi.mock('../../contexts/UIStateContext.js'); + +const mockUseUIState = vi.mocked(useUIState); + +const mockExtensions = [ + { name: 'ext-one', version: '1.0.0', isActive: true }, + { name: 'ext-two', version: '2.1.0', isActive: true }, + { name: 'ext-disabled', version: '3.0.0', isActive: false }, +]; + +describe('', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + const mockUIState = ( + extensions: unknown[], + extensionsUpdateState: Map, + disabledExtensions: string[] = [], + ) => { + mockUseUIState.mockReturnValue({ + commandContext: createMockCommandContext({ + services: { + config: { + getExtensions: () => extensions, + }, + settings: { + merged: { + extensions: { + disabled: disabledExtensions, + }, + }, + }, + }, + }), + extensionsUpdateState, + // Add other required properties from UIState if needed by the component + } as never); + }; + + it('should render "No extensions installed." if there are no extensions', () => { + mockUIState([], new Map()); + const { lastFrame } = render(); + expect(lastFrame()).toContain('No extensions installed.'); + }); + + it('should render a list of extensions with their version and status', () => { + mockUIState(mockExtensions, new Map(), ['ext-disabled']); + const { lastFrame } = render(); + const output = lastFrame(); + expect(output).toContain('ext-one (v1.0.0) - active'); + expect(output).toContain('ext-two (v2.1.0) - active'); + expect(output).toContain('ext-disabled (v3.0.0) - disabled'); + }); + + it('should display "unknown state" if an extension has no update state', () => { + mockUIState([mockExtensions[0]], new Map()); + const { lastFrame } = render(); + expect(lastFrame()).toContain('(unknown state)'); + }); + + const stateTestCases = [ + { + state: ExtensionUpdateState.CHECKING_FOR_UPDATES, + expectedText: '(checking for updates)', + }, + { + state: ExtensionUpdateState.UPDATING, + expectedText: '(updating)', + }, + { + state: ExtensionUpdateState.UPDATE_AVAILABLE, + expectedText: '(update available)', + }, + { + state: ExtensionUpdateState.UPDATED_NEEDS_RESTART, + expectedText: '(updated, needs restart)', + }, + { + state: ExtensionUpdateState.ERROR, + expectedText: '(error checking for updates)', + }, + { + state: ExtensionUpdateState.UP_TO_DATE, + expectedText: '(up to date)', + }, + ]; + + for (const { state, expectedText } of stateTestCases) { + it(`should correctly display the state: ${state}`, () => { + const updateState = new Map([[mockExtensions[0].name, state]]); + mockUIState([mockExtensions[0]], updateState); + const { lastFrame } = render(); + expect(lastFrame()).toContain(expectedText); + }); + } +}); diff --git a/packages/cli/src/ui/components/views/ExtensionsList.tsx b/packages/cli/src/ui/components/views/ExtensionsList.tsx new file mode 100644 index 0000000000..742bee1304 --- /dev/null +++ b/packages/cli/src/ui/components/views/ExtensionsList.tsx @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { useUIState } from '../../contexts/UIStateContext.js'; +import { ExtensionUpdateState } from '../../state/extensions.js'; + +export const ExtensionsList = () => { + const { commandContext, extensionsUpdateState } = useUIState(); + const allExtensions = commandContext.services.config!.getExtensions(); + const settings = commandContext.services.settings; + const disabledExtensions = settings.merged.extensions?.disabled ?? []; + + if (allExtensions.length === 0) { + return No extensions installed.; + } + + return ( + + Installed extensions: + + {allExtensions.map((ext) => { + const state = extensionsUpdateState.get(ext.name); + const isActive = !disabledExtensions.includes(ext.name); + const activeString = isActive ? 'active' : 'disabled'; + + let stateColor = 'gray'; + const stateText = state || 'unknown state'; + + switch (state) { + case ExtensionUpdateState.CHECKING_FOR_UPDATES: + case ExtensionUpdateState.UPDATING: + stateColor = 'cyan'; + break; + case ExtensionUpdateState.UPDATE_AVAILABLE: + case ExtensionUpdateState.UPDATED_NEEDS_RESTART: + stateColor = 'yellow'; + break; + case ExtensionUpdateState.ERROR: + stateColor = 'red'; + break; + case ExtensionUpdateState.UP_TO_DATE: + case ExtensionUpdateState.NOT_UPDATABLE: + stateColor = 'green'; + break; + default: + console.error(`Unhandled ExtensionUpdateState ${state}`); + break; + } + + return ( + + + {`${ext.name} (v${ext.version})`} + {` - ${activeString}`} + {{` (${stateText})`}} + + + ); + })} + + + ); +}; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index a5421dabe4..669ac65533 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -27,6 +27,7 @@ import type { import type { DOMElement } from 'ink'; import type { SessionStatsState } from '../contexts/SessionContext.js'; import type { UpdateObject } from '../utils/updateCheck.js'; +import type { ExtensionUpdateState } from '../state/extensions.js'; export interface ProQuotaDialogRequest { failedModel: string; @@ -108,6 +109,7 @@ export interface UIState { updateInfo: UpdateObject | null; showIdeRestartPrompt: boolean; isRestarting: boolean; + extensionsUpdateState: Map; activePtyId: number | undefined; shellFocused: boolean; } diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 86c309989b..16ddb7042e 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -33,6 +33,7 @@ import { CommandService } from '../../services/CommandService.js'; import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js'; import { FileCommandLoader } from '../../services/FileCommandLoader.js'; import { McpPromptLoader } from '../../services/McpPromptLoader.js'; +import type { ExtensionUpdateState } from '../state/extensions.js'; interface SlashCommandProcessorActions { openAuthDialog: () => void; @@ -43,6 +44,9 @@ interface SlashCommandProcessorActions { quit: (messages: HistoryItem[]) => void; setDebugMessage: (message: string) => void; toggleCorgiMode: () => void; + setExtensionsUpdateState: ( + updateState: Map, + ) => void; } /** @@ -59,6 +63,7 @@ export const useSlashCommandProcessor = ( setIsProcessing: (isProcessing: boolean) => void, setGeminiMdFileCount: (count: number) => void, actions: SlashCommandProcessorActions, + extensionsUpdateState: Map, isConfigInitialized: boolean, ) => { const session = useSessionStats(); @@ -101,16 +106,17 @@ export const useSlashCommandProcessor = ( return l; }, [config]); - const [pendingCompressionItem, setPendingCompressionItem] = - useState(null); + const [pendingItem, setPendingItem] = useState( + null, + ); const pendingHistoryItems = useMemo(() => { const items: HistoryItemWithoutId[] = []; - if (pendingCompressionItem != null) { - items.push(pendingCompressionItem); + if (pendingItem != null) { + items.push(pendingItem); } return items; - }, [pendingCompressionItem]); + }, [pendingItem]); const addMessage = useCallback( (message: Message) => { @@ -182,12 +188,14 @@ export const useSlashCommandProcessor = ( }, loadHistory, setDebugMessage: actions.setDebugMessage, - pendingItem: pendingCompressionItem, - setPendingItem: setPendingCompressionItem, + pendingItem, + setPendingItem, toggleCorgiMode: actions.toggleCorgiMode, toggleVimEnabled, setGeminiMdFileCount, reloadCommands, + extensionsUpdateState, + setExtensionsUpdateState: actions.setExtensionsUpdateState, }, session: { stats: session.stats, @@ -205,12 +213,13 @@ export const useSlashCommandProcessor = ( refreshStatic, session.stats, actions, - pendingCompressionItem, - setPendingCompressionItem, + pendingItem, + setPendingItem, toggleVimEnabled, sessionShellAllowlist, setGeminiMdFileCount, reloadCommands, + extensionsUpdateState, ], ); diff --git a/packages/cli/src/ui/state/extensions.ts b/packages/cli/src/ui/state/extensions.ts new file mode 100644 index 0000000000..3bc625f133 --- /dev/null +++ b/packages/cli/src/ui/state/extensions.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum ExtensionUpdateState { + CHECKING_FOR_UPDATES = 'checking for updates', + UPDATED_NEEDS_RESTART = 'updated, needs restart', + UPDATING = 'updating', + UPDATE_AVAILABLE = 'update available', + UP_TO_DATE = 'up to date', + ERROR = 'error checking for updates', + NOT_UPDATABLE = 'not updatable', +} diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 4e459f65ff..3ab892b2cf 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -155,6 +155,10 @@ export type HistoryItemCompression = HistoryItemBase & { compression: CompressionProps; }; +export type HistoryItemExtensionsList = HistoryItemBase & { + type: 'extensions_list'; +}; + // Using Omit seems to have some issues with typescript's // type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that // 'tools' in historyItem. @@ -173,7 +177,8 @@ export type HistoryItemWithoutId = | HistoryItemModelStats | HistoryItemToolStats | HistoryItemQuit - | HistoryItemCompression; + | HistoryItemCompression + | HistoryItemExtensionsList; export type HistoryItem = HistoryItemWithoutId & { id: number }; @@ -190,6 +195,7 @@ export enum MessageType { QUIT = 'quit', GEMINI = 'gemini', COMPRESSION = 'compression', + EXTENSIONS_LIST = 'extensions_list', } // Simplified message structure for internal feedback diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index af2264a89a..0fcbc0a61c 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -115,7 +115,11 @@ export interface GeminiCLIExtension { version: string; isActive: boolean; path: string; + source?: string; + type?: 'git' | 'local' | 'link'; + ref?: string; } + export interface FileFilteringOptions { respectGitIgnore: boolean; respectGeminiIgnore: boolean;