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' && ( { await waitFor(() => { // Should wrap to last setting (without relying on exact bullet character) - expect(lastFrame()).toContain('Hook Notifications'); + expect(lastFrame()).toContain('Zed Integration'); }); unmount(); @@ -753,6 +753,33 @@ describe('SettingsDialog', () => { }); describe('Specific Settings Behavior', () => { + it('should render feature stage badges', async () => { + const featureSettings = createMockSettings({ + 'features.allAlpha': false, + 'features.allBeta': true, + }); + + const onSelect = vi.fn(); + const { lastFrame, stdin } = render( + + + , + ); + + act(() => { + stdin.write('Plan'); + }); + + await waitFor(() => { + expect(lastFrame()).toContain('Plan Mode'); + expect(lastFrame()).toContain('[ALPHA]'); + }); + }); + it('should show correct display values for settings with different states', async () => { const settings = createMockSettings({ user: { diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index 23e8a55a7d..3283fc716f 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -33,7 +33,7 @@ import { type SettingsValue, TOGGLE_TYPES, } from '../../config/settingsSchema.js'; -import { debugLogger } from '@google/gemini-cli-core'; +import { debugLogger, FeatureDefinitions } from '@google/gemini-cli-core'; import { useSearchBuffer } from '../hooks/useSearchBuffer.js'; import { @@ -244,6 +244,14 @@ export function SettingsDialog({ // The inline editor needs a string but non primitive settings like Arrays and Objects exist const editValue = getEditValue(type, rawValue); + let stage: string | undefined; + const definitionKey = key.replace(/^features\./, ''); + if (key.startsWith('features.') && FeatureDefinitions[definitionKey]) { + const specs = FeatureDefinitions[definitionKey]; + const latest = specs[specs.length - 1]; + stage = latest.preRelease; + } + return { key, label: definition?.label || key, @@ -254,6 +262,7 @@ export function SettingsDialog({ scopeMessage, rawValue, editValue, + stage, }; }); }, [settingKeys, selectedScope, settings]); diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx index 05cef4fcf2..eef22fd2c8 100644 --- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx +++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx @@ -26,6 +26,7 @@ import { import { useKeypress, type Key } from '../../hooks/useKeypress.js'; import { keyMatchers, Command } from '../../keyMatchers.js'; import { formatCommand } from '../../utils/keybindingUtils.js'; +import { FeatureStage } from '@google/gemini-cli-core'; /** * Represents a single item in the settings dialog. @@ -49,6 +50,8 @@ export interface SettingsDialogItem { rawValue?: SettingsValue; /** Optional pre-formatted edit buffer value for complex types */ editValue?: string; + /** Feature stage (e.g. ALPHA, BETA) */ + stage?: string; } /** @@ -553,6 +556,20 @@ export function BaseSettingsDialog({ color={isActive ? theme.ui.focus : theme.text.primary} > {item.label} + {item.stage && item.stage !== FeatureStage.GA && ( + + {' '} + [{item.stage}]{' '} + + )} {item.scopeMessage && ( {' '} diff --git a/packages/cli/src/ui/components/views/FeaturesList.test.tsx b/packages/cli/src/ui/components/views/FeaturesList.test.tsx new file mode 100644 index 0000000000..9d98b34a1e --- /dev/null +++ b/packages/cli/src/ui/components/views/FeaturesList.test.tsx @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { FeaturesList } from './FeaturesList.js'; +import { type FeatureInfo } from '../../types.js'; +import { FeatureStage } from '@google/gemini-cli-core'; +import { renderWithProviders } from '../../../test-utils/render.js'; + +const mockFeatures: FeatureInfo[] = [ + { + key: 'alphaFeat', + enabled: false, + stage: FeatureStage.Alpha, + since: '0.30.0', + description: 'An alpha feature.', + }, + { + key: 'betaFeat', + enabled: true, + stage: FeatureStage.Beta, + since: '0.29.0', + description: 'A beta feature.', + }, + { + key: 'deprecatedFeat', + enabled: false, + stage: FeatureStage.Deprecated, + since: '0.28.0', + until: '0.31.0', + description: 'A deprecated feature.', + }, +]; + +describe('', () => { + it('renders correctly with features', async () => { + const { lastFrame, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly with no features', async () => { + const { lastFrame, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + 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..e4e419a373 --- /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 + + + + + ENABLED + + + + + SINCE + + + + + UNTIL + + + + + {/* Table Rows */} + {sectionFeatures.map((feature) => ( + + + + + {feature.key} + + + + + {feature.enabled ? '🟢 ' : '🔴 '} + + {feature.enabled ? 'true' : 'false'} + + + + + {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..809c1dbbe0 --- /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 ENABLED SINCE UNTIL +──────────────────────────────────────────────────────────────────────────────────────────────────── + + alphaFeat 🔴 false 0.30.0 — + An alpha feature. + + +┃ Beta Features (1) + +──────────────────────────────────────────────────────────────────────────────────────────────────── + FEATURE ENABLED SINCE UNTIL +──────────────────────────────────────────────────────────────────────────────────────────────────── + + betaFeat 🟢 true 0.29.0 — + A beta feature. + + +┃ Deprecated Features (1) + +──────────────────────────────────────────────────────────────────────────────────────────────────── + FEATURE ENABLED SINCE UNTIL +──────────────────────────────────────────────────────────────────────────────────────────────────── + + deprecatedFeat 🔴 false 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',