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

463 lines
12 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;
}
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 && !showLabels) {
elements.push(
<Box key={`sep-${item.key}`} height={1}>
<Text color={theme.ui.comment}> · </Text>
</Box>,
);
}
elements.push(
<Box key={item.key} flexDirection="column">
{showLabels && (
<Box height={1}>
<Text color={theme.ui.comment}>{item.header}</Text>
</Box>
)}
<Box height={1}>{item.element}</Box>
</Box>,
);
});
return (
<Box
flexDirection="row"
flexWrap="nowrap"
columnGap={showLabels ? COLUMN_GAP : 0}
>
{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 = () => {
const uiState = useUIState();
const config = useConfig();
const settings = useSettings();
const { vimEnabled, vimMode } = useVimMode();
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} />, 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 ---
let currentWidth = 2; // Initial padding
const columnsToRender: FooterColumn[] = [];
let droppedAny = false;
for (let i = 0; i < potentialColumns.length; i++) {
const col = potentialColumns[i];
const gap = columnsToRender.length > 0 ? (showLabels ? COLUMN_GAP : 3) : 0; // Use 3 for dot separator width
const budgetWidth = col.id === 'workspace' ? 20 : col.width;
if (
col.isHighPriority ||
currentWidth + gap + budgetWidth <= terminalWidth - 2
) {
columnsToRender.push(col);
currentWidth += gap + budgetWidth;
} else {
droppedAny = true;
}
}
const totalBudgeted = columnsToRender.reduce(
(sum, c, idx) =>
sum +
(c.id === 'workspace' ? 20 : c.width) +
(idx > 0 ? (showLabels ? COLUMN_GAP : 3) : 0),
2,
);
const excessSpace = Math.max(0, terminalWidth - totalBudgeted);
const rowItems: FooterRowItem[] = columnsToRender.map((col) => {
const maxWidth = col.id === 'workspace' ? 20 + excessSpace : col.width;
return {
key: col.id,
header: col.header,
element: col.element(maxWidth),
};
});
if (droppedAny) {
rowItems.push({
key: 'ellipsis',
header: '',
element: <Text color={theme.ui.comment}>…</Text>,
});
}
return (
<Box width={terminalWidth} paddingX={1} overflow="hidden" flexWrap="nowrap">
<FooterRow items={rowItems} showLabels={showLabels} />
</Box>
);
};