mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-17 16:51:15 -07:00
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:
406
packages/cli/src/ui/components/FooterConfigDialog.tsx
Normal file
406
packages/cli/src/ui/components/FooterConfigDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user