diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx index a20a44be422..fde24c24b4f 100644 --- a/packages/cli/src/ui/components/shared/SearchableList.tsx +++ b/packages/cli/src/ui/components/shared/SearchableList.tsx @@ -58,6 +58,17 @@ export interface SearchableListProps { totalVisible: number; }) => React.ReactNode; maxItemsToShow?: number; + /** + * When provided, the component will size the list to the available terminal height + * rather than using a fixed maxItemsToShow. + */ + availableTerminalHeight?: number; + /** + * Scrolling behavior: + * - 'center': keep selection roughly centered (previous behavior) + * - 'keep-visible': only scroll when needed to keep selection visible + */ + scrollMode?: 'center' | 'keep-visible'; /** Hook to handle search logic */ useSearch: (props: { items: T[]; @@ -81,6 +92,8 @@ export function SearchableList({ header, footer, maxItemsToShow = 10, + availableTerminalHeight, + scrollMode = 'center', useSearch, onSearch, resetSelectionOnItemsChange = false, @@ -135,17 +148,83 @@ export function SearchableList({ { isActive: true }, ); - const scrollOffset = Math.max( - 0, - Math.min( - activeIndex - Math.floor(maxItemsToShow / 2), - Math.max(0, filteredItems.length - maxItemsToShow), - ), - ); + const computedMaxItemsToShow = useMemo(() => { + if (availableTerminalHeight === undefined) { + return maxItemsToShow; + } + + // Estimate rows taken by chrome. Keep conservative to avoid flicker. + // - title (optional): 2 + // - search input (optional): 3 + // - header (optional): 2 + // - footer (optional): 2 + const reservedRows = + (title ? 2 : 0) + + (searchBuffer ? 3 : 0) + + (header ? 2 : 0) + + (footer ? 2 : 0) + + 2; // padding / safety + + const availableForList = Math.max( + 1, + availableTerminalHeight - reservedRows, + ); + const approxRowsPerItem = 2; + const fit = Math.max(1, Math.floor(availableForList / approxRowsPerItem)); + return Math.min(30, fit); + }, [ + availableTerminalHeight, + footer, + header, + maxItemsToShow, + searchBuffer, + title, + ]); + + const [scrollOffset, setScrollOffset] = React.useState(0); + const scrollOffsetRef = React.useRef(0); + React.useEffect(() => { + scrollOffsetRef.current = scrollOffset; + }, [scrollOffset]); + + React.useEffect(() => { + const windowSize = computedMaxItemsToShow; + const maxScroll = Math.max(0, filteredItems.length - windowSize); + + if (scrollMode === 'keep-visible') { + const padding = Math.min(1, Math.floor(windowSize / 4)); + const minVisible = scrollOffsetRef.current + padding; + const maxVisible = scrollOffsetRef.current + (windowSize - 1) - padding; + + let next = scrollOffsetRef.current; + if (activeIndex < minVisible) { + next = activeIndex - padding; + } else if (activeIndex > maxVisible) { + next = activeIndex - (windowSize - 1) + padding; + } + + next = Math.max(0, Math.min(next, maxScroll)); + if (next !== scrollOffsetRef.current) { + setScrollOffset(next); + } + return; + } + + const centered = Math.max( + 0, + Math.min( + activeIndex - Math.floor(windowSize / 2), + Math.max(0, filteredItems.length - windowSize), + ), + ); + if (centered !== scrollOffsetRef.current) { + setScrollOffset(centered); + } + }, [activeIndex, computedMaxItemsToShow, filteredItems.length, scrollMode]); const visibleItems = filteredItems.slice( scrollOffset, - scrollOffset + maxItemsToShow, + scrollOffset + computedMaxItemsToShow, ); const defaultRenderItem = ( diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx index 9a7c15144a0..a5d7175896b 100644 --- a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx @@ -21,6 +21,7 @@ import { useExtensionUpdates } from '../../hooks/useExtensionUpdates.js'; import { useConfig } from '../../contexts/ConfigContext.js'; import type { ExtensionManager } from '../../../config/extension-manager.js'; import { useRegistrySearch } from '../../hooks/useRegistrySearch.js'; +import { useUIState } from '../../contexts/UIStateContext.js'; interface ExtensionRegistryViewProps { onSelect?: (extension: RegistryExtension) => void; @@ -39,6 +40,7 @@ export function ExtensionRegistryView({ }: ExtensionRegistryViewProps): React.JSX.Element { const { extensions, loading, error, search } = useExtensionRegistry(); const config = useConfig(); + const uiState = useUIState(); const { extensionsUpdateState } = useExtensionUpdates( extensionManager, @@ -83,7 +85,7 @@ export function ExtensionRegistryView({ - {isActive ? '> ' : ' '} + {isActive ? '>' : ' '} @@ -192,6 +194,8 @@ export function ExtensionRegistryView({ header={header} footer={footer} maxItemsToShow={8} + availableTerminalHeight={uiState.availableTerminalHeight} + scrollMode="keep-visible" useSearch={useRegistrySearch} onSearch={search} resetSelectionOnItemsChange={true} diff --git a/packages/core/src/utils/compatibility.test.ts b/packages/core/src/utils/compatibility.test.ts index c7819578f1e..912488f9ea9 100644 --- a/packages/core/src/utils/compatibility.test.ts +++ b/packages/core/src/utils/compatibility.test.ts @@ -131,7 +131,7 @@ describe('compatibility', () => { ); }); - it('should return JetBrains warning when detected', () => { + it('should return JetBrains warning when detected on macOS', () => { vi.mocked(os.platform).mockReturnValue('darwin'); vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); @@ -139,7 +139,34 @@ describe('compatibility', () => { expect(warnings).toContainEqual( expect.objectContaining({ id: 'jetbrains-terminal', - message: expect.stringContaining('JetBrains terminal detected'), + message: expect.stringContaining('iTerm2 or Terminal.app'), + }), + ); + }); + + it('should return JetBrains warning with Windows Terminal recommendation on Windows', () => { + vi.mocked(os.platform).mockReturnValue('win32'); + vi.mocked(os.release).mockReturnValue('10.0.22000'); + vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); + + const warnings = getCompatibilityWarnings(); + expect(warnings).toContainEqual( + expect.objectContaining({ + id: 'jetbrains-terminal', + message: expect.stringContaining('Windows Terminal'), + }), + ); + }); + + it('should return JetBrains warning with native terminal recommendation on Linux', () => { + vi.mocked(os.platform).mockReturnValue('linux'); + vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); + + const warnings = getCompatibilityWarnings(); + expect(warnings).toContainEqual( + expect.objectContaining({ + id: 'jetbrains-terminal', + message: expect.stringContaining('native terminal emulator'), }), ); }); @@ -205,6 +232,7 @@ describe('compatibility', () => { expect(warnings).toHaveLength(3); expect(warnings[0].message).toContain('Windows 10 detected'); expect(warnings[1].message).toContain('JetBrains terminal detected'); + expect(warnings[1].message).toContain('Windows Terminal'); expect(warnings[2].message).toContain( 'True color (24-bit) support not detected', ); diff --git a/packages/core/src/utils/compatibility.ts b/packages/core/src/utils/compatibility.ts index 8099351ad02..0bfd1ce8fff 100644 --- a/packages/core/src/utils/compatibility.ts +++ b/packages/core/src/utils/compatibility.ts @@ -102,10 +102,20 @@ export function getCompatibilityWarnings(): StartupWarning[] { } if (isJetBrainsTerminal()) { + const platform = os.platform(); + let terminalRecommendation = ''; + + if (platform === 'win32') { + terminalRecommendation = 'Windows Terminal'; + } else if (platform === 'darwin') { + terminalRecommendation = 'iTerm2 or Terminal.app'; + } else { + terminalRecommendation = 'a native terminal emulator'; + } + warnings.push({ id: 'jetbrains-terminal', - message: - 'Warning: JetBrains terminal detected. You may experience rendering or scrolling issues. Using an external terminal (e.g., Windows Terminal, iTerm2) is recommended.', + message: `Warning: JetBrains terminal detected. You may experience rendering or scrolling issues. Using ${terminalRecommendation} is recommended.`, priority: WarningPriority.High, }); }