diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index c9a03d587..8ac52abbc 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -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 diff --git a/packages/cli/CONTRIBUTING.md b/packages/cli/CONTRIBUTING.md index c28e16653..b9fbcc36a 100644 --- a/packages/cli/CONTRIBUTING.md +++ b/packages/cli/CONTRIBUTING.md @@ -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 diff --git a/packages/cli/src/App.tsx b/packages/cli/src/App.tsx index ddef3d3f3..91b17ff07 100644 --- a/packages/cli/src/App.tsx +++ b/packages/cli/src/App.tsx @@ -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('loading'); const [client] = useState(() => new ApiClient({ apiUrl })); const [eventSources, setLogSources] = useState([]); @@ -37,6 +44,7 @@ export default function App({ apiUrl, query, sourceName, follow }: AppProps) { ); const [activeQuery, setActiveQuery] = useState(query ?? ''); const [error, setError] = useState(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 ( @@ -143,53 +199,71 @@ export default function App({ apiUrl, query, sourceName, follow }: AppProps) { ); } - switch (screen) { - case 'loading': - return ( - - - Connecting to {apiUrl}… - - - ); + const renderScreen = () => { + switch (screen) { + case 'loading': + return ( + + + Connecting to {apiUrl}… + + + ); - case 'login': - return ; + case 'login': + return ; - case 'pick-source': - return ( - - - - HyperDX TUI - - Search and tail events from the terminal + case 'pick-source': + return ( + + + + HyperDX TUI + + Search and tail events from the terminal + + - ; + + case 'events': + if (!selectedSource) return null; + return ( + - - ); + ); + } + }; - case 'alerts': - return ; - - case 'events': - if (!selectedSource) return null; - return ( - + - ); + + ); } + + return renderScreen(); } diff --git a/packages/cli/src/components/EventViewer/EventViewer.tsx b/packages/cli/src/components/EventViewer/EventViewer.tsx index 86a2a8ca9..3ff0b66f6 100644 --- a/packages/cli/src/components/EventViewer/EventViewer.tsx +++ b/packages/cli/src/components/EventViewer/EventViewer.tsx @@ -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, diff --git a/packages/cli/src/components/EventViewer/SubComponents.tsx b/packages/cli/src/components/EventViewer/SubComponents.tsx index e84cf5f75..7d2a6d81d 100644 --- a/packages/cli/src/components/EventViewer/SubComponents.tsx +++ b/packages/cli/src/components/EventViewer/SubComponents.tsx @@ -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'], diff --git a/packages/cli/src/components/EventViewer/types.ts b/packages/cli/src/components/EventViewer/types.ts index 37f205af4..179bcd9c0 100644 --- a/packages/cli/src/components/EventViewer/types.ts +++ b/packages/cli/src/components/EventViewer/types.ts @@ -15,6 +15,7 @@ export interface EventViewerProps { savedSearches: SavedSearchResponse[]; onSavedSearchSelect: (search: SavedSearchResponse) => void; onOpenAlerts?: () => void; + onOpenSpotlight?: () => void; initialQuery?: string; follow?: boolean; } diff --git a/packages/cli/src/components/EventViewer/useKeybindings.ts b/packages/cli/src/components/EventViewer/useKeybindings.ts index 9efdfb488..3d7d30137 100644 --- a/packages/cli/src/components/EventViewer/useKeybindings.ts +++ b/packages/cli/src/components/EventViewer/useKeybindings.ts @@ -40,6 +40,7 @@ export interface KeybindingParams { // Navigation onOpenAlerts?: () => void; + onOpenSpotlight?: () => void; // State setters setFocusSearch: React.Dispatch>; @@ -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); diff --git a/packages/cli/src/components/SourcePicker.tsx b/packages/cli/src/components/SourcePicker.tsx index f2e27c086..05395691b 100644 --- a/packages/cli/src/components/SourcePicker.tsx +++ b/packages/cli/src/components/SourcePicker.tsx @@ -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({ ))} - j/k to navigate, Enter/l to select, A=alerts + + j/k to navigate, Enter/l to select, Ctrl+K=spotlight, A=alerts + ); } diff --git a/packages/cli/src/components/Spotlight.tsx b/packages/cli/src/components/Spotlight.tsx new file mode 100644 index 000000000..e1b1f0a2f --- /dev/null +++ b/packages/cli/src/components/Spotlight.tsx @@ -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 ( + + + + Go to… + + (Ctrl+K) + + + + > + { + setQuery(v); + setSelectedIdx(0); + }} + placeholder="Type to filter…" + /> + + + + {visibleItems.length === 0 ? ( + No results + ) : ( + visibleItems.map((item, i) => { + const isSelected = i === clampedIdx; + return ( + + + {isSelected ? ' ▸ ' : ' '} + + [{typeLabel(item.type)}] + + {' '} + {item.label} + {item.description ? ( + + {' '} + — {item.description} + + ) : null} + + + ); + }) + )} + + + {filtered.length > maxVisible && ( + + + {filtered.length - maxVisible} more… + + + )} + + + ↑↓ navigate Enter select Esc close + + + ); +}