Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion docs/extensions/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,17 @@ The manifest file defines the extension's behavior and configuration.
}
},
"contextFileName": "GEMINI.md",
"excludeTools": ["run_shell_command"]
"excludeTools": ["run_shell_command"],
"ui": {
"badges": [
{
"command": "sh",
"args": ["${extensionPath}/status.sh"],
"color": "accent",
"intervalMs": 30000
}
]
}
}
```

Expand Down Expand Up @@ -157,6 +167,23 @@ The manifest file defines the extension's behavior and configuration.
`"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf`
command. Note that this differs from the MCP server `excludeTools`
functionality, which can be listed in the MCP server config.
- `ui`: Configuration for UI contributions.
- `badges`: An array of status badge definitions to display in the CLI footer.
- `type`: The type of badge. Use `"command"` to execute a shell command, or
`"env"` to read an environment variable.
- `command`: The executable command to run to get the badge text (Required
if `type` is `"command"`).
- `args`: An array of arguments to pass to the command.
- `envVar`: The name of the environment variable to read (Required if `type`
is `"env"`).
- `format`: Optional formatting to apply to the result. Currently supports
`"basename"` (extracts the last segment of a path, useful for
`VIRTUAL_ENV`).
- `icon`: An optional icon to display before the badge text.
- `intervalMs`: The interval (in milliseconds) at which to poll the command
for updates. Defaults to `30000`.
- `color`: The semantic color for the badge text (e.g., `primary`, `accent`,
`warning`, `error`).

