mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-19 02:20:42 -07:00
feat: first pass at /footer
This commit is contained in:
committed by
Keith Guerin
parent
703759cfae
commit
4476c35e4d
93
packages/cli/src/config/__tests__/footerItems.test.ts
Normal file
93
packages/cli/src/config/__tests__/footerItems.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deriveItemsFromLegacySettings } from '../footerItems.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
|
||||
describe('deriveItemsFromLegacySettings', () => {
|
||||
it('returns defaults when no legacy settings are customized', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { footer: { hideContextPercentage: true } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).toEqual([
|
||||
'cwd',
|
||||
'git-branch',
|
||||
'sandbox-status',
|
||||
'model-name',
|
||||
'quota',
|
||||
'error-count',
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes cwd when hideCWD is true', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { footer: { hideCWD: true, hideContextPercentage: true } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).not.toContain('cwd');
|
||||
});
|
||||
|
||||
it('removes sandbox-status when hideSandboxStatus is true', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { footer: { hideSandboxStatus: true, hideContextPercentage: true } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).not.toContain('sandbox-status');
|
||||
});
|
||||
|
||||
it('removes model-name, context-remaining, and quota when hideModelInfo is true', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { footer: { hideModelInfo: true, hideContextPercentage: true } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).not.toContain('model-name');
|
||||
expect(items).not.toContain('context-remaining');
|
||||
expect(items).not.toContain('quota');
|
||||
});
|
||||
|
||||
it('includes context-remaining when hideContextPercentage is false', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { footer: { hideContextPercentage: false } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).toContain('context-remaining');
|
||||
// Should be after model-name
|
||||
const modelIdx = items.indexOf('model-name');
|
||||
const contextIdx = items.indexOf('context-remaining');
|
||||
expect(contextIdx).toBe(modelIdx + 1);
|
||||
});
|
||||
|
||||
it('includes memory-usage when showMemoryUsage is true', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { showMemoryUsage: true, footer: { hideContextPercentage: true } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).toContain('memory-usage');
|
||||
});
|
||||
|
||||
it('handles combination of settings', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: {
|
||||
showMemoryUsage: true,
|
||||
footer: {
|
||||
hideCWD: true,
|
||||
hideModelInfo: true,
|
||||
hideContextPercentage: false,
|
||||
},
|
||||
},
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).toEqual([
|
||||
'git-branch',
|
||||
'sandbox-status',
|
||||
'error-count',
|
||||
'context-remaining',
|
||||
'memory-usage',
|
||||
]);
|
||||
});
|
||||
});
|
||||
142
packages/cli/src/config/footerItems.ts
Normal file
142
packages/cli/src/config/footerItems.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { MergedSettings } from './settings.js';
|
||||
|
||||
export interface FooterItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
defaultEnabled: boolean;
|
||||
}
|
||||
|
||||
export const ALL_ITEMS: FooterItem[] = [
|
||||
{
|
||||
id: 'cwd',
|
||||
label: 'cwd',
|
||||
description: 'Current directory path',
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
id: 'git-branch',
|
||||
label: 'git-branch',
|
||||
description: 'Current git branch name',
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
id: 'sandbox-status',
|
||||
label: 'sandbox-status',
|
||||
description: 'Sandbox type and trust indicator',
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
id: 'model-name',
|
||||
label: 'model-name',
|
||||
description: 'Current model identifier',
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
id: 'context-remaining',
|
||||
label: 'context-remaining',
|
||||
description: 'Percentage of context window remaining',
|
||||
defaultEnabled: false,
|
||||
},
|
||||
{
|
||||
id: 'quota',
|
||||
label: 'quota',
|
||||
description: 'Remaining quota and reset time',
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
id: 'memory-usage',
|
||||
label: 'memory-usage',
|
||||
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',
|
||||
description: 'Unique identifier for the current session',
|
||||
defaultEnabled: false,
|
||||
},
|
||||
{
|
||||
id: 'code-changes',
|
||||
label: 'code-changes',
|
||||
description: 'Lines added/removed in the session',
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
id: 'token-count',
|
||||
label: 'token-count',
|
||||
description: 'Total tokens used in the session',
|
||||
defaultEnabled: false,
|
||||
},
|
||||
{
|
||||
id: 'corgi',
|
||||
label: 'corgi',
|
||||
description: 'A friendly corgi companion',
|
||||
defaultEnabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_ORDER = [
|
||||
'cwd',
|
||||
'git-branch',
|
||||
'sandbox-status',
|
||||
'model-name',
|
||||
'context-remaining',
|
||||
'quota',
|
||||
'memory-usage',
|
||||
'error-count',
|
||||
'session-id',
|
||||
'code-changes',
|
||||
'token-count',
|
||||
'corgi',
|
||||
];
|
||||
|
||||
export function deriveItemsFromLegacySettings(
|
||||
settings: MergedSettings,
|
||||
): string[] {
|
||||
const defaults = [
|
||||
'cwd',
|
||||
'git-branch',
|
||||
'sandbox-status',
|
||||
'model-name',
|
||||
'quota',
|
||||
'error-count',
|
||||
];
|
||||
const items = [...defaults];
|
||||
|
||||
const remove = (arr: string[], id: string) => {
|
||||
const idx = arr.indexOf(id);
|
||||
if (idx !== -1) arr.splice(idx, 1);
|
||||
};
|
||||
|
||||
if (settings.ui.footer.hideCWD) remove(items, 'cwd');
|
||||
if (settings.ui.footer.hideSandboxStatus) remove(items, 'sandbox-status');
|
||||
if (settings.ui.footer.hideModelInfo) {
|
||||
remove(items, 'model-name');
|
||||
remove(items, 'context-remaining');
|
||||
remove(items, 'quota');
|
||||
}
|
||||
if (
|
||||
!settings.ui.footer.hideContextPercentage &&
|
||||
!items.includes('context-remaining')
|
||||
) {
|
||||
const modelIdx = items.indexOf('model-name');
|
||||
if (modelIdx !== -1) items.splice(modelIdx + 1, 0, 'context-remaining');
|
||||
else items.push('context-remaining');
|
||||
}
|
||||
if (settings.ui.showMemoryUsage) items.push('memory-usage');
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -571,6 +571,17 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Settings for the footer.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
label: 'Footer Items',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'List of item IDs to display in the footer. Rendered in order',
|
||||
showInDialog: false,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
hideCWD: {
|
||||
type: 'boolean',
|
||||
label: 'Hide CWD',
|
||||
|
||||
@@ -31,6 +31,7 @@ import { docsCommand } from '../ui/commands/docsCommand.js';
|
||||
import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||
import { footerCommand } from '../ui/commands/footerCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js';
|
||||
import { rewindCommand } from '../ui/commands/rewindCommand.js';
|
||||
@@ -119,6 +120,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
]
|
||||
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
|
||||
helpCommand,
|
||||
footerCommand,
|
||||
shortcutsCommand,
|
||||
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
|
||||
rewindCommand,
|
||||
|
||||
25
packages/cli/src/ui/commands/footerCommand.tsx
Normal file
25
packages/cli/src/ui/commands/footerCommand.tsx
Normal 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', 'status-line', 'status'],
|
||||
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} />,
|
||||
}),
|
||||
};
|
||||
@@ -58,63 +58,202 @@ 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);
|
||||
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 showDebugProfiler = debugMode || isDevelopment;
|
||||
const hasCustomItems = settings.merged.ui.footer.items != null;
|
||||
|
||||
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>
|
||||
if (!hasCustomItems) {
|
||||
const showMemoryUsage =
|
||||
config.getDebugMode() || settings.merged.ui.showMemoryUsage;
|
||||
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 showDebugProfiler = debugMode || isDevelopment;
|
||||
|
||||
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>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{debugMode && (
|
||||
<Text color={theme.status.error}>
|
||||
{' ' + (debugMessage || '--debug')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{!showErrorDetails && errorCount > 0 && (
|
||||
<Box paddingLeft={1} flexDirection="row">
|
||||
<Text color={theme.ui.comment}>| </Text>
|
||||
<ConsoleSummaryDisplay errorCount={errorCount} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Middle Section: Centered Trust/Sandbox Info */}
|
||||
{!hideSandboxStatus && (
|
||||
<Box
|
||||
flexGrow={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
display="flex"
|
||||
>
|
||||
{isTrustedFolder === false ? (
|
||||
// Items-based rendering path
|
||||
const items = settings.merged.ui.footer.items ?? [];
|
||||
const elements: React.ReactNode[] = [];
|
||||
|
||||
const addElement = (id: string, element: React.ReactNode) => {
|
||||
if (elements.length > 0) {
|
||||
elements.push(
|
||||
<Text key={`sep-${id}`} color={theme.text.secondary}>
|
||||
{' | '}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
elements.push(<Box key={id}>{element}</Box>);
|
||||
};
|
||||
|
||||
// Prepend Vim mode if enabled
|
||||
if (displayVimMode) {
|
||||
elements.push(
|
||||
<Box key="vim-mode-static">
|
||||
<Text color={theme.text.secondary}>[{displayVimMode}] </Text>
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
|
||||
for (const id of items) {
|
||||
switch (id) {
|
||||
case 'cwd': {
|
||||
const pathLength = Math.max(20, Math.floor(terminalWidth * 0.25));
|
||||
const displayPath = shortenPath(tildeifyPath(targetDir), pathLength);
|
||||
addElement(
|
||||
id,
|
||||
<Text color={theme.text.secondary}>
|
||||
{displayPath}
|
||||
{debugMode && (
|
||||
<Text color={theme.status.error}>
|
||||
{' ' + (debugMessage || '--debug')}
|
||||
</Text>
|
||||
)}
|
||||
</Text>,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'git-branch': {
|
||||
if (branchName) {
|
||||
addElement(
|
||||
id,
|
||||
<Text color={theme.text.secondary}>{branchName}*</Text>,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'sandbox-status': {
|
||||
addElement(
|
||||
id,
|
||||
isTrustedFolder === false ? (
|
||||
<Text color={theme.status.warning}>untrusted</Text>
|
||||
) : process.env['SANDBOX'] &&
|
||||
process.env['SANDBOX'] !== 'sandbox-exec' ? (
|
||||
@@ -129,69 +268,119 @@ export const Footer: React.FC = () => {
|
||||
</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.status.error}>
|
||||
no sandbox
|
||||
{terminalWidth >= 100 && (
|
||||
<Text color={theme.text.secondary}> (see /docs)</Text>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<Text color={theme.status.error}>no sandbox</Text>
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'model-name': {
|
||||
addElement(
|
||||
id,
|
||||
<Text color={theme.text.secondary}>{getDisplayString(model)}</Text>,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'context-remaining': {
|
||||
addElement(
|
||||
id,
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={promptTokenCount}
|
||||
model={model}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'quota': {
|
||||
if (quotaStats) {
|
||||
addElement(
|
||||
id,
|
||||
<QuotaDisplay
|
||||
remaining={quotaStats.remaining}
|
||||
limit={quotaStats.limit}
|
||||
resetTime={quotaStats.resetTime}
|
||||
terse={true}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'memory-usage': {
|
||||
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>);
|
||||
break;
|
||||
}
|
||||
case 'code-changes': {
|
||||
const added = uiState.sessionStats.metrics.files.totalLinesAdded;
|
||||
const removed = uiState.sessionStats.metrics.files.totalLinesRemoved;
|
||||
if (added > 0 || removed > 0) {
|
||||
addElement(
|
||||
id,
|
||||
<Text>
|
||||
<Text color={theme.status.success}>+{added}</Text>{' '}
|
||||
<Text color={theme.status.error}>-{removed}</Text>
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'token-count': {
|
||||
let totalTokens = 0;
|
||||
for (const m of Object.values(uiState.sessionStats.metrics.models)) {
|
||||
totalTokens += m.tokens.total;
|
||||
}
|
||||
if (totalTokens > 0) {
|
||||
const formatted =
|
||||
totalTokens > 1000
|
||||
? `${(totalTokens / 1000).toFixed(1)}k`
|
||||
: totalTokens;
|
||||
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>,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
return (
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
paddingX={1}
|
||||
flexWrap="nowrap"
|
||||
overflow="hidden"
|
||||
>
|
||||
{elements}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
322
packages/cli/src/ui/components/FooterConfigDialog.tsx
Normal file
322
packages/cli/src/ui/components/FooterConfigDialog.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useMemo, useState, useEffect } 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 { TextInput } from './shared/TextInput.js';
|
||||
import { useFuzzyList } from '../hooks/useFuzzyList.js';
|
||||
import {
|
||||
ALL_ITEMS,
|
||||
DEFAULT_ORDER,
|
||||
deriveItemsFromLegacySettings,
|
||||
} from '../../config/footerItems.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
|
||||
interface FooterConfigDialogProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const { settings, setSetting } = useSettingsStore();
|
||||
|
||||
// Initialize orderedIds and selectedIds
|
||||
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
|
||||
if (settings.merged.ui?.footer?.items) {
|
||||
// Start with saved items in their saved order
|
||||
const savedItems = settings.merged.ui.footer.items;
|
||||
// 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 others = DEFAULT_ORDER.filter((id) => !derived.includes(id));
|
||||
return [...derived, ...others];
|
||||
});
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => {
|
||||
if (settings.merged.ui?.footer?.items) {
|
||||
return new Set(settings.merged.ui.footer.items);
|
||||
}
|
||||
return new Set(deriveItemsFromLegacySettings(settings.merged));
|
||||
});
|
||||
|
||||
// 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],
|
||||
);
|
||||
|
||||
const { filteredItems, searchBuffer, searchQuery, maxLabelWidth } =
|
||||
useFuzzyList({
|
||||
items: listItems,
|
||||
});
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const maxItemsToShow = 10;
|
||||
|
||||
// Reset index when search changes
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
setScrollOffset(0);
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
const item = filteredItems[activeIndex];
|
||||
if (!item) return;
|
||||
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(item.key)) {
|
||||
next.delete(item.key);
|
||||
} else {
|
||||
next.add(item.key);
|
||||
}
|
||||
setSelectedIds(next);
|
||||
|
||||
// Save immediately on toggle
|
||||
const finalItems = orderedIds.filter((id) => next.has(id));
|
||||
setSetting(SettingScope.User, 'ui.footer.items', finalItems);
|
||||
}, [filteredItems, activeIndex, orderedIds, setSetting, selectedIds]);
|
||||
|
||||
const handleReorder = useCallback(
|
||||
(direction: number) => {
|
||||
if (searchQuery) return; // Reorder disabled when searching
|
||||
|
||||
const currentItem = filteredItems[activeIndex];
|
||||
if (!currentItem) return;
|
||||
|
||||
const currentId = currentItem.key;
|
||||
const currentIndex = orderedIds.indexOf(currentId);
|
||||
const newIndex = currentIndex + direction;
|
||||
|
||||
if (newIndex < 0 || newIndex >= orderedIds.length) return;
|
||||
|
||||
const newOrderedIds = [...orderedIds];
|
||||
[newOrderedIds[currentIndex], newOrderedIds[newIndex]] = [
|
||||
newOrderedIds[newIndex],
|
||||
newOrderedIds[currentIndex],
|
||||
];
|
||||
setOrderedIds(newOrderedIds);
|
||||
setActiveIndex(newIndex);
|
||||
|
||||
// Save immediately on reorder
|
||||
const finalItems = newOrderedIds.filter((id) => selectedIds.has(id));
|
||||
setSetting(SettingScope.User, 'ui.footer.items', finalItems);
|
||||
|
||||
// Adjust scroll offset if needed
|
||||
if (newIndex < scrollOffset) {
|
||||
setScrollOffset(newIndex);
|
||||
} else if (newIndex >= scrollOffset + maxItemsToShow) {
|
||||
setScrollOffset(newIndex - maxItemsToShow + 1);
|
||||
}
|
||||
},
|
||||
[
|
||||
searchQuery,
|
||||
filteredItems,
|
||||
activeIndex,
|
||||
orderedIds,
|
||||
scrollOffset,
|
||||
maxItemsToShow,
|
||||
selectedIds,
|
||||
setSetting,
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(
|
||||
(key: Key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onClose?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
|
||||
const newIndex =
|
||||
activeIndex > 0 ? activeIndex - 1 : filteredItems.length - 1;
|
||||
setActiveIndex(newIndex);
|
||||
if (newIndex === filteredItems.length - 1) {
|
||||
setScrollOffset(Math.max(0, filteredItems.length - maxItemsToShow));
|
||||
} else if (newIndex < scrollOffset) {
|
||||
setScrollOffset(newIndex);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
|
||||
const newIndex =
|
||||
activeIndex < filteredItems.length - 1 ? activeIndex + 1 : 0;
|
||||
setActiveIndex(newIndex);
|
||||
if (newIndex === 0) {
|
||||
setScrollOffset(0);
|
||||
} else if (newIndex >= scrollOffset + maxItemsToShow) {
|
||||
setScrollOffset(newIndex - maxItemsToShow + 1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.MOVE_LEFT](key)) {
|
||||
handleReorder(-1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.MOVE_RIGHT](key)) {
|
||||
handleReorder(1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.RETURN](key)) {
|
||||
void handleConfirm();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
{ isActive: true, priority: true },
|
||||
);
|
||||
|
||||
const visibleItems = filteredItems.slice(
|
||||
scrollOffset,
|
||||
scrollOffset + maxItemsToShow,
|
||||
);
|
||||
|
||||
// Preview logic
|
||||
const previewText = useMemo(() => {
|
||||
const itemsToPreview = orderedIds.filter((id) => selectedIds.has(id));
|
||||
if (itemsToPreview.length === 0) return 'Empty Footer';
|
||||
|
||||
// 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>,
|
||||
'model-name': (
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary}>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>,
|
||||
'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>
|
||||
</Box>
|
||||
),
|
||||
'token-count': <Text color={theme.text.secondary}>tokens:1.5k</Text>,
|
||||
corgi: <Text>🐶</Text>,
|
||||
};
|
||||
|
||||
const elements: React.ReactNode[] = [];
|
||||
itemsToPreview.forEach((id, idx) => {
|
||||
if (idx > 0) {
|
||||
elements.push(
|
||||
<Text key={`sep-${id}`} color={theme.text.secondary}>
|
||||
{' | '}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
elements.push(<Box key={id}>{mockValues[id] || id}</Box>);
|
||||
});
|
||||
|
||||
return elements;
|
||||
}, [orderedIds, selectedIds]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold>Configure Footer</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Select which items to display in the footer.
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>Type to search</Text>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.focused}
|
||||
paddingX={1}
|
||||
height={3}
|
||||
>
|
||||
{searchBuffer && <TextInput buffer={searchBuffer} focus={true} />}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<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">
|
||||
<Text color={theme.text.secondary}>
|
||||
↑/↓ navigate · ←/→ reorder · enter select · esc close
|
||||
</Text>
|
||||
{searchQuery && (
|
||||
<Text color={theme.status.warning}>
|
||||
Reordering is disabled when searching.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
marginTop={1}
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<Text bold>Preview:</Text>
|
||||
<Box flexDirection="row">{previewText}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
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 { act } from 'react';
|
||||
|
||||
describe('<FooterConfigDialog />', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly with default settings', () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<FooterConfigDialog onClose={mockOnClose} />,
|
||||
{ settings },
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toBeDefined();
|
||||
expect(output).toContain('Configure Footer');
|
||||
expect(output).toContain('[✓] cwd');
|
||||
expect(output).toContain('[ ] session-id');
|
||||
});
|
||||
|
||||
it('toggles an item when enter is pressed', async () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame, stdin } = renderWithProviders(
|
||||
<FooterConfigDialog onClose={mockOnClose} />,
|
||||
{ settings },
|
||||
);
|
||||
|
||||
// Initial state: cwd is checked by default and highlighted
|
||||
|
||||
act(() => {
|
||||
stdin.write('\r'); // Enter to toggle
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('[ ] cwd');
|
||||
});
|
||||
|
||||
// Toggle it back
|
||||
act(() => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('[✓] cwd');
|
||||
});
|
||||
});
|
||||
|
||||
it('filters items when typing in search', async () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame, stdin } = renderWithProviders(
|
||||
<FooterConfigDialog onClose={mockOnClose} />,
|
||||
{ settings },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('session');
|
||||
// Give search a moment to trigger and re-render
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const output = lastFrame();
|
||||
expect(output).toBeDefined();
|
||||
expect(output).toContain('session-id');
|
||||
expect(output).not.toContain('model-name');
|
||||
});
|
||||
});
|
||||
|
||||
it('reorders items with arrow keys', async () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame, stdin } = renderWithProviders(
|
||||
<FooterConfigDialog onClose={mockOnClose} />,
|
||||
{ settings },
|
||||
);
|
||||
|
||||
// Initial order: cwd, git-branch, ...
|
||||
const output = lastFrame();
|
||||
expect(output).toBeDefined();
|
||||
const cwdIdx = output!.indexOf('cwd');
|
||||
const branchIdx = output!.indexOf('git-branch');
|
||||
expect(cwdIdx).toBeLessThan(branchIdx);
|
||||
|
||||
// Move cwd down (right arrow)
|
||||
act(() => {
|
||||
stdin.write('\u001b[C'); // Right arrow
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const outputAfter = lastFrame();
|
||||
expect(outputAfter).toBeDefined();
|
||||
const cwdIdxAfter = outputAfter!.indexOf('cwd');
|
||||
const branchIdxAfter = outputAfter!.indexOf('git-branch');
|
||||
expect(branchIdxAfter).toBeLessThan(cwdIdxAfter);
|
||||
});
|
||||
});
|
||||
|
||||
it('closes on Esc', async () => {
|
||||
const settings = createMockSettings();
|
||||
const { stdin } = renderWithProviders(
|
||||
<FooterConfigDialog onClose={mockOnClose} />,
|
||||
{ settings },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
stdin.write('\x1b'); // Esc
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user