diff --git a/.superpowers/brainstorm/1885621-1774257922/.server-info b/.superpowers/brainstorm/1885621-1774257922/.server-info new file mode 100644 index 00000000..bc393ae7 --- /dev/null +++ b/.superpowers/brainstorm/1885621-1774257922/.server-info @@ -0,0 +1 @@ +{"type":"server-started","port":63636,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:63636","screen_dir":"/home/mark/GitHub/ccstatusline-wtree/feat/unified-editing/.superpowers/brainstorm/1885621-1774257922"} diff --git a/.superpowers/brainstorm/1885621-1774257922/accordion-models.html b/.superpowers/brainstorm/1885621-1774257922/accordion-models.html new file mode 100644 index 00000000..ca9b2a2d --- /dev/null +++ b/.superpowers/brainstorm/1885621-1774257922/accordion-models.html @@ -0,0 +1,62 @@ +

Accordion Rules: How should expanded rules look?

+

When you press a key on a widget, its rules expand inline below it. But how much detail should each rule show?

+ +
+
+
A
+
+

Compact: Condition + Color Preview Only

+
+
ItemsEditor - Compact Rules
+
+
Edit Line 1
+
1. Model [cyan]
+
▸ when >50 ■ red
+
▸ when >80 ■ red, bold (hidden)
+
2. GitBranch [green]
+
3. TokensTotal [white] (2 rules)
+
+
+

Each rule is one short line. Condition summary + color swatch + key overrides. Quick to scan. Less info but less clutter.

+
+
+ +
+
B
+
+

Detailed: Full Condition + All Properties

+
+
ItemsEditor - Detailed Rules
+
+
Edit Line 1
+
1. Model [cyan]
+
▸ 1. Model (when self greaterThan 50) (raw value)
+
▸ 2. Model (when self greaterThan 80) (stop) (hidden)
+
2. GitBranch [green]
+
3. TokensTotal [white] (2 rules)
+
+
+

Mirrors current RulesEditor display: styled widget name + full condition text + all property overrides. More info but takes more space.

+
+
+ +
+
C
+
+

Hybrid: Compact by default, expand selected rule

+
+
ItemsEditor - Hybrid Rules
+
+
Edit Line 1
+
1. Model [cyan]
+
▸ when >50 ■ red
+
Model (when self greaterThan 80) (stop) (hidden)
+
↑↓ select, ←→ edit condition, Tab: color mode, (s)top, (h)ide
+
2. GitBranch [green]
+
3. TokensTotal [white] (2 rules)
+
+
+

Unselected rules show compact view. The selected rule shows full detail + inline help text. Best of both: scannable list, detailed editing.

+
+
+
diff --git a/.superpowers/brainstorm/1885621-1774257922/full-design-v2.html b/.superpowers/brainstorm/1885621-1774257922/full-design-v2.html new file mode 100644 index 00000000..1d53992d --- /dev/null +++ b/.superpowers/brainstorm/1885621-1774257922/full-design-v2.html @@ -0,0 +1,96 @@ +

Accordion Rules Editor: Full Design (v2)

+

Fixed: Selection arrow stays in a fixed column. Indented rules have their own arrow column.

+ +
+

1. Normal Widget View (no rules expanded)

+
+
Edit Line 1
+
+
↑↓ select, ←→ type picker, (a)dd, (d)elete, (x) rules, Tab: color mode, ESC: back
+
1. Model [cyan] (2 rules)
+
2. GitBranch [green]
+
3. TokensTotal [white] (1 rule)
+
4. Separator |
+
5. SessionCost [yellow]
+
+
+

↑↓ navigates widgets. Arrow column is fixed at left edge.

+
+ +
+

2. Press x — Rules expand, focus shifts into rules

+
+
Edit Line 1 — Rules for Model
+
+
↑↓ select, ←→ edit condition, (a)dd, (d)elete, (s)top, (h)ide, Tab: color mode, ESC: collapse
+
1. Model [cyan]
+
1. Model (when self > 50) (raw value)
+
2. Model (when self > 80) (stop) (hidden)
+
2. GitBranch [green]
+
3. TokensTotal [white] (1 rule)
+
+
+

Rules are permanently indented under the parent widget. The rule arrow column is offset from the widget arrow column. The parent widget loses its arrow (no longer selected). ↑↓ navigates rules only.

+
+ +
+

3. Navigating to rule 2

+
+
Edit Line 1 — Rules for Model
+
+
↑↓ select, ←→ edit condition, (a)dd, (d)elete, (s)top, (h)ide, Tab: color mode, ESC: collapse
+
1. Model [cyan]
+
1. Model (when self > 50) (raw value)
+
2. Model (when self > 80) (stop) (hidden)
+
2. GitBranch [green]
+
3. TokensTotal [white] (1 rule)
+
+
+

Arrow moves down to rule 2. Nothing else shifts. The indentation is fixed — only the arrow position changes.

+
+ +
+

4. Tab to color mode (on rule 2)

+
+
Edit Line 1 — Rules for Model [COLOR MODE - FOREGROUND]
+
+
←→ cycle foreground, (f) bg/fg, (b)old, (r)eset, Tab: property mode, ESC: property mode
+
Current foreground (3/16): Red
+
1. Model [cyan]
+
1. Model (when self > 50) (raw value)
+
2. Model (when self > 80) (stop) (hidden)
+
2. GitBranch [green]
+
+
+

Magenta arrow for color mode. Color info shown. Everything else stays put.

+
+ +
+

5. ESC Layering

+
+
Navigation Stack
+
+
Rule color mode    → ESC →   Rule property mode
+
Rule property mode  → ESC →   Collapse rules (widget level)
+
Widget color mode   → ESC →   Widget items mode
+
Widget items mode   → ESC →   Line Selector
+
+
+
+ +
+
+
+
+

Design looks correct

+

Ready to write up as a spec

+
+
+
+
+
+

Needs changes

+

I'll describe what to adjust in the terminal

+
+
+
diff --git a/.superpowers/brainstorm/1885621-1774257922/full-design.html b/.superpowers/brainstorm/1885621-1774257922/full-design.html new file mode 100644 index 00000000..fd4ecf4b --- /dev/null +++ b/.superpowers/brainstorm/1885621-1774257922/full-design.html @@ -0,0 +1,142 @@ +

Accordion Rules Editor: Full Design

+

Review the complete interaction model. Does this capture everything correctly?

+ +
+

1. Normal Widget View (no rules expanded)

+
+
Edit Line 1 — Items Mode
+
+
↑↓ select, ←→ open type picker, (a)dd, (d)elete, (x) rules, Tab: color mode, ESC: back
+
1. Model [cyan] (2 rules)
+
2. GitBranch [green]
+
3. TokensTotal [white] (1 rule)
+
4. Separator |
+
5. SessionCost [yellow]
+
+
+

Standard widget list. (x) rules hint visible. Rule count shown as annotation.

+
+ +
+

2. Rules Expanded (press x — focus shifts to rules)

+
+
Edit Line 1 — Rules for Model [PROPERTY MODE]
+
+
↑↓ select, ←→ edit condition, Enter move, (a)dd, (d)elete, (s)top, (h)ide, Tab: color mode, ESC: collapse
+
1. Model [cyan] (2 rules)
+
1. Model (when self > 50) (raw value)
+
2. Model (when self > 80) (stop) (hidden)
+
2. GitBranch [green]
+
3. TokensTotal [white] (1 rule)
+
+
+

Rules expand inline below the widget. The widget's selector dims () to show it's the parent, not the focus. The first rule gets the active selector. ↑↓ navigates between rules only. Help text updates to show rule-level keybinds.

+
+ +
+

3. Tab to Color Mode (on a rule)

+
+
Edit Line 1 — Rules for Model [COLOR MODE - FOREGROUND]
+
+
←→ cycle foreground, (f) bg/fg, (b)old, (r)eset, Tab: property mode, ESC: property mode
+
Current foreground (3/16): Red
+
1. Model [cyan] (2 rules)
+
1. Model (when self > 50) (raw value)
+
2. Model (when self > 80) (stop) (hidden)
+
2. GitBranch [green]
+
+
+

Tab switches to color mode for the selected rule. Magenta selector. Color info displayed. ←→ cycles colors for the rule's override. Same UX as widget-level color editing.

+
+ +
+

4. ESC Behavior (layered peeling)

+
+
Navigation Stack
+
+
Rule color mode   ⟶ ESC ⟶   Rule property mode
+
Rule property mode   ⟶ ESC ⟶   Collapse rules (widget level)
+
Widget color mode   ⟶ ESC ⟶   Widget items mode
+
Widget items mode   ⟶ ESC ⟶   Line Selector
+
+
+

ESC always peels back one layer. Never skips a level. Predictable and safe.

+
+ +
+

5. Keybind Parity Table

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyWidget LevelRule Level
↑↓Navigate widgetsNavigate rules
TabItems ↔ Color modeProperty ↔ Color mode
←→Type picker (items) / Cycle colorCondition editor (prop) / Cycle color
aAdd widgetAdd rule
dDelete widgetDelete rule
EnterMove mode (reorder)Move mode (reorder)
xExpand rules (drill in)N/A
sN/AToggle stop flag
hHex input (color mode)Hide toggle (prop) / Hex input (color)
ESCBack to line selectorCollapse rules (back to widget)
+
+ +
+
+
+
+

Design looks correct

+

Ready to write up as a spec

+
+
+
+
+
+

Needs changes

+

I'll describe what to adjust in the terminal

+
+
+
diff --git a/.superpowers/brainstorm/1885621-1774257922/waiting.html b/.superpowers/brainstorm/1885621-1774257922/waiting.html new file mode 100644 index 00000000..ef076525 --- /dev/null +++ b/.superpowers/brainstorm/1885621-1774257922/waiting.html @@ -0,0 +1,3 @@ +
+

Continuing in terminal...

+
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/App.tsx b/src/tui/App.tsx index aead5585..92763ec7 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -42,7 +42,6 @@ import { import { getPackageVersion } from '../utils/terminal'; import { - ColorMenu, ConfirmDialog, GlobalOverridesMenu, InstallMenu, @@ -66,8 +65,6 @@ interface FlashMessage { type AppScreen = 'main' | 'lines' | 'items' - | 'colorLines' - | 'colors' | 'terminalWidth' | 'terminalConfig' | 'globalOverrides' @@ -252,9 +249,6 @@ export const App: React.FC = () => { case 'lines': setScreen('lines'); break; - case 'colors': - setScreen('colorLines'); - break; case 'terminalConfig': setScreen('terminalConfig'); break; @@ -297,7 +291,18 @@ export const App: React.FC = () => { exit(); break; case 'exit': - exit(); + if (hasChanges) { + setConfirmDialog({ + message: 'You have unsaved changes. Exit without saving?', + action: () => { + exit(); + return Promise.resolve(); + } + }); + setScreen('confirm'); + } else { + exit(); + } break; } }; @@ -396,44 +401,6 @@ export const App: React.FC = () => { settings={settings} /> )} - {screen === 'colorLines' && ( - { - setMenuSelections(prev => ({ ...prev, lines: line })); - setSelectedLine(line); - setScreen('colors'); - }} - onBack={() => { - // Save that we came from 'colors' menu (index 1) - setMenuSelections(prev => ({ ...prev, main: 1 })); - setScreen('main'); - }} - initialSelection={menuSelections.lines} - title='Select Line to Edit Colors' - blockIfPowerlineActive={true} - settings={settings} - allowEditing={false} - /> - )} - {screen === 'colors' && ( - { - // Update only the selected line - const newLines = [...settings.lines]; - newLines[selectedLine] = updatedWidgets; - setSettings({ ...settings, lines: newLines }); - }} - onBack={() => { - // Go back to line selection for colors - setScreen('colorLines'); - }} - /> - )} {screen === 'terminalConfig' && ( { if (target === 'width') { setScreen('terminalWidth'); } else { - // Save that we came from 'terminalConfig' menu (index 3) - setMenuSelections(prev => ({ ...prev, main: 3 })); + // Save that we came from 'terminalConfig' menu (index 2) + setMenuSelections(prev => ({ ...prev, main: 2 })); setScreen('main'); } }} @@ -469,8 +436,8 @@ export const App: React.FC = () => { setSettings(updatedSettings); }} onBack={() => { - // Save that we came from 'globalOverrides' menu (index 4) - setMenuSelections(prev => ({ ...prev, main: 4 })); + // Save that we came from 'globalOverrides' menu (index 3) + setMenuSelections(prev => ({ ...prev, main: 3 })); setScreen('main'); }} /> 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 deleted file mode 100644 index 92178574..00000000 --- a/src/tui/components/ColorMenu.tsx +++ /dev/null @@ -1,507 +0,0 @@ -import chalk from 'chalk'; -import { - Box, - Text, - useInput -} from 'ink'; -import SelectInput from 'ink-select-input'; -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'; - -import { ConfirmDialog } from './ConfirmDialog'; -import { - clearAllWidgetStyling, - cycleWidgetColor, - resetWidgetStyling, - setWidgetColor, - toggleWidgetBold -} from './color-menu/mutations'; - -export interface ColorMenuProps { - widgets: WidgetItem[]; - lineIndex?: number; - settings: Settings; - onUpdate: (widgets: WidgetItem[]) => void; - onBack: () => void; -} - -export const ColorMenu: React.FC = ({ widgets, lineIndex, settings, onUpdate, onBack }) => { - const [showSeparators, setShowSeparators] = useState(false); - const [hexInputMode, setHexInputMode] = useState(false); - const [hexInput, setHexInput] = useState(''); - const [ansi256InputMode, setAnsi256InputMode] = useState(false); - const [ansi256Input, setAnsi256Input] = useState(''); - const [showClearConfirm, setShowClearConfirm] = useState(false); - - const powerlineEnabled = settings.powerline.enabled; - - const colorableWidgets = widgets.filter((widget) => { - // Include separators only if showSeparators is true - if (widget.type === 'separator') { - return showSeparators; - } - // Use the widget's supportsColors method - const widgetInstance = getWidget(widget.type); - // Include unknown widgets (they might support colors, we just don't know) - return widgetInstance ? widgetInstance.supportsColors(widget) : true; - }); - const [highlightedItemId, setHighlightedItemId] = useState(colorableWidgets[0]?.id ?? null); - const [editingBackground, setEditingBackground] = useState(false); - - // Handle keyboard input - const hasNoItems = colorableWidgets.length === 0; - useInput((input, key) => { - // If no items, any key goes back - if (hasNoItems) { - onBack(); - return; - } - - // Skip input handling when confirmation is active - let ConfirmDialog handle it - if (showClearConfirm) { - return; - } - - // Handle hex input mode - if (hexInputMode) { - // Disable arrow keys in input mode - if (key.upArrow || key.downArrow) { - return; - } - if (key.escape) { - setHexInputMode(false); - setHexInput(''); - } else if (key.return) { - // Validate and apply the hex color - if (hexInput.length === 6) { - const hexColor = `hex:${hexInput}`; - const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); - if (selectedWidget) { - const newItems = setWidgetColor(widgets, selectedWidget.id, hexColor, editingBackground); - onUpdate(newItems); - } - setHexInputMode(false); - setHexInput(''); - } - } else if (key.backspace || key.delete) { - setHexInput(hexInput.slice(0, -1)); - } else if (shouldInsertInput(input, key) && hexInput.length < 6) { - // Only accept hex characters (0-9, A-F, a-f) - const upperInput = input.toUpperCase(); - if (/^[0-9A-F]$/.test(upperInput)) { - setHexInput(hexInput + upperInput); - } - } - return; - } - - // Handle ansi256 input mode - if (ansi256InputMode) { - // Disable arrow keys in input mode - if (key.upArrow || key.downArrow) { - return; - } - if (key.escape) { - setAnsi256InputMode(false); - setAnsi256Input(''); - } else if (key.return) { - // Validate and apply the ansi256 color - const code = parseInt(ansi256Input, 10); - if (!isNaN(code) && code >= 0 && code <= 255) { - const ansiColor = `ansi256:${code}`; - - const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); - - if (selectedWidget) { - const newItems = setWidgetColor(widgets, selectedWidget.id, ansiColor, editingBackground); - - onUpdate(newItems); - setAnsi256InputMode(false); - setAnsi256Input(''); - } - } - } else if (key.backspace || key.delete) { - setAnsi256Input(ansi256Input.slice(0, -1)); - } else if (shouldInsertInput(input, key) && ansi256Input.length < 3) { - // Only accept numeric characters (0-9) - if (/^[0-9]$/.test(input)) { - const newInput = ansi256Input + input; - const code = parseInt(newInput, 10); - // Only allow if it won't exceed 255 - if (code <= 255) { - setAnsi256Input(newInput); - } - } - } - return; - } - - // Ignore number keys to prevent SelectInput numerical navigation - if (input && /^[0-9]$/.test(input)) { - return; - } - - // Normal keyboard handling when there are items - if (key.escape) { - if (editingBackground) { - setEditingBackground(false); - } else { - onBack(); - } - } else if (input === 'h' || input === 'H') { - // Enter hex input mode (only in truecolor mode) - if (highlightedItemId && highlightedItemId !== 'back' && settings.colorLevel === 3) { - setHexInputMode(true); - setHexInput(''); - } - } else if (input === 'a' || input === 'A') { - // Enter ansi256 input mode (only in 256 color mode) - if (highlightedItemId && highlightedItemId !== 'back' && settings.colorLevel === 2) { - setAnsi256InputMode(true); - setAnsi256Input(''); - } - } else if ((input === 's' || input === 'S') && !key.ctrl) { - // Toggle show separators (only if not in powerline mode and no default separator) - if (!settings.powerline.enabled && !settings.defaultSeparator) { - setShowSeparators(!showSeparators); - // The highlighted item ID will be maintained, and we'll recalculate - // the initial index when rendering the SelectInput - } - } else if (input === 'f' || input === 'F') { - if (colorableWidgets.length > 0) { - setEditingBackground(!editingBackground); - } - } else if (input === 'b' || input === 'B') { - if (highlightedItemId && highlightedItemId !== 'back') { - // Toggle bold for the highlighted item - const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); - if (selectedWidget) { - const newItems = toggleWidgetBold(widgets, selectedWidget.id); - onUpdate(newItems); - } - } - } else if (input === 'r' || input === 'R') { - if (highlightedItemId && highlightedItemId !== 'back') { - // Reset all styling (color, background, and bold) for the highlighted item - const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); - if (selectedWidget) { - const newItems = resetWidgetStyling(widgets, selectedWidget.id); - onUpdate(newItems); - } - } - } else if (input === 'c' || input === 'C') { - // Show clear all confirmation - setShowClearConfirm(true); - } else if (key.leftArrow || key.rightArrow) { - // Cycle through colors with arrow keys - if (highlightedItemId && highlightedItemId !== 'back') { - const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); - if (selectedWidget) { - const newItems = cycleWidgetColor({ - widgets, - widgetId: selectedWidget.id, - direction: key.rightArrow ? 'right' : 'left', - editingBackground, - colors, - backgroundColors: bgColors - }); - onUpdate(newItems); - } - } - } - }); - - if (hasNoItems) { - return ( - - - Configure Colors - {lineIndex !== undefined ? ` - Line ${lineIndex + 1}` : ''} - - No colorable widgets in the status line. - Add a widget first to continue. - Press any key to go back... - - ); - } - - const getItemLabel = (widget: WidgetItem) => { - if (widget.type === 'separator') { - const char = widget.character ?? '|'; - return `Separator: ${char === ' ' ? 'space' : char}`; - } - if (widget.type === 'flex-separator') { - return 'Flex Separator'; - } - - const widgetImpl = getWidget(widget.type); - return widgetImpl ? widgetImpl.getDisplayName() : `Unknown: ${widget.type}`; - }; - - // Color list for cycling - // Get available colors from colors.ts - const colorOptions = getAvailableColorsForUI(); - const colors = colorOptions.map(c => c.value || ''); - - // For background, get background colors - const bgColorOptions = getAvailableBackgroundColorsForUI(); - const bgColors = bgColorOptions.map(c => c.value || ''); - - // Create menu items with colored labels - const menuItems = colorableWidgets.map((widget, index) => { - const label = `${index + 1}: ${getItemLabel(widget)}`; - // Apply both foreground and background colors - const level = getColorLevelString(settings.colorLevel); - let defaultColor = 'white'; - if (widget.type !== 'separator' && widget.type !== 'flex-separator') { - const widgetImpl = getWidget(widget.type); - if (widgetImpl) { - defaultColor = widgetImpl.getDefaultColor(); - } - } - const styledLabel = applyColors(label, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, level); - return { - label: styledLabel, - value: widget.id - }; - }); - menuItems.push({ label: '← Back', value: 'back' }); - - const handleSelect = (selected: { value: string }) => { - if (selected.value === 'back') { - onBack(); - } - // Enter no longer cycles colors - use left/right arrow keys instead - }; - - const handleHighlight = (item: { value: string }) => { - setHighlightedItemId(item.value); - }; - - // Get current color for highlighted item - const selectedWidget = highlightedItemId && highlightedItemId !== 'back' - ? colorableWidgets.find(widget => widget.id === highlightedItemId) - : null; - const currentColor = editingBackground - ? (selectedWidget?.backgroundColor ?? '') // Empty string for 'none' - : (selectedWidget ? (selectedWidget.color ?? (() => { - if (selectedWidget.type !== 'separator' && selectedWidget.type !== 'flex-separator') { - const widgetImpl = getWidget(selectedWidget.type); - return widgetImpl ? widgetImpl.getDefaultColor() : 'white'; - } - return 'white'; - })()) : 'white'); - - const colorList = editingBackground ? bgColors : colors; - const colorIndex = colorList.indexOf(currentColor); - const colorNumber = colorIndex === -1 ? 'custom' : colorIndex + 1; - - let colorDisplay; - if (editingBackground) { - if (!currentColor || currentColor === '') { - colorDisplay = chalk.gray('(no background)'); - } else { - // Determine display name based on format - let displayName; - if (currentColor.startsWith('ansi256:')) { - displayName = `ANSI ${currentColor.substring(8)}`; - } else if (currentColor.startsWith('hex:')) { - displayName = `#${currentColor.substring(4)}`; - } else { - const colorOption = bgColorOptions.find(c => c.value === currentColor); - displayName = colorOption ? colorOption.name : currentColor; - } - - // Apply the color using our applyColors function with the current colorLevel - const level = getColorLevelString(settings.colorLevel); - colorDisplay = applyColors(` ${displayName} `, undefined, currentColor, false, level); - } - } else { - if (!currentColor || currentColor === '') { - colorDisplay = chalk.gray('(default)'); - } else { - // Determine display name based on format - let displayName; - if (currentColor.startsWith('ansi256:')) { - displayName = `ANSI ${currentColor.substring(8)}`; - } else if (currentColor.startsWith('hex:')) { - displayName = `#${currentColor.substring(4)}`; - } else { - const colorOption = colorOptions.find(c => c.value === currentColor); - displayName = colorOption ? colorOption.name : currentColor; - } - - // Apply the color using our applyColors function with the current colorLevel - const level = getColorLevelString(settings.colorLevel); - colorDisplay = applyColors(displayName, currentColor, undefined, false, level); - } - } - - // Show confirmation dialog if clearing all colors - if (showClearConfirm) { - return ( - - ⚠ Confirm Clear All Colors - - This will reset all colors for all widgets to their defaults. - This action cannot be undone! - - - Continue? - - - { - const newItems = clearAllWidgetStyling(widgets); - onUpdate(newItems); - setShowClearConfirm(false); - }} - onCancel={() => { - setShowClearConfirm(false); - }} - /> - - - ); - } - - // Check for global overrides - // Note: When powerline is enabled, background override doesn't affect the display - // since powerline uses item-specific backgrounds for segments - const hasGlobalFgOverride = !!settings.overrideForegroundColor; - const hasGlobalBgOverride = !!settings.overrideBackgroundColor && !powerlineEnabled; - const globalOverrideMessage = hasGlobalFgOverride && hasGlobalBgOverride - ? '⚠ Global override for FG and BG active' - : hasGlobalFgOverride - ? '⚠ Global override for FG active' - : hasGlobalBgOverride - ? '⚠ Global override for BG active' - : null; - - return ( - - - - Configure Colors - {lineIndex !== undefined ? ` - Line ${lineIndex + 1}` : ''} - {editingBackground && chalk.yellow(' [Background Mode]')} - - {globalOverrideMessage && ( - - {'. '} - {globalOverrideMessage} - - )} - - {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 - - ) : ( - <> - - ↑↓ to select, ←→ to cycle - {' '} - {editingBackground ? 'background' : 'foreground'} - , (f) to toggle bg/fg, (b)old, - {settings.colorLevel === 3 ? ' (h)ex,' : settings.colorLevel === 2 ? ' (a)nsi256,' : ''} - {' '} - (r)eset, (c)lear all, ESC to go back - - {!settings.powerline.enabled && !settings.defaultSeparator && ( - - (s)how separators: - {showSeparators ? chalk.green('ON') : chalk.gray('OFF')} - - )} - {selectedWidget ? ( - - - Current - {' '} - {editingBackground ? 'background' : 'foreground'} - {' '} - ( - {colorNumber === 'custom' ? 'custom' : `${colorNumber}/${colorList.length}`} - ): - {' '} - {colorDisplay} - {selectedWidget.bold && chalk.bold(' [BOLD]')} - - - ) : ( - - - - )} - - )} - - {(hexInputMode || ansi256InputMode) ? ( - // Static list when in input mode - no keyboard interaction - - {menuItems.map(item => ( - - {item.value === highlightedItemId ? '▶ ' : ' '} - {item.label} - - ))} - - ) : ( - // Interactive SelectInput when not in input mode - item.value === highlightedItemId))} - indicatorComponent={({ isSelected }) => ( - {isSelected ? '▶' : ' '} - )} - itemComponent={({ isSelected, label }) => ( - // The label already has ANSI codes applied via applyColors() - // We need to pass it directly as a single Text child to preserve the codes - {` ${label}`} - )} - /> - )} - - - ⚠ VSCode Users: - If colors appear incorrect in the VSCode integrated terminal, the "Terminal › Integrated: Minimum Contrast Ratio" (`terminal.integrated.minimumContrastRatio`) setting is forcing a minimum contrast between foreground and background colors. You can adjust this setting to 1 to disable the contrast enforcement, or use a standalone terminal for accurate colors. - - - ); -}; \ No newline at end of file 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/GlobalOverridesMenu.tsx b/src/tui/components/GlobalOverridesMenu.tsx index ebc3a04d..0505fd30 100644 --- a/src/tui/components/GlobalOverridesMenu.tsx +++ b/src/tui/components/GlobalOverridesMenu.tsx @@ -51,7 +51,7 @@ export const GlobalOverridesMenu: React.FC = ({ settin }; onUpdate(updatedSettings); setEditingPadding(false); - } else if (key.escape) { + } else if (key.escape || key.leftArrow) { setPaddingInput(settings.defaultPadding ?? ''); setEditingPadding(false); } else if (key.backspace) { @@ -80,7 +80,7 @@ export const GlobalOverridesMenu: React.FC = ({ settin onUpdate(updatedSettings); setEditingSeparator(false); } - } else if (key.escape) { + } else if (key.escape || key.leftArrow) { setSeparatorInput(settings.defaultSeparator ?? ''); setEditingSeparator(false); } else if (key.backspace) { @@ -94,13 +94,14 @@ export const GlobalOverridesMenu: React.FC = ({ settin // Skip input handling when confirmation is active - let ConfirmDialog handle it return; } else { - if (key.escape) { + const cmd = input.toLowerCase(); + if (key.escape || key.leftArrow) { onBack(); - } else if (input === 'p' || input === 'P') { + } else if (cmd === 'p') { setEditingPadding(true); - } else if ((input === 's' || input === 'S') && !isPowerlineEnabled && !key.ctrl) { + } else if (cmd === 's' && !isPowerlineEnabled && !key.ctrl) { setEditingSeparator(true); - } else if ((input === 'i' || input === 'I') && !isPowerlineEnabled) { + } else if (cmd === 'i' && !isPowerlineEnabled) { const newInheritColors = !inheritColors; setInheritColors(newInheritColors); const updatedSettings = { @@ -108,7 +109,7 @@ export const GlobalOverridesMenu: React.FC = ({ settin inheritSeparatorColors: newInheritColors }; onUpdate(updatedSettings); - } else if ((input === 'b' || input === 'B') && !isPowerlineEnabled) { + } else if (cmd === 'b' && !isPowerlineEnabled) { // Cycle through background colors const nextIndex = (currentBgIndex + 1) % bgColors.length; const nextBgColor = bgColors[nextIndex]; @@ -117,14 +118,14 @@ export const GlobalOverridesMenu: React.FC = ({ settin overrideBackgroundColor: nextBgColor === 'none' ? undefined : nextBgColor }; onUpdate(updatedSettings); - } else if ((input === 'c' || input === 'C') && !isPowerlineEnabled) { + } else if (cmd === 'c' && !isPowerlineEnabled) { // Clear override background color const updatedSettings = { ...settings, overrideBackgroundColor: undefined }; onUpdate(updatedSettings); - } else if (input === 'o' || input === 'O') { + } else if (cmd === 'o') { // Toggle global bold const newGlobalBold = !globalBold; setGlobalBold(newGlobalBold); @@ -133,7 +134,7 @@ export const GlobalOverridesMenu: React.FC = ({ settin globalBold: newGlobalBold }; onUpdate(updatedSettings); - } else if (input === 'f' || input === 'F') { + } else if (cmd === 'f') { // Cycle through foreground colors const nextIndex = (currentFgIndex + 1) % fgColors.length; const nextFgColor = fgColors[nextIndex]; @@ -142,7 +143,7 @@ export const GlobalOverridesMenu: React.FC = ({ settin overrideForegroundColor: nextFgColor === 'none' ? undefined : nextFgColor }; onUpdate(updatedSettings); - } else if (input === 'g' || input === 'G') { + } else if (cmd === 'g') { // Clear override foreground color const updatedSettings = { ...settings, diff --git a/src/tui/components/InstallMenu.tsx b/src/tui/components/InstallMenu.tsx index 093ab521..cd70f0dd 100644 --- a/src/tui/components/InstallMenu.tsx +++ b/src/tui/components/InstallMenu.tsx @@ -42,9 +42,6 @@ export const InstallMenu: React.FC = ({ onSelectBunx(); } break; - case 'back': - onCancel(); - break; } } @@ -83,16 +80,11 @@ export const InstallMenu: React.FC = ({ color='blue' marginTop={1} items={listItems} + onBack={onCancel} onSelect={(line) => { - if (line === 'back') { - onCancel(); - return; - } - onSelect(line); }} initialSelection={initialSelection} - showBackButton={true} /> diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 5a564b13..4ecca557 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -5,6 +5,7 @@ import { } from 'ink'; import React, { useState } from 'react'; +import { getColorLevelString } from '../../types/ColorLevel'; import type { Settings } from '../../types/Settings'; import type { CustomKeybind, @@ -12,9 +13,13 @@ import type { WidgetItem, WidgetItemType } from '../../types/Widget'; -import { getBackgroundColorsForPowerline } from '../../utils/colors'; +import { + applyColors, + getBackgroundColorsForPowerline +} from '../../utils/colors'; import { generateGuid } from '../../utils/guid'; import { canDetectTerminalWidth } from '../../utils/terminal'; +import { mergeWidgetWithRuleApply } from '../../utils/widget-properties'; import { filterWidgetCatalog, getWidget, @@ -22,7 +27,13 @@ import { getWidgetCatalogCategories } from '../../utils/widgets'; +import { ConditionEditor } from './ConditionEditor'; import { ConfirmDialog } from './ConfirmDialog'; +import { + getCurrentColorInfo, + handleColorInput, + type ColorEditorState +} from './color-editor/input-handlers'; import { handleMoveInputMode, handleNormalInputMode, @@ -32,6 +43,18 @@ import { type WidgetPickerAction, type WidgetPickerState } from './items-editor/input-handlers'; +import { + formatAppliedProperties, + formatCondition +} from './rules-editor/formatting'; +import { + addRule, + deleteRule, + handleRuleColorInput, + handleRuleEditorComplete, + handleRuleMoveMode, + handleRulePropertyInput +} from './rules-editor/input-handlers'; export interface ItemsEditorProps { widgets: WidgetItem[]; @@ -47,6 +70,27 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB const [customEditorWidget, setCustomEditorWidget] = useState(null); const [widgetPicker, setWidgetPicker] = useState(null); const [showClearConfirm, setShowClearConfirm] = useState(false); + const [expandedWidgetId, setExpandedWidgetId] = useState(null); + const [ruleSelectedIndex, setRuleSelectedIndex] = useState(0); + const [ruleEditorMode, setRuleEditorMode] = useState<'property' | 'color'>('property'); + const [ruleColorEditorState, setRuleColorEditorState] = useState({ + editingBackground: false, + hexInputMode: false, + hexInput: '', + ansi256InputMode: false, + ansi256Input: '' + }); + const [ruleMoveMode, setRuleMoveMode] = useState(false); + const [ruleConditionEditorIndex, setRuleConditionEditorIndex] = useState(null); + + const [editorMode, setEditorMode] = useState<'items' | 'color'>('items'); + const [colorEditorState, setColorEditorState] = useState({ + editingBackground: false, + hexInputMode: false, + hexInput: '', + ansi256InputMode: false, + ansi256Input: '' + }); const separatorChars = ['|', '-', ',', ' ']; const widgetCatalog = getWidgetCatalog(settings); @@ -84,6 +128,27 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB }; const handleEditorComplete = (updatedWidget: WidgetItem) => { + if (expandedWidgetId) { + // Rules are expanded — route through rule editor complete + const expandedWidget = widgets.find(w => w.id === expandedWidgetId); + if (expandedWidget) { + handleRuleEditorComplete({ + updatedWidget, + baseWidget: expandedWidget, + selectedIndex: ruleSelectedIndex, + onUpdate: (updated) => { + const newWidgets = [...widgets]; + const widgetIndex = widgets.findIndex(w => w.id === expandedWidgetId); + if (widgetIndex !== -1) { + newWidgets[widgetIndex] = updated; + onUpdate(newWidgets); + } + }, + setCustomEditorWidget + }); + return; + } + } const newWidgets = [...widgets]; newWidgets[selectedIndex] = updatedWidget; onUpdate(newWidgets); @@ -94,6 +159,25 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB setCustomEditorWidget(null); }; + const toggleRulesExpansion = (widget: WidgetItem) => { + if (expandedWidgetId === widget.id) { + setExpandedWidgetId(null); + } else { + setExpandedWidgetId(widget.id); + setRuleSelectedIndex(0); + setRuleEditorMode('property'); + setRuleColorEditorState({ + editingBackground: false, + hexInputMode: false, + hexInput: '', + ansi256InputMode: false, + ansi256Input: '' + }); + setRuleMoveMode(false); + setRuleConditionEditorIndex(null); + } + }; + const getCustomKeybindsForWidget = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => { if (!widgetImpl.getCustomKeybinds) { return []; @@ -162,6 +246,299 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB return; } + // Tab toggles between items and color mode — always consumed here, never falls through + // Only when: widgets exist, no overlay active (picker, move mode, custom editor, clear confirm) + if (key.tab && widgets.length > 0 && !widgetPicker && !moveMode) { + const widget = widgets[selectedIndex]; + if (widget) { + const widgetImpl = widget.type !== 'separator' && widget.type !== 'flex-separator' + ? getWidget(widget.type) + : null; + if (widgetImpl?.supportsColors(widget)) { + if (editorMode === 'color') { + // Reset hex/ansi256 input modes when switching back to items mode + if (colorEditorState.hexInputMode || colorEditorState.ansi256InputMode) { + setColorEditorState(prev => ({ + ...prev, + hexInputMode: false, + hexInput: '', + ansi256InputMode: false, + ansi256Input: '' + })); + } + setEditorMode('items'); + } else { + setEditorMode('color'); + } + } + } + return; + } + + // Auto-reset color mode if the current widget no longer supports colors + // (e.g. user navigated to a different widget while in color mode) + if (editorMode === 'color') { + const widget = widgets[selectedIndex]; + const widgetImpl = widget && widget.type !== 'separator' && widget.type !== 'flex-separator' + ? getWidget(widget.type) + : null; + const supportsColors = widget !== undefined && (widgetImpl?.supportsColors(widget) ?? false); + if (!supportsColors) { + setEditorMode('items'); + setColorEditorState(prev => ({ + ...prev, + editingBackground: false, + hexInputMode: false, + hexInput: '', + ansi256InputMode: false, + ansi256Input: '' + })); + } + } + + // Rule-level input routing — when rules are expanded for a widget + if (expandedWidgetId !== null) { + const expandedWidget = widgets.find(w => w.id === expandedWidgetId); + if (!expandedWidget) { + setExpandedWidgetId(null); + return; + } + + const rules = expandedWidget.rules ?? []; + const currentRule = rules[ruleSelectedIndex]; + + const updateExpandedWidget = (updatedWidget: WidgetItem) => { + const newWidgets = [...widgets]; + const widgetIndex = widgets.findIndex(w => w.id === expandedWidgetId); + if (widgetIndex !== -1) { + newWidgets[widgetIndex] = updatedWidget; + onUpdate(newWidgets); + } + }; + + // 1. Condition editor active — let it handle input + if (ruleConditionEditorIndex !== null) { + return; + } + + // 2. Custom editor widget is already guarded at the top of useInput, + // so it is always null here. No additional check needed. + + // 3. Rule move mode + if (ruleMoveMode) { + handleRuleMoveMode({ + key, + baseWidget: expandedWidget, + selectedIndex: ruleSelectedIndex, + setSelectedIndex: setRuleSelectedIndex, + setMoveMode: setRuleMoveMode, + onUpdate: updateExpandedWidget + }); + return; + } + + // 4. Rule color mode + if (ruleEditorMode === 'color') { + // Up/Down navigate rules + if (key.upArrow) { + setRuleSelectedIndex(ruleSelectedIndex > 0 ? ruleSelectedIndex - 1 : rules.length - 1); + return; + } + if (key.downArrow) { + setRuleSelectedIndex(ruleSelectedIndex < rules.length - 1 ? ruleSelectedIndex + 1 : 0); + return; + } + + // ESC: if sub-mode active, delegate to handleRuleColorInput (it cancels sub-mode) + // If no sub-mode, switch to property mode + if (key.escape) { + if (ruleColorEditorState.hexInputMode || ruleColorEditorState.ansi256InputMode) { + if (currentRule) { + handleRuleColorInput({ + input, + key, + baseWidget: expandedWidget, + rule: currentRule, + ruleIndex: ruleSelectedIndex, + settings, + colorEditorState: ruleColorEditorState, + setColorEditorState: setRuleColorEditorState, + onUpdate: updateExpandedWidget + }); + } + } else { + setRuleEditorMode('property'); + } + return; + } + + // Tab: switch to property mode, reset sub-modes + if (key.tab) { + if (ruleColorEditorState.hexInputMode || ruleColorEditorState.ansi256InputMode) { + setRuleColorEditorState(prev => ({ + ...prev, + hexInputMode: false, + hexInput: '', + ansi256InputMode: false, + ansi256Input: '' + })); + } + setRuleEditorMode('property'); + return; + } + + // Delegate remaining to handleRuleColorInput + if (currentRule) { + handleRuleColorInput({ + input, + key, + baseWidget: expandedWidget, + rule: currentRule, + ruleIndex: ruleSelectedIndex, + settings, + colorEditorState: ruleColorEditorState, + setColorEditorState: setRuleColorEditorState, + onUpdate: updateExpandedWidget + }); + } + return; + } + + // 5. Rule property mode (the only remaining mode after 'color' above) + // Up/Down navigate rules + if (key.upArrow) { + setRuleSelectedIndex(ruleSelectedIndex > 0 ? ruleSelectedIndex - 1 : rules.length - 1); + return; + } + if (key.downArrow) { + setRuleSelectedIndex(ruleSelectedIndex < rules.length - 1 ? ruleSelectedIndex + 1 : 0); + return; + } + + // ESC: collapse rules + if (key.escape) { + setExpandedWidgetId(null); + return; + } + + // Tab: switch to color mode (only if widget supports colors) + if (key.tab) { + const widgetImpl = expandedWidget.type !== 'separator' && expandedWidget.type !== 'flex-separator' + ? getWidget(expandedWidget.type) + : null; + if (widgetImpl?.supportsColors(expandedWidget)) { + setRuleEditorMode('color'); + } + return; + } + + // Enter: start rule move mode + if (key.return) { + setRuleMoveMode(true); + return; + } + + // a: add rule + if (input === 'a') { + addRule({ + baseWidget: expandedWidget, + setSelectedIndex: setRuleSelectedIndex, + onUpdate: updateExpandedWidget + }); + return; + } + + // d: delete rule, auto-collapse if empty + if (input === 'd') { + deleteRule({ + baseWidget: expandedWidget, + selectedIndex: ruleSelectedIndex, + setSelectedIndex: setRuleSelectedIndex, + onUpdate: updateExpandedWidget + }); + // Check if rules are now empty (after delete) + const remainingRules = (expandedWidget.rules ?? []).filter((_, i) => i !== ruleSelectedIndex); + if (remainingRules.length === 0) { + setExpandedWidgetId(null); + } + return; + } + + // Left: collapse rules (same as ESC) + if (key.leftArrow) { + setExpandedWidgetId(null); + return; + } + + // Right: open condition editor + if (key.rightArrow) { + if (rules.length > 0) { + setRuleConditionEditorIndex(ruleSelectedIndex); + } + return; + } + + // Delegate remaining to handleRulePropertyInput + if (currentRule) { + handleRulePropertyInput({ + input, + key, + baseWidget: expandedWidget, + rule: currentRule, + ruleIndex: ruleSelectedIndex, + onUpdate: updateExpandedWidget, + getCustomKeybindsForWidget, + setCustomEditorWidget + }); + } + + // Unconditional return — prevent fallthrough to widget-level handlers + return; + } + + // Color mode input routing + if (editorMode === 'color') { + const widget = widgets[selectedIndex]; + if (widget) { + // Up/Down for navigation (same as items mode) + if (key.upArrow) { + setSelectedIndex(selectedIndex > 0 ? selectedIndex - 1 : widgets.length - 1); + return; + } + if (key.downArrow) { + setSelectedIndex(selectedIndex < widgets.length - 1 ? selectedIndex + 1 : 0); + return; + } + + // ESC with no sub-mode active: switch back to items mode. + // If a sub-mode is active, fall through to handleColorInput which handles ESC internally. + if (key.escape && !colorEditorState.hexInputMode && !colorEditorState.ansi256InputMode) { + setEditorMode('items'); + return; + } + + const updateWidget = (updatedWidget: WidgetItem) => { + const newWidgets = [...widgets]; + newWidgets[selectedIndex] = updatedWidget; + onUpdate(newWidgets); + }; + + // Delegate all input (including ESC in sub-modes) to handleColorInput + handleColorInput({ + input, + key, + widget, + settings, + state: colorEditorState, + setState: setColorEditorState, + onUpdate: updateWidget + }); + } + // Return unconditionally to prevent fall-through even when widget is undefined + // (shouldn't happen since Tab guards widgets.length > 0, but is defensive) + return; + } + if (widgetPicker) { handlePickerInputMode({ input, @@ -182,7 +559,8 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB selectedIndex, onUpdate, setSelectedIndex, - setMoveMode + setMoveMode, + openWidgetPicker }); return; } @@ -193,6 +571,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB widgets, selectedIndex, separatorChars, + expandedWidgetId, onBack, onUpdate, setSelectedIndex, @@ -200,7 +579,8 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB setShowClearConfirm, openWidgetPicker, getCustomKeybindsForWidget, - setCustomEditorWidget + setCustomEditorWidget, + toggleRulesExpansion }); }); @@ -271,23 +651,99 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB const canMerge = currentWidget && selectedIndex < widgets.length - 1 && !isSeparator && !isFlexSeparator; const hasWidgets = widgets.length > 0; - // Build main help text (without custom keybinds) - let helpText = hasWidgets - ? '↑↓ select, ←→ open type picker' - : '(a)dd via picker, (i)nsert via picker'; - if (isSeparator) { - helpText += ', Space edit separator'; - } - if (hasWidgets) { - helpText += ', Enter to move, (a)dd via picker, (i)nsert via picker, (d)elete, (c)lear line'; - } - if (canToggleRaw) { - helpText += ', (r)aw value'; - } - if (canMerge) { - helpText += ', (m)erge'; - } - helpText += ', ESC back'; + // Build mode-aware help text + const buildHelpText = (): string => { + // Rule-level modes take priority + if (expandedWidgetId !== null) { + if (ruleMoveMode) { + return '↑↓ move rule, Enter/ESC exit move mode'; + } + + if (ruleEditorMode === 'color') { + const { hexInputMode, ansi256InputMode, editingBackground } = ruleColorEditorState; + + if (hexInputMode || ansi256InputMode) { + // Sub-modes render their own prompts + return ''; + } + + const colorType = editingBackground ? 'background' : 'foreground'; + const hexAnsiHelp = settings.colorLevel === 3 + ? ', (h)ex' + : settings.colorLevel === 2 + ? ', (a)nsi256' + : ''; + + return `←→ cycle ${colorType}, (f) bg/fg, (b)old${hexAnsiHelp}, (r)eset\nTab: property mode, ESC: property mode`; + } + + // Rule property mode + const expandedWidget = widgets.find(w => w.id === expandedWidgetId); + let ruleText = '↑↓ select, → edit condition, Enter to edit, (a)dd, (d)elete, (s)top, (h)ide, (c)lear properties, Tab: color mode, ← collapse, ESC: collapse'; + + if (expandedWidget && expandedWidget.type !== 'separator' && expandedWidget.type !== 'flex-separator') { + const expandedWidgetImpl = getWidget(expandedWidget.type); + if (expandedWidgetImpl) { + const widgetCustomKeybinds = getCustomKeybindsForWidget(expandedWidgetImpl, expandedWidget); + if (widgetCustomKeybinds.length > 0) { + ruleText += '\n' + widgetCustomKeybinds.map(kb => kb.label).join(', '); + } + if (expandedWidgetImpl.supportsRawValue()) { + ruleText += ', (r)aw value'; + } + // Check if merge is applicable (widget is not the last) + const expandedWidgetIndex = widgets.findIndex(w => w.id === expandedWidgetId); + if (expandedWidgetIndex !== -1 && expandedWidgetIndex < widgets.length - 1) { + ruleText += ', (m)erge'; + } + } + } + + return ruleText; + } + + if (editorMode === 'color') { + const { editingBackground, hexInputMode, ansi256InputMode } = colorEditorState; + + if (hexInputMode || ansi256InputMode) { + // Sub-modes render their own help text inline + return ''; + } + + const colorType = editingBackground ? 'background' : 'foreground'; + const hexAnsiHelp = settings.colorLevel === 3 + ? ', (h)ex' + : settings.colorLevel === 2 + ? ', (a)nsi256' + : ''; + + return `←→ cycle ${colorType}, (f) bg/fg, (b)old${hexAnsiHelp}, (r)eset\nTab: items mode, ESC: items mode`; + } + + // Items mode + let text = hasWidgets + ? '↑↓ select, → expand rules' + : '(a)dd via picker, (i)nsert via picker'; + if (isSeparator) { + text += ', Space edit separator'; + } + if (hasWidgets) { + text += ', Enter to edit, (a)dd via picker, (i)nsert via picker, (d)elete, (c)lear line'; + } + if (canToggleRaw) { + text += ', (r)aw value'; + } + if (canMerge) { + text += ', (m)erge'; + } + if (hasWidgets && !isSeparator && !isFlexSeparator) { + text += ', Tab: color mode'; + } + text += ', ESC: back'; + return text; + }; + + const helpText = buildHelpText(); // Build custom keybinds text const customKeybindsText = customKeybinds.map(kb => kb.label).join(', '); @@ -340,6 +796,45 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB ); } + // If condition editor is active, render it instead of the normal UI + if (ruleConditionEditorIndex !== null && expandedWidgetId !== null) { + const condEditorWidget = widgets.find(w => w.id === expandedWidgetId); + if (condEditorWidget) { + const condEditorRules = condEditorWidget.rules ?? []; + const condEditorRule = condEditorRules[ruleConditionEditorIndex]; + if (condEditorRule) { + return ( + { + const newRules = [...condEditorRules]; + newRules[ruleConditionEditorIndex] = { + ...condEditorRule, + when: newCondition + }; + const newWidgets = widgets.map(w => w.id === expandedWidgetId ? { ...w, rules: newRules } : w); + onUpdate(newWidgets); + setRuleConditionEditorIndex(null); + }} + onCancel={() => { setRuleConditionEditorIndex(null); }} + /> + ); + } + // Rule index out of bounds — reset + setRuleConditionEditorIndex(null); + } + } + + // Compute expanded widget display name for title bar + const expandedWidget = expandedWidgetId !== null ? widgets.find(w => w.id === expandedWidgetId) : null; + const expandedWidgetDisplayName = expandedWidget + ? (expandedWidget.type !== 'separator' && expandedWidget.type !== 'flex-separator' + ? (getWidget(expandedWidget.type)?.getDisplayName() ?? expandedWidget.type) + : expandedWidget.type) + : null; + return ( @@ -347,9 +842,26 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB Edit Line {' '} {lineNumber} - {' '} + {expandedWidgetDisplayName !== null + ? ` — Rules for ${expandedWidgetDisplayName}` + : ' '} - {moveMode && [MOVE MODE]} + {expandedWidgetId !== null && ruleMoveMode && [MOVE MODE]} + {expandedWidgetId !== null && !ruleMoveMode && ruleEditorMode === 'color' && ( + + [COLOR MODE + {ruleColorEditorState.editingBackground ? ' - BACKGROUND' : ' - FOREGROUND'} + ] + + )} + {expandedWidgetId === null && moveMode && [MOVE MODE]} + {expandedWidgetId === null && !moveMode && !widgetPicker && editorMode === 'color' && ( + + [COLOR MODE + {colorEditorState.editingBackground ? ' - BACKGROUND' : ' - FOREGROUND'} + ] + + )} {widgetPicker && {`[${pickerActionLabel.toUpperCase()}]`}} {(settings.powerline.enabled || Boolean(settings.defaultSeparator)) && ( @@ -365,7 +877,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {moveMode ? ( - ↑↓ to move widget, ESC or Enter to exit move mode + ↑↓ to move widget, ←→ change type, ESC or Enter to exit move mode ) : widgetPicker ? ( @@ -401,7 +913,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB ) : ( {helpText} - {customKeybindsText || ' '} + {editorMode === 'items' && {customKeybindsText || ' '}} )} {hasFlexSeparator && !widthDetectionAvailable && ( @@ -498,6 +1010,149 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB )} )} + {!widgetPicker && expandedWidgetId !== null && ruleEditorMode === 'color' && ruleColorEditorState.hexInputMode && ( + + Enter 6-digit hex color code (without #): + + # + {ruleColorEditorState.hexInput} + + {ruleColorEditorState.hexInput.length < 6 ? '_'.repeat(6 - ruleColorEditorState.hexInput.length) : ''} + + + + )} + {!widgetPicker && expandedWidgetId !== null && ruleEditorMode === 'color' && ruleColorEditorState.ansi256InputMode && ( + + Enter ANSI 256 color code (0-255): + + {ruleColorEditorState.ansi256Input} + + {ruleColorEditorState.ansi256Input.length === 0 + ? '___' + : ruleColorEditorState.ansi256Input.length === 1 + ? '__' + : ruleColorEditorState.ansi256Input.length === 2 + ? '_' + : ''} + + + + )} + {!widgetPicker && expandedWidgetId !== null && ruleEditorMode === 'color' && !ruleColorEditorState.hexInputMode && !ruleColorEditorState.ansi256InputMode && (() => { + const baseWidget = widgets.find(w => w.id === expandedWidgetId); + if (!baseWidget) { + return null; + } + + const isSep = baseWidget.type === 'separator' || baseWidget.type === 'flex-separator'; + if (isSep) { + return null; + } + + const currentRule = (baseWidget.rules ?? [])[ruleSelectedIndex]; + const tempWidget = currentRule ? mergeWidgetWithRuleApply(baseWidget, currentRule.apply) : baseWidget; + + const { colorIndex, totalColors, displayName } = getCurrentColorInfo( + tempWidget, + ruleColorEditorState.editingBackground + ); + + const colorType = ruleColorEditorState.editingBackground ? 'background' : 'foreground'; + const colorNumber = colorIndex === -1 ? 'custom' : `${colorIndex}/${totalColors}`; + + const level = getColorLevelString(settings.colorLevel); + const styledColor = ruleColorEditorState.editingBackground + ? applyColors(` ${displayName} `, undefined, tempWidget.backgroundColor, false, level) + : applyColors(displayName, tempWidget.color, undefined, false, level); + + return ( + + + Current + {' '} + {colorType} + {' '} + ( + {colorNumber} + ): + {' '} + {styledColor} + {tempWidget.bold && [BOLD]} + + + ); + })()} + {!widgetPicker && editorMode === 'color' && colorEditorState.hexInputMode && ( + + Enter 6-digit hex color code (without #): + + # + {colorEditorState.hexInput} + + {colorEditorState.hexInput.length < 6 ? '_'.repeat(6 - colorEditorState.hexInput.length) : ''} + + + + )} + {!widgetPicker && 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 + ? '_' + : ''} + + + + )} + {!widgetPicker && editorMode === 'color' && !colorEditorState.hexInputMode && !colorEditorState.ansi256InputMode && (() => { + const selectedWidget = widgets[selectedIndex]; + if (!selectedWidget) { + return null; + } + + const isSep = selectedWidget.type === 'separator' || selectedWidget.type === 'flex-separator'; + if (isSep) { + return null; + } + + const { colorIndex, totalColors, displayName } = getCurrentColorInfo( + selectedWidget, + colorEditorState.editingBackground + ); + + const colorType = colorEditorState.editingBackground ? 'background' : 'foreground'; + const colorNumber = colorIndex === -1 ? 'custom' : `${colorIndex}/${totalColors}`; + + const level = getColorLevelString(settings.colorLevel); + const styledColor = colorEditorState.editingBackground + ? applyColors(` ${displayName} `, undefined, selectedWidget.backgroundColor, false, level) + : applyColors(displayName, selectedWidget.color, undefined, false, level); + + return ( + + + Current + {' '} + {colorType} + {' '} + ( + {colorNumber} + ): + {' '} + {styledColor} + {selectedWidget.bold && [BOLD]} + + + ); + })()} {!widgetPicker && ( {widgets.length === 0 ? ( @@ -506,30 +1161,144 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB <> {widgets.map((widget, index) => { const isSelected = index === selectedIndex; - const widgetImpl = widget.type !== 'separator' && widget.type !== 'flex-separator' ? getWidget(widget.type) : null; + const isSep = widget.type === 'separator' || widget.type === 'flex-separator'; + const widgetImpl = !isSep ? getWidget(widget.type) : null; const { displayText, modifierText } = widgetImpl?.getEditorDisplay(widget) ?? { displayText: getWidgetDisplay(widget) }; const supportsRawValue = widgetImpl?.supportsRawValue() ?? false; + const inColorMode = editorMode === 'color'; + const isExpanded = expandedWidgetId === widget.id; + + // Determine selector color: blue for move, magenta for color, green for items + const selectorColor = moveMode ? 'blue' : inColorMode ? 'magenta' : 'green'; + + // When rules are expanded, parent widget loses its selector arrow + const showParentSelector = isSelected && !isExpanded; + + // Build styled label for color mode + let styledLabel: string | undefined; + if (inColorMode && !isSep && widgetImpl) { + const colorLevel = getColorLevelString(settings.colorLevel); + const defaultColor = widgetImpl.getDefaultColor(); + const fgColor = widget.color ?? defaultColor; + const bgColor = widget.backgroundColor; + const boldFlag = widget.bold ?? false; + styledLabel = applyColors( + displayText || getWidgetDisplay(widget), + fgColor, + bgColor, + boldFlag, + colorLevel + ); + } + + // Build rule rows for expanded widget + const rules = isExpanded ? (widget.rules ?? []) : []; return ( - - - - {isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '} - + + + + + {showParentSelector ? (moveMode ? '◆ ' : '▶ ') : ' '} + + + {inColorMode && isSep ? ( + + {`${index + 1}. ${displayText || getWidgetDisplay(widget)}`} + + ) : inColorMode && styledLabel ? ( + + {`${index + 1}. `} + {styledLabel} + + ) : ( + + {`${index + 1}. ${displayText || getWidgetDisplay(widget)}`} + + )} + {modifierText && ( + + {' '} + {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→)} + {!isExpanded && widget.rules && widget.rules.length > 0 && ( + + {' '} + ( + {widget.rules.length} + {' '} + rule + {widget.rules.length === 1 ? '' : 's'} + ) + + )} - - {`${index + 1}. ${displayText || getWidgetDisplay(widget)}`} - - {modifierText && ( - - {' '} - {modifierText} - + {isExpanded && rules.length === 0 && ( + + + {' '} + + + {' '} + + (no rules — press 'a' to add) + )} - {supportsRawValue && widget.rawValue && (raw value)} - {widget.merge === true && (merged→)} - {widget.merge === 'no-padding' && (merged-no-pad→)} - + {isExpanded && rules.map((rule, ruleIndex) => { + const isRuleSelected = ruleIndex === ruleSelectedIndex; + const condition = formatCondition(rule.when); + const stopIndicator = rule.stop ? ' (stop)' : ''; + const appliedProps = formatAppliedProperties(rule.apply, widget); + + // Get display name for rule line + const ruleDisplayName = widgetImpl + ? (widgetImpl.getEditorDisplay(widget).displayText || getWidgetDisplay(widget)) + : getWidgetDisplay(widget); + + // Get effective colors via mergeWidgetWithRuleApply + const tempWidget = mergeWidgetWithRuleApply(widget, rule.apply); + const colorLevel = getColorLevelString(settings.colorLevel); + const effectiveColor = tempWidget.color ?? widgetImpl?.getDefaultColor() ?? 'white'; + const effectiveBg = tempWidget.backgroundColor; + const effectiveBold = tempWidget.bold ?? false; + const ruleStyledLabel = applyColors(ruleDisplayName, effectiveColor, effectiveBg, effectiveBold, colorLevel); + + // Selector colors: green in property mode, magenta in color mode, blue in move mode + const ruleSelectorColor = ruleMoveMode ? 'blue' : ruleEditorMode === 'color' ? 'magenta' : 'green'; + + return ( + + + {' '} + + + + {isRuleSelected ? (ruleMoveMode ? '◆ ' : '▶ ') : ' '} + + + + {`${ruleIndex + 1}.`} + {ruleMoveMode ? ruleDisplayName : ruleStyledLabel} + + + {` (${condition})${stopIndicator}${appliedProps}`} + + + ); + })} + ); })} {/* Display description for selected widget */} diff --git a/src/tui/components/LineSelector.tsx b/src/tui/components/LineSelector.tsx index 771d9ef7..42553e2a 100644 --- a/src/tui/components/LineSelector.tsx +++ b/src/tui/components/LineSelector.tsx @@ -288,18 +288,13 @@ const LineSelector: React.FC = ({ marginTop={1} items={lineItems} onSelect={(line) => { - if (line === 'back') { - onBack(); - return; - } - onSelect(line); }} onSelectionChange={(_, index) => { setSelectedIndex(index); }} + onBack={onBack} initialSelection={selectedIndex} - showBackButton={true} /> )} diff --git a/src/tui/components/List.tsx b/src/tui/components/List.tsx index 48af0a29..325bc880 100644 --- a/src/tui/components/List.tsx +++ b/src/tui/components/List.tsx @@ -7,7 +7,6 @@ import { } from 'ink'; import { useEffect, - useMemo, useRef, useState, type PropsWithChildren @@ -24,10 +23,10 @@ export interface ListEntry { interface ListProps extends BoxProps { items: (ListEntry | '-')[]; - onSelect: (value: V | 'back', index: number) => void; - onSelectionChange?: (value: V | 'back', index: number) => void; + onSelect: (value: V, index: number) => void; + onSelectionChange?: (value: V, index: number) => void; + onBack?: () => void; initialSelection?: number; - showBackButton?: boolean; color?: ForegroundColorName; wrapNavigation?: boolean; } @@ -36,26 +35,19 @@ export function List({ items, onSelect, onSelectionChange, + onBack, initialSelection = 0, - showBackButton, color, - wrapNavigation = false, + wrapNavigation = true, ...boxProps }: ListProps) { const [selectedIndex, setSelectedIndex] = useState(initialSelection); const latestOnSelectionChangeRef = useRef(onSelectionChange); - const _items = useMemo(() => { - if (showBackButton) { - return [...items, '-' as const, { label: '← Back', value: 'back' as V }]; - } - return items; - }, [items, showBackButton]); - - const selectableItems = _items.filter(item => item !== '-' && !item.disabled) as ListEntry[]; + const selectableItems = items.filter(item => item !== '-' && !item.disabled) as ListEntry[]; const selectedItem = selectableItems[selectedIndex]; const selectedValue = selectedItem?.value; - const actualIndex = _items.findIndex(item => item === selectedItem); + const actualIndex = items.findIndex(item => item === selectedItem); useEffect(() => { latestOnSelectionChangeRef.current = onSelectionChange; @@ -97,11 +89,21 @@ export function List({ onSelect(selectedItem.value, selectedIndex); return; } + + if (key.rightArrow && selectedItem) { + onSelect(selectedItem.value, selectedIndex); + return; + } + + if (key.leftArrow) { + if (onBack) { onBack(); } + return; + } }); return ( - {_items.map((item, index) => { + {items.map((item, index) => { if (item === '-') { return ; } diff --git a/src/tui/components/MainMenu.tsx b/src/tui/components/MainMenu.tsx index f0148509..aa648c91 100644 --- a/src/tui/components/MainMenu.tsx +++ b/src/tui/components/MainMenu.tsx @@ -10,7 +10,6 @@ import { type PowerlineFontStatus } from '../../utils/powerline'; import { List } from './List'; export type MainMenuOption = 'lines' - | 'colors' | 'powerline' | 'terminalConfig' | 'globalOverrides' @@ -50,12 +49,6 @@ export const MainMenu: React.FC = ({ description: 'Configure any number of status lines with various widgets like model info, git status, and token usage' }, - { - label: '🎨 Edit Colors', - value: 'colors', - description: - 'Customize colors for each widget including foreground, background, and bold styling' - }, { label: '⚡ Powerline Setup', value: 'powerline', @@ -142,10 +135,6 @@ export const MainMenu: React.FC = ({ items={menuItems} marginTop={1} onSelect={(value, index) => { - if (value === 'back') { - return; - } - onSelect(value, index); }} initialSelection={initialSelection} diff --git a/src/tui/components/PowerlineSeparatorEditor.tsx b/src/tui/components/PowerlineSeparatorEditor.tsx index c14021f1..11837d49 100644 --- a/src/tui/components/PowerlineSeparatorEditor.tsx +++ b/src/tui/components/PowerlineSeparatorEditor.tsx @@ -43,6 +43,7 @@ export const PowerlineSeparatorEditor: React.FC = : []; const [selectedIndex, setSelectedIndex] = useState(0); + const [focusMode, setFocusMode] = useState(false); const [hexInputMode, setHexInputMode] = useState(false); const [hexInput, setHexInput] = useState(''); const [cursorPos, setCursorPos] = useState(0); @@ -113,7 +114,7 @@ export const PowerlineSeparatorEditor: React.FC = useInput((input, key) => { if (hexInputMode) { // Hex input mode - if (key.escape) { + if (key.escape || key.leftArrow) { setHexInputMode(false); setHexInput(''); setCursorPos(0); @@ -142,14 +143,10 @@ export const PowerlineSeparatorEditor: React.FC = setHexInput(hexInput.slice(0, cursorPos) + input.toUpperCase() + hexInput.slice(cursorPos)); setCursorPos(cursorPos + 1); } - } else { - // Normal mode - if (key.escape) { - onBack(); - } else if (key.upArrow) { - setSelectedIndex(Math.max(0, selectedIndex - 1)); - } else if (key.downArrow && separators.length > 0) { - setSelectedIndex(Math.min(separators.length - 1, selectedIndex + 1)); + } else if (focusMode) { + // Focus mode: edit the selected separator + if (key.escape || key.return) { + setFocusMode(false); } else if ((key.leftArrow || key.rightArrow) && separators.length > 0) { // Cycle through preset separators const currentChar = separators[selectedIndex] ?? '\uE0B0'; @@ -159,18 +156,16 @@ export const PowerlineSeparatorEditor: React.FC = let newIndex; if (currentPresetIndex !== -1) { - // It's a preset, cycle to next/prev preset if (key.rightArrow) { newIndex = (currentPresetIndex + 1) % presetSeparators.length; } else { newIndex = currentPresetIndex === 0 ? presetSeparators.length - 1 : currentPresetIndex - 1; } } else { - // It's a custom separator, cycle to first or last preset if (key.rightArrow) { - newIndex = 0; // Go to first preset + newIndex = 0; } else { - newIndex = presetSeparators.length - 1; // Go to last preset + newIndex = presetSeparators.length - 1; } } @@ -184,14 +179,65 @@ export const PowerlineSeparatorEditor: React.FC = } updateSeparators(newSeparators, mode === 'separator' ? newInvertBgs : undefined); - } else if ((input === 'a' || input === 'A') && (mode === 'separator' || separators.length < 3)) { + } else if (key.upArrow && separators.length > 1) { + // Reorder: move selected item up + if (selectedIndex > 0) { + const newSeparators = [...separators]; + const newInvertBgs = mode === 'separator' ? [...invertBgs] : []; + const temp = newSeparators[selectedIndex - 1] ?? ''; + newSeparators[selectedIndex - 1] = newSeparators[selectedIndex] ?? ''; + newSeparators[selectedIndex] = temp; + if (mode === 'separator') { + const tempInvert = newInvertBgs[selectedIndex - 1] ?? false; + newInvertBgs[selectedIndex - 1] = newInvertBgs[selectedIndex] ?? false; + newInvertBgs[selectedIndex] = tempInvert; + } + updateSeparators(newSeparators, mode === 'separator' ? newInvertBgs : undefined); + setSelectedIndex(selectedIndex - 1); + } + } else if (key.downArrow && separators.length > 1) { + // Reorder: move selected item down + if (selectedIndex < separators.length - 1) { + const newSeparators = [...separators]; + const newInvertBgs = mode === 'separator' ? [...invertBgs] : []; + const temp = newSeparators[selectedIndex + 1] ?? ''; + newSeparators[selectedIndex + 1] = newSeparators[selectedIndex] ?? ''; + newSeparators[selectedIndex] = temp; + if (mode === 'separator') { + const tempInvert = newInvertBgs[selectedIndex + 1] ?? false; + newInvertBgs[selectedIndex + 1] = newInvertBgs[selectedIndex] ?? false; + newInvertBgs[selectedIndex] = tempInvert; + } + updateSeparators(newSeparators, mode === 'separator' ? newInvertBgs : undefined); + setSelectedIndex(selectedIndex + 1); + } + } else { + const cmd = input.toLowerCase(); + if (cmd === 't' && mode === 'separator') { + // Toggle background inversion + const newInvertBgs = [...invertBgs]; + newInvertBgs[selectedIndex] = !(newInvertBgs[selectedIndex] ?? false); + updateSeparators(separators, newInvertBgs); + } + } + } else { + // Normal mode + const cmd = input.toLowerCase(); + if (key.escape || key.leftArrow) { + onBack(); + } else if (key.upArrow && separators.length > 0) { + setSelectedIndex(selectedIndex <= 0 ? separators.length - 1 : selectedIndex - 1); + } else if (key.downArrow && separators.length > 0) { + setSelectedIndex(selectedIndex >= separators.length - 1 ? 0 : selectedIndex + 1); + } else if (key.return && separators.length > 0) { + setFocusMode(true); + } else if (cmd === 'a' && (mode === 'separator' || separators.length < 3)) { // Add after current (max 3 for caps) const newSeparators = [...separators]; const newInvertBgs = mode === 'separator' ? [...invertBgs] : []; const defaultChar = presetSeparators[0]?.char ?? '\uE0B0'; const isLeftFacing = defaultChar === '\uE0B2' || defaultChar === '\uE0B6'; if (separators.length === 0) { - // If empty, just add at the beginning newSeparators.push(defaultChar); if (mode === 'separator') { newInvertBgs.push(isLeftFacing); @@ -199,7 +245,6 @@ export const PowerlineSeparatorEditor: React.FC = updateSeparators(newSeparators, newInvertBgs); setSelectedIndex(0); } else { - // Add after current selected item newSeparators.splice(selectedIndex + 1, 0, defaultChar); if (mode === 'separator') { newInvertBgs.splice(selectedIndex + 1, 0, isLeftFacing); @@ -207,14 +252,13 @@ export const PowerlineSeparatorEditor: React.FC = updateSeparators(newSeparators, newInvertBgs); setSelectedIndex(selectedIndex + 1); } - } else if ((input === 'i' || input === 'I') && (mode === 'separator' || separators.length < 3)) { + } else if (cmd === 'i' && (mode === 'separator' || separators.length < 3)) { // Insert before current (max 3 for caps) const newSeparators = [...separators]; const newInvertBgs = mode === 'separator' ? [...invertBgs] : []; const defaultChar = presetSeparators[0]?.char ?? '\uE0B0'; const isLeftFacing = defaultChar === '\uE0B2' || defaultChar === '\uE0B6'; if (separators.length === 0) { - // If empty, just add at the beginning newSeparators.push(defaultChar); if (mode === 'separator') { newInvertBgs.push(isLeftFacing); @@ -222,39 +266,31 @@ export const PowerlineSeparatorEditor: React.FC = updateSeparators(newSeparators, newInvertBgs); setSelectedIndex(0); } else { - // Insert before current selected item newSeparators.splice(selectedIndex, 0, defaultChar); if (mode === 'separator') { newInvertBgs.splice(selectedIndex, 0, isLeftFacing); } updateSeparators(newSeparators, newInvertBgs); - // Keep selection on the newly inserted item (which is now at selectedIndex) } - } else if ((input === 'd' || input === 'D') && (mode !== 'separator' || separators.length > 1)) { + } else if (cmd === 'd' && (mode !== 'separator' || separators.length > 1)) { // Delete current (min 1 for separator, no min for caps) const newSeparators = separators.filter((_, i) => i !== selectedIndex); const newInvertBgs = mode === 'separator' ? invertBgs.filter((_, i) => i !== selectedIndex) : []; updateSeparators(newSeparators, newInvertBgs); setSelectedIndex(Math.min(selectedIndex, Math.max(0, newSeparators.length - 1))); - } else if (input === 'c' || input === 'C') { + } else if (cmd === 'c') { // Clear all if (mode === 'separator') { - // Reset to default right-facing separator with no inversion updateSeparators(['\uE0B0'], [false]); } else { updateSeparators([]); } setSelectedIndex(0); - } else if (input === 'h' || input === 'H') { + } else if (cmd === 'h') { // Enter hex input mode setHexInputMode(true); setHexInput(''); setCursorPos(0); - } else if ((input === 't' || input === 'T') && mode === 'separator') { - // Toggle background inversion (for all separators in separator mode) - const newInvertBgs = [...invertBgs]; - newInvertBgs[selectedIndex] = !(newInvertBgs[selectedIndex] ?? false); - updateSeparators(separators, newInvertBgs); } } }); @@ -300,7 +336,9 @@ export const PowerlineSeparatorEditor: React.FC = <> - {`↑↓ select, ← → cycle${canAdd ? ', (a)dd, (i)nsert' : ''}${canDelete ? ', (d)elete' : ''}, (c)lear, (h)ex${mode === 'separator' ? ', (t)oggle invert' : ''}, ESC back`} + {focusMode + ? `←→ cycle preset, ↑↓ reorder${mode === 'separator' ? ', (t)oggle invert' : ''}, Enter/ESC exit` + : `↑↓ select, Enter: edit${canAdd ? ', (a)dd, (i)nsert' : ''}${canDelete ? ', (d)elete' : ''}, (c)lear, (h)ex, ← back`} @@ -308,8 +346,8 @@ export const PowerlineSeparatorEditor: React.FC = {separators.length > 0 ? ( separators.map((sep, index) => ( - - {index === selectedIndex ? '▶ ' : ' '} + + {index === selectedIndex ? (focusMode ? '✎ ' : '▶ ') : ' '} {`${index + 1}: ${getSeparatorDisplay(sep, index)}`} diff --git a/src/tui/components/PowerlineSetup.tsx b/src/tui/components/PowerlineSetup.tsx index c00c281d..3470ca39 100644 --- a/src/tui/components/PowerlineSetup.tsx +++ b/src/tui/components/PowerlineSetup.tsx @@ -183,9 +183,10 @@ export const PowerlineSetup: React.FC = ({ } if (screen === 'menu') { + const cmd = input.toLowerCase(); if (key.escape) { onBack(); - } else if (input === 't' || input === 'T') { + } else if (cmd === 't') { if (!powerlineConfig.enabled) { if (hasSeparatorItems) { setConfirmingEnable(true); @@ -201,9 +202,9 @@ export const PowerlineSetup: React.FC = ({ } }); } - } else if (input === 'i' || input === 'I') { + } else if (cmd === 'i') { setConfirmingFontInstall(true); - } else if ((input === 'a' || input === 'A') && powerlineConfig.enabled) { + } else if (cmd === 'a' && powerlineConfig.enabled) { onUpdate({ ...settings, powerline: { @@ -422,19 +423,14 @@ export const PowerlineSetup: React.FC = ({ { - if (value === 'back') { - onBack(); - return; - } - setScreen(value); }} onSelectionChange={(_, index) => { setSelectedMenuItem(index); }} initialSelection={selectedMenuItem} - showBackButton={true} /> )} diff --git a/src/tui/components/PowerlineThemeSelector.tsx b/src/tui/components/PowerlineThemeSelector.tsx index ed2eb8b4..7b8c8da5 100644 --- a/src/tui/components/PowerlineThemeSelector.tsx +++ b/src/tui/components/PowerlineThemeSelector.tsx @@ -139,10 +139,11 @@ export const PowerlineThemeSelector: React.FC = ({ return; } + const cmd = input.toLowerCase(); if (key.escape) { onUpdate(originalSettingsRef.current); onBack(); - } else if (input === 'c' || input === 'C') { + } else if (cmd === 'c') { const currentThemeName = themes[selectedIndex]; if (currentThemeName && currentThemeName !== 'custom') { setShowCustomizeConfirm(true); @@ -207,14 +208,14 @@ export const PowerlineThemeSelector: React.FC = ({ { + onUpdate(originalSettingsRef.current); + onBack(); + }} onSelect={() => { onBack(); }} onSelectionChange={(themeName, index) => { - if (themeName === 'back') { - return; - } - setSelectedIndex(index); }} initialSelection={selectedIndex} 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/TerminalOptionsMenu.tsx b/src/tui/components/TerminalOptionsMenu.tsx index 27c75c3b..c7ed36ae 100644 --- a/src/tui/components/TerminalOptionsMenu.tsx +++ b/src/tui/components/TerminalOptionsMenu.tsx @@ -72,12 +72,7 @@ export const TerminalOptionsMenu: React.FC = ({ const [showColorWarning, setShowColorWarning] = useState(false); const [pendingColorLevel, setPendingColorLevel] = useState<0 | 1 | 2 | 3 | null>(null); - const handleSelect = (value: TerminalOptionsValue | 'back') => { - if (value === 'back') { - onBack(); - return; - } - + const handleSelect = (value: TerminalOptionsValue) => { if (value === 'width') { onBack('width'); return; @@ -154,7 +149,7 @@ export const TerminalOptionsMenu: React.FC = ({ marginTop={1} items={buildTerminalOptionsItems(settings.colorLevel)} onSelect={handleSelect} - showBackButton={true} + onBack={onBack} /> )} diff --git a/src/tui/components/TerminalWidthMenu.tsx b/src/tui/components/TerminalWidthMenu.tsx index 01a4dc7f..c4f432b4 100644 --- a/src/tui/components/TerminalWidthMenu.tsx +++ b/src/tui/components/TerminalWidthMenu.tsx @@ -150,12 +150,8 @@ export const TerminalWidthMenu: React.FC = ({ marginTop={1} items={buildTerminalWidthItems(selectedOption, compactThreshold)} initialSelection={getTerminalWidthSelectionIndex(selectedOption)} + onBack={onBack} onSelect={(value) => { - if (value === 'back') { - onBack(); - return; - } - setSelectedOption(value); const updatedSettings = { @@ -169,7 +165,6 @@ export const TerminalWidthMenu: React.FC = ({ setEditingThreshold(true); } }} - showBackButton={true} /> )} 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..72e8114b --- /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 ItemsEditor and rules-editor input handlers + * 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/color-menu/__tests__/mutations.test.ts b/src/tui/components/color-menu/__tests__/mutations.test.ts deleted file mode 100644 index bbd68a13..00000000 --- a/src/tui/components/color-menu/__tests__/mutations.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { - describe, - expect, - it -} from 'vitest'; - -import type { WidgetItem } from '../../../../types/Widget'; -import { - clearAllWidgetStyling, - cycleWidgetColor, - resetWidgetStyling, - toggleWidgetBold, - updateWidgetById -} from '../mutations'; - -describe('color-menu mutations', () => { - it('updateWidgetById only updates the matching widget', () => { - const widgets: WidgetItem[] = [ - { id: '1', type: 'tokens-input', color: 'blue' }, - { id: '2', type: 'tokens-output', color: 'white' } - ]; - - const updated = updateWidgetById(widgets, '1', widget => ({ - ...widget, - color: 'red' - })); - - expect(updated[0]?.color).toBe('red'); - expect(updated[1]?.color).toBe('white'); - }); - - it('toggleWidgetBold flips bold state for the selected widget only', () => { - const widgets: WidgetItem[] = [ - { id: '1', type: 'tokens-input', bold: true }, - { id: '2', type: 'tokens-output', bold: false } - ]; - - const updated = toggleWidgetBold(widgets, '1'); - - expect(updated[0]?.bold).toBe(false); - expect(updated[1]?.bold).toBe(false); - }); - - it('resetWidgetStyling removes color, backgroundColor, and bold from one widget', () => { - const widgets: WidgetItem[] = [ - { - id: '1', - type: 'tokens-input', - color: 'red', - backgroundColor: 'blue', - bold: true - }, - { id: '2', type: 'tokens-output', color: 'white', bold: true } - ]; - - const updated = resetWidgetStyling(widgets, '1'); - - expect(updated[0]).toEqual({ id: '1', type: 'tokens-input' }); - expect(updated[1]).toEqual({ id: '2', type: 'tokens-output', color: 'white', bold: true }); - }); - - it('clearAllWidgetStyling strips styling fields from every widget', () => { - const widgets: WidgetItem[] = [ - { - id: '1', - type: 'tokens-input', - color: 'red', - backgroundColor: 'blue', - bold: true - }, - { id: '2', type: 'tokens-output', color: 'white', bold: true } - ]; - - const updated = clearAllWidgetStyling(widgets); - - expect(updated).toEqual([ - { id: '1', type: 'tokens-input' }, - { id: '2', type: 'tokens-output' } - ]); - }); - - it('cycles background colors and maps empty background to undefined', () => { - const widgets: WidgetItem[] = [ - { id: '1', type: 'tokens-input', backgroundColor: 'bg:red' } - ]; - - const right = cycleWidgetColor({ - widgets, - widgetId: '1', - direction: 'right', - editingBackground: true, - colors: ['blue', 'red'], - backgroundColors: ['bg:red', ''] - }); - const left = cycleWidgetColor({ - widgets: right, - widgetId: '1', - direction: 'left', - editingBackground: true, - colors: ['blue', 'red'], - backgroundColors: ['bg:red', ''] - }); - - expect(right[0]?.backgroundColor).toBeUndefined(); - expect(left[0]?.backgroundColor).toBe('bg:red'); - }); - - it('cycles foreground colors from widget default and treats dim as default', () => { - const fromDefault: WidgetItem[] = [ - { id: '1', type: 'tokens-input' } - ]; - const fromDim: WidgetItem[] = [ - { id: '1', type: 'tokens-input', color: 'dim' } - ]; - - const defaultCycle = cycleWidgetColor({ - widgets: fromDefault, - widgetId: '1', - direction: 'right', - editingBackground: false, - colors: ['blue', 'red'], - backgroundColors: ['bg:red', ''] - }); - const dimCycle = cycleWidgetColor({ - widgets: fromDim, - widgetId: '1', - direction: 'right', - editingBackground: false, - colors: ['blue', 'red'], - backgroundColors: ['bg:red', ''] - }); - - expect(defaultCycle[0]?.color).toBe('red'); - expect(dimCycle[0]?.color).toBe('red'); - }); -}); \ No newline at end of file diff --git a/src/tui/components/color-menu/mutations.ts b/src/tui/components/color-menu/mutations.ts deleted file mode 100644 index 1556d4b1..00000000 --- a/src/tui/components/color-menu/mutations.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { WidgetItem } from '../../../types/Widget'; -import { getWidget } from '../../../utils/widgets'; - -export function updateWidgetById( - widgets: WidgetItem[], - widgetId: string, - updater: (widget: WidgetItem) => WidgetItem -): WidgetItem[] { - return widgets.map(widget => widget.id === widgetId ? updater(widget) : widget); -} - -export function setWidgetColor( - widgets: WidgetItem[], - widgetId: string, - color: string, - editingBackground: boolean -): WidgetItem[] { - return updateWidgetById(widgets, widgetId, (widget) => { - if (editingBackground) { - return { - ...widget, - backgroundColor: color - }; - } - - return { - ...widget, - color - }; - }); -} - -export function toggleWidgetBold(widgets: WidgetItem[], widgetId: string): WidgetItem[] { - return updateWidgetById(widgets, widgetId, widget => ({ - ...widget, - bold: !widget.bold - })); -} - -export function resetWidgetStyling(widgets: WidgetItem[], widgetId: string): WidgetItem[] { - return updateWidgetById(widgets, widgetId, (widget) => { - const { - color, - backgroundColor, - bold, - ...restWidget - } = widget; - void color; // Intentionally unused - void backgroundColor; // Intentionally unused - void bold; // Intentionally unused - return restWidget; - }); -} - -export function clearAllWidgetStyling(widgets: WidgetItem[]): WidgetItem[] { - return widgets.map((widget) => { - const { - color, - backgroundColor, - bold, - ...restWidget - } = widget; - void color; // Intentionally unused - void backgroundColor; // Intentionally unused - void bold; // Intentionally unused - return restWidget; - }); -} - -function getDefaultForegroundColor(widget: WidgetItem): string { - if (widget.type === 'separator' || widget.type === 'flex-separator') { - return 'white'; - } - - const widgetImpl = getWidget(widget.type); - return widgetImpl ? widgetImpl.getDefaultColor() : 'white'; -} - -function getNextIndex(currentIndex: number, length: number, direction: 'left' | 'right'): number { - if (direction === 'right') { - return (currentIndex + 1) % length; - } - - return currentIndex === 0 ? length - 1 : currentIndex - 1; -} - -export interface CycleWidgetColorOptions { - widgets: WidgetItem[]; - widgetId: string; - direction: 'left' | 'right'; - editingBackground: boolean; - colors: string[]; - backgroundColors: string[]; -} - -export function cycleWidgetColor({ - widgets, - widgetId, - direction, - editingBackground, - colors, - backgroundColors -}: CycleWidgetColorOptions): WidgetItem[] { - return updateWidgetById(widgets, widgetId, (widget) => { - if (editingBackground) { - if (backgroundColors.length === 0) { - return widget; - } - - const currentBgColor = widget.backgroundColor ?? ''; - let currentBgColorIndex = backgroundColors.indexOf(currentBgColor); - if (currentBgColorIndex === -1) { - currentBgColorIndex = 0; - } - - const nextBgColorIndex = getNextIndex(currentBgColorIndex, backgroundColors.length, direction); - const nextBgColor = backgroundColors[nextBgColorIndex]; - - return { - ...widget, - backgroundColor: nextBgColor === '' ? undefined : nextBgColor - }; - } - - if (colors.length === 0) { - return widget; - } - - const defaultColor = getDefaultForegroundColor(widget); - let currentColor = widget.color ?? defaultColor; - if (currentColor === 'dim') { - currentColor = defaultColor; - } - - let currentColorIndex = colors.indexOf(currentColor); - if (currentColorIndex === -1) { - currentColorIndex = 0; - } - - const nextColorIndex = getNextIndex(currentColorIndex, colors.length, direction); - const nextColor = colors[nextColorIndex]; - - return { - ...widget, - color: nextColor - }; - }); -} \ No newline at end of file diff --git a/src/tui/components/index.ts b/src/tui/components/index.ts index 34afc2e6..d19ba943 100644 --- a/src/tui/components/index.ts +++ b/src/tui/components/index.ts @@ -1,5 +1,4 @@ // Barrel file - exports all components and their types -export * from './ColorMenu'; export * from './ConfirmDialog'; export * from './GlobalOverridesMenu'; export * from './InstallMenu'; 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..78e029f9 100644 --- a/src/tui/components/items-editor/__tests__/input-handlers.test.ts +++ b/src/tui/components/items-editor/__tests__/input-handlers.test.ts @@ -137,7 +137,8 @@ describe('items-editor input handlers', () => { selectedIndex: 1, onUpdate, setSelectedIndex, - setMoveMode + setMoveMode, + openWidgetPicker: vi.fn() }); expect(onUpdate).toHaveBeenCalledWith([ @@ -160,6 +161,7 @@ describe('items-editor input handlers', () => { widgets, selectedIndex: 0, separatorChars: ['|', '-'], + expandedWidgetId: null, onBack: vi.fn(), onUpdate, setSelectedIndex: vi.fn(), @@ -167,7 +169,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(), + toggleRulesExpansion: vi.fn() }); const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; @@ -186,6 +189,7 @@ describe('items-editor input handlers', () => { widgets, selectedIndex: 0, separatorChars: ['|', '-'], + expandedWidgetId: null, onBack: vi.fn(), onUpdate, setSelectedIndex: vi.fn(), @@ -193,7 +197,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(), + toggleRulesExpansion: vi.fn() }); const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; @@ -212,6 +217,7 @@ describe('items-editor input handlers', () => { widgets, selectedIndex: 0, separatorChars: ['|', '-'], + expandedWidgetId: null, onBack: vi.fn(), onUpdate, setSelectedIndex: vi.fn(), @@ -219,7 +225,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(), + toggleRulesExpansion: vi.fn() }); const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; @@ -238,6 +245,7 @@ describe('items-editor input handlers', () => { widgets, selectedIndex: 0, separatorChars: ['|', '-'], + expandedWidgetId: null, onBack: vi.fn(), onUpdate, setSelectedIndex: vi.fn(), @@ -245,7 +253,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(), + toggleRulesExpansion: vi.fn() }); const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; @@ -265,6 +274,7 @@ describe('items-editor input handlers', () => { widgets, selectedIndex: 0, separatorChars: ['|', '-'], + expandedWidgetId: null, onBack: vi.fn(), onUpdate, setSelectedIndex: vi.fn(), @@ -272,7 +282,8 @@ describe('items-editor input handlers', () => { setShowClearConfirm: vi.fn(), openWidgetPicker: vi.fn(), getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], - setCustomEditorWidget + setCustomEditorWidget, + toggleRulesExpansion: 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..a5c4963b 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( @@ -293,6 +285,7 @@ export interface HandleMoveInputModeArgs { onUpdate: (widgets: WidgetItem[]) => void; setSelectedIndex: (index: number) => void; setMoveMode: (moveMode: boolean) => void; + openWidgetPicker: (action: WidgetPickerAction) => void; } export function handleMoveInputMode({ @@ -301,7 +294,8 @@ export function handleMoveInputMode({ selectedIndex, onUpdate, setSelectedIndex, - setMoveMode + setMoveMode, + openWidgetPicker }: HandleMoveInputModeArgs): void { if (key.upArrow && selectedIndex > 0) { const newWidgets = [...widgets]; @@ -321,17 +315,90 @@ export function handleMoveInputMode({ } onUpdate(newWidgets); setSelectedIndex(selectedIndex + 1); + } else if (key.leftArrow || key.rightArrow) { + openWidgetPicker('change'); } else if (key.escape || key.return) { setMoveMode(false); } } +/** + * Handle widget property editing (raw value, merge, custom keybinds) + * Shared between ItemsEditor and rules-editor input handlers + */ +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; widgets: WidgetItem[]; selectedIndex: number; separatorChars: string[]; + expandedWidgetId: string | null; onBack: () => void; onUpdate: (widgets: WidgetItem[]) => void; setSelectedIndex: (index: number) => void; @@ -340,6 +407,7 @@ export interface HandleNormalInputModeArgs { openWidgetPicker: (action: WidgetPickerAction) => void; getCustomKeybindsForWidget: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[]; setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void; + toggleRulesExpansion: (widget: WidgetItem) => void; } export function handleNormalInputMode({ @@ -348,6 +416,7 @@ export function handleNormalInputMode({ widgets, selectedIndex, separatorChars, + expandedWidgetId, onBack, onUpdate, setSelectedIndex, @@ -355,16 +424,25 @@ export function handleNormalInputMode({ setShowClearConfirm, openWidgetPicker, getCustomKeybindsForWidget, - setCustomEditorWidget + setCustomEditorWidget, + toggleRulesExpansion }: HandleNormalInputModeArgs): void { if (key.upArrow && widgets.length > 0) { - setSelectedIndex(Math.max(0, selectedIndex - 1)); + setSelectedIndex(selectedIndex > 0 ? selectedIndex - 1 : widgets.length - 1); } else if (key.downArrow && widgets.length > 0) { - setSelectedIndex(Math.min(widgets.length - 1, selectedIndex + 1)); - } else if (key.leftArrow && widgets.length > 0) { - openWidgetPicker('change'); + setSelectedIndex(selectedIndex < widgets.length - 1 ? selectedIndex + 1 : 0); } else if (key.rightArrow && widgets.length > 0) { - openWidgetPicker('change'); + const currentWidget = widgets[selectedIndex]; + if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { + toggleRulesExpansion(currentWidget); + } + } else if (key.leftArrow && widgets.length > 0) { + const currentWidget = widgets[selectedIndex]; + if (currentWidget?.id === expandedWidgetId) { + toggleRulesExpansion(currentWidget); + } else if (expandedWidgetId === null) { + onBack(); + } } else if (key.return && widgets.length > 0) { setMoveMode(true); } else if (input === 'a') { @@ -377,10 +455,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 +467,29 @@ export function handleNormalInputMode({ newWidgets[selectedIndex] = { ...currentWidget, character: nextChar }; onUpdate(newWidgets); } - } else if (input === 'r' && 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); - } } 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/tui/components/rules-editor/formatting.ts b/src/tui/components/rules-editor/formatting.ts new file mode 100644 index 00000000..386d3483 --- /dev/null +++ b/src/tui/components/rules-editor/formatting.ts @@ -0,0 +1,131 @@ +import { + DISPLAY_OPERATOR_LABELS, + OPERATOR_LABELS, + getConditionNot, + getConditionOperator, + getConditionValue, + getConditionWidget, + getDisplayOperator, + isBooleanOperator, + isSetOperator, + isStringOperator +} from '../../../types/Condition'; +import type { WidgetItem } from '../../../types/Widget'; +import { mergeWidgetWithRuleApply } from '../../../utils/widget-properties'; +import { getWidget } from '../../../utils/widgets'; + +/** + * Format a rule condition into a human-readable summary string. + * + * Handles display operators (notEquals, notContains, etc.) and base operators + * with optional NOT prefix. + */ +export function 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 the widget's own display logic. + * + * Creates a temp widget by merging base widget with rule.apply, then uses + * the widget's getEditorDisplay to format modifier text alongside base + * property labels (raw value, merge, hidden, character). + */ +export function formatAppliedProperties( + apply: Record, + baseWidget: WidgetItem +): 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(baseWidget, apply); + + // Let the widget format its own modifiers (hide, remaining, etc.) + const widgetImpl = getWidget(baseWidget.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(' ')}` : ''; +} \ No newline at end of file diff --git a/src/tui/components/rules-editor/input-handlers.ts b/src/tui/components/rules-editor/input-handlers.ts new file mode 100644 index 00000000..3a0a1d68 --- /dev/null +++ b/src/tui/components/rules-editor/input-handlers.ts @@ -0,0 +1,360 @@ +import type { Settings } from '../../../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetItem +} from '../../../types/Widget'; +import type { InputKey } from '../../../utils/input-guards'; +import { + extractWidgetOverrides, + mergeWidgetWithRuleApply +} from '../../../utils/widget-properties'; +import { + handleColorInput, + type ColorEditorState +} from '../color-editor/input-handlers'; +import { + handleWidgetPropertyInput, + type CustomEditorWidgetState +} from '../items-editor/input-handlers'; + +export type { InputKey }; + +export interface HandleRulePropertyInputArgs { + input: string; + key: InputKey; + baseWidget: WidgetItem; + rule: { when: Record; apply: Record; stop?: boolean }; + ruleIndex: number; + onUpdate: (updatedWidget: WidgetItem) => void; + getCustomKeybindsForWidget: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[]; + setCustomEditorWidget?: (state: CustomEditorWidgetState | null) => void; +} + +/** + * Handle rule-specific property input keys: s (toggle stop), h (toggle hide), c (clear properties). + * After handling rule-specific keys, delegates to the shared handleWidgetPropertyInput + * for r (raw value), m (merge), and custom keybinds. + * + * All updates go through extractWidgetOverrides to store only diffs in rule.apply. + */ +export function handleRulePropertyInput({ + input, + key, + baseWidget, + rule, + ruleIndex, + onUpdate, + getCustomKeybindsForWidget, + setCustomEditorWidget +}: HandleRulePropertyInputArgs): boolean { + const rules = baseWidget.rules ?? []; + + // Toggle stop flag + if (input === 's') { + const newRules = [...rules]; + newRules[ruleIndex] = { + ...rule, + stop: !rule.stop + }; + onUpdate({ ...baseWidget, rules: newRules }); + return true; + } + + // Toggle hide flag + if (input === 'h') { + const tempWidget = mergeWidgetWithRuleApply(baseWidget, rule.apply); + const updatedWidget = { ...tempWidget, hide: !tempWidget.hide }; + + const newApply = extractWidgetOverrides(updatedWidget, baseWidget, rule.apply); + const newRules = [...rules]; + newRules[ruleIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...baseWidget, rules: newRules }); + return true; + } + + // Clear property overrides (preserve color/backgroundColor/bold/hide) + if (input === 'c') { + const newRules = [...rules]; + const { color, backgroundColor, bold, hide, ...restApply } = rule.apply; + + 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[ruleIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...baseWidget, rules: newRules }); + return true; + } + + // Delegate to shared widget property input handler (r, m, custom keybinds) + const tempWidget = mergeWidgetWithRuleApply(baseWidget, rule.apply); + + return handleWidgetPropertyInput({ + input, + key, + widget: tempWidget, + onUpdate: (updatedWidget) => { + const newApply = extractWidgetOverrides(updatedWidget, baseWidget, rule.apply); + const newRules = [...rules]; + newRules[ruleIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...baseWidget, rules: newRules }); + }, + getCustomKeybindsForWidget, + setCustomEditorWidget + }); +} + +// --- Color mode handler --- + +export interface HandleRuleColorInputArgs { + input: string; + key: InputKey; + baseWidget: WidgetItem; + rule: { when: Record; apply: Record; stop?: boolean }; + ruleIndex: number; + settings: Settings; + colorEditorState: ColorEditorState; + setColorEditorState: (updater: (prev: ColorEditorState) => ColorEditorState) => void; + onUpdate: (updatedWidget: WidgetItem) => void; +} + +/** + * Handle color mode input for a rule. + * Creates a temp widget via mergeWidgetWithRuleApply, delegates to shared handleColorInput, + * and routes onUpdate/onReset callbacks through extractWidgetOverrides. + * + * The onReset callback resets to BASE WIDGET colors (widget.color, widget.backgroundColor, + * widget.bold), not removing them entirely — this differs from widget-level color reset. + */ +export function handleRuleColorInput({ + input, + key, + baseWidget, + rule, + ruleIndex, + settings, + colorEditorState, + setColorEditorState, + onUpdate +}: HandleRuleColorInputArgs): boolean { + const rules = baseWidget.rules ?? []; + + // Create temp widget by merging base + apply + const tempWidget = mergeWidgetWithRuleApply(baseWidget, rule.apply); + + // Use shared color input handler + return handleColorInput({ + input, + key, + widget: tempWidget, + settings, + state: colorEditorState, + setState: setColorEditorState, + onUpdate: (updatedWidget) => { + // Extract what changed compared to base widget + const newApply = extractWidgetOverrides(updatedWidget, baseWidget, rule.apply); + const newRules = [...rules]; + newRules[ruleIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...baseWidget, rules: newRules }); + }, + onReset: () => { + // Reset colors to base widget (remove color/backgroundColor/bold from apply) + const resetWidget = { + ...tempWidget, + color: baseWidget.color, + backgroundColor: baseWidget.backgroundColor, + bold: baseWidget.bold + }; + const newApply = extractWidgetOverrides(resetWidget, baseWidget, rule.apply); + const newRules = [...rules]; + newRules[ruleIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...baseWidget, rules: newRules }); + } + }); +} + +// --- Move mode handler --- + +export interface HandleRuleMoveModeArgs { + key: { upArrow?: boolean; downArrow?: boolean; return?: boolean; escape?: boolean }; + baseWidget: WidgetItem; + selectedIndex: number; + setSelectedIndex: (index: number) => void; + setMoveMode: (moveMode: boolean) => void; + onUpdate: (updatedWidget: WidgetItem) => void; +} + +/** + * Handle move mode input: swap rules on up/down arrows, exit on Enter/ESC. + */ +export function handleRuleMoveMode({ + key, + baseWidget, + selectedIndex, + setSelectedIndex, + setMoveMode, + onUpdate +}: HandleRuleMoveModeArgs): void { + const rules = baseWidget.rules ?? []; + + 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({ ...baseWidget, 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({ ...baseWidget, rules: newRules }); + setSelectedIndex(selectedIndex + 1); + } else if (key.escape || key.return) { + setMoveMode(false); + } +} + +// --- Add/Delete rule functions --- + +export interface AddRuleArgs { + baseWidget: WidgetItem; + setSelectedIndex: (index: number) => void; + onUpdate: (updatedWidget: WidgetItem) => void; +} + +/** + * Add a new rule with placeholder condition { greaterThan: 50 } and empty apply. + * Selects the newly added rule. + */ +export function addRule({ + baseWidget, + setSelectedIndex, + onUpdate +}: AddRuleArgs): void { + const rules = baseWidget.rules ?? []; + + const newRule = { + when: { greaterThan: 50 }, + apply: {}, + stop: false + }; + + const newRules = [...rules, newRule]; + onUpdate({ ...baseWidget, rules: newRules }); + setSelectedIndex(newRules.length - 1); +} + +export interface DeleteRuleArgs { + baseWidget: WidgetItem; + selectedIndex: number; + setSelectedIndex: (index: number) => void; + onUpdate: (updatedWidget: WidgetItem) => void; +} + +/** + * Delete the rule at selectedIndex. Adjusts selection after delete: + * if selectedIndex >= newRules.length, decrements by 1. + */ +export function deleteRule({ + baseWidget, + selectedIndex, + setSelectedIndex, + onUpdate +}: DeleteRuleArgs): void { + const rules = baseWidget.rules ?? []; + + if (rules.length === 0) { + return; + } + + const newRules = rules.filter((_, i) => i !== selectedIndex); + onUpdate({ ...baseWidget, rules: newRules }); + + // Adjust selection after delete (same pattern as ItemsEditor) + if (selectedIndex >= newRules.length && selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1); + } +} + +// --- Custom editor completion handler --- + +export interface HandleRuleEditorCompleteArgs { + updatedWidget: WidgetItem; + baseWidget: WidgetItem; + selectedIndex: number; + onUpdate: (updatedWidget: WidgetItem) => void; + setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void; +} + +/** + * Handle custom editor widget completion while rules are expanded. + * Routes the completed widget through extractWidgetOverrides so only + * the diff is stored in rule.apply. + */ +export function handleRuleEditorComplete({ + updatedWidget, + baseWidget, + selectedIndex, + onUpdate, + setCustomEditorWidget +}: HandleRuleEditorCompleteArgs): void { + const rules = baseWidget.rules ?? []; + const rule = rules[selectedIndex]; + + if (!rule) { + setCustomEditorWidget(null); + return; + } + + // Extract what changed compared to base widget + const newApply = extractWidgetOverrides(updatedWidget, baseWidget, rule.apply); + const newRules = [...rules]; + newRules[selectedIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...baseWidget, rules: newRules }); + setCustomEditorWidget(null); +} \ 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..1e2d98fb --- /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 ItemsEditor 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 the accordion rules editor 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