Skip to content
Merged
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
29 changes: 23 additions & 6 deletions src/tui/components/ItemsEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { generateGuid } from '../../utils/guid';
import { canDetectTerminalWidth } from '../../utils/terminal';
import {
filterWidgetCatalog,
getMatchSegments,
getWidget,
getWidgetCatalog,
getWidgetCatalogCategories
Expand Down Expand Up @@ -420,16 +421,24 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
<>
{topLevelSearchEntries.map((entry, index) => {
const isSelected = entry.type === selectedTopLevelSearchEntry?.type;
const segments = getMatchSegments(entry.displayName, widgetPicker.categoryQuery);
return (
<Box key={entry.type} flexDirection='row' flexWrap='nowrap'>
<Box width={3}>
<Text color={isSelected ? 'green' : undefined}>
{isSelected ? '▶ ' : ' '}
</Text>
</Box>
<Text color={isSelected ? 'green' : undefined}>
{`${index + 1}. ${entry.displayName}`}
</Text>
<Text color={isSelected ? 'green' : undefined}>{`${index + 1}. `}</Text>
{segments.map((seg, i) => (
<Text
key={i}
color={isSelected ? 'green' : seg.matched ? 'yellowBright' : undefined}
bold={isSelected ? true : seg.matched}
>
{seg.text}
</Text>
))}
</Box>
);
})}
Expand Down Expand Up @@ -475,16 +484,24 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
<>
{pickerEntries.map((entry, index) => {
const isSelected = entry.type === selectedPickerEntry?.type;
const segments = getMatchSegments(entry.displayName, widgetPicker.widgetQuery);
return (
<Box key={entry.type} flexDirection='row' flexWrap='nowrap'>
<Box width={3}>
<Text color={isSelected ? 'green' : undefined}>
{isSelected ? '▶ ' : ' '}
</Text>
</Box>
<Text color={isSelected ? 'green' : undefined}>
{`${index + 1}. ${entry.displayName}`}
</Text>
<Text color={isSelected ? 'green' : undefined}>{`${index + 1}. `}</Text>
{segments.map((seg, i) => (
<Text
key={i}
color={isSelected ? 'green' : (seg.matched ? 'yellowBright' : undefined)}
bold={seg.matched}
>
{seg.text}
</Text>
))}
</Box>
);
})}
Expand Down
56 changes: 56 additions & 0 deletions src/tui/components/items-editor/__tests__/input-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WidgetPickerState | null>({
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<WidgetPickerState | null>({
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' }
Expand Down
12 changes: 8 additions & 4 deletions src/tui/components/items-editor/input-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -229,7 +230,8 @@ export function handlePickerInputMode({
) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
categoryQuery: prev.categoryQuery + input
categoryQuery: prev.categoryQuery + input,
selectedType: null
}));
}
} else {
Expand Down Expand Up @@ -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
Expand All @@ -280,7 +283,8 @@ export function handlePickerInputMode({
) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
widgetQuery: prev.widgetQuery + input
widgetQuery: prev.widgetQuery + input,
selectedType: null
}));
}
}
Expand Down
77 changes: 77 additions & 0 deletions src/utils/__tests__/widgets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { WidgetItemType } from '../../types/Widget';
import {
filterWidgetCatalog,
getAllWidgetTypes,
getMatchSegments,
getWidget,
getWidgetCatalog,
getWidgetCatalogCategories,
Expand Down Expand Up @@ -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[] = [
{
Expand Down Expand Up @@ -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 });
});
});
Loading