Improve TUI widget picker UX and clear-line safety

Closes #19
This commit is contained in:
Matthew Breedlove committed 2026-02-21 10:35:47 -05:00
1 parent 09e1d723ac
commit 76d9af84a5
26 files changed
+777 -147

No files matched your search

+493 -141
View File
@@ -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<ItemsEditorProps> = ({ 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<WidgetPickerState | null>(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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ widgets, onUpdate, onB
});
}
if (showClearConfirm) {
return (
<Box flexDirection='column'>
<Text bold color='yellow'>⚠ Confirm Clear Line</Text>
<Box marginTop={1} flexDirection='column'>
<Text>
This will remove all widgets from Line
{' '}
{lineNumber}
.
</Text>
<Text color='red'>This action cannot be undone!</Text>
</Box>
<Box marginTop={2}>
<Text>Continue?</Text>
</Box>
<Box marginTop={1}>
<ConfirmDialog
inline={true}
onConfirm={() => {
onUpdate([]);
setSelectedIndex(0);
setShowClearConfirm(false);
}}
onCancel={() => {
setShowClearConfirm(false);
}}
/>
</Box>
</Box>
);
}
return (
<Box flexDirection='column'>
<Box>
@@ -379,6 +609,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
{' '}
</Text>
{moveMode && <Text color='blue'>[MOVE MODE]</Text>}
{widgetPicker && <Text color='cyan'>{`[${pickerActionLabel.toUpperCase()}]`}</Text>}
{(settings.powerline.enabled || Boolean(settings.defaultSeparator)) && (
<Box marginLeft={2}>
<Text color='yellow'>
@@ -395,6 +626,37 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
<Box flexDirection='column' marginBottom={1}>
<Text dimColor>↑↓ to move widget, ESC or Enter to exit move mode</Text>
</Box>
) : widgetPicker ? (
<Box flexDirection='column'>
{widgetPicker.level === 'category' ? (
<>
{widgetPicker.categoryQuery.trim().length > 0 ? (
<Text dimColor>↑↓ select widget match, Enter apply, ESC clear/cancel</Text>
) : (
<Text dimColor>↑↓ select category, type to search all widgets, Enter continue, ESC cancel</Text>
)}
<Box>
<Text dimColor>Search: </Text>
<Text color='cyan'>{widgetPicker.categoryQuery || '(none)'}</Text>
</Box>
</>
) : (
<>
<Text dimColor>↑↓ select widget, type to search widgets, Enter apply, ESC back</Text>
<Box>
<Text dimColor>
Category:
{' '}
{selectedPickerCategory ?? '(none)'}
{' '}
| Search:
{' '}
</Text>
<Text color='cyan'>{widgetPicker.widgetQuery || '(none)'}</Text>
</Box>
</>
)}
</Box>
) : (
<Box flexDirection='column'>
<Text dimColor>{helpText}</Text>
@@ -407,58 +669,148 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
<Text dimColor> Flex separators will act as normal separators until width detection is available.</Text>
</Box>
)}
<Box marginTop={1} flexDirection='column'>
{widgets.length === 0 ? (
<Text dimColor>No widgets. Press 'a' to add one.</Text>
) : (
<>
{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 (
<Box key={widget.id} flexDirection='row' flexWrap='nowrap'>
<Box width={3}>
<Text color={isSelected ? (moveMode ? 'blue' : 'green') : undefined}>
{isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '}
</Text>
</Box>
<Text color={isSelected ? (moveMode ? 'blue' : 'green') : undefined}>
{`${index + 1}. ${displayText || getWidgetDisplay(widget)}`}
</Text>
{modifierText && (
<Text dimColor>
{' '}
{modifierText}
</Text>
{widgetPicker && (
<Box marginTop={1} flexDirection='column'>
{widgetPicker.level === 'category' ? (
widgetPicker.categoryQuery.trim().length > 0 ? (
topLevelSearchEntries.length === 0 ? (
<Text dimColor>No widgets match the search.</Text>
) : (
<>
{topLevelSearchEntries.map((entry, index) => {
const isSelected = entry.type === selectedTopLevelSearchEntry?.type;
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>
</Box>
);
})}
{selectedTopLevelSearchEntry && (
<Box marginTop={1} paddingLeft={2}>
<Text dimColor>{selectedTopLevelSearchEntry.description}</Text>
</Box>
)}
{widget.rawValue && <Text dimColor> (raw value)</Text>}
{widget.merge === true && <Text dimColor> (merged→)</Text>}
{widget.merge === 'no-padding' && <Text dimColor> (merged-no-pad→)</Text>}
</>
)
) : (
pickerCategories.length === 0 ? (
<Text dimColor>No categories available.</Text>
) : (
<>
{pickerCategories.map((category, index) => {
const isSelected = category === selectedPickerCategory;
return (
<Box key={category} flexDirection='row' flexWrap='nowrap'>
<Box width={3}>
<Text color={isSelected ? 'green' : undefined}>
{isSelected ? '▶ ' : ' '}
</Text>
</Box>
<Text color={isSelected ? 'green' : undefined}>
{`${index + 1}. ${category}`}
</Text>
</Box>
);
})}
{selectedPickerCategory === 'All' && (
<Box marginTop={1} paddingLeft={2}>
<Text dimColor>Search across all widget categories.</Text>
</Box>
)}
</>
)
)
) : (
pickerEntries.length === 0 ? (
<Text dimColor>No widgets match the current category/search.</Text>
) : (
<>
{pickerEntries.map((entry, index) => {
const isSelected = entry.type === selectedPickerEntry?.type;
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>
</Box>
);
})}
{selectedPickerEntry && (
<Box marginTop={1} paddingLeft={2}>
<Text dimColor>{selectedPickerEntry.description}</Text>
</Box>
)}
</>
)
)}
</Box>
)}
{!widgetPicker && (
<Box marginTop={1} flexDirection='column'>
{widgets.length === 0 ? (
<Text dimColor>No widgets. Press 'a' to add one.</Text>
) : (
<>
{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 (
<Box key={widget.id} flexDirection='row' flexWrap='nowrap'>
<Box width={3}>
<Text color={isSelected ? (moveMode ? 'blue' : 'green') : undefined}>
{isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '}
</Text>
</Box>
<Text color={isSelected ? (moveMode ? 'blue' : 'green') : undefined}>
{`${index + 1}. ${displayText || getWidgetDisplay(widget)}`}
</Text>
{modifierText && (
<Text dimColor>
{' '}
{modifierText}
</Text>
)}
{widget.rawValue && <Text dimColor> (raw value)</Text>}
{widget.merge === true && <Text dimColor> (merged→)</Text>}
{widget.merge === 'no-padding' && <Text dimColor> (merged-no-pad→)</Text>}
</Box>
);
})}
{/* Display description for selected widget */}
{currentWidget && (
<Box marginTop={1} paddingLeft={2}>
<Text dimColor>
{(() => {
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';
}
})()}
</Text>
</Box>
);
})}
{/* Display description for selected widget */}
{currentWidget && (
<Box marginTop={1} paddingLeft={2}>
<Text dimColor>
{(() => {
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';
}
})()}
</Text>
</Box>
)}
</>
)}
</Box>
)}
</>
)}
</Box>
)}
</Box>
);
};
+1
View File
@@ -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[];
+130
View File
@@ -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']);
});
});
+124
View File
@@ -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<string, Omit<WidgetCatalogEntry, 'type' | 'searchText'>> = {
'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<string>();
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'
+1
View File
@@ -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';
+1
View File
@@ -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() };
}
+1
View File
@@ -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() };
}
+1
View File
@@ -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[] = [];
+1
View File
@@ -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[] = [];
+1
View File
@@ -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';
+1
View File
@@ -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';
+1
View File
@@ -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';
+1
View File
@@ -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[] = [];
+1
View File
@@ -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[] = [];
+1
View File
@@ -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[] = [];
+1
View File
@@ -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() };
}
+1
View File
@@ -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() };
}
+1
View File
@@ -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() };
}
+1
View File
@@ -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() };
}
+1
View File
@@ -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() };
}
+1
View File
@@ -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() };
}
+1
View File
@@ -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() };
}
+1
View File
@@ -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() };
}
+1
View File
@@ -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() };
}
+1
View File
@@ -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() };
}
+8 -6
View File
@@ -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');
});
});
});