diff --git a/src/tui/components/CommandPalette.test.ts b/src/tui/components/CommandPalette.test.ts new file mode 100644 index 0000000..8139a31 --- /dev/null +++ b/src/tui/components/CommandPalette.test.ts @@ -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; + 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 = {}; + 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 = {}; + 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 = {}; + 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 = {}; + 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 = {}; + 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 = {}; + 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 + }); + }); +}); diff --git a/src/tui/components/CommandPalette.ts b/src/tui/components/CommandPalette.ts index 44b6cae..1550c31 100644 --- a/src/tui/components/CommandPalette.ts +++ b/src/tui/components/CommandPalette.ts @@ -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(); }