chore: tests and cleanup

This commit is contained in:
Jack Wotherspoon
2026-02-13 09:21:48 -05:00
committed by Keith Guerin
parent 4476c35e4d
commit 57a05b33b5
9 changed files with 323 additions and 231 deletions

View File

@@ -5,8 +5,8 @@
*/
import { describe, it, expect } from 'vitest';
import { deriveItemsFromLegacySettings } from '../footerItems.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { deriveItemsFromLegacySettings } from './footerItems.js';
import { createMockSettings } from '../test-utils/settings.js';
describe('deriveItemsFromLegacySettings', () => {
it('returns defaults when no legacy settings are customized', () => {
@@ -20,7 +20,6 @@ describe('deriveItemsFromLegacySettings', () => {
'sandbox-status',
'model-name',
'quota',
'error-count',
]);
});
@@ -85,7 +84,6 @@ describe('deriveItemsFromLegacySettings', () => {
expect(items).toEqual([
'git-branch',
'sandbox-status',
'error-count',
'context-remaining',
'memory-usage',
]);

View File

@@ -56,12 +56,6 @@ export const ALL_ITEMS: FooterItem[] = [
description: 'Node.js heap memory usage',
defaultEnabled: false,
},
{
id: 'error-count',
label: 'error-count',
description: 'Console errors encountered',
defaultEnabled: true,
},
{
id: 'session-id',
label: 'session-id',
@@ -80,12 +74,6 @@ export const ALL_ITEMS: FooterItem[] = [
description: 'Total tokens used in the session',
defaultEnabled: false,
},
{
id: 'corgi',
label: 'corgi',
description: 'A friendly corgi companion',
defaultEnabled: false,
},
];
export const DEFAULT_ORDER = [
@@ -96,11 +84,9 @@ export const DEFAULT_ORDER = [
'context-remaining',
'quota',
'memory-usage',
'error-count',
'session-id',
'code-changes',
'token-count',
'corgi',
];
export function deriveItemsFromLegacySettings(
@@ -112,7 +98,6 @@ export function deriveItemsFromLegacySettings(
'sandbox-status',
'model-name',
'quota',
'error-count',
];
const items = [...defaults];

View File

@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Text, Box } from 'ink';
import { theme } from '../semantic-colors.js';
interface ConsoleSummaryDisplayProps {

View File

@@ -615,3 +615,206 @@ describe('fallback mode display', () => {
unmount();
});
});
describe('Footer Token Formatting', () => {
const setup = (totalTokens: number) => {
const settings = createMockSettings();
settings.merged.ui.footer.items = ['token-count'];
const uiState: { sessionStats: Partial<SessionStatsState> } = {
sessionStats: {
lastPromptTokenCount: 0,
sessionId: 'test-session',
metrics: {
models: {
'gemini-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
total: totalTokens,
input: totalTokens / 2,
candidates: totalTokens / 2,
prompt: totalTokens / 2,
cached: 0,
thoughts: 0,
tool: 0,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0, auto_accept: 0 },
byName: {},
},
files: { totalLinesAdded: 0, totalLinesRemoved: 0 },
},
},
};
return renderWithProviders(<Footer />, {
settings,
uiState,
});
};
it('formats thousands with k', () => {
const { lastFrame } = setup(12400);
expect(lastFrame()).toContain('12.4k tokens');
});
it('formats millions with m', () => {
const { lastFrame } = setup(1500000);
expect(lastFrame()).toContain('1.5m tokens');
});
it('formats billions with b', () => {
const { lastFrame } = setup(2700000000);
expect(lastFrame()).toContain('2.7b tokens');
});
it('formats small numbers without suffix', () => {
const { lastFrame } = setup(850);
expect(lastFrame()).toContain('850 tokens');
});
});
describe('Footer Custom Items', () => {
const customMockSessionStats: SessionStatsState = {
sessionId: 'test-session-id-12345',
sessionStartTime: new Date(),
lastPromptTokenCount: 0,
promptCount: 0,
metrics: {
models: {
'gemini-pro': {
api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 0 },
tokens: {
input: 100,
prompt: 0,
candidates: 50,
total: 150,
cached: 0,
thoughts: 0,
tool: 0,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 12,
totalLinesRemoved: 4,
},
},
};
it('renders items in the specified order', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: {
currentModel: 'gemini-pro',
sessionStats: customMockSessionStats,
},
settings: createMockSettings({
ui: {
footer: {
items: ['session-id', 'code-changes', 'token-count'],
},
},
}),
});
const output = lastFrame();
expect(output).toBeDefined();
expect(output).toContain('test-ses');
expect(output).toContain('+12 -4');
expect(output).toContain('150 tokens');
// Check order
const idIdx = output!.indexOf('test-ses');
const codeIdx = output!.indexOf('+12 -4');
const tokenIdx = output!.indexOf('150 tokens');
expect(idIdx).toBeLessThan(codeIdx);
expect(codeIdx).toBeLessThan(tokenIdx);
});
it('renders all items with dividers', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: {
currentModel: 'gemini-pro',
sessionStats: customMockSessionStats,
branchName: 'main',
},
settings: createMockSettings({
general: {
vimMode: true,
},
ui: {
footer: {
items: ['vim-mode', 'cwd', 'git-branch', 'model-name'],
},
},
}),
});
const output = lastFrame();
expect(output).toBeDefined();
expect(output).toContain('|');
expect(output!.split('|').length).toBe(4);
});
it('handles empty items array', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { sessionStats: customMockSessionStats },
settings: createMockSettings({
ui: {
footer: {
items: [],
},
},
}),
});
const output = lastFrame();
expect(output).toBeDefined();
expect(output!.trim()).toBe('');
});
it('does not render items that are conditionally hidden', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: {
sessionStats: customMockSessionStats,
branchName: undefined, // No branch
},
settings: createMockSettings({
ui: {
footer: {
items: ['cwd', 'git-branch', 'model-name'],
},
},
}),
});
const output = lastFrame();
expect(output).toBeDefined();
expect(output).not.toContain('('); // Branch is usually in (branch*)
expect(output!.split('|').length).toBe(2); // Only cwd and model-name
});
});

