mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: add Ctrl+K spotlight UI for quick navigation between pages
Add a command palette / spotlight overlay triggered by Ctrl+K that enables quick navigation between sources, saved searches, and pages (Alerts). - New Spotlight component with fuzzy text filtering and j/k navigation - Ctrl+K keybinding works from EventViewer and SourcePicker screens - Items are categorized (Source/Search/Page) with color-coded labels - Esc to close, Enter to select, arrow keys or Ctrl+N/P to navigate - Updated help screen, AGENTS.md, and CONTRIBUTING.md with new keybinding Co-authored-by: Warren Lee <wrn14897@users.noreply.github.com>
This commit is contained in:
parent
7a9882d421
commit
e10e284672
9 changed files with 360 additions and 57 deletions
|
|
@ -49,7 +49,8 @@ src/
|
|||
│ ├── RowOverview.tsx # Structured overview (top-level attrs, event attrs, resource attrs)
|
||||
│ ├── ColumnValues.tsx # Shared key-value renderer (used by Column Values tab + Event Details)
|
||||
│ ├── LoginForm.tsx # Email/password login form (used inside TUI App)
|
||||
│ └── SourcePicker.tsx # Arrow-key source selector
|
||||
│ ├── SourcePicker.tsx # Arrow-key source selector
|
||||
│ └── Spotlight.tsx # Ctrl+K spotlight overlay for quick navigation
|
||||
└── utils/
|
||||
├── config.ts # Session persistence (~/.config/hyperdx/cli/session.json)
|
||||
├── editor.ts # $EDITOR integration for time range and select clause editing
|
||||
|
|
@ -157,6 +158,7 @@ Key expression mappings from the web frontend's `getConfig()`:
|
|||
| `t` | Edit time range in $EDITOR |
|
||||
| `f` | Toggle follow mode (live tail) |
|
||||
| `w` | Toggle line wrap |
|
||||
| `Ctrl+K` | Open spotlight (quick navigation) |
|
||||
| `A` (Shift+A) | Open alerts page |
|
||||
| `?` | Toggle help screen |
|
||||
| `q` | Quit |
|
||||
|
|
@ -226,11 +228,13 @@ reorder these checks**:
|
|||
|
||||
1. `?` toggles help (except when search focused)
|
||||
2. Any key closes help when showing
|
||||
3. `focusDetailSearch` — consumes all keys except Esc/Enter
|
||||
4. `focusSearch` — consumes all keys except Tab/Esc
|
||||
5. Trace tab j/k — when detail panel open and Trace tab active
|
||||
6. General j/k, G/g, Enter/Esc, Tab, etc.
|
||||
7. Single-key shortcuts: `w`, `f`, `/`, `s`, `t`, `q`
|
||||
3. SQL preview — D/Esc close, Ctrl+D/U scroll
|
||||
4. `Ctrl+K` — opens spotlight (quick navigation)
|
||||
5. `focusDetailSearch` — consumes all keys except Esc/Enter
|
||||
6. `focusSearch` — consumes all keys except Tab/Esc
|
||||
7. Trace tab j/k — when detail panel open and Trace tab active
|
||||
8. General j/k, G/g, Enter/Esc, Tab, etc.
|
||||
9. Single-key shortcuts: `w`, `f`, `/`, `s`, `t`, `q`
|
||||
|
||||
### Dynamic Table Columns
|
||||
|
||||
|
|
|
|||
|
|
@ -106,7 +106,8 @@ src/
|
|||
│ ├── RowOverview.tsx # Structured overview (Top Level, Attributes, Resources)
|
||||
│ ├── ColumnValues.tsx # Shared key-value renderer with scroll support
|
||||
│ ├── LoginForm.tsx # Email/password login form (used inside TUI App)
|
||||
│ └── SourcePicker.tsx # j/k source selector
|
||||
│ ├── SourcePicker.tsx # j/k source selector
|
||||
│ └── Spotlight.tsx # Ctrl+K spotlight overlay for quick navigation
|
||||
├── shared/ # Logic ported from packages/app (@source annotated)
|
||||
│ ├── useRowWhere.ts # processRowToWhereClause, buildColumnMap, getRowWhere
|
||||
│ ├── source.ts # getDisplayedTimestampValueExpression, getEventBody, etc.
|
||||
|
|
@ -181,12 +182,14 @@ reorder these checks**:
|
|||
|
||||
1. `?` toggles help (except when search focused)
|
||||
2. Any key closes help when showing
|
||||
3. `focusDetailSearch` — consumes all keys except Esc/Enter
|
||||
4. `focusSearch` — consumes all keys except Tab/Esc
|
||||
5. Trace tab j/k + Ctrl+D/U — when detail panel open and Trace tab active
|
||||
6. Column Values / Overview Ctrl+D/U — scroll detail view
|
||||
7. General j/k, G/g, Enter/Esc, Tab, etc.
|
||||
8. Single-key shortcuts: `w`, `f`, `/`, `s`, `t`, `q`
|
||||
3. SQL preview — D/Esc close, Ctrl+D/U scroll
|
||||
4. `Ctrl+K` — opens spotlight (quick navigation)
|
||||
5. `focusDetailSearch` — consumes all keys except Esc/Enter
|
||||
6. `focusSearch` — consumes all keys except Tab/Esc
|
||||
7. Trace tab j/k + Ctrl+D/U — when detail panel open and Trace tab active
|
||||
8. Column Values / Overview Ctrl+D/U — scroll detail view
|
||||
9. General j/k, G/g, Enter/Esc, Tab, etc.
|
||||
10. Single-key shortcuts: `w`, `f`, `/`, `s`, `t`, `q`
|
||||
|
||||
### Follow Mode
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Box, Text, useStdout } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
|
||||
import { SourceKind } from '@hyperdx/common-utils/dist/types';
|
||||
|
|
@ -13,6 +13,10 @@ import AlertsPage from '@/components/AlertsPage';
|
|||
import ErrorDisplay from '@/components/ErrorDisplay';
|
||||
import LoginForm from '@/components/LoginForm';
|
||||
import SourcePicker from '@/components/SourcePicker';
|
||||
import Spotlight, {
|
||||
buildSpotlightItems,
|
||||
type SpotlightItem,
|
||||
} from '@/components/Spotlight';
|
||||
import EventViewer from '@/components/EventViewer';
|
||||
|
||||
type Screen = 'loading' | 'login' | 'pick-source' | 'events' | 'alerts';
|
||||
|
|
@ -28,6 +32,9 @@ interface AppProps {
|
|||
}
|
||||
|
||||
export default function App({ apiUrl, query, sourceName, follow }: AppProps) {
|
||||
const { stdout } = useStdout();
|
||||
const termHeight = stdout?.rows ?? 24;
|
||||
|
||||
const [screen, setScreen] = useState<Screen>('loading');
|
||||
const [client] = useState(() => new ApiClient({ apiUrl }));
|
||||
const [eventSources, setLogSources] = useState<SourceResponse[]>([]);
|
||||
|
|
@ -37,6 +44,7 @@ export default function App({ apiUrl, query, sourceName, follow }: AppProps) {
|
|||
);
|
||||
const [activeQuery, setActiveQuery] = useState(query ?? '');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSpotlight, setShowSpotlight] = useState(false);
|
||||
|
||||
// Check existing session on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -135,6 +143,54 @@ export default function App({ apiUrl, query, sourceName, follow }: AppProps) {
|
|||
setScreen(preAlertsScreen);
|
||||
}, [preAlertsScreen]);
|
||||
|
||||
// ---- Spotlight (Ctrl+K) --------------------------------------------
|
||||
|
||||
const spotlightItems = useMemo(
|
||||
() => buildSpotlightItems(eventSources, savedSearches),
|
||||
[eventSources, savedSearches],
|
||||
);
|
||||
|
||||
const handleOpenSpotlight = useCallback(() => {
|
||||
setShowSpotlight(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseSpotlight = useCallback(() => {
|
||||
setShowSpotlight(false);
|
||||
}, []);
|
||||
|
||||
const handleSpotlightSelect = useCallback(
|
||||
(item: SpotlightItem) => {
|
||||
setShowSpotlight(false);
|
||||
switch (item.type) {
|
||||
case 'source':
|
||||
if (item.source) {
|
||||
setSelectedSource(item.source);
|
||||
setActiveQuery('');
|
||||
setScreen('events');
|
||||
}
|
||||
break;
|
||||
case 'saved-search':
|
||||
if (item.search) {
|
||||
const source = eventSources.find(
|
||||
s => s.id === item.search!.source || s._id === item.search!.source,
|
||||
);
|
||||
if (source) {
|
||||
setSelectedSource(source);
|
||||
}
|
||||
setActiveQuery(item.search.where);
|
||||
setScreen('events');
|
||||
}
|
||||
break;
|
||||
case 'page':
|
||||
if (item.page === 'alerts') {
|
||||
handleOpenAlerts();
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[eventSources, handleOpenAlerts],
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box paddingX={1}>
|
||||
|
|
@ -143,53 +199,71 @@ export default function App({ apiUrl, query, sourceName, follow }: AppProps) {
|
|||
);
|
||||
}
|
||||
|
||||
switch (screen) {
|
||||
case 'loading':
|
||||
return (
|
||||
<Box paddingX={1}>
|
||||
<Text>
|
||||
<Spinner type="dots" /> Connecting to {apiUrl}…
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'login':
|
||||
return <LoginForm apiUrl={apiUrl} onLogin={handleLogin} />;
|
||||
|
||||
case 'pick-source':
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="#00c28a" bold>
|
||||
HyperDX TUI
|
||||
const renderScreen = () => {
|
||||
switch (screen) {
|
||||
case 'loading':
|
||||
return (
|
||||
<Box paddingX={1}>
|
||||
<Text>
|
||||
<Spinner type="dots" /> Connecting to {apiUrl}…
|
||||
</Text>
|
||||
<Text dimColor>Search and tail events from the terminal</Text>
|
||||
</Box>
|
||||
<SourcePicker
|
||||
);
|
||||
|
||||
case 'login':
|
||||
return <LoginForm apiUrl={apiUrl} onLogin={handleLogin} />;
|
||||
|
||||
case 'pick-source':
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="#00c28a" bold>
|
||||
HyperDX TUI
|
||||
</Text>
|
||||
<Text dimColor>Search and tail events from the terminal</Text>
|
||||
</Box>
|
||||
<SourcePicker
|
||||
sources={eventSources}
|
||||
onSelect={handleSourceSelect}
|
||||
onOpenAlerts={handleOpenAlerts}
|
||||
onOpenSpotlight={handleOpenSpotlight}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'alerts':
|
||||
return <AlertsPage client={client} onClose={handleCloseAlerts} />;
|
||||
|
||||
case 'events':
|
||||
if (!selectedSource) return null;
|
||||
return (
|
||||
<EventViewer
|
||||
clickhouseClient={client.createClickHouseClient()}
|
||||
metadata={client.createMetadata()}
|
||||
source={selectedSource}
|
||||
sources={eventSources}
|
||||
onSelect={handleSourceSelect}
|
||||
savedSearches={savedSearches}
|
||||
onSavedSearchSelect={handleSavedSearchSelect}
|
||||
onOpenAlerts={handleOpenAlerts}
|
||||
onOpenSpotlight={handleOpenSpotlight}
|
||||
initialQuery={activeQuery}
|
||||
follow={follow}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
case 'alerts':
|
||||
return <AlertsPage client={client} onClose={handleCloseAlerts} />;
|
||||
|
||||
case 'events':
|
||||
if (!selectedSource) return null;
|
||||
return (
|
||||
<EventViewer
|
||||
clickhouseClient={client.createClickHouseClient()}
|
||||
metadata={client.createMetadata()}
|
||||
source={selectedSource}
|
||||
sources={eventSources}
|
||||
savedSearches={savedSearches}
|
||||
onSavedSearchSelect={handleSavedSearchSelect}
|
||||
onOpenAlerts={handleOpenAlerts}
|
||||
initialQuery={activeQuery}
|
||||
follow={follow}
|
||||
if (showSpotlight) {
|
||||
return (
|
||||
<Box flexDirection="column" height={termHeight}>
|
||||
<Spotlight
|
||||
items={spotlightItems}
|
||||
onSelect={handleSpotlightSelect}
|
||||
onClose={handleCloseSpotlight}
|
||||
/>
|
||||
);
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return renderScreen();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export default function EventViewer({
|
|||
savedSearches,
|
||||
onSavedSearchSelect,
|
||||
onOpenAlerts,
|
||||
onOpenSpotlight,
|
||||
initialQuery = '',
|
||||
follow = true,
|
||||
}: EventViewerProps) {
|
||||
|
|
@ -182,6 +183,7 @@ export default function EventViewer({
|
|||
findActiveIndex,
|
||||
onSavedSearchSelect,
|
||||
onOpenAlerts,
|
||||
onOpenSpotlight,
|
||||
setFocusSearch,
|
||||
setFocusDetailSearch,
|
||||
setShowHelp,
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@ export const HelpScreen = React.memo(function HelpScreen() {
|
|||
['D', 'Show generated SQL'],
|
||||
['f', 'Toggle follow mode (live tail)'],
|
||||
['w', 'Toggle line wrap'],
|
||||
['Ctrl+K', 'Open spotlight (quick navigation)'],
|
||||
['A (Shift+A)', 'Open alerts page'],
|
||||
['?', 'Toggle this help'],
|
||||
['q', 'Quit'],
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export interface EventViewerProps {
|
|||
savedSearches: SavedSearchResponse[];
|
||||
onSavedSearchSelect: (search: SavedSearchResponse) => void;
|
||||
onOpenAlerts?: () => void;
|
||||
onOpenSpotlight?: () => void;
|
||||
initialQuery?: string;
|
||||
follow?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export interface KeybindingParams {
|
|||
|
||||
// Navigation
|
||||
onOpenAlerts?: () => void;
|
||||
onOpenSpotlight?: () => void;
|
||||
|
||||
// State setters
|
||||
setFocusSearch: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
|
@ -98,6 +99,7 @@ export function useKeybindings(params: KeybindingParams): void {
|
|||
findActiveIndex,
|
||||
onSavedSearchSelect,
|
||||
onOpenAlerts,
|
||||
onOpenSpotlight,
|
||||
setFocusSearch,
|
||||
setFocusDetailSearch,
|
||||
setShowHelp,
|
||||
|
|
@ -175,6 +177,12 @@ export function useKeybindings(params: KeybindingParams): void {
|
|||
return;
|
||||
}
|
||||
|
||||
// Ctrl+K opens spotlight from anywhere (except text inputs)
|
||||
if (key.ctrl && input === 'k' && onOpenSpotlight) {
|
||||
onOpenSpotlight();
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusDetailSearch) {
|
||||
if (key.escape || key.return) {
|
||||
setFocusDetailSearch(false);
|
||||
|
|
|
|||
|
|
@ -7,16 +7,22 @@ interface SourcePickerProps {
|
|||
sources: SourceResponse[];
|
||||
onSelect: (source: SourceResponse) => void;
|
||||
onOpenAlerts?: () => void;
|
||||
onOpenSpotlight?: () => void;
|
||||
}
|
||||
|
||||
export default function SourcePicker({
|
||||
sources,
|
||||
onSelect,
|
||||
onOpenAlerts,
|
||||
onOpenSpotlight,
|
||||
}: SourcePickerProps) {
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.ctrl && input === 'k' && onOpenSpotlight) {
|
||||
onOpenSpotlight();
|
||||
return;
|
||||
}
|
||||
if (input === 'A' && onOpenAlerts) {
|
||||
onOpenAlerts();
|
||||
return;
|
||||
|
|
@ -48,7 +54,9 @@ export default function SourcePicker({
|
|||
</Text>
|
||||
))}
|
||||
<Text> </Text>
|
||||
<Text dimColor>j/k to navigate, Enter/l to select, A=alerts</Text>
|
||||
<Text dimColor>
|
||||
j/k to navigate, Enter/l to select, Ctrl+K=spotlight, A=alerts
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
202
packages/cli/src/components/Spotlight.tsx
Normal file
202
packages/cli/src/components/Spotlight.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { Box, Text, useInput, useStdout } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
|
||||
import type { SourceResponse, SavedSearchResponse } from '@/api/client';
|
||||
|
||||
export interface SpotlightItem {
|
||||
id: string;
|
||||
type: 'source' | 'saved-search' | 'page';
|
||||
label: string;
|
||||
description?: string;
|
||||
source?: SourceResponse;
|
||||
search?: SavedSearchResponse;
|
||||
page?: string;
|
||||
}
|
||||
|
||||
interface SpotlightProps {
|
||||
items: SpotlightItem[];
|
||||
onSelect: (item: SpotlightItem) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function buildSpotlightItems(
|
||||
sources: SourceResponse[],
|
||||
savedSearches: SavedSearchResponse[],
|
||||
): SpotlightItem[] {
|
||||
const items: SpotlightItem[] = [];
|
||||
|
||||
for (const source of sources) {
|
||||
items.push({
|
||||
id: `source-${source.id}`,
|
||||
type: 'source',
|
||||
label: source.name,
|
||||
description: `${source.from.databaseName}.${source.from.tableName}`,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
for (const ss of savedSearches) {
|
||||
const src = sources.find(s => s.id === ss.source || s._id === ss.source);
|
||||
items.push({
|
||||
id: `search-${ss.id || ss._id}`,
|
||||
type: 'saved-search',
|
||||
label: ss.name,
|
||||
description: src ? `${src.name} — ${ss.where || '(no filter)'}` : ss.where,
|
||||
search: ss,
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: 'page-alerts',
|
||||
type: 'page',
|
||||
label: 'Alerts',
|
||||
description: 'View alert rules and recent history',
|
||||
page: 'alerts',
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export default function Spotlight({ items, onSelect, onClose }: SpotlightProps) {
|
||||
const { stdout } = useStdout();
|
||||
const termHeight = stdout?.rows ?? 24;
|
||||
const termWidth = stdout?.columns ?? 80;
|
||||
const maxVisible = Math.min(items.length, Math.max(5, termHeight - 8));
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return items;
|
||||
const q = query.toLowerCase();
|
||||
return items.filter(
|
||||
item =>
|
||||
item.label.toLowerCase().includes(q) ||
|
||||
(item.description && item.description.toLowerCase().includes(q)) ||
|
||||
item.type.toLowerCase().includes(q),
|
||||
);
|
||||
}, [items, query]);
|
||||
|
||||
const visibleItems = filtered.slice(0, maxVisible);
|
||||
const clampedIdx = Math.min(selectedIdx, Math.max(0, filtered.length - 1));
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.escape) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (key.return) {
|
||||
if (filtered.length > 0) {
|
||||
onSelect(filtered[clampedIdx]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.downArrow || (key.ctrl && input === 'n')) {
|
||||
setSelectedIdx(prev => Math.min(prev + 1, filtered.length - 1));
|
||||
return;
|
||||
}
|
||||
if (key.upArrow || (key.ctrl && input === 'p')) {
|
||||
setSelectedIdx(prev => Math.max(0, prev - 1));
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const boxWidth = Math.min(60, termWidth - 4);
|
||||
|
||||
const typeLabel = (type: SpotlightItem['type']) => {
|
||||
switch (type) {
|
||||
case 'source':
|
||||
return 'Source';
|
||||
case 'saved-search':
|
||||
return 'Search';
|
||||
case 'page':
|
||||
return 'Page';
|
||||
}
|
||||
};
|
||||
|
||||
const typeColor = (type: SpotlightItem['type']) => {
|
||||
switch (type) {
|
||||
case 'source':
|
||||
return 'green';
|
||||
case 'saved-search':
|
||||
return 'yellow';
|
||||
case 'page':
|
||||
return 'magenta';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
width={boxWidth}
|
||||
borderStyle="round"
|
||||
borderColor="cyan"
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color="cyan">
|
||||
Go to…
|
||||
</Text>
|
||||
<Text dimColor> (Ctrl+K)</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text color="cyan">> </Text>
|
||||
<TextInput
|
||||
value={query}
|
||||
onChange={v => {
|
||||
setQuery(v);
|
||||
setSelectedIdx(0);
|
||||
}}
|
||||
placeholder="Type to filter…"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{visibleItems.length === 0 ? (
|
||||
<Text dimColor>No results</Text>
|
||||
) : (
|
||||
visibleItems.map((item, i) => {
|
||||
const isSelected = i === clampedIdx;
|
||||
return (
|
||||
<Box key={item.id}>
|
||||
<Text
|
||||
color={isSelected ? 'cyan' : undefined}
|
||||
bold={isSelected}
|
||||
inverse={isSelected}
|
||||
>
|
||||
{isSelected ? ' ▸ ' : ' '}
|
||||
<Text color={typeColor(item.type)} bold={isSelected} inverse={isSelected}>
|
||||
[{typeLabel(item.type)}]
|
||||
</Text>
|
||||
{' '}
|
||||
{item.label}
|
||||
{item.description ? (
|
||||
<Text dimColor={!isSelected} inverse={isSelected}>
|
||||
{' '}
|
||||
— {item.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{filtered.length > maxVisible && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
{filtered.length - maxVisible} more…
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>↑↓ navigate Enter select Esc close</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue