diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index f867f84c80..15b7caaff1 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -31,6 +31,7 @@ import { docsCommand } from '../ui/commands/docsCommand.js'; import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; +import { featuresCommand } from '../ui/commands/featuresCommand.js'; import { footerCommand } from '../ui/commands/footerCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js'; @@ -119,6 +120,7 @@ export class BuiltinCommandLoader implements ICommandLoader { }, ] : [extensionsCommand(this.config?.getEnableExtensionReloading())]), + featuresCommand, helpCommand, footerCommand, shortcutsCommand, diff --git a/packages/cli/src/ui/commands/featuresCommand.test.ts b/packages/cli/src/ui/commands/featuresCommand.test.ts new file mode 100644 index 0000000000..6e86887b55 --- /dev/null +++ b/packages/cli/src/ui/commands/featuresCommand.test.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, type vi } from 'vitest'; +import { featuresCommand } from './featuresCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { MessageType, type HistoryItemFeaturesList } from '../types.js'; +import { FeatureStage } from '@google/gemini-cli-core'; + +describe('featuresCommand', () => { + it('should display an error if the feature gate is unavailable', async () => { + const mockContext = createMockCommandContext({ + services: { + config: { + getFeatureGate: () => undefined, + }, + }, + }); + + if (!featuresCommand.action) throw new Error('Action not defined'); + await featuresCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: 'Could not retrieve feature gate.', + }); + }); + + it('should list alpha, beta, and deprecated features', async () => { + const mockFeatures = [ + { key: 'alphaFeat', enabled: false, stage: FeatureStage.Alpha }, + { key: 'betaFeat', enabled: true, stage: FeatureStage.Beta }, + { key: 'deprecatedFeat', enabled: false, stage: FeatureStage.Deprecated }, + { key: 'gaFeat', enabled: true, stage: FeatureStage.GA }, + ]; + + const mockContext = createMockCommandContext({ + services: { + config: { + getFeatureGate: () => ({ + getFeatureInfo: () => mockFeatures, + }), + }, + }, + }); + + if (!featuresCommand.action) throw new Error('Action not defined'); + await featuresCommand.action(mockContext, ''); + + const [message] = (mockContext.ui.addItem as ReturnType).mock + .calls[0]; + const featuresList = message as HistoryItemFeaturesList; + expect(featuresList.type).toBe(MessageType.FEATURES_LIST); + expect(featuresList.features).toHaveLength(3); + expect(featuresList.features.map((f) => f.key)).toEqual([ + 'alphaFeat', + 'betaFeat', + 'deprecatedFeat', + ]); + }); +}); diff --git a/packages/cli/src/ui/commands/featuresCommand.ts b/packages/cli/src/ui/commands/featuresCommand.ts new file mode 100644 index 0000000000..87fa965d43 --- /dev/null +++ b/packages/cli/src/ui/commands/featuresCommand.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type CommandContext, + type SlashCommand, + CommandKind, +} from './types.js'; +import { MessageType, type HistoryItemFeaturesList } from '../types.js'; +import { FeatureStage } from '@google/gemini-cli-core'; + +export const featuresCommand: SlashCommand = { + name: 'features', + altNames: ['feature'], + description: 'List alpha, beta, and deprecated Gemini CLI features.', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: async (context: CommandContext): Promise => { + const featureGate = context.services.config?.getFeatureGate(); + if (!featureGate) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Could not retrieve feature gate.', + }); + return; + } + + const allFeatures = featureGate.getFeatureInfo(); + const filteredFeatures = allFeatures.filter( + (f) => + f.stage === FeatureStage.Alpha || + f.stage === FeatureStage.Beta || + f.stage === FeatureStage.Deprecated, + ); + + const featuresListItem: HistoryItemFeaturesList = { + type: MessageType.FEATURES_LIST, + features: filteredFeatures, + }; + + context.ui.addItem(featuresListItem); + }, +}; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index f40dcf9dc9..314070b2ac 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -29,6 +29,7 @@ import { ExtensionsList } from './views/ExtensionsList.js'; import { getMCPServerStatus } from '@google/gemini-cli-core'; import { ToolsList } from './views/ToolsList.js'; import { SkillsList } from './views/SkillsList.js'; +import { FeaturesList } from './views/FeaturesList.js'; import { AgentsStatus } from './views/AgentsStatus.js'; import { McpStatus } from './views/McpStatus.js'; import { ChatList } from './views/ChatList.js'; @@ -205,6 +206,9 @@ export const HistoryItemDisplay: React.FC = ({ showDescriptions={itemForDisplay.showDescriptions} /> )} + {itemForDisplay.type === 'features_list' && ( + + )} {itemForDisplay.type === 'agents_list' && ( ', () => { + it('renders correctly with features', () => { + const { lastFrame } = renderWithProviders( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly with no features', () => { + const { lastFrame } = renderWithProviders(); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/views/FeaturesList.tsx b/packages/cli/src/ui/components/views/FeaturesList.tsx new file mode 100644 index 0000000000..93855eeb18 --- /dev/null +++ b/packages/cli/src/ui/components/views/FeaturesList.tsx @@ -0,0 +1,174 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import { type FeatureInfo } from '../../types.js'; +import { FeatureStage } from '@google/gemini-cli-core'; + +interface FeaturesListProps { + features: FeatureInfo[]; +} + +export const FeaturesList: React.FC = ({ features }) => { + if (features.length === 0) { + return ( + + No features found + + ); + } + + const alphaFeatures = features.filter((f) => f.stage === FeatureStage.Alpha); + const betaFeatures = features.filter((f) => f.stage === FeatureStage.Beta); + const deprecatedFeatures = features.filter( + (f) => f.stage === FeatureStage.Deprecated, + ); + + // Column widths as percentages/proportions + const colWidths = { + feature: '40%', + status: '20%', + since: '20%', + until: '20%', + }; + + const renderSection = ( + title: string, + sectionFeatures: FeatureInfo[], + stageColor: string, + ) => { + if (sectionFeatures.length === 0) return null; + + return ( + + + + ┃ + + + + {title} + + + {' '} + ({sectionFeatures.length}) + + + + + {/* Table Header */} + + + + FEATURE + + + + + STATUS + + + + + SINCE + + + + + UNTIL + + + + + {/* Table Rows */} + {sectionFeatures.map((feature) => ( + + + + + {feature.key} + + + + + {feature.enabled ? '🟢 ' : '🔴 '} + + {feature.enabled ? 'Enabled' : 'Disabled'} + + + + + {feature.since || '—'} + + + {feature.until || '—'} + + + {feature.description && ( + + + {feature.description} + + + )} + + ))} + + ); + }; + + return ( + + {renderSection('Alpha Features', alphaFeatures, theme.status.error)} + {renderSection('Beta Features', betaFeatures, theme.status.warning)} + {renderSection( + 'Deprecated Features', + deprecatedFeatures, + theme.text.secondary, + )} + + + + 💡 Use{' '} + + /settings + {' '} + to enable or disable features. You can also use stage toggles like{' '} + + allAlpha=true + {' '} + or{' '} + + allBeta=false + {' '} + to toggle entire stages. + + + + ); +}; diff --git a/packages/cli/src/ui/components/views/__snapshots__/FeaturesList.test.tsx.snap b/packages/cli/src/ui/components/views/__snapshots__/FeaturesList.test.tsx.snap new file mode 100644 index 0000000000..42b61782a5 --- /dev/null +++ b/packages/cli/src/ui/components/views/__snapshots__/FeaturesList.test.tsx.snap @@ -0,0 +1,43 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders correctly with features 1`] = ` +"┃ Alpha Features (1) + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + FEATURE STATUS SINCE UNTIL +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + alphaFeat 🔴 Disabled 0.30.0 — + An alpha feature. + + +┃ Beta Features (1) + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + FEATURE STATUS SINCE UNTIL +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + betaFeat 🟢 Enabled 0.29.0 — + A beta feature. + + +┃ Deprecated Features (1) + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + FEATURE STATUS SINCE UNTIL +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + deprecatedFeat 🔴 Disabled 0.28.0 0.31.0 + A deprecated feature. + + + + 💡 Use /settings to enable or disable features. You can also use stage toggles like allAlpha=true or allBeta=false to + toggle entire stages. +" +`; + +exports[` > renders correctly with no features 1`] = ` +" No features found +" +`; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index c9910179a5..3e376e50f5 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -15,13 +15,14 @@ import { type SkillDefinition, type AgentDefinition, type ApprovalMode, + type FeatureInfo, CoreToolCallStatus, checkExhaustive, } from '@google/gemini-cli-core'; import type { PartListUnion } from '@google/genai'; import { type ReactNode } from 'react'; -export type { ThoughtSummary, SkillDefinition }; +export type { ThoughtSummary, SkillDefinition, FeatureInfo }; export enum AuthState { // Attempting to authenticate or re-authenticate @@ -288,6 +289,11 @@ export type HistoryItemSkillsList = HistoryItemBase & { showDescriptions: boolean; }; +export type HistoryItemFeaturesList = HistoryItemBase & { + type: 'features_list'; + features: FeatureInfo[]; +}; + export type AgentDefinitionJson = Pick< AgentDefinition, 'name' | 'displayName' | 'description' | 'kind' @@ -374,6 +380,7 @@ export type HistoryItemWithoutId = | HistoryItemExtensionsList | HistoryItemToolsList | HistoryItemSkillsList + | HistoryItemFeaturesList | HistoryItemAgentsList | HistoryItemMcpStatus | HistoryItemChatList @@ -399,6 +406,7 @@ export enum MessageType { EXTENSIONS_LIST = 'extensions_list', TOOLS_LIST = 'tools_list', SKILLS_LIST = 'skills_list', + FEATURES_LIST = 'features_list', AGENTS_LIST = 'agents_list', MCP_STATUS = 'mcp_status', CHAT_LIST = 'chat_list', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d70ee60d7d..2e4883d947 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1126,6 +1126,13 @@ export class Config implements McpContext { ); } + /** + * Returns the feature gate for querying feature status. + */ + getFeatureGate(): FeatureGate { + return this.featureGate; + } + isInitialized(): boolean { return this.initialized; } diff --git a/packages/core/src/config/features.test.ts b/packages/core/src/config/features.test.ts index b8b190fe6f..3180462738 100644 --- a/packages/core/src/config/features.test.ts +++ b/packages/core/src/config/features.test.ts @@ -100,6 +100,48 @@ describe('FeatureGate', () => { expect(gate.enabled('testGA')).toBe(true); }); + it('should return feature info with metadata', () => { + const gate = DefaultFeatureGate.deepCopy(); + gate.add({ + feat1: [ + { + preRelease: FeatureStage.Alpha, + since: '0.1.0', + description: 'Feature 1', + }, + ], + feat2: [ + { + preRelease: FeatureStage.Beta, + since: '0.2.0', + until: '0.3.0', + description: 'Feature 2', + }, + ], + }); + + const info = gate.getFeatureInfo(); + const feat1 = info.find((f) => f.key === 'feat1'); + const feat2 = info.find((f) => f.key === 'feat2'); + + expect(feat1).toEqual({ + key: 'feat1', + enabled: false, + stage: FeatureStage.Alpha, + since: '0.1.0', + until: undefined, + description: 'Feature 1', + }); + expect(feat2).toEqual({ + key: 'feat2', + enabled: true, + stage: FeatureStage.Beta, + since: '0.2.0', + until: '0.3.0', + description: 'Feature 2', + }); + }); + it('should respect allAlpha/allBeta toggles', () => { const gate = DefaultFeatureGate.deepCopy(); gate.add({ diff --git a/packages/core/src/config/features.ts b/packages/core/src/config/features.ts index d8218d7e3a..a0fe00b6e9 100644 --- a/packages/core/src/config/features.ts +++ b/packages/core/src/config/features.ts @@ -67,6 +67,18 @@ export interface FeatureSpec { description?: string; } +/** + * FeatureInfo provides a summary of a feature's current state and metadata. + */ +export interface FeatureInfo { + key: string; + enabled: boolean; + stage: FeatureStage; + since?: string; + until?: string; + description?: string; +} + /** * FeatureGate provides a read-only interface to query feature status. */ @@ -79,6 +91,10 @@ export interface FeatureGate { * Returns all known feature keys. */ knownFeatures(): string[]; + /** + * Returns all features with their status and metadata. + */ + getFeatureInfo(): FeatureInfo[]; /** * Returns a mutable copy of the current gate. */ @@ -189,6 +205,22 @@ class FeatureGateImpl implements MutableFeatureGate { return Array.from(this.specs.keys()); } + getFeatureInfo(): FeatureInfo[] { + return Array.from(this.specs.entries()) + .map(([key, specs]) => { + const latestSpec = specs[specs.length - 1]; + return { + key, + enabled: this.enabled(key), + stage: latestSpec.preRelease, + since: latestSpec.since, + until: latestSpec.until, + description: latestSpec.description, + }; + }) + .sort((a, b) => a.key.localeCompare(b.key)); + } + deepCopy(): MutableFeatureGate { const copy = new FeatureGateImpl(); copy.specs = new Map(