View File

@@ -171,7 +171,12 @@ export const Footer: React.FC = () => {
</>
)}
</Text>
{showMemoryUsage && <MemoryUsageDisplay />}
{showMemoryUsage && (
<>
<Text color={theme.text.secondary}> | </Text>
<MemoryUsageDisplay />
</>
)}
</Box>
<Box alignItems="center">
{corgiMode && (
@@ -309,12 +314,6 @@ export const Footer: React.FC = () => {
addElement(id, <MemoryUsageDisplay />);
break;
}
case 'error-count': {
if (!showErrorDetails && errorCount > 0) {
addElement(id, <ConsoleSummaryDisplay errorCount={errorCount} />);
}
break;
}
case 'session-id': {
const idShort = uiState.sessionStats.sessionId.slice(0, 8);
addElement(id, <Text color={theme.text.secondary}>{idShort}</Text>);
@@ -340,28 +339,19 @@ export const Footer: React.FC = () => {
totalTokens += m.tokens.total;
}
if (totalTokens > 0) {
const formatted =
totalTokens > 1000
? `${(totalTokens / 1000).toFixed(1)}k`
: totalTokens;
let formatted: string;
if (totalTokens >= 1_000_000_000) {
formatted = `${(totalTokens / 1_000_000_000).toFixed(1)}b`;
} else if (totalTokens >= 1_000_000) {
formatted = `${(totalTokens / 1_000_000).toFixed(1)}m`;
} else if (totalTokens >= 1000) {
formatted = `${(totalTokens / 1000).toFixed(1)}k`;
} else {
formatted = totalTokens.toString();
}
addElement(
id,
<Text color={theme.text.secondary}>tokens:{formatted}</Text>,
);
}
break;
}
case 'corgi': {
if (corgiMode) {
addElement(
id,
<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>,
<Text color={theme.text.secondary}>{formatted} tokens</Text>,
);
}
break;
@@ -371,6 +361,26 @@ export const Footer: React.FC = () => {
}
}
if (corgiMode) {
addElement(
'corgi-transient',
<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>,
);
}
if (!showErrorDetails && errorCount > 0) {
addElement(
'error-count-transient',
<ConsoleSummaryDisplay errorCount={errorCount} />,
);
}
return (
<Box
width={terminalWidth}

View File

@@ -5,10 +5,10 @@
*/
import { describe, it, expect, vi, beforeEach } 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 { 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 />', () => {
@@ -123,4 +123,27 @@ describe('<FooterConfigDialog />', () => {
expect(mockOnClose).toHaveBeenCalled();
});
});
it('highlights the active item in the preview', async () => {
const settings = createMockSettings();
const { lastFrame, stdin } = renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
// Initial state: 'cwd' is active.
// Verify 'cwd' content exists in the preview area
expect(lastFrame()).toContain('~/dev/gemini-cli');
// Move focus down to 'git-branch'
act(() => {
stdin.write('\u001b[B'); // Down arrow
});
await waitFor(() => {
const output = lastFrame();
// Verify 'git-branch' content exists in the preview area
expect(output).toContain('main*');
});
});
});

