feat: add custom footer configuration via /footer (#19001)

Co-authored-by: Keith Guerin <keithguerin@gmail.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Jack Wotherspoon
2026-03-04 21:21:48 -05:00
committed by GitHub
parent c5112cde46
commit 9dc6898d28
19 changed files with 1635 additions and 262 deletions

View File

@@ -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'],
description: 'Configure which items appear in the footer (statusline)',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context: CommandContext): OpenCustomDialogActionReturn => ({
type: 'custom_dialog',
component: <FooterConfigDialog onClose={context.ui.removeComponent} />,
}),
};

View File

@@ -28,7 +28,7 @@ describe('ContextUsageDisplay', () => {
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('50% context used');
expect(output).toContain('50% used');
unmount();
});
@@ -42,7 +42,7 @@ describe('ContextUsageDisplay', () => {
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('0% context used');
expect(output).toContain('0% used');
unmount();
});
@@ -72,7 +72,7 @@ describe('ContextUsageDisplay', () => {
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('80% context used');
expect(output).toContain('80% used');
unmount();
});
@@ -86,7 +86,7 @@ describe('ContextUsageDisplay', () => {
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('100% context used');
expect(output).toContain('100% used');
unmount();
});
});

View File

@@ -38,7 +38,7 @@ export const ContextUsageDisplay = ({
}
const label =
terminalWidth < MIN_TERMINAL_WIDTH_FOR_FULL_LABEL ? '%' : '% context used';
terminalWidth < MIN_TERMINAL_WIDTH_FOR_FULL_LABEL ? '%' : '% used';
return (
<Text color={textColor}>

View File

@@ -4,16 +4,17 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { Footer } from './Footer.js';
import {
makeFakeConfig,
tildeifyPath,
ToolCallDecision,
} from '@google/gemini-cli-core';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import { createMockSettings } from '../../test-utils/settings.js';
import path from 'node:path';
// Normalize paths to POSIX slashes for stable cross-platform snapshots.
const normalizeFrame = (frame: string | undefined) => {
if (!frame) return frame;
return frame.replace(/\\/g, '/');
};
let mockIsDevelopment = false;
@@ -49,14 +50,18 @@ const defaultProps = {
branchName: 'main',
};
const mockSessionStats: SessionStatsState = {
sessionId: 'test-session',
const mockSessionStats = {
sessionId: 'test-session-id',
sessionStartTime: new Date(),
lastPromptTokenCount: 0,
promptCount: 0,
lastPromptTokenCount: 150000,
metrics: {
models: {},
files: {
totalLinesAdded: 12,
totalLinesRemoved: 4,
},
tools: {
count: 0,
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
@@ -65,18 +70,39 @@ const mockSessionStats: SessionStatsState = {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
auto_accept: 0,
},
byName: {},
latency: { avg: 0, max: 0, min: 0 },
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
models: {
'gemini-pro': {
api: {
totalRequests: 0,
totalErrors: 0,
totalLatencyMs: 0,
},
tokens: {
input: 0,
prompt: 0,
candidates: 0,
total: 1500,
cached: 0,
thoughts: 0,
tool: 0,
},
roles: {},
},
},
},
};
describe('<Footer />', () => {
beforeEach(() => {
const root = path.parse(process.cwd()).root;
vi.stubEnv('GEMINI_CLI_HOME', path.join(root, 'Users', 'test'));
});
it('renders the component', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
@@ -103,11 +129,12 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
const tildePath = tildeifyPath(defaultProps.targetDir);
const pathLength = Math.max(20, Math.floor(79 * 0.25));
const expectedPath =
'...' + tildePath.slice(tildePath.length - pathLength + 3);
expect(lastFrame()).toContain(expectedPath);
const output = lastFrame();
expect(output).toBeDefined();
// Should contain some part of the path, likely shortened
expect(output).toContain(
path.join('directories', 'to', 'make', 'it', 'long'),
);
unmount();
});
@@ -120,10 +147,11 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
const tildePath = tildeifyPath(defaultProps.targetDir);
const expectedPath =
'...' + tildePath.slice(tildePath.length - 80 * 0.25 + 3);
expect(lastFrame()).toContain(expectedPath);
const output = lastFrame();
expect(output).toBeDefined();
expect(output).toContain(
path.join('directories', 'to', 'make', 'it', 'long'),
);
unmount();
});
});
@@ -140,7 +168,7 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
expect(lastFrame()).toContain(`(${defaultProps.branchName}*)`);
expect(lastFrame()).toContain(defaultProps.branchName);
unmount();
});
@@ -153,7 +181,7 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain(`(${defaultProps.branchName}*)`);
expect(lastFrame()).not.toContain('Branch');
unmount();
});
@@ -162,7 +190,13 @@ describe('<Footer />', () => {
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
uiState: {
currentModel: defaultProps.model,
sessionStats: {
...mockSessionStats,
lastPromptTokenCount: 1000,
},
},
settings: createMockSettings({
ui: {
footer: {
@@ -174,7 +208,7 @@ describe('<Footer />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% context used/);
expect(lastFrame()).toMatch(/\d+% used/);
unmount();
});
@@ -202,7 +236,7 @@ describe('<Footer />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain('15%');
expect(lastFrame()).toMatchSnapshot();
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
@@ -229,8 +263,8 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('used');
expect(lastFrame()).toMatchSnapshot();
expect(normalizeFrame(lastFrame())).not.toContain('used');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
@@ -257,8 +291,8 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Limit reached');
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()?.toLowerCase()).toContain('limit reached');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
@@ -391,7 +425,9 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('complete-footer-wide');
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
'complete-footer-wide',
);
unmount();
});
@@ -413,7 +449,9 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toMatchSnapshot('footer-minimal');
expect(normalizeFrame(lastFrame({ allowEmpty: true }))).toMatchSnapshot(
'footer-minimal',
);
unmount();
});
@@ -435,7 +473,7 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('footer-no-model');
expect(normalizeFrame(lastFrame())).toMatchSnapshot('footer-no-model');
unmount();
});
@@ -457,7 +495,9 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('footer-only-sandbox');
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
'footer-only-sandbox',
);
unmount();
});
@@ -478,7 +518,7 @@ describe('<Footer />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).not.toMatch(/\d+% context used/);
expect(lastFrame()).not.toMatch(/\d+% used/);
unmount();
});
it('shows the context percentage when hideContextPercentage is false', async () => {
@@ -498,7 +538,7 @@ describe('<Footer />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% context used/);
expect(lastFrame()).toMatch(/\d+% used/);
unmount();
});
it('renders complete footer in narrow terminal (baseline narrow)', async () => {
@@ -517,7 +557,77 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('complete-footer-narrow');
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
'complete-footer-narrow',
);
unmount();
});
});
describe('Footer Token Formatting', () => {
const renderWithTokens = async (tokens: number) => {
const result = renderWithProviders(<Footer />, {
width: 120,
uiState: {
sessionStats: {
...mockSessionStats,
metrics: {
...mockSessionStats.metrics,
models: {
'gemini-pro': {
api: {
totalRequests: 0,
totalErrors: 0,
totalLatencyMs: 0,
},
tokens: {
input: 0,
prompt: 0,
candidates: 0,
total: tokens,
cached: 0,
thoughts: 0,
tool: 0,
},
roles: {},
},
},
},
},
},
settings: createMockSettings({
ui: {
footer: {
items: ['token-count'],
},
},
}),
});
await result.waitUntilReady();
return result;
};
it('formats thousands with k', async () => {
const { lastFrame, unmount } = await renderWithTokens(1500);
expect(lastFrame()).toContain('1.5k tokens');
unmount();
});
it('formats millions with m', async () => {
const { lastFrame, unmount } = await renderWithTokens(1500000);
expect(lastFrame()).toContain('1.5m tokens');
unmount();
});
it('formats billions with b', async () => {
const { lastFrame, unmount } = await renderWithTokens(1500000000);
expect(lastFrame()).toContain('1.5b tokens');
unmount();
});
it('formats small numbers without suffix', async () => {
const { lastFrame, unmount } = await renderWithTokens(500);
expect(lastFrame()).toContain('500 tokens');
unmount();
});
});
@@ -548,7 +658,6 @@ describe('<Footer />', () => {
);
await waitUntilReady();
expect(lastFrame()).not.toContain('F12 for details');
expect(lastFrame()).not.toContain('2 errors');
unmount();
});
@@ -594,68 +703,159 @@ describe('<Footer />', () => {
expect(lastFrame()).toContain('2 errors');
unmount();
});
});
it('shows error summary in debug mode even when verbosity is low', async () => {
const debugConfig = makeFakeConfig();
vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true);
describe('Footer Custom Items', () => {
it('renders items in the specified order', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
config: debugConfig,
uiState: {
currentModel: 'gemini-pro',
sessionStats: mockSessionStats,
errorCount: 1,
showErrorDetails: false,
},
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'low' } },
ui: {
footer: {
items: ['model-name', 'workspace'],
},
},
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('F12 for details');
expect(lastFrame()).toContain('1 error');
const output = lastFrame();
const modelIdx = output.indexOf('/model');
const cwdIdx = output.indexOf('workspace (/directory)');
expect(modelIdx).toBeLessThan(cwdIdx);
unmount();
});
it('renders multiple items with proper alignment', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
branchName: 'main',
},
settings: createMockSettings({
vimMode: {
vimMode: true,
},
ui: {
footer: {
items: ['workspace', 'git-branch', 'sandbox', 'model-name'],
},
},
}),
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
// Headers should be present
expect(output).toContain('workspace (/directory)');
expect(output).toContain('branch');
expect(output).toContain('sandbox');
expect(output).toContain('/model');
// Data should be present
expect(output).toContain('main');
expect(output).toContain('gemini-pro');
unmount();
});
it('handles empty items array', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
items: [],
},
},
}),
},
);
await waitUntilReady();
const output = lastFrame({ allowEmpty: true });
expect(output).toBeDefined();
expect(output.trim()).toBe('');
unmount();
});
it('does not render items that are conditionally hidden', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
branchName: undefined, // No branch
},
settings: createMockSettings({
ui: {
footer: {
items: ['workspace', 'git-branch', 'model-name'],
},
},
}),
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
expect(output).not.toContain('branch');
expect(output).toContain('workspace (/directory)');
expect(output).toContain('/model');
unmount();
});
});
describe('fallback mode display', () => {
it('should display Flash model when in fallback mode, not the configured Pro model', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-flash', // Fallback active, showing Flash
},
},
);
await waitUntilReady();
// Footer should show the effective model (Flash), not the config model (Pro)
expect(lastFrame()).toContain('gemini-2.5-flash');
expect(lastFrame()).not.toContain('gemini-2.5-pro');
unmount();
});
it('should display Pro model when NOT in fallback mode', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-pro', // Normal mode, showing Pro
},
},
);
await waitUntilReady();
expect(lastFrame()).toContain('gemini-2.5-pro');
unmount();
});
});
});
describe('fallback mode display', () => {
it('should display Flash model when in fallback mode, not the configured Pro model', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-flash', // Fallback active, showing Flash
},
},
);
await waitUntilReady();
// Footer should show the effective model (Flash), not the config model (Pro)
expect(lastFrame()).toContain('gemini-2.5-flash');
expect(lastFrame()).not.toContain('gemini-2.5-pro');
unmount();
});
it('should display Pro model when NOT in fallback mode', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-pro', // Normal mode, showing Pro
},
},
);
await waitUntilReady();
expect(lastFrame()).toContain('gemini-2.5-pro');
unmount();
});
});

