/** * @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 { shortenPath, tildeifyPath, getDisplayString, checkExhaustive, } from '@google/gemini-cli-core'; import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js'; import process from 'node:process'; import { MemoryUsageDisplay } from './MemoryUsageDisplay.js'; import { ContextUsageDisplay } from './ContextUsageDisplay.js'; import { QuotaDisplay } from './QuotaDisplay.js'; import { DebugProfiler } from './DebugProfiler.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { ALL_ITEMS, type FooterItemId, deriveItemsFromLegacySettings, } from '../../config/footerItems.js'; import { isDevelopment } from '../../utils/installationInfo.js'; interface CwdIndicatorProps { targetDir: string; maxWidth: number; debugMode?: boolean; debugMessage?: string; color?: string; } const CwdIndicator: React.FC = ({ targetDir, maxWidth, debugMode, debugMessage, color = theme.text.primary, }) => { const debugSuffix = debugMode ? ' ' + (debugMessage || '--debug') : ''; const availableForPath = Math.max(10, maxWidth - debugSuffix.length); const displayPath = shortenPath(tildeifyPath(targetDir), availableForPath); return ( {displayPath} {debugMode && {debugSuffix}} ); }; interface SandboxIndicatorProps { isTrustedFolder: boolean | undefined; } const SandboxIndicator: React.FC = ({ isTrustedFolder, }) => { if (isTrustedFolder === false) { return untrusted; } const sandbox = process.env['SANDBOX']; if (sandbox && sandbox !== 'sandbox-exec') { return ( {sandbox.replace(/^gemini-(?:cli-)?/, '')} ); } if (sandbox === 'sandbox-exec') { return ( macOS Seatbelt{' '} ({process.env['SEATBELT_PROFILE']}) ); } return no sandbox; }; const CorgiIndicator: React.FC = () => ( `) ); export interface FooterRowItem { key: string; header: string; element: React.ReactNode; flexGrow?: number; flexShrink?: number; isFocused?: boolean; alignItems?: 'flex-start' | 'center' | 'flex-end'; } const COLUMN_GAP = 3; export const FooterRow: React.FC<{ items: FooterRowItem[]; showLabels: boolean; }> = ({ items, showLabels }) => { const elements: React.ReactNode[] = []; items.forEach((item, idx) => { if (idx > 0) { elements.push( {!showLabels && · } , ); } elements.push( {showLabels && ( {item.header} )} {item.element} , ); }); return ( {elements} ); }; function isFooterItemId(id: string): id is FooterItemId { return ALL_ITEMS.some((i) => i.id === id); } interface FooterColumn { id: string; header: string; element: (maxWidth: number) => React.ReactNode; width: number; isHighPriority: boolean; } export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({ copyModeEnabled = false, }) => { const uiState = useUIState(); const config = useConfig(); const settings = useSettings(); const { vimEnabled, vimMode } = useVimMode(); if (copyModeEnabled) { return ; } const { model, targetDir, debugMode, branchName, debugMessage, corgiMode, errorCount, showErrorDetails, promptTokenCount, isTrustedFolder, terminalWidth, quotaStats, } = { model: uiState.currentModel, targetDir: config.getTargetDir(), debugMode: config.getDebugMode(), branchName: uiState.branchName, debugMessage: uiState.debugMessage, corgiMode: uiState.corgiMode, errorCount: uiState.errorCount, showErrorDetails: uiState.showErrorDetails, promptTokenCount: uiState.sessionStats.lastPromptTokenCount, isTrustedFolder: uiState.isTrustedFolder, terminalWidth: uiState.terminalWidth, quotaStats: uiState.quota.stats, }; const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full'; const showErrorSummary = !showErrorDetails && errorCount > 0 && (isFullErrorVerbosity || debugMode || isDevelopment); const displayVimMode = vimEnabled ? vimMode : undefined; const items = settings.merged.ui.footer.items ?? deriveItemsFromLegacySettings(settings.merged); const showLabels = settings.merged.ui.footer.showLabels !== false; const itemColor = showLabels ? theme.text.primary : theme.ui.comment; const potentialColumns: FooterColumn[] = []; const addCol = ( id: string, header: string, element: (maxWidth: number) => React.ReactNode, dataWidth: number, isHighPriority = false, ) => { potentialColumns.push({ id, header: showLabels ? header : '', element, width: Math.max(dataWidth, showLabels ? header.length : 0), isHighPriority, }); }; // 1. System Indicators (Far Left, high priority) if (uiState.showDebugProfiler) { addCol('debug', '', () => , 45, true); } if (displayVimMode) { const vimStr = `[${displayVimMode}]`; addCol( 'vim', '', () => {vimStr}, vimStr.length, true, ); } // 2. Main Configurable Items for (const id of items) { if (!isFooterItemId(id)) continue; const itemConfig = ALL_ITEMS.find((i) => i.id === id); const header = itemConfig?.header ?? id; switch (id) { case 'workspace': { const fullPath = tildeifyPath(targetDir); const debugSuffix = debugMode ? ' ' + (debugMessage || '--debug') : ''; addCol( id, header, (maxWidth) => ( ), fullPath.length + debugSuffix.length, ); break; } case 'git-branch': { if (branchName) { addCol( id, header, () => {branchName}, branchName.length, ); } break; } case 'sandbox': { let str = 'no sandbox'; const sandbox = process.env['SANDBOX']; if (isTrustedFolder === false) str = 'untrusted'; else if (sandbox === 'sandbox-exec') str = `macOS Seatbelt (${process.env['SEATBELT_PROFILE']})`; else if (sandbox) str = sandbox.replace(/^gemini-(?:cli-)?/, ''); addCol( id, header, () => , str.length, ); break; } case 'model-name': { const str = getDisplayString(model); addCol( id, header, () => {str}, str.length, ); break; } case 'context-used': { addCol( id, header, () => ( ), 10, // "100% used" is 9 chars ); break; } case 'quota': { if (quotaStats?.remaining !== undefined && quotaStats.limit) { addCol( id, header, () => ( ), 10, // "daily 100%" is 10 chars, but terse is "100%" (4 chars) ); } break; } case 'memory-usage': { addCol( id, header, () => ( ), 10, ); break; } case 'session-id': { addCol( id, header, () => ( {uiState.sessionStats.sessionId.slice(0, 8)} ), 8, ); break; } case 'code-changes': { const added = uiState.sessionStats.metrics.files.totalLinesAdded; const removed = uiState.sessionStats.metrics.files.totalLinesRemoved; if (added > 0 || removed > 0) { const str = `+${added} -${removed}`; addCol( id, header, () => ( +{added}{' '} -{removed} ), str.length, ); } break; } case 'token-count': { let total = 0; for (const m of Object.values(uiState.sessionStats.metrics.models)) total += m.tokens.total; if (total > 0) { const formatter = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1, }); const formatted = formatter.format(total).toLowerCase(); addCol( id, header, () => {formatted} tokens, formatted.length + 7, ); } break; } default: checkExhaustive(id); break; } } // 3. Transients if (corgiMode) addCol('corgi', '', () => , 5); if (showErrorSummary) { addCol( 'error-count', '', () => , 12, true, ); } // --- Width Fitting Logic --- const columnsToRender: FooterColumn[] = []; let droppedAny = false; let currentUsedWidth = 2; // Initial padding for (const col of potentialColumns) { const gap = columnsToRender.length > 0 ? (showLabels ? COLUMN_GAP : 3) : 0; const budgetWidth = col.id === 'workspace' ? 20 : col.width; if ( col.isHighPriority || currentUsedWidth + gap + budgetWidth <= terminalWidth - 2 ) { columnsToRender.push(col); currentUsedWidth += gap + budgetWidth; } else { droppedAny = true; } } const rowItems: FooterRowItem[] = columnsToRender.map((col, index) => { const isWorkspace = col.id === 'workspace'; const isLast = index === columnsToRender.length - 1; // Calculate exact space available for growth to prevent over-estimation truncation const otherItemsWidth = columnsToRender .filter((c) => c.id !== 'workspace') .reduce((sum, c) => sum + c.width, 0); const numItems = columnsToRender.length + (droppedAny ? 1 : 0); const numGaps = numItems > 1 ? numItems - 1 : 0; const gapsWidth = numGaps * (showLabels ? COLUMN_GAP : 3); const ellipsisWidth = droppedAny ? 1 : 0; const availableForWorkspace = Math.max( 20, terminalWidth - 2 - gapsWidth - otherItemsWidth - ellipsisWidth, ); const estimatedWidth = isWorkspace ? availableForWorkspace : col.width; return { key: col.id, header: col.header, element: col.element(estimatedWidth), flexGrow: 0, flexShrink: isWorkspace ? 1 : 0, alignItems: isLast && !droppedAny && index > 0 ? 'flex-end' : 'flex-start', }; }); if (droppedAny) { rowItems.push({ key: 'ellipsis', header: '', element: , flexGrow: 0, flexShrink: 0, alignItems: 'flex-end', }); } return ( ); };