/** * @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 = ({ onClose, }) => { const { settings, setSetting } = useSettingsStore(); // Initialize orderedIds and selectedIds const [orderedIds, setOrderedIds] = useState(() => { 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.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).filter( (id) => validIds.has(id), ); const others = DEFAULT_ORDER.filter((id) => !derived.includes(id)); return [...derived, ...others]; }); const [selectedIds, setSelectedIds] = useState>(() => { const validIds = new Set(ALL_ITEMS.map((i) => i.id)); if (settings.merged.ui?.footer?.items) { return new Set( settings.merged.ui.footer.items.filter((id) => validIds.has(id)), ); } 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); if (!item) return null; return { key: id, label: item.id, description: item.description, }; }) .filter((i): i is NonNullable => i !== null), [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]); // The reset action lives one index past the filtered item list const isResetFocused = activeIndex === filteredItems.length; const handleResetToDefaults = useCallback(() => { // Clear the custom items setting so the legacy footer path is used setSetting(SettingScope.User, 'ui.footer.items', undefined); // Reset local state to reflect legacy-derived items const validIds = new Set(ALL_ITEMS.map((i) => i.id)); const derived = deriveItemsFromLegacySettings(settings.merged).filter( (id) => validIds.has(id), ); const others = DEFAULT_ORDER.filter((id) => !derived.includes(id)); setOrderedIds([...derived, ...others]); setSelectedIds(new Set(derived)); setActiveIndex(0); setScrollOffset(0); }, [setSetting, settings.merged]); const handleConfirm = useCallback(async () => { if (isResetFocused) { handleResetToDefaults(); return; } 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, isResetFocused, handleResetToDefaults, ]); 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)) { // Navigation wraps: items 0..filteredItems.length-1, then reset row at filteredItems.length const totalSlots = filteredItems.length + 1; const newIndex = activeIndex > 0 ? activeIndex - 1 : totalSlots - 1; setActiveIndex(newIndex); // Only adjust scroll when within the item list if (newIndex < filteredItems.length) { 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 totalSlots = filteredItems.length + 1; const newIndex = activeIndex < totalSlots - 1 ? activeIndex + 1 : 0; setActiveIndex(newIndex); if (newIndex === 0) { setScrollOffset(0); } else if ( newIndex < filteredItems.length && 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, ); const activeId = filteredItems[activeIndex]?.key; // Preview logic const previewText = useMemo(() => { if (isResetFocused) { return ( Default footer (uses legacy settings) ); } const itemsToPreview = orderedIds.filter((id) => selectedIds.has(id)); if (itemsToPreview.length === 0) return null; const getColor = (id: string, defaultColor?: string) => id === activeId ? 'white' : defaultColor || theme.text.secondary; // Mock values for preview const mockValues: Record = { cwd: ~/project/path, 'git-branch': main*, 'sandbox-status': ( docker ), 'model-name': ( gemini-2.5-pro ), 'context-remaining': ( 85% context left ), quota: daily 97%, 'memory-usage': 124MB, 'session-id': 769992f9, 'code-changes': ( +12 -4 ), 'token-count': 1.5k tokens, }; const elements: React.ReactNode[] = []; itemsToPreview.forEach((id, idx) => { if (idx > 0) { elements.push( {' | '} , ); } elements.push({mockValues[id] || id}); }); return elements; }, [orderedIds, selectedIds, activeId, isResetFocused]); return ( Configure Footer Select which items to display in the footer. Type to search {searchBuffer && } {visibleItems.length === 0 ? ( No items found. ) : ( visibleItems.map((item, idx) => { const index = scrollOffset + idx; const isFocused = index === activeIndex; const isChecked = selectedIds.has(item.key); return ( {isFocused ? '> ' : ' '} [{isChecked ? '✓' : ' '}]{' '} {item.label.padEnd(maxLabelWidth + 1)} {item.description} ); }) )} {isResetFocused ? '> ' : ' '} Reset to default footer ↑/↓ navigate · ←/→ reorder · enter select · esc close {searchQuery && ( Reordering is disabled when searching. )} Preview: {previewText} ); };