When Gemini CLI starts, it loads all the extensions and merges their
configurations. If there are any conflicts, the workspace configuration takes
Expand Down
41 changes: 40 additions & 1 deletion docs/extensions/writing-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ which features your extension needs.
| **[Context file (`GEMINI.md`)](reference.md#contextfilename)** | A markdown file containing instructions that are loaded into the model's context at the start of every session. | Use this to define the "personality" of your extension, set coding standards, or provide essential knowledge that the model should always have. | CLI provides to model |
| **[Agent skills](../cli/skills.md)** | A specialized set of instructions and workflows that the model activates only when needed. | Use this for complex, occasional tasks (like "create a PR" or "audit security") to avoid cluttering the main context window when the skill isn't being used. | Model |
| **[Hooks](../hooks/index.md)** | A way to intercept and customize the CLI's behavior at specific lifecycle events (e.g., before/after a tool call). | Use this when you want to automate actions based on what the model is doing, like validating tool arguments, logging activity, or modifying the model's input/output. | CLI |
| **[UI contributions](#step-8-add-ui-contributions)** | Dynamic visual elements added to the CLI interface, such as status badges in the footer. | Use this to surface environmental context, such as the active cloud project, virtual environment, or system status, directly in the CLI's persistent status bar. | CLI |
| **[Custom themes](reference.md#themes)** | A set of color definitions to personalize the CLI UI. | Use this to provide a unique visual identity for your extension or to offer specialized high-contrast or thematic color schemes. | User (via /theme) |

## Step 1: Create a new extension
Expand Down Expand Up @@ -278,7 +279,45 @@ Skills are activated only when needed, which saves context tokens.
Gemini CLI automatically discovers skills bundled with your extension. The model
activates them when it identifies a relevant task.

## Step 8: Release your extension
## Step 8: Add UI contributions

UI contributions let you add dynamic visual elements to the Gemini CLI
interface. The most common use case is adding "status badges" to the footer to
surface environmental context.

1. Open `gemini-extension.json`.
2. Add a `ui` object with a `badges` array:

```json
{
"name": "my-first-extension",
"version": "1.0.0",
"ui": {
"badges": [
{
"type": "command",
"command": "sh",
"args": ["${extensionPath}/status.sh"],
"color": "accent",
"intervalMs": 30000
}
]
}
}
```

3. Create the script file (for example, `status.sh`) that returns the text to
display:

```bash
#!/bin/bash
echo "active-context"
```

Gemini CLI executes these commands asynchronously in the background and renders
the output as a badge in the persistent footer.

## Step 9: Release your extension

When your extension is ready, share it with others via a Git repository or
GitHub Releases. Refer to the [Extension Releasing Guide](./releasing.md) for
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/extension-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,7 @@ Would you like to attempt to install via "git clone" instead?`,
themes: config.themes,
rules,
checkers,
ui: config.ui,
};
} catch (e) {
debugLogger.error(
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/config/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
MCPServerConfig,
ExtensionInstallMetadata,
CustomTheme,
ExtensionUIContributions,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import * as path from 'node:path';
Expand All @@ -33,6 +34,10 @@ export interface ExtensionConfig {
* These themes will be registered when the extension is activated.
*/
themes?: CustomTheme[];
/**
* UI contributions provided by this extension.
*/
ui?: ExtensionUIContributions;
}

export interface ExtensionUpdateInfo {
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/config/extensions/consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,14 @@ async function extensionConsentString(
'⚠️ This extension contains Hooks which can automatically execute commands.',
);
}
const hasCommandBadges = sanitizedConfig.ui?.badges?.some(
(b) => b.type === 'command' || b.command,
);
if (hasCommandBadges) {
output.push(
'⚠️ This extension contributes UI Badges which will automatically execute commands in the background to update their status.',
);
}
if (skills.length > 0) {
output.push(`\n${chalk.bold('Agent Skills:')}`);
output.push('\nThis extension will install the following agent skills:\n');
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ import { useMcpStatus } from './hooks/useMcpStatus.js';
import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js';
import { useSessionStats } from './contexts/SessionContext.js';
import { useGitBranchName } from './hooks/useGitBranchName.js';
import { useExtensionStatusBadges } from './hooks/useExtensionStatusBadges.js';
import {
useConfirmUpdateRequests,
useExtensionUpdates,
Expand Down Expand Up @@ -430,6 +431,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Additional hooks moved from App.tsx
const { stats: sessionStats } = useSessionStats();
const branchName = useGitBranchName(config.getTargetDir());
const statusBadges = useExtensionStatusBadges(config);

// Layout measurements
const mainControlsRef = useRef<DOMElement>(null);
Expand Down Expand Up @@ -2341,6 +2343,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
...pendingGeminiHistoryItems,
]),
hintBuffer: '',
statusBadges,
}),
[
isThemeDialogOpen,
Expand Down Expand Up @@ -2461,6 +2464,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
adminSettingsChanged,
newAgents,
showIsExpandableHint,
statusBadges,
],
);

Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/ui/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ export const Footer: React.FC = () => {
)}
</Text>
)}
{uiState.statusBadges.map((badge, i) => (
<Text key={i}>
{' '}
<Text color={badge.color || theme.text.accent}>
{badge.text}
</Text>
</Text>
))}
{debugMode && (
<Text color={theme.status.error}>
{' ' + (debugMessage || '--debug')}
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/ui/contexts/UIStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export interface QuotaState {
validationRequest: ValidationDialogRequest | null;
}

export interface StatusBadge {
text: string;
color?: string;
}

export interface UIState {
history: HistoryItem[];
historyManager: UseHistoryManagerReturn;
Expand Down Expand Up @@ -191,6 +196,7 @@ export interface UIState {
text: string;
type: TransientMessageType;
} | null;
statusBadges: StatusBadge[];
}

export const UIStateContext = createContext<UIState | null>(null);
Expand Down
82 changes: 82 additions & 0 deletions packages/cli/src/ui/hooks/useExtensionStatusBadges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { useState, useEffect, useCallback } from 'react';
import { spawnAsync, type Config } from '@google/gemini-cli-core';
import { type StatusBadge } from '../contexts/UIStateContext.js';
import * as path from 'node:path';

export function useExtensionStatusBadges(config: Config): StatusBadge[] {
const [badges, setBadges] = useState<StatusBadge[]>([]);

const fetchBadges = useCallback(async () => {
const activeExtensions = config
.getExtensionLoader()
.getExtensions()
.filter(
(ext) => ext.isActive && ext.ui?.badges && ext.ui.badges.length > 0,
);

const badgePromises = activeExtensions.flatMap((ext) => {
if (!ext.ui?.badges) return [];

return ext.ui.badges.map(
async (badgeConfig): Promise<StatusBadge | null> => {
try {
let val: string | undefined;

if (badgeConfig.type === 'env' && badgeConfig.envVar) {
val = process.env[badgeConfig.envVar];
} else if (badgeConfig.type === 'command' && badgeConfig.command) {
const { stdout } = await spawnAsync(
badgeConfig.command,
badgeConfig.args ?? [],
);
val = stdout.toString().trim();
}

if (val) {
if (badgeConfig.format === 'basename') {
val = path.basename(val);
}
return {
text: badgeConfig.icon ? `${badgeConfig.icon} ${val}` : val,
color: badgeConfig.color,
};
}
} catch (_e) {
// Ignore failing badge commands silently so they don't break the UI
}
return null;
},
);
});

const results = await Promise.allSettled(badgePromises);
const newBadges: StatusBadge[] = results
.filter(
(result): result is PromiseFulfilledResult<StatusBadge> =>
result.status === 'fulfilled' && result.value !== null,
)
.map((result) => result.value);

setBadges(newBadges);
}, [config]);

useEffect(() => {
void fetchBadges();

// Poll every 30 seconds for all extension badges
// Future enhancement: Respect `intervalMs` from individual badge configs
const interval = setInterval(() => {
void fetchBadges();
}, 30000);

return () => clearInterval(interval);
}, [fetchBadges]);

return badges;
}
51 changes: 51 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,53 @@ export interface BrowserAgentCustomConfig {
visualModel?: string;
}

/**
* A dynamic status badge contributed by an extension.
*/
export interface ExtensionUIBadge {
/**
* The type of badge to render.
* 'command' executes a shell command. 'env' reads an environment variable.
*/
type: 'command' | 'env';
/**
* The command to execute (Required if type === 'command').
*/
command?: string;
/**
* Optional arguments for the command.
*/
args?: string[];
/**
* The environment variable to read (Required if type === 'env').
*/
envVar?: string;
/**
* Optional formatting to apply to the result.
* 'basename': Returns the last portion of a path.
*/
format?: 'basename';
/**
* Optional icon to display before the text.
*/
icon?: string;
/**
* Optional polling interval in milliseconds for commands. Defaults to 30000.
*/
intervalMs?: number;
/**
* Optional semantic color for the badge.
*/
color?: string;
}

export interface ExtensionUIContributions {
/**
* Badges to display in the footer.
*/
badges?: ExtensionUIBadge[];
}

/**
* All information required in CLI to handle an extension. Defined in Core so
* that the collection of loaded, active, and inactive extensions can be passed
Expand Down Expand Up @@ -337,6 +384,10 @@ export interface GeminiCLIExtension {
* Safety checkers contributed by this extension.
*/
checkers?: SafetyCheckerRule[];
/**
* UI contributions provided by this extension.
*/
ui?: ExtensionUIContributions;
}

export interface ExtensionInstallMetadata {
Expand Down