diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 5a564b13..e89b8d3b 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -17,6 +17,7 @@ import { generateGuid } from '../../utils/guid'; import { canDetectTerminalWidth } from '../../utils/terminal'; import { filterWidgetCatalog, + getMatchSegments, getWidget, getWidgetCatalog, getWidgetCatalogCategories @@ -420,6 +421,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB <> {topLevelSearchEntries.map((entry, index) => { const isSelected = entry.type === selectedTopLevelSearchEntry?.type; + const segments = getMatchSegments(entry.displayName, widgetPicker.categoryQuery); return ( @@ -427,9 +429,16 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {isSelected ? '▶ ' : ' '} - - {`${index + 1}. ${entry.displayName}`} - + {`${index + 1}. `} + {segments.map((seg, i) => ( + + {seg.text} + + ))} ); })} @@ -475,6 +484,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB <> {pickerEntries.map((entry, index) => { const isSelected = entry.type === selectedPickerEntry?.type; + const segments = getMatchSegments(entry.displayName, widgetPicker.widgetQuery); return ( @@ -482,9 +492,16 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {isSelected ? '▶ ' : ' '} - - {`${index + 1}. ${entry.displayName}`} - + {`${index + 1}. `} + {segments.map((seg, i) => ( + + {seg.text} + + ))} ); })} diff --git a/src/tui/components/items-editor/__tests__/input-handlers.test.ts b/src/tui/components/items-editor/__tests__/input-handlers.test.ts index 160d84ca..27c92790 100644 --- a/src/tui/components/items-editor/__tests__/input-handlers.test.ts +++ b/src/tui/components/items-editor/__tests__/input-handlers.test.ts @@ -95,6 +95,62 @@ describe('items-editor input handlers', () => { expect(applySelection).toHaveBeenCalledWith('git-branch'); }); + it('resets selection to best match when typing in category search', () => { + const widgetCatalog = createCatalog([ + { type: 'vim-mode', displayName: 'Vim Mode', category: 'Core' }, + { type: 'git-branch', displayName: 'Git Branch', category: 'Core' } + ]); + const widgetCategories = ['All', 'Core']; + const pickerState = createStateSetter({ + action: 'change', + level: 'category', + selectedCategory: 'All', + categoryQuery: '', + widgetQuery: '', + selectedType: 'git-branch' + }); + + handlePickerInputMode({ + input: 'v', + key: {}, + widgetPicker: requireState(pickerState.get()), + widgetCatalog, + widgetCategories, + setWidgetPicker: pickerState.set, + applyWidgetPickerSelection: vi.fn() + }); + + expect(pickerState.get()?.selectedType).toBe('vim-mode'); + }); + + it('resets selection to best match when typing in widget search', () => { + const widgetCatalog = createCatalog([ + { type: 'vim-mode', displayName: 'Vim Mode', category: 'Core' }, + { type: 'git-branch', displayName: 'Git Branch', category: 'Core' } + ]); + const widgetCategories = ['All', 'Core']; + const pickerState = createStateSetter({ + action: 'change', + level: 'widget', + selectedCategory: 'Core', + categoryQuery: '', + widgetQuery: '', + selectedType: 'git-branch' + }); + + handlePickerInputMode({ + input: 'v', + key: {}, + widgetPicker: requireState(pickerState.get()), + widgetCatalog, + widgetCategories, + setWidgetPicker: pickerState.set, + applyWidgetPickerSelection: vi.fn() + }); + + expect(pickerState.get()?.selectedType).toBe('vim-mode'); + }); + it('returns to category level from widget picker on escape when widget query is empty', () => { const widgetCatalog = createCatalog([ { type: 'git-branch', displayName: 'Git Branch', category: 'Git' } diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index 7dafe493..46485acd 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -219,7 +219,8 @@ export function handlePickerInputMode({ } else if (key.backspace || key.delete) { setPickerState(setWidgetPicker, normalizeState, prev => ({ ...prev, - categoryQuery: prev.categoryQuery.slice(0, -1) + categoryQuery: prev.categoryQuery.slice(0, -1), + selectedType: null })); } else if ( input @@ -229,7 +230,8 @@ export function handlePickerInputMode({ ) { setPickerState(setWidgetPicker, normalizeState, prev => ({ ...prev, - categoryQuery: prev.categoryQuery + input + categoryQuery: prev.categoryQuery + input, + selectedType: null })); } } else { @@ -270,7 +272,8 @@ export function handlePickerInputMode({ } else if (key.backspace || key.delete) { setPickerState(setWidgetPicker, normalizeState, prev => ({ ...prev, - widgetQuery: prev.widgetQuery.slice(0, -1) + widgetQuery: prev.widgetQuery.slice(0, -1), + selectedType: null })); } else if ( input @@ -280,7 +283,8 @@ export function handlePickerInputMode({ ) { setPickerState(setWidgetPicker, normalizeState, prev => ({ ...prev, - widgetQuery: prev.widgetQuery + input + widgetQuery: prev.widgetQuery + input, + selectedType: null })); } } diff --git a/src/utils/__tests__/widgets.test.ts b/src/utils/__tests__/widgets.test.ts index 3be756b2..b34a7b1a 100644 --- a/src/utils/__tests__/widgets.test.ts +++ b/src/utils/__tests__/widgets.test.ts @@ -12,6 +12,7 @@ import type { WidgetItemType } from '../../types/Widget'; import { filterWidgetCatalog, getAllWidgetTypes, + getMatchSegments, getWidget, getWidgetCatalog, getWidgetCatalogCategories, @@ -147,6 +148,35 @@ describe('widget catalog filtering', () => { expect(results).toHaveLength(0); }); + it('fuzzy-matches initials across word boundaries (gb → Git Branch)', () => { + const results = filterWidgetCatalog(catalog, 'All', 'gb'); + expect(results[0]?.type).toBe('git-branch'); + }); + + it('prioritizes display-name fuzzy matches over description substring hits', () => { + const results = filterWidgetCatalog(catalog, 'All', 'tw'); + expect(results[0]?.type).toBe('terminal-width'); + }); + + it('prioritizes word-initial fuzzy matches over incidental subsequence matches', () => { + expect(filterWidgetCatalog(catalog, 'All', 'tc')[0]?.type).toBe('tokens-cached'); + expect(filterWidgetCatalog(catalog, 'All', 'ti')[0]?.type).toBe('tokens-input'); + expect(filterWidgetCatalog(catalog, 'All', 'to')[0]?.type).toBe('tokens-output'); + }); + + it('ranks exact substring matches above fuzzy matches', () => { + const exactResults = filterWidgetCatalog(catalog, 'All', 'git'); + const fuzzyResults = filterWidgetCatalog(catalog, 'All', 'gb'); + const exactIndex = exactResults.findIndex(e => e.type === 'git-branch'); + const fuzzyIndex = fuzzyResults.findIndex(e => e.type === 'git-branch'); + expect(exactIndex).toBeLessThanOrEqual(fuzzyIndex); + }); + + it('returns no results when query chars cannot form a subsequence in any entry', () => { + const results = filterWidgetCatalog(catalog, 'All', 'zzz'); + expect(results).toHaveLength(0); + }); + it('prioritizes name match before type and description matches', () => { const rankingCatalog: WidgetCatalogEntry[] = [ { @@ -175,4 +205,51 @@ describe('widget catalog filtering', () => { const results = filterWidgetCatalog(rankingCatalog, 'All', 'git'); expect(results.map(entry => entry.type)).toEqual(['alpha', 'git-type-only', 'desc-only']); }); +}); + +describe('getMatchSegments', () => { + it('returns single unmatched segment when query is empty', () => { + expect(getMatchSegments('Git Branch', '')).toEqual([{ text: 'Git Branch', matched: false }]); + }); + + it('highlights exact substring match', () => { + const segments = getMatchSegments('Git Branch', 'git'); + expect(segments).toEqual([ + { text: 'Git', matched: true }, + { text: ' Branch', matched: false } + ]); + }); + + it('highlights exact substring in the middle', () => { + const segments = getMatchSegments('Git Branch', 'it B'); + expect(segments).toEqual([ + { text: 'G', matched: false }, + { text: 'it B', matched: true }, + { text: 'ranch', matched: false } + ]); + }); + + it('highlights fuzzy match positions when no substring match exists', () => { + const segments = getMatchSegments('Git Branch', 'gb'); + const matched = segments.filter(s => s.matched).map(s => s.text).join(''); + expect(matched.toLowerCase()).toBe('gb'); + }); + + it('prefers word-initial fuzzy positions over incidental interior-letter matches', () => { + expect(getMatchSegments('Tokens Output', 'to')).toEqual([ + { text: 'T', matched: true }, + { text: 'okens ', matched: false }, + { text: 'O', matched: true }, + { text: 'utput', matched: false } + ]); + }); + + it('returns unmatched segment when query chars cannot form a subsequence', () => { + expect(getMatchSegments('Git Branch', 'zzz')).toEqual([{ text: 'Git Branch', matched: false }]); + }); + + it('is case-insensitive but preserves original casing in output', () => { + const segments = getMatchSegments('Git Branch', 'GIT'); + expect(segments[0]).toEqual({ text: 'Git', matched: true }); + }); }); \ No newline at end of file diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts index 1631cca1..9fe26f80 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -90,7 +90,126 @@ export function getWidgetCatalogCategories(catalog: WidgetCatalogEntry[]): strin return Array.from(categories); } +function isWordStart(text: string, position: number): boolean { + return position === 0 + || text[position - 1] === ' ' + || text[position - 1] === '-' + || text[position - 1] === '_'; +} + +function findSubsequencePositions(text: string, query: string): number[] | null { + let qi = 0; + const positions: number[] = []; + + for (let ti = 0; ti < text.length && qi < query.length; ti++) { + if (text[ti] === query[qi]) { + positions.push(ti); + qi++; + } + } + + return qi < query.length ? null : positions; +} + +function findInitialismMatch(text: string, query: string): { positions: number[]; score: number } | null { + const wordStartPositions: number[] = []; + + for (let i = 0; i < text.length; i++) { + if (isWordStart(text, i)) { + wordStartPositions.push(i); + } + } + + const initials = wordStartPositions.map(position => text[position]).join(''); + const matchedInitials = findSubsequencePositions(initials, query); + + if (matchedInitials === null) { + return null; + } + + const first = matchedInitials[0]; + const last = matchedInitials[matchedInitials.length - 1]; + + if (first === undefined || last === undefined) { + return null; + } + + const positions: number[] = []; + + for (const initialIndex of matchedInitials) { + const position = wordStartPositions[initialIndex]; + if (position === undefined) { + return null; + } + positions.push(position); + } + + return { + positions, + score: (last - first) + first + }; +} + +function computeFuzzyScore(text: string, query: string): number | null { + const CONSECUTIVE_BONUS_RATE = 5; + const WORD_START_BONUS_RATE = 20; + const FULL_INITIALISM_BONUS = 40; + const positions = findSubsequencePositions(text, query); + + if (positions === null) { + return null; + } + + const first = positions[0]; + const last = positions[positions.length - 1]; + + if (first === undefined || last === undefined) { + return null; + } + + const span = last - first; + let consecutiveBonus = 0; + let wordStartMatches = 0; + + let previousPosition: number | null = null; + + positions.forEach((position) => { + if (previousPosition !== null && position === previousPosition + 1) { + consecutiveBonus += CONSECUTIVE_BONUS_RATE; + } + if (isWordStart(text, position)) { + wordStartMatches++; + } + previousPosition = position; + }); + + const fullInitialismBonus = wordStartMatches === query.length + ? FULL_INITIALISM_BONUS + : 0; + + return span + + first + - consecutiveBonus + - (wordStartMatches * WORD_START_BONUS_RATE) + - fullInitialismBonus; +} + export function filterWidgetCatalog(catalog: WidgetCatalogEntry[], category: string, query: string): WidgetCatalogEntry[] { + const MATCH_PRIORITY = { + NAME_PREFIX_WITH_INITIALISM: 0, + NAME_PREFIX: 1, + NAME_INITIALISM: 2, + NAME_SUBSTRING: 3, + TYPE_SUBSTRING: 4, + NAME_FUZZY: 5, + DESCRIPTION_SUBSTRING: 6, + SEARCH_SUBSTRING: 7, + TYPE_FUZZY: 8, + SEARCH_FUZZY: 9 + }; + + const MATCH_TIER_SIZE = 1000; + const normalizedQuery = query.trim().toLowerCase(); const categoryFiltered = category === 'All' @@ -109,21 +228,41 @@ export function filterWidgetCatalog(catalog: WidgetCatalogEntry[], category: str const name = entry.displayName.toLowerCase(); const description = entry.description.toLowerCase(); const type = entry.type.toLowerCase(); + const nameInitialism = findInitialismMatch(name, normalizedQuery); + if (name.startsWith(normalizedQuery) && nameInitialism !== null) { + return { entry, score: MATCH_PRIORITY.NAME_PREFIX_WITH_INITIALISM * MATCH_TIER_SIZE + nameInitialism.score }; + } if (name.startsWith(normalizedQuery)) { - return { entry, score: 0 }; + return { entry, score: MATCH_PRIORITY.NAME_PREFIX * MATCH_TIER_SIZE }; + } + if (nameInitialism !== null) { + return { entry, score: MATCH_PRIORITY.NAME_INITIALISM * MATCH_TIER_SIZE + nameInitialism.score }; } if (name.includes(normalizedQuery)) { - return { entry, score: 1 }; + return { entry, score: MATCH_PRIORITY.NAME_SUBSTRING * MATCH_TIER_SIZE }; } if (type.includes(normalizedQuery)) { - return { entry, score: 2 }; + return { entry, score: MATCH_PRIORITY.TYPE_SUBSTRING * MATCH_TIER_SIZE }; + } + + const nameFuzzy = computeFuzzyScore(name, normalizedQuery); + if (nameFuzzy !== null) { + return { entry, score: MATCH_PRIORITY.NAME_FUZZY * MATCH_TIER_SIZE + nameFuzzy }; } if (description.includes(normalizedQuery)) { - return { entry, score: 3 }; + return { entry, score: MATCH_PRIORITY.DESCRIPTION_SUBSTRING * MATCH_TIER_SIZE }; } if (entry.searchText.includes(normalizedQuery)) { - return { entry, score: 4 }; + return { entry, score: MATCH_PRIORITY.SEARCH_SUBSTRING * MATCH_TIER_SIZE }; + } + const typeFuzzy = computeFuzzyScore(type, normalizedQuery); + if (typeFuzzy !== null) { + return { entry, score: MATCH_PRIORITY.TYPE_FUZZY * MATCH_TIER_SIZE + typeFuzzy }; + } + const searchFuzzy = computeFuzzyScore(entry.searchText, normalizedQuery); + if (searchFuzzy !== null) { + return { entry, score: MATCH_PRIORITY.SEARCH_FUZZY * MATCH_TIER_SIZE + searchFuzzy }; } return null; @@ -146,6 +285,81 @@ export function filterWidgetCatalog(catalog: WidgetCatalogEntry[], category: str .map(item => item.entry); } +export function getMatchSegments(text: string, query: string): { text: string; matched: boolean }[] { + if (!query.trim()) { + return [{ text, matched: false }]; + } + + const normalizedQuery = query.trim().toLowerCase(); + const normalizedText = text.toLowerCase(); + + const initialismPositions = findInitialismMatch(normalizedText, normalizedQuery)?.positions; + if (initialismPositions !== undefined) { + const posSet = new Set(initialismPositions); + const segments: { text: string; matched: boolean }[] = []; + let current = ''; + let currentMatched = posSet.has(0); + + for (let i = 0; i < text.length; i++) { + const isMatched = posSet.has(i); + if (isMatched !== currentMatched && current) { + segments.push({ text: current, matched: currentMatched }); + current = text[i] ?? ''; + currentMatched = isMatched; + } else { + current += text[i] ?? ''; + } + } + + if (current) { + segments.push({ text: current, matched: currentMatched }); + } + + return segments; + } + + const substringIdx = normalizedText.indexOf(normalizedQuery); + if (substringIdx !== -1) { + const end = substringIdx + normalizedQuery.length; + const segments: { text: string; matched: boolean }[] = []; + if (substringIdx > 0) { + segments.push({ text: text.slice(0, substringIdx), matched: false }); + } + segments.push({ text: text.slice(substringIdx, end), matched: true }); + if (end < text.length) { + segments.push({ text: text.slice(end), matched: false }); + } + return segments; + } + + const positions = findSubsequencePositions(normalizedText, normalizedQuery); + if (positions === null) { + return [{ text, matched: false }]; + } + + const posSet = new Set(positions); + const segments: { text: string; matched: boolean }[] = []; + let current = ''; + let currentMatched = posSet.has(0); + + for (let i = 0; i < text.length; i++) { + const isMatched = posSet.has(i); + if (isMatched !== currentMatched && current) { + segments.push({ text: current, matched: currentMatched }); + current = text[i] ?? ''; + currentMatched = isMatched; + } else { + current += text[i] ?? ''; + } + } + + if (current) { + segments.push({ text: current, matched: currentMatched }); + } + + return segments; +} + export function isKnownWidgetType(type: string): boolean { return widgetRegistry.has(type) || layoutWidgetTypes.has(type);