View File

@@ -11,6 +11,7 @@ import {
shortenPath,
tildeifyPath,
getDisplayString,
checkExhaustive,
} from '@google/gemini-cli-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process';
@@ -18,11 +19,143 @@ import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { QuotaDisplay } from './QuotaDisplay.js';
import { DebugProfiler } from './DebugProfiler.js';
import { isDevelopment } from '../../utils/installationInfo.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();
@@ -58,142 +191,272 @@ 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 || isDevelopment);
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 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 showDebugProfiler = debugMode || isDevelopment;
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 ---
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
justifyContent={justifyContent}
width={terminalWidth}
flexDirection="row"
alignItems="center"
paddingX={1}
>
{(showDebugProfiler || displayVimMode || !hideCWD) && (
<Box>
{showDebugProfiler && <DebugProfiler />}
{displayVimMode && (
<Text color={theme.text.secondary}>[{displayVimMode}] </Text>
)}
{!hideCWD && (
<Text color={theme.text.primary}>
{displayPath}
{branchName && (
<Text color={theme.text.secondary}> ({branchName}*)</Text>
)}
</Text>
)}
{debugMode && (
<Text color={theme.status.error}>
{' ' + (debugMessage || '--debug')}
</Text>
)}
</Box>
)}
{/* Middle Section: Centered Trust/Sandbox Info */}
{!hideSandboxStatus && (
<Box
flexGrow={1}
alignItems="center"
justifyContent="center"
display="flex"
>
{isTrustedFolder === false ? (
<Text color={theme.status.warning}>untrusted</Text>
) : process.env['SANDBOX'] &&
process.env['SANDBOX'] !== 'sandbox-exec' ? (
<Text color="green">
{process.env['SANDBOX'].replace(/^gemini-(?:cli-)?/, '')}
</Text>
) : process.env['SANDBOX'] === 'sandbox-exec' ? (
<Text color={theme.status.warning}>
macOS Seatbelt{' '}
<Text color={theme.text.secondary}>
({process.env['SEATBELT_PROFILE']})
</Text>
</Text>
) : (
<Text color={theme.status.error}>
no sandbox
{terminalWidth >= 100 && (
<Text color={theme.text.secondary}> (see /docs)</Text>
)}
</Text>
)}
</Box>
)}
{/* Right Section: Gemini Label and Console Summary */}
{!hideModelInfo && (
<Box alignItems="center" justifyContent="flex-end">
<Box alignItems="center">
<Text color={theme.text.primary}>
<Text color={theme.text.secondary}>/model </Text>
{getDisplayString(model)}
{!hideContextPercentage && (
<>
{' '}
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
model={model}
terminalWidth={terminalWidth}
/>
</>
)}
{quotaStats && (
<>
{' '}
<QuotaDisplay
remaining={quotaStats.remaining}
limit={quotaStats.limit}
resetTime={quotaStats.resetTime}
terse={true}
/>
</>
)}
</Text>
{showMemoryUsage && <MemoryUsageDisplay />}
</Box>
<Box alignItems="center">
{corgiMode && (
<Box paddingLeft={1} flexDirection="row">
<Text>
<Text color={theme.ui.symbol}>| </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>
</Box>
)}
{showErrorSummary && (
<Box paddingLeft={1} flexDirection="row">
<Text color={theme.ui.comment}>| </Text>
<ConsoleSummaryDisplay errorCount={errorCount} />
</Box>
)}
</Box>
</Box>
)}
<Box width={terminalWidth} paddingX={1} overflow="hidden" flexWrap="nowrap">
<FooterRow items={rowItems} showLabels={showLabels} />
</Box>
);
};

