diff --git a/packages/cli/src/config/__tests__/footerItems.test.ts b/packages/cli/src/config/__tests__/footerItems.test.ts
new file mode 100644
index 0000000000..5442c504c8
--- /dev/null
+++ b/packages/cli/src/config/__tests__/footerItems.test.ts
@@ -0,0 +1,93 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import { deriveItemsFromLegacySettings } from '../footerItems.js';
+import { createMockSettings } from '../../test-utils/settings.js';
+
+describe('deriveItemsFromLegacySettings', () => {
+ it('returns defaults when no legacy settings are customized', () => {
+ const settings = createMockSettings({
+ ui: { footer: { hideContextPercentage: true } },
+ }).merged;
+ const items = deriveItemsFromLegacySettings(settings);
+ expect(items).toEqual([
+ 'cwd',
+ 'git-branch',
+ 'sandbox-status',
+ 'model-name',
+ 'quota',
+ 'error-count',
+ ]);
+ });
+
+ it('removes cwd when hideCWD is true', () => {
+ const settings = createMockSettings({
+ ui: { footer: { hideCWD: true, hideContextPercentage: true } },
+ }).merged;
+ const items = deriveItemsFromLegacySettings(settings);
+ expect(items).not.toContain('cwd');
+ });
+
+ it('removes sandbox-status when hideSandboxStatus is true', () => {
+ const settings = createMockSettings({
+ ui: { footer: { hideSandboxStatus: true, hideContextPercentage: true } },
+ }).merged;
+ const items = deriveItemsFromLegacySettings(settings);
+ expect(items).not.toContain('sandbox-status');
+ });
+
+ it('removes model-name, context-remaining, and quota when hideModelInfo is true', () => {
+ const settings = createMockSettings({
+ ui: { footer: { hideModelInfo: true, hideContextPercentage: true } },
+ }).merged;
+ const items = deriveItemsFromLegacySettings(settings);
+ expect(items).not.toContain('model-name');
+ expect(items).not.toContain('context-remaining');
+ expect(items).not.toContain('quota');
+ });
+
+ it('includes context-remaining when hideContextPercentage is false', () => {
+ const settings = createMockSettings({
+ ui: { footer: { hideContextPercentage: false } },
+ }).merged;
+ const items = deriveItemsFromLegacySettings(settings);
+ expect(items).toContain('context-remaining');
+ // Should be after model-name
+ const modelIdx = items.indexOf('model-name');
+ const contextIdx = items.indexOf('context-remaining');
+ expect(contextIdx).toBe(modelIdx + 1);
+ });
+
+ it('includes memory-usage when showMemoryUsage is true', () => {
+ const settings = createMockSettings({
+ ui: { showMemoryUsage: true, footer: { hideContextPercentage: true } },
+ }).merged;
+ const items = deriveItemsFromLegacySettings(settings);
+ expect(items).toContain('memory-usage');
+ });
+
+ it('handles combination of settings', () => {
+ const settings = createMockSettings({
+ ui: {
+ showMemoryUsage: true,
+ footer: {
+ hideCWD: true,
+ hideModelInfo: true,
+ hideContextPercentage: false,
+ },
+ },
+ }).merged;
+ const items = deriveItemsFromLegacySettings(settings);
+ expect(items).toEqual([
+ 'git-branch',
+ 'sandbox-status',
+ 'error-count',
+ 'context-remaining',
+ 'memory-usage',
+ ]);
+ });
+});
diff --git a/packages/cli/src/config/footerItems.ts b/packages/cli/src/config/footerItems.ts
new file mode 100644
index 0000000000..41d3a31f6c
--- /dev/null
+++ b/packages/cli/src/config/footerItems.ts
@@ -0,0 +1,142 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { MergedSettings } from './settings.js';
+
+export interface FooterItem {
+ id: string;
+ label: string;
+ description: string;
+ defaultEnabled: boolean;
+}
+
+export const ALL_ITEMS: FooterItem[] = [
+ {
+ id: 'cwd',
+ label: 'cwd',
+ description: 'Current directory path',
+ defaultEnabled: true,
+ },
+ {
+ id: 'git-branch',
+ label: 'git-branch',
+ description: 'Current git branch name',
+ defaultEnabled: true,
+ },
+ {
+ id: 'sandbox-status',
+ label: 'sandbox-status',
+ description: 'Sandbox type and trust indicator',
+ defaultEnabled: true,
+ },
+ {
+ id: 'model-name',
+ label: 'model-name',
+ description: 'Current model identifier',
+ defaultEnabled: true,
+ },
+ {
+ id: 'context-remaining',
+ label: 'context-remaining',
+ description: 'Percentage of context window remaining',
+ defaultEnabled: false,
+ },
+ {
+ id: 'quota',
+ label: 'quota',
+ description: 'Remaining quota and reset time',
+ defaultEnabled: true,
+ },
+ {
+ id: 'memory-usage',
+ label: 'memory-usage',
+ description: 'Node.js heap memory usage',
+ defaultEnabled: false,
+ },
+ {
+ id: 'error-count',
+ label: 'error-count',
+ description: 'Console errors encountered',
+ defaultEnabled: true,
+ },
+ {
+ id: 'session-id',
+ label: 'session-id',
+ description: 'Unique identifier for the current session',
+ defaultEnabled: false,
+ },
+ {
+ id: 'code-changes',
+ label: 'code-changes',
+ description: 'Lines added/removed in the session',
+ defaultEnabled: true,
+ },
+ {
+ id: 'token-count',
+ label: 'token-count',
+ description: 'Total tokens used in the session',
+ defaultEnabled: false,
+ },
+ {
+ id: 'corgi',
+ label: 'corgi',
+ description: 'A friendly corgi companion',
+ defaultEnabled: false,
+ },
+];
+
+export const DEFAULT_ORDER = [
+ 'cwd',
+ 'git-branch',
+ 'sandbox-status',
+ 'model-name',
+ 'context-remaining',
+ 'quota',
+ 'memory-usage',
+ 'error-count',
+ 'session-id',
+ 'code-changes',
+ 'token-count',
+ 'corgi',
+];
+
+export function deriveItemsFromLegacySettings(
+ settings: MergedSettings,
+): string[] {
+ const defaults = [
+ 'cwd',
+ 'git-branch',
+ 'sandbox-status',
+ 'model-name',
+ 'quota',
+ 'error-count',
+ ];
+ const items = [...defaults];
+
+ const remove = (arr: string[], id: string) => {
+ const idx = arr.indexOf(id);
+ if (idx !== -1) arr.splice(idx, 1);
+ };
+
+ if (settings.ui.footer.hideCWD) remove(items, 'cwd');
+ if (settings.ui.footer.hideSandboxStatus) remove(items, 'sandbox-status');
+ if (settings.ui.footer.hideModelInfo) {
+ remove(items, 'model-name');
+ remove(items, 'context-remaining');
+ remove(items, 'quota');
+ }
+ if (
+ !settings.ui.footer.hideContextPercentage &&
+ !items.includes('context-remaining')
+ ) {
+ const modelIdx = items.indexOf('model-name');
+ if (modelIdx !== -1) items.splice(modelIdx + 1, 0, 'context-remaining');
+ else items.push('context-remaining');
+ }
+ if (settings.ui.showMemoryUsage) items.push('memory-usage');
+
+ return items;
+}
diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts
index 599c8e586b..6ef47208a8 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -571,6 +571,17 @@ const SETTINGS_SCHEMA = {
description: 'Settings for the footer.',
showInDialog: false,
properties: {
+ items: {
+ type: 'array',
+ label: 'Footer Items',
+ category: 'UI',
+ requiresRestart: false,
+ default: undefined as string[] | undefined,
+ description:
+ 'List of item IDs to display in the footer. Rendered in order',
+ showInDialog: false,
+ items: { type: 'string' },
+ },
hideCWD: {
type: 'boolean',
label: 'Hide CWD',
diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts
index 31673e921a..f867f84c80 100644
--- a/packages/cli/src/services/BuiltinCommandLoader.ts
+++ b/packages/cli/src/services/BuiltinCommandLoader.ts
@@ -31,6 +31,7 @@ import { docsCommand } from '../ui/commands/docsCommand.js';
import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
+import { footerCommand } from '../ui/commands/footerCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js';
import { rewindCommand } from '../ui/commands/rewindCommand.js';
@@ -119,6 +120,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
]
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
helpCommand,
+ footerCommand,
shortcutsCommand,
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
rewindCommand,
diff --git a/packages/cli/src/ui/commands/footerCommand.tsx b/packages/cli/src/ui/commands/footerCommand.tsx
new file mode 100644
index 0000000000..7779e5649f
--- /dev/null
+++ b/packages/cli/src/ui/commands/footerCommand.tsx
@@ -0,0 +1,25 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ type SlashCommand,
+ type CommandContext,
+ type OpenCustomDialogActionReturn,
+ CommandKind,
+} from './types.js';
+import { FooterConfigDialog } from '../components/FooterConfigDialog.js';
+
+export const footerCommand: SlashCommand = {
+ name: 'footer',
+ altNames: ['statusline', 'status-line', 'status'],
+ description: 'Configure which items appear in the footer (statusline)',
+ kind: CommandKind.BUILT_IN,
+ autoExecute: true,
+ action: (context: CommandContext): OpenCustomDialogActionReturn => ({
+ type: 'custom_dialog',
+ component: ,
+ }),
+};
diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx
index 9babae6ce3..15112dfc3a 100644
--- a/packages/cli/src/ui/components/Footer.tsx
+++ b/packages/cli/src/ui/components/Footer.tsx
@@ -58,63 +58,202 @@ export const Footer: React.FC = () => {
quotaStats: uiState.quota.stats,
};
- const showMemoryUsage =
- config.getDebugMode() || settings.merged.ui.showMemoryUsage;
- const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full';
- const showErrorSummary =
- !showErrorDetails && errorCount > 0 && (isFullErrorVerbosity || debugMode);
- const hideCWD = settings.merged.ui.footer.hideCWD;
- const hideSandboxStatus = settings.merged.ui.footer.hideSandboxStatus;
- const hideModelInfo = settings.merged.ui.footer.hideModelInfo;
- const hideContextPercentage = settings.merged.ui.footer.hideContextPercentage;
-
- const pathLength = Math.max(20, Math.floor(terminalWidth * 0.25));
- const displayPath = shortenPath(tildeifyPath(targetDir), pathLength);
-
- const justifyContent = hideCWD && hideModelInfo ? 'center' : 'space-between';
const displayVimMode = vimEnabled ? vimMode : undefined;
- const showDebugProfiler = debugMode || isDevelopment;
+ const hasCustomItems = settings.merged.ui.footer.items != null;
- return (
-
- {(showDebugProfiler || displayVimMode || !hideCWD) && (
-
- {showDebugProfiler && }
- {displayVimMode && (
- [{displayVimMode}]
- )}
- {!hideCWD && (
-
- {displayPath}
- {branchName && (
- ({branchName}*)
+ if (!hasCustomItems) {
+ const showMemoryUsage =
+ config.getDebugMode() || settings.merged.ui.showMemoryUsage;
+ const hideCWD = settings.merged.ui.footer.hideCWD;
+ const hideSandboxStatus = settings.merged.ui.footer.hideSandboxStatus;
+ const hideModelInfo = settings.merged.ui.footer.hideModelInfo;
+ const hideContextPercentage =
+ settings.merged.ui.footer.hideContextPercentage;
+
+ const pathLength = Math.max(20, Math.floor(terminalWidth * 0.25));
+ const displayPath = shortenPath(tildeifyPath(targetDir), pathLength);
+
+ const justifyContent =
+ hideCWD && hideModelInfo ? 'center' : 'space-between';
+
+ const showDebugProfiler = debugMode || isDevelopment;
+
+ return (
+
+ {(showDebugProfiler || displayVimMode || !hideCWD) && (
+
+ {showDebugProfiler && }
+ {displayVimMode && (
+ [{displayVimMode}]
+ )}
+ {!hideCWD && (
+
+ {displayPath}
+ {branchName && (
+ ({branchName}*)
+ )}
+
+ )}
+ {debugMode && (
+
+ {' ' + (debugMessage || '--debug')}
+
+ )}
+
+ )}
+
+ {/* Middle Section: Centered Trust/Sandbox Info */}
+ {!hideSandboxStatus && (
+
+ {isTrustedFolder === false ? (
+ untrusted
+ ) : process.env['SANDBOX'] &&
+ process.env['SANDBOX'] !== 'sandbox-exec' ? (
+
+ {process.env['SANDBOX'].replace(/^gemini-(?:cli-)?/, '')}
+
+ ) : process.env['SANDBOX'] === 'sandbox-exec' ? (
+
+ macOS Seatbelt{' '}
+
+ ({process.env['SEATBELT_PROFILE']})
+
+
+ ) : (
+
+ no sandbox
+ {terminalWidth >= 100 && (
+ (see /docs)
+ )}
+
+ )}
+
+ )}
+
+ {/* Right Section: Gemini Label and Console Summary */}
+ {!hideModelInfo && (
+
+
+
+ /model
+ {getDisplayString(model)}
+ {!hideContextPercentage && (
+ <>
+ {' '}
+
+ >
+ )}
+ {quotaStats && (
+ <>
+ {' '}
+
+ >
+ )}
+
+ {showMemoryUsage && }
+
+
+ {corgiMode && (
+
+
+ |
+ ▼
+ (´
+ ᴥ
+ `)
+ ▼
+
+
)}
-
- )}
- {debugMode && (
-
- {' ' + (debugMessage || '--debug')}
-
- )}
-
- )}
+ {!showErrorDetails && errorCount > 0 && (
+
+ |
+
+
+ )}
+
+
+ )}
+
+ );
+ }
- {/* Middle Section: Centered Trust/Sandbox Info */}
- {!hideSandboxStatus && (
-
- {isTrustedFolder === false ? (
+ // Items-based rendering path
+ const items = settings.merged.ui.footer.items ?? [];
+ const elements: React.ReactNode[] = [];
+
+ const addElement = (id: string, element: React.ReactNode) => {
+ if (elements.length > 0) {
+ elements.push(
+
+ {' | '}
+ ,
+ );
+ }
+ elements.push({element});
+ };
+
+ // Prepend Vim mode if enabled
+ if (displayVimMode) {
+ elements.push(
+
+ [{displayVimMode}]
+ ,
+ );
+ }
+
+ for (const id of items) {
+ switch (id) {
+ case 'cwd': {
+ const pathLength = Math.max(20, Math.floor(terminalWidth * 0.25));
+ const displayPath = shortenPath(tildeifyPath(targetDir), pathLength);
+ addElement(
+ id,
+
+ {displayPath}
+ {debugMode && (
+
+ {' ' + (debugMessage || '--debug')}
+
+ )}
+ ,
+ );
+ break;
+ }
+ case 'git-branch': {
+ if (branchName) {
+ addElement(
+ id,
+ {branchName}*,
+ );
+ }
+ break;
+ }
+ case 'sandbox-status': {
+ addElement(
+ id,
+ isTrustedFolder === false ? (
untrusted
) : process.env['SANDBOX'] &&
process.env['SANDBOX'] !== 'sandbox-exec' ? (
@@ -129,69 +268,119 @@ export const Footer: React.FC = () => {
) : (
-
- no sandbox
- {terminalWidth >= 100 && (
- (see /docs)
- )}
-
- )}
-
- )}
+ no sandbox
+ ),
+ );
+ break;
+ }
+ case 'model-name': {
+ addElement(
+ id,
+ {getDisplayString(model)},
+ );
+ break;
+ }
+ case 'context-remaining': {
+ addElement(
+ id,
+ ,
+ );
+ break;
+ }
+ case 'quota': {
+ if (quotaStats) {
+ addElement(
+ id,
+ ,
+ );
+ }
+ break;
+ }
+ case 'memory-usage': {
+ addElement(id, );
+ break;
+ }
+ case 'error-count': {
+ if (!showErrorDetails && errorCount > 0) {
+ addElement(id, );
+ }
+ break;
+ }
+ case 'session-id': {
+ const idShort = uiState.sessionStats.sessionId.slice(0, 8);
+ addElement(id, {idShort});
+ break;
+ }
+ case 'code-changes': {
+ const added = uiState.sessionStats.metrics.files.totalLinesAdded;
+ const removed = uiState.sessionStats.metrics.files.totalLinesRemoved;
+ if (added > 0 || removed > 0) {
+ addElement(
+ id,
+
+ +{added}{' '}
+ -{removed}
+ ,
+ );
+ }
+ break;
+ }
+ case 'token-count': {
+ let totalTokens = 0;
+ for (const m of Object.values(uiState.sessionStats.metrics.models)) {
+ totalTokens += m.tokens.total;
+ }
+ if (totalTokens > 0) {
+ const formatted =
+ totalTokens > 1000
+ ? `${(totalTokens / 1000).toFixed(1)}k`
+ : totalTokens;
+ addElement(
+ id,
+ tokens:{formatted},
+ );
+ }
+ break;
+ }
+ case 'corgi': {
+ if (corgiMode) {
+ addElement(
+ id,
+
+ ▼
+ (´
+ ᴥ
+ `)
+ ▼
+ ,
+ );
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ }
- {/* Right Section: Gemini Label and Console Summary */}
- {!hideModelInfo && (
-
-
-
- /model
- {getDisplayString(model)}
- {!hideContextPercentage && (
- <>
- {' '}
-
- >
- )}
- {quotaStats && (
- <>
- {' '}
-
- >
- )}
-
- {showMemoryUsage && }
-
-
- {corgiMode && (
-
-
- |
- ▼
- (´
- ᴥ
- `)
- ▼
-
-
- )}
- {showErrorSummary && (
-
- |
-
-
- )}
-
-
- )}
+ return (
+
+ {elements}
);
};
diff --git a/packages/cli/src/ui/components/FooterConfigDialog.tsx b/packages/cli/src/ui/components/FooterConfigDialog.tsx
new file mode 100644
index 0000000000..05d8422b4f
--- /dev/null
+++ b/packages/cli/src/ui/components/FooterConfigDialog.tsx
@@ -0,0 +1,322 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { useCallback, useMemo, useState, useEffect } from 'react';
+import { Box, Text } from 'ink';
+import { theme } from '../semantic-colors.js';
+import { useSettingsStore } from '../contexts/SettingsContext.js';
+import { useKeypress, type Key } from '../hooks/useKeypress.js';
+import { keyMatchers, Command } from '../keyMatchers.js';
+import { TextInput } from './shared/TextInput.js';
+import { useFuzzyList } from '../hooks/useFuzzyList.js';
+import {
+ ALL_ITEMS,
+ DEFAULT_ORDER,
+ deriveItemsFromLegacySettings,
+} from '../../config/footerItems.js';
+import { SettingScope } from '../../config/settings.js';
+
+interface FooterConfigDialogProps {
+ onClose?: () => void;
+}
+
+export const FooterConfigDialog: React.FC = ({
+ onClose,
+}) => {
+ const { settings, setSetting } = useSettingsStore();
+
+ // Initialize orderedIds and selectedIds
+ const [orderedIds, setOrderedIds] = useState(() => {
+ if (settings.merged.ui?.footer?.items) {
+ // Start with saved items in their saved order
+ const savedItems = settings.merged.ui.footer.items;
+ // Then add any items from DEFAULT_ORDER that aren't in savedItems
+ const others = DEFAULT_ORDER.filter((id) => !savedItems.includes(id));
+ return [...savedItems, ...others];
+ }
+ // Fallback to legacy settings derivation
+ const derived = deriveItemsFromLegacySettings(settings.merged);
+ const others = DEFAULT_ORDER.filter((id) => !derived.includes(id));
+ return [...derived, ...others];
+ });
+
+ const [selectedIds, setSelectedIds] = useState>(() => {
+ if (settings.merged.ui?.footer?.items) {
+ return new Set(settings.merged.ui.footer.items);
+ }
+ return new Set(deriveItemsFromLegacySettings(settings.merged));
+ });
+
+ // Prepare items for fuzzy list
+ const listItems = useMemo(
+ () =>
+ orderedIds.map((id) => {
+ const item = ALL_ITEMS.find((i) => i.id === id)!;
+ return {
+ key: id,
+ label: item.id,
+ description: item.description,
+ };
+ }),
+ [orderedIds],
+ );
+
+ const { filteredItems, searchBuffer, searchQuery, maxLabelWidth } =
+ useFuzzyList({
+ items: listItems,
+ });
+
+ const [activeIndex, setActiveIndex] = useState(0);
+ const [scrollOffset, setScrollOffset] = useState(0);
+ const maxItemsToShow = 10;
+
+ // Reset index when search changes
+ useEffect(() => {
+ setActiveIndex(0);
+ setScrollOffset(0);
+ }, [searchQuery]);
+
+ const handleConfirm = useCallback(async () => {
+ const item = filteredItems[activeIndex];
+ if (!item) return;
+
+ const next = new Set(selectedIds);
+ if (next.has(item.key)) {
+ next.delete(item.key);
+ } else {
+ next.add(item.key);
+ }
+ setSelectedIds(next);
+
+ // Save immediately on toggle
+ const finalItems = orderedIds.filter((id) => next.has(id));
+ setSetting(SettingScope.User, 'ui.footer.items', finalItems);
+ }, [filteredItems, activeIndex, orderedIds, setSetting, selectedIds]);
+
+ const handleReorder = useCallback(
+ (direction: number) => {
+ if (searchQuery) return; // Reorder disabled when searching
+
+ const currentItem = filteredItems[activeIndex];
+ if (!currentItem) return;
+
+ const currentId = currentItem.key;
+ const currentIndex = orderedIds.indexOf(currentId);
+ const newIndex = currentIndex + direction;
+
+ if (newIndex < 0 || newIndex >= orderedIds.length) return;
+
+ const newOrderedIds = [...orderedIds];
+ [newOrderedIds[currentIndex], newOrderedIds[newIndex]] = [
+ newOrderedIds[newIndex],
+ newOrderedIds[currentIndex],
+ ];
+ setOrderedIds(newOrderedIds);
+ setActiveIndex(newIndex);
+
+ // Save immediately on reorder
+ const finalItems = newOrderedIds.filter((id) => selectedIds.has(id));
+ setSetting(SettingScope.User, 'ui.footer.items', finalItems);
+
+ // Adjust scroll offset if needed
+ if (newIndex < scrollOffset) {
+ setScrollOffset(newIndex);
+ } else if (newIndex >= scrollOffset + maxItemsToShow) {
+ setScrollOffset(newIndex - maxItemsToShow + 1);
+ }
+ },
+ [
+ searchQuery,
+ filteredItems,
+ activeIndex,
+ orderedIds,
+ scrollOffset,
+ maxItemsToShow,
+ selectedIds,
+ setSetting,
+ ],
+ );
+
+ useKeypress(
+ (key: Key) => {
+ if (keyMatchers[Command.ESCAPE](key)) {
+ onClose?.();
+ return true;
+ }
+
+ if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
+ const newIndex =
+ activeIndex > 0 ? activeIndex - 1 : filteredItems.length - 1;
+ setActiveIndex(newIndex);
+ if (newIndex === filteredItems.length - 1) {
+ setScrollOffset(Math.max(0, filteredItems.length - maxItemsToShow));
+ } else if (newIndex < scrollOffset) {
+ setScrollOffset(newIndex);
+ }
+ return true;
+ }
+
+ if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
+ const newIndex =
+ activeIndex < filteredItems.length - 1 ? activeIndex + 1 : 0;
+ setActiveIndex(newIndex);
+ if (newIndex === 0) {
+ setScrollOffset(0);
+ } else if (newIndex >= scrollOffset + maxItemsToShow) {
+ setScrollOffset(newIndex - maxItemsToShow + 1);
+ }
+ return true;
+ }
+
+ if (keyMatchers[Command.MOVE_LEFT](key)) {
+ handleReorder(-1);
+ return true;
+ }
+
+ if (keyMatchers[Command.MOVE_RIGHT](key)) {
+ handleReorder(1);
+ return true;
+ }
+
+ if (keyMatchers[Command.RETURN](key)) {
+ void handleConfirm();
+ return true;
+ }
+
+ return false;
+ },
+ { isActive: true, priority: true },
+ );
+
+ const visibleItems = filteredItems.slice(
+ scrollOffset,
+ scrollOffset + maxItemsToShow,
+ );
+
+ // Preview logic
+ const previewText = useMemo(() => {
+ const itemsToPreview = orderedIds.filter((id) => selectedIds.has(id));
+ if (itemsToPreview.length === 0) return 'Empty Footer';
+
+ // Mock values for preview
+ const mockValues: Record = {
+ cwd: ~/dev/gemini-cli,
+ 'git-branch': main*,
+ 'sandbox-status': macOS Seatbelt,
+ 'model-name': (
+
+ gemini-2.5-pro
+
+ ),
+ 'context-remaining': 85%,
+ quota: 1.2k left,
+ 'memory-usage': 124MB,
+ 'error-count': 2 errors,
+ 'session-id': 769992f9,
+ 'code-changes': (
+
+ +12
+
+ -4
+
+ ),
+ 'token-count': tokens:1.5k,
+ corgi: 🐶,
+ };
+
+ const elements: React.ReactNode[] = [];
+ itemsToPreview.forEach((id, idx) => {
+ if (idx > 0) {
+ elements.push(
+
+ {' | '}
+ ,
+ );
+ }
+ elements.push({mockValues[id] || id});
+ });
+
+ return elements;
+ }, [orderedIds, selectedIds]);
+
+ return (
+
+ Configure Footer
+
+ Select which items to display in the footer.
+
+
+
+ Type to search
+
+ {searchBuffer && }
+
+
+
+
+ {visibleItems.length === 0 ? (
+ No items found.
+ ) : (
+ visibleItems.map((item, idx) => {
+ const index = scrollOffset + idx;
+ const isFocused = index === activeIndex;
+ const isChecked = selectedIds.has(item.key);
+
+ return (
+
+
+ {isFocused ? '> ' : ' '}
+
+
+ [{isChecked ? '✓' : ' '}]{' '}
+ {item.label.padEnd(maxLabelWidth + 1)}
+
+ {item.description}
+
+ );
+ })
+ )}
+
+
+
+
+ ↑/↓ navigate · ←/→ reorder · enter select · esc close
+
+ {searchQuery && (
+
+ Reordering is disabled when searching.
+
+ )}
+
+
+
+ Preview:
+ {previewText}
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/__tests__/FooterConfigDialog.test.tsx b/packages/cli/src/ui/components/__tests__/FooterConfigDialog.test.tsx
new file mode 100644
index 0000000000..ee150858b1
--- /dev/null
+++ b/packages/cli/src/ui/components/__tests__/FooterConfigDialog.test.tsx
@@ -0,0 +1,126 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderWithProviders } from '../../../test-utils/render.js';
+import { waitFor } from '../../../test-utils/async.js';
+import { FooterConfigDialog } from '../FooterConfigDialog.js';
+import { createMockSettings } from '../../../test-utils/settings.js';
+import { act } from 'react';
+
+describe('', () => {
+ const mockOnClose = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders correctly with default settings', () => {
+ const settings = createMockSettings();
+ const { lastFrame } = renderWithProviders(
+ ,
+ { settings },
+ );
+
+ const output = lastFrame();
+ expect(output).toBeDefined();
+ expect(output).toContain('Configure Footer');
+ expect(output).toContain('[✓] cwd');
+ expect(output).toContain('[ ] session-id');
+ });
+
+ it('toggles an item when enter is pressed', async () => {
+ const settings = createMockSettings();
+ const { lastFrame, stdin } = renderWithProviders(
+ ,
+ { settings },
+ );
+
+ // Initial state: cwd is checked by default and highlighted
+
+ act(() => {
+ stdin.write('\r'); // Enter to toggle
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('[ ] cwd');
+ });
+
+ // Toggle it back
+ act(() => {
+ stdin.write('\r');
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('[✓] cwd');
+ });
+ });
+
+ it('filters items when typing in search', async () => {
+ const settings = createMockSettings();
+ const { lastFrame, stdin } = renderWithProviders(
+ ,
+ { settings },
+ );
+
+ await act(async () => {
+ stdin.write('session');
+ // Give search a moment to trigger and re-render
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ });
+
+ await waitFor(() => {
+ const output = lastFrame();
+ expect(output).toBeDefined();
+ expect(output).toContain('session-id');
+ expect(output).not.toContain('model-name');
+ });
+ });
+
+ it('reorders items with arrow keys', async () => {
+ const settings = createMockSettings();
+ const { lastFrame, stdin } = renderWithProviders(
+ ,
+ { settings },
+ );
+
+ // Initial order: cwd, git-branch, ...
+ const output = lastFrame();
+ expect(output).toBeDefined();
+ const cwdIdx = output!.indexOf('cwd');
+ const branchIdx = output!.indexOf('git-branch');
+ expect(cwdIdx).toBeLessThan(branchIdx);
+
+ // Move cwd down (right arrow)
+ act(() => {
+ stdin.write('\u001b[C'); // Right arrow
+ });
+
+ await waitFor(() => {
+ const outputAfter = lastFrame();
+ expect(outputAfter).toBeDefined();
+ const cwdIdxAfter = outputAfter!.indexOf('cwd');
+ const branchIdxAfter = outputAfter!.indexOf('git-branch');
+ expect(branchIdxAfter).toBeLessThan(cwdIdxAfter);
+ });
+ });
+
+ it('closes on Esc', async () => {
+ const settings = createMockSettings();
+ const { stdin } = renderWithProviders(
+ ,
+ { settings },
+ );
+
+ act(() => {
+ stdin.write('\x1b'); // Esc
+ });
+
+ await waitFor(() => {
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/__tests__/FooterCustomItems.test.tsx b/packages/cli/src/ui/components/__tests__/FooterCustomItems.test.tsx
new file mode 100644
index 0000000000..1c90fbd4bc
--- /dev/null
+++ b/packages/cli/src/ui/components/__tests__/FooterCustomItems.test.tsx
@@ -0,0 +1,151 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import { renderWithProviders } from '../../../test-utils/render.js';
+import { createMockSettings } from '../../../test-utils/settings.js';
+import { Footer } from '../Footer.js';
+import { ToolCallDecision } from '@google/gemini-cli-core';
+import type { SessionStatsState } from '../../contexts/SessionContext.js';
+
+const mockSessionStats: SessionStatsState = {
+ sessionId: 'test-session-id-12345',
+ sessionStartTime: new Date(),
+ lastPromptTokenCount: 0,
+ promptCount: 0,
+ metrics: {
+ models: {
+ 'gemini-pro': {
+ api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 0 },
+ tokens: {
+ input: 100,
+ prompt: 0,
+ candidates: 50,
+ total: 150,
+ cached: 0,
+ thoughts: 0,
+ tool: 0,
+ },
+ },
+ },
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: {
+ accept: 0,
+ reject: 0,
+ modify: 0,
+ [ToolCallDecision.AUTO_ACCEPT]: 0,
+ },
+ byName: {},
+ },
+ files: {
+ totalLinesAdded: 12,
+ totalLinesRemoved: 4,
+ },
+ },
+};
+
+describe('Footer Custom Items', () => {
+ it('renders items in the specified order', () => {
+ const { lastFrame } = renderWithProviders(, {
+ width: 120,
+ uiState: {
+ currentModel: 'gemini-pro',
+ sessionStats: mockSessionStats,
+ },
+ settings: createMockSettings({
+ ui: {
+ footer: {
+ items: ['session-id', 'code-changes', 'token-count'],
+ },
+ },
+ }),
+ });
+
+ const output = lastFrame();
+ expect(output).toBeDefined();
+ expect(output).toContain('test-ses');
+ expect(output).toContain('+12 -4');
+ expect(output).toContain('tokens:150');
+
+ // Check order
+ const idIdx = output!.indexOf('test-ses');
+ const codeIdx = output!.indexOf('+12 -4');
+ const tokenIdx = output!.indexOf('tokens:150');
+
+ expect(idIdx).toBeLessThan(codeIdx);
+ expect(codeIdx).toBeLessThan(tokenIdx);
+ });
+
+ it('renders all items with dividers', () => {
+ const { lastFrame } = renderWithProviders(, {
+ width: 120,
+ uiState: {
+ currentModel: 'gemini-pro',
+ sessionStats: mockSessionStats,
+ branchName: 'main',
+ },
+ settings: createMockSettings({
+ general: {
+ vimMode: true,
+ },
+ ui: {
+ footer: {
+ items: ['vim-mode', 'cwd', 'git-branch', 'model-name'],
+ },
+ },
+ }),
+ });
+
+ const output = lastFrame();
+ expect(output).toBeDefined();
+ expect(output).toContain('|');
+ expect(output!.split('|').length).toBe(4);
+ });
+
+ it('handles empty items array', () => {
+ const { lastFrame } = renderWithProviders(, {
+ width: 120,
+ uiState: { sessionStats: mockSessionStats },
+ settings: createMockSettings({
+ ui: {
+ footer: {
+ items: [],
+ },
+ },
+ }),
+ });
+
+ const output = lastFrame();
+ expect(output).toBeDefined();
+ expect(output!.trim()).toBe('');
+ });
+
+ it('does not render items that are conditionally hidden', () => {
+ const { lastFrame } = renderWithProviders(, {
+ width: 120,
+ uiState: {
+ sessionStats: mockSessionStats,
+ branchName: undefined, // No branch
+ },
+ settings: createMockSettings({
+ ui: {
+ footer: {
+ items: ['cwd', 'git-branch', 'model-name'],
+ },
+ },
+ }),
+ });
+
+ const output = lastFrame();
+ expect(output).toBeDefined();
+ expect(output).not.toContain('('); // Branch is usually in (branch*)
+ expect(output!.split('|').length).toBe(2); // Only cwd and model-name
+ });
+});