diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx index 1090d4010d..32a353d283 100644 --- a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx +++ b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { Text, Box } from 'ink'; import { theme } from '../../semantic-colors.js'; import { @@ -82,35 +82,39 @@ export function BaseSelectionList< priority, }); - const [scrollOffset, setScrollOffset] = useState(0); + const [scrollOffsetState, setScrollOffsetState] = useState(0); - // Handle scrolling for long lists - useEffect(() => { - const newScrollOffset = Math.max( + // Compute effective scroll offset synchronously during render to avoid flicker + let scrollOffset = scrollOffsetState; + + if (activeIndex < scrollOffset) { + scrollOffset = activeIndex; + } else if (activeIndex >= scrollOffset + maxItemsToShow) { + scrollOffset = Math.max( 0, Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow), ); - if (activeIndex < scrollOffset) { - setScrollOffset(activeIndex); - } else if (activeIndex >= scrollOffset + maxItemsToShow) { - setScrollOffset(newScrollOffset); - } - }, [activeIndex, items.length, scrollOffset, maxItemsToShow]); + } + + const maxScroll = Math.max(0, items.length - maxItemsToShow); + if (scrollOffset > maxScroll) { + scrollOffset = maxScroll; + } + + // Update state to match derived value if it changed + if (scrollOffsetState !== scrollOffset) { + setScrollOffsetState(scrollOffset); + } const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow); const numberColumnWidth = String(items.length).length; + const canScrollUp = scrollOffset > 0; + const canScrollDown = scrollOffset + maxItemsToShow < items.length; + const hasScrollArrows = showScrollArrows && items.length > maxItemsToShow; + return ( - {/* Use conditional coloring instead of conditional rendering */} - {showScrollArrows && items.length > maxItemsToShow && ( - 0 ? theme.text.primary : theme.text.secondary} - > - ▲ - - )} - {visibleItems.map((item, index) => { const itemIndex = scrollOffset + index; const isSelected = activeIndex === itemIndex; @@ -139,19 +143,35 @@ export function BaseSelectionList< numberColumnWidth, )}.`; + // Determine the indicator character for the radio column + let indicator = ' '; + let indicatorColor = theme.text.primary; + + if (isSelected) { + indicator = selectedIndicator; + indicatorColor = theme.ui.focus; + } else if (hasScrollArrows && index === 0 && canScrollUp) { + indicator = '▲'; + indicatorColor = theme.text.secondary; + } else if ( + hasScrollArrows && + index === visibleItems.length - 1 && + canScrollDown + ) { + indicator = '▼'; + indicatorColor = theme.text.secondary; + } + return ( - {/* Radio button indicator */} + {/* Radio button indicator (also shows scroll arrows inline) */} - - {isSelected ? selectedIndicator : ' '} + + {indicator} @@ -178,18 +198,6 @@ export function BaseSelectionList< ); })} - - {showScrollArrows && items.length > maxItemsToShow && ( - - ▼ - - )} ); } diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx index 5b6c5259e7..c6b9ee1226 100644 --- a/packages/cli/src/ui/components/shared/SearchableList.tsx +++ b/packages/cli/src/ui/components/shared/SearchableList.tsx @@ -172,11 +172,12 @@ export function SearchableList({ item: T, isActive: boolean, labelWidth: number, + arrowChar?: string, ) => ( - {isActive ? '●' : ''} + {isActive ? '●' : arrowChar || ' '} @@ -226,26 +227,44 @@ export function SearchableList({ ) : ( <> - {filteredItems.length > maxItemsToShow && ( - - - - )} {visibleItems.map((item, index) => { const isSelected = activeIndex === scrollOffset + index; + const hasScrollArrows = filteredItems.length > maxItemsToShow; + const canScrollUp = scrollOffset > 0; + const canScrollDown = + scrollOffset + maxItemsToShow < filteredItems.length; + + // Determine inline scroll arrow for this item position + let arrowChar = ''; + if ( + hasScrollArrows && + index === 0 && + canScrollUp && + !isSelected + ) { + arrowChar = '▲'; + } else if ( + hasScrollArrows && + index === visibleItems.length - 1 && + canScrollDown && + !isSelected + ) { + arrowChar = '▼'; + } + return ( {renderItem ? renderItem(item, isSelected, maxLabelWidth) - : defaultRenderItem(item, isSelected, maxLabelWidth)} + : defaultRenderItem( + item, + isSelected, + maxLabelWidth, + arrowChar, + )} ); })} - {filteredItems.length > maxItemsToShow && ( - - - - )} )} diff --git a/packages/cli/src/ui/components/shared/__snapshots__/BaseSelectionList.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/BaseSelectionList.test.tsx.snap index 040b6babec..09a6162d3d 100644 --- a/packages/cli/src/ui/components/shared/__snapshots__/BaseSelectionList.test.tsx.snap +++ b/packages/cli/src/ui/components/shared/__snapshots__/BaseSelectionList.test.tsx.snap @@ -8,28 +8,22 @@ exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should not show `; exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the end 1`] = ` -"▲ - 8. Item 8 +"▲ 8. Item 8 9. Item 9 ● 10. Item 10 -▼ " `; exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the middle 1`] = ` -"▲ - 4. Item 4 +"▲ 4. Item 4 5. Item 5 ● 6. Item 6 -▼ " `; exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows with correct colors when enabled (at the top) 1`] = ` -"▲ -● 1. Item 1 +"● 1. Item 1 2. Item 2 - 3. Item 3 -▼ +▼ 3. Item 3 " `; diff --git a/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap index 9f256d4cb6..35f21daee3 100644 --- a/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap +++ b/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap @@ -7,10 +7,10 @@ exports[`SearchableList > should match snapshot 1`] = ` │ Search... │ ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ - ● Item One + ● Item One Description for item one - Item Two + Item Two Description for item two Item Three @@ -25,10 +25,10 @@ exports[`SearchableList > should reset selection to top when items change if res │ Search... │ ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ - Item One + Item One Description for item one - ● Item Two + ● Item Two Description for item two Item Three @@ -43,7 +43,7 @@ exports[`SearchableList > should reset selection to top when items change if res │ One │ ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ - ● Item One + ● Item One Description for item one " `; @@ -55,10 +55,10 @@ exports[`SearchableList > should reset selection to top when items change if res │ Search... │ ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ - ● Item One + ● Item One Description for item one - Item Two + Item Two Description for item two Item Three