Compare commits

...

5 Commits

Author SHA1 Message Date
Jerop Kipruto fd3632c007 fix: remove stale code re-introduced during rebase
Remove syncPlanModeTools(), getExperimentalZedIntegration(), and all
zedIntegration references that were re-introduced by conflict resolution
but had already been deleted on main. Fix stale doc path in package.json
lint-staged config.
2026-03-05 21:12:34 -05:00
Jerop Kipruto 4663dff8e2 feat(core): implement lifecycle tracking issues for features
- Add GitHub issue template for tracking feature maturity (Alpha/Beta/GA).
- Add issueUrl field to FeatureSpec metadata.
- Update /features UI to display tracking issue links.
- Update documentation with requirement for lifecycle trackers.
2026-03-05 19:26:05 -05:00
Jerop Kipruto f1bf34ceee docs: document /features command 2026-03-05 19:26:05 -05:00
Jerop Kipruto 0640faa4c6 feat: add /features command and lifecycle visibility 2026-03-05 19:26:05 -05:00
Jerop Kipruto 04f22a51b1 feat(core): implement feature lifecycle management (Alpha, Beta, GA) 2026-03-05 19:25:39 -05:00
31 changed files with 2151 additions and 178 deletions
+3
View File
@@ -4,6 +4,9 @@
"extensionReloading": true,
"modelSteering": true
},
"features": {
"allAlpha": true
},
"general": {
"devtools": true
}
@@ -0,0 +1,67 @@
name: 'Feature Lifecycle Tracker'
description: 'Track a feature through Alpha, Beta, and GA stages'
labels:
- 'lifecycle/alpha'
- 'status/need-triage'
body:
- type: 'markdown'
attributes:
value: |-
## 🚀 Feature Overview
Provide a concise description of what this feature does and why it is being introduced.
- type: 'textarea'
id: 'spec'
attributes:
label: 'Feature Specification'
description: 'Link to design docs, RFCs, or PRs.'
validations:
required: true
- type: 'markdown'
attributes:
value: |-
## 🗺️ Progression Roadmap
*Maintainers: Update this table and the checkboxes below as the feature matures.*
| Stage | Targeted Version | Actual Version | Date |
| :--- | :--- | :--- | :--- |
| **Alpha** | 0.x.x | | |
| **Beta** | 0.x.x | | |
| **GA** | 0.x.x | | |
- type: 'checkboxes'
id: 'alpha-checklist'
attributes:
label: 'Alpha Requirements (Disabled by Default)'
options:
- label: 'Feature gated by Alpha flag in `packages/core/src/config/features.ts`'
- label: 'Initial implementation merged'
- label: 'Basic unit and integration tests'
- type: 'checkboxes'
id: 'beta-checklist'
attributes:
label: 'Beta Requirements (Enabled by Default)'
options:
- label: 'Feature stable (no major architectural changes) for >= 2 minor versions'
- label: 'Comprehensive documentation in `docs/`'
- label: 'Telemetry/metrics implemented and verified'
- label: 'Promotion PR merged (stage moved to Beta)'
- type: 'checkboxes'
id: 'ga-checklist'
attributes:
label: 'GA Requirements (Locked to Enabled)'
options:
- label: 'Stable for >= 4 minor versions since Beta'
- label: 'No high-priority regressions reported'
- label: 'Performance audit complete'
- label: 'Migration plan (if applicable) documented'
- label: 'PR merged (feature gate removed, code integrated into core)'
- type: 'textarea'
id: 'feedback'
attributes:
label: 'Key Feedback & Bug Links'
description: 'List major bug reports or user feedback threads here.'
+220
View File
@@ -0,0 +1,220 @@
# Feature Lifecycle
Gemini CLI uses a **Feature Lifecycle** management system to handle the
introduction, maturation, and deprecation of new and optional features.
- [Feature Lifecycle](#feature-lifecycle)
- [Feature Stages](#feature-stages)
- [Configuration](#configuration)
- [Precedence and Reconciliation](#precedence-and-reconciliation)
- [Available Features](#available-features)
- [Managing Feature Lifecycle](#managing-feature-lifecycle)
- [Adding a Feature](#adding-a-feature)
- [Promoting a Feature](#promoting-a-feature)
- [Deprecating a Feature](#deprecating-a-feature)
- [Removing a Feature](#removing-a-feature)
- [Relevant Documentation](#relevant-documentation)
## Feature Stages
Features progress through the following stages:
| Stage | Default | UI Badge | Description |
| -------------- | -------- | -------------- | ------------------------------------------------------------------------------------------- |
| **ALPHA** | Disabled | `[ALPHA]` | Early-access features. May be unstable, change significantly, or be removed without notice. |
| **BETA** | Enabled | `[BETA]` | Features that are well-tested and considered stable. Can be disabled if issues arise. |
| **GA** | Enabled | - | Stable features that are part of the core product. Cannot be disabled. |
| **DEPRECATED** | Disabled | `[DEPRECATED]` | Features scheduled for removal. Using them triggers a warning. |
## Configuration
The feature lifecycle can be configured in several ways:
1. **`/features` Command**: Use the `/features` (or `/feature`) command
directly in the CLI to list all Alpha, Beta, and Deprecated features. This
view shows the maturity stage, enablement status, and metadata like the
version it was introduced (`since`) and when it is scheduled for removal
(`until`).
2. **`settings.json`**: Use the `features` object to toggle specific features
or entire stages.
- `features.allAlpha`: Enable/disable all Alpha features.
- `features.allBeta`: Enable/disable all Beta features.
- `features.<featureName>`: Toggle an individual feature.
3. **CLI Flag**: Use `--feature-gates="feat1=true,feat2=false"` for runtime
overrides.
4. **Environment Variable**: Set
`GEMINI_FEATURE_GATES="feat1=true,feat2=false"`.
The stability of each feature is visually indicated in the
[`/settings` UI](/docs/cli/settings.md) with colored badges. **GA** features are
considerd stable and look identical to standard settings.
## Lifecycle tracking issues
Every feature managed under this system must have a corresponding **Lifecycle
Tracking Issue** on GitHub. These issues act as a living roadmap and a public
feedback loop for the feature's progression through Alpha, Beta, and GA stages.
You can find the link to a feature's tracking issue in the following ways:
1. **`/features` Command:** The tracker URL is displayed alongside the metadata
for each feature.
2. **`FeatureDefinitions`:** The `issueUrl` is defined in
`packages/core/src/config/features.ts`.
Maintainers use these issues to document promotion criteria, link related bug
reports, and collect user feedback before moving a feature to the next stage.
### Precedence and reconciliation
When determining if a feature is enabled, the system follows this order of
precedence (highest priority first):
1. **Global Lock**: Features in the **GA** stage are locked to `true` and cannot
be disabled.
2. **CLI Flags & Environment Variables**: Runtime overrides (`--feature-gates`
or `GEMINI_FEATURE_GATES`) override persistent settings.
3. **Individual Toggle**: Specific feature toggles in `settings.json` (e.g.,
`"features": { "plan": true }`).
4. **Meta Toggles**: Stage-wide toggles in `settings.json` (`allAlpha` or
`allBeta`). For example, if `allAlpha` is `true`, all Alpha features are
enabled unless specifically disabled by an individual toggle.
5. **Stage Default**: The inherent default for the feature's current stage
(Alpha: Disabled, Beta/GA: Enabled).
For more details on persistent configuration, see the [Configuration guide].
## Available Features
<!-- FEATURES-AUTOGEN:START -->
| Feature | Stage | Default | Since | Description |
| --------------------- | ----- | -------- | ------ | ----------------------------------------------------------- |
| `enableAgents` | ALPHA | Disabled | 0.30.0 | Enable local and remote subagents. |
| `extensionConfig` | BETA | Enabled | 0.30.0 | Enable requesting and fetching of extension settings. |
| `extensionManagement` | BETA | Enabled | 0.30.0 | Enable extension management features. |
| `extensionRegistry` | ALPHA | Disabled | 0.30.0 | Enable extension registry explore UI. |
| `extensionReloading` | ALPHA | Disabled | 0.30.0 | Enables extension loading/unloading within the CLI session. |
| `jitContext` | ALPHA | Disabled | 0.30.0 | Enable Just-In-Time (JIT) context loading. |
| `plan` | ALPHA | Disabled | 0.30.0 | Enable planning features (Plan Mode and tools). |
| `toolOutputMasking` | BETA | Enabled | 0.30.0 | Enables tool output masking to save tokens. |
| `useOSC52Paste` | ALPHA | Disabled | 0.30.0 | Use OSC 52 sequence for pasting. |
<!-- FEATURES-AUTOGEN:END -->
## Managing Feature Lifecycle
Maintaining a feature involves promoting it through stages or eventually
deprecating and removing it.
### Adding a feature
To add a new feature under lifecycle management:
1. **Create a Tracker Issue:** Use the **Feature Lifecycle Tracker** template
on GitHub to create a new issue. This issue will track the feature from
Alpha through GA.
2. **Define the Feature:** Add a new entry to [`FeatureDefinitions`] in
[`features.ts`].
```typescript
export const FeatureDefinitions: Record<string, FeatureSpec[]> = {
// ... existing features
myNewFeature: [
{
lockToDefault: false,
preRelease: FeatureStage.Alpha,
since: '0.31.0',
description: 'Description of my new feature.',
issueUrl: 'https://github.com/google-gemini/gemini-cli/issues/123',
},
],
};
```
_Note: The `default` field is optional. If omitted, it defaults to `false`
for Alpha/Deprecated and `true` for Beta/GA._
3. **Expose in Settings**: Add the feature to the `features` object in
[`settingsSchema.ts`]. This ensures it appears in the `/settings` UI and is
validated.
```typescript
features: {
// ...
properties: {
// ...
myNewFeature: {
type: 'boolean',
label: 'My New Feature',
category: 'Features',
requiresRestart: true, // or false
description: 'Description of my new feature.',
showInDialog: true,
},
},
},
```
4. **Use the Feature**: In your code, check if the feature is enabled using the
`Config` object.
```typescript
if (this.config.isFeatureEnabled('myNewFeature')) {
// Feature logic
}
```
### Promoting a feature
When a feature is ready for the next stage:
1. **Update the Tracker:** Review the requirements in the lifecycle tracker
issue. Once met, update the roadmap table in the issue description and post
a comment announcing the promotion.
2. **Update [`features.ts`]**: Add a new `FeatureSpec` to the feature's array.
- **To BETA**: Set `preRelease: FeatureStage.Beta` (Defaults to `true`).
- **To GA**: Set `preRelease: FeatureStage.GA` (Defaults to `true` and
locked).
- Update the `since` version.
3. **Update [`settingsSchema.ts`]**: Update the `label` and `description` if
necessary.
4. **GA Cleanup**: Once a feature is GA and no longer optional, remove the
feature gate check from the code and make it a core part of the logic.
### Deprecating a Feature
This stage is for **Beta** and **GA** features scheduled for removal.
1. **Update [`features.ts`]**: Add a new `FeatureSpec` with
`preRelease: FeatureStage.Deprecated`.
- Optionally set `default: true` if it should remain enabled during
deprecation (it defaults to `false`).
- Optionally set an `until` version to indicate when it will be removed.
2. **Update [`settingsSchema.ts`]**: Update the description to notify users of
the deprecation and suggest alternatives.
### Removing a Feature
> **Alpha** features can be removed without formal deprecation. **Beta** and
> **GA** features should typically go through a
> [deprecation period](#deprecating-a-feature) first.
To completely remove a feature:
1. **Cleanup Code**: Remove all logic and tests associated with the feature.
2. **Update [`features.ts`]**: Remove the feature from [`FeatureDefinitions`].
3. **Update [`settingsSchema.ts`]**: Remove the feature from the `features`
object.
4. **Legacy Settings**: If the feature had a legacy `experimental` flag, ensure
its migration logic is cleaned up in [`config.ts`].
## Relevant Documentation
- [Settings Reference]
- [Configuration Layers]
[Configuration guide]: /docs/get-started/configuration.md
[Settings Reference]: /docs/cli/settings.md#features
[Configuration Layers]: /docs/get-started/configuration.md#configuration-layers
[`FeatureDefinitions`]: /packages/core/src/config/features.ts
[`features.ts`]: /packages/core/src/config/features.ts
[`settingsSchema.ts`]: /packages/cli/src/config/settingsSchema.ts
[`config.ts`]: /packages/core/src/config/config.ts
+16 -12
View File
@@ -137,18 +137,6 @@ they appear in the UI.
| --------------------------------- | ------------------------------ | --------------------------------------------- | ------- |
| Auto Configure Max Old Space Size | `advanced.autoConfigureMemory` | Automatically configure Node.js memory limits | `false` |
### Experimental
| UI Label | Setting | Description | Default |
| -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router. Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
### Skills
| UI Label | Setting | Description | Default |
@@ -162,4 +150,20 @@ they appear in the UI.
| Enable Hooks | `hooksConfig.enabled` | Canonical toggle for the hooks system. When disabled, no hooks will be executed. | `true` |
| Hook Notifications | `hooksConfig.notifications` | Show visual indicators when hooks are executing. | `true` |
### Features
| UI Label | Setting | Description | Default | Stage |
| ----------------------------- | ------------------------------ | ----------------------------------------------------------- | ------- | ------- |
| Enable all Alpha features | `features.allAlpha` | Enable all Alpha features by default. | `false` | - |
| Enable all Beta features | `features.allBeta` | Enable all Beta features by default. | `true` | - |
| Tool Output Masking | `features.toolOutputMasking` | Enables tool output masking to save tokens. | `true` | `BETA` |
| Enable Agents | `features.enableAgents` | Enable local and remote subagents. | `false` | `ALPHA` |
| Extension Management | `features.extensionManagement` | Enable extension management features. | `true` | `BETA` |
| Extension Configuration | `features.extensionConfig` | Enable requesting and fetching of extension settings. | `true` | `BETA` |
| Extension Registry Explore UI | `features.extensionRegistry` | Enable extension registry explore UI. | `false` | `ALPHA` |
| Extension Reloading | `features.extensionReloading` | Enables extension loading/unloading within the CLI session. | `false` | `ALPHA` |
| JIT Context Loading | `features.jitContext` | Enable Just-In-Time (JIT) context loading. | `false` | `ALPHA` |
| Use OSC 52 Paste | `features.useOSC52Paste` | Use OSC 52 sequence for pasting. | `false` | `ALPHA` |
| Plan Mode | `features.plan` | Enable planning features (Plan Mode and tools). | `false` | `ALPHA` |
<!-- SETTINGS-AUTOGEN:END -->
+85 -106
View File
@@ -83,6 +83,16 @@ contain other project-specific files related to Gemini CLI's operation, such as:
Settings are organized into categories. All settings should be placed within
their corresponding top-level category object in your `settings.json` file.
#### Feature Lifecycle
Gemini CLI uses a feature lifecycle management system to manage experimental and
optional features. Features are categorized by their stability stage (`ALPHA`,
`BETA`, `GA`, `DEPRECATED`).
For a detailed explanation of feature stages and a list of all available
features, see the
[Feature Lifecycle documentation](/docs/cli/feature-lifecycle.md).
<!-- SETTINGS-AUTOGEN:START -->
#### `policyPaths`
@@ -953,110 +963,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Configuration for the bug report command.
- **Default:** `undefined`
#### `experimental`
- **`experimental.toolOutputMasking.enabled`** (boolean):
- **Description:** Enables tool output masking to save tokens.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.toolOutputMasking.toolProtectionThreshold`** (number):
- **Description:** Minimum number of tokens to protect from masking (most
recent tool outputs).
- **Default:** `50000`
- **Requires restart:** Yes
- **`experimental.toolOutputMasking.minPrunableTokensThreshold`** (number):
- **Description:** Minimum prunable tokens required to trigger a masking pass.
- **Default:** `30000`
- **Requires restart:** Yes
- **`experimental.toolOutputMasking.protectLatestTurn`** (boolean):
- **Description:** Ensures the absolute latest turn is never masked,
regardless of token count.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.enableAgents`** (boolean):
- **Description:** Enable local and remote subagents. Warning: Experimental
feature, uses YOLO mode for subagents
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.extensionManagement`** (boolean):
- **Description:** Enable extension management features.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.extensionConfig`** (boolean):
- **Description:** Enable requesting and fetching of extension settings.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.extensionRegistry`** (boolean):
- **Description:** Enable extension registry explore UI.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.extensionReloading`** (boolean):
- **Description:** Enables extension loading/unloading within the CLI session.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.jitContext`** (boolean):
- **Description:** Enable Just-In-Time (JIT) context loading.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.useOSC52Paste`** (boolean):
- **Description:** Use OSC 52 for pasting. This may be more robust than the
default system when using remote terminal sessions (if your terminal is
configured to allow it).
- **Default:** `false`
- **`experimental.useOSC52Copy`** (boolean):
- **Description:** Use OSC 52 for copying. This may be more robust than the
default system when using remote terminal sessions (if your terminal is
configured to allow it).
- **Default:** `false`
- **`experimental.plan`** (boolean):
- **Description:** Enable planning features (Plan Mode and tools).
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.taskTracker`** (boolean):
- **Description:** Enable task tracker tools.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.modelSteering`** (boolean):
- **Description:** Enable model steering (user hints) to guide the model
during tool execution.
- **Default:** `false`
- **`experimental.directWebFetch`** (boolean):
- **Description:** Enable web fetch behavior that bypasses LLM summarization.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.gemmaModelRouter.enabled`** (boolean):
- **Description:** Enable the Gemma Model Router. Requires a local endpoint
serving Gemma via the Gemini API using LiteRT-LM shim.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.gemmaModelRouter.classifier.host`** (string):
- **Description:** The host of the classifier.
- **Default:** `"http://localhost:9379"`
- **Requires restart:** Yes
- **`experimental.gemmaModelRouter.classifier.model`** (string):
- **Description:** The model to use for the classifier. Only tested on
`gemma3-1b-gpu-custom`.
- **Default:** `"gemma3-1b-gpu-custom"`
- **Requires restart:** Yes
#### `skills`
- **`skills.enabled`** (boolean):
@@ -1165,6 +1071,71 @@ their corresponding top-level category object in your `settings.json` file.
- **`admin.skills.enabled`** (boolean):
- **Description:** If false, disallows agent skills from being used.
- **Default:** `true`
#### `features`
- **`features.allAlpha`** (boolean):
- **Description:** Enable all Alpha features by default.
- **Default:** `false`
- **Requires restart:** Yes
- **`features.allBeta`** (boolean):
- **Description:** Enable all Beta features by default.
- **Default:** `true`
- **Requires restart:** Yes
- **`features.toolOutputMasking`** (boolean):
- **Description:** Enables tool output masking to save tokens.
- **Default:** `true`
- **Stage:** BETA
- **Requires restart:** Yes
- **`features.enableAgents`** (boolean):
- **Description:** Enable local and remote subagents.
- **Default:** `false`
- **Stage:** ALPHA
- **Requires restart:** Yes
- **`features.extensionManagement`** (boolean):
- **Description:** Enable extension management features.
- **Default:** `true`
- **Stage:** BETA
- **Requires restart:** Yes
- **`features.extensionConfig`** (boolean):
- **Description:** Enable requesting and fetching of extension settings.
- **Default:** `true`
- **Stage:** BETA
- **Requires restart:** Yes
- **`features.extensionRegistry`** (boolean):
- **Description:** Enable extension registry explore UI.
- **Default:** `false`
- **Stage:** ALPHA
- **Requires restart:** Yes
- **`features.extensionReloading`** (boolean):
- **Description:** Enables extension loading/unloading within the CLI session.
- **Default:** `false`
- **Stage:** ALPHA
- **Requires restart:** Yes
- **`features.jitContext`** (boolean):
- **Description:** Enable Just-In-Time (JIT) context loading.
- **Default:** `false`
- **Stage:** ALPHA
- **Requires restart:** Yes
- **`features.useOSC52Paste`** (boolean):
- **Description:** Use OSC 52 sequence for pasting.
- **Default:** `false`
- **Stage:** ALPHA
- **`features.plan`** (boolean):
- **Description:** Enable planning features (Plan Mode and tools).
- **Default:** `false`
- **Stage:** ALPHA
- **Requires restart:** Yes
<!-- SETTINGS-AUTOGEN:END -->
#### `mcpServers`
@@ -1350,6 +1321,10 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
is useful when running Gemini CLI in a standalone terminal while still
wanting to associate it with a specific IDE instance.
- Overrides the automatic IDE detection logic.
- **`GEMINI_FEATURE_GATES`**:
- Specifies a comma-separated list of feature key-value pairs to override the
default feature states.
- Example: `export GEMINI_FEATURE_GATES="plan=true,enableAgents=false"`
- **`GEMINI_CLI_HOME`**:
- Specifies the root directory for Gemini CLI's user-level configuration and
storage.
@@ -1551,13 +1526,17 @@ for that specific session.
- `auto_edit`: Automatically approve edit tools (replace, write_file) while
prompting for others
- `yolo`: Automatically approve all tool calls (equivalent to `--yolo`)
- `plan`: Read-only mode for tool calls (requires experimental planning to
be enabled).
- `plan`: Read-only mode for tool calls (requires planning feature to be
enabled).
> **Note:** This mode is currently under development and not yet fully
> functional.
- Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of
`--yolo` for the new unified approach.
- Example: `gemini --approval-mode auto_edit`
- **`--feature-gates <key1=val1,key2=val2,...>`**:
- A comma-separated list of feature key-value pairs to override the default
feature states for this session.
- Example: `gemini --feature-gates "plan=true,enableAgents=false"`
- **`--allowed-tools <tool1,tool2,...>`**:
- A comma-separated list of tool names that will bypass the confirmation
dialog.
+8
View File
@@ -161,6 +161,14 @@
],
"*.{json,md}": [
"prettier --write"
],
"packages/cli/src/config/settingsSchema.ts": [
"npm run docs:settings",
"git add schemas/settings.schema.json docs/reference/configuration.md docs/cli/settings.md docs/cli/feature-lifecycle.md"
],
"packages/cli/src/config/keyBindings.ts": [
"npm run docs:keybindings",
"git add docs/cli/keyboard-shortcuts.md"
]
}
}
@@ -12,7 +12,7 @@ import {
configureSpecificSetting,
getExtensionManager,
} from './utils.js';
import { loadSettings } from '../../config/settings.js';
import { loadSettings, isFeatureEnabled } from '../../config/settings.js';
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
@@ -45,10 +45,10 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
const { name, setting, scope } = args;
const settings = loadSettings(process.cwd()).merged;
if (!(settings.experimental?.extensionConfig ?? true)) {
if (!isFeatureEnabled(settings, 'extensionConfig')) {
coreEvents.emitFeedback(
'error',
'Extension configuration is currently disabled. Enable it by setting "experimental.extensionConfig" to true.',
'Extension configuration is currently disabled. Enable it by setting "features.extensionConfig" to true.',
);
await exitCli();
return;
+23 -9
View File
@@ -44,6 +44,7 @@ import {
type MergedSettings,
saveModelChange,
loadSettings,
isFeatureEnabled,
} from './settings.js';
import { loadSandboxConfig } from './sandboxConfig.js';
@@ -78,6 +79,7 @@ export interface CliArgs {
allowedTools: string[] | undefined;
acp?: boolean;
experimentalAcp?: boolean;
featureGates?: string | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
resume: string | typeof RESUME_LATEST | undefined;
@@ -182,6 +184,12 @@ export async function parseArguments(
description:
'Starts the agent in ACP mode (deprecated, use --acp instead)',
})
.option('feature-gates', {
type: 'string',
nargs: 1,
description:
'Comma-separated list of feature key-value pairs (e.g. "Foo=true,Bar=false")',
})
.option('allowed-mcp-server-names', {
type: 'array',
string: true,
@@ -328,7 +336,7 @@ export async function parseArguments(
return true;
});
if (settings.experimental?.extensionManagement) {
if (isFeatureEnabled(settings, 'extensionManagement')) {
yargsInstance.command(extensionsCommand);
}
@@ -486,7 +494,7 @@ export async function loadCliConfig(
.getExtensions()
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
const experimentalJitContext = settings.experimental?.jitContext ?? false;
const experimentalJitContext = isFeatureEnabled(settings, 'jitContext');
let memoryContent: string | HierarchicalMemory = '';
let fileCount = 0;
@@ -532,7 +540,7 @@ export async function loadCliConfig(
approvalMode = ApprovalMode.AUTO_EDIT;
break;
case 'plan':
if (!(settings.experimental?.plan ?? false)) {
if (!isFeatureEnabled(settings, 'plan')) {
debugLogger.warn(
'Approval mode "plan" is only available when experimental.plan is enabled. Falling back to "default".',
);
@@ -759,15 +767,16 @@ export async function loadCliConfig(
bugCommand: settings.advanced?.bugCommand,
model: resolvedModel,
maxSessionTurns: settings.model?.maxSessionTurns,
features: settings.features,
featureGates: argv.featureGates,
listExtensions: argv.listExtensions || false,
listSessions: argv.listSessions || false,
deleteSession: argv.deleteSession,
enabledExtensions: argv.extensions,
extensionLoader: extensionManager,
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,
enableExtensionReloading: isFeatureEnabled(settings, 'extensionReloading'),
enableAgents: isFeatureEnabled(settings, 'enableAgents'),
plan: isFeatureEnabled(settings, 'plan'),
tracker: settings.experimental?.taskTracker,
directWebFetch: settings.experimental?.directWebFetch,
planSettings: settings.general?.plan?.directory
@@ -776,9 +785,14 @@ export async function loadCliConfig(
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
experimentalJitContext: settings.experimental?.jitContext,
experimentalJitContext: isFeatureEnabled(settings, 'jitContext'),
modelSteering: settings.experimental?.modelSteering,
toolOutputMasking: settings.experimental?.toolOutputMasking,
toolOutputMasking:
(settings.features as Record<string, unknown> | undefined)?.[
'toolOutputMasking'
] !== undefined
? { enabled: isFeatureEnabled(settings, 'toolOutputMasking') }
: settings.experimental?.toolOutputMasking,
noBrowser: !!process.env['NO_BROWSER'],
summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode,
+18 -5
View File
@@ -9,7 +9,11 @@ import * as path from 'node:path';
import { stat } from 'node:fs/promises';
import chalk from 'chalk';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { type MergedSettings, SettingScope } from './settings.js';
import {
type MergedSettings,
SettingScope,
isFeatureEnabled,
} from './settings.js';
import { createHash, randomUUID } from 'node:crypto';
import { loadInstallMetadata, type ExtensionConfig } from './extension.js';
import {
@@ -323,7 +327,10 @@ Would you like to attempt to install via "git clone" instead?`,
}
await fs.promises.mkdir(destinationPath, { recursive: true });
if (this.requestSetting && this.settings.experimental.extensionConfig) {
if (
this.requestSetting &&
isFeatureEnabled(this.settings, 'extensionConfig')
) {
if (isUpdate) {
await maybePromptForSettings(
newExtensionConfig,
@@ -341,7 +348,10 @@ Would you like to attempt to install via "git clone" instead?`,
}
}
const missingSettings = this.settings.experimental.extensionConfig
const missingSettings = isFeatureEnabled(
this.settings,
'extensionConfig',
)
? await getMissingSettings(
newExtensionConfig,
extensionId,
@@ -677,7 +687,7 @@ Would you like to attempt to install via "git clone" instead?`,
let userSettings: Record<string, string> = {};
let workspaceSettings: Record<string, string> = {};
if (this.settings.experimental.extensionConfig) {
if (isFeatureEnabled(this.settings, 'extensionConfig')) {
userSettings = await getScopedEnvContents(
config,
extensionId,
@@ -697,7 +707,10 @@ Would you like to attempt to install via "git clone" instead?`,
config = resolveEnvVarsInObject(config, customEnv);
const resolvedSettings: ResolvedExtensionSetting[] = [];
if (config.settings && this.settings.experimental.extensionConfig) {
if (
config.settings &&
isFeatureEnabled(this.settings, 'extensionConfig')
) {
for (const setting of config.settings) {
const value = customEnv[setting.envVar];
let scope: 'user' | 'workspace' | undefined;
+7 -1
View File
@@ -206,7 +206,13 @@ describe('extension tests', () => {
});
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
const settings = loadSettings(tempWorkspaceDir).merged;
settings.experimental.extensionConfig = true;
if (settings.features) {
(settings.features as Record<string, unknown>)['extensionConfig'] = true;
} else {
(settings as unknown as Record<string, unknown>)['features'] = {
extensionConfig: true,
};
}
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
+48
View File
@@ -76,6 +76,8 @@ import {
LoadedSettings,
sanitizeEnvVar,
createTestMergedSettings,
isFeatureEnabled,
type MergedSettings,
} from './settings.js';
import {
FatalConfigError,
@@ -3119,4 +3121,50 @@ describe('LoadedSettings Isolation and Serializability', () => {
}).toThrow(/Maximum call stack size exceeded/);
});
});
describe('isFeatureEnabled', () => {
it('should return true if feature is enabled in "features"', () => {
const settings = {
features: { plan: true },
} as unknown as Settings; // Casting for simplicity
expect(
isFeatureEnabled(settings as unknown as MergedSettings, 'plan'),
).toBe(true);
});
it('should return false if feature is disabled in "features"', () => {
const settings = {
features: { plan: false },
} as unknown as Settings;
expect(
isFeatureEnabled(settings as unknown as MergedSettings, 'plan'),
).toBe(false);
});
it('should fallback to "experimental" if feature is not in "features"', () => {
const settings = {
experimental: { plan: true },
} as unknown as Settings;
expect(
isFeatureEnabled(settings as unknown as MergedSettings, 'plan'),
).toBe(true);
});
it('should prioritize "features" over "experimental"', () => {
const settings = {
features: { plan: false },
experimental: { plan: true },
} as unknown as Settings;
expect(
isFeatureEnabled(settings as unknown as MergedSettings, 'plan'),
).toBe(false);
});
it('should return false if neither is set', () => {
const settings = {} as unknown as Settings;
expect(
isFeatureEnabled(settings as unknown as MergedSettings, 'plan'),
).toBe(false);
});
});
});
+53
View File
@@ -18,6 +18,8 @@ import {
coreEvents,
homedir,
type AdminControlsSettings,
DefaultFeatureGate,
type FeatureGate,
} from '@google/gemini-cli-core';
import stripJsonComments from 'strip-json-comments';
import { DefaultLight } from '../ui/themes/builtin/light/default-light.js';
@@ -43,6 +45,57 @@ export {
getSettingsSchema,
};
const featureGateCache = new WeakMap<MergedSettings, FeatureGate>();
/**
* Returns true if the feature is enabled in the given settings.
* Checks "features" first, then falls back to "experimental".
*/
export function isFeatureEnabled(
settings: MergedSettings,
featureName: string,
): boolean {
let gate = featureGateCache.get(settings);
if (!gate) {
const mutableGate = DefaultFeatureGate.deepCopy();
const overrides: Record<string, boolean> = {};
// 1. Experimental (Low Priority)
const experimental = settings.experimental as Record<string, unknown>;
if (experimental) {
for (const [key, value] of Object.entries(experimental)) {
if (typeof value === 'boolean') {
overrides[key] = value;
}
}
// Handle nested toolOutputMasking legacy structure
const toolOutputMasking = experimental['toolOutputMasking'];
if (
toolOutputMasking &&
typeof toolOutputMasking === 'object' &&
'enabled' in toolOutputMasking &&
typeof (toolOutputMasking as Record<string, unknown>)['enabled'] ===
'boolean'
) {
overrides['toolOutputMasking'] = Boolean(
(toolOutputMasking as Record<string, unknown>)['enabled'],
);
}
}
// 2. Features (High Priority)
if (settings.features) {
Object.assign(overrides, settings.features);
}
mutableGate.setFromMap(overrides);
gate = mutableGate;
featureGateCache.set(settings, gate);
}
return gate.enabled(featureName);
}
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { customDeepMerge } from '../utils/deepMerge.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
+117 -3
View File
@@ -1687,7 +1687,9 @@ const SETTINGS_SCHEMA = {
category: 'Experimental',
requiresRestart: true,
default: {},
description: 'Setting to enable experimental features',
ignoreInDocs: true,
description:
'DEPRECATED: Use the "features" object instead. Setting to enable experimental features',
showInDialog: false,
properties: {
toolOutputMasking: {
@@ -1708,7 +1710,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: true,
default: true,
description: 'Enables tool output masking to save tokens.',
showInDialog: true,
showInDialog: false,
},
toolProtectionThreshold: {
type: 'number',
@@ -1825,7 +1827,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: true,
default: false,
description: 'Enable planning features (Plan Mode and tools).',
showInDialog: true,
showInDialog: false,
},
taskTracker: {
type: 'boolean',
@@ -2277,6 +2279,118 @@ const SETTINGS_SCHEMA = {
},
},
},
features: {
type: 'object',
label: 'Features',
category: 'Features',
requiresRestart: true,
default: {},
description: 'Feature Lifecycle Management settings.',
showInDialog: false,
properties: {
allAlpha: {
type: 'boolean',
label: 'Enable all Alpha features',
category: 'Features',
requiresRestart: true,
default: false,
description: 'Enable all Alpha features by default.',
showInDialog: true,
},
allBeta: {
type: 'boolean',
label: 'Enable all Beta features',
category: 'Features',
requiresRestart: true,
default: true,
description: 'Enable all Beta features by default.',
showInDialog: true,
},
toolOutputMasking: {
type: 'boolean',
label: 'Tool Output Masking',
category: 'Features',
requiresRestart: true,
default: true,
description: 'Enables tool output masking to save tokens.',
showInDialog: true,
},
enableAgents: {
type: 'boolean',
label: 'Enable Agents',
category: 'Features',
requiresRestart: true,
default: false,
description: 'Enable local and remote subagents.',
showInDialog: true,
},
extensionManagement: {
type: 'boolean',
label: 'Extension Management',
category: 'Features',
requiresRestart: true,
default: true,
description: 'Enable extension management features.',
showInDialog: true,
},
extensionConfig: {
type: 'boolean',
label: 'Extension Configuration',
category: 'Features',
requiresRestart: true,
default: true,
description: 'Enable requesting and fetching of extension settings.',
showInDialog: true,
},
extensionRegistry: {
type: 'boolean',
label: 'Extension Registry Explore UI',
category: 'Features',
requiresRestart: true,
default: false,
description: 'Enable extension registry explore UI.',
showInDialog: true,
},
extensionReloading: {
type: 'boolean',
label: 'Extension Reloading',
category: 'Features',
requiresRestart: true,
default: false,
description:
'Enables extension loading/unloading within the CLI session.',
showInDialog: true,
},
jitContext: {
type: 'boolean',
label: 'JIT Context Loading',
category: 'Features',
requiresRestart: true,
default: false,
description: 'Enable Just-In-Time (JIT) context loading.',
showInDialog: true,
},
useOSC52Paste: {
type: 'boolean',
label: 'Use OSC 52 Paste',
category: 'Features',
requiresRestart: false,
default: false,
description: 'Use OSC 52 sequence for pasting.',
showInDialog: true,
},
plan: {
type: 'boolean',
label: 'Plan Mode',
category: 'Features',
requiresRestart: true,
default: false,
description: 'Enable planning features (Plan Mode and tools).',
showInDialog: true,
},
},
},
} as const satisfies SettingsSchema;
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;
@@ -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,
@@ -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',
]);
});
});
@@ -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);
},
};
@@ -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}
@@ -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: {
@@ -33,7 +33,12 @@ import {
type SettingsValue,
TOGGLE_TYPES,
} from '../../config/settingsSchema.js';
import { debugLogger } from '@google/gemini-cli-core';
import {
coreEvents,
debugLogger,
FeatureDefinitions,
} from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import { useSearchBuffer } from '../hooks/useSearchBuffer.js';
import {
@@ -244,6 +249,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,
@@ -252,8 +265,10 @@ export function SettingsDialog({
displayValue,
isGreyedOut,
scopeMessage,
rawValue,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
rawValue: rawValue as string | number | boolean | undefined,
editValue,
stage,
};
});
}, [settingKeys, selectedScope, settings]);
@@ -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}>
{' '}
@@ -0,0 +1,50 @@
/**
* @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', () => {
const { lastFrame } = renderWithProviders(
<FeaturesList features={mockFeatures} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with no features', () => {
const { lastFrame } = renderWithProviders(<FeaturesList features={[]} />);
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -0,0 +1,184 @@
/**
* @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}>
STATUS
</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 ? 'Enabled' : 'Disabled'}
</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}>
<Text dimColor color={theme.text.primary}>
{feature.description}
</Text>
</Box>
)}
{feature.issueUrl && (
<Box marginLeft={2} marginBottom={1}>
<Text color={theme.text.accent}>
Tracker: <Text dimColor>{feature.issueUrl}</Text>
</Text>
</Box>
)}
{!feature.issueUrl && feature.description && (
<Box marginBottom={1} />
)}
</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>
);
};
@@ -0,0 +1,43 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<FeaturesList /> > 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[`<FeaturesList /> > renders correctly with no features 1`] = `
" No features found
"
`;
+9 -1
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',
+94 -1
View File
@@ -2391,7 +2391,100 @@ describe('Config setExperiments logging', () => {
});
});
describe('Availability Service Integration', () => {
describe('FeatureGate Integration', () => {
const baseParams: ConfigParameters = {
cwd: '/tmp',
targetDir: '/path/to/target',
debugMode: false,
sessionId: 'test-session-id',
model: 'gemini-pro',
usageStatisticsEnabled: false,
};
it('should initialize FeatureGate with defaults', () => {
const config = new Config(baseParams);
// Assuming 'plan' is Alpha and defaults to false
expect(config.isFeatureEnabled('plan')).toBe(false);
});
it('should respect "features" setting', () => {
const params = {
...baseParams,
features: { plan: true },
};
const config = new Config(params);
expect(config.isFeatureEnabled('plan')).toBe(true);
});
it('should respect "featureGates" CLI flag', () => {
const params = {
...baseParams,
featureGates: 'plan=true',
};
const config = new Config(params);
expect(config.isFeatureEnabled('plan')).toBe(true);
});
it('should respect "featureGates" CLI flag overriding settings', () => {
const params = {
...baseParams,
features: { plan: false },
featureGates: 'plan=true',
};
const config = new Config(params);
expect(config.isFeatureEnabled('plan')).toBe(true);
});
it('should respect legacy "experimental" settings if feature is not explicitly set', () => {
const params = {
...baseParams,
plan: true, // legacy experimental param for plan
};
const config = new Config(params);
expect(config.isFeatureEnabled('plan')).toBe(true);
});
it('should prioritize "features" over legacy settings', () => {
const params = {
...baseParams,
plan: true, // legacy enabled
features: { plan: false }, // new disabled
};
const config = new Config(params);
expect(config.isFeatureEnabled('plan')).toBe(false);
});
it('should respect stage-based toggles from settings', () => {
const params = {
...baseParams,
features: { allAlpha: true },
};
const config = new Config(params);
// 'plan' is Alpha
expect(config.isFeatureEnabled('plan')).toBe(true);
});
it('should resolve specific feature accessors using FeatureGate', () => {
const config = new Config({
...baseParams,
features: {
jitContext: true,
toolOutputMasking: false,
extensionManagement: true,
plan: true,
enableAgents: true,
},
});
expect(config.isJitContextEnabled()).toBe(true);
expect(config.getToolOutputMaskingEnabled()).toBe(false);
expect(config.getExtensionManagement()).toBe(true);
expect(config.isPlanEnabled()).toBe(true);
expect(config.isAgentsEnabled()).toBe(true);
});
});
describe('refreshAuth', () => {
const baseModel = 'test-model';
const baseParams: ConfigParameters = {
sessionId: 'test',
+64 -17
View File
@@ -114,6 +114,7 @@ import { WorkspaceContext } from '../utils/workspaceContext.js';
import { Storage } from './storage.js';
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
import { FileExclusions } from '../utils/ignorePatterns.js';
import { DefaultFeatureGate, type FeatureGate } from './features.js';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import type { EventEmitter } from 'node:events';
import { PolicyEngine } from '../policy/policy-engine.js';
@@ -584,6 +585,8 @@ export interface ConfigParameters {
tracker?: boolean;
planSettings?: PlanSettings;
modelSteering?: boolean;
features?: Record<string, boolean>;
featureGates?: string;
onModelChange?: (model: string) => void;
mcpEnabled?: boolean;
extensionsEnabled?: boolean;
@@ -730,7 +733,6 @@ export class Config implements McpContext {
private readonly useBackgroundColor: boolean;
private readonly useAlternateBuffer: boolean;
private shellExecutionConfig: ShellExecutionConfig;
private readonly extensionManagement: boolean = true;
private readonly truncateToolOutputThreshold: number;
private compressionTruncationCounter = 0;
private initialized = false;
@@ -784,14 +786,14 @@ export class Config implements McpContext {
overageStrategy: OverageStrategy;
};
private readonly enableAgents: boolean;
private agents: AgentSettings;
private readonly enableEventDrivenScheduler: boolean;
private readonly skillsSupport: boolean;
private disabledSkills: string[];
private readonly adminSkillsEnabled: boolean;
private readonly experimentalJitContext: boolean;
private readonly featureGate: FeatureGate;
private readonly disableLLMCorrection: boolean;
private readonly planEnabled: boolean;
private readonly trackerEnabled: boolean;
@@ -881,7 +883,6 @@ export class Config implements McpContext {
this.model = params.model;
this.disableLoopDetection = params.disableLoopDetection ?? false;
this._activeModel = params.model;
this.enableAgents = params.enableAgents ?? false;
this.agents = params.agents ?? {};
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
this.planEnabled = params.plan ?? false;
@@ -891,8 +892,41 @@ export class Config implements McpContext {
this.skillsSupport = params.skillsSupport ?? true;
this.disabledSkills = params.disabledSkills ?? [];
this.adminSkillsEnabled = params.adminSkillsEnabled ?? true;
// Initialize FeatureGate with precedence:
// 1. CLI Flags (params.featureGates)
// 2. Env Var (GEMINI_FEATURE_GATES)
// 3. User Settings (params.features)
// 4. Legacy Experimental Settings
const gate = DefaultFeatureGate.deepCopy();
if (params.features) {
gate.setFromMap(params.features);
}
// Map legacy experimental flags to features if not already set
const legacyMap: Record<string, boolean | undefined> = {
toolOutputMasking: params.toolOutputMasking?.enabled,
enableAgents: params.enableAgents,
extensionManagement: params.extensionManagement,
plan: params.plan,
jitContext: params.experimentalJitContext,
};
for (const [key, value] of Object.entries(legacyMap)) {
if (value !== undefined && params.features?.[key] === undefined) {
gate.setFromMap({ [key]: value });
}
}
const envGates = process.env['GEMINI_FEATURE_GATES'];
if (envGates) {
gate.set(envGates);
}
if (params.featureGates) {
gate.set(params.featureGates);
}
this.featureGate = gate;
this.modelAvailabilityService = new ModelAvailabilityService();
this.experimentalJitContext = params.experimentalJitContext ?? false;
this.modelSteering = params.modelSteering ?? false;
this.userHintService = new UserHintService(() =>
this.isModelSteeringEnabled(),
@@ -959,7 +993,6 @@ export class Config implements McpContext {
params.enableShellOutputEfficiency ?? true;
this.shellToolInactivityTimeout =
(params.shellToolInactivityTimeout ?? 300) * 1000; // 5 minutes
this.extensionManagement = params.extensionManagement ?? true;
this.enableExtensionReloading = params.enableExtensionReloading ?? false;
this.storage = new Storage(this.targetDir, this.sessionId);
this.storage.setCustomPlansDir(params.planSettings?.directory);
@@ -1088,10 +1121,24 @@ export class Config implements McpContext {
);
}
/**
* Returns the feature gate for querying feature status.
*/
getFeatureGate(): FeatureGate {
return this.featureGate;
}
isInitialized(): boolean {
return this.initialized;
}
/**
* Returns true if the feature is enabled.
*/
isFeatureEnabled(key: string): boolean {
return this.featureGate.enabled(key);
}
/**
* Dedups initialization requests using a shared promise that is only resolved
* once.
@@ -1193,7 +1240,7 @@ export class Config implements McpContext {
await this.hookSystem.initialize();
}
if (this.experimentalJitContext) {
if (this.isFeatureEnabled('jitContext')) {
this.contextManager = new ContextManager(this);
await this.contextManager.refresh();
}
@@ -1853,7 +1900,7 @@ export class Config implements McpContext {
}
getUserMemory(): string | HierarchicalMemory {
if (this.experimentalJitContext && this.contextManager) {
if (this.isFeatureEnabled('jitContext') && this.contextManager) {
return {
global: this.contextManager.getGlobalMemory(),
extension: this.contextManager.getExtensionMemory(),
@@ -1867,7 +1914,7 @@ export class Config implements McpContext {
* Refreshes the MCP context, including memory, tools, and system instructions.
*/
async refreshMcpContext(): Promise<void> {
if (this.experimentalJitContext && this.contextManager) {
if (this.isFeatureEnabled('jitContext') && this.contextManager) {
await this.contextManager.refresh();
} else {
const { refreshServerHierarchicalMemory } = await import(
@@ -1898,7 +1945,7 @@ export class Config implements McpContext {
}
isJitContextEnabled(): boolean {
return this.experimentalJitContext;
return this.isFeatureEnabled('jitContext');
}
isModelSteeringEnabled(): boolean {
@@ -1906,7 +1953,7 @@ export class Config implements McpContext {
}
getToolOutputMaskingEnabled(): boolean {
return this.toolOutputMasking.enabled;
return this.isFeatureEnabled('toolOutputMasking');
}
async getToolOutputMaskingConfig(): Promise<ToolOutputMaskingConfig> {
@@ -1930,7 +1977,7 @@ export class Config implements McpContext {
: undefined;
return {
enabled: this.toolOutputMasking.enabled,
enabled: this.getToolOutputMaskingEnabled(),
toolProtectionThreshold:
parsedProtection !== undefined && !isNaN(parsedProtection)
? parsedProtection
@@ -1945,7 +1992,7 @@ export class Config implements McpContext {
}
getGeminiMdFileCount(): number {
if (this.experimentalJitContext && this.contextManager) {
if (this.isFeatureEnabled('jitContext') && this.contextManager) {
return this.contextManager.getLoadedPaths().size;
}
return this.geminiMdFileCount;
@@ -1956,7 +2003,7 @@ export class Config implements McpContext {
}
getGeminiMdFilePaths(): string[] {
if (this.experimentalJitContext && this.contextManager) {
if (this.isFeatureEnabled('jitContext') && this.contextManager) {
return Array.from(this.contextManager.getLoadedPaths());
}
return this.geminiMdFilePaths;
@@ -2259,7 +2306,7 @@ export class Config implements McpContext {
}
getExtensionManagement(): boolean {
return this.extensionManagement;
return this.isFeatureEnabled('extensionManagement');
}
getExtensions(): GeminiCLIExtension[] {
@@ -2285,7 +2332,7 @@ export class Config implements McpContext {
}
isPlanEnabled(): boolean {
return this.planEnabled;
return this.isFeatureEnabled('plan');
}
isTrackerEnabled(): boolean {
@@ -2305,7 +2352,7 @@ export class Config implements McpContext {
}
isAgentsEnabled(): boolean {
return this.enableAgents;
return this.isFeatureEnabled('enableAgents');
}
isEventDrivenSchedulerEnabled(): boolean {
+368
View File
@@ -0,0 +1,368 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { it, expect, describe, vi } from 'vitest';
import { DefaultFeatureGate, FeatureStage } from './features.js';
import { debugLogger } from '../utils/debugLogger.js';
describe('FeatureGate', () => {
it('should resolve default values', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
testAlpha: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
testBeta: [
{ default: true, lockToDefault: false, preRelease: FeatureStage.Beta },
],
});
expect(gate.enabled('testAlpha')).toBe(false);
expect(gate.enabled('testBeta')).toBe(true);
});
it('should infer default values from stage', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
autoAlpha: [{ lockToDefault: false, preRelease: FeatureStage.Alpha }],
autoBeta: [{ lockToDefault: false, preRelease: FeatureStage.Beta }],
autoGA: [{ lockToDefault: true, preRelease: FeatureStage.GA }],
autoDeprecated: [
{ lockToDefault: false, preRelease: FeatureStage.Deprecated },
],
});
expect(gate.enabled('autoAlpha')).toBe(false);
expect(gate.enabled('autoBeta')).toBe(true);
expect(gate.enabled('autoGA')).toBe(true);
expect(gate.enabled('autoDeprecated')).toBe(false);
});
it('should infer lockToDefault from stage', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
autoLockedGA: [{ preRelease: FeatureStage.GA }],
autoUnlockedAlpha: [{ preRelease: FeatureStage.Alpha }],
});
// Attempt to disable both
gate.setFromMap({ autoLockedGA: false, autoUnlockedAlpha: true });
// GA should remain enabled (locked)
expect(gate.enabled('autoLockedGA')).toBe(true);
// Alpha should respect override (unlocked)
expect(gate.enabled('autoUnlockedAlpha')).toBe(true);
});
it('should respect explicit default even if stage default differs', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
offBeta: [
{ default: false, lockToDefault: false, preRelease: FeatureStage.Beta },
],
onAlpha: [
{ default: true, lockToDefault: false, preRelease: FeatureStage.Alpha },
],
});
expect(gate.enabled('offBeta')).toBe(false);
expect(gate.enabled('onAlpha')).toBe(true);
});
it('should respect manual overrides', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
testAlpha: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
});
gate.setFromMap({ testAlpha: true });
expect(gate.enabled('testAlpha')).toBe(true);
});
it('should respect lockToDefault', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
testGA: [
{ default: true, lockToDefault: true, preRelease: FeatureStage.GA },
],
});
// Attempt to disable GA feature
gate.setFromMap({ testGA: false });
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',
issueUrl: undefined,
});
expect(feat2).toEqual({
key: 'feat2',
enabled: true,
stage: FeatureStage.Beta,
since: '0.2.0',
until: '0.3.0',
description: 'Feature 2',
issueUrl: undefined,
});
});
it('should include issueUrl in feature info', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
featWithUrl: [
{
preRelease: FeatureStage.Alpha,
issueUrl: 'https://github.com/google/gemini-cli/issues/1',
},
],
});
const info = gate.getFeatureInfo();
const feat = info.find((f) => f.key === 'featWithUrl');
expect(feat).toMatchObject({
key: 'featWithUrl',
issueUrl: 'https://github.com/google/gemini-cli/issues/1',
});
});
it('should respect allAlpha/allBeta toggles', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
alpha1: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
alpha2: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
beta1: [
{ default: true, lockToDefault: false, preRelease: FeatureStage.Beta },
],
});
// Enable all alpha, disable all beta
gate.setFromMap({ allAlpha: true, allBeta: false });
expect(gate.enabled('alpha1')).toBe(true);
expect(gate.enabled('alpha2')).toBe(true);
expect(gate.enabled('beta1')).toBe(false);
// Individual override should still win
gate.setFromMap({ alpha1: false });
expect(gate.enabled('alpha1')).toBe(false);
});
it('should parse comma-separated strings', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
feat1: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
feat2: [
{ default: true, lockToDefault: false, preRelease: FeatureStage.Beta },
],
});
gate.set('feat1=true,feat2=false');
expect(gate.enabled('feat1')).toBe(true);
expect(gate.enabled('feat2')).toBe(false);
});
it('should handle case-insensitive boolean values in set', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
feat1: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
feat2: [
{ default: true, lockToDefault: false, preRelease: FeatureStage.Beta },
],
});
gate.set('feat1=TRUE,feat2=FaLsE');
expect(gate.enabled('feat1')).toBe(true);
expect(gate.enabled('feat2')).toBe(false);
});
it('should ignore whitespace in set', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
feat1: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
});
gate.set(' feat1 = true ');
expect(gate.enabled('feat1')).toBe(true);
});
it('should return default if feature is unknown', () => {
const gate = DefaultFeatureGate.deepCopy();
// unknownFeature is not added
expect(gate.enabled('unknownFeature')).toBe(false);
});
it('should respect precedence: Lock > Override > Stage > Default', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
// Locked GA feature
featLocked: [
{ default: true, lockToDefault: true, preRelease: FeatureStage.GA },
],
// Alpha feature
featAlpha: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
});
// 1. Lock wins over override
gate.setFromMap({ featLocked: false });
expect(gate.enabled('featLocked')).toBe(true);
// 2. Override wins over Stage
gate.setFromMap({ allAlpha: true, featAlpha: false });
expect(gate.enabled('featAlpha')).toBe(false);
// 3. Stage wins over Default
gate.setFromMap({
allAlpha: true,
featAlpha: undefined as unknown as boolean,
}); // Removing specific override effectively
// Re-create to clear overrides map for cleaner test
const gate2 = DefaultFeatureGate.deepCopy();
gate2.add({
featAlpha: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
});
gate2.setFromMap({ allAlpha: true });
expect(gate2.enabled('featAlpha')).toBe(true);
});
it('should use the latest feature spec', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
evolvedFeat: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
since: '1.0',
},
{
default: true,
lockToDefault: false,
preRelease: FeatureStage.Beta,
since: '1.1',
},
],
});
// Should use the last spec (Beta, default true)
expect(gate.enabled('evolvedFeat')).toBe(true);
});
it('should log warning when using deprecated feature only once', () => {
const warnSpy = vi.spyOn(debugLogger, 'warn').mockImplementation(() => {});
const gate = DefaultFeatureGate.deepCopy();
gate.add({
deprecatedFeat: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Deprecated,
},
],
});
gate.setFromMap({ deprecatedFeat: true });
expect(gate.enabled('deprecatedFeat')).toBe(true);
expect(gate.enabled('deprecatedFeat')).toBe(true); // Call again
// Should only be called once
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Feature "deprecatedFeat" is deprecated'),
);
warnSpy.mockRestore();
});
it('should perform deep copy of specs', () => {
const gate = DefaultFeatureGate.deepCopy();
const featKey = 'copiedFeat';
const initialSpecs = [{ preRelease: FeatureStage.Alpha }];
gate.add({ [featKey]: initialSpecs });
const copy = gate.deepCopy();
// Modifying original spec array should not affect copy if it was truly deep copied
// (though our implementation clones the array, not the spec objects, which is usually enough for this use case)
gate.add({
[featKey]: [{ preRelease: FeatureStage.Beta }],
});
expect(gate.enabled(featKey)).toBe(true); // Beta (default true)
expect(copy.enabled(featKey)).toBe(false); // Alpha (default false)
});
});
+317
View File
@@ -0,0 +1,317 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { debugLogger } from '../utils/debugLogger.js';
/**
* FeatureStage indicates the maturity level of a feature.
* Strictly aligned with Kubernetes Feature Gates.
*/
export enum FeatureStage {
/**
* Alpha features are disabled by default and may be unstable.
*/
Alpha = 'ALPHA',
/**
* Beta features are enabled by default and are considered stable.
*/
Beta = 'BETA',
/**
* GA features are stable and locked to enabled.
*/
GA = 'GA',
/**
* Deprecated features are scheduled for removal.
*/
Deprecated = 'DEPRECATED',
}
/**
* FeatureSpec defines the behavior and metadata of a feature at a specific version.
*/
export interface FeatureSpec {
/**
* Default enablement state.
* If not provided, defaults to:
* - Alpha: false
* - Beta: true
* - GA: true
* - Deprecated: false
*/
default?: boolean;
/**
* If true, the feature cannot be changed from its default value.
* Defaults to:
* - GA: true
* - Others: false
*/
lockToDefault?: boolean;
/**
* The maturity stage of the feature.
*/
preRelease: FeatureStage;
/**
* The version since this spec became valid.
*/
since?: string;
/**
* The version until which this spec is valid or scheduled for removal.
*/
until?: string;
/**
* Description of the feature.
*/
description?: string;
/**
* Link to the Lifecycle Tracking Issue on GitHub.
*/
issueUrl?: 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;
issueUrl?: string;
}
/**
* FeatureGate provides a read-only interface to query feature status.
*/
export interface FeatureGate {
/**
* Returns true if the feature is enabled.
*/
enabled(key: string): boolean;
/**
* 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.
*/
deepCopy(): MutableFeatureGate;
}
/**
* MutableFeatureGate allows registering and configuring features.
*/
export interface MutableFeatureGate extends FeatureGate {
/**
* Adds new features or updates existing ones with versioned specs.
*/
add(features: Record<string, FeatureSpec[]>): void;
/**
* Sets feature states from a comma-separated string (e.g., "Foo=true,Bar=false").
*/
set(instance: string): void;
/**
* Sets feature states from a map.
*/
setFromMap(m: Record<string, boolean>): void;
}
class FeatureGateImpl implements MutableFeatureGate {
private specs: Map<string, FeatureSpec[]> = new Map();
private overrides: Map<string, boolean> = new Map();
private warnedFeatures: Set<string> = new Set();
add(features: Record<string, FeatureSpec[]>): void {
for (const [key, specs] of Object.entries(features)) {
this.specs.set(key, specs);
}
}
set(instance: string): void {
const pairs = instance.split(',');
for (const pair of pairs) {
const eqIndex = pair.indexOf('=');
if (eqIndex !== -1) {
const key = pair.slice(0, eqIndex).trim();
const value = pair.slice(eqIndex + 1).trim();
if (key) {
this.overrides.set(key, value.toLowerCase() === 'true');
}
}
}
}
setFromMap(m: Record<string, boolean>): void {
for (const [key, value] of Object.entries(m)) {
this.overrides.set(key, value);
}
}
enabled(key: string): boolean {
const specs = this.specs.get(key);
if (!specs || specs.length === 0) {
return false;
}
// Get the latest spec (for now, just the last one in the array)
const latestSpec = specs[specs.length - 1];
const isLocked =
latestSpec.lockToDefault ?? latestSpec.preRelease === FeatureStage.GA;
if (isLocked) {
return latestSpec.default ?? true; // Locked features (GA) must be enabled unless explicitly disabled (rare)
}
const override = this.overrides.get(key);
if (override !== undefined) {
if (
latestSpec.preRelease === FeatureStage.Deprecated &&
!this.warnedFeatures.has(key)
) {
debugLogger.warn(
`[WARNING] Feature "${key}" is deprecated and will be removed in a future release.`,
);
this.warnedFeatures.add(key);
}
return override;
}
// Handle stage-wide defaults if set (e.g., allAlpha, allBeta)
if (latestSpec.preRelease === FeatureStage.Alpha) {
const allAlpha = this.overrides.get('allAlpha');
if (allAlpha !== undefined) return allAlpha;
}
if (latestSpec.preRelease === FeatureStage.Beta) {
const allBeta = this.overrides.get('allBeta');
if (allBeta !== undefined) return allBeta;
}
if (latestSpec.default !== undefined) {
return latestSpec.default;
}
// Auto-default based on stage
return (
latestSpec.preRelease === FeatureStage.Beta ||
latestSpec.preRelease === FeatureStage.GA
);
}
knownFeatures(): string[] {
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,
issueUrl: latestSpec.issueUrl,
};
})
.sort((a, b) => a.key.localeCompare(b.key));
}
deepCopy(): MutableFeatureGate {
const copy = new FeatureGateImpl();
copy.specs = new Map(
Array.from(this.specs.entries()).map(([k, v]) => [k, [...v]]),
);
copy.overrides = new Map(this.overrides);
// warnedFeatures are not copied, we want to warn again in a new context if needed
return copy;
}
}
/**
* Global default feature gate.
*/
export const DefaultFeatureGate: MutableFeatureGate = new FeatureGateImpl();
/**
* Registry of core features.
*/
export const FeatureDefinitions: Record<string, FeatureSpec[]> = {
toolOutputMasking: [
{
preRelease: FeatureStage.Beta,
since: '0.30.0',
description: 'Enables tool output masking to save tokens.',
},
],
enableAgents: [
{
preRelease: FeatureStage.Alpha,
since: '0.30.0',
description: 'Enable local and remote subagents.',
},
],
extensionManagement: [
{
preRelease: FeatureStage.Beta,
since: '0.30.0',
description: 'Enable extension management features.',
},
],
extensionConfig: [
{
preRelease: FeatureStage.Beta,
since: '0.30.0',
description: 'Enable requesting and fetching of extension settings.',
},
],
extensionRegistry: [
{
preRelease: FeatureStage.Alpha,
since: '0.30.0',
description: 'Enable extension registry explore UI.',
},
],
extensionReloading: [
{
preRelease: FeatureStage.Alpha,
since: '0.30.0',
description:
'Enables extension loading/unloading within the CLI session.',
},
],
jitContext: [
{
preRelease: FeatureStage.Alpha,
since: '0.30.0',
description: 'Enable Just-In-Time (JIT) context loading.',
},
],
useOSC52Paste: [
{
preRelease: FeatureStage.Alpha,
since: '0.30.0',
description: 'Use OSC 52 sequence for pasting.',
},
],
plan: [
{
preRelease: FeatureStage.Alpha,
since: '0.30.0',
description: 'Enable planning features (Plan Mode and tools).',
},
],
};
// Register core features
DefaultFeatureGate.add(FeatureDefinitions);
+1
View File
@@ -7,6 +7,7 @@
// Export config
export * from './config/config.js';
export * from './config/memory.js';
export * from './config/features.js';
export * from './config/defaultModelConfigs.js';
export * from './config/models.js';
export * from './config/constants.js';
+89 -2
View File
@@ -1611,8 +1611,8 @@
},
"experimental": {
"title": "Experimental",
"description": "Setting to enable experimental features",
"markdownDescription": "Setting to enable experimental features\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `{}`",
"description": "DEPRECATED: Use the \"features\" object instead. Setting to enable experimental features",
"markdownDescription": "DEPRECATED: Use the \"features\" object instead. Setting to enable experimental features\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `{}`",
"default": {},
"type": "object",
"properties": {
@@ -2040,6 +2040,93 @@
}
},
"additionalProperties": false
},
"features": {
"title": "Features",
"description": "Feature Lifecycle Management settings.",
"markdownDescription": "Feature Lifecycle Management settings.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `{}`",
"default": {},
"type": "object",
"properties": {
"allAlpha": {
"title": "Enable all Alpha features",
"description": "Enable all Alpha features by default.",
"markdownDescription": "Enable all Alpha features by default.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"allBeta": {
"title": "Enable all Beta features",
"description": "Enable all Beta features by default.",
"markdownDescription": "Enable all Beta features by default.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `true`",
"default": true,
"type": "boolean"
},
"toolOutputMasking": {
"title": "Tool Output Masking",
"description": "Enables tool output masking to save tokens.",
"markdownDescription": "Enables tool output masking to save tokens.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `true`",
"default": true,
"type": "boolean"
},
"enableAgents": {
"title": "Enable Agents",
"description": "Enable local and remote subagents.",
"markdownDescription": "Enable local and remote subagents.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"extensionManagement": {
"title": "Extension Management",
"description": "Enable extension management features.",
"markdownDescription": "Enable extension management features.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `true`",
"default": true,
"type": "boolean"
},
"extensionConfig": {
"title": "Extension Configuration",
"description": "Enable requesting and fetching of extension settings.",
"markdownDescription": "Enable requesting and fetching of extension settings.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `true`",
"default": true,
"type": "boolean"
},
"extensionRegistry": {
"title": "Extension Registry Explore UI",
"description": "Enable extension registry explore UI.",
"markdownDescription": "Enable extension registry explore UI.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"extensionReloading": {
"title": "Extension Reloading",
"description": "Enables extension loading/unloading within the CLI session.",
"markdownDescription": "Enables extension loading/unloading within the CLI session.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"jitContext": {
"title": "JIT Context Loading",
"description": "Enable Just-In-Time (JIT) context loading.",
"markdownDescription": "Enable Just-In-Time (JIT) context loading.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"useOSC52Paste": {
"title": "Use OSC 52 Paste",
"description": "Use OSC 52 sequence for pasting.",
"markdownDescription": "Use OSC 52 sequence for pasting.\n\n- Category: `Features`\n- Requires restart: `no`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"plan": {
"title": "Plan Mode",
"description": "Enable planning features (Plan Mode and tools).",
"markdownDescription": "Enable planning features (Plan Mode and tools).\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
}
},
"additionalProperties": false
}
},
"$defs": {
+86 -15
View File
@@ -22,6 +22,11 @@ import type {
SettingsSchemaType,
} from '../packages/cli/src/config/settingsSchema.js';
import {
FeatureDefinitions,
FeatureStage,
} from '../packages/core/src/config/features.js';
const START_MARKER = '<!-- SETTINGS-AUTOGEN:START -->';
const END_MARKER = '<!-- SETTINGS-AUTOGEN:END -->';
@@ -36,6 +41,7 @@ interface DocEntry {
defaultValue: string;
requiresRestart: boolean;
enumValues?: string[];
stage?: string;
}
export async function main(argv = process.argv.slice(2)) {
@@ -49,6 +55,10 @@ export async function main(argv = process.argv.slice(2)) {
);
const docPath = path.join(repoRoot, 'docs/reference/configuration.md');
const cliSettingsDocPath = path.join(repoRoot, 'docs/cli/settings.md');
const featureGatesDocPath = path.join(
repoRoot,
'docs/cli/feature-lifecycle.md',
);
const { getSettingsSchema } = await loadSettingsSchemaModule();
const schema = getSettingsSchema();
@@ -59,21 +69,31 @@ export async function main(argv = process.argv.slice(2)) {
const generatedBlock = renderSections(allSettingsSections);
const generatedTableBlock = renderTableSections(filteredSettingsSections);
const generatedFeatureGatesBlock = renderFeatureGatesTable();
await updateFile(docPath, generatedBlock, checkOnly);
await updateFile(cliSettingsDocPath, generatedTableBlock, checkOnly);
await updateFile(
featureGatesDocPath,
generatedFeatureGatesBlock,
checkOnly,
'<!-- FEATURES-AUTOGEN:START -->',
'<!-- FEATURES-AUTOGEN:END -->',
);
}
async function updateFile(
filePath: string,
newContent: string,
checkOnly: boolean,
startMarker = START_MARKER,
endMarker = END_MARKER,
) {
const doc = await readFile(filePath, 'utf8');
const injectedDoc = injectBetweenMarkers({
document: doc,
startMarker: START_MARKER,
endMarker: END_MARKER,
startMarker,
endMarker,
newContent: newContent,
paddingBefore: '\n',
paddingAfter: '\n',
@@ -142,19 +162,31 @@ function collectEntries(
sections.set(sectionKey, []);
}
let defaultValue = definition.default;
let stage: string | undefined;
if (sectionKey === 'features' && FeatureDefinitions[key]) {
const specs = FeatureDefinitions[key];
const latest = specs[specs.length - 1];
stage = latest.preRelease;
defaultValue =
latest.default ??
(stage === FeatureStage.Beta || stage === FeatureStage.GA);
}
sections.get(sectionKey)!.push({
path: newPathSegments.join('.'),
type: formatType(definition),
label: definition.label,
category: definition.category,
description: formatDescription(definition),
defaultValue: formatDefaultValue(definition.default, {
defaultValue: formatDefaultValue(defaultValue, {
quoteStrings: true,
}),
requiresRestart: Boolean(definition.requiresRestart),
enumValues: definition.options?.map((option) =>
formatDefaultValue(option.value, { quoteStrings: true }),
),
stage,
});
}
@@ -225,6 +257,10 @@ function renderSections(sections: Map<string, DocEntry[]>) {
lines.push(' - **Values:** ' + values);
}
if (entry.stage) {
lines.push(' - **Stage:** ' + entry.stage);
}
if (entry.requiresRestart) {
lines.push(' - **Requires restart:** Yes');
}
@@ -252,23 +288,34 @@ function renderTableSections(sections: Map<string, DocEntry[]>) {
}
lines.push(`### ${title}`);
lines.push('');
lines.push('| UI Label | Setting | Description | Default |');
lines.push('| --- | --- | --- | --- |');
if (section === 'features') {
lines.push('| UI Label | Setting | Description | Default | Stage |');
lines.push('| --- | --- | --- | --- | --- |');
} else {
lines.push('| UI Label | Setting | Description | Default |');
lines.push('| --- | --- | --- | --- |');
}
for (const entry of entries) {
const val = entry.defaultValue.replace(/\n/g, ' ');
const defaultVal = '`' + escapeBackticks(val) + '`';
lines.push(
let row =
'| ' +
entry.label +
' | `' +
entry.path +
'` | ' +
entry.description +
' | ' +
defaultVal +
' |',
);
entry.label +
' | `' +
entry.path +
'` | ' +
entry.description +
' | ' +
defaultVal +
' |';
if (section === 'features') {
const stageVal = entry.stage ? '`' + entry.stage + '`' : '-';
row += ' ' + stageVal + ' |';
}
lines.push(row);
}
lines.push('');
@@ -277,6 +324,30 @@ function renderTableSections(sections: Map<string, DocEntry[]>) {
return lines.join('\n').trimEnd();
}
function renderFeatureGatesTable() {
let markdown = '| Feature | Stage | Default | Since | Description |\n';
markdown += '| --- | --- | --- | --- | --- |\n';
const sortedFeatures = Object.entries(FeatureDefinitions).sort(
([keyA], [keyB]) => keyA.localeCompare(keyB),
);
for (const [key, specs] of sortedFeatures) {
const latest = specs[specs.length - 1];
const stage = latest.preRelease;
const isEnabled =
latest.default ??
(stage === FeatureStage.Beta || stage === FeatureStage.GA);
const defaultValue = isEnabled ? 'Enabled' : 'Disabled';
const since = latest.since || '-';
const description = latest.description || '-';
markdown += `| \`${key}\` | ${stage} | ${defaultValue} | ${since} | ${description} |\n`;
}
return markdown;
}
if (process.argv[1]) {
const entryUrl = pathToFileURL(path.resolve(process.argv[1])).href;
if (entryUrl === import.meta.url) {