mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-10-03 07:12:22 +00:00
1 parent
09e1d723ac
commit
76d9af84a5
26 files changed
+777
-147
No files matched your search
+493
-141
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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[];
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user