diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 930384e..419e67f 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -15,10 +15,14 @@ import { getBackgroundColorsForPowerline } from '../../utils/colors'; import { generateGuid } from '../../utils/guid'; import { canDetectTerminalWidth } from '../../utils/terminal'; import { - getAllWidgetTypes, - getWidget + filterWidgetCatalog, + getWidget, + getWidgetCatalog, + getWidgetCatalogCategories } from '../../utils/widgets'; +import { ConfirmDialog } from './ConfirmDialog'; + export interface ItemsEditorProps { widgets: WidgetItem[]; onUpdate: (widgets: WidgetItem[]) => void; @@ -27,34 +31,28 @@ export interface ItemsEditorProps { settings: Settings; } +type WidgetPickerAction = 'change' | 'add' | 'insert'; +type WidgetPickerLevel = 'category' | 'widget'; + +interface WidgetPickerState { + action: WidgetPickerAction; + level: WidgetPickerLevel; + selectedCategory: string | null; + categoryQuery: string; + widgetQuery: string; + selectedType: WidgetItemType | null; +} + export const ItemsEditor: React.FC = ({ widgets, onUpdate, onBack, lineNumber, settings }) => { const [selectedIndex, setSelectedIndex] = useState(0); const [moveMode, setMoveMode] = useState(false); const [customEditorWidget, setCustomEditorWidget] = useState<{ widget: WidgetItem; impl: Widget; action?: string } | null>(null); + const [widgetPicker, setWidgetPicker] = useState(null); + const [showClearConfirm, setShowClearConfirm] = useState(false); const separatorChars = ['|', '-', ',', ' ']; - // Determine which item types are allowed based on settings - const getAllowedTypes = (): WidgetItemType[] => { - let allowedTypes = getAllWidgetTypes(settings); - - // Remove separator if default separator is set - if (settings.defaultSeparator) { - allowedTypes = allowedTypes.filter(t => t !== 'separator'); - } - - // Remove both separator and flex-separator if powerline mode is enabled - if (settings.powerline.enabled) { - allowedTypes = allowedTypes.filter(t => t !== 'separator' && t !== 'flex-separator'); - } - - return allowedTypes; - }; - - // Get the default type for new widgets (first non-separator type) - const getDefaultItemType = (): WidgetItemType => { - const allowedTypes = getAllowedTypes(); - return allowedTypes.includes('model') ? 'model' : (allowedTypes[0] ?? 'model'); - }; + const widgetCatalog = getWidgetCatalog(settings); + const widgetCategories = ['All', ...getWidgetCatalogCategories(widgetCatalog)]; // Get a unique background color for powerline mode const getUniqueBackgroundColor = (insertIndex: number): string | undefined => { @@ -98,12 +96,237 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB setCustomEditorWidget(null); }; + const getFilteredCategories = (query: string): string[] => { + void query; + return [...widgetCategories]; + }; + + const normalizePickerState = (state: WidgetPickerState): WidgetPickerState => { + const filteredCategories = getFilteredCategories(state.categoryQuery); + const selectedCategory = state.selectedCategory && filteredCategories.includes(state.selectedCategory) + ? state.selectedCategory + : (filteredCategories[0] ?? null); + + const hasTopLevelSearch = state.level === 'category' && state.categoryQuery.trim().length > 0; + const effectiveCategory = hasTopLevelSearch ? 'All' : (selectedCategory ?? 'All'); + const effectiveQuery = hasTopLevelSearch ? state.categoryQuery : state.widgetQuery; + const filteredWidgets = filterWidgetCatalog(widgetCatalog, effectiveCategory, effectiveQuery); + const hasSelectedType = state.selectedType + ? filteredWidgets.some(entry => entry.type === state.selectedType) + : false; + + return { + ...state, + selectedCategory, + selectedType: hasSelectedType ? state.selectedType : (filteredWidgets[0]?.type ?? null) + }; + }; + + const openWidgetPicker = (action: WidgetPickerAction) => { + if (widgetCatalog.length === 0) { + return; + } + + const currentType = widgets[selectedIndex]?.type; + const selectedType = action === 'change' ? currentType ?? null : null; + + setWidgetPicker(normalizePickerState({ + action, + level: 'category', + selectedCategory: 'All', + categoryQuery: '', + widgetQuery: '', + selectedType + })); + }; + + const applyWidgetPickerSelection = (selectedType: WidgetItemType) => { + if (!widgetPicker) { + return; + } + + if (widgetPicker.action === 'change') { + const currentWidget = widgets[selectedIndex]; + if (currentWidget) { + const newWidgets = [...widgets]; + newWidgets[selectedIndex] = { ...currentWidget, type: selectedType }; + onUpdate(newWidgets); + } + } else { + const insertIndex = widgetPicker.action === 'add' + ? (widgets.length > 0 ? selectedIndex + 1 : 0) + : selectedIndex; + const backgroundColor = getUniqueBackgroundColor(insertIndex); + const newWidget: WidgetItem = { + id: generateGuid(), + type: selectedType, + ...(backgroundColor && { backgroundColor }) + }; + const newWidgets = [...widgets]; + newWidgets.splice(insertIndex, 0, newWidget); + onUpdate(newWidgets); + setSelectedIndex(insertIndex); + } + + setWidgetPicker(null); + }; + useInput((input, key) => { // Skip input if custom editor is active if (customEditorWidget) { return; } + // Skip input handling when clear confirmation is active - let ConfirmDialog handle it + if (showClearConfirm) { + return; + } + + if (widgetPicker) { + const filteredCategories = getFilteredCategories(widgetPicker.categoryQuery); + const selectedCategory = widgetPicker.selectedCategory && filteredCategories.includes(widgetPicker.selectedCategory) + ? widgetPicker.selectedCategory + : (filteredCategories[0] ?? null); + const hasTopLevelSearch = widgetPicker.level === 'category' && widgetPicker.categoryQuery.trim().length > 0; + const topLevelSearchEntries = hasTopLevelSearch + ? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery) + : []; + const topLevelSelectedEntry = topLevelSearchEntries.find(entry => entry.type === widgetPicker.selectedType) ?? topLevelSearchEntries[0]; + const filteredWidgets = filterWidgetCatalog(widgetCatalog, selectedCategory ?? 'All', widgetPicker.widgetQuery); + const selectedEntry = filteredWidgets.find(entry => entry.type === widgetPicker.selectedType) ?? filteredWidgets[0]; + + if (widgetPicker.level === 'category') { + if (key.escape) { + if (widgetPicker.categoryQuery.length > 0) { + setWidgetPicker(prev => prev ? normalizePickerState({ + ...prev, + categoryQuery: '' + }) : prev); + } else { + setWidgetPicker(null); + } + } else if (key.return) { + if (hasTopLevelSearch) { + if (topLevelSelectedEntry) { + applyWidgetPickerSelection(topLevelSelectedEntry.type); + } + } else if (selectedCategory) { + setWidgetPicker(prev => prev ? normalizePickerState({ + ...prev, + level: 'widget', + selectedCategory + }) : prev); + } + } else if (key.upArrow || key.downArrow) { + if (hasTopLevelSearch) { + if (topLevelSearchEntries.length === 0) { + return; + } + + let currentIndex = topLevelSearchEntries.findIndex(entry => entry.type === widgetPicker.selectedType); + if (currentIndex === -1) { + currentIndex = 0; + } + + const nextIndex = key.downArrow + ? Math.min(topLevelSearchEntries.length - 1, currentIndex + 1) + : Math.max(0, currentIndex - 1); + const nextType = topLevelSearchEntries[nextIndex]?.type ?? null; + setWidgetPicker(prev => prev ? normalizePickerState({ + ...prev, + selectedType: nextType + }) : prev); + } else { + if (filteredCategories.length === 0) { + return; + } + + let currentIndex = filteredCategories.findIndex(category => category === selectedCategory); + if (currentIndex === -1) { + currentIndex = 0; + } + + const nextIndex = key.downArrow + ? Math.min(filteredCategories.length - 1, currentIndex + 1) + : Math.max(0, currentIndex - 1); + const nextCategory = filteredCategories[nextIndex] ?? null; + setWidgetPicker(prev => prev ? normalizePickerState({ + ...prev, + selectedCategory: nextCategory + }) : prev); + } + } else if (key.backspace || key.delete) { + setWidgetPicker(prev => prev ? normalizePickerState({ + ...prev, + categoryQuery: prev.categoryQuery.slice(0, -1) + }) : prev); + } else if ( + input + && !key.ctrl + && !key.meta + && !key.tab + ) { + setWidgetPicker(prev => prev ? normalizePickerState({ + ...prev, + categoryQuery: prev.categoryQuery + input + }) : prev); + } + } else { + if (key.escape) { + if (widgetPicker.widgetQuery.length > 0) { + setWidgetPicker(prev => prev ? normalizePickerState({ + ...prev, + widgetQuery: '' + }) : prev); + } else { + setWidgetPicker(prev => prev ? normalizePickerState({ + ...prev, + level: 'category' + }) : prev); + } + } else if (key.return) { + if (selectedEntry) { + applyWidgetPickerSelection(selectedEntry.type); + } + } else if (key.upArrow || key.downArrow) { + if (filteredWidgets.length === 0) { + return; + } + + let currentIndex = filteredWidgets.findIndex(entry => entry.type === widgetPicker.selectedType); + if (currentIndex === -1) { + currentIndex = 0; + } + + const nextIndex = key.downArrow + ? Math.min(filteredWidgets.length - 1, currentIndex + 1) + : Math.max(0, currentIndex - 1); + const nextType = filteredWidgets[nextIndex]?.type ?? null; + setWidgetPicker(prev => prev ? normalizePickerState({ + ...prev, + selectedType: nextType + }) : prev); + } else if (key.backspace || key.delete) { + setWidgetPicker(prev => prev ? normalizePickerState({ + ...prev, + widgetQuery: prev.widgetQuery.slice(0, -1) + }) : prev); + } else if ( + input + && !key.ctrl + && !key.meta + && !key.tab + ) { + setWidgetPicker(prev => prev ? normalizePickerState({ + ...prev, + widgetQuery: prev.widgetQuery + input + }) : prev); + } + } + + return; + } + if (moveMode) { // In move mode, use up/down to move the selected item if (key.upArrow && selectedIndex > 0) { @@ -130,77 +353,21 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB } } else { // Normal mode - if (key.upArrow) { + if (key.upArrow && widgets.length > 0) { setSelectedIndex(Math.max(0, selectedIndex - 1)); - } else if (key.downArrow) { + } else if (key.downArrow && widgets.length > 0) { setSelectedIndex(Math.min(widgets.length - 1, selectedIndex + 1)); } else if (key.leftArrow && widgets.length > 0) { - // Toggle item type backwards - const types = getAllowedTypes(); - const currentWidget = widgets[selectedIndex]; - if (currentWidget) { - const currentType = currentWidget.type; - let currentIndex = types.indexOf(currentType); - // If current type is not in allowed types (e.g., separator when disabled), find a valid type - if (currentIndex === -1) { - currentIndex = 0; - } - const prevIndex = currentIndex === 0 ? types.length - 1 : currentIndex - 1; - const newWidgets = [...widgets]; - const prevType = types[prevIndex]; - if (prevType) { - newWidgets[selectedIndex] = { ...currentWidget, type: prevType }; - onUpdate(newWidgets); - } - } + openWidgetPicker('change'); } else if (key.rightArrow && widgets.length > 0) { - // Toggle item type forwards - const types = getAllowedTypes(); - const currentWidget = widgets[selectedIndex]; - if (currentWidget) { - const currentType = currentWidget.type; - let currentIndex = types.indexOf(currentType); - // If current type is not in allowed types (e.g., separator when disabled), find a valid type - if (currentIndex === -1) { - currentIndex = 0; - } - const nextIndex = (currentIndex + 1) % types.length; - const newWidgets = [...widgets]; - const nextType = types[nextIndex]; - if (nextType) { - newWidgets[selectedIndex] = { ...currentWidget, type: nextType }; - onUpdate(newWidgets); - } - } + openWidgetPicker('change'); } else if (key.return && widgets.length > 0) { // Enter move mode setMoveMode(true); } else if (input === 'a') { - // Add widget after selected - const insertIndex = widgets.length > 0 ? selectedIndex + 1 : 0; - const backgroundColor = getUniqueBackgroundColor(insertIndex); - const newWidget: WidgetItem = { - id: generateGuid(), - type: getDefaultItemType(), - ...(backgroundColor && { backgroundColor }) - }; - const newWidgets = [...widgets]; - newWidgets.splice(insertIndex, 0, newWidget); - onUpdate(newWidgets); - setSelectedIndex(insertIndex); // Move selection to new widget + openWidgetPicker('add'); } else if (input === 'i') { - // Insert item before selected - const insertIndex = selectedIndex; - const backgroundColor = getUniqueBackgroundColor(insertIndex); - const newWidget: WidgetItem = { - id: generateGuid(), - type: getDefaultItemType(), - ...(backgroundColor && { backgroundColor }) - }; - const newWidgets = [...widgets]; - newWidgets.splice(insertIndex, 0, newWidget); - onUpdate(newWidgets); - // Keep selection on the new widget (which is now at selectedIndex) + openWidgetPicker('insert'); } else if (input === 'd' && widgets.length > 0) { // Delete selected item const newWidgets = widgets.filter((_, i) => i !== selectedIndex); @@ -209,9 +376,9 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB setSelectedIndex(selectedIndex - 1); } } else if (input === 'c') { - // Clear entire line - onUpdate([]); - setSelectedIndex(0); + if (widgets.length > 0) { + setShowClearConfirm(true); + } } else if (input === ' ' && widgets.length > 0) { // Space key - cycle separator character for separator types only (not flex) const currentWidget = widgets[selectedIndex]; @@ -318,6 +485,26 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB const hasFlexSeparator = widgets.some(widget => widget.type === 'flex-separator'); const widthDetectionAvailable = canDetectTerminalWidth(); + const pickerCategories = widgetPicker + ? getFilteredCategories(widgetPicker.categoryQuery) + : []; + const selectedPickerCategory = widgetPicker + ? (widgetPicker.selectedCategory && pickerCategories.includes(widgetPicker.selectedCategory) + ? widgetPicker.selectedCategory + : (pickerCategories[0] ?? null)) + : null; + const topLevelSearchEntries = widgetPicker && widgetPicker.level === 'category' && widgetPicker.categoryQuery.trim().length > 0 + ? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery) + : []; + const selectedTopLevelSearchEntry = widgetPicker + ? (topLevelSearchEntries.find(entry => entry.type === widgetPicker.selectedType) ?? topLevelSearchEntries[0]) + : null; + const pickerEntries = widgetPicker + ? filterWidgetCatalog(widgetCatalog, selectedPickerCategory ?? 'All', widgetPicker.widgetQuery) + : []; + const selectedPickerEntry = widgetPicker + ? (pickerEntries.find(entry => entry.type === widgetPicker.selectedType) ?? pickerEntries[0]) + : null; // Build dynamic help text based on selected item const currentWidget = widgets[selectedIndex]; @@ -341,13 +528,18 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB } const canMerge = currentWidget && selectedIndex < widgets.length - 1 && !isSeparator && !isFlexSeparator; + const hasWidgets = widgets.length > 0; // Build main help text (without custom keybinds) - let helpText = '↑↓ select, ←→ change type'; + let helpText = hasWidgets + ? '↑↓ select, ←→ open type picker' + : '(a)dd via picker, (i)nsert via picker'; if (isSeparator) { helpText += ', Space edit separator'; } - helpText += ', Enter to move, (a)dd, (i)nsert, (d)elete, (c)lear line'; + if (hasWidgets) { + helpText += ', Enter to move, (a)dd via picker, (i)nsert via picker, (d)elete, (c)lear line'; + } if (canToggleRaw) { helpText += ', (r)aw value'; } @@ -358,6 +550,11 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB // Build custom keybinds text const customKeybindsText = customKeybinds.map(kb => kb.label).join(', '); + const pickerActionLabel = widgetPicker?.action === 'add' + ? 'Add Widget' + : widgetPicker?.action === 'insert' + ? 'Insert Widget' + : 'Change Widget Type'; // If custom editor is active, render it instead of the normal UI if (customEditorWidget?.impl.renderEditor) { @@ -369,6 +566,39 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB }); } + if (showClearConfirm) { + return ( + + ⚠ Confirm Clear Line + + + This will remove all widgets from Line + {' '} + {lineNumber} + . + + This action cannot be undone! + + + Continue? + + + { + onUpdate([]); + setSelectedIndex(0); + setShowClearConfirm(false); + }} + onCancel={() => { + setShowClearConfirm(false); + }} + /> + + + ); + } + return ( @@ -379,6 +609,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {' '} {moveMode && [MOVE MODE]} + {widgetPicker && {`[${pickerActionLabel.toUpperCase()}]`}} {(settings.powerline.enabled || Boolean(settings.defaultSeparator)) && ( @@ -395,6 +626,37 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB ↑↓ to move widget, ESC or Enter to exit move mode + ) : widgetPicker ? ( + + {widgetPicker.level === 'category' ? ( + <> + {widgetPicker.categoryQuery.trim().length > 0 ? ( + ↑↓ select widget match, Enter apply, ESC clear/cancel + ) : ( + ↑↓ select category, type to search all widgets, Enter continue, ESC cancel + )} + + Search: + {widgetPicker.categoryQuery || '(none)'} + + + ) : ( + <> + ↑↓ select widget, type to search widgets, Enter apply, ESC back + + + Category: + {' '} + {selectedPickerCategory ?? '(none)'} + {' '} + | Search: + {' '} + + {widgetPicker.widgetQuery || '(none)'} + + + )} + ) : ( {helpText} @@ -407,58 +669,148 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB Flex separators will act as normal separators until width detection is available. )} - - {widgets.length === 0 ? ( - No widgets. Press 'a' to add one. - ) : ( - <> - {widgets.map((widget, index) => { - const isSelected = index === selectedIndex; - const widgetImpl = widget.type !== 'separator' && widget.type !== 'flex-separator' ? getWidget(widget.type) : null; - const { displayText, modifierText } = widgetImpl?.getEditorDisplay(widget) ?? { displayText: getWidgetDisplay(widget) }; - - return ( - - - - {isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '} - - - - {`${index + 1}. ${displayText || getWidgetDisplay(widget)}`} - - {modifierText && ( - - {' '} - {modifierText} - + {widgetPicker && ( + + {widgetPicker.level === 'category' ? ( + widgetPicker.categoryQuery.trim().length > 0 ? ( + topLevelSearchEntries.length === 0 ? ( + No widgets match the search. + ) : ( + <> + {topLevelSearchEntries.map((entry, index) => { + const isSelected = entry.type === selectedTopLevelSearchEntry?.type; + return ( + + + + {isSelected ? '▶ ' : ' '} + + + + {`${index + 1}. ${entry.displayName}`} + + + ); + })} + {selectedTopLevelSearchEntry && ( + + {selectedTopLevelSearchEntry.description} + )} - {widget.rawValue && (raw value)} - {widget.merge === true && (merged→)} - {widget.merge === 'no-padding' && (merged-no-pad→)} + + ) + ) : ( + pickerCategories.length === 0 ? ( + No categories available. + ) : ( + <> + {pickerCategories.map((category, index) => { + const isSelected = category === selectedPickerCategory; + return ( + + + + {isSelected ? '▶ ' : ' '} + + + + {`${index + 1}. ${category}`} + + + ); + })} + {selectedPickerCategory === 'All' && ( + + Search across all widget categories. + + )} + + ) + ) + ) : ( + pickerEntries.length === 0 ? ( + No widgets match the current category/search. + ) : ( + <> + {pickerEntries.map((entry, index) => { + const isSelected = entry.type === selectedPickerEntry?.type; + return ( + + + + {isSelected ? '▶ ' : ' '} + + + + {`${index + 1}. ${entry.displayName}`} + + + ); + })} + {selectedPickerEntry && ( + + {selectedPickerEntry.description} + + )} + + ) + )} + + )} + {!widgetPicker && ( + + {widgets.length === 0 ? ( + No widgets. Press 'a' to add one. + ) : ( + <> + {widgets.map((widget, index) => { + const isSelected = index === selectedIndex; + const widgetImpl = widget.type !== 'separator' && widget.type !== 'flex-separator' ? getWidget(widget.type) : null; + const { displayText, modifierText } = widgetImpl?.getEditorDisplay(widget) ?? { displayText: getWidgetDisplay(widget) }; + + return ( + + + + {isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '} + + + + {`${index + 1}. ${displayText || getWidgetDisplay(widget)}`} + + {modifierText && ( + + {' '} + {modifierText} + + )} + {widget.rawValue && (raw value)} + {widget.merge === true && (merged→)} + {widget.merge === 'no-padding' && (merged-no-pad→)} + + ); + })} + {/* Display description for selected widget */} + {currentWidget && ( + + + {(() => { + if (currentWidget.type === 'separator') { + return 'A separator character between status line widgets'; + } else if (currentWidget.type === 'flex-separator') { + return 'Expands to fill available terminal width'; + } else { + const widgetImpl = getWidget(currentWidget.type); + return widgetImpl ? widgetImpl.getDescription() : 'Unknown widget type'; + } + })()} + - ); - })} - {/* Display description for selected widget */} - {currentWidget && ( - - - {(() => { - if (currentWidget.type === 'separator') { - return 'A separator character between status line widgets'; - } else if (currentWidget.type === 'flex-separator') { - return 'Expands to fill available terminal width'; - } else { - const widgetImpl = getWidget(currentWidget.type); - return widgetImpl ? widgetImpl.getDescription() : 'Unknown widget type'; - } - })()} - - - )} - - )} - + )} + + )} + + )} ); }; \ No newline at end of file diff --git a/src/types/Widget.ts b/src/types/Widget.ts index 23ab461..463a4ca 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -34,6 +34,7 @@ export interface Widget { getDefaultColor(): string; getDescription(): string; getDisplayName(): string; + getCategory(): string; getEditorDisplay(item: WidgetItem): WidgetEditorDisplay; render(item: WidgetItem, context: RenderContext, settings: Settings): string | null; getCustomKeybinds?(): CustomKeybind[]; diff --git a/src/utils/__tests__/widgets.test.ts b/src/utils/__tests__/widgets.test.ts new file mode 100644 index 0000000..e11a47d --- /dev/null +++ b/src/utils/__tests__/widgets.test.ts @@ -0,0 +1,130 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { + DEFAULT_SETTINGS, + type Settings +} from '../../types/Settings'; +import type { WidgetItemType } from '../../types/Widget'; +import { + filterWidgetCatalog, + getWidgetCatalog, + getWidgetCatalogCategories, + type WidgetCatalogEntry +} from '../widgets'; + +describe('widget catalog', () => { + const baseSettings: Settings = { + ...DEFAULT_SETTINGS, + powerline: { ...DEFAULT_SETTINGS.powerline } + }; + + it('builds catalog entries with categories from widget definitions', () => { + const catalog = getWidgetCatalog(baseSettings); + + const model = catalog.find(entry => entry.type === 'model'); + const separator = catalog.find(entry => entry.type === 'separator'); + + expect(model?.displayName).toBe('Model'); + expect(model?.category).toBe('Core'); + expect(separator?.displayName).toBe('Separator'); + expect(separator?.category).toBe('Layout'); + }); + + it('hides manual separator when default separator is configured', () => { + const catalog = getWidgetCatalog({ + ...baseSettings, + defaultSeparator: '|' + }); + + const types = new Set(catalog.map(entry => entry.type)); + expect(types.has('separator')).toBe(false); + expect(types.has('flex-separator')).toBe(true); + }); + + it('hides both separator types in powerline mode', () => { + const catalog = getWidgetCatalog({ + ...baseSettings, + powerline: { + ...baseSettings.powerline, + enabled: true + } + }); + + const types = new Set(catalog.map(entry => entry.type)); + expect(types.has('separator')).toBe(false); + expect(types.has('flex-separator')).toBe(false); + }); + + it('returns unique categories in discovery order', () => { + const categories = getWidgetCatalogCategories(getWidgetCatalog(baseSettings)); + + expect(categories).toContain('Core'); + expect(categories).toContain('Git'); + expect(categories).toContain('Context'); + expect(categories).toContain('Tokens'); + expect(categories).toContain('Session'); + expect(categories).toContain('Environment'); + expect(categories).toContain('Custom'); + expect(categories).toContain('Layout'); + }); +}); + +describe('widget catalog filtering', () => { + const catalog = getWidgetCatalog({ + ...DEFAULT_SETTINGS, + powerline: { ...DEFAULT_SETTINGS.powerline } + }); + + it('matches display name with case-insensitive partial search', () => { + const results = filterWidgetCatalog(catalog, 'All', 'gIt br'); + expect(results[0]?.type).toBe('git-branch'); + }); + + it('matches type string search such as git-worktree', () => { + const results = filterWidgetCatalog(catalog, 'All', 'git-worktree'); + expect(results[0]?.type).toBe('git-worktree'); + }); + + it('matches description search', () => { + const results = filterWidgetCatalog(catalog, 'All', 'working directory'); + expect(results.some(entry => entry.type === 'current-working-dir')).toBe(true); + }); + + it('applies category and query filters together', () => { + const results = filterWidgetCatalog(catalog, 'Git', 'context'); + expect(results).toHaveLength(0); + }); + + it('prioritizes name match before type and description matches', () => { + const rankingCatalog: WidgetCatalogEntry[] = [ + { + type: 'alpha' as WidgetItemType, + displayName: 'Git Branch', + description: 'Primary match', + category: 'Core', + searchText: 'git branch primary match alpha' + }, + { + type: 'git-type-only' as WidgetItemType, + displayName: 'Branch', + description: 'Type fallback match', + category: 'Core', + searchText: 'branch type fallback match git-type-only' + }, + { + type: 'desc-only' as WidgetItemType, + displayName: 'Branch', + description: 'Description contains git', + category: 'Core', + searchText: 'branch description contains git desc-only' + } + ]; + + const results = filterWidgetCatalog(rankingCatalog, 'All', 'git'); + expect(results.map(entry => entry.type)).toEqual(['alpha', 'git-type-only', 'desc-only']); + }); +}); \ No newline at end of file diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts index 15a2510..62c59db 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -48,6 +48,130 @@ export function getAllWidgetTypes(settings: Settings): WidgetItemType[] { return allTypes; } +export interface WidgetCatalogEntry { + type: WidgetItemType; + displayName: string; + description: string; + category: string; + searchText: string; +} + +const LAYOUT_WIDGETS: Record> = { + 'separator': { + displayName: 'Separator', + description: 'A separator character between status line widgets', + category: 'Layout' + }, + 'flex-separator': { + displayName: 'Flex Separator', + description: 'Expands to fill available terminal width', + category: 'Layout' + } +}; + +function getLayoutCatalogEntry(type: WidgetItemType): WidgetCatalogEntry | null { + const layout = LAYOUT_WIDGETS[type]; + if (!layout) { + return null; + } + + return { + type, + displayName: layout.displayName, + description: layout.description, + category: layout.category, + searchText: `${layout.displayName} ${layout.description} ${type}`.toLowerCase() + }; +} + +export function getWidgetCatalog(settings: Settings): WidgetCatalogEntry[] { + return getAllWidgetTypes(settings).map((type) => { + const layoutEntry = getLayoutCatalogEntry(type); + if (layoutEntry) { + return layoutEntry; + } + + const widget = getWidget(type); + const displayName = widget?.getDisplayName() ?? type; + const description = widget?.getDescription() ?? `Unknown widget: ${type}`; + const category = widget?.getCategory() ?? 'Other'; + + return { + type, + displayName, + description, + category, + searchText: `${displayName} ${description} ${type}`.toLowerCase() + }; + }); +} + +export function getWidgetCatalogCategories(catalog: WidgetCatalogEntry[]): string[] { + const categories = new Set(); + + for (const entry of catalog) { + categories.add(entry.category); + } + + return Array.from(categories); +} + +export function filterWidgetCatalog(catalog: WidgetCatalogEntry[], category: string, query: string): WidgetCatalogEntry[] { + const normalizedQuery = query.trim().toLowerCase(); + + const categoryFiltered = category === 'All' + ? [...catalog] + : catalog.filter(entry => entry.category === category); + + const withScore = categoryFiltered + .map((entry) => { + if (!normalizedQuery) { + return { + entry, + score: 99 + }; + } + + const name = entry.displayName.toLowerCase(); + const description = entry.description.toLowerCase(); + const type = entry.type.toLowerCase(); + + if (name.startsWith(normalizedQuery)) { + return { entry, score: 0 }; + } + if (name.includes(normalizedQuery)) { + return { entry, score: 1 }; + } + if (type.includes(normalizedQuery)) { + return { entry, score: 2 }; + } + if (description.includes(normalizedQuery)) { + return { entry, score: 3 }; + } + if (entry.searchText.includes(normalizedQuery)) { + return { entry, score: 4 }; + } + + return null; + }) + .filter((item): item is { entry: WidgetCatalogEntry; score: number } => item !== null); + + return withScore + .sort((a, b) => { + if (a.score !== b.score) { + return a.score - b.score; + } + + const byDisplayName = a.entry.displayName.localeCompare(b.entry.displayName); + if (byDisplayName !== 0) { + return byDisplayName; + } + + return a.entry.type.localeCompare(b.entry.type); + }) + .map(item => item.entry); +} + export function isKnownWidgetType(type: string): boolean { return widgetRegistry.has(type) || type === 'separator' diff --git a/src/widgets/BlockTimer.ts b/src/widgets/BlockTimer.ts index c0c399f..cd87f8f 100644 --- a/src/widgets/BlockTimer.ts +++ b/src/widgets/BlockTimer.ts @@ -13,6 +13,7 @@ export class BlockTimerWidget implements Widget { getDefaultColor(): string { return 'yellow'; } getDescription(): string { return 'Shows elapsed time since beginning of current 5hr block'; } getDisplayName(): string { return 'Block Timer'; } + getCategory(): string { return 'Session'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const mode = item.metadata?.display ?? 'time'; diff --git a/src/widgets/ClaudeSessionId.ts b/src/widgets/ClaudeSessionId.ts index a94bebd..2b0394d 100644 --- a/src/widgets/ClaudeSessionId.ts +++ b/src/widgets/ClaudeSessionId.ts @@ -10,6 +10,7 @@ export class ClaudeSessionIdWidget implements Widget { getDefaultColor(): string { return 'cyan'; } getDescription(): string { return 'Shows the current Claude Code session ID reported in status JSON'; } getDisplayName(): string { return 'Claude Session ID'; } + getCategory(): string { return 'Core'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } diff --git a/src/widgets/ContextLength.ts b/src/widgets/ContextLength.ts index 3d60f77..b5459d0 100644 --- a/src/widgets/ContextLength.ts +++ b/src/widgets/ContextLength.ts @@ -11,6 +11,7 @@ export class ContextLengthWidget implements Widget { getDefaultColor(): string { return 'brightBlack'; } getDescription(): string { return 'Shows the current context window size in tokens'; } getDisplayName(): string { return 'Context Length'; } + getCategory(): string { return 'Context'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index 7111dbb..22312f2 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -12,6 +12,7 @@ export class ContextPercentageWidget implements Widget { getDefaultColor(): string { return 'blue'; } getDescription(): string { return 'Shows percentage of context window used or remaining'; } getDisplayName(): string { return 'Context %'; } + getCategory(): string { return 'Context'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const isInverse = item.metadata?.inverse === 'true'; const modifiers: string[] = []; diff --git a/src/widgets/ContextPercentageUsable.ts b/src/widgets/ContextPercentageUsable.ts index 7ab5446..f9b868b 100644 --- a/src/widgets/ContextPercentageUsable.ts +++ b/src/widgets/ContextPercentageUsable.ts @@ -12,6 +12,7 @@ export class ContextPercentageUsableWidget implements Widget { getDefaultColor(): string { return 'green'; } getDescription(): string { return 'Shows percentage of usable context window used or remaining (80% of max before auto-compact)'; } getDisplayName(): string { return 'Context % (usable)'; } + getCategory(): string { return 'Context'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const isInverse = item.metadata?.inverse === 'true'; const modifiers: string[] = []; diff --git a/src/widgets/CurrentWorkingDir.tsx b/src/widgets/CurrentWorkingDir.tsx index 00165e6..d3e459e 100644 --- a/src/widgets/CurrentWorkingDir.tsx +++ b/src/widgets/CurrentWorkingDir.tsx @@ -20,6 +20,7 @@ export class CurrentWorkingDirWidget implements Widget { getDefaultColor(): string { return 'blue'; } getDescription(): string { return 'Shows the current working directory'; } getDisplayName(): string { return 'Current Working Dir'; } + getCategory(): string { return 'Environment'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const segments = item.metadata?.segments ? parseInt(item.metadata.segments, 10) : undefined; const fishStyle = item.metadata?.fishStyle === 'true'; diff --git a/src/widgets/CustomCommand.tsx b/src/widgets/CustomCommand.tsx index 38f13ce..8a8460d 100644 --- a/src/widgets/CustomCommand.tsx +++ b/src/widgets/CustomCommand.tsx @@ -20,6 +20,7 @@ export class CustomCommandWidget implements Widget { getDefaultColor(): string { return 'white'; } getDescription(): string { return 'Executes a custom shell command and displays output'; } getDisplayName(): string { return 'Custom Command'; } + getCategory(): string { return 'Custom'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const cmd = item.commandPath ?? 'No command'; diff --git a/src/widgets/CustomText.tsx b/src/widgets/CustomText.tsx index 5843994..0bda7ed 100644 --- a/src/widgets/CustomText.tsx +++ b/src/widgets/CustomText.tsx @@ -19,6 +19,7 @@ export class CustomTextWidget implements Widget { getDefaultColor(): string { return 'white'; } getDescription(): string { return 'Displays user-defined custom text'; } getDisplayName(): string { return 'Custom Text'; } + getCategory(): string { return 'Custom'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const text = item.customText ?? 'Empty'; diff --git a/src/widgets/GitBranch.ts b/src/widgets/GitBranch.ts index e96cd70..5f928ae 100644 --- a/src/widgets/GitBranch.ts +++ b/src/widgets/GitBranch.ts @@ -13,6 +13,7 @@ export class GitBranchWidget implements Widget { getDefaultColor(): string { return 'magenta'; } getDescription(): string { return 'Shows the current git branch name'; } getDisplayName(): string { return 'Git Branch'; } + getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const hideNoGit = item.metadata?.hideNoGit === 'true'; const modifiers: string[] = []; diff --git a/src/widgets/GitChanges.ts b/src/widgets/GitChanges.ts index 922a268..03b64ee 100644 --- a/src/widgets/GitChanges.ts +++ b/src/widgets/GitChanges.ts @@ -13,6 +13,7 @@ export class GitChangesWidget implements Widget { getDefaultColor(): string { return 'yellow'; } getDescription(): string { return 'Shows git changes count (+insertions, -deletions)'; } getDisplayName(): string { return 'Git Changes'; } + getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const hideNoGit = item.metadata?.hideNoGit === 'true'; const modifiers: string[] = []; diff --git a/src/widgets/GitWorktree.ts b/src/widgets/GitWorktree.ts index 8c16fee..f614d54 100644 --- a/src/widgets/GitWorktree.ts +++ b/src/widgets/GitWorktree.ts @@ -12,6 +12,7 @@ export class GitWorktreeWidget implements Widget { getDefaultColor(): string { return 'blue'; } getDescription(): string { return 'Shows the current git worktree name'; } getDisplayName(): string { return 'Git Worktree'; } + getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const hideNoGit = item.metadata?.hideNoGit === 'true'; const modifiers: string[] = []; diff --git a/src/widgets/Model.ts b/src/widgets/Model.ts index c4c22f4..94b2b90 100644 --- a/src/widgets/Model.ts +++ b/src/widgets/Model.ts @@ -10,6 +10,7 @@ export class ModelWidget implements Widget { getDefaultColor(): string { return 'cyan'; } getDescription(): string { return 'Displays the Claude model name (e.g., Claude 3.5 Sonnet)'; } getDisplayName(): string { return 'Model'; } + getCategory(): string { return 'Core'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } diff --git a/src/widgets/OutputStyle.ts b/src/widgets/OutputStyle.ts index de41b9e..3e10e18 100644 --- a/src/widgets/OutputStyle.ts +++ b/src/widgets/OutputStyle.ts @@ -10,6 +10,7 @@ export class OutputStyleWidget implements Widget { getDefaultColor(): string { return 'cyan'; } getDescription(): string { return 'Shows the current Claude Code output style'; } getDisplayName(): string { return 'Output Style'; } + getCategory(): string { return 'Core'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } diff --git a/src/widgets/SessionClock.ts b/src/widgets/SessionClock.ts index 15d9f7e..85d1d97 100644 --- a/src/widgets/SessionClock.ts +++ b/src/widgets/SessionClock.ts @@ -10,6 +10,7 @@ export class SessionClockWidget implements Widget { getDefaultColor(): string { return 'yellow'; } getDescription(): string { return 'Shows elapsed time since current session started'; } getDisplayName(): string { return 'Session Clock'; } + getCategory(): string { return 'Session'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } diff --git a/src/widgets/SessionCost.ts b/src/widgets/SessionCost.ts index 95f8a8e..dd1bdbd 100644 --- a/src/widgets/SessionCost.ts +++ b/src/widgets/SessionCost.ts @@ -10,6 +10,7 @@ export class SessionCostWidget implements Widget { getDefaultColor(): string { return 'green'; } getDescription(): string { return 'Shows the total session cost in USD'; } getDisplayName(): string { return 'Session Cost'; } + getCategory(): string { return 'Session'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } diff --git a/src/widgets/TerminalWidth.ts b/src/widgets/TerminalWidth.ts index 0c48c83..2ce94f1 100644 --- a/src/widgets/TerminalWidth.ts +++ b/src/widgets/TerminalWidth.ts @@ -11,6 +11,7 @@ export class TerminalWidthWidget implements Widget { getDefaultColor(): string { return 'gray'; } getDescription(): string { return 'Shows current terminal width in columns'; } getDisplayName(): string { return 'Terminal Width'; } + getCategory(): string { return 'Environment'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } diff --git a/src/widgets/TokensCached.ts b/src/widgets/TokensCached.ts index ad4984d..2903f87 100644 --- a/src/widgets/TokensCached.ts +++ b/src/widgets/TokensCached.ts @@ -11,6 +11,7 @@ export class TokensCachedWidget implements Widget { getDefaultColor(): string { return 'cyan'; } getDescription(): string { return 'Shows cached token count for the current session'; } getDisplayName(): string { return 'Tokens Cached'; } + getCategory(): string { return 'Tokens'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } diff --git a/src/widgets/TokensInput.ts b/src/widgets/TokensInput.ts index 707aab7..452f081 100644 --- a/src/widgets/TokensInput.ts +++ b/src/widgets/TokensInput.ts @@ -11,6 +11,7 @@ export class TokensInputWidget implements Widget { getDefaultColor(): string { return 'blue'; } getDescription(): string { return 'Shows input token count for the current session'; } getDisplayName(): string { return 'Tokens Input'; } + getCategory(): string { return 'Tokens'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } diff --git a/src/widgets/TokensOutput.ts b/src/widgets/TokensOutput.ts index d5008c8..6e25a6e 100644 --- a/src/widgets/TokensOutput.ts +++ b/src/widgets/TokensOutput.ts @@ -11,6 +11,7 @@ export class TokensOutputWidget implements Widget { getDefaultColor(): string { return 'white'; } getDescription(): string { return 'Shows output token count for the current session'; } getDisplayName(): string { return 'Tokens Output'; } + getCategory(): string { return 'Tokens'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } diff --git a/src/widgets/TokensTotal.ts b/src/widgets/TokensTotal.ts index a1ccf36..ef50858 100644 --- a/src/widgets/TokensTotal.ts +++ b/src/widgets/TokensTotal.ts @@ -11,6 +11,7 @@ export class TokensTotalWidget implements Widget { getDefaultColor(): string { return 'cyan'; } getDescription(): string { return 'Shows total token count (input + output + cache) for the current session'; } getDisplayName(): string { return 'Tokens Total'; } + getCategory(): string { return 'Tokens'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } diff --git a/src/widgets/Version.ts b/src/widgets/Version.ts index e46c991..436b3a5 100644 --- a/src/widgets/Version.ts +++ b/src/widgets/Version.ts @@ -10,6 +10,7 @@ export class VersionWidget implements Widget { getDefaultColor(): string { return 'gray'; } getDescription(): string { return 'Shows Claude Code CLI version number'; } getDisplayName(): string { return 'Version'; } + getCategory(): string { return 'Core'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } diff --git a/src/widgets/__tests__/GitWorktree.test.ts b/src/widgets/__tests__/GitWorktree.test.ts index 79a43d8..8b2be3d 100644 --- a/src/widgets/__tests__/GitWorktree.test.ts +++ b/src/widgets/__tests__/GitWorktree.test.ts @@ -1,3 +1,4 @@ +import { execSync } from 'child_process'; import { beforeEach, describe, @@ -6,17 +7,18 @@ import { vi } from 'vitest'; -vi.mock('child_process', () => ({ execSync: vi.fn() })); - -import { execSync } from 'child_process'; - import type { RenderContext, WidgetItem } from '../../types'; import { GitWorktreeWidget } from '../GitWorktree'; -const mockExecSync = vi.mocked(execSync); +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +const mockExecSync = execSync as unknown as { + mockReturnValue: (value: string) => void; + mockImplementation: (impl: () => never) => void; +}; function render(rawValue = false, isPreview = false) { const widget = new GitWorktreeWidget(); @@ -78,4 +80,4 @@ describe('GitWorktreeWidget', () => { expect(render()).toBe('𖠰 no git'); }); -}); +}); \ No newline at end of file