mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-23 04:21:31 -07:00
496 lines
13 KiB
TypeScript
496 lines
13 KiB
TypeScript
/**
|
||
* @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<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;
|
||
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(
|
||
<Box
|
||
key={item.key}
|
||
flexDirection="column"
|
||
flexGrow={item.flexGrow ?? 0}
|
||
flexShrink={item.flexShrink ?? 1}
|
||
alignItems={item.alignItems}
|
||
backgroundColor={item.isFocused ? theme.background.focus : undefined}
|
||
>
|
||
{showLabels && (
|
||
<Box height={1}>
|
||
<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 = () => {
|
||
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,
|
||
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', '', () => <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)}
|
||
</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;
|
||
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: <Text color={theme.ui.comment}>…</Text>,
|
||
flexGrow: 0,
|
||
flexShrink: 0,
|
||
alignItems: 'flex-end',
|
||
});
|
||
}
|
||
|
||
return (
|
||
<Box width={terminalWidth} paddingX={1} overflow="hidden" flexWrap="nowrap">
|
||
<FooterRow items={rowItems} showLabels={showLabels} />
|
||
</Box>
|
||
);
|
||
};
|