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.
This commit is contained in:
Jerop Kipruto
2026-03-05 22:34:49 -05:00
parent d08450b0c2
commit 1d40ea6402
11 changed files with 451 additions and 3 deletions

View File

@@ -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,

View File

@@ -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<typeof vi.fn>).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',
]);
});
});

View File

@@ -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<void> => {
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);
},
};

View File

@@ -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<HistoryItemDisplayProps> = ({
showDescriptions={itemForDisplay.showDescriptions}
/>
)}
{itemForDisplay.type === 'features_list' && (
<FeaturesList features={itemForDisplay.features} />
)}
{itemForDisplay.type === 'agents_list' && (
<AgentsStatus
agents={itemForDisplay.agents}

View File

@@ -415,7 +415,7 @@ describe('SettingsDialog', () => {
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(
<KeypressProvider>
<SettingsDialog
settings={featureSettings}
onSelect={onSelect}
availableTerminalHeight={100}
/>
</KeypressProvider>,
);
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: {

View File

@@ -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]);

View File

@@ -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 && (
<Text
color={
item.stage === FeatureStage.Deprecated
? theme.status.error
: item.stage === FeatureStage.Beta
? theme.text.accent
: theme.status.warning
}
>
{' '}
[{item.stage}]{' '}
</Text>
)}
{item.scopeMessage && (
<Text color={theme.text.secondary}>
{' '}

View File

@@ -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('<FeaturesList />', () => {
it('renders correctly with features', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<FeaturesList features={mockFeatures} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with no features', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<FeaturesList features={[]} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});

View File

@@ -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<FeaturesListProps> = ({ features }) => {
if (features.length === 0) {
return (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.text.primary}> No features found</Text>
</Box>
);
}
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 (
<Box flexDirection="column" marginBottom={1}>
<Box flexDirection="row" marginBottom={1}>
<Text bold color={stageColor}>
</Text>
<Box marginLeft={1}>
<Text bold color={theme.text.primary}>
{title}
</Text>
<Text color={theme.text.secondary}>
{' '}
({sectionFeatures.length})
</Text>
</Box>
</Box>
{/* Table Header */}
<Box
flexDirection="row"
paddingX={1}
borderStyle="single"
borderTop={true}
borderBottom={true}
borderLeft={false}
borderRight={false}
borderColor={theme.border.default}
>
<Box width={colWidths.feature}>
<Text bold color={theme.text.secondary}>
FEATURE
</Text>
</Box>
<Box width={colWidths.status}>
<Text bold color={theme.text.secondary}>
ENABLED
</Text>
</Box>
<Box width={colWidths.since}>
<Text bold color={theme.text.secondary}>
SINCE
</Text>
</Box>
<Box width={colWidths.until}>
<Text bold color={theme.text.secondary}>
UNTIL
</Text>
</Box>
</Box>
{/* Table Rows */}
{sectionFeatures.map((feature) => (
<Box
key={feature.key}
flexDirection="column"
paddingX={1}
paddingTop={1}
>
<Box flexDirection="row">
<Box width={colWidths.feature}>
<Text bold color={theme.text.accent}>
{feature.key}
</Text>
</Box>
<Box width={colWidths.status}>
<Box flexDirection="row">
<Text>{feature.enabled ? '🟢 ' : '🔴 '}</Text>
<Text
color={
feature.enabled
? theme.status.success
: theme.status.error
}
>
{feature.enabled ? 'true' : 'false'}
</Text>
</Box>
</Box>
<Box width={colWidths.since}>
<Text color={theme.text.secondary}>{feature.since || '—'}</Text>
</Box>
<Box width={colWidths.until}>
<Text color={theme.text.secondary}>{feature.until || '—'}</Text>
</Box>
</Box>
{feature.description && (
<Box marginLeft={2} marginBottom={1}>
<Text dimColor color={theme.text.primary}>
{feature.description}
</Text>
</Box>
)}
</Box>
))}
</Box>
);
};
return (
<Box flexDirection="column" marginBottom={1}>
{renderSection('Alpha Features', alphaFeatures, theme.status.error)}
{renderSection('Beta Features', betaFeatures, theme.status.warning)}
{renderSection(
'Deprecated Features',
deprecatedFeatures,
theme.text.secondary,
)}
<Box flexDirection="row" marginTop={1} paddingX={1}>
<Text color={theme.text.secondary}>
💡 Use{' '}
<Text bold color={theme.text.accent}>
/settings
</Text>{' '}
to enable or disable features. You can also use stage toggles like{' '}
<Text bold color={theme.text.accent}>
allAlpha=true
</Text>{' '}
or{' '}
<Text bold color={theme.text.accent}>
allBeta=false
</Text>{' '}
to toggle entire stages.
</Text>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,43 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<FeaturesList /> > 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[`<FeaturesList /> > renders correctly with no features 1`] = `
" No features found
"
`;

View File

@@ -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',