Skip to content
Open
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,5 @@ desktop.ini
.sass-cache/
connect.lock
typings/
**/.worktrees/
**/.worktrees/
**/docs/feat/
3 changes: 3 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
npm run lint
npm test
npm run build
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"test:component": "npm run format:check && jest",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "npm run format"
"lint": "npm run format",
"prepare": "husky"
},
"files": [
"dist"
Expand Down Expand Up @@ -48,6 +49,7 @@
"eslint-config-xo-react": "^0.27.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^5.2.0",
"husky": "^9.1.7",
"ink-testing-library": "^3.0.0",
"jest": "^29.7.0",
"prettier": "^2.8.7",
Expand Down
190 changes: 190 additions & 0 deletions source/components/SmartCommandFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import {useState, useEffect, useCallback} from 'react';
import {Box, Text, useInput} from 'ink';
import {
fuzzySearch,
debounce,
type SearchableCommand,
} from '../utils/fuzzySearch.js';

interface SmartCommandFilterProps {
commands: SearchableCommand[];
onSelect: (command: SearchableCommand) => void;
onFilterChange?: (filteredCommands: SearchableCommand[]) => void;
placeholder?: string;
maxResults?: number;
}

/**
* SmartCommandFilter component provides intelligent command filtering with:
* - Fuzzy search across command keys, labels, and descriptions
* - Real-time filtering as user types
* - Keyboard navigation (arrow keys, Enter)
* - Accessibility compliance
*/
export default function SmartCommandFilter({
commands,
onSelect,
onFilterChange,
placeholder = 'Search commands...',
maxResults = 10,
}: SmartCommandFilterProps) {
const [filter, setFilter] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const [filteredCommands, setFilteredCommands] =
useState<SearchableCommand[]>(commands);

// Debounced filter function to improve performance
const debouncedFilterUpdate = useCallback(
debounce((newFilter: string) => {
const filtered = fuzzySearch(commands, newFilter).slice(0, maxResults);
setFilteredCommands(filtered);
setSelectedIndex(0); // Reset selection to first item
onFilterChange?.(filtered);
}, 150),
[commands, maxResults, onFilterChange],
);

// Update filtered commands when filter changes
useEffect(() => {
debouncedFilterUpdate(filter);
}, [filter, debouncedFilterUpdate]);

// Reset filtered commands when commands prop changes
useEffect(() => {
setFilteredCommands(commands.slice(0, maxResults));
setSelectedIndex(0);
setFilter('');
}, [commands, maxResults]);

// Handle keyboard input
useInput((input: string, key: any) => {
if (key.upArrow) {
setSelectedIndex(prevIndex => {
const newIndex =
prevIndex > 0 ? prevIndex - 1 : filteredCommands.length - 1;
return newIndex;
});
} else if (key.downArrow) {
setSelectedIndex(prevIndex => {
const newIndex =
prevIndex < filteredCommands.length - 1 ? prevIndex + 1 : 0;
return newIndex;
});
} else if (key.return) {
if (filteredCommands[selectedIndex]) {
onSelect(filteredCommands[selectedIndex]);
}
} else if (key.backspace || key.delete) {
setFilter(prevFilter => prevFilter.slice(0, -1));
} else if (input && !key.ctrl && !key.meta) {
setFilter(prevFilter => prevFilter + input);
}
});

const renderCommand = (command: SearchableCommand, index: number) => {
const isSelected = index === selectedIndex;
const prefix = isSelected ? '> ' : ' ';

return (
<Box key={command.key} flexDirection="row">
<Text color={isSelected ? 'cyan' : 'gray'}>{prefix}</Text>
<Box flexDirection="column" flexGrow={1}>
<Text color={isSelected ? 'cyan' : 'green'} bold>
{command.key}
</Text>
<Text color={isSelected ? 'white' : 'gray'} dimColor={!isSelected}>
{command.description}
</Text>
</Box>
</Box>
);
};

const renderEmpty = () => {
if (commands.length === 0) {
return (
<Box marginTop={1}>
<Text color="yellow">No commands available</Text>
</Box>
);
}

if (filter && filteredCommands.length === 0) {
return (
<Box marginTop={1}>
<Text color="yellow">No commands found matching "{filter}"</Text>
<Text color="gray" dimColor>
Try a different search term or check spelling
</Text>
</Box>
);
}

return null;
};

const renderResults = () => {
if (filteredCommands.length === 0) {
return renderEmpty();
}

return (
<Box flexDirection="column" marginTop={1}>
{filteredCommands.map((command, index) =>
renderCommand(command, index),
)}
</Box>
);
};

const renderHeader = () => {
const commandCount = filteredCommands.length;
const totalCount = commands.length;
const countText = filter
? `${commandCount} of ${totalCount} commands`
: `${totalCount} commands`;

return (
<Box flexDirection="column" marginBottom={1}>
<Box flexDirection="row" marginBottom={1}>
<Text color="blue" bold>
🔍{' '}
</Text>
<Text color="white">{placeholder}</Text>
</Box>

<Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row">
<Text color="gray">Search: </Text>
<Text color="white" backgroundColor="gray">
{filter || ' '}
</Text>
</Box>
<Text color="gray" dimColor>
{countText}
</Text>
</Box>
</Box>
);
};

const renderFooter = () => {
return (
<Box marginTop={1} paddingTop={1} borderStyle="single" borderTop>
<Text color="gray" dimColor>
↑/↓ navigate • Enter to select • Type to filter
</Text>
</Box>
);
};

return (
<Box flexDirection="column">
{renderHeader()}
{renderResults()}
{renderFooter()}
</Box>
);
}

export type {SmartCommandFilterProps, SearchableCommand};
Loading