From 1d40ea6402756c8846ac2952cbc670a0984f2d0e Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Thu, 5 Mar 2026 22:34:49 -0500 Subject: [PATCH] feat(cli): add /features command and lifecycle visualization Add /features slash command to list Alpha, Beta, and Deprecated features with their status, version info, and descriptions. Add FeaturesList UI component with grouped table display by stage. Update SettingsDialog to show feature stage badges ([ALPHA], [BETA], [DEPRECATED]) next to feature toggle labels. --- .../cli/src/services/BuiltinCommandLoader.ts | 2 + .../src/ui/commands/featuresCommand.test.ts | 64 +++++++ .../cli/src/ui/commands/featuresCommand.ts | 46 +++++ .../src/ui/components/HistoryItemDisplay.tsx | 4 + .../src/ui/components/SettingsDialog.test.tsx | 29 ++- .../cli/src/ui/components/SettingsDialog.tsx | 11 +- .../components/shared/BaseSettingsDialog.tsx | 17 ++ .../ui/components/views/FeaturesList.test.tsx | 54 ++++++ .../src/ui/components/views/FeaturesList.tsx | 174 ++++++++++++++++++ .../__snapshots__/FeaturesList.test.tsx.snap | 43 +++++ packages/cli/src/ui/types.ts | 10 +- 11 files changed, 451 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/ui/commands/featuresCommand.test.ts create mode 100644 packages/cli/src/ui/commands/featuresCommand.ts create mode 100644 packages/cli/src/ui/components/views/FeaturesList.test.tsx create mode 100644 packages/cli/src/ui/components/views/FeaturesList.tsx create mode 100644 packages/cli/src/ui/components/views/__snapshots__/FeaturesList.test.tsx.snap 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',