View File

@@ -31,37 +31,53 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
// Initialize orderedIds and selectedIds
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
const validIds = new Set(ALL_ITEMS.map((i) => i.id));
if (settings.merged.ui?.footer?.items) {
// Start with saved items in their saved order
const savedItems = settings.merged.ui.footer.items;
const savedItems = settings.merged.ui.footer.items.filter((id) =>
validIds.has(id),
);
// Then add any items from DEFAULT_ORDER that aren't in savedItems
const others = DEFAULT_ORDER.filter((id) => !savedItems.includes(id));
return [...savedItems, ...others];
}
// Fallback to legacy settings derivation
const derived = deriveItemsFromLegacySettings(settings.merged);
const derived = deriveItemsFromLegacySettings(settings.merged).filter(
(id) => validIds.has(id),
);
const others = DEFAULT_ORDER.filter((id) => !derived.includes(id));
return [...derived, ...others];
});
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => {
const validIds = new Set(ALL_ITEMS.map((i) => i.id));
if (settings.merged.ui?.footer?.items) {
return new Set(settings.merged.ui.footer.items);
return new Set(
settings.merged.ui.footer.items.filter((id) => validIds.has(id)),
);
}
return new Set(deriveItemsFromLegacySettings(settings.merged));
return new Set(
deriveItemsFromLegacySettings(settings.merged).filter((id) =>
validIds.has(id),
),
);
});
// Prepare items for fuzzy list
const listItems = useMemo(
() =>
orderedIds.map((id) => {
const item = ALL_ITEMS.find((i) => i.id === id)!;
return {
key: id,
label: item.id,
description: item.description,
};
}),
orderedIds
.map((id) => {
const item = ALL_ITEMS.find((i) => i.id === id);
if (!item) return null;
return {
key: id,
label: item.id,
description: item.description,
};
})
.filter((i): i is NonNullable<typeof i> => i !== null),
[orderedIds],
);
@@ -197,35 +213,44 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
scrollOffset + maxItemsToShow,
);
const activeId = filteredItems[activeIndex]?.key;
// Preview logic
const previewText = useMemo(() => {
const itemsToPreview = orderedIds.filter((id) => selectedIds.has(id));
if (itemsToPreview.length === 0) return 'Empty Footer';
const getColor = (id: string, defaultColor?: string) =>
id === activeId ? 'white' : defaultColor || theme.text.secondary;
// Mock values for preview
const mockValues: Record<string, React.ReactNode> = {
cwd: <Text color={theme.text.secondary}>~/dev/gemini-cli</Text>,
'git-branch': <Text color={theme.text.secondary}>main*</Text>,
'sandbox-status': <Text color="green">macOS Seatbelt</Text>,
cwd: <Text color={getColor('cwd')}>~/dev/gemini-cli</Text>,
'git-branch': <Text color={getColor('git-branch')}>main*</Text>,
'sandbox-status': (
<Text color={getColor('sandbox-status', 'green')}>docker</Text>
),
'model-name': (
<Box flexDirection="row">
<Text color={theme.text.secondary}>gemini-2.5-pro</Text>
<Text color={getColor('model-name')}>gemini-2.5-pro</Text>
</Box>
),
'context-remaining': <Text color={theme.text.primary}>85%</Text>,
quota: <Text color={theme.text.primary}>1.2k left</Text>,
'memory-usage': <Text color={theme.text.primary}>124MB</Text>,
'error-count': <Text color={theme.status.error}>2 errors</Text>,
'session-id': <Text color={theme.text.secondary}>769992f9</Text>,
'context-remaining': (
<Text color={getColor('context-remaining')}>85%</Text>
),
quota: <Text color={getColor('quota')}>1.2k left</Text>,
'memory-usage': <Text color={getColor('memory-usage')}>124MB</Text>,
'session-id': <Text color={getColor('session-id')}>769992f9</Text>,
'code-changes': (
<Box flexDirection="row">
<Text color={theme.status.success}>+12</Text>
<Text color={theme.text.primary}> </Text>
<Text color={theme.status.error}>-4</Text>
<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={theme.text.secondary}>tokens:1.5k</Text>,
corgi: <Text>🐶</Text>,
'token-count': <Text color={getColor('token-count')}>1.5k tokens</Text>,
};
const elements: React.ReactNode[] = [];
@@ -241,7 +266,7 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
});
return elements;
}, [orderedIds, selectedIds]);
}, [orderedIds, selectedIds, activeId]);
return (
<Box

View File

@@ -6,7 +6,7 @@
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';
@@ -34,7 +34,6 @@ export const MemoryUsageDisplay: React.FC = () => {
return (
<Box>
<Text color={theme.text.secondary}> | </Text>
<Text color={memoryUsageColor}>{memoryUsage}</Text>
</Box>
);

View File

@@ -1,151 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { Footer } from '../Footer.js';
import { ToolCallDecision } from '@google/gemini-cli-core';
import type { SessionStatsState } from '../../contexts/SessionContext.js';
const mockSessionStats: SessionStatsState = {
sessionId: 'test-session-id-12345',
sessionStartTime: new Date(),
lastPromptTokenCount: 0,
promptCount: 0,
metrics: {
models: {
'gemini-pro': {
api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 0 },
tokens: {
input: 100,
prompt: 0,
candidates: 50,
total: 150,
cached: 0,
thoughts: 0,
tool: 0,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 12,
totalLinesRemoved: 4,
},
},
};
describe('Footer Custom Items', () => {
it('renders items in the specified order', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: {
currentModel: 'gemini-pro',
sessionStats: mockSessionStats,
},
settings: createMockSettings({
ui: {
footer: {
items: ['session-id', 'code-changes', 'token-count'],
},
},
}),
});
const output = lastFrame();
expect(output).toBeDefined();
expect(output).toContain('test-ses');
expect(output).toContain('+12 -4');
expect(output).toContain('tokens:150');
// Check order
const idIdx = output!.indexOf('test-ses');
const codeIdx = output!.indexOf('+12 -4');
const tokenIdx = output!.indexOf('tokens:150');
expect(idIdx).toBeLessThan(codeIdx);
expect(codeIdx).toBeLessThan(tokenIdx);
});
it('renders all items with dividers', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: {
currentModel: 'gemini-pro',
sessionStats: mockSessionStats,
branchName: 'main',
},
settings: createMockSettings({
general: {
vimMode: true,
},
ui: {
footer: {
items: ['vim-mode', 'cwd', 'git-branch', 'model-name'],
},
},
}),
});
const output = lastFrame();
expect(output).toBeDefined();
expect(output).toContain('|');
expect(output!.split('|').length).toBe(4);
});
it('handles empty items array', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
items: [],
},
},
}),
});
const output = lastFrame();
expect(output).toBeDefined();
expect(output!.trim()).toBe('');
});
it('does not render items that are conditionally hidden', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: {
sessionStats: mockSessionStats,
branchName: undefined, // No branch
},
settings: createMockSettings({
ui: {
footer: {
items: ['cwd', 'git-branch', 'model-name'],
},
},
}),
});
const output = lastFrame();
expect(output).toBeDefined();
expect(output).not.toContain('('); // Branch is usually in (branch*)
expect(output!.split('|').length).toBe(2); // Only cwd and model-name
});
});