Files
gemini-cli/packages/cli/src/ui/components/FooterConfigDialog.tsx

439 lines
13 KiB
TypeScript
Raw Normal View History

2026-02-13 07:04:28 -05:00
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
2026-02-13 12:50:26 -05:00
import { useCallback, useMemo, useReducer, useEffect } from 'react';
2026-02-13 07:04:28 -05:00
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;
}
2026-02-13 12:50:26 -05:00
interface FooterConfigState {
orderedIds: string[];
selectedIds: Set<string>;
activeIndex: number;
scrollOffset: number;
}
type FooterConfigAction =
| { type: 'MOVE_UP'; filteredCount: number; maxToShow: number }
| { type: 'MOVE_DOWN'; filteredCount: number; maxToShow: number }
| {
type: 'MOVE_LEFT';
searchQuery: string;
filteredItems: Array<{ key: string }>;
}
| {
type: 'MOVE_RIGHT';
searchQuery: string;
filteredItems: Array<{ key: string }>;
}
| { type: 'TOGGLE_ITEM'; filteredItems: 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 { filteredCount, maxToShow } = action;
const totalSlots = filteredCount + 1;
const newIndex =
state.activeIndex > 0 ? state.activeIndex - 1 : totalSlots - 1;
let newOffset = state.scrollOffset;
if (newIndex < filteredCount) {
if (newIndex === filteredCount - 1) {
newOffset = Math.max(0, filteredCount - maxToShow);
} else if (newIndex < state.scrollOffset) {
newOffset = newIndex;
}
}
return { ...state, activeIndex: newIndex, scrollOffset: newOffset };
}
case 'MOVE_DOWN': {
const { filteredCount, maxToShow } = action;
const totalSlots = filteredCount + 1;
const newIndex =
state.activeIndex < totalSlots - 1 ? state.activeIndex + 1 : 0;
let newOffset = state.scrollOffset;
if (newIndex === 0) {
newOffset = 0;
} else if (
newIndex < filteredCount &&
newIndex >= state.scrollOffset + maxToShow
) {
newOffset = newIndex - maxToShow + 1;
}
return { ...state, activeIndex: newIndex, scrollOffset: newOffset };
}
case 'MOVE_LEFT':
case 'MOVE_RIGHT': {
if (action.searchQuery) return state;
const direction = action.type === 'MOVE_LEFT' ? -1 : 1;
const currentItem = action.filteredItems[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 isResetFocused = state.activeIndex === action.filteredItems.length;
if (isResetFocused) return state; // Handled by separate effect/callback if needed, or we can add a RESET_DEFAULTS action
const item = action.filteredItems[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;
}
}
2026-02-13 07:04:28 -05:00
export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
onClose,
}) => {
const { settings, setSetting } = useSettingsStore();
2026-02-13 12:50:26 -05:00
const maxItemsToShow = 10;
2026-02-13 07:04:28 -05:00
2026-02-13 12:50:26 -05:00
const [state, dispatch] = useReducer(footerConfigReducer, undefined, () => {
const validIds = new Set(ALL_ITEMS.map((i: { id: string }) => i.id));
let ordered: string[];
let selected: Set<string>;
2026-02-13 09:21:48 -05:00
2026-02-13 07:04:28 -05:00
if (settings.merged.ui?.footer?.items) {
2026-02-13 12:50:26 -05:00
const savedItems = settings.merged.ui.footer.items.filter((id: string) =>
2026-02-13 09:21:48 -05:00
validIds.has(id),
);
2026-02-13 12:50:26 -05:00
const others = DEFAULT_ORDER.filter(
(id: string) => !savedItems.includes(id),
2026-02-13 09:21:48 -05:00
);
2026-02-13 12:50:26 -05:00
ordered = [...savedItems, ...others];
selected = new Set(savedItems);
} else {
const derived = deriveItemsFromLegacySettings(settings.merged).filter(
(id: string) => validIds.has(id),
);
const others = DEFAULT_ORDER.filter(
(id: string) => !derived.includes(id),
);
ordered = [...derived, ...others];
selected = new Set(derived);
2026-02-13 07:04:28 -05:00
}
2026-02-13 12:50:26 -05:00
return {
orderedIds: ordered,
selectedIds: selected,
activeIndex: 0,
scrollOffset: 0,
};
2026-02-13 07:04:28 -05:00
});
2026-02-13 12:50:26 -05:00
const { orderedIds, selectedIds, activeIndex, scrollOffset } = state;
2026-02-13 07:04:28 -05:00
// Prepare items for fuzzy list
const listItems = useMemo(
() =>
2026-02-13 09:21:48 -05:00
orderedIds
2026-02-13 12:50:26 -05:00
.map((id: string) => {
2026-02-13 09:21:48 -05:00
const item = ALL_ITEMS.find((i) => i.id === id);
if (!item) return null;
return {
key: id,
2026-02-13 12:50:26 -05:00
label: item.id as string,
description: item.description as string,
2026-02-13 09:21:48 -05:00
};
})
.filter((i): i is NonNullable<typeof i> => i !== null),
2026-02-13 07:04:28 -05:00
[orderedIds],
);
const { filteredItems, searchBuffer, searchQuery, maxLabelWidth } =
useFuzzyList({
items: listItems,
});
2026-02-13 12:50:26 -05:00
// Save settings when orderedIds or selectedIds change
useEffect(() => {
const finalItems = orderedIds.filter((id: string) => selectedIds.has(id));
// Only save if it's different from current setting to avoid loops
const currentSetting = settings.merged.ui?.footer?.items;
if (JSON.stringify(finalItems) !== JSON.stringify(currentSetting)) {
setSetting(SettingScope.User, 'ui.footer.items', finalItems);
}
}, [orderedIds, selectedIds, setSetting, settings.merged.ui?.footer?.items]);
2026-02-13 07:04:28 -05:00
// Reset index when search changes
useEffect(() => {
2026-02-13 12:50:26 -05:00
dispatch({ type: 'RESET_INDEX' });
2026-02-13 07:04:28 -05:00
}, [searchQuery]);
2026-02-13 11:12:20 -05:00
const isResetFocused = activeIndex === filteredItems.length;
const handleResetToDefaults = useCallback(() => {
setSetting(SettingScope.User, 'ui.footer.items', undefined);
2026-02-13 12:50:26 -05:00
const validIds = new Set(ALL_ITEMS.map((i: { id: string }) => i.id));
2026-02-13 11:12:20 -05:00
const derived = deriveItemsFromLegacySettings(settings.merged).filter(
2026-02-13 12:50:26 -05:00
(id: string) => validIds.has(id),
2026-02-13 11:12:20 -05:00
);
2026-02-13 12:50:26 -05:00
const others = DEFAULT_ORDER.filter((id: string) => !derived.includes(id));
dispatch({
type: 'SET_STATE',
payload: {
orderedIds: [...derived, ...others],
selectedIds: new Set(derived),
activeIndex: 0,
scrollOffset: 0,
},
});
2026-02-13 11:12:20 -05:00
}, [setSetting, settings.merged]);
2026-02-13 07:04:28 -05:00
useKeypress(
(key: Key) => {
if (keyMatchers[Command.ESCAPE](key)) {
onClose?.();
return true;
}
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
2026-02-13 12:50:26 -05:00
dispatch({
type: 'MOVE_UP',
filteredCount: filteredItems.length,
maxToShow: maxItemsToShow,
});
2026-02-13 07:04:28 -05:00
return true;
}
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
2026-02-13 12:50:26 -05:00
dispatch({
type: 'MOVE_DOWN',
filteredCount: filteredItems.length,
maxToShow: maxItemsToShow,
});
2026-02-13 07:04:28 -05:00
return true;
}
if (keyMatchers[Command.MOVE_LEFT](key)) {
2026-02-13 12:50:26 -05:00
dispatch({ type: 'MOVE_LEFT', searchQuery, filteredItems });
2026-02-13 07:04:28 -05:00
return true;
}
if (keyMatchers[Command.MOVE_RIGHT](key)) {
2026-02-13 12:50:26 -05:00
dispatch({ type: 'MOVE_RIGHT', searchQuery, filteredItems });
2026-02-13 07:04:28 -05:00
return true;
}
if (keyMatchers[Command.RETURN](key)) {
2026-02-13 12:50:26 -05:00
if (isResetFocused) {
handleResetToDefaults();
} else {
dispatch({ type: 'TOGGLE_ITEM', filteredItems });
}
2026-02-13 07:04:28 -05:00
return true;
}
return false;
},
{ isActive: true, priority: true },
);
const visibleItems = filteredItems.slice(
scrollOffset,
scrollOffset + maxItemsToShow,
);
2026-02-13 09:21:48 -05:00
const activeId = filteredItems[activeIndex]?.key;
2026-02-13 07:04:28 -05:00
// Preview logic
const previewText = useMemo(() => {
2026-02-13 11:12:20 -05:00
if (isResetFocused) {
return (
<Text color={theme.text.secondary} italic>
Default footer (uses legacy settings)
</Text>
);
}
2026-02-13 12:50:26 -05:00
const itemsToPreview = orderedIds.filter((id: string) =>
selectedIds.has(id),
);
2026-02-13 11:12:20 -05:00
if (itemsToPreview.length === 0) return null;
2026-02-13 07:04:28 -05:00
2026-02-13 09:21:48 -05:00
const getColor = (id: string, defaultColor?: string) =>
id === activeId ? 'white' : defaultColor || theme.text.secondary;
2026-02-13 07:04:28 -05:00
// Mock values for preview
const mockValues: Record<string, React.ReactNode> = {
2026-02-13 11:12:20 -05:00
cwd: <Text color={getColor('cwd')}>~/project/path</Text>,
2026-02-13 09:21:48 -05:00
'git-branch': <Text color={getColor('git-branch')}>main*</Text>,
'sandbox-status': (
<Text color={getColor('sandbox-status', 'green')}>docker</Text>
),
2026-02-13 07:04:28 -05:00
'model-name': (
<Box flexDirection="row">
2026-02-13 09:21:48 -05:00
<Text color={getColor('model-name')}>gemini-2.5-pro</Text>
2026-02-13 07:04:28 -05:00
</Box>
),
2026-02-13 09:21:48 -05:00
'context-remaining': (
2026-02-13 11:12:20 -05:00
<Text color={getColor('context-remaining')}>85% context left</Text>
2026-02-13 09:21:48 -05:00
),
2026-02-13 11:12:20 -05:00
quota: <Text color={getColor('quota')}>daily 97%</Text>,
2026-02-13 09:21:48 -05:00
'memory-usage': <Text color={getColor('memory-usage')}>124MB</Text>,
'session-id': <Text color={getColor('session-id')}>769992f9</Text>,
2026-02-13 07:04:28 -05:00
'code-changes': (
<Box flexDirection="row">
2026-02-13 09:21:48 -05:00
<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>
2026-02-13 07:04:28 -05:00
</Box>
),
2026-02-13 09:21:48 -05:00
'token-count': <Text color={getColor('token-count')}>1.5k tokens</Text>,
2026-02-13 07:04:28 -05:00
};
const elements: React.ReactNode[] = [];
2026-02-13 12:50:26 -05:00
itemsToPreview.forEach((id: string, idx: number) => {
2026-02-13 07:04:28 -05:00
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;
2026-02-13 11:12:20 -05:00
}, [orderedIds, selectedIds, activeId, isResetFocused]);
2026-02-13 07:04:28 -05:00
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>
2026-02-13 11:12:20 -05:00
<Box marginTop={1}>
<Text
color={isResetFocused ? theme.status.warning : theme.text.secondary}
>
{isResetFocused ? '> ' : ' '}
Reset to default footer
</Text>
</Box>
2026-02-13 07:04:28 -05:00
<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>
);
};