View File

@@ -0,0 +1,153 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } 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('<FooterConfigDialog />', () => {
const mockOnClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('renders correctly with default settings', async () => {
const settings = createMockSettings();
const { lastFrame, waitUntilReady } = renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('toggles an item when enter is pressed', async () => {
const settings = createMockSettings();
const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
act(() => {
stdin.write('\r'); // Enter to toggle
});
await waitFor(() => {
expect(lastFrame()).toContain('[ ] workspace');
});
act(() => {
stdin.write('\r');
});
await waitFor(() => {
expect(lastFrame()).toContain('[✓] workspace');
});
});
it('reorders items with arrow keys', async () => {
const settings = createMockSettings();
const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
// Initial order: workspace, branch, ...
const output = lastFrame();
const cwdIdx = output.indexOf('] workspace');
const branchIdx = output.indexOf('] git-branch');
expect(cwdIdx).toBeGreaterThan(-1);
expect(branchIdx).toBeGreaterThan(-1);
expect(cwdIdx).toBeLessThan(branchIdx);
// Move workspace down (right arrow)
act(() => {
stdin.write('\u001b[C'); // Right arrow
});
await waitFor(() => {
const outputAfter = lastFrame();
const cwdIdxAfter = outputAfter.indexOf('] workspace');
const branchIdxAfter = outputAfter.indexOf('] git-branch');
expect(cwdIdxAfter).toBeGreaterThan(-1);
expect(branchIdxAfter).toBeGreaterThan(-1);
expect(branchIdxAfter).toBeLessThan(cwdIdxAfter);
});
});
it('closes on Esc', async () => {
const settings = createMockSettings();
const { stdin, waitUntilReady } = renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
act(() => {
stdin.write('\x1b'); // Esc
});
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
});
it('highlights the active item in the preview', async () => {
const settings = createMockSettings();
const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
expect(lastFrame()).toContain('~/project/path');
// Move focus down to 'git-branch'
act(() => {
stdin.write('\u001b[B'); // Down arrow
});
await waitFor(() => {
expect(lastFrame()).toContain('main');
});
});
it('shows an empty preview when all items are deselected', async () => {
const settings = createMockSettings();
const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
for (let i = 0; i < 10; i++) {
act(() => {
stdin.write('\r'); // Toggle (deselect)
stdin.write('\u001b[B'); // Down arrow
});
}
await waitFor(() => {
const output = lastFrame();
expect(output).toContain('Preview:');
expect(output).not.toContain('~/project/path');
expect(output).not.toContain('docker');
expect(output).not.toContain('gemini-2.5-pro');
expect(output).not.toContain('1.2k left');
});
});
});

View File

@@ -0,0 +1,406 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useMemo, useReducer } 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 { FooterRow, type FooterRowItem } from './Footer.js';
import { ALL_ITEMS, resolveFooterState } from '../../config/footerItems.js';
import { SettingScope } from '../../config/settings.js';
interface FooterConfigDialogProps {
onClose?: () => void;
}
interface FooterConfigState {
orderedIds: string[];
selectedIds: Set<string>;
activeIndex: number;
scrollOffset: number;
}
type FooterConfigAction =
| { type: 'MOVE_UP'; itemCount: number; maxToShow: number }
| { type: 'MOVE_DOWN'; itemCount: number; maxToShow: number }
| {
type: 'MOVE_LEFT';
items: Array<{ key: string }>;
}
| {
type: 'MOVE_RIGHT';
items: Array<{ key: string }>;
}
| { type: 'TOGGLE_ITEM'; items: Array<{ key: string }> }
| { type: 'SET_STATE'; payload: Partial<FooterConfigState> }
| { type: 'RESET_INDEX' };
function footerConfigReducer(
state: FooterConfigState,
action: FooterConfigAction,
): FooterConfigState {
switch (action.type) {
case 'MOVE_UP': {
const { itemCount, maxToShow } = action;
const totalSlots = itemCount + 2; // +1 for showLabels, +1 for reset
const newIndex =
state.activeIndex > 0 ? state.activeIndex - 1 : totalSlots - 1;
let newOffset = state.scrollOffset;
if (newIndex < itemCount) {
if (newIndex === itemCount - 1) {
newOffset = Math.max(0, itemCount - maxToShow);
} else if (newIndex < state.scrollOffset) {
newOffset = newIndex;
}
}
return { ...state, activeIndex: newIndex, scrollOffset: newOffset };
}
case 'MOVE_DOWN': {
const { itemCount, maxToShow } = action;
const totalSlots = itemCount + 2;
const newIndex =
state.activeIndex < totalSlots - 1 ? state.activeIndex + 1 : 0;
let newOffset = state.scrollOffset;
if (newIndex === 0) {
newOffset = 0;
} else if (
newIndex < itemCount &&
newIndex >= state.scrollOffset + maxToShow
) {
newOffset = newIndex - maxToShow + 1;
}
return { ...state, activeIndex: newIndex, scrollOffset: newOffset };
}
case 'MOVE_LEFT':
case 'MOVE_RIGHT': {
const direction = action.type === 'MOVE_LEFT' ? -1 : 1;
const currentItem = action.items[state.activeIndex];
if (!currentItem) return state;
const currentId = currentItem.key;
const currentIndex = state.orderedIds.indexOf(currentId);
const newIndex = currentIndex + direction;
if (newIndex < 0 || newIndex >= state.orderedIds.length) return state;
const newOrderedIds = [...state.orderedIds];
[newOrderedIds[currentIndex], newOrderedIds[newIndex]] = [
newOrderedIds[newIndex],
newOrderedIds[currentIndex],
];
return { ...state, orderedIds: newOrderedIds, activeIndex: newIndex };
}
case 'TOGGLE_ITEM': {
const isSystemFocused = state.activeIndex >= action.items.length;
if (isSystemFocused) return state;
const item = action.items[state.activeIndex];
if (!item) return state;
const nextSelected = new Set(state.selectedIds);
if (nextSelected.has(item.key)) {
nextSelected.delete(item.key);
} else {
nextSelected.add(item.key);
}
return { ...state, selectedIds: nextSelected };
}
case 'SET_STATE':
return { ...state, ...action.payload };
case 'RESET_INDEX':
return { ...state, activeIndex: 0, scrollOffset: 0 };
default:
return state;
}
}
export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
onClose,
}) => {
const { settings, setSetting } = useSettingsStore();
const maxItemsToShow = 10;
const [state, dispatch] = useReducer(footerConfigReducer, undefined, () => ({
...resolveFooterState(settings.merged),
activeIndex: 0,
scrollOffset: 0,
}));
const { orderedIds, selectedIds, activeIndex, scrollOffset } = state;
// Prepare items
const listItems = useMemo(
() =>
orderedIds
.map((id: string) => {
const item = ALL_ITEMS.find((i) => i.id === id);
if (!item) return null;
return {
key: id,
label: item.id,
description: item.description as string,
};
})
.filter((i): i is NonNullable<typeof i> => i !== null),
[orderedIds],
);
const maxLabelWidth = useMemo(
() => listItems.reduce((max, item) => Math.max(max, item.label.length), 0),
[listItems],
);
const isResetFocused = activeIndex === listItems.length + 1;
const isShowLabelsFocused = activeIndex === listItems.length;
const handleSaveAndClose = useCallback(() => {
const finalItems = orderedIds.filter((id: string) => selectedIds.has(id));
const currentSetting = settings.merged.ui?.footer?.items;
if (JSON.stringify(finalItems) !== JSON.stringify(currentSetting)) {
setSetting(SettingScope.User, 'ui.footer.items', finalItems);
}
onClose?.();
}, [
orderedIds,
selectedIds,
setSetting,
settings.merged.ui?.footer?.items,
onClose,
]);
const handleResetToDefaults = useCallback(() => {
setSetting(SettingScope.User, 'ui.footer.items', undefined);
dispatch({
type: 'SET_STATE',
payload: {
...resolveFooterState(settings.merged),
activeIndex: 0,
scrollOffset: 0,
},
});
}, [setSetting, settings.merged]);
const handleToggleLabels = useCallback(() => {
const current = settings.merged.ui.footer.showLabels !== false;
setSetting(SettingScope.User, 'ui.footer.showLabels', !current);
}, [setSetting, settings.merged.ui.footer.showLabels]);
useKeypress(
(key: Key) => {
if (keyMatchers[Command.ESCAPE](key)) {
handleSaveAndClose();
return true;
}
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
dispatch({
type: 'MOVE_UP',
itemCount: listItems.length,
maxToShow: maxItemsToShow,
});
return true;
}
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
dispatch({
type: 'MOVE_DOWN',
itemCount: listItems.length,
maxToShow: maxItemsToShow,
});
return true;
}
if (keyMatchers[Command.MOVE_LEFT](key)) {
dispatch({ type: 'MOVE_LEFT', items: listItems });
return true;
}
if (keyMatchers[Command.MOVE_RIGHT](key)) {
dispatch({ type: 'MOVE_RIGHT', items: listItems });
return true;
}
if (keyMatchers[Command.RETURN](key) || key.name === 'space') {
if (isResetFocused) {
handleResetToDefaults();
} else if (isShowLabelsFocused) {
handleToggleLabels();
} else {
dispatch({ type: 'TOGGLE_ITEM', items: listItems });
}
return true;
}
return false;
},
{ isActive: true, priority: true },
);
const visibleItems = listItems.slice(
scrollOffset,
scrollOffset + maxItemsToShow,
);
const activeId = listItems[activeIndex]?.key;
const showLabels = settings.merged.ui.footer.showLabels !== false;
// Preview logic
const previewContent = useMemo(() => {
if (isResetFocused) {
return (
<Text color={theme.ui.comment} italic>
Default footer (uses legacy settings)
</Text>
);
}
const itemsToPreview = orderedIds.filter((id: string) =>
selectedIds.has(id),
);
if (itemsToPreview.length === 0) return null;
const itemColor = showLabels ? theme.text.primary : theme.ui.comment;
const getColor = (id: string, defaultColor?: string) =>
id === activeId ? 'white' : defaultColor || itemColor;
// Mock data for preview (headers come from ALL_ITEMS)
const mockData: Record<string, React.ReactNode> = {
workspace: (
<Text color={getColor('workspace', itemColor)}>~/project/path</Text>
),
'git-branch': <Text color={getColor('git-branch', itemColor)}>main</Text>,
sandbox: <Text color={getColor('sandbox', 'green')}>docker</Text>,
'model-name': (
<Text color={getColor('model-name', itemColor)}>gemini-2.5-pro</Text>
),
'context-used': (
<Text color={getColor('context-used', itemColor)}>85% used</Text>
),
quota: <Text color={getColor('quota', itemColor)}>97%</Text>,
'memory-usage': (
<Text color={getColor('memory-usage', itemColor)}>260 MB</Text>
),
'session-id': (
<Text color={getColor('session-id', itemColor)}>769992f9</Text>
),
'code-changes': (
<Box flexDirection="row">
<Text color={getColor('code-changes', theme.status.success)}>
+12
</Text>
<Text color={getColor('code-changes')}> </Text>
<Text color={getColor('code-changes', theme.status.error)}>-4</Text>
</Box>
),
'token-count': (
<Text color={getColor('token-count', itemColor)}>1.5k tokens</Text>
),
};
const rowItems: FooterRowItem[] = itemsToPreview
.filter((id: string) => mockData[id])
.map((id: string) => ({
key: id,
header: ALL_ITEMS.find((i) => i.id === id)?.header ?? id,
element: mockData[id],
}));
return (
<Box overflow="hidden" flexWrap="nowrap">
<Box flexShrink={0}>
<FooterRow items={rowItems} showLabels={showLabels} />
</Box>
</Box>
);
}, [orderedIds, selectedIds, activeId, isResetFocused, showLabels]);
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={2}
paddingY={1}
width="100%"
>
<Text bold>Configure Footer{'\n'}</Text>
<Text color={theme.text.secondary}>
Select which items to display in the footer.
</Text>
<Box flexDirection="column" marginTop={1} minHeight={maxItemsToShow}>
{visibleItems.length === 0 ? (
<Text color={theme.text.secondary}>No items found.</Text>
) : (
visibleItems.map((item, idx) => {
const index = scrollOffset + idx;
const isFocused = index === activeIndex;
const isChecked = selectedIds.has(item.key);
return (
<Box key={item.key} flexDirection="row">
<Text color={isFocused ? theme.status.success : undefined}>
{isFocused ? '> ' : ' '}
</Text>
<Text
color={isFocused ? theme.status.success : theme.text.primary}
>
[{isChecked ? '✓' : ' '}]{' '}
{item.label.padEnd(maxLabelWidth + 1)}
</Text>
<Text color={theme.text.secondary}> {item.description}</Text>
</Box>
);
})
)}
</Box>
<Box marginTop={1} flexDirection="column">
<Box flexDirection="row">
<Text color={isShowLabelsFocused ? theme.status.success : undefined}>
{isShowLabelsFocused ? '> ' : ' '}
</Text>
<Text color={isShowLabelsFocused ? theme.status.success : undefined}>
[{showLabels ? '✓' : ' '}] Show footer labels
</Text>
</Box>
<Box flexDirection="row">
<Text color={isResetFocused ? theme.status.warning : undefined}>
{isResetFocused ? '> ' : ' '}
</Text>
<Text
color={isResetFocused ? theme.status.warning : theme.text.secondary}
>
Reset to default footer
</Text>
</Box>
</Box>
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>
/ navigate · / reorder · enter/space select · esc close
</Text>
</Box>
<Box
marginTop={1}
borderStyle="single"
borderColor={theme.border.default}
paddingX={1}
flexDirection="column"
>
<Text bold>Preview:</Text>
<Box flexDirection="row">{previewContent}</Box>
</Box>
</Box>
);
};

