Files
gemini-cli/packages/cli/src/ui/components/Footer.tsx
T

512 lines
13 KiB
TypeScript
Raw Normal View History

/**
* @license
2026-02-09 21:53:10 -05:00
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
2025-04-15 21:41:08 -07:00
import { Box, Text } from 'ink';
2025-08-07 16:11:35 -07:00
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';
2026-02-09 21:53:10 -05:00
import { QuotaDisplay } from './QuotaDisplay.js';
2025-07-30 17:43:11 -07:00
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<CwdIndicatorProps> = ({
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 (
<Text color={color}>
{displayPath}
{debugMode && <Text color={theme.status.error}>{debugSuffix}</Text>}
</Text>
);
};
interface SandboxIndicatorProps {
isTrustedFolder: boolean | undefined;
}
const SandboxIndicator: React.FC<SandboxIndicatorProps> = ({
isTrustedFolder,
}) => {
if (isTrustedFolder === false) {
return <Text color={theme.status.warning}>untrusted</Text>;
}
const sandbox = process.env['SANDBOX'];
if (sandbox && sandbox !== 'sandbox-exec') {
return (
<Text color="green">{sandbox.replace(/^gemini-(?:cli-)?/, '')}</Text>
);
}
if (sandbox === 'sandbox-exec') {
return (
<Text color={theme.status.warning}>
macOS Seatbelt{' '}
<Text color={theme.ui.comment}>
({process.env['SEATBELT_PROFILE']})
</Text>
</Text>
);
}
return <Text color={theme.status.error}>no sandbox</Text>;
};
const CorgiIndicator: React.FC = () => (
<Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>(´</Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>`)</Text>
<Text color={theme.status.error}>▼</Text>
</Text>
);
export interface FooterRowItem {
key: string;
header: string;
element: React.ReactNode;
2026-03-08 00:36:54 -08:00
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(
<Box
key={`sep-${item.key}`}
flexGrow={1}
flexShrink={1}
minWidth={showLabels ? COLUMN_GAP : 3}
justifyContent="center"
alignItems="center"
>
{!showLabels && <Text color={theme.ui.comment}> · </Text>}
</Box>,
);
}
elements.push(
2026-03-08 00:36:54 -08:00
<Box
key={item.key}
flexDirection="column"
flexGrow={item.flexGrow ?? 0}
flexShrink={item.flexShrink ?? 1}
alignItems={item.alignItems}
2026-03-08 00:36:54 -08:00
backgroundColor={item.isFocused ? theme.background.focus : undefined}
>
{showLabels && (
<Box height={1}>
2026-03-08 00:36:54 -08:00
<Text
color={item.isFocused ? theme.text.primary : theme.ui.comment}
>
{item.header}
</Text>
</Box>
)}
<Box height={1}>{item.element}</Box>
</Box>,
);
});
return (
<Box flexDirection="row" flexWrap="nowrap" width="100%">
{elements}
</Box>
);
};
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 <Box height={1} />;
}
const {
model,
targetDir,
debugMode,
branchName,
debugMessage,
corgiMode,
errorCount,
showErrorDetails,
promptTokenCount,
isTrustedFolder,
terminalWidth,
2026-02-09 21:53:10 -05:00
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,
2026-02-09 21:53:10 -05:00
quotaStats: uiState.quota.stats,
};
const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full';
const showErrorSummary =
2026-03-02 20:32:50 -08:00
!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;
2025-04-15 21:41:08 -07:00
const potentialColumns: FooterColumn[] = [];
2025-06-15 11:15:53 -07:00
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', '', () => <DebugProfiler />, 45, true);
}
if (displayVimMode) {
const vimStr = `[${displayVimMode}]`;
addCol(
'vim',
'',
() => <Text color={theme.text.accent}>{vimStr}</Text>,
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) => (
<CwdIndicator
targetDir={targetDir}
maxWidth={maxWidth}
debugMode={debugMode}
debugMessage={debugMessage}
color={itemColor}
/>
),
fullPath.length + debugSuffix.length,
);
break;
}
case 'git-branch': {
if (branchName) {
addCol(
id,
header,
() => <Text color={itemColor}>{branchName}</Text>,
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,
() => <SandboxIndicator isTrustedFolder={isTrustedFolder} />,
str.length,
);
break;
}
case 'model-name': {
const str = getDisplayString(model);
addCol(
id,
header,
() => <Text color={itemColor}>{str}</Text>,
str.length,
);
break;
}
case 'context-used': {
addCol(
id,
header,
() => (
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
model={model}
terminalWidth={terminalWidth}
/>
),
10, // "100% used" is 9 chars
);
break;
}
case 'quota': {
if (quotaStats?.remaining !== undefined && quotaStats.limit) {
addCol(
id,
header,
() => (
<QuotaDisplay
remaining={quotaStats.remaining}
limit={quotaStats.limit}
resetTime={quotaStats.resetTime}
terse={true}
forceShow={true}
lowercase={true}
/>
),
10, // "daily 100%" is 10 chars, but terse is "100%" (4 chars)
);
}
break;
}
case 'memory-usage': {
addCol(
id,
header,
() => (
<MemoryUsageDisplay
color={itemColor}
isActive={!uiState.copyModeEnabled}
/>
),
10,
);
break;
}
case 'session-id': {
addCol(
id,
header,
() => (
<Text color={itemColor}>
{uiState.sessionStats.sessionId.slice(0, 8)}
2025-08-07 16:11:35 -07:00
</Text>
),
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,
() => (
<Text>
<Text color={theme.status.success}>+{added}</Text>{' '}
<Text color={theme.status.error}>-{removed}</Text>
</Text>
),
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,
() => <Text color={itemColor}>{formatted} tokens</Text>,
formatted.length + 7,
);
}
break;
}
default:
checkExhaustive(id);
break;
}
}
// 3. Transients
if (corgiMode) addCol('corgi', '', () => <CorgiIndicator />, 5);
if (showErrorSummary) {
addCol(
'error-count',
'',
() => <ConsoleSummaryDisplay errorCount={errorCount} />,
12,
true,
);
}
// --- Width Fitting Logic ---
const columnsToRender: FooterColumn[] = [];
let droppedAny = false;
2026-03-08 00:36:54 -08:00
let currentUsedWidth = 2; // Initial padding
2026-03-08 00:36:54 -08:00
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 ||
2026-03-08 00:36:54 -08:00
currentUsedWidth + gap + budgetWidth <= terminalWidth - 2
) {
columnsToRender.push(col);
2026-03-08 00:36:54 -08:00
currentUsedWidth += gap + budgetWidth;
} else {
droppedAny = true;
}
}
const rowItems: FooterRowItem[] = columnsToRender.map((col, index) => {
2026-03-08 00:36:54 -08:00
const isWorkspace = col.id === 'workspace';
const isLast = index === columnsToRender.length - 1;
2026-03-08 00:36:54 -08:00
// 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,
2026-03-08 00:36:54 -08:00
element: col.element(estimatedWidth),
flexGrow: 0,
2026-03-08 00:36:54 -08:00
flexShrink: isWorkspace ? 1 : 0,
alignItems:
isLast && !droppedAny && index > 0 ? 'flex-end' : 'flex-start',
};
});
if (droppedAny) {
rowItems.push({
key: 'ellipsis',
header: '',
element: <Text color={theme.ui.comment}>…</Text>,
2026-03-08 00:36:54 -08:00
flexGrow: 0,
flexShrink: 0,
alignItems: 'flex-end',
});
}
return (
<Box width={terminalWidth} paddingX={1} overflow="hidden" flexWrap="nowrap">
<FooterRow items={rowItems} showLabels={showLabels} />
</Box>
);
};