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:
default 2026-03-08 22:53:03 +00:00
parent 629d7430dc
commit d6b9976a88
2 changed files with 449 additions and 19 deletions

View 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
});
});
});

View file

@ -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();
}