View File

@@ -6,35 +6,32 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { Box, Text } from 'ink';
import { Text, Box } from 'ink';
import { theme } from '../semantic-colors.js';
import process from 'node:process';
import { formatBytes } from '../utils/formatters.js';
export const MemoryUsageDisplay: React.FC = () => {
export const MemoryUsageDisplay: React.FC<{ color?: string }> = ({
color = theme.text.primary,
}) => {
const [memoryUsage, setMemoryUsage] = useState<string>('');
const [memoryUsageColor, setMemoryUsageColor] = useState<string>(
theme.text.secondary,
);
const [memoryUsageColor, setMemoryUsageColor] = useState<string>(color);
useEffect(() => {
const updateMemory = () => {
const usage = process.memoryUsage().rss;
setMemoryUsage(formatBytes(usage));
setMemoryUsageColor(
usage >= 2 * 1024 * 1024 * 1024
? theme.status.error
: theme.text.secondary,
usage >= 2 * 1024 * 1024 * 1024 ? theme.status.error : color,
);
};
const intervalId = setInterval(updateMemory, 2000);
updateMemory(); // Initial update
return () => clearInterval(intervalId);
}, []);
}, [color]);
return (
<Box>
<Text color={theme.text.secondary}> | </Text>
<Text color={memoryUsageColor}>{memoryUsage}</Text>
</Box>
);

