Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 87 additions & 8 deletions packages/cli/src/ui/components/shared/SearchableList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ export interface SearchableListProps<T extends GenericListItem> {
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[];
Expand All @@ -81,6 +92,8 @@ export function SearchableList<T extends GenericListItem>({
header,
footer,
maxItemsToShow = 10,
availableTerminalHeight,
scrollMode = 'center',
useSearch,
onSearch,
resetSelectionOnItemsChange = false,
Expand Down Expand Up @@ -135,17 +148,83 @@ export function SearchableList<T extends GenericListItem>({
{ 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 = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -83,7 +85,7 @@ export function ExtensionRegistryView({
<Text
color={isActive ? theme.status.success : theme.text.secondary}
>
{isActive ? '> ' : ' '}
{isActive ? '>' : ' '}
</Text>
</Box>
<Box flexShrink={0}>
Expand Down Expand Up @@ -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}
Expand Down
32 changes: 30 additions & 2 deletions packages/core/src/utils/compatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,15 +131,42 @@ 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');

const warnings = getCompatibilityWarnings();
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'),
}),
);
});
Expand Down Expand Up @@ -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',
);
Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/utils/compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
Expand Down