/** * @license * Copyright 2025 Vybestack LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useState, useMemo, useCallback, useEffect } from 'react'; import { Box, Text } from 'ink'; import { SemanticColors } from '../colors.js'; import { useResponsive } from '../hooks/useResponsive.js'; import { truncateEnd } from '../utils/responsive.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { getBorderStyle } from '../contexts/UnicodeRenderingContext.js'; export interface ProfileListItem { name: string; type: 'standard' | 'loadbalancer'; provider?: string; model?: string; isDefault?: boolean; isActive?: boolean; loadError?: boolean; } interface ProfileListDialogProps { profiles: ProfileListItem[]; onSelect: (profileName: string) => void; onClose: () => void; onViewDetail: (profileName: string) => void; onDelete: (profileName: string) => void; isLoading?: boolean; defaultProfileName?: string; activeProfileName?: string; } const ProfileItem: React.FC<{ profile: ProfileListItem; index: number; selectedIndex: number; isSearching: boolean; isNarrow: boolean; isWide: boolean; activeProfileName?: string; defaultProfileName?: string; colWidth: number; }> = ({ profile, index, selectedIndex, isSearching, isNarrow, isWide, activeProfileName, defaultProfileName, colWidth, }) => { const selected = index === selectedIndex && (!isSearching || isNarrow); const isActiveProfile = profile.name === activeProfileName; const isDefaultProfile = profile.name === defaultProfileName; let indicators = ''; if (isActiveProfile) indicators += '*'; if (isDefaultProfile) indicators += 'D'; if (profile.type === 'loadbalancer') indicators += 'LB'; const indicatorText = indicators ? ` [${indicators}]` : ''; const maxNameLen = colWidth - 6 - indicatorText.length; const needsTruncation = !isWide && profile.name.length > maxNameLen; const displayName = needsTruncation ? truncateEnd(profile.name, maxNameLen) : profile.name; const getNameColor = (): string => { if (selected) { return SemanticColors.text.accent; } if (isSearching && !isNarrow) { return SemanticColors.text.secondary; } if (isActiveProfile) { return SemanticColors.status.success; } return SemanticColors.text.primary; }; return ( {selected ? '● ' : '○ '} {displayName} {indicatorText && ( {indicatorText} )} ); }; function handleSearchModeKeys( key: { name?: string; sequence?: string; ctrl?: boolean; meta?: boolean }, setSearchTerm: React.Dispatch>, setIsSearching: React.Dispatch>, onViewDetail: (name: string) => void, selectedIndex: number, filteredProfiles: ProfileListItem[], ): void { if (key.name === 'return') { if (filteredProfiles.length > 0) { onViewDetail(filteredProfiles[selectedIndex].name); } return; } if (key.name === 'tab') { setIsSearching(false); return; } if (key.name === 'backspace' || key.name === 'delete') { setSearchTerm((prev) => prev.slice(0, -1)); return; } if (isSingleCharacterInput(key)) { setSearchTerm((prev) => prev + key.sequence); } } function isSingleCharacterInput(key: { sequence?: string; ctrl?: boolean; meta?: boolean; }): key is { sequence: string } { if (key.ctrl === true || key.meta === true) { return false; } return typeof key.sequence === 'string' && key.sequence.length === 1; } function handleNavModeKeys( key: { name?: string; sequence?: string }, filteredProfiles: ProfileListItem[], index: number, move: (delta: number) => void, onViewDetail: (name: string) => void, onSelect: (name: string) => void, setIsSearching: React.Dispatch>, requestDelete: (name: string) => void, columns: number, ): void { if (key.name === 'return' && filteredProfiles.length > 0) { onViewDetail(filteredProfiles[index].name); return; } if (key.name === 'tab') { setIsSearching(true); return; } if (filteredProfiles.length === 0) return; if (key.sequence === 'l') { onSelect(filteredProfiles[index].name); return; } if (key.sequence === 'd') { requestDelete(filteredProfiles[index].name); return; } if (key.name === 'left') move(-1); else if (key.name === 'right') move(1); else if (key.name === 'up') move(-columns); else if (key.name === 'down') move(columns); else if (key.sequence === 'j') move(columns); else if (key.sequence === 'k') move(-columns); else if (key.sequence === 'h') move(-1); } const DeleteConfirmationBanner: React.FC<{ confirmDeleteName: string | null; activeProfileName?: string; defaultProfileName?: string; }> = ({ confirmDeleteName, activeProfileName, defaultProfileName }) => { if (!confirmDeleteName) { return null; } const isActive = confirmDeleteName === activeProfileName; const isDefault = confirmDeleteName === defaultProfileName; const qualifiers = [ isActive ? 'active' : null, isDefault ? 'default' : null, ].filter(Boolean); const qualifierText = qualifiers.length > 0 ? ` (${qualifiers.join(', ')})` : ''; return ( Delete profile '{confirmDeleteName}'{qualifierText}? Press y to confirm, n or Esc to cancel. ); }; const NarrowContent: React.FC<{ searchTerm: string; filteredProfiles: ProfileListItem[]; grid: React.ReactNode[]; confirmDeleteName: string | null; activeProfileName?: string; defaultProfileName?: string; }> = ({ searchTerm, filteredProfiles, grid, confirmDeleteName, activeProfileName, defaultProfileName, }) => ( Profiles Search: {searchTerm} Type to filter, Tab to navigate ([d] Delete), Enter for details, Esc to cancel {filteredProfiles.length} profiles{searchTerm && ' found'} {filteredProfiles.length > 0 ? ( grid ) : ( No profiles match "{searchTerm}" )} ); const WideSearchBar: React.FC<{ isSearching: boolean; searchTerm: string; filteredProfiles: ProfileListItem[]; }> = ({ isSearching, searchTerm, filteredProfiles }) => ( Search: {isSearching && } {searchTerm} {' '} (press Tab to {isSearching ? 'navigate' : 'search'}) Found{' '} {filteredProfiles.length} profiles ); const WideSelectionDetail: React.FC<{ filteredProfiles: ProfileListItem[]; index: number; isSearching: boolean; }> = ({ filteredProfiles, index, isSearching }) => { if (isSearching || index < 0 || index >= filteredProfiles.length) { return null; } const selected = filteredProfiles[index]; return ( Selected: {selected.name} {selected.provider != null && ( {' '} ({selected.provider} {selected.model != null && ` / ${selected.model}`}) )} ); }; const WideContent: React.FC<{ isSearching: boolean; searchTerm: string; filteredProfiles: ProfileListItem[]; index: number; grid: React.ReactNode[]; confirmDeleteName: string | null; activeProfileName?: string; defaultProfileName?: string; }> = ({ isSearching, searchTerm, filteredProfiles, index, grid, confirmDeleteName, activeProfileName, defaultProfileName, }) => ( Profile List {filteredProfiles.length > 0 ? ( grid ) : ( No profiles match "{searchTerm}" )} Legend: * = active, D = default, LB = load balancer Controls: ↑↓←→ Navigate [Enter] Details [l] Load [d] Delete [Esc] Close ); function buildGrid( filteredProfiles: ProfileListItem[], rows: number, columns: number, index: number, isSearching: boolean, isNarrow: boolean, isWide: boolean, activeProfileName: string | undefined, defaultProfileName: string | undefined, colWidth: number, ): React.ReactNode[] { const grid: React.ReactNode[] = []; for (let r = 0; r < rows; r++) { const rowItems: React.ReactNode[] = []; for (let c = 0; c < columns; c++) { const i = r * columns + c; if (i < filteredProfiles.length) { rowItems.push( , ); } } grid.push({rowItems}); } return grid; } const LoadingState: React.FC = () => ( Loading profiles... ); const EmptyState: React.FC = () => ( No saved profiles found. Use /profile save model <name> to create one. Press Esc to close ); function handleDeleteConfirmKeys( key: { name?: string; sequence?: string }, confirmDeleteName: string, setConfirmDeleteName: React.Dispatch>, onDelete: (name: string) => void, ): void { if (key.name === 'escape') { setConfirmDeleteName(null); return; } if (key.sequence === 'y' || key.sequence === 'Y') { setConfirmDeleteName(null); onDelete(confirmDeleteName); return; } if (key.sequence === 'n' || key.sequence === 'N') { setConfirmDeleteName(null); } } function useListKeypress( isSearching: boolean, searchTerm: string, setSearchTerm: React.Dispatch>, setIsSearching: React.Dispatch>, onViewDetail: (name: string) => void, index: number, filteredProfiles: ProfileListItem[], move: (delta: number) => void, onSelect: (name: string) => void, onDelete: (name: string) => void, columns: number, onClose: () => void, isLoading: boolean, confirmDeleteName: string | null, setConfirmDeleteName: React.Dispatch>, ) { const handleKeypress = useCallback( (key: Parameters[0]>[0]) => { if (confirmDeleteName !== null) { handleDeleteConfirmKeys( key, confirmDeleteName, setConfirmDeleteName, onDelete, ); return; } if (key.name === 'escape') { if (isSearching && searchTerm.length > 0) { setSearchTerm(''); } else { onClose(); } return; } if (isSearching) { handleSearchModeKeys( key, setSearchTerm, setIsSearching, onViewDetail, index, filteredProfiles, ); } else { handleNavModeKeys( key, filteredProfiles, index, move, onViewDetail, onSelect, setIsSearching, setConfirmDeleteName, columns, ); } }, [ confirmDeleteName, setConfirmDeleteName, onDelete, isSearching, searchTerm, setSearchTerm, onClose, setIsSearching, onViewDetail, index, filteredProfiles, move, onSelect, columns, ], ); useKeypress(handleKeypress, { isActive: !isLoading }); } function useListLayout( isNarrow: boolean, isWide: boolean, filteredProfiles: ProfileListItem[], ) { const getColumnCount = (): number => { if (isNarrow) { return 1; } return isWide ? 3 : 2; }; const columns = getColumnCount(); const longest = filteredProfiles.reduce( (len, p) => Math.max(len, p.name.length + 10), 0, ); const colWidth = isWide ? Math.max(longest + 4, 35) : Math.max(longest + 4, 25); const rows = Math.ceil(filteredProfiles.length / columns); return { columns, colWidth, rows }; } function useProfileListIndexBounds( searchTerm: string, filteredProfiles: ProfileListItem[], setIndex: React.Dispatch>, ): void { useEffect(() => { setIndex(0); }, [searchTerm, setIndex]); useEffect(() => { setIndex((current) => { if (filteredProfiles.length === 0) return 0; return Math.min(current, filteredProfiles.length - 1); }); }, [filteredProfiles.length, setIndex]); } const ProfileListBody: React.FC<{ isNarrow: boolean; width: number; isSearching: boolean; searchTerm: string; filteredProfiles: ProfileListItem[]; index: number; grid: React.ReactNode[]; confirmDeleteName: string | null; activeProfileName?: string; defaultProfileName?: string; }> = ({ isNarrow, width, isSearching, searchTerm, filteredProfiles, index, grid, confirmDeleteName, activeProfileName, defaultProfileName, }) => { if (isNarrow) { return ( ); } return ( ); }; function useConfirmDeleteClear( confirmDeleteName: string | null, filteredProfiles: ProfileListItem[], setConfirmDeleteName: React.Dispatch>, ): void { useEffect(() => { if ( confirmDeleteName !== null && !filteredProfiles.some((profile) => profile.name === confirmDeleteName) ) { setConfirmDeleteName(null); } }, [confirmDeleteName, filteredProfiles, setConfirmDeleteName]); } function useProfileListMove( index: number, filteredLength: number, setIndex: React.Dispatch>, ) { return useCallback( (delta: number) => { if (filteredLength === 0) { setIndex(0); return; } let next = index + delta; if (next < 0) next = 0; if (next >= filteredLength) next = filteredLength - 1; setIndex(next); }, [index, filteredLength, setIndex], ); } function useProfileListController(opts: { profiles: ProfileListItem[]; onSelect: (profileName: string) => void; onClose: () => void; onViewDetail: (profileName: string) => void; onDelete: (profileName: string) => void; isLoading: boolean; activeProfileName?: string; defaultProfileName?: string; }) { const { isNarrow, isWide, width } = useResponsive(); const [searchTerm, setSearchTerm] = useState(''); const [isSearching, setIsSearching] = useState(true); const [index, setIndex] = useState(0); const [confirmDeleteName, setConfirmDeleteName] = useState( null, ); const filteredProfiles = useMemo( () => opts.profiles.filter((p) => p.name.toLowerCase().includes(searchTerm.toLowerCase()), ), [opts.profiles, searchTerm], ); const { columns, colWidth, rows } = useListLayout( isNarrow, isWide, filteredProfiles, ); useProfileListIndexBounds(searchTerm, filteredProfiles, setIndex); useConfirmDeleteClear( confirmDeleteName, filteredProfiles, setConfirmDeleteName, ); const move = useProfileListMove(index, filteredProfiles.length, setIndex); useListKeypress( isSearching, searchTerm, setSearchTerm, setIsSearching, opts.onViewDetail, index, filteredProfiles, move, opts.onSelect, opts.onDelete, columns, opts.onClose, opts.isLoading, confirmDeleteName, setConfirmDeleteName, ); return { isNarrow, width, isSearching, searchTerm, filteredProfiles, index, confirmDeleteName, grid: buildGrid( filteredProfiles, rows, columns, index, isSearching, isNarrow, isWide, opts.activeProfileName, opts.defaultProfileName, colWidth, ), }; } export const ProfileListDialog: React.FC = (props) => { const controller = useProfileListController({ profiles: props.profiles, onSelect: props.onSelect, onClose: props.onClose, onViewDetail: props.onViewDetail, onDelete: props.onDelete, isLoading: props.isLoading === true, activeProfileName: props.activeProfileName, defaultProfileName: props.defaultProfileName, }); if (props.isLoading === true) return ; if (props.profiles.length === 0) return ; return ( ); };