feat(bd-1o0): Command Palette Fuzzy Search
- Integrate fuzzyMatch utility for fzf-style fuzzy matching on label, category, and action fields - Highlight matching characters in yellow using blessed tags - Add recent commands history (last 10), persisted to ~/.fabric/recent-commands.json - Boost recently-used commands in fuzzy search scoring - Show recent commands first when no query is entered - Arrow key navigation with wrapping (pre-existing) - Add comprehensive test suite (13 tests) Co-Authored-By: Claude Worker <noreply@anthropic.com>
This commit is contained in:
parent
629d7430dc
commit
d6b9976a88
2 changed files with 449 additions and 19 deletions
321
src/tui/components/CommandPalette.test.ts
Normal file
321
src/tui/components/CommandPalette.test.ts
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
/**
|
||||
* Tests for CommandPalette Component
|
||||
*
|
||||
* Tests fuzzy search, recent commands, and match highlighting.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock fs module
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => '[]'),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock blessed module
|
||||
vi.mock('blessed', () => {
|
||||
const mockBoxInstance = {
|
||||
show: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
hidden: true,
|
||||
screen: { render: vi.fn() },
|
||||
};
|
||||
const mockInputInstance = {
|
||||
on: vi.fn(),
|
||||
key: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
setValue: vi.fn(),
|
||||
getValue: vi.fn(() => ''),
|
||||
};
|
||||
const mockListInstance = {
|
||||
setItems: vi.fn(),
|
||||
select: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
default: {
|
||||
box: vi.fn(() => mockBoxInstance),
|
||||
textbox: vi.fn(() => mockInputInstance),
|
||||
list: vi.fn(() => mockListInstance),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import blessed from 'blessed';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { CommandPalette } from './CommandPalette.js';
|
||||
|
||||
function createMockScreen() {
|
||||
return {
|
||||
render: vi.fn(),
|
||||
append: vi.fn(),
|
||||
key: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
} as unknown as blessed.Widgets.Screen;
|
||||
}
|
||||
|
||||
describe('CommandPalette', () => {
|
||||
let palette: CommandPalette;
|
||||
let mockScreen: blessed.Widgets.Screen;
|
||||
let onSubmit: ReturnType<typeof vi.fn>;
|
||||
let mockInput: any;
|
||||
let mockList: any;
|
||||
let mockBox: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(existsSync as any).mockReturnValue(false);
|
||||
|
||||
mockScreen = createMockScreen();
|
||||
onSubmit = vi.fn();
|
||||
|
||||
// Get mock instances
|
||||
const blessedMock = blessed as any;
|
||||
mockBox = blessedMock.box();
|
||||
mockInput = blessedMock.textbox();
|
||||
mockList = blessedMock.list();
|
||||
|
||||
// Reset mock instances
|
||||
vi.clearAllMocks();
|
||||
|
||||
palette = new CommandPalette({
|
||||
parent: mockScreen,
|
||||
onSubmit,
|
||||
});
|
||||
|
||||
// Re-capture after construction
|
||||
mockBox = blessedMock.box();
|
||||
mockInput = blessedMock.textbox();
|
||||
mockList = blessedMock.list();
|
||||
});
|
||||
|
||||
describe('Fuzzy Search', () => {
|
||||
it('should show all suggestions when query is empty', () => {
|
||||
// Trigger show which calls filterSuggestions('')
|
||||
palette.show();
|
||||
|
||||
// The list should be populated with all default suggestions
|
||||
const setItemsCalls = mockList.setItems.mock.calls;
|
||||
const lastCall = setItemsCalls[setItemsCalls.length - 1];
|
||||
expect(lastCall[0].length).toBe(13); // 13 default suggestions
|
||||
});
|
||||
|
||||
it('should fuzzy match on partial input', () => {
|
||||
// Access internal filterSuggestions via keypress handler
|
||||
// We need to simulate the filtering through the public interface
|
||||
const inputHandlers: Record<string, Function> = {};
|
||||
mockInput.on.mockImplementation((event: string, handler: Function) => {
|
||||
inputHandlers[event] = handler;
|
||||
});
|
||||
mockInput.getValue.mockReturnValue('fltr');
|
||||
|
||||
// Re-create to capture handlers
|
||||
palette = new CommandPalette({
|
||||
parent: mockScreen,
|
||||
onSubmit,
|
||||
});
|
||||
|
||||
// Simulate keypress
|
||||
if (inputHandlers['keypress']) {
|
||||
inputHandlers['keypress']('r', { name: 'r' });
|
||||
}
|
||||
|
||||
// Verify setItems was called with filtered results
|
||||
const setItemsCalls = mockList.setItems.mock.calls;
|
||||
if (setItemsCalls.length > 0) {
|
||||
const lastCall = setItemsCalls[setItemsCalls.length - 1];
|
||||
// "fltr" should fuzzy match "Filter by worker", "Filter by level", etc.
|
||||
expect(lastCall[0].length).toBeGreaterThan(0);
|
||||
expect(lastCall[0].length).toBeLessThan(13);
|
||||
}
|
||||
});
|
||||
|
||||
it('should highlight matching characters with yellow tags', () => {
|
||||
const inputHandlers: Record<string, Function> = {};
|
||||
mockInput.on.mockImplementation((event: string, handler: Function) => {
|
||||
inputHandlers[event] = handler;
|
||||
});
|
||||
mockInput.getValue.mockReturnValue('help');
|
||||
|
||||
palette = new CommandPalette({
|
||||
parent: mockScreen,
|
||||
onSubmit,
|
||||
});
|
||||
|
||||
if (inputHandlers['keypress']) {
|
||||
inputHandlers['keypress']('p', { name: 'p' });
|
||||
}
|
||||
|
||||
const setItemsCalls = mockList.setItems.mock.calls;
|
||||
if (setItemsCalls.length > 0) {
|
||||
const lastCall = setItemsCalls[setItemsCalls.length - 1];
|
||||
// At least one result should contain yellow highlight tags
|
||||
const hasHighlight = lastCall[0].some((item: string) =>
|
||||
item.includes('{yellow-fg}')
|
||||
);
|
||||
expect(hasHighlight).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return no results for non-matching query', () => {
|
||||
const inputHandlers: Record<string, Function> = {};
|
||||
mockInput.on.mockImplementation((event: string, handler: Function) => {
|
||||
inputHandlers[event] = handler;
|
||||
});
|
||||
mockInput.getValue.mockReturnValue('zzzzzzz');
|
||||
|
||||
palette = new CommandPalette({
|
||||
parent: mockScreen,
|
||||
onSubmit,
|
||||
});
|
||||
|
||||
if (inputHandlers['keypress']) {
|
||||
inputHandlers['keypress']('z', { name: 'z' });
|
||||
}
|
||||
|
||||
const setItemsCalls = mockList.setItems.mock.calls;
|
||||
if (setItemsCalls.length > 0) {
|
||||
const lastCall = setItemsCalls[setItemsCalls.length - 1];
|
||||
expect(lastCall[0].length).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Recent Commands', () => {
|
||||
it('should load recent commands from file on construction', () => {
|
||||
(existsSync as any).mockReturnValue(true);
|
||||
(readFileSync as any).mockReturnValue(JSON.stringify(['help', 'quit']));
|
||||
|
||||
const p = new CommandPalette({
|
||||
parent: mockScreen,
|
||||
onSubmit,
|
||||
});
|
||||
|
||||
expect(existsSync).toHaveBeenCalled();
|
||||
expect(readFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should save recent commands when executing a command', () => {
|
||||
const inputHandlers: Record<string, Function> = {};
|
||||
mockInput.on.mockImplementation((event: string, handler: Function) => {
|
||||
inputHandlers[event] = handler;
|
||||
});
|
||||
|
||||
palette = new CommandPalette({
|
||||
parent: mockScreen,
|
||||
onSubmit,
|
||||
});
|
||||
|
||||
// Simulate enter key to execute selected
|
||||
if (inputHandlers['keypress']) {
|
||||
inputHandlers['keypress']('', { name: 'enter' });
|
||||
}
|
||||
|
||||
// writeFileSync should have been called to save recent commands
|
||||
expect(writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle missing recent commands file gracefully', () => {
|
||||
(existsSync as any).mockReturnValue(false);
|
||||
|
||||
// Should not throw
|
||||
expect(() => {
|
||||
new CommandPalette({
|
||||
parent: mockScreen,
|
||||
onSubmit,
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle corrupt recent commands file gracefully', () => {
|
||||
(existsSync as any).mockReturnValue(true);
|
||||
(readFileSync as any).mockReturnValue('not valid json{{{');
|
||||
|
||||
expect(() => {
|
||||
new CommandPalette({
|
||||
parent: mockScreen,
|
||||
onSubmit,
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should support arrow key selection', () => {
|
||||
const inputHandlers: Record<string, Function> = {};
|
||||
mockInput.on.mockImplementation((event: string, handler: Function) => {
|
||||
inputHandlers[event] = handler;
|
||||
});
|
||||
|
||||
palette = new CommandPalette({
|
||||
parent: mockScreen,
|
||||
onSubmit,
|
||||
});
|
||||
|
||||
// Arrow down
|
||||
if (inputHandlers['keypress']) {
|
||||
inputHandlers['keypress']('', { name: 'down' });
|
||||
const selectCalls = mockList.select.mock.calls;
|
||||
if (selectCalls.length > 0) {
|
||||
expect(selectCalls[selectCalls.length - 1][0]).toBe(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should wrap around when navigating past end', () => {
|
||||
const inputHandlers: Record<string, Function> = {};
|
||||
mockInput.on.mockImplementation((event: string, handler: Function) => {
|
||||
inputHandlers[event] = handler;
|
||||
});
|
||||
|
||||
palette = new CommandPalette({
|
||||
parent: mockScreen,
|
||||
onSubmit,
|
||||
});
|
||||
|
||||
if (inputHandlers['keypress']) {
|
||||
// Navigate up from index 0 should wrap to last
|
||||
inputHandlers['keypress']('', { name: 'up' });
|
||||
const selectCalls = mockList.select.mock.calls;
|
||||
if (selectCalls.length > 0) {
|
||||
expect(selectCalls[selectCalls.length - 1][0]).toBe(12); // last of 13 items
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Public API', () => {
|
||||
it('should add custom suggestions', () => {
|
||||
palette.addSuggestion({ label: 'Custom', category: 'Test', action: 'custom' });
|
||||
palette.show();
|
||||
|
||||
const setItemsCalls = mockList.setItems.mock.calls;
|
||||
const lastCall = setItemsCalls[setItemsCalls.length - 1];
|
||||
expect(lastCall[0].length).toBe(14); // 13 defaults + 1 custom
|
||||
});
|
||||
|
||||
it('should clear custom suggestions', () => {
|
||||
palette.addSuggestion({ label: 'Custom', category: 'Test', action: 'custom' });
|
||||
palette.clearSuggestions();
|
||||
palette.show();
|
||||
|
||||
const setItemsCalls = mockList.setItems.mock.calls;
|
||||
const lastCall = setItemsCalls[setItemsCalls.length - 1];
|
||||
expect(lastCall[0].length).toBe(13); // Back to defaults
|
||||
});
|
||||
|
||||
it('should set suggestions', () => {
|
||||
palette.setSuggestions([
|
||||
{ label: 'Extra1', category: 'Test', action: 'e1' },
|
||||
{ label: 'Extra2', category: 'Test', action: 'e2' },
|
||||
]);
|
||||
palette.show();
|
||||
|
||||
const setItemsCalls = mockList.setItems.mock.calls;
|
||||
const lastCall = setItemsCalls[setItemsCalls.length - 1];
|
||||
expect(lastCall[0].length).toBe(15); // 13 defaults + 2 extra
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -5,7 +5,11 @@
|
|||
*/
|
||||
|
||||
import blessed from 'blessed';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { colors } from '../utils/colors.js';
|
||||
import { fuzzyMatch, highlightMatches } from '../utils/fuzzyMatch.js';
|
||||
|
||||
export interface CommandPaletteOptions {
|
||||
/** Parent screen */
|
||||
|
|
@ -29,6 +33,15 @@ export interface CommandSuggestion {
|
|||
action: string;
|
||||
}
|
||||
|
||||
interface ScoredSuggestion {
|
||||
suggestion: CommandSuggestion;
|
||||
score: number;
|
||||
labelIndices: number[];
|
||||
}
|
||||
|
||||
const MAX_RECENT_COMMANDS = 10;
|
||||
const RECENT_COMMANDS_FILE = join(homedir(), '.fabric', 'recent-commands.json');
|
||||
|
||||
/**
|
||||
* Default command suggestions
|
||||
*/
|
||||
|
|
@ -58,14 +71,16 @@ export class CommandPalette {
|
|||
private onSubmit?: (command: string) => void;
|
||||
private onSearch?: (query: string) => void;
|
||||
private suggestions: CommandSuggestion[];
|
||||
private filteredSuggestions: CommandSuggestion[];
|
||||
private scoredSuggestions: ScoredSuggestion[];
|
||||
private selectedIndex = 0;
|
||||
private recentCommands: string[];
|
||||
|
||||
constructor(options: CommandPaletteOptions) {
|
||||
this.onSubmit = options.onSubmit;
|
||||
this.onSearch = options.onSearch;
|
||||
this.suggestions = [...DEFAULT_SUGGESTIONS];
|
||||
this.filteredSuggestions = [...this.suggestions];
|
||||
this.scoredSuggestions = this.suggestions.map(s => ({ suggestion: s, score: 0, labelIndices: [] }));
|
||||
this.recentCommands = CommandPalette.loadRecentCommands();
|
||||
|
||||
// Container box
|
||||
this.box = blessed.box({
|
||||
|
|
@ -166,21 +181,84 @@ export class CommandPalette {
|
|||
}
|
||||
|
||||
private filterSuggestions(query: string): void {
|
||||
const q = query.toLowerCase();
|
||||
this.filteredSuggestions = this.suggestions.filter(s =>
|
||||
s.label.toLowerCase().includes(q) ||
|
||||
s.category.toLowerCase().includes(q) ||
|
||||
s.action.toLowerCase().includes(q)
|
||||
);
|
||||
if (!query) {
|
||||
// No query: show recent commands first, then all suggestions
|
||||
const recentSet = new Set(this.recentCommands);
|
||||
const recentSuggestions: ScoredSuggestion[] = [];
|
||||
const otherSuggestions: ScoredSuggestion[] = [];
|
||||
|
||||
for (const s of this.suggestions) {
|
||||
const entry: ScoredSuggestion = { suggestion: s, score: 0, labelIndices: [] };
|
||||
if (recentSet.has(s.action)) {
|
||||
// Order by recency (index in recentCommands)
|
||||
recentSuggestions.push(entry);
|
||||
} else {
|
||||
otherSuggestions.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort recent by recency order
|
||||
recentSuggestions.sort((a, b) =>
|
||||
this.recentCommands.indexOf(a.suggestion.action) -
|
||||
this.recentCommands.indexOf(b.suggestion.action)
|
||||
);
|
||||
|
||||
this.scoredSuggestions = [...recentSuggestions, ...otherSuggestions];
|
||||
} else {
|
||||
// Fuzzy match each suggestion across label, category, and action
|
||||
const scored: ScoredSuggestion[] = [];
|
||||
|
||||
for (const s of this.suggestions) {
|
||||
const labelMatch = fuzzyMatch(s.label, query);
|
||||
const catMatch = fuzzyMatch(s.category, query);
|
||||
const actionMatch = fuzzyMatch(s.action, query);
|
||||
|
||||
// Pick the best match across all fields
|
||||
let bestScore = -Infinity;
|
||||
let labelIndices: number[] = [];
|
||||
|
||||
if (labelMatch) {
|
||||
bestScore = labelMatch.score;
|
||||
labelIndices = labelMatch.matchIndices;
|
||||
}
|
||||
if (catMatch && catMatch.score > bestScore) {
|
||||
bestScore = catMatch.score;
|
||||
labelIndices = []; // Matched on category, no label highlights
|
||||
}
|
||||
if (actionMatch && actionMatch.score > bestScore) {
|
||||
bestScore = actionMatch.score;
|
||||
labelIndices = []; // Matched on action, no label highlights
|
||||
}
|
||||
|
||||
if (bestScore > -Infinity) {
|
||||
// Boost recently-used commands
|
||||
const recentIdx = this.recentCommands.indexOf(s.action);
|
||||
if (recentIdx >= 0) {
|
||||
bestScore += (MAX_RECENT_COMMANDS - recentIdx);
|
||||
}
|
||||
scored.push({ suggestion: s, score: bestScore, labelIndices });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
this.scoredSuggestions = scored;
|
||||
}
|
||||
|
||||
this.selectedIndex = 0;
|
||||
this.renderSuggestions();
|
||||
}
|
||||
|
||||
private renderSuggestions(): void {
|
||||
const items = this.filteredSuggestions.map((s, i) => {
|
||||
const items = this.scoredSuggestions.map((entry, i) => {
|
||||
const s = entry.suggestion;
|
||||
const label = entry.labelIndices.length > 0
|
||||
? highlightMatches(s.label, entry.labelIndices, '{yellow-fg}', '{/}')
|
||||
: s.label;
|
||||
const prefix = `${s.category}: `;
|
||||
const selected = i === this.selectedIndex ? '{green-fg}' : '';
|
||||
const end = i === this.selectedIndex ? '{/}' : '';
|
||||
return `${selected}${s.category}: ${s.label}${end}`;
|
||||
return `${selected}${prefix}${label}${end}`;
|
||||
});
|
||||
|
||||
this.suggestionBox.setItems(items);
|
||||
|
|
@ -189,36 +267,67 @@ export class CommandPalette {
|
|||
}
|
||||
|
||||
private selectNext(): void {
|
||||
if (this.filteredSuggestions.length === 0) return;
|
||||
this.selectedIndex = (this.selectedIndex + 1) % this.filteredSuggestions.length;
|
||||
if (this.scoredSuggestions.length === 0) return;
|
||||
this.selectedIndex = (this.selectedIndex + 1) % this.scoredSuggestions.length;
|
||||
this.renderSuggestions();
|
||||
}
|
||||
|
||||
private selectPrevious(): void {
|
||||
if (this.filteredSuggestions.length === 0) return;
|
||||
if (this.scoredSuggestions.length === 0) return;
|
||||
this.selectedIndex = this.selectedIndex === 0
|
||||
? this.filteredSuggestions.length - 1
|
||||
? this.scoredSuggestions.length - 1
|
||||
: this.selectedIndex - 1;
|
||||
this.renderSuggestions();
|
||||
}
|
||||
|
||||
private executeSelected(): void {
|
||||
const selected = this.filteredSuggestions[this.selectedIndex];
|
||||
if (selected && this.onSubmit) {
|
||||
this.onSubmit(selected.action);
|
||||
const entry = this.scoredSuggestions[this.selectedIndex];
|
||||
if (entry && this.onSubmit) {
|
||||
this.addRecentCommand(entry.suggestion.action);
|
||||
this.onSubmit(entry.suggestion.action);
|
||||
}
|
||||
this.hide();
|
||||
}
|
||||
|
||||
private addRecentCommand(action: string): void {
|
||||
this.recentCommands = [action, ...this.recentCommands.filter(c => c !== action)]
|
||||
.slice(0, MAX_RECENT_COMMANDS);
|
||||
CommandPalette.saveRecentCommands(this.recentCommands);
|
||||
}
|
||||
|
||||
private static loadRecentCommands(): string[] {
|
||||
try {
|
||||
if (existsSync(RECENT_COMMANDS_FILE)) {
|
||||
const data = readFileSync(RECENT_COMMANDS_FILE, 'utf-8');
|
||||
const parsed = JSON.parse(data);
|
||||
if (Array.isArray(parsed)) return parsed.slice(0, MAX_RECENT_COMMANDS);
|
||||
}
|
||||
} catch {
|
||||
// Ignore read errors
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private static saveRecentCommands(commands: string[]): void {
|
||||
try {
|
||||
const dir = join(homedir(), '.fabric');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(RECENT_COMMANDS_FILE, JSON.stringify(commands));
|
||||
} catch {
|
||||
// Ignore write errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the command palette
|
||||
*/
|
||||
show(): void {
|
||||
this.box.show();
|
||||
this.input.setValue('');
|
||||
this.filteredSuggestions = [...this.suggestions];
|
||||
this.filterSuggestions('');
|
||||
this.selectedIndex = 0;
|
||||
this.renderSuggestions();
|
||||
this.input.focus();
|
||||
this.box.screen.render();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue