diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md index 2c2b730126d..0cea4391ae6 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -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 + } + ] + } } ``` @@ -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 diff --git a/docs/extensions/writing-extensions.md b/docs/extensions/writing-extensions.md index 213d77542ed..9ddbeb72419 100644 --- a/docs/extensions/writing-extensions.md +++ b/docs/extensions/writing-extensions.md @@ -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 @@ -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 diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 56152cd6e16..e8c49248ff7 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -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( diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 815cf23ecec..cb915d859b6 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -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'; @@ -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 { diff --git a/packages/cli/src/config/extensions/consent.ts b/packages/cli/src/config/extensions/consent.ts index 9a63054d122..017c8090b2a 100644 --- a/packages/cli/src/config/extensions/consent.ts +++ b/packages/cli/src/config/extensions/consent.ts @@ -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'); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b89d0b83c08..a7989915dca 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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, @@ -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(null); @@ -2341,6 +2343,7 @@ Logging in with Google... Restarting Gemini CLI to continue. ...pendingGeminiHistoryItems, ]), hintBuffer: '', + statusBadges, }), [ isThemeDialogOpen, @@ -2461,6 +2464,7 @@ Logging in with Google... Restarting Gemini CLI to continue. adminSettingsChanged, newAgents, showIsExpandableHint, + statusBadges, ], ); diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 3fc830c1b70..02a575fea7c 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -95,6 +95,14 @@ export const Footer: React.FC = () => { )} )} + {uiState.statusBadges.map((badge, i) => ( + + {' '} + + {badge.text} + + + ))} {debugMode && ( {' ' + (debugMessage || '--debug')} diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 79464271b89..ae71e4afd9f 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -66,6 +66,11 @@ export interface QuotaState { validationRequest: ValidationDialogRequest | null; } +export interface StatusBadge { + text: string; + color?: string; +} + export interface UIState { history: HistoryItem[]; historyManager: UseHistoryManagerReturn; @@ -191,6 +196,7 @@ export interface UIState { text: string; type: TransientMessageType; } | null; + statusBadges: StatusBadge[]; } export const UIStateContext = createContext(null); diff --git a/packages/cli/src/ui/hooks/useExtensionStatusBadges.ts b/packages/cli/src/ui/hooks/useExtensionStatusBadges.ts new file mode 100644 index 00000000000..b7ad9284ca2 --- /dev/null +++ b/packages/cli/src/ui/hooks/useExtensionStatusBadges.ts @@ -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([]); + + 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 => { + 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 => + 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; +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 32d74479e72..497513eaecb 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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 @@ -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 {