diff --git a/README.md b/README.md index eb22c2da..f3d4cd22 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ - [Quick Start](#-quick-start) - [Windows Support](#-windows-support) - [Usage](#-usage) + - [Widget Rules](#-widget-rules) - [API Documentation](#-api-documentation) - [Development](#️-development) - [Contributing](#-contributing) @@ -46,6 +47,18 @@ ## πŸ†• Recent Updates +### v2.3.0 - Widget Rules Engine + +- **πŸ“ Conditional Widget Rules** - Define rules that dynamically change widget properties based on conditions + - Apply color, bold, hide, and other property overrides when conditions match + - Supports numeric operators (`>`, `>=`, `<`, `<=`, `=`, `β‰ `) + - Supports string operators (`contains`, `starts with`, `ends with`, and negations) + - Supports boolean operators (`is true`, `is false`) + - Supports set operators (`in`, `not in` for matching against lists) + - Cross-widget conditions: change one widget's appearance based on another widget's value + - Rules execute top-to-bottom with optional `stop` flag to halt evaluation + - Edit rules via the TUI: press `x` on any widget in the line editor + ### v2.2.0 - v2.2.6 - Speed, widgets, links, and reliability updates - **πŸš€ New Token Speed widgets** - Added three widgets: **Input Speed**, **Output Speed**, and **Total Speed**. @@ -549,6 +562,7 @@ Common controls in the line editor: - `d` delete selected widget - `r` toggle raw value (supported widgets) - `m` cycle merge mode (`off` β†’ `merge` β†’ `merge no padding`) +- `x` open rules editor (define conditional property overrides) Widget-specific shortcuts: - **Git widgets**: `h` toggle hide `no git` output @@ -562,6 +576,164 @@ Widget-specific shortcuts: --- +### πŸ“ Widget Rules + +Rules let you dynamically change widget properties based on conditions. For example, change the Context % widget to red when usage exceeds 75%, or highlight the Git Branch widget when on `main`. + +#### Opening the Rules Editor + +In the line editor, press `x` on any widget to open its rules editor. + +#### How Rules Work + +Rules follow an MS Office-style model: +- Rules are evaluated **top-to-bottom** in order +- Each matching rule's `apply` properties are merged onto the widget +- A rule with `stop: true` halts further evaluation when it matches +- The widget's base properties serve as defaults (no special "default rule" needed) + +#### Rules Schema + +```json +{ + "id": "context-1", + "type": "context-percentage", + "color": "white", + "rules": [ + { + "when": { "greaterThan": 75 }, + "apply": { "color": "red", "bold": true }, + "stop": true + }, + { + "when": { "greaterThan": 50 }, + "apply": { "color": "yellow" }, + "stop": true + } + ] +} +``` + +#### Available Operators + +| Category | Operators | Example | +|----------|-----------|---------| +| **Numeric** | `>`, `>=`, `<`, `<=`, `=`, `β‰ ` | `{ "greaterThan": 75 }` | +| **String** | `contains`, `starts with`, `ends with` | `{ "contains": "feature/" }` | +| **String (negated)** | `does not contain`, `does not start with`, `does not end with` | `{ "contains": "main", "not": true }` | +| **Boolean** | `is true`, `is false` | `{ "isTrue": true }` | +| **Set** | `in`, `not in` | `{ "in": ["main", "master", "develop"] }` | + +#### Cross-Widget Conditions + +Reference another widget's value using the `widget` property: + +```json +{ + "when": { "widget": "git-branch", "in": ["main", "master"] }, + "apply": { "color": "cyan", "bold": true } +} +``` + +This rule changes the current widget's appearance based on the git branch name, even if the current widget is something else entirely (like Context %). + +#### Applyable Properties + +Rules can override any widget property: +- `color` - Foreground color +- `backgroundColor` - Background color +- `bold` - Bold text +- `hide` - Hide the widget entirely +- `rawValue` - Toggle raw value mode +- `merge` - Merge with adjacent widget +- `character` - Override display character +- Widget-specific metadata + +#### Rules Editor Keybinds + +The rules editor has two modes, toggled with `Tab`: + +**Property Mode:** +- `↑↓` - Navigate rules +- `←→` - Open condition editor +- `a` - Add new rule +- `d` - Delete selected rule +- `Enter` - Move mode (reorder rules) +- `s` - Toggle stop flag +- `h` - Toggle hide +- `r` - Toggle raw value +- `m` - Cycle merge mode +- `c` - Clear property overrides +- `Tab` - Switch to color mode + +**Color Mode:** +- `←→` - Cycle foreground color +- `↑↓` - Navigate rules +- `f` - Toggle foreground/background editing +- `b` - Toggle bold +- `h` - Enter hex color (truecolor terminals) +- `a` - Enter ANSI 256 color code +- `r` - Reset colors to base widget +- `Tab` - Switch to property mode + +#### Editing Conditions + +Press `←` or `β†’` in property mode to open the condition editor: + +- **←→** cycles between fields: Widget β†’ Operator β†’ Value +- **↑↓** opens pickers (widget picker or operator picker) +- **Enter** saves the condition +- **ESC** cancels + +The operator picker is organized by category (Numeric, String, Boolean, Set) β€” use `←→` to switch categories and `↑↓` to select an operator. + +#### Example: Traffic Light Context % + +```json +{ + "type": "context-percentage", + "color": "green", + "rules": [ + { "when": { "greaterThan": 80 }, "apply": { "color": "red", "bold": true }, "stop": true }, + { "when": { "greaterThan": 60 }, "apply": { "color": "yellow" }, "stop": true } + ] +} +``` +- 0-60%: Green (base color) +- 61-80%: Yellow +- 81-100%: Red + bold + +#### Example: Highlight Protected Branches + +```json +{ + "type": "git-branch", + "color": "white", + "rules": [ + { "when": { "in": ["main", "master", "production"] }, "apply": { "color": "cyan", "bold": true } }, + { "when": { "startsWith": "release/" }, "apply": { "color": "magenta" } } + ] +} +``` + +#### Example: Cross-Widget Alert + +```json +{ + "type": "model", + "color": "white", + "rules": [ + { + "when": { "widget": "context-percentage", "greaterThan": 90 }, + "apply": { "color": "red", "bold": true } + } + ] +} +``` +The Model widget turns red when context usage exceeds 90%. + +--- + ### πŸ”§ Custom Widgets #### Custom Text Widget diff --git a/src/tui/components/ColorEditor.tsx b/src/tui/components/ColorEditor.tsx new file mode 100644 index 00000000..d88a959f --- /dev/null +++ b/src/tui/components/ColorEditor.tsx @@ -0,0 +1,287 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import { getColorLevelString } from '../../types/ColorLevel'; +import type { Settings } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { + applyColors, + getAvailableBackgroundColorsForUI, + getAvailableColorsForUI +} from '../../utils/colors'; +import { shouldInsertInput } from '../../utils/input-guards'; +import { getWidget } from '../../utils/widgets'; + +export interface ColorEditorProps { + widget: WidgetItem; + settings: Settings; + onUpdate: (updatedWidget: WidgetItem) => void; + onReset?: () => void; // Optional: for resetting to base widget colors + onCancel: () => void; +} + +export const ColorEditor: React.FC = ({ + widget, + settings, + onUpdate, + onReset, + onCancel +}) => { + const [editingBackground, setEditingBackground] = useState(false); + const [hexInputMode, setHexInputMode] = useState(false); + const [hexInput, setHexInput] = useState(''); + const [ansi256InputMode, setAnsi256InputMode] = useState(false); + const [ansi256Input, setAnsi256Input] = useState(''); + + const widgetImpl = getWidget(widget.type); + const defaultColor = widgetImpl?.getDefaultColor() ?? 'white'; + + // Get available colors + const colorOptions = getAvailableColorsForUI(); + const colors = colorOptions.map(c => c.value); + const bgColorOptions = getAvailableBackgroundColorsForUI(); + const bgColors = bgColorOptions.map(c => c.value); + + // Cycle through colors + const cycleColor = (direction: 1 | -1) => { + const colorList = editingBackground ? bgColors : colors; + if (colorList.length === 0) { + return; + } + + const currentColor = editingBackground + ? (widget.backgroundColor ?? '') + : (widget.color ?? defaultColor); + + let currentIndex = colorList.indexOf(currentColor); + if (currentIndex === -1) { + currentIndex = 0; + } + + const nextIndex = direction > 0 + ? (currentIndex + 1) % colorList.length + : (currentIndex - 1 + colorList.length) % colorList.length; + const nextColor = colorList[nextIndex]; + + if (editingBackground) { + onUpdate({ + ...widget, + backgroundColor: nextColor === '' ? undefined : nextColor + }); + } else { + onUpdate({ + ...widget, + color: nextColor + }); + } + }; + + // Apply hex color + const applyHexColor = () => { + if (hexInput.length === 6) { + const hexColor = `hex:${hexInput}`; + if (editingBackground) { + onUpdate({ ...widget, backgroundColor: hexColor }); + } else { + onUpdate({ ...widget, color: hexColor }); + } + setHexInputMode(false); + setHexInput(''); + } + }; + + // Apply ANSI256 color + const applyAnsi256Color = () => { + const code = parseInt(ansi256Input, 10); + if (!isNaN(code) && code >= 0 && code <= 255) { + const ansiColor = `ansi256:${code}`; + if (editingBackground) { + onUpdate({ ...widget, backgroundColor: ansiColor }); + } else { + onUpdate({ ...widget, color: ansiColor }); + } + setAnsi256InputMode(false); + setAnsi256Input(''); + } + }; + + useInput((input, key) => { + // Handle hex input mode + if (hexInputMode) { + if (key.escape) { + setHexInputMode(false); + setHexInput(''); + } else if (key.return) { + applyHexColor(); + } else if (key.backspace || key.delete) { + setHexInput(hexInput.slice(0, -1)); + } else if (shouldInsertInput(input, key) && hexInput.length < 6) { + const upperInput = input.toUpperCase(); + if (/^[0-9A-F]$/.test(upperInput)) { + setHexInput(hexInput + upperInput); + } + } + return; + } + + // Handle ANSI256 input mode + if (ansi256InputMode) { + if (key.escape) { + setAnsi256InputMode(false); + setAnsi256Input(''); + } else if (key.return) { + applyAnsi256Color(); + } else if (key.backspace || key.delete) { + setAnsi256Input(ansi256Input.slice(0, -1)); + } else if (shouldInsertInput(input, key) && ansi256Input.length < 3) { + if (/^[0-9]$/.test(input)) { + const newInput = ansi256Input + input; + const code = parseInt(newInput, 10); + if (code <= 255) { + setAnsi256Input(newInput); + } + } + } + return; + } + + // Normal mode + if (key.escape) { + onCancel(); + } else if (input === 'h' && settings.colorLevel === 3) { + setHexInputMode(true); + setHexInput(''); + } else if (input === 'a' && settings.colorLevel === 2) { + setAnsi256InputMode(true); + setAnsi256Input(''); + } else if (input === 'f') { + setEditingBackground(!editingBackground); + } else if (input === 'b') { + onUpdate({ ...widget, bold: !widget.bold }); + } else if (input === 'r') { + if (onReset) { + onReset(); + } else { + // Default reset: remove color/backgroundColor/bold + const { color, backgroundColor, bold, ...rest } = widget; + void color; + void backgroundColor; + void bold; + onUpdate(rest); + } + } else if (key.leftArrow || key.rightArrow) { + cycleColor(key.rightArrow ? 1 : -1); + } + }); + + // Get current color for display + const currentColor = editingBackground + ? (widget.backgroundColor ?? '') + : (widget.color ?? defaultColor); + + const colorList = editingBackground ? bgColors : colors; + const colorIndex = colorList.indexOf(currentColor); + const colorNumber = colorIndex === -1 ? 'custom' : colorIndex + 1; + + // Format color display + let colorDisplay; + if (editingBackground) { + if (!currentColor) { + colorDisplay = (no background); + } else { + const displayName = currentColor.startsWith('ansi256:') + ? `ANSI ${currentColor.substring(8)}` + : currentColor.startsWith('hex:') + ? `#${currentColor.substring(4)}` + : bgColorOptions.find(c => c.value === currentColor)?.name ?? currentColor; + + const level = getColorLevelString(settings.colorLevel); + colorDisplay = {applyColors(` ${displayName} `, undefined, currentColor, false, level)}; + } + } else { + if (!currentColor) { + colorDisplay = (default); + } else { + const displayName = currentColor.startsWith('ansi256:') + ? `ANSI ${currentColor.substring(8)}` + : currentColor.startsWith('hex:') + ? `#${currentColor.substring(4)}` + : colorOptions.find(c => c.value === currentColor)?.name ?? currentColor; + + const level = getColorLevelString(settings.colorLevel); + colorDisplay = {applyColors(displayName, currentColor, undefined, false, level)}; + } + } + + // Get widget display name + const widgetName = widgetImpl?.getDisplayName() ?? widget.type; + + return ( + + + + Edit Colors: + {' '} + {widgetName} + {editingBackground && [BACKGROUND]} + + + + {hexInputMode ? ( + + Enter 6-digit hex color code (without #): + + # + {hexInput} + {hexInput.length < 6 ? '_'.repeat(6 - hexInput.length) : ''} + + Press Enter when done, ESC to cancel + + ) : ansi256InputMode ? ( + + Enter ANSI 256 color code (0-255): + + {ansi256Input} + + {ansi256Input.length === 0 ? '___' : ansi256Input.length === 1 ? '__' : ansi256Input.length === 2 ? '_' : ''} + + + Press Enter when done, ESC to cancel + + ) : ( + <> + + + Current + {' '} + {editingBackground ? 'background' : 'foreground'} + {' '} + ( + {colorNumber === 'custom' ? 'custom' : `${colorNumber}/${colorList.length}`} + ): + {' '} + {colorDisplay} + {widget.bold && [BOLD]} + + + + + + ←→ cycle + {' '} + {editingBackground ? 'background' : 'foreground'} + , (f) bg/fg, (b)old, + {settings.colorLevel === 3 ? ' (h)ex,' : settings.colorLevel === 2 ? ' (a)nsi256,' : ''} + {' '} + (r)eset, ESC cancel + + + + )} + + ); +}; \ No newline at end of file diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index 92178574..8bc07ad5 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -27,6 +27,13 @@ import { toggleWidgetBold } from './color-menu/mutations'; +export function getColorsWithHidden(): { name: string; value: string }[] { + return [ + { name: 'Hidden', value: 'hidden' }, + ...getAvailableColorsForUI() + ]; +} + export interface ColorMenuProps { widgets: WidgetItem[]; lineIndex?: number; diff --git a/src/tui/components/ConditionEditor.tsx b/src/tui/components/ConditionEditor.tsx new file mode 100644 index 00000000..b9071de8 --- /dev/null +++ b/src/tui/components/ConditionEditor.tsx @@ -0,0 +1,575 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import { + DISPLAY_OPERATOR_CONFIG, + DISPLAY_OPERATOR_LABELS, + OPERATOR_LABELS, + getConditionNot, + getConditionOperator, + getConditionValue, + getConditionWidget, + getDisplayOperator, + isBooleanOperator, + isNumericOperator, + isSetOperator, + isStringOperator, + type DisplayOperator, + type Operator +} from '../../types/Condition'; +import type { Settings } from '../../types/Settings'; +import { + filterWidgetCatalog, + getWidget, + getWidgetCatalog, + getWidgetCatalogCategories +} from '../../utils/widgets'; + +import { + handlePickerInputMode, + normalizePickerState, + type WidgetPickerState +} from './items-editor/input-handlers'; + +export interface ConditionEditorProps { + widgetType: string; // For display name + condition: Record; + settings: Settings; // For widget catalog + onSave: (condition: Record) => void; + onCancel: () => void; +} + +export const ConditionEditor: React.FC = ({ + widgetType, + condition, + settings, + onSave, + onCancel +}) => { + const initialOp = getConditionOperator(condition) ?? 'greaterThan'; + const initialValue = getConditionValue(condition); + const initialWidget = getConditionWidget(condition); + const initialNot = getConditionNot(condition); + + const [operator, setOperator] = useState(initialOp); + const [valueInput, setValueInput] = useState(() => { + if (initialValue === null) { + return '50'; // Default numeric value + } + if (Array.isArray(initialValue)) { + return initialValue.map(v => String(v)).join(', '); + } + return String(initialValue); + }); + const [selectedWidget, setSelectedWidget] = useState(initialWidget); + const [notFlag, setNotFlag] = useState(initialNot); + const [mode, setMode] = useState<'widget' | 'operator' | 'value'>('widget'); + const [widgetPicker, setWidgetPicker] = useState(null); + const [operatorPicker, setOperatorPicker] = useState<{ + selectedIndex: number; + category: 'Numeric' | 'String' | 'Boolean' | 'Set'; + selectedItem: Operator | DisplayOperator; + } | null>(null); + + const widgetCatalog = getWidgetCatalog(settings); + const widgetCategories = ['All', ...getWidgetCatalogCategories(widgetCatalog)]; + + const openWidgetPicker = () => { + if (widgetCatalog.length === 0) { + return; + } + + setWidgetPicker(normalizePickerState({ + action: 'change', + level: 'category', + selectedCategory: 'All', + categoryQuery: '', + widgetQuery: '', + selectedType: selectedWidget === 'self' ? null : selectedWidget + }, widgetCatalog, widgetCategories)); + }; + + const applyWidgetPickerSelection = (selectedType: string) => { + setSelectedWidget(selectedType); + setWidgetPicker(null); + }; + + // Operator picker helpers + const getOperatorCategory = (op: Operator | DisplayOperator): 'Numeric' | 'String' | 'Boolean' | 'Set' => { + // Check if it's a display operator + if (op in DISPLAY_OPERATOR_CONFIG) { + const config = DISPLAY_OPERATOR_CONFIG[op as DisplayOperator]; + return getOperatorCategory(config.operator); + } + + // Base operators + if (isNumericOperator(op as Operator)) + return 'Numeric'; + if (isStringOperator(op as Operator)) + return 'String'; + if (isBooleanOperator(op as Operator)) + return 'Boolean'; + return 'Set'; + }; + + const getOperatorsInCategory = (category: 'Numeric' | 'String' | 'Boolean' | 'Set'): (Operator | DisplayOperator)[] => { + switch (category) { + case 'Numeric': + return ['equals', 'notEquals', 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual']; + case 'String': + return ['equals', 'notEquals', 'contains', 'notContains', 'startsWith', 'notStartsWith', 'endsWith', 'notEndsWith', 'isEmpty', 'notEmpty']; + case 'Boolean': + return ['isTrue', 'isFalse']; + case 'Set': + return ['in', 'notIn']; + } + }; + + const openOperatorPicker = () => { + // Check if current condition matches a display operator + const displayOp = getDisplayOperator(condition); + const currentOp = displayOp ?? operator; + + const currentCategory = getOperatorCategory(currentOp); + const operatorsInCategory = getOperatorsInCategory(currentCategory); + const selectedIndex = operatorsInCategory.indexOf(currentOp); + + setOperatorPicker({ + selectedIndex: Math.max(0, selectedIndex), + category: currentCategory, + selectedItem: currentOp + }); + }; + + const applyOperatorPickerSelection = (selectedOp: Operator | DisplayOperator) => { + // Check if it's a display operator + if (selectedOp in DISPLAY_OPERATOR_CONFIG) { + const config = DISPLAY_OPERATOR_CONFIG[selectedOp as DisplayOperator]; + setOperator(config.operator); + + if (config.not !== undefined) { + setNotFlag(config.not); + } + + if (config.value !== undefined) { + // Special case: isFalse uses isTrue with value false + setValueInput(String(config.value)); + } + } else { + // Base operator + setOperator(selectedOp as Operator); + setNotFlag(false); + } + + setOperatorPicker(null); + + // Update value input based on new operator type (for base operators) + const baseOp = selectedOp in DISPLAY_OPERATOR_CONFIG + ? DISPLAY_OPERATOR_CONFIG[selectedOp as DisplayOperator].operator + : selectedOp as Operator; + + if (isBooleanOperator(baseOp)) { + if (!(selectedOp in DISPLAY_OPERATOR_CONFIG && DISPLAY_OPERATOR_CONFIG[selectedOp as DisplayOperator].value !== undefined)) { + setValueInput('true'); + } + } else if (isSetOperator(baseOp)) { + setValueInput(''); + } else if (isStringOperator(baseOp)) { + setValueInput(''); + } + // Keep numeric value as-is for numeric operators + }; + + useInput((input, key) => { + // Handle widget picker input + if (widgetPicker) { + handlePickerInputMode({ + input, + key, + widgetPicker, + setWidgetPicker, + widgetCatalog, + widgetCategories, + applyWidgetPickerSelection + }); + return; + } + + // Handle operator picker input + if (operatorPicker) { + const categories: ('Numeric' | 'String' | 'Boolean' | 'Set')[] = ['Numeric', 'String', 'Boolean', 'Set']; + const operatorsInCategory = getOperatorsInCategory(operatorPicker.category); + + if (key.escape) { + setOperatorPicker(null); + } else if (key.leftArrow || key.rightArrow) { + // Switch category + const currentCategoryIndex = categories.indexOf(operatorPicker.category); + const nextCategoryIndex = key.rightArrow + ? (currentCategoryIndex + 1) % categories.length + : (currentCategoryIndex - 1 + categories.length) % categories.length; + const newCategory = categories[nextCategoryIndex] ?? categories[0]; + if (!newCategory) { + setOperatorPicker(null); + return; + } + const newOps = getOperatorsInCategory(newCategory); + const firstOperator = newOps[0]; + if (!firstOperator) { + setOperatorPicker(null); + return; + } + + setOperatorPicker({ + category: newCategory, + selectedIndex: 0, + selectedItem: firstOperator + }); + } else if (key.upArrow) { + const newIndex = Math.max(0, operatorPicker.selectedIndex - 1); + const selectedItem = operatorsInCategory[newIndex]; + if (!selectedItem) { + return; + } + setOperatorPicker({ + ...operatorPicker, + selectedIndex: newIndex, + selectedItem + }); + } else if (key.downArrow) { + const newIndex = Math.min(operatorsInCategory.length - 1, operatorPicker.selectedIndex + 1); + const selectedItem = operatorsInCategory[newIndex]; + if (!selectedItem) { + return; + } + setOperatorPicker({ + ...operatorPicker, + selectedIndex: newIndex, + selectedItem + }); + } else if (key.return) { + applyOperatorPickerSelection(operatorPicker.selectedItem); + } + return; + } + + if (key.escape) { + onCancel(); + } else if (key.return) { + // Save condition - parse value based on operator type + const newCondition: Record = {}; + + // Only include widget if it's not 'self' + if (selectedWidget !== 'self') { + newCondition.widget = selectedWidget; + } + + // Include not flag if true + if (notFlag) { + newCondition.not = true; + } + + // Parse and validate value based on operator type + if (isBooleanOperator(operator)) { + const boolValue = valueInput.toLowerCase() === 'true'; + newCondition[operator] = boolValue; + onSave(newCondition); + } else if (isSetOperator(operator)) { + // Parse comma-separated values + const values = valueInput + .split(',') + .map(v => v.trim()) + .filter(v => v.length > 0) + .map((v) => { + // Try to parse as number, otherwise keep as string + const num = Number(v); + return isNaN(num) ? v : num; + }); + + if (values.length > 0) { + newCondition[operator] = values; + onSave(newCondition); + } + // Don't save if empty + } else if (isStringOperator(operator)) { + // String value - no parsing needed + if (valueInput.trim().length > 0) { + newCondition[operator] = valueInput.trim(); + onSave(newCondition); + } + // Don't save if empty + } else { + // Numeric operator + const numValue = Number(valueInput); + if (!isNaN(numValue)) { + newCondition[operator] = numValue; + onSave(newCondition); + } + // Don't save if invalid number + } + } else if (key.leftArrow) { + // Navigate fields left (cycles): value β†’ operator β†’ widget β†’ value + if (mode === 'value') { + setMode('operator'); + } else if (mode === 'operator') { + setMode('widget'); + } else { + setMode('value'); // Wrap around + } + } else if (key.rightArrow) { + // Navigate fields right (cycles): widget β†’ operator β†’ value β†’ widget + if (mode === 'widget') { + setMode('operator'); + } else if (mode === 'operator') { + setMode('value'); + } else { + setMode('widget'); // Wrap around + } + } else if (key.upArrow || key.downArrow) { + // Open pickers with up/down + if (mode === 'widget') { + openWidgetPicker(); + } else if (mode === 'operator') { + openOperatorPicker(); + } + // In value mode, up/down does nothing (no picker) + } else if (mode === 'value') { + if (key.backspace || key.delete) { + setValueInput(valueInput.slice(0, -1)); + } else if (input) { + // Allow different input based on operator type + if (isBooleanOperator(operator)) { + // Toggle true/false + if (input === 't' || input === 'f') { + setValueInput(input === 't' ? 'true' : 'false'); + } + } else { + // For string, numeric, and set operators, allow any character + setValueInput(valueInput + input); + } + } + } + }); + + // Check if current state matches a display operator + // Parse the value to its proper type so display operator detection works correctly + // (valueInput is always a string, but operators like isTrue need boolean values) + const parsedValue = isBooleanOperator(operator) + ? valueInput.toLowerCase() === 'true' + : valueInput; + const currentCondition = { + [operator]: parsedValue, + ...(notFlag ? { not: true } : {}), + ...(selectedWidget !== 'self' ? { widget: selectedWidget } : {}) + }; + const displayOp = getDisplayOperator(currentCondition); + const opLabel = displayOp ? DISPLAY_OPERATOR_LABELS[displayOp] : OPERATOR_LABELS[operator]; + + // Validate value based on operator type + const isValid = (() => { + if (isBooleanOperator(operator)) { + return valueInput.toLowerCase() === 'true' || valueInput.toLowerCase() === 'false'; + } + if (isSetOperator(operator)) { + return valueInput.trim().length > 0; + } + if (isStringOperator(operator)) { + return valueInput.trim().length > 0; + } + // Numeric operator + return !isNaN(Number(valueInput)); + })(); + + // Get widget display name + const getWidgetDisplayName = (widgetType: string): string => { + if (widgetType === 'self') { + return 'This widget (self)'; + } + const widgetImpl = getWidget(widgetType); + return widgetImpl ? widgetImpl.getDisplayName() : widgetType; + }; + + const widgetLabel = getWidgetDisplayName(selectedWidget); + + // Render operator picker if open + if (operatorPicker) { + const categories: ('Numeric' | 'String' | 'Boolean' | 'Set')[] = ['Numeric', 'String', 'Boolean', 'Set']; + const operatorsInCategory = getOperatorsInCategory(operatorPicker.category); + + return ( + + + Select Operator + + + {/* Category navigation bar */} + + ← + {categories.map((cat, idx) => { + const isActive = cat === operatorPicker.category; + return ( + + {idx > 0 && } + + {isActive ? `[${cat}]` : cat} + + + ); + })} + β†’ + + + ↑↓ select, ←→ switch category, Enter apply, ESC cancel + + + {operatorsInCategory.map((op, idx) => { + const isSelected = idx === operatorPicker.selectedIndex; + // Get label from either base operators or display operators + const label = (op in DISPLAY_OPERATOR_LABELS) + ? DISPLAY_OPERATOR_LABELS[op as DisplayOperator] + : OPERATOR_LABELS[op as Operator]; + + return ( + + {isSelected ? 'β–Ά ' : ' '} + {label} + + ); + })} + + + ); + } + + // Render widget picker if open + if (widgetPicker) { + const selectedPickerCategory = widgetPicker.selectedCategory ?? (widgetCategories[0] ?? 'All'); + const pickerCategories = widgetCategories.filter(c => c.toLowerCase().includes(widgetPicker.categoryQuery.toLowerCase()) + ); + const topLevelSearchEntries = widgetPicker.level === 'category' && widgetPicker.categoryQuery.trim().length > 0 + ? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery) + : []; + const selectedTopLevelSearchEntry = topLevelSearchEntries.find(entry => entry.type === widgetPicker.selectedType) ?? null; + const pickerEntries = filterWidgetCatalog(widgetCatalog, selectedPickerCategory, widgetPicker.widgetQuery); + const selectedPickerEntry = pickerEntries.find(entry => entry.type === widgetPicker.selectedType) ?? null; + + return ( + + + Select Widget to Reference + + + {widgetPicker.level === 'category' ? ( + <> + {widgetPicker.categoryQuery.trim().length > 0 ? ( + ↑↓ select widget match, Enter apply, ESC clear/cancel + ) : ( + ↑↓ select category, Enter drill in, type to search + )} + + Search: + {widgetPicker.categoryQuery || '(none)'} + + + ) : ( + <> + ↑↓ select widget, Enter apply, ESC back, type to search + + + Category: + {selectedPickerCategory} + + + + + Search: + {' '} + + {widgetPicker.widgetQuery || '(none)'} + + + )} + + + {widgetPicker.level === 'category' ? ( + widgetPicker.categoryQuery.trim().length > 0 ? ( + topLevelSearchEntries.length === 0 ? ( + No widgets match the search. + ) : ( + topLevelSearchEntries.map(entry => ( + + {selectedTopLevelSearchEntry?.type === entry.type ? 'β–Ά ' : ' '} + {entry.displayName} + + )) + ) + ) : ( + pickerCategories.map(cat => ( + + {cat === selectedPickerCategory ? 'β–Ά ' : ' '} + {cat} + + )) + ) + ) : ( + pickerEntries.length === 0 ? ( + No widgets in this category match the search. + ) : ( + pickerEntries.map(entry => ( + + {selectedPickerEntry?.type === entry.type ? 'β–Ά ' : ' '} + {entry.displayName} + + )) + ) + )} + + + ); + } + + return ( + + + Edit Condition + + + + when + + {widgetLabel} + + + + {opLabel} + + {!isBooleanOperator(operator) && ( + <> + + + {isStringOperator(operator) && `"${valueInput || '(empty)'}"`} + {isSetOperator(operator) && `[${valueInput || '(empty)'}]`} + {!isStringOperator(operator) && !isSetOperator(operator) && (valueInput || '(empty)')} + + + )} + {!isValid && (invalid)} + + + + + {mode === 'widget' && '↑↓ open widget picker, ←→ switch field, Enter save, ESC cancel'} + {mode === 'operator' && '↑↓ open operator picker, ←→ switch field, Enter save, ESC cancel'} + {mode === 'value' && isBooleanOperator(operator) && 't/f for true/false, ←→ switch field, Enter save, ESC cancel'} + {mode === 'value' && isSetOperator(operator) && 'type comma-separated values, ←→ switch field, Enter save, ESC cancel'} + {mode === 'value' && isStringOperator(operator) && 'type text, ←→ switch field, Enter save, ESC cancel'} + {mode === 'value' && !isBooleanOperator(operator) && !isSetOperator(operator) && !isStringOperator(operator) && 'type number, ←→ switch field, Enter save, ESC cancel'} + + + + ); +}; \ No newline at end of file diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 5a564b13..2571495f 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -23,6 +23,7 @@ import { } from '../../utils/widgets'; import { ConfirmDialog } from './ConfirmDialog'; +import { RulesEditor } from './RulesEditor'; import { handleMoveInputMode, handleNormalInputMode, @@ -47,6 +48,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB const [customEditorWidget, setCustomEditorWidget] = useState(null); const [widgetPicker, setWidgetPicker] = useState(null); const [showClearConfirm, setShowClearConfirm] = useState(false); + const [rulesEditorWidget, setRulesEditorWidget] = useState(null); const separatorChars = ['|', '-', ',', ' ']; const widgetCatalog = getWidgetCatalog(settings); @@ -157,6 +159,11 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB return; } + // Skip input if rules editor is active + if (rulesEditorWidget) { + return; + } + // Skip input handling when clear confirmation is active - let ConfirmDialog handle it if (showClearConfirm) { return; @@ -200,7 +207,8 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB setShowClearConfirm, openWidgetPicker, getCustomKeybindsForWidget, - setCustomEditorWidget + setCustomEditorWidget, + setRulesEditorWidget }); }); @@ -287,6 +295,9 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB if (canMerge) { helpText += ', (m)erge'; } + if (!isSeparator && !isFlexSeparator && hasWidgets) { + helpText += ', (x) exceptions'; + } helpText += ', ESC back'; // Build custom keybinds text @@ -307,6 +318,23 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB }); } + if (rulesEditorWidget) { + return ( + { + // Update widget in widgets array + const newWidgets = widgets.map(w => w.id === updatedWidget.id ? updatedWidget : w + ); + onUpdate(newWidgets); // This triggers preview update! + setRulesEditorWidget(updatedWidget); // Keep editor in sync + }} + onBack={() => { setRulesEditorWidget(null); }} + /> + ); + } + if (showClearConfirm) { return ( @@ -526,9 +554,28 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {modifierText} )} + {widget.type !== 'separator' && widget.type !== 'flex-separator' && ( + + {' '} + [ + {widget.color ?? 'default'} + ] + + )} {supportsRawValue && widget.rawValue && (raw value)} {widget.merge === true && (mergedβ†’)} {widget.merge === 'no-padding' && (merged-no-padβ†’)} + {widget.rules && widget.rules.length > 0 && ( + + {' '} + ( + {widget.rules.length} + {' '} + rule + {widget.rules.length === 1 ? '' : 's'} + ) + + )} ); })} diff --git a/src/tui/components/RulesEditor.tsx b/src/tui/components/RulesEditor.tsx new file mode 100644 index 00000000..f7e9ee6e --- /dev/null +++ b/src/tui/components/RulesEditor.tsx @@ -0,0 +1,697 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import { getColorLevelString } from '../../types/ColorLevel'; +import { + DISPLAY_OPERATOR_LABELS, + OPERATOR_LABELS, + getConditionNot, + getConditionOperator, + getConditionValue, + getConditionWidget, + getDisplayOperator, + isBooleanOperator, + isSetOperator, + isStringOperator +} from '../../types/Condition'; +import type { Settings } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { applyColors } from '../../utils/colors'; +import { + extractWidgetOverrides, + mergeWidgetWithRuleApply +} from '../../utils/widget-properties'; +import { getWidget } from '../../utils/widgets'; + +import { ConditionEditor } from './ConditionEditor'; +import { + getCurrentColorInfo, + handleColorInput, + type ColorEditorState +} from './color-editor/input-handlers'; +import { handleWidgetPropertyInput } from './items-editor/input-handlers'; + +export interface RulesEditorProps { + widget: WidgetItem; + settings: Settings; + onUpdate: (updatedWidget: WidgetItem) => void; + onBack: () => void; +} + +type RulesEditorMode = 'color' | 'property'; + +export const RulesEditor: React.FC = ({ widget, settings, onUpdate, onBack }) => { + const [selectedIndex, setSelectedIndex] = useState(0); + const [moveMode, setMoveMode] = useState(false); + const [conditionEditorIndex, setConditionEditorIndex] = useState(null); + const [editorMode, setEditorMode] = useState('property'); + const [colorEditorState, setColorEditorState] = useState({ + editingBackground: false, + hexInputMode: false, + hexInput: '', + ansi256InputMode: false, + ansi256Input: '' + }); + const [customEditorWidget, setCustomEditorWidget] = useState<{ widget: WidgetItem; impl: ReturnType; action?: string } | null>(null); + const rules = widget.rules ?? []; + + // Add new rule with placeholder values + const addRule = () => { + const newRule = { + when: { greaterThan: 50 }, // Placeholder - Phase 4 will make this editable + apply: {}, // Empty overrides - Phase 7 will make this editable + stop: false + }; + + const newRules = [...rules, newRule]; + const updatedWidget = { ...widget, rules: newRules }; + onUpdate(updatedWidget); + setSelectedIndex(newRules.length - 1); // Select newly added rule + }; + + // Delete selected rule + const deleteRule = () => { + if (rules.length === 0) { + return; + } + + const newRules = rules.filter((_, i) => i !== selectedIndex); + const updatedWidget = { ...widget, rules: newRules }; + onUpdate(updatedWidget); + + // Adjust selection after delete (same pattern as ItemsEditor) + if (selectedIndex >= newRules.length && selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1); + } + }; + + // Handle custom editor completion + const handleEditorComplete = (updatedWidget: WidgetItem) => { + const rule = rules[selectedIndex]; + if (!rule) { + setCustomEditorWidget(null); + return; + } + + // Extract what changed compared to base widget + const newApply = extractWidgetOverrides(updatedWidget, widget, rule.apply); + const newRules = [...rules]; + newRules[selectedIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...widget, rules: newRules }); + setCustomEditorWidget(null); + }; + + // Handle custom editor cancellation + const handleEditorCancel = () => { + setCustomEditorWidget(null); + }; + + // Handle color mode input using shared handler + const handleColorModeInput = (input: string, key: { leftArrow?: boolean; rightArrow?: boolean; escape?: boolean; return?: boolean; backspace?: boolean; delete?: boolean; upArrow?: boolean; downArrow?: boolean }) => { + const rule = rules[selectedIndex]; + if (!rule) { + return; + } + + // Create temp widget by merging base + apply + const tempWidget = mergeWidgetWithRuleApply(widget, rule.apply); + + // Use shared color input handler + handleColorInput({ + input, + key, + widget: tempWidget, + settings, + state: colorEditorState, + setState: setColorEditorState, + onUpdate: (updatedWidget) => { + // Extract what changed compared to base widget + const newApply = extractWidgetOverrides(updatedWidget, widget, rule.apply); + const newRules = [...rules]; + newRules[selectedIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...widget, rules: newRules }); + }, + onReset: () => { + // Reset colors to base widget (remove color/backgroundColor/bold from apply) + const resetWidget = { + ...tempWidget, + color: widget.color, + backgroundColor: widget.backgroundColor, + bold: widget.bold + }; + const newApply = extractWidgetOverrides(resetWidget, widget, rule.apply); + const newRules = [...rules]; + newRules[selectedIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...widget, rules: newRules }); + } + }); + }; + + // Handle property mode input + const handlePropertyModeInput = (input: string, key: { ctrl?: boolean; meta?: boolean }) => { + const rule = rules[selectedIndex]; + if (!rule) { + return; + } + + // Handle rule-specific properties + if (input === 's') { + // Toggle stop flag + const newRules = [...rules]; + newRules[selectedIndex] = { + ...rule, + stop: !rule.stop + }; + onUpdate({ ...widget, rules: newRules }); + return; + } + + if (input === 'h') { + // Toggle hide flag + const tempWidget = mergeWidgetWithRuleApply(widget, rule.apply); + const updatedWidget = { ...tempWidget, hide: !tempWidget.hide }; + + // Extract diffs and update + const newApply = extractWidgetOverrides(updatedWidget, widget, rule.apply); + const newRules = [...rules]; + newRules[selectedIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...widget, rules: newRules }); + return; + } + + if (input === 'c') { + // Clear only property overrides (NOT color/bold/hide - those are managed separately) + const newRules = [...rules]; + const { color, backgroundColor, bold, hide, ...restApply } = rule.apply; + + // Preserve color/bold/hide if they exist + const newApply: Record = {}; + if (color !== undefined) { + newApply.color = color; + } + if (backgroundColor !== undefined) { + newApply.backgroundColor = backgroundColor; + } + if (bold !== undefined) { + newApply.bold = bold; + } + if (hide !== undefined) { + newApply.hide = hide; + } + + void restApply; // All other properties are cleared + + newRules[selectedIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...widget, rules: newRules }); + return; + } + + // Handle widget property toggles ('r', 'm', custom keybinds) using shared logic + // Create temp widget by merging base widget with rule.apply + const tempWidget = mergeWidgetWithRuleApply(widget, rule.apply); + + handleWidgetPropertyInput({ + input, + key, + widget: tempWidget, + onUpdate: (updatedWidget) => { + // Extract what changed compared to base widget + const newApply = extractWidgetOverrides(updatedWidget, widget, rule.apply); + + const newRules = [...rules]; + newRules[selectedIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...widget, rules: newRules }); + }, + getCustomKeybindsForWidget: (widgetImpl, w) => { + return widgetImpl.getCustomKeybinds?.(w) ?? []; + }, + setCustomEditorWidget + }); + }; + + // Handle move mode input + const handleMoveMode = (key: { upArrow?: boolean; downArrow?: boolean; return?: boolean; escape?: boolean }) => { + if (key.upArrow && selectedIndex > 0) { + // Swap with rule above + const newRules = [...rules]; + const currentRule = newRules[selectedIndex]; + const previousRule = newRules[selectedIndex - 1]; + if (!currentRule || !previousRule) { + return; + } + newRules[selectedIndex] = previousRule; + newRules[selectedIndex - 1] = currentRule; + + onUpdate({ ...widget, rules: newRules }); + setSelectedIndex(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < rules.length - 1) { + // Swap with rule below + const newRules = [...rules]; + const currentRule = newRules[selectedIndex]; + const nextRule = newRules[selectedIndex + 1]; + if (!currentRule || !nextRule) { + return; + } + newRules[selectedIndex] = nextRule; + newRules[selectedIndex + 1] = currentRule; + + onUpdate({ ...widget, rules: newRules }); + setSelectedIndex(selectedIndex + 1); + } else if (key.escape || key.return) { + setMoveMode(false); + } + }; + + useInput((input, key) => { + if (customEditorWidget) { + return; // Let custom editor handle input + } + + if (moveMode) { + handleMoveMode(key); + return; + } + + if (conditionEditorIndex !== null) { + return; // Let ConditionEditor handle input + } + + if (key.escape) { + onBack(); + } else if (key.upArrow && rules.length > 0) { + setSelectedIndex(Math.max(0, selectedIndex - 1)); + } else if (key.downArrow && rules.length > 0) { + setSelectedIndex(Math.min(rules.length - 1, selectedIndex + 1)); + } else if (key.return && rules.length > 0) { + setMoveMode(true); // Enter move mode + } else if (key.tab && rules.length > 0) { + // Toggle between color and property modes + setEditorMode(prev => prev === 'color' ? 'property' : 'color'); + } else if (input === 'a') { + addRule(); + } else if (input === 'd' && rules.length > 0) { + deleteRule(); // Immediate delete, no confirmation (matches ItemsEditor) + } else if (rules.length > 0) { + // Handle mode-specific input + if (editorMode === 'color') { + // Color mode: inline editing + handleColorModeInput(input, key); + } else { + // Property mode: check for condition editor trigger first + if (key.leftArrow || key.rightArrow) { + // Open condition editor (same UX as ItemsEditor widget type picker) + setConditionEditorIndex(selectedIndex); + } else { + handlePropertyModeInput(input, key); + } + } + } + }); + + // Get widget display name (same as ColorMenu does) + const getWidgetDisplayName = () => { + const widgetImpl = getWidget(widget.type); + return widgetImpl ? widgetImpl.getDisplayName() : widget.type; + }; + + // Format condition summary + const formatCondition = (when: Record): string => { + const widgetRef = getConditionWidget(when); + const operator = getConditionOperator(when); + const value = getConditionValue(when); + const notFlag = getConditionNot(when); + + // Get widget display name + let widgetName = 'self'; + if (widgetRef !== 'self') { + const widgetImpl = getWidget(widgetRef); + widgetName = widgetImpl ? widgetImpl.getDisplayName() : widgetRef; + } + + // Check if this matches a display operator pattern + const displayOp = getDisplayOperator(when); + if (displayOp) { + const displayLabel = DISPLAY_OPERATOR_LABELS[displayOp]; + + // Format based on display operator type + if (displayOp === 'notEquals') { + if (typeof value === 'string') { + return `when ${widgetName} ${displayLabel} "${value}"`; + } + return `when ${widgetName} ${displayLabel} ${value}`; + } + if (displayOp === 'notContains' || displayOp === 'notStartsWith' || displayOp === 'notEndsWith') { + return `when ${widgetName} ${displayLabel} "${value}"`; + } + return `when ${widgetName} ${displayLabel}`; + } + + // Fall back to showing base operator with NOT prefix if needed + const notPrefix = notFlag ? 'NOT ' : ''; + + if (operator && value !== null) { + const opLabel = OPERATOR_LABELS[operator]; + + // Format based on operator type + if (isStringOperator(operator)) { + return `when ${notPrefix}${widgetName} ${opLabel} "${value}"`; + } + + if (isBooleanOperator(operator)) { + return `when ${notPrefix}${widgetName} ${opLabel}`; + } + + if (isSetOperator(operator) && Array.isArray(value)) { + const valueList = value.map(v => JSON.stringify(v)).join(', '); + return `when ${notPrefix}${widgetName} ${opLabel} [${valueList}]`; + } + + // Numeric or equals + return `when ${notPrefix}${widgetName} ${opLabel}${value}`; + } + + return `when ${JSON.stringify(when)}`; + }; + + // Format applied properties as labels (using widget's own display logic) + const formatAppliedProperties = (apply: Record): string => { + // Create a temp widget by merging base widget with rule.apply + // Use shared merge function to handle metadata deep merge correctly + const tempWidget = mergeWidgetWithRuleApply(widget, apply); + + // Let the widget format its own modifiers (hide, remaining, etc.) + const widgetImpl = getWidget(widget.type); + const { modifierText } = widgetImpl?.getEditorDisplay(tempWidget) ?? { modifierText: undefined }; + + // Build labels for base properties (rawValue, merge) + const baseLabels: string[] = []; + + if (tempWidget.rawValue) { + baseLabels.push('raw value'); + } + + if (tempWidget.merge === true) { + baseLabels.push('mergedβ†’'); + } else if (tempWidget.merge === 'no-padding') { + baseLabels.push('merged-no-padβ†’'); + } + + if (tempWidget.hide) { + baseLabels.push('hidden'); + } + + if (tempWidget.character !== undefined) { + baseLabels.push(`character: ${tempWidget.character}`); + } + + // Combine widget-specific modifiers and base property labels + const parts: string[] = []; + if (modifierText) { + parts.push(modifierText); + } + if (baseLabels.length > 0) { + parts.push(...baseLabels.map(l => `(${l})`)); + } + + return parts.length > 0 ? ` ${parts.join(' ')}` : ''; + }; + + // Build help text based on mode + const buildHelpText = (): string => { + if (moveMode) { + return '↑↓ move rule, Enter/ESC exit move mode'; + } + + if (rules.length === 0) { + return '(a)dd rule, ESC back'; + } + + const baseHelp = '↑↓ select, Enter move mode, (a)dd, (d)elete'; + + if (editorMode === 'color') { + const { editingBackground, hexInputMode, ansi256InputMode } = colorEditorState; + + if (hexInputMode) { + return 'Type 6-digit hex code (without #), Enter to apply, ESC to cancel'; + } + + if (ansi256InputMode) { + return 'Type ANSI 256 color code (0-255), Enter to apply, ESC to cancel'; + } + + const colorType = editingBackground ? 'background' : 'foreground'; + const hexAnsiHelp = settings.colorLevel === 3 + ? ', (h)ex' + : settings.colorLevel === 2 + ? ', (a)nsi256' + : ''; + + return `${baseHelp}, ←→ cycle ${colorType}\n(f) bg/fg, (b)old${hexAnsiHelp}, (r)eset\nTab: property mode, ESC back`; + } else { + // Property mode - include widget custom keybinds and base properties + const widgetImpl = getWidget(widget.type); + const customKeybinds = widgetImpl?.getCustomKeybinds?.(widget) ?? []; + const keybindHelp = customKeybinds + .map(kb => kb.label) + .join(', '); + + // Build base property keybinds + const basePropertyKeybinds: string[] = []; + if (widgetImpl?.supportsRawValue()) { + basePropertyKeybinds.push('(r)aw value'); + } + basePropertyKeybinds.push('(m)erge'); + basePropertyKeybinds.push('(h)ide'); + basePropertyKeybinds.push('(s)top'); + basePropertyKeybinds.push('(c)lear properties'); + + const propertyHelp = keybindHelp ? `, ${keybindHelp}` : ''; + const basePropsHelp = basePropertyKeybinds.join(', '); + return `${baseHelp}, ←→ edit condition${propertyHelp}, ${basePropsHelp}\nTab: color mode, ESC back`; + } + }; + + const helpText = buildHelpText(); + + // Show custom widget editor + if (customEditorWidget?.impl?.renderEditor) { + return customEditorWidget.impl.renderEditor({ + widget: customEditorWidget.widget, + onComplete: handleEditorComplete, + onCancel: handleEditorCancel, + action: customEditorWidget.action + }); + } + + // Show condition editor + if (conditionEditorIndex !== null) { + const rule = rules[conditionEditorIndex]; + if (!rule) { + setConditionEditorIndex(null); + return null; + } + + return ( + { + const newRules = [...rules]; + newRules[conditionEditorIndex] = { + ...rule, + when: newCondition + }; + onUpdate({ ...widget, rules: newRules }); + setConditionEditorIndex(null); + }} + onCancel={() => { setConditionEditorIndex(null); }} + /> + ); + } + + return ( + + + + Rules for + {widget.type} + + {moveMode && [MOVE MODE]} + {!moveMode && editorMode === 'color' && ( + + {' '} + [COLOR MODE + {colorEditorState.editingBackground ? ' - BACKGROUND' : ' - FOREGROUND'} + ] + + )} + {!moveMode && editorMode === 'property' && [PROPERTY MODE]} + + + {rules.length === 0 ? ( + No rules defined + ) : ( + <> + + {helpText} + + + {editorMode === 'color' && colorEditorState.hexInputMode && ( + + Enter 6-digit hex color code (without #): + + # + {colorEditorState.hexInput} + + {colorEditorState.hexInput.length < 6 ? '_'.repeat(6 - colorEditorState.hexInput.length) : ''} + + + + )} + + {editorMode === 'color' && colorEditorState.ansi256InputMode && ( + + Enter ANSI 256 color code (0-255): + + {colorEditorState.ansi256Input} + + {colorEditorState.ansi256Input.length === 0 + ? '___' + : colorEditorState.ansi256Input.length === 1 + ? '__' + : colorEditorState.ansi256Input.length === 2 + ? '_' + : ''} + + + + )} + + {editorMode === 'color' && !colorEditorState.hexInputMode && !colorEditorState.ansi256InputMode && (() => { + const rule = rules[selectedIndex]; + if (!rule) { + return null; + } + + // Create temp widget by merging base + apply + const tempWidget = mergeWidgetWithRuleApply(widget, rule.apply); + const { colorIndex, totalColors, displayName } = getCurrentColorInfo( + tempWidget, + colorEditorState.editingBackground + ); + + const colorType = colorEditorState.editingBackground ? 'background' : 'foreground'; + const colorNumber = colorIndex === -1 ? 'custom' : `${colorIndex}/${totalColors}`; + + // Apply color to display name + const level = getColorLevelString(settings.colorLevel); + const styledColor = colorEditorState.editingBackground + ? applyColors(` ${displayName} `, undefined, tempWidget.backgroundColor, false, level) + : applyColors(displayName, tempWidget.color, undefined, false, level); + + return ( + + + Current + {' '} + {colorType} + {' '} + ( + {colorNumber} + ): + {' '} + {styledColor} + {tempWidget.bold && [BOLD]} + + + ); + })()} + + {rules.map((rule, index) => { + const isSelected = index === selectedIndex; + const condition = formatCondition(rule.when); + const stopIndicator = rule.stop ? ' (stop)' : ''; + const appliedProps = formatAppliedProperties(rule.apply); + + // Get widget display name (same as ColorMenu pattern) + const displayName = getWidgetDisplayName(); + + // Get widget's actual configured color/bold as base + const widgetImpl = getWidget(widget.type); + const baseColor = widget.color ?? widgetImpl?.getDefaultColor() ?? 'white'; + const baseBackgroundColor = widget.backgroundColor; + const baseBold = widget.bold ?? false; + + // Apply rule overrides (or use widget's configured properties) + const color = rule.apply.color ? String(rule.apply.color) : baseColor; + const backgroundColor = rule.apply.backgroundColor ? String(rule.apply.backgroundColor) : baseBackgroundColor; + const bold = rule.apply.bold !== undefined ? Boolean(rule.apply.bold) : baseBold; + + // Apply colors (same as ColorMenu does) + const colorLevel = getColorLevelString(settings.colorLevel); + const styledLabel = applyColors(displayName, color, backgroundColor, bold, colorLevel); + + // Selection color: blue in move mode, green otherwise (same as ItemsEditor) + const selectionColor = isSelected ? (moveMode ? 'blue' : 'green') : undefined; + + return ( + + + + {isSelected ? (moveMode ? 'β—† ' : 'β–Ά ') : ' '} + + + {/* In move mode, override styling with selection color */} + {/* In normal mode, show rule's styled label */} + + {index + 1} + . + {moveMode ? displayName : styledLabel} + + + {(() => { + const fullText = ` (${condition})${stopIndicator}${appliedProps}`; + return fullText; + })()} + + + ); + })} + + )} + + + + {rules.length === 0 ? ( + {helpText} + ) : ( + `${rules.length} rule${rules.length === 1 ? '' : 's'}` + )} + + + + ); +}; \ No newline at end of file diff --git a/src/tui/components/StatusLinePreview.tsx b/src/tui/components/StatusLinePreview.tsx index 9db79dad..84e239db 100644 --- a/src/tui/components/StatusLinePreview.tsx +++ b/src/tui/components/StatusLinePreview.tsx @@ -17,6 +17,24 @@ import { } from '../../utils/renderer'; import { advanceGlobalSeparatorIndex } from '../../utils/separator-index'; +/** + * Create mock context data for preview mode that matches widget preview values + * This ensures rules evaluate correctly in preview + */ +function createPreviewContextData(): RenderContext['data'] { + return { + context_window: { + context_window_size: 200000, + total_input_tokens: 18600, // Results in ~9.3% used + total_output_tokens: 0, + current_usage: 18600, + used_percentage: 11.6, // Matches context-percentage-usable preview + remaining_percentage: 88.4 + }, + cost: { total_cost_usd: 2.45 } + }; +} + export interface StatusLinePreviewProps { lines: WidgetItem[][]; terminalWidth: number; @@ -33,12 +51,18 @@ const renderSingleLine = ( preRenderedWidgets: PreRenderedWidget[], preCalculatedMaxWidths: number[] ): RenderResult => { - // Create render context for preview + // Create render context for preview with mock data for rules evaluation const context: RenderContext = { + data: createPreviewContextData(), terminalWidth, isPreview: true, lineIndex, - globalSeparatorIndex + globalSeparatorIndex, + gitData: { + changedFiles: 3, + insertions: 42, + deletions: 18 + } }; return renderStatusLineWithInfo(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths); @@ -52,7 +76,18 @@ export const StatusLinePreview: React.FC = ({ lines, ter return { renderedLines: [], anyTruncated: false }; // Always pre-render all widgets once (for efficiency) - const preRenderedLines = preRenderAllWidgets(lines, settings, { terminalWidth, isPreview: true }); + // Include mock data for rules evaluation in preview + const previewContext: RenderContext = { + data: createPreviewContextData(), + terminalWidth, + isPreview: true, + gitData: { + changedFiles: 3, + insertions: 42, + deletions: 18 + } + }; + const preRenderedLines = preRenderAllWidgets(lines, settings, previewContext); const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings); let globalSeparatorIndex = 0; diff --git a/src/tui/components/color-editor/input-handlers.ts b/src/tui/components/color-editor/input-handlers.ts new file mode 100644 index 00000000..38cadddc --- /dev/null +++ b/src/tui/components/color-editor/input-handlers.ts @@ -0,0 +1,264 @@ +import type { Settings } from '../../../types/Settings'; +import type { WidgetItem } from '../../../types/Widget'; +import { + getAvailableBackgroundColorsForUI, + getAvailableColorsForUI +} from '../../../utils/colors'; +import { + shouldInsertInput, + type InputKey +} from '../../../utils/input-guards'; +import { getWidget } from '../../../utils/widgets'; + +export type { InputKey }; + +export interface ColorEditorState { + editingBackground: boolean; + hexInputMode: boolean; + hexInput: string; + ansi256InputMode: boolean; + ansi256Input: string; +} + +export interface HandleColorInputArgs { + input: string; + key: InputKey; + widget: WidgetItem; + settings: Settings; + state: ColorEditorState; + setState: (updater: (prev: ColorEditorState) => ColorEditorState) => void; + onUpdate: (widget: WidgetItem) => void; + onReset?: () => void; +} + +function cycleColor( + currentColor: string, + colors: string[], + direction: 'left' | 'right' +): string { + if (colors.length === 0) { + return currentColor; + } + + let currentIndex = colors.indexOf(currentColor); + if (currentIndex === -1) { + currentIndex = 0; + } + + const nextIndex = direction === 'right' + ? (currentIndex + 1) % colors.length + : (currentIndex - 1 + colors.length) % colors.length; + + return colors[nextIndex] ?? currentColor; +} + +/** + * Shared color input handler for both ColorMenu and RulesEditor + * Returns true if input was handled, false otherwise + */ +export function handleColorInput({ + input, + key, + widget, + settings, + state, + setState, + onUpdate, + onReset +}: HandleColorInputArgs): boolean { + const { editingBackground, hexInputMode, hexInput, ansi256InputMode, ansi256Input } = state; + + // Handle hex input mode + if (hexInputMode) { + if (key.upArrow || key.downArrow) { + // Disable arrow keys in input mode + return true; + } + + if (key.escape) { + setState(prev => ({ ...prev, hexInputMode: false, hexInput: '' })); + return true; + } + + if (key.return) { + if (hexInput.length === 6) { + const hexColor = `hex:${hexInput}`; + const updatedWidget = editingBackground + ? { ...widget, backgroundColor: hexColor } + : { ...widget, color: hexColor }; + onUpdate(updatedWidget); + setState(prev => ({ ...prev, hexInputMode: false, hexInput: '' })); + } + return true; + } + + if (key.backspace || key.delete) { + setState(prev => ({ ...prev, hexInput: prev.hexInput.slice(0, -1) })); + return true; + } + + if (shouldInsertInput(input, key) && hexInput.length < 6) { + const upperInput = input.toUpperCase(); + if (/^[0-9A-F]$/.test(upperInput)) { + setState(prev => ({ ...prev, hexInput: prev.hexInput + upperInput })); + } + return true; + } + + return true; + } + + // Handle ANSI256 input mode + if (ansi256InputMode) { + if (key.upArrow || key.downArrow) { + // Disable arrow keys in input mode + return true; + } + + if (key.escape) { + setState(prev => ({ ...prev, ansi256InputMode: false, ansi256Input: '' })); + return true; + } + + if (key.return) { + const code = parseInt(ansi256Input, 10); + if (!isNaN(code) && code >= 0 && code <= 255) { + const ansiColor = `ansi256:${code}`; + const updatedWidget = editingBackground + ? { ...widget, backgroundColor: ansiColor } + : { ...widget, color: ansiColor }; + onUpdate(updatedWidget); + setState(prev => ({ ...prev, ansi256InputMode: false, ansi256Input: '' })); + } + return true; + } + + if (key.backspace || key.delete) { + setState(prev => ({ ...prev, ansi256Input: prev.ansi256Input.slice(0, -1) })); + return true; + } + + if (shouldInsertInput(input, key) && ansi256Input.length < 3) { + if (/^[0-9]$/.test(input)) { + const newInput = ansi256Input + input; + const code = parseInt(newInput, 10); + if (code <= 255) { + setState(prev => ({ ...prev, ansi256Input: newInput })); + } + } + return true; + } + + return true; + } + + // Normal color editing mode + if (input === 'h' && settings.colorLevel === 3) { + setState(prev => ({ ...prev, hexInputMode: true, hexInput: '' })); + return true; + } + + if (input === 'a' && settings.colorLevel === 2) { + setState(prev => ({ ...prev, ansi256InputMode: true, ansi256Input: '' })); + return true; + } + + if (input === 'f') { + setState(prev => ({ ...prev, editingBackground: !prev.editingBackground })); + return true; + } + + if (input === 'b') { + onUpdate({ ...widget, bold: !widget.bold }); + return true; + } + + if (input === 'r') { + if (onReset) { + onReset(); + } else { + // Default reset: remove color/backgroundColor/bold + const { color, backgroundColor, bold, ...rest } = widget; + void color; + void backgroundColor; + void bold; + onUpdate(rest); + } + return true; + } + + if (key.leftArrow || key.rightArrow) { + const colors = editingBackground + ? getAvailableBackgroundColorsForUI().map(c => c.value) + : getAvailableColorsForUI().map(c => c.value); + + const widgetImpl = getWidget(widget.type); + const defaultColor = widgetImpl?.getDefaultColor() ?? 'white'; + + const currentColor = editingBackground + ? (widget.backgroundColor ?? '') + : (widget.color ?? defaultColor); + + const nextColor = cycleColor( + currentColor, + colors, + key.rightArrow ? 'right' : 'left' + ); + + const updatedWidget = editingBackground + ? { ...widget, backgroundColor: nextColor === '' ? undefined : nextColor } + : { ...widget, color: nextColor }; + + onUpdate(updatedWidget); + return true; + } + + return false; +} + +/** + * Get current color info for display + */ +export function getCurrentColorInfo( + widget: WidgetItem, + editingBackground: boolean +): { + currentColor: string; + colorIndex: number; + totalColors: number; + displayName: string; +} { + const colorOptions = editingBackground + ? getAvailableBackgroundColorsForUI() + : getAvailableColorsForUI(); + + const colors = colorOptions.map(c => c.value); + const widgetImpl = getWidget(widget.type); + const defaultColor = widgetImpl?.getDefaultColor() ?? 'white'; + + const currentColor = editingBackground + ? (widget.backgroundColor ?? '') + : (widget.color ?? defaultColor); + + const colorIndex = colors.indexOf(currentColor); + + // Determine display name + let displayName: string; + if (!currentColor || currentColor === '') { + displayName = editingBackground ? '(no background)' : '(default)'; + } else if (currentColor.startsWith('ansi256:')) { + displayName = `ANSI ${currentColor.substring(8)}`; + } else if (currentColor.startsWith('hex:')) { + displayName = `#${currentColor.substring(4)}`; + } else { + const option = colorOptions.find(c => c.value === currentColor); + displayName = option ? option.name : currentColor; + } + + return { + currentColor, + colorIndex: colorIndex === -1 ? -1 : colorIndex + 1, // 1-indexed for display + totalColors: colors.length, + displayName + }; +} \ No newline at end of file diff --git a/src/tui/components/items-editor/__tests__/input-handlers.test.ts b/src/tui/components/items-editor/__tests__/input-handlers.test.ts index 160d84ca..1a068043 100644 --- a/src/tui/components/items-editor/__tests__/input-handlers.test.ts +++ b/src/tui/components/items-editor/__tests__/input-handlers.test.ts @@ -167,7 +167,8 @@ describe('items-editor input handlers', () => { setShowClearConfirm: vi.fn(), openWidgetPicker: vi.fn(), getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], - setCustomEditorWidget: vi.fn() + setCustomEditorWidget: vi.fn(), + setRulesEditorWidget: vi.fn() }); const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; @@ -193,7 +194,8 @@ describe('items-editor input handlers', () => { setShowClearConfirm: vi.fn(), openWidgetPicker: vi.fn(), getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], - setCustomEditorWidget: vi.fn() + setCustomEditorWidget: vi.fn(), + setRulesEditorWidget: vi.fn() }); const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; @@ -219,7 +221,8 @@ describe('items-editor input handlers', () => { setShowClearConfirm: vi.fn(), openWidgetPicker: vi.fn(), getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], - setCustomEditorWidget: vi.fn() + setCustomEditorWidget: vi.fn(), + setRulesEditorWidget: vi.fn() }); const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; @@ -245,7 +248,8 @@ describe('items-editor input handlers', () => { setShowClearConfirm: vi.fn(), openWidgetPicker: vi.fn(), getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], - setCustomEditorWidget: vi.fn() + setCustomEditorWidget: vi.fn(), + setRulesEditorWidget: vi.fn() }); const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; @@ -272,7 +276,8 @@ describe('items-editor input handlers', () => { setShowClearConfirm: vi.fn(), openWidgetPicker: vi.fn(), getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], - setCustomEditorWidget + setCustomEditorWidget, + setRulesEditorWidget: vi.fn() }); expect(onUpdate).not.toHaveBeenCalled(); diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index 7dafe493..ff01074d 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -4,12 +4,19 @@ import type { WidgetItem, WidgetItemType } from '../../../types/Widget'; +import type { InputKey } from '../../../utils/input-guards'; +import { + toggleWidgetMerge, + toggleWidgetRawValue +} from '../../../utils/widget-properties'; import { filterWidgetCatalog, getWidget, type WidgetCatalogEntry } from '../../../utils/widgets'; +export type { InputKey }; + export type WidgetPickerAction = 'change' | 'add' | 'insert'; export type WidgetPickerLevel = 'category' | 'widget'; @@ -28,21 +35,6 @@ export interface CustomEditorWidgetState { action?: string; } -export interface InputKey { - ctrl?: boolean; - meta?: boolean; - tab?: boolean; - shift?: boolean; - upArrow?: boolean; - downArrow?: boolean; - leftArrow?: boolean; - rightArrow?: boolean; - return?: boolean; - escape?: boolean; - backspace?: boolean; - delete?: boolean; -} - type Setter = (value: T | ((prev: T) => T)) => void; function setPickerState( @@ -326,6 +318,76 @@ export function handleMoveInputMode({ } } +/** + * Handle widget property editing (raw value, merge, custom keybinds) + * Shared between ItemsEditor and RulesEditor + */ +export interface HandleWidgetPropertyInputArgs { + input: string; + key: InputKey; + widget: WidgetItem; + onUpdate: (updatedWidget: WidgetItem) => void; + getCustomKeybindsForWidget: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[]; + setCustomEditorWidget?: (state: CustomEditorWidgetState | null) => void; +} + +export function handleWidgetPropertyInput({ + input, + key, + widget, + onUpdate, + getCustomKeybindsForWidget, + setCustomEditorWidget +}: HandleWidgetPropertyInputArgs): boolean { + // Handle raw value toggle + if (input === 'r') { + const widgetImpl = getWidget(widget.type); + if (!widgetImpl?.supportsRawValue()) { + return false; + } + onUpdate(toggleWidgetRawValue(widget)); + return true; + } + + // Handle merge toggle + if (input === 'm') { + onUpdate(toggleWidgetMerge(widget)); + return true; + } + + // Handle widget-specific custom keybinds + const widgetImpl = getWidget(widget.type); + if (!widgetImpl?.getCustomKeybinds) { + return false; + } + + const customKeybinds = getCustomKeybindsForWidget(widgetImpl, widget); + const matchedKeybind = customKeybinds.find(kb => kb.key === input); + + if (matchedKeybind && !key.ctrl) { + // Try handleEditorAction first (for widgets that handle actions directly) + if (widgetImpl.handleEditorAction) { + const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, widget); + if (updatedWidget) { + onUpdate(updatedWidget); + return true; + } + } + + // If no handleEditorAction or it didn't return a widget, check for renderEditor + if (widgetImpl.renderEditor && setCustomEditorWidget) { + setCustomEditorWidget({ + widget, + impl: widgetImpl, + action: matchedKeybind.action + }); + return true; + } + } + + return false; +} + export interface HandleNormalInputModeArgs { input: string; key: InputKey; @@ -340,6 +402,7 @@ export interface HandleNormalInputModeArgs { openWidgetPicker: (action: WidgetPickerAction) => void; getCustomKeybindsForWidget: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[]; setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void; + setRulesEditorWidget: (widget: WidgetItem | null) => void; } export function handleNormalInputMode({ @@ -355,7 +418,8 @@ export function handleNormalInputMode({ setShowClearConfirm, openWidgetPicker, getCustomKeybindsForWidget, - setCustomEditorWidget + setCustomEditorWidget, + setRulesEditorWidget }: HandleNormalInputModeArgs): void { if (key.upArrow && widgets.length > 0) { setSelectedIndex(Math.max(0, selectedIndex - 1)); @@ -377,10 +441,8 @@ export function handleNormalInputMode({ if (selectedIndex >= newWidgets.length && selectedIndex > 0) { setSelectedIndex(selectedIndex - 1); } - } else if (input === 'c') { - if (widgets.length > 0) { - setShowClearConfirm(true); - } + } else if (input === 'c' && widgets.length > 0) { + setShowClearConfirm(true); } else if (input === ' ' && widgets.length > 0) { const currentWidget = widgets[selectedIndex]; if (currentWidget?.type === 'separator') { @@ -391,68 +453,34 @@ export function handleNormalInputMode({ newWidgets[selectedIndex] = { ...currentWidget, character: nextChar }; onUpdate(newWidgets); } - } else if (input === 'r' && widgets.length > 0) { + } else if (input === 'x' && widgets.length > 0) { const currentWidget = widgets[selectedIndex]; if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { - const widgetImpl = getWidget(currentWidget.type); - if (!widgetImpl?.supportsRawValue()) { - return; - } - const newWidgets = [...widgets]; - newWidgets[selectedIndex] = { ...currentWidget, rawValue: !currentWidget.rawValue }; - onUpdate(newWidgets); - } - } else if (input === 'm' && widgets.length > 0) { - const currentWidget = widgets[selectedIndex]; - if (currentWidget && selectedIndex < widgets.length - 1 - && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { - const newWidgets = [...widgets]; - let nextMergeState: boolean | 'no-padding' | undefined; - - if (currentWidget.merge === undefined) { - nextMergeState = true; - } else if (currentWidget.merge === true) { - nextMergeState = 'no-padding'; - } else { - nextMergeState = undefined; - } - - if (nextMergeState === undefined) { - const { merge, ...rest } = currentWidget; - void merge; // Intentionally unused - newWidgets[selectedIndex] = rest; - } else { - newWidgets[selectedIndex] = { ...currentWidget, merge: nextMergeState }; - } - onUpdate(newWidgets); + setRulesEditorWidget(currentWidget); } } else if (key.escape) { onBack(); } else if (widgets.length > 0) { + // Try widget property input ('r', 'm', custom keybinds) const currentWidget = widgets[selectedIndex]; if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { - const widgetImpl = getWidget(currentWidget.type); - if (!widgetImpl?.getCustomKeybinds) { + // Special case for merge: only allow if not the last widget + if (input === 'm' && selectedIndex >= widgets.length - 1) { return; } - const customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget); - const matchedKeybind = customKeybinds.find(kb => kb.key === input); - - if (matchedKeybind && !key.ctrl) { - if (widgetImpl.handleEditorAction) { - const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget); - if (updatedWidget) { - const newWidgets = [...widgets]; - newWidgets[selectedIndex] = updatedWidget; - onUpdate(newWidgets); - } else if (widgetImpl.renderEditor) { - setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action }); - } - } else if (widgetImpl.renderEditor) { - setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action }); - } - } + handleWidgetPropertyInput({ + input, + key, + widget: currentWidget, + onUpdate: (updatedWidget) => { + const newWidgets = [...widgets]; + newWidgets[selectedIndex] = updatedWidget; + onUpdate(newWidgets); + }, + getCustomKeybindsForWidget, + setCustomEditorWidget + }); } } } \ No newline at end of file diff --git a/src/types/Condition.ts b/src/types/Condition.ts new file mode 100644 index 00000000..00a42d1f --- /dev/null +++ b/src/types/Condition.ts @@ -0,0 +1,175 @@ +// Operator types +export type NumericOperator + = | 'greaterThan' + | 'lessThan' + | 'equals' + | 'greaterThanOrEqual' + | 'lessThanOrEqual'; + +export type StringOperator + = | 'contains' + | 'startsWith' + | 'endsWith' + | 'equals' + | 'isEmpty'; + +export type BooleanOperator + = | 'isTrue'; + +export type SetOperator + = | 'in' + | 'notIn'; + +export type Operator = NumericOperator | StringOperator | BooleanOperator | SetOperator; + +export const NUMERIC_OPERATORS: NumericOperator[] = [ + 'greaterThan', + 'greaterThanOrEqual', + 'lessThan', + 'lessThanOrEqual', + 'equals' +]; + +export const STRING_OPERATORS: StringOperator[] = [ + 'contains', + 'startsWith', + 'endsWith', + 'equals', + 'isEmpty' +]; + +export const BOOLEAN_OPERATORS: BooleanOperator[] = [ + 'isTrue' +]; + +export const SET_OPERATORS: SetOperator[] = [ + 'in', + 'notIn' +]; + +export const ALL_OPERATORS: Operator[] = [ + ...NUMERIC_OPERATORS, + ...STRING_OPERATORS, + ...BOOLEAN_OPERATORS, + ...SET_OPERATORS +]; + +// Display labels for operators +export const OPERATOR_LABELS: Record = { + // Numeric + equals: 'equals', + greaterThan: 'greater than', + greaterThanOrEqual: 'greater than or equal', + lessThan: 'less than', + lessThanOrEqual: 'less than or equal', + // String + contains: 'contains', + startsWith: 'starts with', + endsWith: 'ends with', + isEmpty: 'is empty', + // Boolean + isTrue: 'is true', + // Set + in: 'in', + notIn: 'not in' +}; + +// Display-only operators - syntactic sugar that maps to base operator + not flag +export type DisplayOperator + = | 'notEquals' // equals + not + | 'notContains' // contains + not + | 'notStartsWith' // startsWith + not + | 'notEndsWith' // endsWith + not + | 'notEmpty' // isEmpty + not + | 'isFalse'; // isTrue: false (special case, not using not flag) + +export const DISPLAY_OPERATOR_LABELS: Record = { + notEquals: 'not equals', + notContains: 'does not contain', + notStartsWith: 'does not start with', + notEndsWith: 'does not end with', + notEmpty: 'is not empty', + isFalse: 'is false' +}; + +// Map display operators to their base operator + not flag +export const DISPLAY_OPERATOR_CONFIG: Record = { + notEquals: { operator: 'equals', not: true }, + notContains: { operator: 'contains', not: true }, + notStartsWith: { operator: 'startsWith', not: true }, + notEndsWith: { operator: 'endsWith', not: true }, + notEmpty: { operator: 'isEmpty', not: true }, + isFalse: { operator: 'isTrue', value: false } // Special: isTrue with value false +}; + +// Get display operator if condition matches a display operator pattern +export function getDisplayOperator(when: Record): DisplayOperator | null { + const operator = getConditionOperator(when); + if (!operator) { + return null; + } + + const notFlag = getConditionNot(when); + const value = when[operator]; + + if (operator === 'equals' && notFlag) + return 'notEquals'; + if (operator === 'contains' && notFlag) + return 'notContains'; + if (operator === 'startsWith' && notFlag) + return 'notStartsWith'; + if (operator === 'endsWith' && notFlag) + return 'notEndsWith'; + if (operator === 'isEmpty' && notFlag) + return 'notEmpty'; + if (operator === 'isTrue' && value === false) + return 'isFalse'; + + return null; +} + +// Helper to get widget reference from condition (defaults to 'self') +export function getConditionWidget(when: Record): string { + const widget = when.widget; + return typeof widget === 'string' ? widget : 'self'; +} + +// Helper to get negation flag from condition (defaults to false) +export function getConditionNot(when: Record): boolean { + const not = when.not; + return typeof not === 'boolean' ? not : false; +} + +// Helper to get operator from condition +export function getConditionOperator(when: Record): Operator | null { + for (const op of ALL_OPERATORS) { + if (op in when) + return op; + } + return null; +} + +// Helper to get value from condition (works for all operator types) +export function getConditionValue(when: Record): number | string | boolean | (string | number)[] | null { + const op = getConditionOperator(when); + if (!op) + return null; + return when[op] as number | string | boolean | (string | number)[] | null; +} + +// Type guards for operators +export function isNumericOperator(op: Operator): op is NumericOperator { + return NUMERIC_OPERATORS.includes(op as NumericOperator); +} + +export function isStringOperator(op: Operator): op is StringOperator { + return STRING_OPERATORS.includes(op as StringOperator); +} + +export function isBooleanOperator(op: Operator): op is BooleanOperator { + return BOOLEAN_OPERATORS.includes(op as BooleanOperator); +} + +export function isSetOperator(op: Operator): op is SetOperator { + return SET_OPERATORS.includes(op as SetOperator); +} \ No newline at end of file diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index 9ba86aea..7d811869 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -32,4 +32,11 @@ export interface RenderContext { isPreview?: boolean; lineIndex?: number; // Index of the current line being rendered (for theme cycling) globalSeparatorIndex?: number; // Global separator index that continues across lines + + // For git widget thresholds + gitData?: { + changedFiles?: number; + insertions?: number; + deletions?: number; + }; } \ No newline at end of file diff --git a/src/types/StatusJSON.ts b/src/types/StatusJSON.ts index 6846cc20..374806da 100644 --- a/src/types/StatusJSON.ts +++ b/src/types/StatusJSON.ts @@ -61,6 +61,13 @@ export const StatusJSONSchema = z.looseObject({ remaining_percentage: CoercedNumberSchema.nullable().optional() }).nullable().optional(), vim: z.object({ mode: z.string().optional() }).nullable().optional(), + worktree: z.object({ + name: z.string().optional(), + path: z.string().optional(), + branch: z.string().optional(), + original_cwd: z.string().optional(), + original_branch: z.string().optional() + }).nullable().optional(), rate_limits: z.object({ five_hour: RateLimitPeriodSchema.optional(), seven_day: RateLimitPeriodSchema.optional() diff --git a/src/types/Widget.ts b/src/types/Widget.ts index fd3e5f83..573d7ad7 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -13,12 +13,19 @@ export const WidgetItemSchema = z.object({ character: z.string().optional(), rawValue: z.boolean().optional(), customText: z.string().optional(), + customSymbol: z.string().optional(), commandPath: z.string().optional(), maxWidth: z.number().optional(), preserveColors: z.boolean().optional(), timeout: z.number().optional(), merge: z.union([z.boolean(), z.literal('no-padding')]).optional(), - metadata: z.record(z.string(), z.string()).optional() + hide: z.boolean().optional(), + metadata: z.record(z.string(), z.string()).optional(), + rules: z.array(z.object({ + when: z.record(z.string(), z.any()), // flexible for now (includes optional 'not' flag) + apply: z.record(z.string(), z.any()), // any widget properties + stop: z.boolean().optional() + })).optional() }); // Inferred types from Zod schemas @@ -42,6 +49,7 @@ export interface Widget { supportsRawValue(): boolean; supportsColors(item: WidgetItem): boolean; handleEditorAction?(action: string, item: WidgetItem): WidgetItem | null; + getNumericValue?(context: RenderContext, item: WidgetItem): number | null; } export interface WidgetEditorProps { diff --git a/src/types/__tests__/Condition.test.ts b/src/types/__tests__/Condition.test.ts new file mode 100644 index 00000000..2be0ec82 --- /dev/null +++ b/src/types/__tests__/Condition.test.ts @@ -0,0 +1,114 @@ +import { + describe, + expect, + test +} from 'vitest'; + +import { + DISPLAY_OPERATOR_LABELS, + OPERATOR_LABELS, + getConditionOperator, + getConditionValue, + getDisplayOperator +} from '../Condition'; + +describe('Condition utilities', () => { + test('getConditionOperator extracts operator', () => { + // Numeric operators + expect(getConditionOperator({ greaterThan: 50 })).toBe('greaterThan'); + expect(getConditionOperator({ lessThan: 100 })).toBe('lessThan'); + expect(getConditionOperator({ equals: 42 })).toBe('equals'); + expect(getConditionOperator({ greaterThanOrEqual: 75 })).toBe('greaterThanOrEqual'); + expect(getConditionOperator({ lessThanOrEqual: 25 })).toBe('lessThanOrEqual'); + + // String operators + expect(getConditionOperator({ contains: 'text' })).toBe('contains'); + expect(getConditionOperator({ startsWith: 'prefix' })).toBe('startsWith'); + expect(getConditionOperator({ endsWith: 'suffix' })).toBe('endsWith'); + expect(getConditionOperator({ equals: 'exact' })).toBe('equals'); + expect(getConditionOperator({ isEmpty: true })).toBe('isEmpty'); + + // Boolean operators + expect(getConditionOperator({ isTrue: true })).toBe('isTrue'); + + // Set operators + expect(getConditionOperator({ in: ['a', 'b'] })).toBe('in'); + expect(getConditionOperator({ notIn: ['x', 'y'] })).toBe('notIn'); + + // No operator + expect(getConditionOperator({})).toBeNull(); + }); + + test('getConditionValue extracts value (supports multiple types)', () => { + // Numeric values + expect(getConditionValue({ greaterThan: 50 })).toBe(50); + expect(getConditionValue({ lessThan: 100.5 })).toBe(100.5); + expect(getConditionValue({ equals: 0 })).toBe(0); + expect(getConditionValue({ greaterThanOrEqual: -10 })).toBe(-10); + expect(getConditionValue({ lessThanOrEqual: 99.99 })).toBe(99.99); + + // String values + expect(getConditionValue({ contains: 'feature/' })).toBe('feature/'); + expect(getConditionValue({ startsWith: 'prefix' })).toBe('prefix'); + + // Boolean values + expect(getConditionValue({ isTrue: true })).toBe(true); + expect(getConditionValue({ isTrue: false })).toBe(false); + + // Array values + expect(getConditionValue({ in: ['a', 'b', 'c'] })).toEqual(['a', 'b', 'c']); + + // No operator + expect(getConditionValue({})).toBeNull(); + }); + + test('all operators have labels', () => { + expect(OPERATOR_LABELS.greaterThan).toBe('greater than'); + expect(OPERATOR_LABELS.greaterThanOrEqual).toBe('greater than or equal'); + expect(OPERATOR_LABELS.lessThan).toBe('less than'); + expect(OPERATOR_LABELS.lessThanOrEqual).toBe('less than or equal'); + expect(OPERATOR_LABELS.equals).toBe('equals'); + }); + + test('handles invalid conditions gracefully', () => { + // Unknown operator + expect(getConditionOperator({ unknown: 50 })).toBeNull(); + expect(getConditionValue({ unknown: 50 })).toBeNull(); + + // Type mismatches are allowed in extraction (validated during evaluation) + expect(getConditionValue({ greaterThan: 'not a number' })).toBe('not a number'); + }); + + test('display operators - detects patterns', () => { + // notEquals: equals + not + expect(getDisplayOperator({ equals: 50, not: true })).toBe('notEquals'); + expect(getDisplayOperator({ equals: 50 })).toBeNull(); + + // notContains: contains + not + expect(getDisplayOperator({ contains: 'feature/', not: true })).toBe('notContains'); + expect(getDisplayOperator({ contains: 'feature/' })).toBeNull(); + + // notStartsWith: startsWith + not + expect(getDisplayOperator({ startsWith: 'prefix', not: true })).toBe('notStartsWith'); + + // notEndsWith: endsWith + not + expect(getDisplayOperator({ endsWith: 'suffix', not: true })).toBe('notEndsWith'); + + // notEmpty: isEmpty + not + expect(getDisplayOperator({ isEmpty: true, not: true })).toBe('notEmpty'); + expect(getDisplayOperator({ isEmpty: true })).toBeNull(); + + // isFalse: isTrue with value false + expect(getDisplayOperator({ isTrue: false })).toBe('isFalse'); + expect(getDisplayOperator({ isTrue: true })).toBeNull(); + }); + + test('display operators have labels', () => { + expect(DISPLAY_OPERATOR_LABELS.notEquals).toBe('not equals'); + expect(DISPLAY_OPERATOR_LABELS.notContains).toBe('does not contain'); + expect(DISPLAY_OPERATOR_LABELS.notStartsWith).toBe('does not start with'); + expect(DISPLAY_OPERATOR_LABELS.notEndsWith).toBe('does not end with'); + expect(DISPLAY_OPERATOR_LABELS.notEmpty).toBe('is not empty'); + expect(DISPLAY_OPERATOR_LABELS.isFalse).toBe('is false'); + }); +}); \ No newline at end of file diff --git a/src/types/__tests__/Widget.test.ts b/src/types/__tests__/Widget.test.ts new file mode 100644 index 00000000..c9e394ce --- /dev/null +++ b/src/types/__tests__/Widget.test.ts @@ -0,0 +1,87 @@ +import { + describe, + expect, + test +} from 'vitest'; + +import { WidgetItemSchema } from '../Widget'; + +describe('WidgetItemSchema', () => { + test('accepts widget without rules', () => { + const widget = { + id: 'test-1', + type: 'model', + color: 'cyan' + }; + + const result = WidgetItemSchema.safeParse(widget); + expect(result.success).toBe(true); + }); + + test('accepts widget with rules array', () => { + const widget = { + id: 'test-2', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 75 }, + apply: { color: 'red', bold: true }, + stop: true + }, + { + when: { greaterThan: 50 }, + apply: { color: 'yellow' }, + stop: true + } + ] + }; + + const result = WidgetItemSchema.safeParse(widget); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.rules).toBeDefined(); + expect(result.data.rules?.length).toBe(2); + expect(result.data.rules?.[0]?.when.greaterThan).toBe(75); + expect(result.data.rules?.[0]?.apply.color).toBe('red'); + expect(result.data.rules?.[0]?.apply.bold).toBe(true); + expect(result.data.rules?.[0]?.stop).toBe(true); + } + }); + + test('accepts widget with empty rules array', () => { + const widget = { + id: 'test-3', + type: 'model', + color: 'cyan', + rules: [] + }; + + const result = WidgetItemSchema.safeParse(widget); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.rules).toBeDefined(); + expect(result.data.rules?.length).toBe(0); + } + }); + + test('accepts rule without stop property', () => { + const widget = { + id: 'test-4', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'yellow' } + } + ] + }; + + const result = WidgetItemSchema.safeParse(widget); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.rules?.[0]?.stop).toBeUndefined(); + } + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/git-remote.test.ts b/src/utils/__tests__/git-remote.test.ts new file mode 100644 index 00000000..55581d09 --- /dev/null +++ b/src/utils/__tests__/git-remote.test.ts @@ -0,0 +1,297 @@ +import { execSync } from 'child_process'; +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { clearGitCache } from '../git'; +import { + buildRepoWebUrl, + getForkStatus, + getRemoteInfo, + listRemotes, + parseRemoteUrl +} from '../git-remote'; + +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +const mockExecSync = execSync as unknown as { + mock: { calls: unknown[][] }; + mockImplementation: (impl: () => never) => void; + mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; +}; + +describe('git-remote utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearGitCache(); + }); + + describe('parseRemoteUrl', () => { + describe('SSH format (git@host:owner/repo)', () => { + it('parses github.com SSH URL', () => { + expect(parseRemoteUrl('git@github.com:owner/repo.git')).toEqual({ + host: 'github.com', + owner: 'owner', + repo: 'repo' + }); + }); + + it('parses GHES SSH URL', () => { + expect(parseRemoteUrl('git@github.service.anz:org/project.git')).toEqual({ + host: 'github.service.anz', + owner: 'org', + repo: 'project' + }); + }); + + it('parses SSH URL without .git suffix', () => { + expect(parseRemoteUrl('git@github.com:owner/repo')).toEqual({ + host: 'github.com', + owner: 'owner', + repo: 'repo' + }); + }); + + it('parses SSH URL with trailing slash', () => { + expect(parseRemoteUrl('git@github.com:owner/repo/')).toEqual({ + host: 'github.com', + owner: 'owner', + repo: 'repo' + }); + }); + + it('parses GitLab SSH URL', () => { + expect(parseRemoteUrl('git@gitlab.com:group/project.git')).toEqual({ + host: 'gitlab.com', + owner: 'group', + repo: 'project' + }); + }); + }); + + describe('HTTPS format', () => { + it('parses github.com HTTPS URL', () => { + expect(parseRemoteUrl('https://github.com/owner/repo.git')).toEqual({ + host: 'github.com', + owner: 'owner', + repo: 'repo' + }); + }); + + it('parses GHES HTTPS URL', () => { + expect(parseRemoteUrl('https://github.service.anz/org/project.git')).toEqual({ + host: 'github.service.anz', + owner: 'org', + repo: 'project' + }); + }); + + it('parses HTTPS URL without .git suffix', () => { + expect(parseRemoteUrl('https://github.com/owner/repo')).toEqual({ + host: 'github.com', + owner: 'owner', + repo: 'repo' + }); + }); + + it('parses HTTP URL', () => { + expect(parseRemoteUrl('http://github.com/owner/repo.git')).toEqual({ + host: 'github.com', + owner: 'owner', + repo: 'repo' + }); + }); + }); + + describe('ssh:// protocol format', () => { + it('parses ssh:// URL', () => { + expect(parseRemoteUrl('ssh://git@github.com/owner/repo.git')).toEqual({ + host: 'github.com', + owner: 'owner', + repo: 'repo' + }); + }); + + it('parses ssh:// URL for GHES', () => { + expect(parseRemoteUrl('ssh://git@github.service.anz/org/project.git')).toEqual({ + host: 'github.service.anz', + owner: 'org', + repo: 'project' + }); + }); + }); + + describe('git:// protocol format', () => { + it('parses git:// URL', () => { + expect(parseRemoteUrl('git://github.com/owner/repo.git')).toEqual({ + host: 'github.com', + owner: 'owner', + repo: 'repo' + }); + }); + }); + + describe('edge cases', () => { + it('returns null for empty string', () => { + expect(parseRemoteUrl('')).toBeNull(); + }); + + it('returns null for whitespace-only string', () => { + expect(parseRemoteUrl(' ')).toBeNull(); + }); + + it('returns null for invalid URL', () => { + expect(parseRemoteUrl('not-a-url')).toBeNull(); + }); + + it('returns null for URL with only owner (no repo)', () => { + expect(parseRemoteUrl('https://github.com/owner')).toBeNull(); + }); + + it('returns null for unsupported protocol', () => { + expect(parseRemoteUrl('ftp://github.com/owner/repo.git')).toBeNull(); + }); + + it('trims whitespace from URL', () => { + expect(parseRemoteUrl(' https://github.com/owner/repo.git ')).toEqual({ + host: 'github.com', + owner: 'owner', + repo: 'repo' + }); + }); + }); + }); + + describe('getRemoteInfo', () => { + it('returns remote info for valid remote', () => { + mockExecSync.mockReturnValue('https://github.com/hangie/ccstatusline.git\n'); + const context: RenderContext = { data: { cwd: '/tmp/repo' } }; + + const result = getRemoteInfo('origin', context); + + expect(result).toEqual({ + name: 'origin', + url: 'https://github.com/hangie/ccstatusline.git', + host: 'github.com', + owner: 'hangie', + repo: 'ccstatusline' + }); + }); + + it('returns null when remote does not exist', () => { + mockExecSync.mockImplementation(() => { throw new Error('No such remote'); }); + + expect(getRemoteInfo('nonexistent', {})).toBeNull(); + }); + + it('returns null when URL cannot be parsed', () => { + mockExecSync.mockReturnValue('invalid-url\n'); + + expect(getRemoteInfo('origin', {})).toBeNull(); + }); + }); + + describe('getForkStatus', () => { + it('detects fork when origin and upstream differ', () => { + mockExecSync.mockReturnValueOnce('https://github.com/hangie/ccstatusline.git\n'); + mockExecSync.mockReturnValueOnce('https://github.com/sirmalloc/ccstatusline.git\n'); + + const result = getForkStatus({}); + + expect(result.isFork).toBe(true); + expect(result.origin?.owner).toBe('hangie'); + expect(result.upstream?.owner).toBe('sirmalloc'); + }); + + it('detects fork when repos have different names', () => { + mockExecSync.mockReturnValueOnce('https://github.com/hangie/my-fork.git\n'); + mockExecSync.mockReturnValueOnce('https://github.com/hangie/original.git\n'); + + const result = getForkStatus({}); + + expect(result.isFork).toBe(true); + }); + + it('returns not a fork when only origin exists', () => { + mockExecSync.mockReturnValueOnce('https://github.com/owner/repo.git\n'); + mockExecSync.mockImplementation(() => { throw new Error('No such remote'); }); + + const result = getForkStatus({}); + + expect(result.isFork).toBe(false); + expect(result.origin).not.toBeNull(); + expect(result.upstream).toBeNull(); + }); + + it('returns not a fork when origin equals upstream', () => { + mockExecSync.mockReturnValueOnce('https://github.com/owner/repo.git\n'); + mockExecSync.mockReturnValueOnce('https://github.com/owner/repo.git\n'); + + const result = getForkStatus({}); + + expect(result.isFork).toBe(false); + }); + + it('returns not a fork when no remotes exist', () => { + mockExecSync.mockImplementation(() => { throw new Error('No such remote'); }); + + const result = getForkStatus({}); + + expect(result.isFork).toBe(false); + expect(result.origin).toBeNull(); + expect(result.upstream).toBeNull(); + }); + }); + + describe('listRemotes', () => { + it('returns list of remote names', () => { + mockExecSync.mockReturnValue('origin\nupstream\n'); + + expect(listRemotes({})).toEqual(['origin', 'upstream']); + }); + + it('returns empty array when no remotes', () => { + mockExecSync.mockImplementation(() => { throw new Error('Not a git repo'); }); + + expect(listRemotes({})).toEqual([]); + }); + + it('filters empty lines', () => { + mockExecSync.mockReturnValue('origin\n\nupstream\n\n'); + + expect(listRemotes({})).toEqual(['origin', 'upstream']); + }); + }); + + describe('buildRepoWebUrl', () => { + it('builds URL for github.com', () => { + const remote = { + name: 'origin', + url: 'git@github.com:owner/repo.git', + host: 'github.com', + owner: 'owner', + repo: 'repo' + }; + + expect(buildRepoWebUrl(remote)).toBe('https://github.com/owner/repo'); + }); + + it('builds URL for GHES', () => { + const remote = { + name: 'origin', + url: 'git@github.service.anz:org/project.git', + host: 'github.service.anz', + owner: 'org', + repo: 'project' + }; + + expect(buildRepoWebUrl(remote)).toBe('https://github.service.anz/org/project'); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index 74ddb314..62e57dc1 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -9,7 +9,9 @@ import { import type { RenderContext } from '../../types/RenderContext'; import { + clearGitCache, getGitChangeCounts, + getGitStatus, isInsideGitWorkTree, resolveGitCwd, runGit @@ -20,13 +22,13 @@ vi.mock('child_process', () => ({ execSync: vi.fn() })); const mockExecSync = execSync as unknown as { mock: { calls: unknown[][] }; mockImplementation: (impl: () => never) => void; - mockReturnValue: (value: string) => void; mockReturnValueOnce: (value: string) => void; }; describe('git utils', () => { beforeEach(() => { vi.clearAllMocks(); + clearGitCache(); }); describe('resolveGitCwd', () => { @@ -83,8 +85,8 @@ describe('git utils', () => { }); describe('runGit', () => { - it('runs git command with resolved cwd and trims output', () => { - mockExecSync.mockReturnValue(' feature/worktree \n'); + it('runs git command with resolved cwd and trims trailing whitespace', () => { + mockExecSync.mockReturnValueOnce('feature/worktree\n'); const context: RenderContext = { data: { cwd: '/tmp/repo' } }; const result = runGit('branch --show-current', context); @@ -99,7 +101,7 @@ describe('git utils', () => { }); it('runs git command without cwd when no context directory exists', () => { - mockExecSync.mockReturnValue('true\n'); + mockExecSync.mockReturnValueOnce('true\n'); const result = runGit('rev-parse --is-inside-work-tree', {}); @@ -119,13 +121,13 @@ describe('git utils', () => { describe('isInsideGitWorkTree', () => { it('returns true when git reports true', () => { - mockExecSync.mockReturnValue('true\n'); + mockExecSync.mockReturnValueOnce('true\n'); expect(isInsideGitWorkTree({})).toBe(true); }); it('returns false when git reports false', () => { - mockExecSync.mockReturnValue('false\n'); + mockExecSync.mockReturnValueOnce('false\n'); expect(isInsideGitWorkTree({})).toBe(false); }); @@ -167,4 +169,193 @@ describe('git utils', () => { }); }); }); + + describe('getGitStatus', () => { + it('returns all false when no git output', () => { + mockExecSync.mockReturnValueOnce(''); + + expect(getGitStatus({})).toEqual({ + staged: false, + unstaged: false, + untracked: false, + conflicts: false + }); + }); + + it('detects staged modification', () => { + mockExecSync.mockReturnValueOnce('M file.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(false); + expect(result.conflicts).toBe(false); + }); + + it('detects unstaged modification', () => { + mockExecSync.mockReturnValueOnce(' M file.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(false); + expect(result.unstaged).toBe(true); + expect(result.conflicts).toBe(false); + }); + + it('detects both staged and unstaged modification', () => { + mockExecSync.mockReturnValueOnce('MM file.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(true); + expect(result.conflicts).toBe(false); + }); + + it('detects unstaged deletion', () => { + mockExecSync.mockReturnValueOnce(' D file.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(false); + expect(result.unstaged).toBe(true); + expect(result.conflicts).toBe(false); + }); + + it('detects staged deletion', () => { + mockExecSync.mockReturnValueOnce('D file.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(false); + expect(result.conflicts).toBe(false); + }); + + it('detects untracked files', () => { + mockExecSync.mockReturnValueOnce('?? newfile.txt'); + + const result = getGitStatus({}); + expect(result.untracked).toBe(true); + expect(result.staged).toBe(false); + expect(result.unstaged).toBe(false); + expect(result.conflicts).toBe(false); + }); + + it('detects merge conflict: both modified (UU)', () => { + mockExecSync.mockReturnValueOnce('UU file.txt'); + + const result = getGitStatus({}); + expect(result.conflicts).toBe(true); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(true); + }); + + it('detects merge conflict: added by us (AU)', () => { + mockExecSync.mockReturnValueOnce('AU file.txt'); + + const result = getGitStatus({}); + expect(result.conflicts).toBe(true); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(true); + }); + + it('detects merge conflict: deleted by us (DU)', () => { + mockExecSync.mockReturnValueOnce('DU file.txt'); + + const result = getGitStatus({}); + expect(result.conflicts).toBe(true); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(true); + }); + + it('detects merge conflict: both added (AA)', () => { + mockExecSync.mockReturnValueOnce('AA file.txt'); + + const result = getGitStatus({}); + expect(result.conflicts).toBe(true); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(true); + }); + + it('detects merge conflict: added by them (UA)', () => { + mockExecSync.mockReturnValueOnce('UA file.txt'); + + const result = getGitStatus({}); + expect(result.conflicts).toBe(true); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(true); + }); + + it('detects merge conflict: deleted by them (UD)', () => { + mockExecSync.mockReturnValueOnce('UD file.txt'); + + const result = getGitStatus({}); + expect(result.conflicts).toBe(true); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(true); + }); + + it('detects merge conflict: both deleted (DD)', () => { + mockExecSync.mockReturnValueOnce('DD file.txt'); + + const result = getGitStatus({}); + expect(result.conflicts).toBe(true); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(true); + }); + + it('detects renamed file in index (staged)', () => { + mockExecSync.mockReturnValueOnce('R oldname.txt -> newname.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(false); + expect(result.conflicts).toBe(false); + }); + + it('detects copied file in index (staged)', () => { + mockExecSync.mockReturnValueOnce('C original.txt -> copy.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(false); + expect(result.conflicts).toBe(false); + }); + + it('detects type changed file in index (staged)', () => { + mockExecSync.mockReturnValueOnce('T file.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(false); + expect(result.conflicts).toBe(false); + }); + + it('detects mixed status with multiple files', () => { + mockExecSync.mockReturnValueOnce('M staged.txt\0 M unstaged.txt\0?? untracked.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(true); + expect(result.untracked).toBe(true); + expect(result.conflicts).toBe(false); + }); + + it('detects mixed status with conflicts', () => { + mockExecSync.mockReturnValueOnce('UU conflict.txt\0M staged.txt\0 M unstaged.txt\0?? untracked.txt'); + + const result = getGitStatus({}); + expect(result.conflicts).toBe(true); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(true); + expect(result.untracked).toBe(true); + }); + + it('handles git command failure', () => { + mockExecSync.mockImplementation(() => { throw new Error('git failed'); }); + + expect(getGitStatus({})).toEqual({ + staged: false, + unstaged: false, + untracked: false, + conflicts: false + }); + }); + }); }); \ No newline at end of file diff --git a/src/utils/__tests__/renderer-rules.test.ts b/src/utils/__tests__/renderer-rules.test.ts new file mode 100644 index 00000000..49500c4d --- /dev/null +++ b/src/utils/__tests__/renderer-rules.test.ts @@ -0,0 +1,234 @@ +import { + describe, + expect, + test, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import * as colorsModule from '../colors'; +import { + preRenderAllWidgets, + renderStatusLine +} from '../renderer'; + +describe('Renderer with Rules', () => { + const baseSettings = { + ...DEFAULT_SETTINGS, + colorLevel: 3 as const, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: false + } + }; + + const createContext = (usedPercentage: number): RenderContext => ({ + data: { + context_window: { + context_window_size: 200000, + total_input_tokens: usedPercentage * 2000, + total_output_tokens: 0, + current_usage: usedPercentage * 2000, + used_percentage: usedPercentage, + remaining_percentage: 100 - usedPercentage + } + }, + terminalWidth: 120, + isPreview: false + }); + + test('preRenderAllWidgets applies rule color when condition matches', () => { + const widget: WidgetItem = { + id: 'test-1', + type: 'context-percentage', + color: 'green', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'red' }, + stop: false + } + ] + }; + + const lines = [[widget]]; + + // 80% should trigger rule + const highContext = createContext(80); + const preRendered = preRenderAllWidgets(lines, baseSettings, highContext); + + // The preRendered widget should have red color applied + expect(preRendered[0]?.[0]?.widget.color).toBe('red'); + }); + + test('preRenderAllWidgets keeps base color when condition does not match', () => { + const widget: WidgetItem = { + id: 'test-1', + type: 'context-percentage', + color: 'green', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'red' }, + stop: false + } + ] + }; + + const lines = [[widget]]; + + // 30% should NOT trigger rule + const lowContext = createContext(30); + const preRendered = preRenderAllWidgets(lines, baseSettings, lowContext); + + // The preRendered widget should keep green color + expect(preRendered[0]?.[0]?.widget.color).toBe('green'); + }); + + test('preRenderAllWidgets applies rule bold override', () => { + const widget: WidgetItem = { + id: 'test-1', + type: 'context-percentage', + color: 'white', + bold: false, + rules: [ + { + when: { greaterThan: 50 }, + apply: { bold: true }, + stop: false + } + ] + }; + + const lines = [[widget]]; + const highContext = createContext(80); + const preRendered = preRenderAllWidgets(lines, baseSettings, highContext); + + expect(preRendered[0]?.[0]?.widget.bold).toBe(true); + }); + + test('preRenderAllWidgets applies rule background color', () => { + const widget: WidgetItem = { + id: 'test-1', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, + apply: { backgroundColor: 'red' }, + stop: false + } + ] + }; + + const lines = [[widget]]; + const highContext = createContext(80); + const preRendered = preRenderAllWidgets(lines, baseSettings, highContext); + + expect(preRendered[0]?.[0]?.widget.backgroundColor).toBe('red'); + }); + + test('multiple rules accumulate properties when stop is false', () => { + const widget: WidgetItem = { + id: 'test-1', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 30 }, + apply: { bold: true }, + stop: false + }, + { + when: { greaterThan: 50 }, + apply: { color: 'red' }, + stop: false + } + ] + }; + + const lines = [[widget]]; + const highContext = createContext(80); + const preRendered = preRenderAllWidgets(lines, baseSettings, highContext); + + // Both rules should apply + expect(preRendered[0]?.[0]?.widget.bold).toBe(true); + expect(preRendered[0]?.[0]?.widget.color).toBe('red'); + }); + + test('renderStatusLine uses rule-applied color, not base widget color', () => { + // This test would FAIL without the fix to use effectiveWidget + const applyColorsSpy = vi.spyOn(colorsModule, 'applyColors'); + + const widget: WidgetItem = { + id: 'test-1', + type: 'context-percentage', + color: 'green', // Base color + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'red' }, // Rule overrides to red + stop: false + } + ] + }; + + const lines = [[widget]]; + const highContext = createContext(80); // 80% > 50%, rule matches + const preRendered = preRenderAllWidgets(lines, baseSettings, highContext); + + // Verify preRender applied the rule + expect(preRendered[0]?.[0]?.widget.color).toBe('red'); + + // Now render - this is where the bug was + renderStatusLine([widget], baseSettings, highContext, preRendered[0] ?? [], []); + + // Find the call that rendered our widget (not separators, etc) + // applyColors is called with (text, fgColor, bgColor, bold, level) + const widgetRenderCall = applyColorsSpy.mock.calls.find( + call => call[0].includes('80.0%') // Our widget content + ); + + expect(widgetRenderCall).toBeDefined(); + // The foreground color (2nd arg) should be 'red' from the rule, NOT 'green' from base + expect(widgetRenderCall?.[1]).toBe('red'); + + applyColorsSpy.mockRestore(); + }); + + test('renderStatusLine uses rule-applied bold, not base widget bold', () => { + const applyColorsSpy = vi.spyOn(colorsModule, 'applyColors'); + + const widget: WidgetItem = { + id: 'test-1', + type: 'context-percentage', + color: 'white', + bold: false, // Base: not bold + rules: [ + { + when: { greaterThan: 50 }, + apply: { bold: true }, // Rule makes it bold + stop: false + } + ] + }; + + const lines = [[widget]]; + const highContext = createContext(80); + const preRendered = preRenderAllWidgets(lines, baseSettings, highContext); + + renderStatusLine([widget], baseSettings, highContext, preRendered[0] ?? [], []); + + const widgetRenderCall = applyColorsSpy.mock.calls.find( + call => call[0].includes('80.0%') + ); + + expect(widgetRenderCall).toBeDefined(); + // The bold flag (4th arg) should be true from the rule + expect(widgetRenderCall?.[3]).toBe(true); + + applyColorsSpy.mockRestore(); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/rules-engine.test.ts b/src/utils/__tests__/rules-engine.test.ts new file mode 100644 index 00000000..94827a2a --- /dev/null +++ b/src/utils/__tests__/rules-engine.test.ts @@ -0,0 +1,1110 @@ +import { execSync } from 'child_process'; +import { + beforeEach, + describe, + expect, + test, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { clearGitCache } from '../git'; +import { applyRules } from '../rules-engine'; + +// Mock child_process +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +const mockExecSync = execSync as unknown as { + mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; + mockImplementation: (impl: () => never) => void; +}; + +describe('Rules Engine', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearGitCache(); + }); + + const mockContext: RenderContext = { + data: { + context_window: { + context_window_size: 200000, + total_input_tokens: 150000, + total_output_tokens: 10000, + current_usage: null, + used_percentage: 80, + remaining_percentage: 20 + } + } + }; + + test('returns original item when no rules', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white' + }; + + const result = applyRules(item, mockContext, [item]); + expect(result).toEqual(item); + }); + + test('applies color override when condition matches', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'yellow' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('yellow'); + }); + + test('keeps original color when condition does not match', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 90 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('white'); + }); + + test('evaluates rules top-to-bottom', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'yellow' } + }, + { + when: { greaterThan: 70 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // Both match (80 > 50 and 80 > 70), but second overrides first (no stop flags) + expect(result.color).toBe('red'); + }); + + test('respects stop flag', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'yellow' }, + stop: true + }, + { + when: { greaterThan: 70 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // First rule matches and stops, second never evaluated + expect(result.color).toBe('yellow'); + }); + + test('applies multiple properties from one rule', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + bold: false, + rules: [ + { + when: { greaterThan: 75 }, + apply: { color: 'red', bold: true } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('red'); + expect(result.bold).toBe(true); + }); + + test('handles widgets without numeric values', () => { + const item = { + id: 'test', + type: 'custom-text', // No numeric value + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // No numeric value, condition can't match + expect(result.color).toBe('white'); + }); + + test('all numeric operators work', () => { + const testCases = [ + { operator: 'greaterThan', value: 70, expected: true }, + { operator: 'greaterThan', value: 90, expected: false }, + { operator: 'greaterThanOrEqual', value: 80, expected: true }, + { operator: 'greaterThanOrEqual', value: 81, expected: false }, + { operator: 'lessThan', value: 90, expected: true }, + { operator: 'lessThan', value: 70, expected: false }, + { operator: 'lessThanOrEqual', value: 80, expected: true }, + { operator: 'lessThanOrEqual', value: 79, expected: false }, + { operator: 'equals', value: 80, expected: true }, + { operator: 'equals', value: 79, expected: false } + ]; + + for (const { operator, value, expected } of testCases) { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { [operator]: value }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe(expected ? 'red' : 'white'); + } + }); + + test('handles empty rules array', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result).toEqual(item); + }); + + test('accumulates properties from multiple matching rules without stop', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + bold: false, + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'yellow' } + }, + { + when: { greaterThan: 60 }, + apply: { bold: true } + }, + { + when: { greaterThan: 70 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // All three match (80 > 50, 80 > 60, 80 > 70) + // Color: white β†’ yellow β†’ red (last override wins) + // Bold: false β†’ true (only set once) + expect(result.color).toBe('red'); + expect(result.bold).toBe(true); + }); + + // Cross-widget condition tests + test('cross-widget condition: references other widget value', () => { + // Mock git commands to return 10 insertions + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('1 file changed, 10 insertions(+)'); + mockExecSync.mockReturnValueOnce(''); + + const gitChangesWidget = { + id: 'git-1', + type: 'git-changes', + color: 'white' + }; + + const contextWidget = { + id: 'context-1', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { widget: 'git-changes', greaterThan: 5 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(contextWidget, mockContext, [gitChangesWidget, contextWidget]); + expect(result.color).toBe('red'); + }); + + test('cross-widget condition: does not match when value too low', () => { + // Mock git commands to return 3 insertions (below threshold of 5) + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('1 file changed, 3 insertions(+)'); + mockExecSync.mockReturnValueOnce(''); + + const gitChangesWidget = { + id: 'git-1', + type: 'git-changes', + color: 'white' + }; + + const contextWidget = { + id: 'context-1', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { widget: 'git-changes', greaterThan: 5 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(contextWidget, mockContext, [gitChangesWidget, contextWidget]); + expect(result.color).toBe('white'); + }); + + test('cross-widget condition: fails gracefully when widget not found', () => { + const contextWidget = { + id: 'context-1', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { widget: 'git-changes', greaterThan: 5 }, + apply: { color: 'red' } + } + ] + }; + + // No git-changes widget in line, and no git data + // git-changes widget should return null for numeric value + const result = applyRules(contextWidget, mockContext, [contextWidget]); + expect(result.color).toBe('white'); // Rule doesn't match (no numeric value) + }); + + test('cross-widget condition: self reference works explicitly', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { widget: 'self', greaterThan: 50 }, + apply: { color: 'yellow' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('yellow'); + }); + + test('cross-widget condition: implicit self reference (no widget property)', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, // No widget property + apply: { color: 'yellow' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('yellow'); + }); + + test('cross-widget condition: evaluates widget not in line', () => { + // Test that rules can reference ANY widget from catalog, not just widgets in the line + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + // Reference tokens-input widget which is NOT in the line + when: { widget: 'tokens-input', greaterThan: 100000 }, + apply: { color: 'yellow' } + } + ] + }; + + // Note: tokens-input widget is NOT in allWidgetsInLine + // Rules engine should create temporary instance and evaluate it + // mockContext has total_input_tokens: 150000 which is > 100000 + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('yellow'); + }); + + test('cross-widget condition: widget not in line returns false when no value', () => { + // Mock git command returning false (not in a git repo) + mockExecSync.mockReturnValueOnce('false\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + // Reference git-changes which is not in line AND has no value in context + when: { widget: 'git-changes', greaterThan: 5 }, + apply: { color: 'red' } + } + ] + }; + + // Context without git data - widget will render "(no git)" which parses to null + const result = applyRules(item, mockContext, [item]); + // Rule should not match (git-changes has no numeric value) + expect(result.color).toBe('white'); + }); + + // String operator tests + describe('String Operators', () => { + test('contains operator matches substring', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('feature/advanced-operators\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { contains: 'feature' }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('green'); + }); + + test('contains operator does not match when substring absent', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('main\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { contains: 'feature' }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + + test('startsWith operator matches prefix', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('feature/test\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { startsWith: 'feature/' }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('green'); + }); + + test('startsWith operator does not match wrong prefix', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('bugfix/test\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { startsWith: 'feature/' }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + + test('endsWith operator matches suffix', () => { + const context: RenderContext = { data: { model: { id: 'claude-opus-4-6' } } }; + + const item = { + id: 'test', + type: 'model', + color: 'white', + rules: [ + { + when: { endsWith: '4-6' }, + apply: { bold: true } + } + ] + }; + + const result = applyRules(item, context, [item]); + expect(result.bold).toBe(true); + }); + + test('endsWith operator does not match wrong suffix', () => { + const context: RenderContext = { data: { model: { id: 'claude-opus-4-6' } } }; + + const item = { + id: 'test', + type: 'model', + color: 'white', + rules: [ + { + when: { endsWith: 'haiku' }, + apply: { bold: true } + } + ] + }; + + const result = applyRules(item, context, [item]); + expect(result.bold).toBeUndefined(); + }); + + test('equals operator matches exact string', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('main\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { equals: 'main' }, + apply: { color: 'cyan' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('cyan'); + }); + + test('equals operator does not match different string', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('develop\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { equals: 'main' }, + apply: { color: 'cyan' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + + test('equals with not flag acts as notEquals for strings', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('develop\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { equals: 'main', not: true }, + apply: { color: 'yellow' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('yellow'); + }); + + test('equals still works for numeric values', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { equals: 80 }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('green'); + }); + + test('isEmpty matches empty string widget', () => { + const item = { + id: 'test', + type: 'custom-text', + customText: '', + color: 'white', + rules: [ + { + when: { isEmpty: true }, + apply: { color: 'gray' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('gray'); + }); + + test('isEmpty does not match non-empty string', () => { + const item = { + id: 'test', + type: 'custom-text', + customText: 'hello', + color: 'white', + rules: [ + { + when: { isEmpty: true }, + apply: { color: 'gray' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + + test('isEmpty matches null widget value', () => { + // custom-text with no customText set renders '' which getWidgetValue returns as null + const item = { + id: 'test', + type: 'custom-text', + color: 'white', + rules: [ + { + when: { isEmpty: true }, + apply: { color: 'gray' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('gray'); + }); + + test('isEmpty with not flag acts as notEmpty', () => { + const item = { + id: 'test', + type: 'custom-text', + customText: 'hello', + color: 'white', + rules: [ + { + when: { isEmpty: true, not: true }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('green'); + }); + + test('notEmpty does not match empty string', () => { + const item = { + id: 'test', + type: 'custom-text', + customText: '', + color: 'white', + rules: [ + { + when: { isEmpty: true, not: true }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + + test('string operators fail on numeric widgets', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { contains: '80' }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // Type mismatch - context-percentage returns number, not string + expect(result.color).toBe('white'); + }); + }); + + // Boolean operator tests + describe('Boolean Operators', () => { + test('isTrue operator matches when widget has changes (numeric to boolean coercion)', () => { + // Mock git commands to return changes + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('1 file changed, 10 insertions(+)'); + mockExecSync.mockReturnValueOnce(''); + + const item = { + id: 'test', + type: 'git-changes', + color: 'white', + rules: [ + { + when: { isTrue: true }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + // git-changes returns 10 (numeric), which is coerced to true (non-zero = true) + expect(result.color).toBe('red'); + }); + + test('isTrue operator works with custom-text returning "true"', () => { + const item = { + id: 'test', + type: 'custom-text', + customText: 'true', + color: 'white', + rules: [ + { + when: { isTrue: true }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('green'); + }); + + test('isTrue operator does not match "false" string', () => { + const item = { + id: 'test', + type: 'custom-text', + customText: 'false', + color: 'white', + rules: [ + { + when: { isTrue: true }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + + test('isTrue with false condition value matches false boolean', () => { + const item = { + id: 'test', + type: 'custom-text', + customText: 'false', + color: 'white', + rules: [ + { + when: { isTrue: false }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('green'); + }); + }); + + // Set operator tests + describe('Set Operators', () => { + test('in operator matches when value in array', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('main\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { in: ['main', 'master', 'develop'] }, + apply: { color: 'cyan', bold: true } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('cyan'); + expect(result.bold).toBe(true); + }); + + test('in operator does not match when value not in array', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('feature/test\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { in: ['main', 'master', 'develop'] }, + apply: { color: 'cyan', bold: true } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + expect(result.bold).toBeUndefined(); + }); + + test('notIn operator matches when value not in array', () => { + const context: RenderContext = { data: { model: { id: 'claude-opus-4-6' } } }; + + const item = { + id: 'test', + type: 'model', + color: 'white', + rules: [ + { + when: { notIn: ['claude-haiku-4-5-20251001'] }, + apply: { bold: true } + } + ] + }; + + const result = applyRules(item, context, [item]); + expect(result.bold).toBe(true); + }); + + test('notIn operator does not match when value in array', () => { + const context: RenderContext = { data: { model: { id: 'claude-opus-4-6' } } }; + + const item = { + id: 'test', + type: 'model', + color: 'white', + rules: [ + { + when: { notIn: ['claude-opus-4-6', 'claude-sonnet-4-6'] }, + apply: { bold: true } + } + ] + }; + + const result = applyRules(item, context, [item]); + expect(result.bold).toBeUndefined(); + }); + + test('in operator works with numeric values', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { in: [60, 70, 80, 90] }, + apply: { color: 'yellow' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('yellow'); + }); + }); + + // Cross-widget with advanced operators + describe('Cross-Widget Advanced Operators', () => { + test('cross-widget string condition', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('urgent/fix-bug\n'); + + const item = { + id: 'context-1', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { widget: 'git-branch', contains: 'urgent' }, + apply: { color: 'red', bold: true } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('red'); + expect(result.bold).toBe(true); + }); + + test('cross-widget set condition', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('main\n'); + + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { widget: 'git-branch', in: ['main', 'master'] }, + apply: { character: 'πŸ”’' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.character).toBe('πŸ”’'); + }); + }); + + // NOT flag tests + describe('NOT Flag (Negation)', () => { + test('NOT with numeric operator inverts result', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 90, not: true }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // Value is 80, which is NOT > 90, so condition matches + expect(result.color).toBe('green'); + }); + + test('NOT with numeric operator when false', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { lessThan: 50, not: true }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // Value is 80, which is NOT < 50, so condition matches + expect(result.color).toBe('red'); + }); + + test('NOT with string operator (notContains)', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('main\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { contains: 'feature/', not: true }, + apply: { color: 'cyan' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + // Branch is "main" which does NOT contain "feature/", so condition matches + expect(result.color).toBe('cyan'); + }); + + test('NOT with startsWith operator', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('bugfix/test\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { startsWith: 'feature/', not: true }, + apply: { color: 'yellow' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + // Branch is "bugfix/test" which does NOT start with "feature/", so condition matches + expect(result.color).toBe('yellow'); + }); + + test('NOT with boolean operator', () => { + // Mock git commands to return no changes + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(''); + mockExecSync.mockReturnValueOnce(''); + + const item = { + id: 'test', + type: 'git-changes', + color: 'white', + rules: [ + { + when: { isTrue: true, not: true }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + // No changes = 0 = false, NOT true = true matches, so condition matches + expect(result.color).toBe('green'); + }); + + test('NOT with set operator (notIn)', () => { + const context: RenderContext = { data: { model: { id: 'claude-opus-4-6' } } }; + + const item = { + id: 'test', + type: 'model', + color: 'white', + rules: [ + { + when: { in: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6'], not: true }, + apply: { bold: true } + } + ] + }; + + const result = applyRules(item, context, [item]); + // Model is NOT in the list, so condition matches + expect(result.bold).toBe(true); + }); + + test('NOT flag with cross-widget condition', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('develop\n'); + + const item = { + id: 'context-1', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { widget: 'git-branch', in: ['main', 'master'], not: true }, + apply: { color: 'yellow' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // Branch is "develop" which is NOT in ["main", "master"], so condition matches + expect(result.color).toBe('yellow'); + }); + + test('Multiple rules with mixed NOT flags', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 90, not: true }, + apply: { color: 'green' } + }, + { + when: { greaterThan: 70 }, + apply: { bold: true } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // First rule: 80 is NOT > 90 = matches, applies green + // Second rule: 80 > 70 = matches, applies bold + expect(result.color).toBe('green'); + expect(result.bold).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/widget-values.test.ts b/src/utils/__tests__/widget-values.test.ts new file mode 100644 index 00000000..0717140c --- /dev/null +++ b/src/utils/__tests__/widget-values.test.ts @@ -0,0 +1,338 @@ +import { execSync } from 'child_process'; +import { + beforeEach, + describe, + expect, + test, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { clearGitCache } from '../git'; +import { + getWidgetBooleanValue, + getWidgetNumericValue, + getWidgetStringValue, + getWidgetValue, + supportsNumericValue +} from '../widget-values'; + +// Mock child_process +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +const mockExecSync = execSync as unknown as { + mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; + mockImplementation: (impl: () => never) => void; +}; + +// Mock git-remote module +const mockGetForkStatus = vi.fn(); +vi.mock('../git-remote', () => ({ getForkStatus: mockGetForkStatus })); + +describe('Widget Values', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearGitCache(); + }); + + test('extracts context-percentage value', () => { + const context: RenderContext = { + data: { + context_window: { + context_window_size: 200000, + total_input_tokens: 150000, + total_output_tokens: 10000, + current_usage: null, + used_percentage: 80, + remaining_percentage: 20 + } + } + }; + + const value = getWidgetNumericValue('context-percentage', context, { id: 'test', type: 'context-percentage' }); + expect(value).toBe(80); + }); + + test('extracts git-changes value', () => { + // Mock git commands: is-inside-work-tree, unstaged diff, staged diff + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('1 file changed, 100 insertions(+)'); + mockExecSync.mockReturnValueOnce(''); + + const context: RenderContext = {}; + + const value = getWidgetNumericValue('git-changes', context, { id: 'test', type: 'git-changes' }); + expect(value).toBe(100); // git-changes returns insertions count + }); + + test('extracts git-insertions value', () => { + // Mock git commands: is-inside-work-tree, unstaged diff, staged diff + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('1 file changed, 100 insertions(+)'); + mockExecSync.mockReturnValueOnce(''); + + const context: RenderContext = {}; + + const value = getWidgetNumericValue('git-insertions', context, { id: 'test', type: 'git-insertions' }); + expect(value).toBe(100); + }); + + test('extracts git-deletions value', () => { + // Mock git commands: is-inside-work-tree, unstaged diff, staged diff + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(''); + mockExecSync.mockReturnValueOnce('1 file changed, 50 deletions(-)'); + + const context: RenderContext = {}; + + const value = getWidgetNumericValue('git-deletions', context, { id: 'test', type: 'git-deletions' }); + expect(value).toBe(-50); // Widget renders "-50", parses to negative number + }); + + test('extracts git-is-fork value when repo is a fork', () => { + const context: RenderContext = {}; + + // Mock getForkStatus to return fork status + mockGetForkStatus.mockReturnValueOnce({ + isFork: true, + origin: null, + upstream: null + }); + + const value = getWidgetNumericValue('git-is-fork', context, { id: 'test', type: 'git-is-fork' }); + expect(value).toBe(1); // Boolean true converted to 1 + }); + + test('extracts git-is-fork value when repo is not a fork', () => { + const context: RenderContext = {}; + + // Mock getForkStatus to return non-fork status + mockGetForkStatus.mockReturnValueOnce({ + isFork: false, + origin: null, + upstream: null + }); + + const value = getWidgetNumericValue('git-is-fork', context, { id: 'test', type: 'git-is-fork' }); + expect(value).toBe(0); // Boolean false converted to 0 + }); + + test('extracts tokens-input value', () => { + const context: RenderContext = { + data: { + context_window: { + context_window_size: 200000, + total_input_tokens: 150000, + total_output_tokens: 10000, + current_usage: null, + used_percentage: null, + remaining_percentage: null + } + } + }; + + const value = getWidgetNumericValue('tokens-input', context, { id: 'test', type: 'tokens-input' }); + expect(value).toBe(150000); + }); + + test('extracts tokens-output value', () => { + const context: RenderContext = { + data: { + context_window: { + context_window_size: 200000, + total_input_tokens: 150000, + total_output_tokens: 10000, + current_usage: null, + used_percentage: null, + remaining_percentage: null + } + } + }; + + const value = getWidgetNumericValue('tokens-output', context, { id: 'test', type: 'tokens-output' }); + expect(value).toBe(10000); + }); + + test('extracts tokens-total value', () => { + const context: RenderContext = { + tokenMetrics: { + inputTokens: 150000, + outputTokens: 10000, + cachedTokens: 0, + totalTokens: 160000, + contextLength: 160000 + } + }; + + const value = getWidgetNumericValue('tokens-total', context, { id: 'test', type: 'tokens-total' }); + expect(value).toBe(160000); + }); + + test('extracts session-cost value', () => { + const context: RenderContext = { data: { cost: { total_cost_usd: 0.45 } } }; + + const value = getWidgetNumericValue('session-cost', context, { id: 'test', type: 'session-cost' }); + expect(value).toBe(0.45); + }); + + test('returns null for unsupported widget types', () => { + const value = getWidgetNumericValue('custom-text', {}, { id: 'test', type: 'custom-text' }); + expect(value).toBeNull(); + }); + + test('returns null when data is missing', () => { + // Mock git command returning false (not in a git repo) + mockExecSync.mockReturnValueOnce('false\n'); + + const value = getWidgetNumericValue('git-changes', {}, { id: 'test', type: 'git-changes' }); + expect(value).toBeNull(); // "(no git)" should parse to null + }); + + test('returns null when context-percentage data missing', () => { + const context: RenderContext = { data: {} }; + const value = getWidgetNumericValue('context-percentage', context, { id: 'test', type: 'context-percentage' }); + expect(value).toBeNull(); + }); + + test('supportsNumericValue identifies supported widgets', () => { + expect(supportsNumericValue('context-percentage')).toBe(true); + expect(supportsNumericValue('context-percentage-usable')).toBe(true); + expect(supportsNumericValue('git-changes')).toBe(true); + expect(supportsNumericValue('git-insertions')).toBe(true); + expect(supportsNumericValue('git-deletions')).toBe(true); + expect(supportsNumericValue('tokens-input')).toBe(true); + expect(supportsNumericValue('tokens-output')).toBe(true); + expect(supportsNumericValue('tokens-cached')).toBe(true); + expect(supportsNumericValue('tokens-total')).toBe(true); + expect(supportsNumericValue('session-cost')).toBe(true); + expect(supportsNumericValue('custom-text')).toBe(true); // All widgets support numeric value extraction (may return null) + expect(supportsNumericValue('unknown-widget')).toBe(false); + }); + + describe('String Value Extraction', () => { + test('extracts git-branch string value', () => { + // Mock git commands: is-inside-work-tree, branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('feature/advanced-operators\n'); + + const context: RenderContext = {}; + const value = getWidgetStringValue('git-branch', context, { id: 'test', type: 'git-branch' }); + expect(value).toBe('feature/advanced-operators'); + }); + + test('extracts model string value', () => { + const context: RenderContext = { data: { model: { id: 'claude-opus-4-6' } } }; + + const value = getWidgetStringValue('model', context, { id: 'test', type: 'model' }); + expect(value).toBe('claude-opus-4-6'); + }); + + test('returns null for empty output', () => { + const value = getWidgetStringValue('custom-text', {}, { id: 'test', type: 'custom-text' }); + expect(value).toBeNull(); + }); + }); + + describe('Boolean Value Extraction', () => { + test('extracts git-changes boolean value (has changes)', () => { + // Mock git commands: is-inside-work-tree, unstaged diff, staged diff + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('1 file changed, 100 insertions(+)'); + mockExecSync.mockReturnValueOnce(''); + + const context: RenderContext = {}; + const value = getWidgetBooleanValue('git-changes', context, { id: 'test', type: 'git-changes' }); + expect(value).toBe(true); + }); + + test('extracts git-changes boolean value (no changes)', () => { + // Mock git commands: is-inside-work-tree, unstaged diff, staged diff + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(''); + mockExecSync.mockReturnValueOnce(''); + + const context: RenderContext = {}; + const value = getWidgetBooleanValue('git-changes', context, { id: 'test', type: 'git-changes' }); + expect(value).toBe(false); + }); + + test('parses "true" string as boolean', () => { + const context: RenderContext = {}; + const value = getWidgetBooleanValue('custom-text', context, { + id: 'test', + type: 'custom-text', + customText: 'true' + }); + expect(value).toBe(true); + }); + + test('parses "false" string as boolean', () => { + const context: RenderContext = {}; + const value = getWidgetBooleanValue('custom-text', context, { + id: 'test', + type: 'custom-text', + customText: 'false' + }); + expect(value).toBe(false); + }); + + test('returns null for non-boolean strings', () => { + const context: RenderContext = { data: { model: { id: 'claude-opus-4-6' } } }; + const value = getWidgetBooleanValue('model', context, { id: 'test', type: 'model' }); + expect(value).toBeNull(); + }); + }); + + describe('Generic Value Extraction', () => { + test('prefers numeric value when available', () => { + const context: RenderContext = { + data: { + context_window: { + context_window_size: 200000, + total_input_tokens: 150000, + total_output_tokens: 10000, + current_usage: null, + used_percentage: 80, + remaining_percentage: 20 + } + } + }; + + const value = getWidgetValue('context-percentage', context, { id: 'test', type: 'context-percentage' }); + expect(value).toBe(80); + expect(typeof value).toBe('number'); + }); + + test('returns numeric value for git-changes', () => { + // Mock git commands: is-inside-work-tree, unstaged diff, staged diff + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('1 file changed, 100 insertions(+)'); + mockExecSync.mockReturnValueOnce(''); + + const context: RenderContext = {}; + const value = getWidgetValue('git-changes', context, { id: 'test', type: 'git-changes' }); + // git-changes primary type is numeric (insertions count) + // It can be used with boolean operators via type coercion + expect(typeof value).toBe('number'); + expect(value).toBe(100); + }); + + test('returns string when numeric and boolean not available', () => { + // Mock git commands: git rev-parse --is-inside-work-tree, git branch --show-current + // Git commands are cached, so only mocked once even though widget renders multiple times + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('main\n'); + + const context: RenderContext = {}; + const value = getWidgetValue('git-branch', context, { id: 'test', type: 'git-branch' }); + expect(value).toBe('main'); + expect(typeof value).toBe('string'); + }); + + test('returns null when no value available', () => { + const value = getWidgetValue('custom-text', {}, { id: 'test', type: 'custom-text' }); + expect(value).toBeNull(); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/git-remote.ts b/src/utils/git-remote.ts new file mode 100644 index 00000000..69c56e0f --- /dev/null +++ b/src/utils/git-remote.ts @@ -0,0 +1,142 @@ +import type { RenderContext } from '../types/RenderContext'; + +import { runGit } from './git'; + +export interface RemoteInfo { + name: string; + url: string; + host: string; + owner: string; + repo: string; +} + +export interface ForkStatus { + isFork: boolean; + origin: RemoteInfo | null; + upstream: RemoteInfo | null; +} + +/** + * Extract owner and repo from a git remote URL. + * Supports SSH, HTTPS, git://, and ssh:// formats. + * Works with any git host (GitHub, GHES, GHEC, GitLab, etc.) + * + * Examples: + * - git@github.com:owner/repo.git + * - https://github.com/owner/repo.git + * - git@github.service.anz:owner/repo.git + * - ssh://git@github.com/owner/repo.git + * - git://github.com/owner/repo + */ +export function parseRemoteUrl(url: string): { host: string; owner: string; repo: string } | null { + const trimmed = url.trim(); + if (trimmed.length === 0) { + return null; + } + + // SSH format: git@host:owner/repo.git or user@host:owner/repo.git + const sshMatch = /^(?:[^@]+@)?([^:]+):([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(trimmed); + if (sshMatch?.[1] && sshMatch[2] && sshMatch[3]) { + return { + host: sshMatch[1], + owner: sshMatch[2], + repo: sshMatch[3] + }; + } + + // URL format: https://host/owner/repo.git, ssh://git@host/owner/repo.git, git://host/owner/repo + try { + const parsedUrl = new URL(trimmed); + const supportedProtocols = new Set(['http:', 'https:', 'ssh:', 'git:']); + + if (!supportedProtocols.has(parsedUrl.protocol)) { + return null; + } + + // Remove leading/trailing slashes and .git suffix + const pathname = parsedUrl.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/, ''); + const segments = pathname.split('/').filter(Boolean); + + const owner = segments[0]; + const repo = segments[1]; + + if (!owner || !repo) { + return null; + } + + return { + host: parsedUrl.hostname, + owner, + repo + }; + } catch { + return null; + } +} + +/** + * Get information about a specific remote. + */ +export function getRemoteInfo(remoteName: string, context: RenderContext): RemoteInfo | null { + const url = runGit(`remote get-url ${remoteName}`, context); + if (!url) { + return null; + } + + const parsed = parseRemoteUrl(url); + if (!parsed) { + return null; + } + + return { + name: remoteName, + url, + host: parsed.host, + owner: parsed.owner, + repo: parsed.repo + }; +} + +/** + * Get fork status by checking origin and upstream remotes. + * A repository is considered a fork if: + * 1. Both origin and upstream remotes exist + * 2. They point to different owner/repo combinations + */ +export function getForkStatus(context: RenderContext): ForkStatus { + const origin = getRemoteInfo('origin', context); + const upstream = getRemoteInfo('upstream', context); + + const isFork = Boolean( + origin + && upstream + && (origin.owner !== upstream.owner || origin.repo !== upstream.repo) + ); + + return { + isFork, + origin, + upstream + }; +} + +/** + * List all remote names. + */ +export function listRemotes(context: RenderContext): string[] { + const output = runGit('remote', context); + if (!output) { + return []; + } + + return output.split('\n').filter(Boolean); +} + +/** + * Build a web URL for a repository on GitHub-like hosts. + * Returns null if the host doesn't appear to be GitHub-like. + */ +export function buildRepoWebUrl(remote: RemoteInfo): string { + // Assume HTTPS for the web URL + return `https://${remote.host}/${remote.owner}/${remote.repo}`; +} \ No newline at end of file diff --git a/src/utils/git.ts b/src/utils/git.ts index e73b9999..d04c669a 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -7,6 +7,9 @@ export interface GitChangeCounts { deletions: number; } +// Cache for git commands - key is "command|cwd" +const gitCommandCache = new Map(); + export function resolveGitCwd(context: RenderContext): string | undefined { const candidates = [ context.data?.cwd, @@ -24,20 +27,37 @@ export function resolveGitCwd(context: RenderContext): string | undefined { } export function runGit(command: string, context: RenderContext): string | null { + const cwd = resolveGitCwd(context); + const cacheKey = `${command}|${cwd ?? ''}`; + + // Check cache first + if (gitCommandCache.has(cacheKey)) { + return gitCommandCache.get(cacheKey) ?? null; + } + try { - const cwd = resolveGitCwd(context); const output = execSync(`git ${command}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], ...(cwd ? { cwd } : {}) - }).trim(); + }).trimEnd(); - return output.length > 0 ? output : null; + const result = output.length > 0 ? output : null; + gitCommandCache.set(cacheKey, result); + return result; } catch { + gitCommandCache.set(cacheKey, null); return null; } } +/** + * Clear git command cache - for testing only + */ +export function clearGitCache(): void { + gitCommandCache.clear(); +} + export function isInsideGitWorkTree(context: RenderContext): boolean { return runGit('rev-parse --is-inside-work-tree', context) === 'true'; } @@ -62,4 +82,82 @@ export function getGitChangeCounts(context: RenderContext): GitChangeCounts { insertions: unstagedCounts.insertions + stagedCounts.insertions, deletions: unstagedCounts.deletions + stagedCounts.deletions }; +} + +export interface GitStatus { + staged: boolean; + unstaged: boolean; + untracked: boolean; + conflicts: boolean; +} + +export function getGitStatus(context: RenderContext): GitStatus { + const output = runGit('--no-optional-locks status --porcelain -z', context); + + if (!output) { + return { staged: false, unstaged: false, untracked: false, conflicts: false }; + } + + let staged = false; + let unstaged = false; + let untracked = false; + let conflicts = false; + + for (const line of output.split('\0')) { + if (line.length < 2) + continue; + // Conflict detection: DD, AU, UD, UA, DU, AA, UU + if (!conflicts && /^(DD|AU|UD|UA|DU|AA|UU)/.test(line)) + conflicts = true; + if (!staged && /^[MADRCTU]/.test(line)) + staged = true; + if (!unstaged && /^.[MADRCTU]/.test(line)) + unstaged = true; + if (!untracked && line.startsWith('??')) + untracked = true; + if (staged && unstaged && untracked && conflicts) + break; + } + + return { staged, unstaged, untracked, conflicts }; +} + +export interface GitAheadBehind { + ahead: number; + behind: number; +} + +export function getGitAheadBehind(context: RenderContext): GitAheadBehind | null { + const output = runGit('rev-list --left-right --count HEAD...@{upstream}', context); + if (!output) + return null; + + const parts = output.split(/\s+/); + if (parts.length !== 2 || !parts[0] || !parts[1]) + return null; + + const ahead = parseInt(parts[0], 10); + const behind = parseInt(parts[1], 10); + + if (isNaN(ahead) || isNaN(behind)) + return null; + + return { ahead, behind }; +} + +export function getGitConflictCount(context: RenderContext): number { + const output = runGit('ls-files --unmerged', context); + if (!output) + return 0; + + // Count unique file paths (unmerged files appear 3 times in output) + const files = new Set(output.split('\n').map((line) => { + const parts = line.split(/\s+/).slice(3); + return parts.join(' '); + }).filter(path => path.length > 0)); + return files.size; +} + +export function getGitShortSha(context: RenderContext): string | null { + return runGit('rev-parse --short HEAD', context); } \ No newline at end of file diff --git a/src/utils/input-guards.ts b/src/utils/input-guards.ts index 9da22726..441d73f5 100644 --- a/src/utils/input-guards.ts +++ b/src/utils/input-guards.ts @@ -5,6 +5,17 @@ export interface InputKeyLike { tab?: boolean; } +export interface InputKey extends InputKeyLike { + upArrow?: boolean; + downArrow?: boolean; + leftArrow?: boolean; + rightArrow?: boolean; + return?: boolean; + escape?: boolean; + backspace?: boolean; + delete?: boolean; +} + const CONTROL_CHAR_REGEX = /[\u0000-\u001F\u007F]/u; export const shouldInsertInput = (input: string, key: InputKeyLike): boolean => { diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index 42815ab5..94ba82ea 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -19,6 +19,7 @@ import { getPowerlineTheme } from './colors'; import { calculateContextPercentage } from './context-percentage'; +import { applyRules } from './rules-engine'; import { getTerminalWidth } from './terminal'; import { getWidget } from './widgets'; @@ -165,13 +166,19 @@ function renderPowerlineStatusLine( } if (widgetText) { + // Use widget from preRendered (has rules applied) instead of original widget + const widgetWithRules = preRendered?.widget ?? widget; + const effectiveColor = widgetWithRules.color ?? defaultColor; + + // Update widget color for use in powerline rendering (use widgetWithRules not widget!) + const coloredWidget = { ...widgetWithRules, color: effectiveColor }; // Apply default padding from settings const padding = settings.defaultPadding ?? ''; // If override FG color is set and this is a custom command with preserveColors, // we need to strip the ANSI codes from the widget text if (settings.overrideForegroundColor && settings.overrideForegroundColor !== 'none' - && widget.type === 'custom-command' && widget.preserveColors) { + && coloredWidget.type === 'custom-command' && coloredWidget.preserveColors) { // Strip ANSI color codes when override is active widgetText = stripSgrCodes(widgetText); } @@ -180,19 +187,19 @@ function renderPowerlineStatusLine( const prevItem = i > 0 ? filteredWidgets[i - 1] : null; const nextItem = i < filteredWidgets.length - 1 ? filteredWidgets[i + 1] : null; const omitLeadingPadding = prevItem?.merge === 'no-padding'; - const omitTrailingPadding = widget.merge === 'no-padding' && nextItem; + const omitTrailingPadding = coloredWidget.merge === 'no-padding' && nextItem; const leadingPadding = omitLeadingPadding ? '' : padding; const trailingPadding = omitTrailingPadding ? '' : padding; const paddedText = `${leadingPadding}${widgetText}${trailingPadding}`; - // Determine colors - let fgColor = widget.color ?? defaultColor; - let bgColor = widget.backgroundColor; + // Determine colors - use effectiveColor from threshold resolution + let fgColor = effectiveColor; + let bgColor = coloredWidget.backgroundColor; // Apply theme colors if a theme is set (and not 'custom') // For custom commands with preserveColors, only skip foreground theme colors - const skipFgTheme = widget.type === 'custom-command' && widget.preserveColors; + const skipFgTheme = coloredWidget.type === 'custom-command' && coloredWidget.preserveColors; if (themeColors) { if (!skipFgTheme) { @@ -202,7 +209,7 @@ function renderPowerlineStatusLine( // Only increment color index if this widget is not merged with the next one // This ensures merged widgets share the same color - if (!widget.merge) { + if (!coloredWidget.merge) { widgetColorIndex++; } } @@ -216,7 +223,7 @@ function renderPowerlineStatusLine( content: paddedText, bgColor: bgColor ?? undefined, // Make sure undefined, not empty string fgColor: fgColor, - widget: widget + widget: coloredWidget }); } } @@ -513,7 +520,16 @@ export function preRenderAllWidgets( continue; } - const widgetText = widgetImpl.render(widget, context, settings) ?? ''; + // Apply rules to widget properties before rendering + // Pass all widgets in the line for cross-widget condition evaluation + const widgetWithRules = applyRules(widget, context, lineWidgets); + + // Skip rendering if widget is hidden + if (widgetWithRules.hide) { + continue; + } + + const widgetText = widgetImpl.render(widgetWithRules, context, settings) ?? ''; // Store the rendered content without padding (padding is applied later) // Use stringWidth to properly calculate Unicode character display width @@ -521,7 +537,7 @@ export function preRenderAllWidgets( preRenderedLine.push({ content: widgetText, plainLength, - widget + widget: widgetWithRules // Use widget with rules applied! }); } @@ -682,9 +698,11 @@ export function renderStatusLine( if (settings.inheritSeparatorColors && i > 0 && !widget.color && !widget.backgroundColor) { // Only inherit if the separator doesn't have explicit colors set - const prevWidget = widgets[i - 1]; + // Use preRendered widget which has rules applied for accurate colors + const prevPreRendered = preRenderedWidgets[i - 1]; + const prevWidget = prevPreRendered?.widget ?? widgets[i - 1]; if (prevWidget && prevWidget.type !== 'separator' && prevWidget.type !== 'flex-separator') { - // Get the previous widget's colors + // Get the previous widget's colors (with rules applied) let widgetColor = prevWidget.color; if (!widgetColor) { const widgetImpl = getWidget(prevWidget.type); @@ -723,24 +741,28 @@ export function renderStatusLine( } if (widgetText) { + // Use widget from preRendered which has rules applied (colors, bold, etc.) + const effectiveWidget = preRendered?.widget ?? widget; + const effectiveColor = effectiveWidget.color ?? defaultColor; + // Special handling for custom-command with preserveColors - if (widget.type === 'custom-command' && widget.preserveColors) { + if (widget.type === 'custom-command' && effectiveWidget.preserveColors) { // Handle max width truncation for commands with ANSI codes let finalOutput = widgetText; - if (widget.maxWidth && widget.maxWidth > 0) { + if (effectiveWidget.maxWidth && effectiveWidget.maxWidth > 0) { const plainLength = getVisibleWidth(widgetText); - if (plainLength > widget.maxWidth) { - finalOutput = truncateStyledText(widgetText, widget.maxWidth, { ellipsis: false }); + if (plainLength > effectiveWidget.maxWidth) { + finalOutput = truncateStyledText(widgetText, effectiveWidget.maxWidth, { ellipsis: false }); } } // Preserve original colors from command output - elements.push({ content: finalOutput, type: widget.type, widget }); + elements.push({ content: finalOutput, type: widget.type, widget: effectiveWidget }); } else { - // Normal widget rendering with colors + // Normal widget rendering with colors - use effectiveWidget which has rules applied elements.push({ - content: applyColorsWithOverride(widgetText, widget.color ?? defaultColor, widget.backgroundColor, widget.bold), + content: applyColorsWithOverride(widgetText, effectiveColor, effectiveWidget.backgroundColor, effectiveWidget.bold), type: widget.type, - widget + widget: effectiveWidget }); } } diff --git a/src/utils/rules-engine.ts b/src/utils/rules-engine.ts new file mode 100644 index 00000000..bfce2bf3 --- /dev/null +++ b/src/utils/rules-engine.ts @@ -0,0 +1,257 @@ +import { + getConditionNot, + getConditionOperator, + getConditionValue, + getConditionWidget, + isBooleanOperator, + isNumericOperator, + isSetOperator, + isStringOperator, + type NumericOperator, + type SetOperator, + type StringOperator +} from '../types/Condition'; +import type { RenderContext } from '../types/RenderContext'; +import type { WidgetItem } from '../types/Widget'; + +import { mergeWidgetWithRuleApply } from './widget-properties'; +import { getWidgetValue } from './widget-values'; + +/** + * Evaluate a numeric condition against a value + */ +function evaluateNumericCondition( + operator: NumericOperator, + widgetValue: number, + conditionValue: number +): boolean { + switch (operator) { + case 'greaterThan': + return widgetValue > conditionValue; + case 'greaterThanOrEqual': + return widgetValue >= conditionValue; + case 'lessThan': + return widgetValue < conditionValue; + case 'lessThanOrEqual': + return widgetValue <= conditionValue; + case 'equals': + return widgetValue === conditionValue; + default: + return false; + } +} + +/** + * Evaluate a string condition against a value + */ +function evaluateStringCondition( + operator: StringOperator, + widgetValue: string, + conditionValue: string +): boolean { + switch (operator) { + case 'contains': + return widgetValue.includes(conditionValue); + case 'startsWith': + return widgetValue.startsWith(conditionValue); + case 'endsWith': + return widgetValue.endsWith(conditionValue); + case 'equals': + return widgetValue === conditionValue; + case 'isEmpty': + return widgetValue === ''; + default: + return false; + } +} + +/** + * Evaluate a boolean condition against a value + * Supports type coercion: numbers and strings can be treated as booleans + */ +function evaluateBooleanCondition( + widgetValue: boolean | number | string, + conditionValue: boolean +): boolean { + // Convert widget value to boolean + let boolValue: boolean; + if (typeof widgetValue === 'boolean') { + boolValue = widgetValue; + } else if (typeof widgetValue === 'number') { + // Standard number-to-boolean conversion: 0 = false, non-zero = true + boolValue = widgetValue !== 0; + } else if (typeof widgetValue === 'string') { + // Parse string "true"/"false" + const lower = widgetValue.toLowerCase(); + if (lower === 'true') { + boolValue = true; + } else if (lower === 'false') { + boolValue = false; + } else { + return false; // String that isn't "true"/"false" can't be evaluated as boolean + } + } else { + return false; + } + + return boolValue === conditionValue; +} + +/** + * Evaluate a set condition against a value + */ +function evaluateSetCondition( + operator: SetOperator, + widgetValue: string | number, + conditionValue: (string | number)[] +): boolean { + switch (operator) { + case 'in': + return conditionValue.includes(widgetValue); + case 'notIn': + return !conditionValue.includes(widgetValue); + default: + return false; + } +} + +/** + * Evaluate a single rule condition + * + * @param when - The condition to evaluate + * @param currentWidget - The widget that owns the rule + * @param allWidgetsInLine - All widgets in the current line (for cross-widget references) + * @param context - The render context + */ +function evaluateCondition( + when: Record, + currentWidget: WidgetItem, + allWidgetsInLine: WidgetItem[], + context: RenderContext +): boolean { + const widgetRef = getConditionWidget(when); + const operator = getConditionOperator(when); + const conditionValue = getConditionValue(when); + const notFlag = getConditionNot(when); + + if (!operator || conditionValue === null) { + return false; // Invalid condition + } + + // Determine which widget to evaluate + let targetWidget: WidgetItem; + if (widgetRef === 'self') { + targetWidget = currentWidget; + } else { + // Find first widget with matching type in the line + const widgetInLine = allWidgetsInLine.find(w => w.type === widgetRef); + + if (widgetInLine) { + targetWidget = widgetInLine; + } else { + // Widget not in line - create temporary instance for evaluation + // This allows rules to reference any widget from the catalog + targetWidget = { + id: 'temp-rule-eval', + type: widgetRef + }; + } + } + + // Get the target widget's value (generic - can be number, string, or boolean) + const widgetValue = getWidgetValue(targetWidget.type, context, targetWidget); + + // isEmpty treats null as empty + if (operator === 'isEmpty') { + const result = widgetValue === null || (typeof widgetValue === 'string' && widgetValue === ''); + return notFlag ? !result : result; + } + + if (widgetValue === null) { + return false; // Widget has no evaluable value + } + + // Route to appropriate evaluation function based on operator type + let result: boolean; + + // equals is shared between numeric and string - route by value types + if (operator === 'equals') { + if (typeof widgetValue === 'string' && typeof conditionValue === 'string') { + result = widgetValue === conditionValue; + } else if (typeof widgetValue === 'number' && typeof conditionValue === 'number') { + result = widgetValue === conditionValue; + } else { + return false; // Type mismatch + } + } else if (isNumericOperator(operator)) { + if (typeof widgetValue !== 'number' || typeof conditionValue !== 'number') { + return false; // Type mismatch + } + result = evaluateNumericCondition(operator, widgetValue, conditionValue); + } else if (isStringOperator(operator)) { + if (typeof widgetValue !== 'string' || typeof conditionValue !== 'string') { + return false; // Type mismatch + } + result = evaluateStringCondition(operator, widgetValue, conditionValue); + } else if (isBooleanOperator(operator)) { + // Boolean operators support type coercion from numbers and strings + if (typeof conditionValue !== 'boolean') { + return false; // Invalid condition value + } + result = evaluateBooleanCondition(widgetValue, conditionValue); + } else if (isSetOperator(operator)) { + if (!Array.isArray(conditionValue)) { + return false; // Invalid condition value + } + if (typeof widgetValue !== 'string' && typeof widgetValue !== 'number') { + return false; // Type mismatch + } + result = evaluateSetCondition(operator, widgetValue, conditionValue); + } else { + return false; // Unknown operator type + } + + // Apply negation if specified + return notFlag ? !result : result; +} + +/** + * Apply rules to a widget and return merged properties + * + * Rules execute top-to-bottom. First matching rule with stop=true halts evaluation. + * Returns the widget item with rule overrides applied. + * + * @param item - The widget to apply rules to + * @param context - The render context + * @param allWidgetsInLine - All widgets in the current line (for cross-widget conditions) + */ +export function applyRules( + item: WidgetItem, + context: RenderContext, + allWidgetsInLine: WidgetItem[] +): WidgetItem { + const rules = item.rules; + if (!rules || rules.length === 0) { + return item; // No rules, return original + } + + // Build merged properties starting with base item + let mergedItem = { ...item }; + + // Evaluate rules top-to-bottom + for (const rule of rules) { + const matches = evaluateCondition(rule.when, item, allWidgetsInLine, context); + + if (matches) { + // Apply this rule's property overrides using deep merge for metadata + mergedItem = mergeWidgetWithRuleApply(mergedItem, rule.apply); + + // Stop if rule has stop flag + if (rule.stop) { + break; + } + } + } + + return mergedItem; +} \ No newline at end of file diff --git a/src/utils/widget-manifest.ts b/src/utils/widget-manifest.ts index 890abd9f..56022fa3 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -25,6 +25,20 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'git-deletions', create: () => new widgets.GitDeletionsWidget() }, { type: 'git-root-dir', create: () => new widgets.GitRootDirWidget() }, { type: 'git-worktree', create: () => new widgets.GitWorktreeWidget() }, + { type: 'git-status', create: () => new widgets.GitStatusWidget() }, + { type: 'git-staged', create: () => new widgets.GitStagedWidget() }, + { type: 'git-unstaged', create: () => new widgets.GitUnstagedWidget() }, + { type: 'git-untracked', create: () => new widgets.GitUntrackedWidget() }, + { type: 'git-ahead-behind', create: () => new widgets.GitAheadBehindWidget() }, + { type: 'git-conflicts', create: () => new widgets.GitConflictsWidget() }, + { type: 'git-sha', create: () => new widgets.GitShaWidget() }, + { type: 'git-origin-owner', create: () => new widgets.GitOriginOwnerWidget() }, + { type: 'git-origin-repo', create: () => new widgets.GitOriginRepoWidget() }, + { type: 'git-origin-owner-repo', create: () => new widgets.GitOriginOwnerRepoWidget() }, + { type: 'git-upstream-owner', create: () => new widgets.GitUpstreamOwnerWidget() }, + { type: 'git-upstream-repo', create: () => new widgets.GitUpstreamRepoWidget() }, + { type: 'git-upstream-owner-repo', create: () => new widgets.GitUpstreamOwnerRepoWidget() }, + { type: 'git-is-fork', create: () => new widgets.GitIsForkWidget() }, { type: 'current-working-dir', create: () => new widgets.CurrentWorkingDirWidget() }, { type: 'tokens-input', create: () => new widgets.TokensInputWidget() }, { type: 'tokens-output', create: () => new widgets.TokensOutputWidget() }, @@ -42,6 +56,7 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'terminal-width', create: () => new widgets.TerminalWidthWidget() }, { type: 'version', create: () => new widgets.VersionWidget() }, { type: 'custom-text', create: () => new widgets.CustomTextWidget() }, + { type: 'custom-symbol', create: () => new widgets.CustomSymbolWidget() }, { type: 'custom-command', create: () => new widgets.CustomCommandWidget() }, { type: 'link', create: () => new widgets.LinkWidget() }, { type: 'claude-session-id', create: () => new widgets.ClaudeSessionIdWidget() }, @@ -54,7 +69,11 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'context-bar', create: () => new widgets.ContextBarWidget() }, { type: 'skills', create: () => new widgets.SkillsWidget() }, { type: 'thinking-effort', create: () => new widgets.ThinkingEffortWidget() }, - { type: 'vim-mode', create: () => new widgets.VimModeWidget() } + { type: 'vim-mode', create: () => new widgets.VimModeWidget() }, + { type: 'worktree-mode', create: () => new widgets.WorktreeModeWidget() }, + { type: 'worktree-name', create: () => new widgets.WorktreeNameWidget() }, + { type: 'worktree-branch', create: () => new widgets.WorktreeBranchWidget() }, + { type: 'worktree-original-branch', create: () => new widgets.WorktreeOriginalBranchWidget() } ]; export const LAYOUT_WIDGET_MANIFEST: LayoutWidgetManifestEntry[] = [ diff --git a/src/utils/widget-properties.ts b/src/utils/widget-properties.ts new file mode 100644 index 00000000..d0bae4c0 --- /dev/null +++ b/src/utils/widget-properties.ts @@ -0,0 +1,176 @@ +/** + * Shared widget property mutation utilities + * + * These functions handle the cycling/toggling of widget properties + * and are used by both ItemsEditor and RulesEditor to ensure consistent behavior. + */ + +import type { WidgetItem } from '../types/Widget'; + +function isStringRecord(value: unknown): value is Record { + return typeof value === 'object' + && value !== null + && Object.values(value).every(entry => typeof entry === 'string'); +} + +/** + * Merge base widget with rule.apply overrides, handling metadata deep merge + * + * IMPORTANT: This is the canonical way to merge widget + rule.apply. + * The shallow spread operator `{ ...widget, ...apply }` loses metadata! + * + * @param widget - Base widget + * @param apply - Rule.apply overrides + * @returns Merged widget with deep-merged metadata + */ +export function mergeWidgetWithRuleApply( + widget: WidgetItem, + apply: Record +): WidgetItem { + const merged = { ...widget, ...apply }; + const applyMetadata = isStringRecord(apply.metadata) ? apply.metadata : undefined; + + // Deep merge metadata to preserve both base widget and rule metadata + if (applyMetadata || widget.metadata) { + merged.metadata = { + ...(widget.metadata ?? {}), + ...(applyMetadata ?? {}) + }; + } + + return merged; +} + +/** + * Cycle merge state through: undefined β†’ true β†’ 'no-padding' β†’ undefined + * + * This is the canonical merge cycling logic used throughout the app. + * + * @param currentMerge - Current merge state + * @returns Next merge state in the cycle + */ +export function cycleMergeState( + currentMerge: boolean | 'no-padding' | undefined +): boolean | 'no-padding' | undefined { + if (currentMerge === undefined) { + return true; + } else if (currentMerge === true) { + return 'no-padding'; + } else { + return undefined; + } +} + +/** + * Apply merge state to a widget, removing the property if undefined + * + * @param widget - Widget to update + * @param mergeState - New merge state (undefined removes the property) + * @returns Updated widget + */ +export function applyMergeState( + widget: WidgetItem, + mergeState: boolean | 'no-padding' | undefined +): WidgetItem { + if (mergeState === undefined) { + const { merge, ...rest } = widget; + void merge; // Intentionally unused + return rest; + } else { + return { ...widget, merge: mergeState }; + } +} + +/** + * Toggle merge state for a widget (used by ItemsEditor) + * + * @param widget - Widget to toggle + * @returns Updated widget + */ +export function toggleWidgetMerge(widget: WidgetItem): WidgetItem { + const nextMergeState = cycleMergeState(widget.merge); + return applyMergeState(widget, nextMergeState); +} + +/** + * Toggle raw value for a widget (used by ItemsEditor) + * + * @param widget - Widget to toggle + * @returns Updated widget + */ +export function toggleWidgetRawValue(widget: WidgetItem): WidgetItem { + return { ...widget, rawValue: !widget.rawValue }; +} + +/** + * Extract property overrides for rule.apply by comparing updated widget to base widget + * + * Used by RulesEditor to determine what should be in rule.apply after widget modifications. + * Only properties that differ from the base widget are included. + * Properties that match the base are removed from the existing apply object. + * + * @param updatedWidget - Widget after modifications + * @param baseWidget - Original widget before modifications + * @param currentApply - Existing rule.apply object to update + * @returns New apply object with only differing properties + */ +/** + * Deep equality check for comparing values + */ +function isEqual(a: unknown, b: unknown): boolean { + if (a === b) + return true; + if (a === null || a === undefined || b === null || b === undefined) + return false; + if (typeof a !== typeof b) + return false; + + // For objects, do deep comparison + if (typeof a === 'object' && typeof b === 'object') { + const aObj = a as Record; + const bObj = b as Record; + + const aKeys = Object.keys(aObj); + const bKeys = Object.keys(bObj); + + if (aKeys.length !== bKeys.length) + return false; + + return aKeys.every(key => isEqual(aObj[key], bObj[key])); + } + + return false; +} + +export function extractWidgetOverrides( + updatedWidget: WidgetItem, + baseWidget: WidgetItem, + currentApply: Record +): Record { + const newApply: Record = {}; + + // Get all keys we need to check: from updatedWidget, baseWidget, and currentApply + const allKeys = new Set([ + ...Object.keys(updatedWidget), + ...Object.keys(baseWidget), + ...Object.keys(currentApply) + ]); + + allKeys.forEach((key) => { + // Skip structural properties that shouldn't be in rule.apply + if (key === 'id' || key === 'type' || key === 'rules') { + return; + } + + const updatedValue = (updatedWidget as Record)[key]; + const baseValue = (baseWidget as Record)[key]; + + // If different from widget base, add to rule.apply (use deep equality) + if (!isEqual(updatedValue, baseValue)) { + newApply[key] = updatedValue; + } + // Otherwise, it matches base, so don't include in apply + }); + + return newApply; +} \ No newline at end of file diff --git a/src/utils/widget-values.ts b/src/utils/widget-values.ts new file mode 100644 index 00000000..0cf3a396 --- /dev/null +++ b/src/utils/widget-values.ts @@ -0,0 +1,220 @@ +import type { RenderContext } from '../types/RenderContext'; +import { DEFAULT_SETTINGS } from '../types/Settings'; +import type { WidgetItem } from '../types/Widget'; + +import { getWidget } from './widgets'; + +/** + * Parse a rendered string to extract numeric value + * Handles: + * 1. Numeric formats: "80%", "150K", "1.5M", "42", "Ctx: 80%", "(+42,-10)" + * 2. Boolean strings: "true" β†’ 1, "false" β†’ 0 + * 3. Empty/null/unparseable: null, "", "(no git)" β†’ null + * + * Does NOT parse strings that are primarily non-numeric: + * - "claude-opus-4-6" β†’ null (identifier, not a number) + * - "main" β†’ null (text, not a number) + * - "feature/test" β†’ null (text, not a number) + */ +function parseNumericValue(text: string | null): number | null { + // Handle null/empty + if (!text) + return null; + + const cleaned = text.trim(); + if (!cleaned) + return null; + + // Try boolean strings first (case-insensitive) + const lower = cleaned.toLowerCase(); + if (lower === 'true') + return 1; + if (lower === 'false') + return 0; + + // Try to extract number with optional suffix (anywhere in string) + // Allow commas/underscores within the number (for formatting), but not at the end + const match = /([+-]?[\d,._]*\d)\s*([KMB%])?/i.exec(cleaned); + if (!match) { + // Not a number, not a boolean - unparseable + return null; + } + + const [fullMatch, numStr, suffix] = match; + if (!numStr) + return null; + + // Check if this looks like an identifier rather than a numeric value + // If there are word characters (letters/numbers) directly adjacent to the matched number + // without clear separators, it's likely an identifier like "opus-4-6" or "v1.2.3" + const beforeMatch = cleaned.substring(0, match.index); + const afterMatch = cleaned.substring(match.index + fullMatch.length); + + // If there are letters immediately before the number (without separator), skip it + // Exception: Allow common prefixes like "Ctx:" or "Cost:" + if (beforeMatch && /[a-zA-Z]$/.test(beforeMatch)) { + // Check if it's a common label pattern (ends with space, colon, or parenthesis) + if (!/[:(\s]$/.test(beforeMatch)) { + return null; + } + } + + // If there are letters or more digits immediately after (without separator), skip it + // This catches "opus-4-6" where we match "4" but there's "-6" after + if (afterMatch && /^[a-zA-Z\d-]/.test(afterMatch)) { + // Exception: Allow trailing closing parenthesis or comma (common in formatted output) + if (!/^[),\s]/.test(afterMatch)) { + return null; + } + } + + // Parse base number (remove commas, handle dots/underscores) + const baseNum = parseFloat(numStr.replace(/[,_]/g, '')); + if (isNaN(baseNum)) + return null; + + // Apply multiplier based on suffix + switch (suffix?.toUpperCase()) { + case 'K': + return baseNum * 1000; + case 'M': + return baseNum * 1000000; + case 'B': + return baseNum * 1000000000; + case '%': + return baseNum; // Keep percentage as-is (80% β†’ 80) + default: + return baseNum; + } +} + +/** + * Extract numeric value from a widget for condition evaluation + * + * Strategy: + * 1. If widget implements getNumericValue(), use that (for precision) + * 2. Otherwise, render the widget and parse the string (default behavior) + */ +export function getWidgetNumericValue( + widgetType: string, + context: RenderContext, + item: WidgetItem +): number | null { + const widget = getWidget(widgetType); + if (!widget) + return null; + + // Try explicit numeric value method first + if (widget.getNumericValue) { + return widget.getNumericValue(context, item); + } + + // Default: render and parse + // Use raw value mode to get numeric data without labels + const rawItem = { ...item, rawValue: true }; + const rendered = widget.render(rawItem, context, DEFAULT_SETTINGS); + const parsed = parseNumericValue(rendered); + + // Debug logging + if (process.env.DEBUG_RULES === 'true') { + console.log(`Widget ${widgetType}: rendered="${rendered}" parsed=${parsed}`); + } + + return parsed; +} + +/** + * Extract string value from a widget for condition evaluation + * + * Renders the widget in raw value mode and returns the text output + */ +export function getWidgetStringValue( + widgetType: string, + context: RenderContext, + item: WidgetItem +): string | null { + const widget = getWidget(widgetType); + if (!widget) + return null; + + // Render in raw value mode to get clean text without labels + const rawItem = { ...item, rawValue: true }; + const rendered = widget.render(rawItem, context, DEFAULT_SETTINGS); + + // Return rendered text (null if empty) + return rendered && rendered.trim() !== '' ? rendered.trim() : null; +} + +/** + * Extract boolean value from a widget for condition evaluation + * + * Handles known boolean widgets and parses boolean strings + */ +export function getWidgetBooleanValue( + widgetType: string, + context: RenderContext, + item: WidgetItem +): boolean | null { + const widget = getWidget(widgetType); + if (!widget) + return null; + + // For git-changes, check if there are any changes + if (widgetType === 'git-changes') { + // Get numeric value and check if > 0 + const numValue = getWidgetNumericValue(widgetType, context, item); + if (numValue !== null) { + return numValue > 0; + } + return null; + } + + // Try to parse as boolean from rendered output + const rawItem = { ...item, rawValue: true }; + const rendered = widget.render(rawItem, context, DEFAULT_SETTINGS); + if (!rendered) + return null; + + const cleaned = rendered.trim().toLowerCase(); + if (cleaned === 'true') + return true; + if (cleaned === 'false') + return false; + + // Not a boolean value + return null; +} + +/** + * Generic value extraction - returns the widget's primary value type + * + * This is the main entry point for rule condition evaluation. + * Priority order: + * 1. Numeric - for numbers, percentages, counts (most common) + * 2. String - for text values + * + * Note: Boolean conversion happens in condition evaluation, not here. + * Numbers can be treated as booleans (0=false, non-zero=true) by the evaluator. + */ +export function getWidgetValue( + widgetType: string, + context: RenderContext, + item: WidgetItem +): number | string | boolean | null { + // Try numeric first (most widgets have numeric values) + const numericValue = getWidgetNumericValue(widgetType, context, item); + if (numericValue !== null) + return numericValue; + + // Fall back to string + return getWidgetStringValue(widgetType, context, item); +} + +/** + * Check if a widget type supports numeric value extraction + */ +export function supportsNumericValue(widgetType: string): boolean { + const widget = getWidget(widgetType); + // All widgets support it via default parsing behavior + return !!widget; +} \ No newline at end of file diff --git a/src/widgets/CustomSymbol.tsx b/src/widgets/CustomSymbol.tsx new file mode 100644 index 00000000..47809b05 --- /dev/null +++ b/src/widgets/CustomSymbol.tsx @@ -0,0 +1,97 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetEditorProps, + WidgetItem +} from '../types/Widget'; +import { shouldInsertInput } from '../utils/input-guards'; + +export class CustomSymbolWidget implements Widget { + getDefaultColor(): string { return 'white'; } + getDescription(): string { return 'Displays a custom symbol or emoji (single character)'; } + getDisplayName(): string { return 'Custom Symbol'; } + getCategory(): string { return 'Custom'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const symbol = item.customSymbol ?? '?'; + return { displayText: `${this.getDisplayName()} (${symbol})` }; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + return item.customSymbol ?? ''; + } + + getCustomKeybinds(): CustomKeybind[] { + return [{ + key: 'e', + label: '(e)dit symbol', + action: 'edit-symbol' + }]; + } + + renderEditor(props: WidgetEditorProps): React.ReactElement { + return ; + } + + supportsRawValue(): boolean { return false; } + supportsColors(item: WidgetItem): boolean { return true; } +} + +const CustomSymbolEditor: React.FC = ({ widget, onComplete, onCancel }) => { + const [symbol, setSymbol] = useState(widget.customSymbol ?? ''); + + // Helper to get grapheme segments if Intl.Segmenter is available + const getFirstGrapheme = (str: string): string => { + if (str.length === 0) { + return ''; + } + + if ('Segmenter' in Intl) { + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + const segments = Array.from(segmenter.segment(str)); + return segments[0]?.segment ?? ''; + } + + // Fallback: just take first character + return Array.from(str)[0] ?? ''; + }; + + useInput((input, key) => { + if (key.return) { + onComplete({ ...widget, customSymbol: symbol }); + } else if (key.escape) { + onCancel(); + } else if (key.backspace || key.delete) { + setSymbol(''); + } else if (shouldInsertInput(input, key)) { + // Take only the first grapheme (handles multi-byte emojis correctly) + const firstGrapheme = getFirstGrapheme(input); + setSymbol(firstGrapheme); + } + }); + + return ( + + + Enter custom symbol: + {' '} + {symbol ? ( + {symbol} + ) : ( + (empty) + )} + + Type any character or emoji, Backspace clear, Enter save, ESC cancel + + ); +}; \ No newline at end of file diff --git a/src/widgets/GitAheadBehind.ts b/src/widgets/GitAheadBehind.ts new file mode 100644 index 00000000..ac5b5d59 --- /dev/null +++ b/src/widgets/GitAheadBehind.ts @@ -0,0 +1,96 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getGitAheadBehind, + isInsideGitWorkTree +} from '../utils/git'; + +import { makeModifierText } from './shared/editor-display'; +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +export class GitAheadBehindWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows commits ahead/behind upstream (↑2↓3)'; } + getDisplayName(): string { return 'Git Ahead/Behind'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const modifiers: string[] = []; + const noGitText = getHideNoGitModifierText(item); + if (noGitText) + modifiers.push('hide \'no git\''); + + return { + displayText: this.getDisplayName(), + modifierText: makeModifierText(modifiers) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + if (item.rawValue) + return '2,3'; + return '↑2↓3'; + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const result = getGitAheadBehind(context); + if (!result) { + return hideNoGit ? null : '(no upstream)'; + } + + // Hide if both are zero + if (result.ahead === 0 && result.behind === 0) { + return null; + } + + if (item.rawValue) { + return `${result.ahead},${result.behind}`; + } + + const parts: string[] = []; + if (result.ahead > 0) + parts.push(`↑${result.ahead}`); + if (result.behind > 0) + parts.push(`↓${result.behind}`); + + return parts.join(''); + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + getNumericValue(context: RenderContext, _item: WidgetItem): number | null { + if (!isInsideGitWorkTree(context)) + return null; + const result = getGitAheadBehind(context); + if (!result) + return null; + // Return total divergence (ahead + behind) + return result.ahead + result.behind; + } + + supportsRawValue(): boolean { return true; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitConflicts.ts b/src/widgets/GitConflicts.ts new file mode 100644 index 00000000..cf80983f --- /dev/null +++ b/src/widgets/GitConflicts.ts @@ -0,0 +1,83 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getGitConflictCount, + isInsideGitWorkTree +} from '../utils/git'; + +import { makeModifierText } from './shared/editor-display'; +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +export class GitConflictsWidget implements Widget { + getDefaultColor(): string { return 'red'; } + getDescription(): string { return 'Shows count of merge conflicts'; } + getDisplayName(): string { return 'Git Conflicts'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const modifiers: string[] = []; + const noGitText = getHideNoGitModifierText(item); + if (noGitText) + modifiers.push('hide \'no git\''); + + return { + displayText: this.getDisplayName(), + modifierText: makeModifierText(modifiers) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + if (item.rawValue) + return 'true'; + return '⚠ 2'; + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const count = getGitConflictCount(context); + + if (count === 0) { + return null; + } + + if (item.rawValue) { + return 'true'; + } + + return `⚠ ${count}`; + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + getNumericValue(context: RenderContext, _item: WidgetItem): number | null { + if (!isInsideGitWorkTree(context)) + return null; + const count = getGitConflictCount(context); + return count > 0 ? 1 : 0; + } + + supportsRawValue(): boolean { return true; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitIsFork.ts b/src/widgets/GitIsFork.ts new file mode 100644 index 00000000..6f0b1cdd --- /dev/null +++ b/src/widgets/GitIsFork.ts @@ -0,0 +1,76 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { getForkStatus } from '../utils/git-remote'; + +import { makeModifierText } from './shared/editor-display'; +import { + isMetadataFlagEnabled, + toggleMetadataFlag +} from './shared/metadata'; + +const HIDE_WHEN_NOT_FORK_KEY = 'hideWhenNotFork'; +const TOGGLE_HIDE_ACTION = 'toggle-hide'; + +export class GitIsForkWidget implements Widget { + getDefaultColor(): string { return 'yellow'; } + getDescription(): string { return 'Shows fork indicator when repo is a fork of upstream'; } + getDisplayName(): string { return 'Git Is Fork'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const modifiers: string[] = []; + + if (isMetadataFlagEnabled(item, HIDE_WHEN_NOT_FORK_KEY)) { + modifiers.push('hide when not fork'); + } + + return { + displayText: this.getDisplayName(), + modifierText: makeModifierText(modifiers) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === TOGGLE_HIDE_ACTION) { + return toggleMetadataFlag(item, HIDE_WHEN_NOT_FORK_KEY); + } + + return null; + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideWhenNotFork = isMetadataFlagEnabled(item, HIDE_WHEN_NOT_FORK_KEY); + + if (context.isPreview) { + return item.rawValue ? 'true' : 'isFork: true'; + } + + const forkStatus = getForkStatus(context); + + if (forkStatus.isFork) { + return item.rawValue ? 'true' : 'isFork: true'; + } + + // Not a fork + if (hideWhenNotFork) { + return null; + } + + return item.rawValue ? 'false' : 'isFork: false'; + } + + getCustomKeybinds(): CustomKeybind[] { + return [ + { key: 'h', label: '(h)ide when not fork', action: TOGGLE_HIDE_ACTION } + ]; + } + + supportsRawValue(): boolean { return true; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitOriginOwner.ts b/src/widgets/GitOriginOwner.ts new file mode 100644 index 00000000..7ed7200b --- /dev/null +++ b/src/widgets/GitOriginOwner.ts @@ -0,0 +1,70 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + buildRepoWebUrl, + getRemoteInfo +} from '../utils/git-remote'; +import { renderOsc8Link } from '../utils/hyperlink'; + +import { + getRemoteWidgetKeybinds, + getRemoteWidgetModifierText, + handleRemoteWidgetAction, + isHideNoRemoteEnabled, + isLinkToRepoEnabled +} from './shared/git-remote'; + +export class GitOriginOwnerWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows the origin remote owner/organization'; } + getDisplayName(): string { return 'Git Origin Owner'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getRemoteWidgetModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleRemoteWidgetAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideWhenEmpty = isHideNoRemoteEnabled(item); + const linkEnabled = isLinkToRepoEnabled(item); + + if (context.isPreview) { + const text = 'owner'; + return linkEnabled ? renderOsc8Link('https://github.com/owner/repo', text) : text; + } + + const origin = getRemoteInfo('origin', context); + if (!origin) { + return hideWhenEmpty ? null : 'no remote'; + } + + const text = origin.owner; + + if (linkEnabled) { + const url = buildRepoWebUrl(origin); + return renderOsc8Link(url, text); + } + + return text; + } + + getCustomKeybinds(): CustomKeybind[] { + return getRemoteWidgetKeybinds(); + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitOriginOwnerRepo.ts b/src/widgets/GitOriginOwnerRepo.ts new file mode 100644 index 00000000..0c0cf72e --- /dev/null +++ b/src/widgets/GitOriginOwnerRepo.ts @@ -0,0 +1,99 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + buildRepoWebUrl, + getForkStatus, + getRemoteInfo +} from '../utils/git-remote'; +import { renderOsc8Link } from '../utils/hyperlink'; + +import { makeModifierText } from './shared/editor-display'; +import { + getRemoteWidgetKeybinds, + handleRemoteWidgetAction, + isHideNoRemoteEnabled, + isLinkToRepoEnabled +} from './shared/git-remote'; +import { + isMetadataFlagEnabled, + toggleMetadataFlag +} from './shared/metadata'; + +const OWNER_ONLY_WHEN_FORK_KEY = 'ownerOnlyWhenFork'; +const TOGGLE_OWNER_ONLY_ACTION = 'toggle-owner-only'; + +export class GitOriginOwnerRepoWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows the origin remote as owner/repo'; } + getDisplayName(): string { return 'Git Origin Owner/Repo'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const modifiers: string[] = []; + + if (isHideNoRemoteEnabled(item)) { + modifiers.push('hide when empty'); + } + if (isLinkToRepoEnabled(item)) { + modifiers.push('link'); + } + if (isMetadataFlagEnabled(item, OWNER_ONLY_WHEN_FORK_KEY)) { + modifiers.push('owner only when fork'); + } + + return { + displayText: this.getDisplayName(), + modifierText: makeModifierText(modifiers) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === TOGGLE_OWNER_ONLY_ACTION) { + return toggleMetadataFlag(item, OWNER_ONLY_WHEN_FORK_KEY); + } + + return handleRemoteWidgetAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideWhenEmpty = isHideNoRemoteEnabled(item); + const linkEnabled = isLinkToRepoEnabled(item); + const ownerOnlyWhenFork = isMetadataFlagEnabled(item, OWNER_ONLY_WHEN_FORK_KEY); + + if (context.isPreview) { + const text = ownerOnlyWhenFork ? 'owner' : 'owner/repo'; + return linkEnabled ? renderOsc8Link('https://github.com/owner/repo', text) : text; + } + + const origin = getRemoteInfo('origin', context); + if (!origin) { + return hideWhenEmpty ? null : 'no remote'; + } + + const isFork = ownerOnlyWhenFork && getForkStatus(context).isFork; + const text = isFork ? origin.owner : `${origin.owner}/${origin.repo}`; + + if (linkEnabled) { + const url = buildRepoWebUrl(origin); + return renderOsc8Link(url, text); + } + + return text; + } + + getCustomKeybinds(): CustomKeybind[] { + return [ + ...getRemoteWidgetKeybinds(), + { key: 'o', label: '(o)wner only when fork', action: TOGGLE_OWNER_ONLY_ACTION } + ]; + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitOriginRepo.ts b/src/widgets/GitOriginRepo.ts new file mode 100644 index 00000000..502eb3dd --- /dev/null +++ b/src/widgets/GitOriginRepo.ts @@ -0,0 +1,70 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + buildRepoWebUrl, + getRemoteInfo +} from '../utils/git-remote'; +import { renderOsc8Link } from '../utils/hyperlink'; + +import { + getRemoteWidgetKeybinds, + getRemoteWidgetModifierText, + handleRemoteWidgetAction, + isHideNoRemoteEnabled, + isLinkToRepoEnabled +} from './shared/git-remote'; + +export class GitOriginRepoWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows the origin remote repository name'; } + getDisplayName(): string { return 'Git Origin Repo'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getRemoteWidgetModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleRemoteWidgetAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideWhenEmpty = isHideNoRemoteEnabled(item); + const linkEnabled = isLinkToRepoEnabled(item); + + if (context.isPreview) { + const text = 'repo'; + return linkEnabled ? renderOsc8Link('https://github.com/owner/repo', text) : text; + } + + const origin = getRemoteInfo('origin', context); + if (!origin) { + return hideWhenEmpty ? null : 'no remote'; + } + + const text = origin.repo; + + if (linkEnabled) { + const url = buildRepoWebUrl(origin); + return renderOsc8Link(url, text); + } + + return text; + } + + getCustomKeybinds(): CustomKeybind[] { + return getRemoteWidgetKeybinds(); + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitSha.ts b/src/widgets/GitSha.ts new file mode 100644 index 00000000..863265ed --- /dev/null +++ b/src/widgets/GitSha.ts @@ -0,0 +1,59 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getGitShortSha, + isInsideGitWorkTree +} from '../utils/git'; + +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +export class GitShaWidget implements Widget { + getDefaultColor(): string { return 'gray'; } + getDescription(): string { return 'Shows short commit hash (SHA)'; } + getDisplayName(): string { return 'Git SHA'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getHideNoGitModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + return 'a1b2c3d'; + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const sha = getGitShortSha(context); + return sha ?? (hideNoGit ? null : '(no commit)'); + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitStaged.ts b/src/widgets/GitStaged.ts new file mode 100644 index 00000000..9d0146b5 --- /dev/null +++ b/src/widgets/GitStaged.ts @@ -0,0 +1,79 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getGitStatus, + isInsideGitWorkTree +} from '../utils/git'; + +import { makeModifierText } from './shared/editor-display'; +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +const DEFAULT_SYMBOL = '+'; + +export class GitStagedWidget implements Widget { + getDefaultColor(): string { return 'green'; } + getDescription(): string { return 'Shows + when there are staged changes'; } + getDisplayName(): string { return 'Git Staged'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const modifiers: string[] = []; + const noGitText = getHideNoGitModifierText(item); + if (noGitText) + modifiers.push('hide \'no git\''); + + return { + displayText: this.getDisplayName(), + modifierText: makeModifierText(modifiers) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL); + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const status = getGitStatus(context); + + if (!status.staged) { + return null; + } + + return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL); + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + getNumericValue(context: RenderContext, _item: WidgetItem): number | null { + if (!isInsideGitWorkTree(context)) + return null; + const status = getGitStatus(context); + return status.staged ? 1 : 0; + } + + supportsRawValue(): boolean { return true; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitStatus.ts b/src/widgets/GitStatus.ts new file mode 100644 index 00000000..dd562f87 --- /dev/null +++ b/src/widgets/GitStatus.ts @@ -0,0 +1,85 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getGitStatus, + isInsideGitWorkTree +} from '../utils/git'; + +import { makeModifierText } from './shared/editor-display'; +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +export class GitStatusWidget implements Widget { + getDefaultColor(): string { return 'yellow'; } + getDescription(): string { return 'Shows git status indicators: + staged, * unstaged, ? untracked, ! conflicts'; } + getDisplayName(): string { return 'Git Status'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const modifiers: string[] = []; + const noGitText = getHideNoGitModifierText(item); + if (noGitText) + modifiers.push('hide \'no git\''); + + return { + displayText: this.getDisplayName(), + modifierText: makeModifierText(modifiers) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + return this.formatStatus(item, { staged: true, unstaged: true, untracked: false, conflicts: false }); + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const status = getGitStatus(context); + + // Hide if clean + if (!status.staged && !status.unstaged && !status.untracked && !status.conflicts) { + return null; + } + + return this.formatStatus(item, status); + } + + private formatStatus(_item: WidgetItem, status: { staged: boolean; unstaged: boolean; untracked: boolean; conflicts: boolean }): string { + const parts: string[] = []; + if (status.conflicts) + parts.push('!'); + if (status.staged) + parts.push('+'); + if (status.unstaged) + parts.push('*'); + if (status.untracked) + parts.push('?'); + + return parts.join(''); + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitUnstaged.ts b/src/widgets/GitUnstaged.ts new file mode 100644 index 00000000..fb1cf651 --- /dev/null +++ b/src/widgets/GitUnstaged.ts @@ -0,0 +1,79 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getGitStatus, + isInsideGitWorkTree +} from '../utils/git'; + +import { makeModifierText } from './shared/editor-display'; +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +const DEFAULT_SYMBOL = '*'; + +export class GitUnstagedWidget implements Widget { + getDefaultColor(): string { return 'yellow'; } + getDescription(): string { return 'Shows * when there are unstaged changes'; } + getDisplayName(): string { return 'Git Unstaged'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const modifiers: string[] = []; + const noGitText = getHideNoGitModifierText(item); + if (noGitText) + modifiers.push('hide \'no git\''); + + return { + displayText: this.getDisplayName(), + modifierText: makeModifierText(modifiers) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL); + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const status = getGitStatus(context); + + if (!status.unstaged) { + return null; + } + + return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL); + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + getNumericValue(context: RenderContext, _item: WidgetItem): number | null { + if (!isInsideGitWorkTree(context)) + return null; + const status = getGitStatus(context); + return status.unstaged ? 1 : 0; + } + + supportsRawValue(): boolean { return true; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitUntracked.ts b/src/widgets/GitUntracked.ts new file mode 100644 index 00000000..40dc0b0a --- /dev/null +++ b/src/widgets/GitUntracked.ts @@ -0,0 +1,79 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getGitStatus, + isInsideGitWorkTree +} from '../utils/git'; + +import { makeModifierText } from './shared/editor-display'; +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +const DEFAULT_SYMBOL = '?'; + +export class GitUntrackedWidget implements Widget { + getDefaultColor(): string { return 'red'; } + getDescription(): string { return 'Shows ? when there are untracked files'; } + getDisplayName(): string { return 'Git Untracked'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const modifiers: string[] = []; + const noGitText = getHideNoGitModifierText(item); + if (noGitText) + modifiers.push('hide \'no git\''); + + return { + displayText: this.getDisplayName(), + modifierText: makeModifierText(modifiers) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL); + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const status = getGitStatus(context); + + if (!status.untracked) { + return null; + } + + return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL); + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + getNumericValue(context: RenderContext, _item: WidgetItem): number | null { + if (!isInsideGitWorkTree(context)) + return null; + const status = getGitStatus(context); + return status.untracked ? 1 : 0; + } + + supportsRawValue(): boolean { return true; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitUpstreamOwner.ts b/src/widgets/GitUpstreamOwner.ts new file mode 100644 index 00000000..e206a6b7 --- /dev/null +++ b/src/widgets/GitUpstreamOwner.ts @@ -0,0 +1,70 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + buildRepoWebUrl, + getRemoteInfo +} from '../utils/git-remote'; +import { renderOsc8Link } from '../utils/hyperlink'; + +import { + getRemoteWidgetKeybinds, + getRemoteWidgetModifierText, + handleRemoteWidgetAction, + isHideNoRemoteEnabled, + isLinkToRepoEnabled +} from './shared/git-remote'; + +export class GitUpstreamOwnerWidget implements Widget { + getDefaultColor(): string { return 'magenta'; } + getDescription(): string { return 'Shows the upstream remote owner/organization'; } + getDisplayName(): string { return 'Git Upstream Owner'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getRemoteWidgetModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleRemoteWidgetAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideWhenEmpty = isHideNoRemoteEnabled(item); + const linkEnabled = isLinkToRepoEnabled(item); + + if (context.isPreview) { + const text = 'upstream-owner'; + return linkEnabled ? renderOsc8Link('https://github.com/upstream-owner/repo', text) : text; + } + + const upstream = getRemoteInfo('upstream', context); + if (!upstream) { + return hideWhenEmpty ? null : 'no upstream'; + } + + const text = upstream.owner; + + if (linkEnabled) { + const url = buildRepoWebUrl(upstream); + return renderOsc8Link(url, text); + } + + return text; + } + + getCustomKeybinds(): CustomKeybind[] { + return getRemoteWidgetKeybinds(); + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitUpstreamOwnerRepo.ts b/src/widgets/GitUpstreamOwnerRepo.ts new file mode 100644 index 00000000..d42ece13 --- /dev/null +++ b/src/widgets/GitUpstreamOwnerRepo.ts @@ -0,0 +1,70 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + buildRepoWebUrl, + getRemoteInfo +} from '../utils/git-remote'; +import { renderOsc8Link } from '../utils/hyperlink'; + +import { + getRemoteWidgetKeybinds, + getRemoteWidgetModifierText, + handleRemoteWidgetAction, + isHideNoRemoteEnabled, + isLinkToRepoEnabled +} from './shared/git-remote'; + +export class GitUpstreamOwnerRepoWidget implements Widget { + getDefaultColor(): string { return 'magenta'; } + getDescription(): string { return 'Shows the upstream remote as owner/repo'; } + getDisplayName(): string { return 'Git Upstream Owner/Repo'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getRemoteWidgetModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleRemoteWidgetAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideWhenEmpty = isHideNoRemoteEnabled(item); + const linkEnabled = isLinkToRepoEnabled(item); + + if (context.isPreview) { + const text = 'upstream-owner/upstream-repo'; + return linkEnabled ? renderOsc8Link('https://github.com/upstream-owner/upstream-repo', text) : text; + } + + const upstream = getRemoteInfo('upstream', context); + if (!upstream) { + return hideWhenEmpty ? null : 'no upstream'; + } + + const text = `${upstream.owner}/${upstream.repo}`; + + if (linkEnabled) { + const url = buildRepoWebUrl(upstream); + return renderOsc8Link(url, text); + } + + return text; + } + + getCustomKeybinds(): CustomKeybind[] { + return getRemoteWidgetKeybinds(); + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitUpstreamRepo.ts b/src/widgets/GitUpstreamRepo.ts new file mode 100644 index 00000000..a1faa8e5 --- /dev/null +++ b/src/widgets/GitUpstreamRepo.ts @@ -0,0 +1,70 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + buildRepoWebUrl, + getRemoteInfo +} from '../utils/git-remote'; +import { renderOsc8Link } from '../utils/hyperlink'; + +import { + getRemoteWidgetKeybinds, + getRemoteWidgetModifierText, + handleRemoteWidgetAction, + isHideNoRemoteEnabled, + isLinkToRepoEnabled +} from './shared/git-remote'; + +export class GitUpstreamRepoWidget implements Widget { + getDefaultColor(): string { return 'magenta'; } + getDescription(): string { return 'Shows the upstream remote repository name'; } + getDisplayName(): string { return 'Git Upstream Repo'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getRemoteWidgetModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleRemoteWidgetAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideWhenEmpty = isHideNoRemoteEnabled(item); + const linkEnabled = isLinkToRepoEnabled(item); + + if (context.isPreview) { + const text = 'upstream-repo'; + return linkEnabled ? renderOsc8Link('https://github.com/upstream-owner/upstream-repo', text) : text; + } + + const upstream = getRemoteInfo('upstream', context); + if (!upstream) { + return hideWhenEmpty ? null : 'no upstream'; + } + + const text = upstream.repo; + + if (linkEnabled) { + const url = buildRepoWebUrl(upstream); + return renderOsc8Link(url, text); + } + + return text; + } + + getCustomKeybinds(): CustomKeybind[] { + return getRemoteWidgetKeybinds(); + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/WorktreeBranch.ts b/src/widgets/WorktreeBranch.ts new file mode 100644 index 00000000..0faacc5f --- /dev/null +++ b/src/widgets/WorktreeBranch.ts @@ -0,0 +1,30 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; + +export class WorktreeBranchWidget implements Widget { + getDefaultColor(): string { return 'yellow'; } + getDescription(): string { return 'Git branch name for the active worktree'; } + getDisplayName(): string { return 'Worktree Branch'; } + getCategory(): string { return 'Session'; } + + getEditorDisplay(_item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(_item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + if (context.isPreview) { + return 'wt-my-feature'; + } + + const branch = context.data?.worktree?.branch; + return branch ?? null; + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/WorktreeMode.ts b/src/widgets/WorktreeMode.ts new file mode 100644 index 00000000..d074ecef --- /dev/null +++ b/src/widgets/WorktreeMode.ts @@ -0,0 +1,36 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; + +export class WorktreeModeWidget implements Widget { + getDefaultColor(): string { return 'yellow'; } + getDescription(): string { return 'Shows indicator when Claude Code is in worktree mode'; } + getDisplayName(): string { return 'Worktree Mode'; } + getCategory(): string { return 'Session'; } + + getEditorDisplay(_item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const worktree = context.isPreview ? true : context.data?.worktree; + const isInWorktree = worktree !== undefined && worktree !== null; + + if (item.rawValue) { + return isInWorktree ? 'true' : 'false'; + } + + if (!isInWorktree) { + return null; + } + + return 'βŽ‡'; + } + + supportsRawValue(): boolean { return true; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/WorktreeName.ts b/src/widgets/WorktreeName.ts new file mode 100644 index 00000000..b211cd19 --- /dev/null +++ b/src/widgets/WorktreeName.ts @@ -0,0 +1,30 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; + +export class WorktreeNameWidget implements Widget { + getDefaultColor(): string { return 'yellow'; } + getDescription(): string { return 'Name of the active worktree'; } + getDisplayName(): string { return 'Worktree Name'; } + getCategory(): string { return 'Session'; } + + getEditorDisplay(_item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(_item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + if (context.isPreview) { + return 'my-feature'; + } + + const name = context.data?.worktree?.name; + return name ?? null; + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/WorktreeOriginalBranch.ts b/src/widgets/WorktreeOriginalBranch.ts new file mode 100644 index 00000000..30ac898e --- /dev/null +++ b/src/widgets/WorktreeOriginalBranch.ts @@ -0,0 +1,30 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; + +export class WorktreeOriginalBranchWidget implements Widget { + getDefaultColor(): string { return 'yellow'; } + getDescription(): string { return 'Git branch checked out before entering the worktree'; } + getDisplayName(): string { return 'Worktree Original Branch'; } + getCategory(): string { return 'Session'; } + + getEditorDisplay(_item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(_item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + if (context.isPreview) { + return 'main'; + } + + const originalBranch = context.data?.worktree?.original_branch; + return originalBranch ?? null; + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/__tests__/GitBranch.test.ts b/src/widgets/__tests__/GitBranch.test.ts index 6ee9856b..20c0a7bd 100644 --- a/src/widgets/__tests__/GitBranch.test.ts +++ b/src/widgets/__tests__/GitBranch.test.ts @@ -10,6 +10,7 @@ import { import type { RenderContext } from '../../types/RenderContext'; import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; +import { clearGitCache } from '../../utils/git'; import { renderOsc8Link } from '../../utils/hyperlink'; import { GitBranchWidget } from '../GitBranch'; @@ -53,6 +54,7 @@ function render(options: { describe('GitBranchWidget', () => { beforeEach(() => { vi.clearAllMocks(); + clearGitCache(); }); it('should render preview', () => { diff --git a/src/widgets/__tests__/GitChanges.test.ts b/src/widgets/__tests__/GitChanges.test.ts index d9161a64..ace47013 100644 --- a/src/widgets/__tests__/GitChanges.test.ts +++ b/src/widgets/__tests__/GitChanges.test.ts @@ -10,6 +10,7 @@ import { import type { RenderContext } from '../../types/RenderContext'; import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; +import { clearGitCache } from '../../utils/git'; import { GitChangesWidget } from '../GitChanges'; vi.mock('child_process', () => ({ execSync: vi.fn() })); @@ -43,6 +44,7 @@ function render(options: { describe('GitChangesWidget', () => { beforeEach(() => { vi.clearAllMocks(); + clearGitCache(); }); it('should render preview', () => { diff --git a/src/widgets/__tests__/GitDeletions.test.ts b/src/widgets/__tests__/GitDeletions.test.ts index a7b71bd9..10d1cab5 100644 --- a/src/widgets/__tests__/GitDeletions.test.ts +++ b/src/widgets/__tests__/GitDeletions.test.ts @@ -10,6 +10,7 @@ import { import type { RenderContext } from '../../types/RenderContext'; import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; +import { clearGitCache } from '../../utils/git'; import { GitDeletionsWidget } from '../GitDeletions'; vi.mock('child_process', () => ({ execSync: vi.fn() })); @@ -43,6 +44,7 @@ function render(options: { describe('GitDeletionsWidget', () => { beforeEach(() => { vi.clearAllMocks(); + clearGitCache(); }); it('should render preview', () => { diff --git a/src/widgets/__tests__/GitInsertions.test.ts b/src/widgets/__tests__/GitInsertions.test.ts index 519df176..19085eeb 100644 --- a/src/widgets/__tests__/GitInsertions.test.ts +++ b/src/widgets/__tests__/GitInsertions.test.ts @@ -10,6 +10,7 @@ import { import type { RenderContext } from '../../types/RenderContext'; import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; +import { clearGitCache } from '../../utils/git'; import { GitInsertionsWidget } from '../GitInsertions'; vi.mock('child_process', () => ({ execSync: vi.fn() })); @@ -43,6 +44,7 @@ function render(options: { describe('GitInsertionsWidget', () => { beforeEach(() => { vi.clearAllMocks(); + clearGitCache(); }); it('should render preview', () => { diff --git a/src/widgets/__tests__/GitRootDir.test.ts b/src/widgets/__tests__/GitRootDir.test.ts index 2d819474..2e3bc7fa 100644 --- a/src/widgets/__tests__/GitRootDir.test.ts +++ b/src/widgets/__tests__/GitRootDir.test.ts @@ -10,6 +10,7 @@ import { import type { RenderContext } from '../../types/RenderContext'; import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; +import { clearGitCache } from '../../utils/git'; import { buildIdeFileUrl, renderOsc8Link @@ -43,6 +44,7 @@ function render(options: { cwd?: string; hideNoGit?: boolean; isPreview?: boolea describe('GitRootDirWidget', () => { beforeEach(() => { vi.clearAllMocks(); + clearGitCache(); }); it('should render preview', () => { diff --git a/src/widgets/__tests__/GitStatus.test.ts b/src/widgets/__tests__/GitStatus.test.ts new file mode 100644 index 00000000..ec78e1ed --- /dev/null +++ b/src/widgets/__tests__/GitStatus.test.ts @@ -0,0 +1,32 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import { GitStatusWidget } from '../GitStatus'; + +describe('GitStatusWidget', () => { + const widget = new GitStatusWidget(); + + it('shows combined status indicators in preview', () => { + const context: RenderContext = { isPreview: true }; + const item = { id: '1', type: 'git-status' }; + const result = widget.render(item, context, DEFAULT_SETTINGS); + + expect(result).toBe('+*'); + }); + + it('has correct metadata', () => { + expect(widget.getDefaultColor()).toBe('yellow'); + expect(widget.getDisplayName()).toBe('Git Status'); + expect(widget.getCategory()).toBe('Git'); + expect(widget.supportsRawValue()).toBe(false); + }); + + it('shows correct description including conflicts', () => { + expect(widget.getDescription()).toContain('! conflicts'); + }); +}); \ No newline at end of file diff --git a/src/widgets/__tests__/GitWorktree.test.ts b/src/widgets/__tests__/GitWorktree.test.ts index 25a9c00b..e8c3f23e 100644 --- a/src/widgets/__tests__/GitWorktree.test.ts +++ b/src/widgets/__tests__/GitWorktree.test.ts @@ -11,6 +11,7 @@ import type { RenderContext, WidgetItem } from '../../types'; +import { clearGitCache } from '../../utils/git'; import { GitWorktreeWidget } from '../GitWorktree'; vi.mock('child_process', () => ({ execSync: vi.fn() })); @@ -46,6 +47,7 @@ function render(options: { describe('GitWorktreeWidget', () => { beforeEach(() => { vi.clearAllMocks(); + clearGitCache(); }); it('should render preview', () => { diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 74d92bce..c57abed0 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -6,6 +6,20 @@ export { GitInsertionsWidget } from './GitInsertions'; export { GitDeletionsWidget } from './GitDeletions'; export { GitRootDirWidget } from './GitRootDir'; export { GitWorktreeWidget } from './GitWorktree'; +export { GitStatusWidget } from './GitStatus'; +export { GitStagedWidget } from './GitStaged'; +export { GitUnstagedWidget } from './GitUnstaged'; +export { GitUntrackedWidget } from './GitUntracked'; +export { GitAheadBehindWidget } from './GitAheadBehind'; +export { GitConflictsWidget } from './GitConflicts'; +export { GitShaWidget } from './GitSha'; +export { GitOriginOwnerWidget } from './GitOriginOwner'; +export { GitOriginRepoWidget } from './GitOriginRepo'; +export { GitOriginOwnerRepoWidget } from './GitOriginOwnerRepo'; +export { GitUpstreamOwnerWidget } from './GitUpstreamOwner'; +export { GitUpstreamRepoWidget } from './GitUpstreamRepo'; +export { GitUpstreamOwnerRepoWidget } from './GitUpstreamOwnerRepo'; +export { GitIsForkWidget } from './GitIsFork'; export { TokensInputWidget } from './TokensInput'; export { TokensOutputWidget } from './TokensOutput'; export { TokensCachedWidget } from './TokensCached'; @@ -18,6 +32,7 @@ export { SessionCostWidget } from './SessionCost'; export { TerminalWidthWidget } from './TerminalWidth'; export { VersionWidget } from './Version'; export { CustomTextWidget } from './CustomText'; +export { CustomSymbolWidget } from './CustomSymbol'; export { CustomCommandWidget } from './CustomCommand'; export { BlockTimerWidget } from './BlockTimer'; export { CurrentWorkingDirWidget } from './CurrentWorkingDir'; @@ -35,4 +50,8 @@ export { ContextBarWidget } from './ContextBar'; export { LinkWidget } from './Link'; export { SkillsWidget } from './Skills'; export { ThinkingEffortWidget } from './ThinkingEffort'; -export { VimModeWidget } from './VimMode'; \ No newline at end of file +export { VimModeWidget } from './VimMode'; +export { WorktreeModeWidget } from './WorktreeMode'; +export { WorktreeNameWidget } from './WorktreeName'; +export { WorktreeBranchWidget } from './WorktreeBranch'; +export { WorktreeOriginalBranchWidget } from './WorktreeOriginalBranch'; \ No newline at end of file diff --git a/src/widgets/shared/git-remote.ts b/src/widgets/shared/git-remote.ts new file mode 100644 index 00000000..32f5a53b --- /dev/null +++ b/src/widgets/shared/git-remote.ts @@ -0,0 +1,68 @@ +import type { + CustomKeybind, + WidgetItem +} from '../../types/Widget'; + +import { makeModifierText } from './editor-display'; +import { + isMetadataFlagEnabled, + toggleMetadataFlag +} from './metadata'; + +const HIDE_NO_REMOTE_KEY = 'hideNoRemote'; +const TOGGLE_NO_REMOTE_ACTION = 'toggle-no-remote'; + +const LINK_TO_REPO_KEY = 'linkToRepo'; +const TOGGLE_LINK_ACTION = 'toggle-link'; + +const HIDE_NO_REMOTE_KEYBIND: CustomKeybind = { + key: 'h', + label: '(h)ide when no remote', + action: TOGGLE_NO_REMOTE_ACTION +}; + +const LINK_TO_REPO_KEYBIND: CustomKeybind = { + key: 'l', + label: '(l)ink to repo', + action: TOGGLE_LINK_ACTION +}; + +export function isHideNoRemoteEnabled(item: WidgetItem): boolean { + return isMetadataFlagEnabled(item, HIDE_NO_REMOTE_KEY); +} + +export function isLinkToRepoEnabled(item: WidgetItem): boolean { + return isMetadataFlagEnabled(item, LINK_TO_REPO_KEY); +} + +export function getRemoteWidgetModifierText(item: WidgetItem): string | undefined { + const modifiers: string[] = []; + + if (isHideNoRemoteEnabled(item)) { + modifiers.push('hide when empty'); + } + if (isLinkToRepoEnabled(item)) { + modifiers.push('link'); + } + + return makeModifierText(modifiers); +} + +export function handleRemoteWidgetAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === TOGGLE_NO_REMOTE_ACTION) { + return toggleMetadataFlag(item, HIDE_NO_REMOTE_KEY); + } + if (action === TOGGLE_LINK_ACTION) { + return toggleMetadataFlag(item, LINK_TO_REPO_KEY); + } + + return null; +} + +export function getRemoteWidgetKeybinds(): CustomKeybind[] { + return [HIDE_NO_REMOTE_KEYBIND, LINK_TO_REPO_KEYBIND]; +} + +export function getHideNoRemoteKeybinds(): CustomKeybind[] { + return [HIDE_NO_REMOTE_KEYBIND]; +} \ No newline at end of file