View File

@@ -18,6 +18,8 @@ interface QuotaDisplayProps {
limit: number | undefined;
resetTime?: string;
terse?: boolean;
forceShow?: boolean;
lowercase?: boolean;
}
export const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
@@ -25,6 +27,8 @@ export const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
limit,
resetTime,
terse = false,
forceShow = false,
lowercase = false,
}) => {
if (remaining === undefined || limit === undefined || limit === 0) {
return null;
@@ -32,7 +36,7 @@ export const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
const percentage = (remaining / limit) * 100;
if (percentage > QUOTA_THRESHOLD_HIGH) {
if (!forceShow && percentage > QUOTA_THRESHOLD_HIGH) {
return null;
}
@@ -45,20 +49,17 @@ export const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
!terse && resetTime ? `, ${formatResetTime(resetTime)}` : '';
if (remaining === 0) {
return (
<Text color={color}>
{terse
? 'Limit reached'
: `/stats Limit reached${resetInfo}${!terse && '. /auth to continue.'}`}
</Text>
);
let text = terse
? 'Limit reached'
: `/stats Limit reached${resetInfo}${!terse && '. /auth to continue.'}`;
if (lowercase) text = text.toLowerCase();
return <Text color={color}>{text}</Text>;
}
return (
<Text color={color}>
{terse
? `${percentage.toFixed(0)}%`
: `/stats ${percentage.toFixed(0)}% usage remaining${resetInfo}`}
</Text>
);
let text = terse
? `${percentage.toFixed(0)}%`
: `/stats ${percentage.toFixed(0)}% usage remaining${resetInfo}`;
if (lowercase) text = text.toLowerCase();
return <Text color={color}>{text}</Text>;
};

View File

@@ -1,38 +1,45 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] = `
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro Limit reached
" workspace (/directory) sandbox /model /stats
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro limit reached
"
`;
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 15%
" workspace (/directory) sandbox /model /stats
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15%
"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `
" ...s/to/make/it/long no sandbox /model gemini-pro 0%
" workspace (/directory) sandbox /model context
...me/more/directories/to/make/it/long no sandbox gemini-pro 14%
"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 0% context used
" workspace (/directory) sandbox /model context
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 14% used
"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `
" no sandbox (see /docs)
" sandbox
no sandbox
"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `
" ...directories/to/make/it/long no sandbox (see /docs)
" workspace (/directory) sandbox
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox
"
`;
exports[`<Footer /> > hides the usage indicator when usage is not near limit 1`] = `
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro
" workspace (/directory) sandbox /model /stats
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85%
"
`;

View File

@@ -0,0 +1,34 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Configure Footer │
│ │
│ Select which items to display in the footer. │
│ │
│ > [✓] workspace Current working directory │
│ [✓] git-branch Current git branch name (not shown when unavailable) │
│ [✓] sandbox Sandbox type and trust indicator │
│ [✓] model-name Current model identifier │
│ [✓] quota Remaining usage on daily limit (not shown when unavailable) │
│ [ ] context-used Percentage of context window used │
│ [ ] memory-usage Memory used by the application │
│ [ ] session-id Unique identifier for the current session │
│ [ ] code-changes Lines added/removed in the session (not shown when zero) │
│ [ ] token-count Total tokens used in the session (not shown when zero) │
│ │
│ [✓] Show footer labels │
│ Reset to default footer │
│ │
│ ↑/↓ navigate · ←/→ reorder · enter/space select · esc close │
│ │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Preview: │ │
│ │ workspace (/directory) branch sandbox /model /stats │ │
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;