From 4476c35e4d8e93edb7de24e10b973af2423ed75f Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Fri, 13 Feb 2026 07:04:28 -0500 Subject: [PATCH 01/29] feat: first pass at /footer --- .../src/config/__tests__/footerItems.test.ts | 93 ++++ packages/cli/src/config/footerItems.ts | 142 ++++++ packages/cli/src/config/settingsSchema.ts | 11 + .../cli/src/services/BuiltinCommandLoader.ts | 2 + .../cli/src/ui/commands/footerCommand.tsx | 25 ++ packages/cli/src/ui/components/Footer.tsx | 417 +++++++++++++----- .../src/ui/components/FooterConfigDialog.tsx | 322 ++++++++++++++ .../__tests__/FooterConfigDialog.test.tsx | 126 ++++++ .../__tests__/FooterCustomItems.test.tsx | 151 +++++++ 9 files changed, 1175 insertions(+), 114 deletions(-) create mode 100644 packages/cli/src/config/__tests__/footerItems.test.ts create mode 100644 packages/cli/src/config/footerItems.ts create mode 100644 packages/cli/src/ui/commands/footerCommand.tsx create mode 100644 packages/cli/src/ui/components/FooterConfigDialog.tsx create mode 100644 packages/cli/src/ui/components/__tests__/FooterConfigDialog.test.tsx create mode 100644 packages/cli/src/ui/components/__tests__/FooterCustomItems.test.tsx 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(