From decf54a6dd43e07b25771410a2c2e506fe804f02 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Apr 2026 09:54:28 +1000 Subject: [PATCH 01/52] feat: add activeWidgetId state and Tab-swap callbacks to App Add App-level infrastructure for Tab-swap between ItemsEditor and ColorMenu screens. Introduces activeWidgetId state for widget-level position tracking, handleTabSwap callback to toggle between 'items' and 'colors' screens, and handleWidgetHighlight callback to update the active widget. Passes onTabSwap, onWidgetHighlight, and initialWidgetId props to both editors. Adds optional prop definitions to ItemsEditorProps and ColorMenuProps interfaces. --- src/tui/App.tsx | 15 +++++++++++++++ src/tui/components/ColorMenu.tsx | 3 +++ src/tui/components/ItemsEditor.tsx | 3 +++ 3 files changed, 21 insertions(+) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index e6bdb842a..d171890e6 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -108,6 +108,7 @@ export const App: React.FC = () => { const [screen, setScreen] = useState('main'); const [selectedLine, setSelectedLine] = useState(0); const [menuSelections, setMenuSelections] = useState>({}); + const [activeWidgetId, setActiveWidgetId] = useState(null); const [confirmDialog, setConfirmDialog] = useState(null); const [isClaudeInstalled, setIsClaudeInstalled] = useState(false); const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80); @@ -331,6 +332,14 @@ export const App: React.FC = () => { setScreen('items'); }; + const handleWidgetHighlight = useCallback((widgetId: string | null) => { + setActiveWidgetId(widgetId); + }, []); + + const handleTabSwap = useCallback(() => { + setScreen((prev) => (prev === 'items' ? 'colors' : 'items')); + }, []); + return ( @@ -408,6 +417,9 @@ export const App: React.FC = () => { }} lineNumber={selectedLine + 1} settings={settings} + onTabSwap={handleTabSwap} + onWidgetHighlight={handleWidgetHighlight} + initialWidgetId={activeWidgetId} /> )} {screen === 'colorLines' && ( @@ -446,6 +458,9 @@ export const App: React.FC = () => { // Go back to line selection for colors setScreen('colorLines'); }} + onTabSwap={handleTabSwap} + onWidgetHighlight={handleWidgetHighlight} + initialWidgetId={activeWidgetId} /> )} {screen === 'terminalConfig' && ( diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index 31b40805c..dc4d6d822 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -33,6 +33,9 @@ export interface ColorMenuProps { settings: Settings; onUpdate: (widgets: WidgetItem[]) => void; onBack: () => void; + onTabSwap?: () => void; + onWidgetHighlight?: (widgetId: string | null) => void; + initialWidgetId?: string | null; } export const ColorMenu: React.FC = ({ widgets, lineIndex, settings, onUpdate, onBack }) => { diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 42152cdcf..8b6c46827 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -40,6 +40,9 @@ export interface ItemsEditorProps { onBack: () => void; lineNumber: number; settings: Settings; + onTabSwap?: () => void; + onWidgetHighlight?: (widgetId: string | null) => void; + initialWidgetId?: string | null; } export const ItemsEditor: React.FC = ({ widgets, onUpdate, onBack, lineNumber, settings }) => { From 331bc09a86c4f7e78589f68a1f800b71f9100871 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Apr 2026 10:15:21 +1000 Subject: [PATCH 02/52] fix: move useCallback hooks above early return to satisfy Rules of Hooks --- src/tui/App.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index d171890e6..53b7b6d30 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -234,6 +234,14 @@ export const App: React.FC = () => { setScreen('main'); }, []); + const handleWidgetHighlight = useCallback((widgetId: string | null) => { + setActiveWidgetId(widgetId); + }, []); + + const handleTabSwap = useCallback(() => { + setScreen(prev => (prev === 'items' ? 'colors' : 'items')); + }, []); + if (!settings) { return Loading settings...; } @@ -332,14 +340,6 @@ export const App: React.FC = () => { setScreen('items'); }; - const handleWidgetHighlight = useCallback((widgetId: string | null) => { - setActiveWidgetId(widgetId); - }, []); - - const handleTabSwap = useCallback(() => { - setScreen((prev) => (prev === 'items' ? 'colors' : 'items')); - }, []); - return ( From 6836995fa80ce27e8a3a1ad586c515630193014f Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Apr 2026 10:19:08 +1000 Subject: [PATCH 03/52] feat: handle Tab key in handleNormalInputMode for color editor swap Add onTabSwap optional callback to HandleNormalInputModeArgs and Tab key handling that invokes it when the selected widget supports colors. Separators and flex-separators are excluded from Tab activation. --- src/tui/components/items-editor/input-handlers.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index 989b4c169..fdb95d9ff 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -348,6 +348,7 @@ export interface HandleNormalInputModeArgs { getCustomKeybindsForWidget: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[]; setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void; getUniqueBackgroundColor?: (insertIndex: number) => string | undefined; + onTabSwap?: () => void; } export function handleNormalInputMode({ @@ -364,7 +365,8 @@ export function handleNormalInputMode({ openWidgetPicker, getCustomKeybindsForWidget, setCustomEditorWidget, - getUniqueBackgroundColor + getUniqueBackgroundColor, + onTabSwap }: HandleNormalInputModeArgs): void { if (key.upArrow && widgets.length > 0) { setSelectedIndex(selectedIndex - 1 < 0 ? widgets.length - 1 : selectedIndex - 1); @@ -455,6 +457,16 @@ export function handleNormalInputMode({ } onUpdate(newWidgets); } + } else if (key.tab && onTabSwap && widgets.length > 0) { + const currentWidget = widgets[selectedIndex]; + if (currentWidget + && currentWidget.type !== 'separator' + && currentWidget.type !== 'flex-separator') { + const widgetImpl = getWidget(currentWidget.type); + if (widgetImpl?.supportsColors(currentWidget)) { + onTabSwap(); + } + } } else if (key.escape) { onBack(); } else if (widgets.length > 0) { From 3dce233bd05812764999e78f168ecc1a8fce82a4 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Apr 2026 10:27:52 +1000 Subject: [PATCH 04/52] feat: wire Tab-swap props into ItemsEditor - Destructure onTabSwap, onWidgetHighlight, and initialWidgetId props - Use lazy useState initializer to position cursor from initialWidgetId on mount - Add useEffect to track cursor position and call onWidgetHighlight - Pass onTabSwap through to handleNormalInputMode - Add Tab hint in help text, grayed out when widget is not colorable --- src/tui/components/ItemsEditor.tsx | 38 ++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 8b6c46827..91cc33aaa 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -3,7 +3,10 @@ import { Text, useInput } from 'ink'; -import React, { useState } from 'react'; +import React, { + useEffect, + useState +} from 'react'; import type { Settings } from '../../types/Settings'; import type { @@ -45,14 +48,27 @@ export interface ItemsEditorProps { initialWidgetId?: string | null; } -export const ItemsEditor: React.FC = ({ widgets, onUpdate, onBack, lineNumber, settings }) => { - const [selectedIndex, setSelectedIndex] = useState(0); +export const ItemsEditor: React.FC = ({ widgets, onUpdate, onBack, lineNumber, settings, onTabSwap, onWidgetHighlight, initialWidgetId }) => { + const [selectedIndex, setSelectedIndex] = useState(() => { + if (initialWidgetId) { + const index = widgets.findIndex(w => w.id === initialWidgetId); + return index >= 0 ? index : 0; + } + return 0; + }); const [moveMode, setMoveMode] = useState(false); const [customEditorWidget, setCustomEditorWidget] = useState(null); const [widgetPicker, setWidgetPicker] = useState(null); const [showClearConfirm, setShowClearConfirm] = useState(false); const separatorChars = ['|', '-', ',', ' ']; + useEffect(() => { + if (onWidgetHighlight) { + const currentWidget = widgets[selectedIndex]; + onWidgetHighlight(currentWidget?.id ?? null); + } + }, [selectedIndex, widgets, onWidgetHighlight]); + const widgetCatalog = getWidgetCatalog(settings); const widgetCategories = ['All', ...getWidgetCatalogCategories(widgetCatalog)]; @@ -205,7 +221,8 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB openWidgetPicker, getCustomKeybindsForWidget, setCustomEditorWidget, - getUniqueBackgroundColor + getUniqueBackgroundColor, + onTabSwap }); }); @@ -274,6 +291,12 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB } const canMerge = currentWidget && selectedIndex < widgets.length - 1 && !isSeparator && !isFlexSeparator; + const isColorable = Boolean( + currentWidget + && !isSeparator + && !isFlexSeparator + && getWidget(currentWidget.type)?.supportsColors(currentWidget) + ); const hasWidgets = widgets.length > 0; // Build main help text (without custom keybinds) @@ -405,7 +428,12 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB ) : ( - {helpText} + + {helpText} + {hasWidgets && onTabSwap && ( + , Tab color + )} + {customKeybindsText || ' '} )} From 6cfb65fe5e1d2501d42fc20f9e3c536e1937c95a Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Apr 2026 10:39:20 +1000 Subject: [PATCH 05/52] feat: wire Tab-swap props into ColorMenu Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tui/components/ColorMenu.tsx | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index dc4d6d822..52cb5d349 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -38,7 +38,7 @@ export interface ColorMenuProps { initialWidgetId?: string | null; } -export const ColorMenu: React.FC = ({ widgets, lineIndex, settings, onUpdate, onBack }) => { +export const ColorMenu: React.FC = ({ widgets, lineIndex, settings, onUpdate, onBack, onTabSwap, onWidgetHighlight, initialWidgetId }) => { const [showSeparators, setShowSeparators] = useState(false); const [hexInputMode, setHexInputMode] = useState(false); const [hexInput, setHexInput] = useState(''); @@ -58,7 +58,15 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin // 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 [highlightedItemId, setHighlightedItemId] = useState(() => { + if (initialWidgetId) { + const match = colorableWidgets.find(w => w.id === initialWidgetId); + if (match) { + return match.id; + } + } + return colorableWidgets[0]?.id ?? null; + }); const [editingBackground, setEditingBackground] = useState(false); // Handle keyboard input @@ -154,6 +162,12 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin return; } + // Tab to swap to ItemsEditor (always available since all items are colorable) + if (key.tab && onTabSwap) { + onTabSwap(); + return; + } + // Normal keyboard handling when there are items if (key.escape) { if (editingBackground) { @@ -289,6 +303,9 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin const handleHighlight = (item: { value: string }) => { setHighlightedItemId(item.value); + if (onWidgetHighlight) { + onWidgetHighlight(item.value === 'back' ? null : item.value); + } }; // Get current color for highlighted item @@ -437,7 +454,10 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin , (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 + (r)eset, (c)lear all, + {onTabSwap ? ' Tab items,' : ''} + {' '} + ESC to go back {!settings.powerline.enabled && !settings.defaultSeparator && ( From b9a45139d4b2035ae1e8bead9d5671d20f08f032 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Apr 2026 10:53:09 +1000 Subject: [PATCH 06/52] =?UTF-8?q?fix:=20make=20Tab=20hint=20consistent=20a?= =?UTF-8?q?cross=20editors=20=E2=80=94=20inline=20text,=20=E2=87=A5=20symb?= =?UTF-8?q?ol,=20hidden=20when=20unavailable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tui/components/ColorMenu.tsx | 2 +- src/tui/components/ItemsEditor.tsx | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index 52cb5d349..79f993578 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -455,7 +455,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin {settings.colorLevel === 3 ? ' (h)ex,' : settings.colorLevel === 2 ? ' (a)nsi256,' : ''} {' '} (r)eset, (c)lear all, - {onTabSwap ? ' Tab items,' : ''} + {onTabSwap ? ' ⇥ edit items,' : ''} {' '} ESC to go back diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 91cc33aaa..15631b34b 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -315,6 +315,9 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB if (canMerge) { helpText += ', (m)erge'; } + if (isColorable && onTabSwap) { + helpText += ', ⇥ edit colors'; + } helpText += ', ESC back'; // Build custom keybinds text @@ -428,12 +431,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB ) : ( - - {helpText} - {hasWidgets && onTabSwap && ( - , Tab color - )} - + {helpText} {customKeybindsText || ' '} )} From d792c4325d329fe31647ce3f2d8d190dcf3fa28b Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Apr 2026 10:56:15 +1000 Subject: [PATCH 07/52] test: add Tab keybind tests for handleNormalInputMode Verify Tab key behavior: calls onTabSwap for colorable widgets, skips separators and flex-separators, and does nothing when onTabSwap is not provided. --- .../__tests__/input-handlers.test.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) 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 45dd555c6..ffd499afd 100644 --- a/src/tui/components/items-editor/__tests__/input-handlers.test.ts +++ b/src/tui/components/items-editor/__tests__/input-handlers.test.ts @@ -729,6 +729,113 @@ describe('items-editor input handlers', () => { expect(customEditorState?.widget?.type).toBe('skills'); }); + describe('Tab shortcut - tab swap', () => { + it('calls onTabSwap when current widget is a colorable type', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'model' } + ]; + const onTabSwap = vi.fn(); + + handleNormalInputMode({ + input: '', + key: { tab: true }, + widgets, + selectedIndex: 0, + separatorChars: ['|'], + onBack: vi.fn(), + onUpdate: vi.fn(), + setSelectedIndex: vi.fn(), + setMoveMode: vi.fn(), + setShowClearConfirm: vi.fn(), + openWidgetPicker: vi.fn(), + getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], + setCustomEditorWidget: vi.fn(), + onTabSwap + }); + + expect(onTabSwap).toHaveBeenCalledTimes(1); + }); + + it('does not call onTabSwap when current widget is a separator', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'separator', character: '|' } + ]; + const onTabSwap = vi.fn(); + + handleNormalInputMode({ + input: '', + key: { tab: true }, + widgets, + selectedIndex: 0, + separatorChars: ['|'], + onBack: vi.fn(), + onUpdate: vi.fn(), + setSelectedIndex: vi.fn(), + setMoveMode: vi.fn(), + setShowClearConfirm: vi.fn(), + openWidgetPicker: vi.fn(), + getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], + setCustomEditorWidget: vi.fn(), + onTabSwap + }); + + expect(onTabSwap).not.toHaveBeenCalled(); + }); + + it('does not call onTabSwap when current widget is a flex-separator', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'flex-separator' } + ]; + const onTabSwap = vi.fn(); + + handleNormalInputMode({ + input: '', + key: { tab: true }, + widgets, + selectedIndex: 0, + separatorChars: ['|'], + onBack: vi.fn(), + onUpdate: vi.fn(), + setSelectedIndex: vi.fn(), + setMoveMode: vi.fn(), + setShowClearConfirm: vi.fn(), + openWidgetPicker: vi.fn(), + getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], + setCustomEditorWidget: vi.fn(), + onTabSwap + }); + + expect(onTabSwap).not.toHaveBeenCalled(); + }); + + it('does nothing when onTabSwap is not provided', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'model' } + ]; + const onUpdate = vi.fn(); + const setSelectedIndex = vi.fn(); + + handleNormalInputMode({ + input: '', + key: { tab: true }, + widgets, + selectedIndex: 0, + separatorChars: ['|'], + onBack: vi.fn(), + onUpdate, + setSelectedIndex, + setMoveMode: vi.fn(), + setShowClearConfirm: vi.fn(), + openWidgetPicker: vi.fn(), + getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], + setCustomEditorWidget: vi.fn() + }); + + expect(onUpdate).not.toHaveBeenCalled(); + expect(setSelectedIndex).not.toHaveBeenCalled(); + }); + }); + describe('k shortcut - clone widget', () => { it('inserts clone after source and moves selection to clone', () => { const widgets: WidgetItem[] = [ From cbcd81104f27acfa7f87146e46f54ff1dbf24e89 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Thu, 30 Apr 2026 15:32:20 +1000 Subject: [PATCH 08/52] fix: disable Tab swap when powerline theme is managing colors When a non-custom powerline theme is active, the renderer ignores widget color fields entirely, so navigating to the color editor via Tab leads to edits that silently have no effect. Gate onTabSwap at the App level so both editors suppress the hint and keypress. --- src/tui/App.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 53b7b6d30..b33b0a74e 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -238,6 +238,10 @@ export const App: React.FC = () => { setActiveWidgetId(widgetId); }, []); + const isThemeManaged = settings?.powerline.enabled + && settings.powerline.theme + && settings.powerline.theme !== 'custom'; + const handleTabSwap = useCallback(() => { setScreen(prev => (prev === 'items' ? 'colors' : 'items')); }, []); @@ -417,7 +421,7 @@ export const App: React.FC = () => { }} lineNumber={selectedLine + 1} settings={settings} - onTabSwap={handleTabSwap} + onTabSwap={isThemeManaged ? undefined : handleTabSwap} onWidgetHighlight={handleWidgetHighlight} initialWidgetId={activeWidgetId} /> @@ -458,7 +462,7 @@ export const App: React.FC = () => { // Go back to line selection for colors setScreen('colorLines'); }} - onTabSwap={handleTabSwap} + onTabSwap={isThemeManaged ? undefined : handleTabSwap} onWidgetHighlight={handleWidgetHighlight} initialWidgetId={activeWidgetId} /> From 4aefb304de71c374641ede92cc71f89fdd07f177 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Tue, 28 Jul 2026 20:27:42 +1000 Subject: [PATCH 09/52] docs(plan): add theme-aware per-widget color overrides plan (phase 1) Co-Authored-By: Claude Opus 4.8 --- .../2026-07-22-theme-aware-color-overrides.md | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-22-theme-aware-color-overrides.md diff --git a/docs/superpowers/plans/2026-07-22-theme-aware-color-overrides.md b/docs/superpowers/plans/2026-07-22-theme-aware-color-overrides.md new file mode 100644 index 000000000..54e6aedf5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-theme-aware-color-overrides.md @@ -0,0 +1,292 @@ +# Theme-Aware Per-Widget Color Overrides Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a user override an individual widget's foreground and/or background colour while a Powerline theme is active, without changing the appearance of any existing config on upgrade. + +**Architecture:** Add two per-channel boolean "pin" flags to `WidgetItem`. A pinned channel's own colour wins over the theme in the Powerline render path; unpinned channels take the theme exactly as today. Because existing configs have no pins, their persisted colours stay dormant under a theme — so opening them renders identically and no settings migration is required. Editing a colour while a theme is active pins that channel (non-destructively surfacing the widget's existing colour); "unpin" hands a channel back to the theme without wiping the colour. + +**Tech Stack:** TypeScript, Zod (`WidgetItemSchema`), React/Ink TUI, Vitest. + +**Scope note:** This plan covers the **colour model** — data model, render precedence, the no-visual-change guarantee, the pin/unpin editing flow, and the theme-switch keep/remove prompt (sirmalloc's points 1, 4, 5). The **navigation/visual restyle** — making colour editing keep the widget-editor's "shape" as a `Color Editing mode`, turning the old top-level colour menu into a signpost, and the exact ESC target (points 2 and 3) — is a **separate follow-up plan**. This plan removes the managed-theme Tab guard so colour editing is *reachable* under a theme, but does not restyle the editor. + +--- + +## Naming decisions (used verbatim across all tasks) + +- Schema fields (both `z.boolean().optional()` on `WidgetItemSchema`): + - `pinColor` — when `true`, the widget's `color` (foreground) overrides the active theme. + - `pinBackgroundColor` — when `true`, the widget's `backgroundColor` overrides the active theme. +- Mutation helpers (in `src/tui/components/color-menu/mutations.ts`): + - `pinWidgetColor(widgets: WidgetItem[], widgetId: string, isBackground: boolean, seedColor: string): WidgetItem[]` + - `unpinWidgetColor(widgets: WidgetItem[], widgetId: string, isBackground: boolean): WidgetItem[]` + - `clearAllPins(widgets: WidgetItem[]): WidgetItem[]` + +## Project-wide constraints (every task must honor) + +- **No migration.** Both new fields are `.optional()`. Existing settings files load unchanged. Do not add any migration or settings rewrite. +- **`preserveColors` is untouched.** It stays custom-command/foreground-only with its current meaning ("the command paints its own foreground; apply no fg from us"). It is a *different* concept from a pin and must keep its exact current behaviour. In the fg precedence it sits **above** the pin. +- **Theme colour cycling must not shift.** In the Powerline render path, the per-widget theme colour index advances for every non-merged widget when a theme is active. A pinned widget still advances the index (it occupies its slot; it just doesn't use the colour), so pinning one widget must not change the theme colours assigned to any other widget. +- **Pins are a no-op without an active theme.** Themes exist only in the Powerline render path and only when `theme` is set and `!== 'custom'`. In the standard (non-powerline) path and in powerline-without-theme, `widget.color`/`widget.backgroundColor` already apply directly; pins change nothing there. +- Run checks with `bun run lint` and tests with `bun test`. Never disable a lint rule via comment. + +--- + +## Task 1 — Add `pinColor` / `pinBackgroundColor` to the widget schema + +**Goal:** Introduce the two per-channel pin flags so later tasks (renderer, editor, prompt) have a place to read and write, *because* everything else depends on these fields existing and being optional (which is what preserves the no-migration guarantee). + +**Files:** `src/types/Widget.ts` + +**Interfaces (produced for later tasks):** +```ts +// added to WidgetItemSchema (z.object) +pinColor: z.boolean().optional(), +pinBackgroundColor: z.boolean().optional(), +``` +`WidgetItem` (the inferred type) therefore gains `pinColor?: boolean` and `pinBackgroundColor?: boolean`. + +**Tests:** `src/types/__tests__/widget-schema.test.ts` (create if absent; otherwise add to the existing schema test file). +```ts +it('accepts pinColor and pinBackgroundColor', () => { + const parsed = WidgetItemSchema.parse({ + id: 'w1', type: 'model', color: 'red', + pinColor: true, pinBackgroundColor: false + }); + expect(parsed.pinColor).toBe(true); + expect(parsed.pinBackgroundColor).toBe(false); +}); + +it('loads a config without pin fields (existing settings, no migration)', () => { + const parsed = WidgetItemSchema.parse({ id: 'w1', type: 'model', color: 'red' }); + expect(parsed.pinColor).toBeUndefined(); + expect(parsed.pinBackgroundColor).toBeUndefined(); +}); +``` + +**Constraints:** Fields MUST be `.optional()`. Do not reorder or alter existing fields. + +**Acceptance criteria:** New tests pass; `bun run lint` clean; existing schema tests unaffected. + +**Risks:** Minimal. Only risk is a typo in field names diverging from the naming block above — copy them verbatim. + +--- + +## Task 2 — Honor pins in the Powerline render path (the guarantee) + +**Goal:** Make a pinned channel's widget colour win over the theme, while leaving every unpinned channel — and every existing config — rendering exactly as before. This task *is* the hard requirement; its regression tests are the proof. + +**Files:** `src/utils/renderer.ts` (the theme-application block inside `renderPowerlineStatusLine` where `themeColors` is applied to `fgColor`/`bgColor`). + +**Decision to implement (contract, not code):** when a theme is active for a widget: +- Foreground precedence: (1) custom-command + `preserveColors` → keep the widget's own fg *(existing)*; (2) else if `pinColor` → keep `widget.color` (do **not** apply theme fg); (3) else → apply theme fg. +- Background precedence: (1) if `pinBackgroundColor` → keep `widget.backgroundColor` (do **not** apply theme bg); (2) else → apply theme bg. +- The theme colour index still advances for the widget regardless of pins (see project-wide "cycling must not shift" constraint). + +**Tests:** `src/utils/__tests__/renderer-theme-color-override.test.ts` (new). Model the setup on `renderer-powerline-theme.test.ts` — build `DEFAULT_SETTINGS` with `colorLevel: 3`, `powerline.enabled: true`, `powerline.theme: 'nord-aurora'`; render via `preRenderAllWidgets` → `calculateMaxWidthsFromPreRendered` → `renderStatusLine`; assert with `getColorAnsiCode(spec, 'truecolor', isBackground)`. + +```ts +// THE no-change guarantee: a legacy colour persisted under a theme stays dormant. +it('ignores an unpinned widget colour under a theme (no change on upgrade)', () => { + const widgets = [{ id: 'w1', type: 'custom-text', customText: 'x', color: 'red' }]; + const line = renderThemed(widgets); // helper mirroring renderer-powerline-theme.test.ts + expect(line).not.toContain(getColorAnsiCode('red', 'truecolor', false)); +}); + +// A pinned foreground overrides the theme. +it('applies a pinned widget foreground over the theme', () => { + const widgets = [{ id: 'w1', type: 'custom-text', customText: 'x', color: 'red', pinColor: true }]; + const line = renderThemed(widgets); + expect(line).toContain(getColorAnsiCode('red', 'truecolor', false)); +}); + +// fg and bg pins are independent. +it('pins background independently of foreground', () => { + const widgets = [{ id: 'w1', type: 'custom-text', customText: 'x', + color: 'red', backgroundColor: 'blue', pinBackgroundColor: true }]; // pinColor absent + const line = renderThemed(widgets); + expect(line).toContain(getColorAnsiCode('blue', 'truecolor', true)); // bg overridden + expect(line).not.toContain(getColorAnsiCode('red', 'truecolor', false)); // fg still themed +}); + +// Cycling must not shift: pinning w1 must not change w2's theme colour. +it('keeps other widgets\' theme colours stable when one widget is pinned', () => { + const base = [ + { id: 'w1', type: 'custom-text', customText: 'a' }, + { id: 'w2', type: 'custom-text', customText: 'b' } + ]; + const pinned = [ + { id: 'w1', type: 'custom-text', customText: 'a', color: 'red', pinColor: true }, + { id: 'w2', type: 'custom-text', customText: 'b' } + ]; + // w2's applied theme foreground is identical in both renders. + const w2ThemeFgBase = extractWidgetFg(renderThemed(base), 'b'); + const w2ThemeFgPinned = extractWidgetFg(renderThemed(pinned), 'b'); + expect(w2ThemeFgPinned).toBe(w2ThemeFgBase); +}); + +// preserveColors regression guard: unchanged behaviour under a theme. +it('leaves custom-command preserveColors behaviour unchanged', () => { + const widgets = [{ id: 'w1', type: 'custom-command', commandPath: 'echo', preserveColors: true, color: 'red' }]; + const line = renderThemed(widgets); + // fg not themed (preserveColors), bg still themed — same as before this feature. + expect(line).not.toContain(/* theme fg[0] ansi code */); +}); +``` + +**Constraints:** Do not change the index-increment logic. Do not touch the standard (`renderStatusLine` non-powerline) colour path. The `not.toContain('red')` assertion in the first test is the load-bearing guarantee and must not depend on knowing the theme's exact hex. + +**Acceptance criteria:** All new tests pass; **every existing test in `renderer-powerline-theme.test.ts` and `powerline-theme-index.test.ts` still passes** (proves no shift/regression); `bun run lint` clean. + +**Risks:** The `extractWidgetFg` helper and the exact theme-hex assertions must match the existing harness's ANSI representation — confirm against `renderer-powerline-theme.test.ts` before finalizing assertion form. If exposing a per-widget theme colour for `extractWidgetFg` is awkward, assert stability by comparing the full rendered substrings for w2 instead. + +--- + +## Task 3 — Pin / unpin mutation helpers + +**Goal:** Provide pure, unit-testable functions the editor uses to pin (non-destructively surfacing a colour) and unpin (without wiping the colour), *so that* the editing behaviour is testable without driving the Ink UI. + +**Files:** `src/tui/components/color-menu/mutations.ts` + +**Interfaces (produced for Task 5 & 6):** +```ts +export function pinWidgetColor(widgets: WidgetItem[], widgetId: string, isBackground: boolean, seedColor: string): WidgetItem[]; +export function unpinWidgetColor(widgets: WidgetItem[], widgetId: string, isBackground: boolean): WidgetItem[]; +export function clearAllPins(widgets: WidgetItem[]): WidgetItem[]; +``` +Semantics (the decisions): +- `pinWidgetColor`: sets `pinColor` (fg) or `pinBackgroundColor` (bg) to `true`. If that channel's colour value (`color`/`backgroundColor`) is **unset**, set it to `seedColor`. If it is already set, **leave it** (surface the existing choice). +- `unpinWidgetColor`: sets the channel's pin flag to `false`/removes it. Leaves the colour value untouched. +- `clearAllPins`: removes `pinColor` and `pinBackgroundColor` from every widget. Leaves colour values untouched. +- All three return a new array (follow `updateWidgetById` immutability pattern already in this file). + +**Tests:** `src/tui/components/color-menu/__tests__/pin-mutations.test.ts` (new). +```ts +it('pin surfaces an existing colour without overwriting it', () => { + const w = [{ id: 'w1', type: 'model', color: 'red' }]; + const out = pinWidgetColor(w, 'w1', false, 'cyan'); + expect(out[0].pinColor).toBe(true); + expect(out[0].color).toBe('red'); // seed ignored because a colour already exists +}); + +it('pin seeds the colour when none is set', () => { + const w = [{ id: 'w1', type: 'model' }]; + const out = pinWidgetColor(w, 'w1', false, 'cyan'); + expect(out[0].pinColor).toBe(true); + expect(out[0].color).toBe('cyan'); +}); + +it('pin targets background independently', () => { + const w = [{ id: 'w1', type: 'model', color: 'red' }]; + const out = pinWidgetColor(w, 'w1', true, 'blue'); + expect(out[0].pinBackgroundColor).toBe(true); + expect(out[0].backgroundColor).toBe('blue'); + expect(out[0].pinColor).toBeUndefined(); // fg untouched +}); + +it('unpin clears the flag but keeps the colour', () => { + const w = [{ id: 'w1', type: 'model', color: 'blue', pinColor: true }]; + const out = unpinWidgetColor(w, 'w1', false); + expect(out[0].pinColor).toBeFalsy(); + expect(out[0].color).toBe('blue'); // NOT an undo — colour stays +}); + +it('clearAllPins removes pins from all widgets, keeps colours', () => { + const w = [ + { id: 'w1', type: 'model', color: 'red', pinColor: true }, + { id: 'w2', type: 'git-branch', backgroundColor: 'blue', pinBackgroundColor: true } + ]; + const out = clearAllPins(w); + expect(out[0].pinColor).toBeFalsy(); + expect(out[1].pinBackgroundColor).toBeFalsy(); + expect(out[0].color).toBe('red'); + expect(out[1].backgroundColor).toBe('blue'); +}); +``` + +**Constraints:** Pure functions, no mutation of inputs. `unpinWidgetColor` must never alter the colour value — that is the "reset only removes the pin" decision. + +**Acceptance criteria:** Tests pass; `bun run lint` clean. + +**Risks:** Low. + +--- + +## Task 4 — Make `(r)eset` clear pins; keep it distinct from unpin + +**Goal:** Ensure the existing "reset styling" action fully forgets a widget's styling *including* its pins, so a reset widget can't be left pinned-with-no-colour and render a default over the theme. Unpin (Task 3) stays a separate, colour-preserving action — *because* the two actions mean different things ("forget my colour" vs "let the theme have it back"). + +**Files:** `src/tui/components/color-menu/mutations.ts` (`resetWidgetStyling`) + +**Interfaces (consumed):** `resetWidgetStyling(widgets: WidgetItem[], widgetId: string): WidgetItem[]` (existing signature — unchanged). + +**Tests:** add to the existing `mutations` test file (or `pin-mutations.test.ts`). +```ts +it('resetWidgetStyling clears pins as well as colours', () => { + const w = [{ id: 'w1', type: 'model', color: 'red', backgroundColor: 'blue', + pinColor: true, pinBackgroundColor: true, bold: true }]; + const out = resetWidgetStyling(w, 'w1'); + expect(out[0].pinColor).toBeUndefined(); + expect(out[0].pinBackgroundColor).toBeUndefined(); + expect(out[0].color).toBeUndefined(); + expect(out[0].backgroundColor).toBeUndefined(); +}); +``` + +**Constraints:** Do not change what `resetWidgetStyling` already clears; only additionally clear the two pin flags. Do not fold unpin into reset — they remain separate actions. + +**Acceptance criteria:** New test passes; existing `resetWidgetStyling`/`clearAllWidgetStyling` tests still pass; `bun run lint` clean. + +**Risks:** Low. Confirm `clearAllWidgetStyling` also clears pins if it is meant to fully wipe styling (add an equivalent assertion if that helper exists and is used by "(c)lear all"). + +--- + +## Task 5 — Reachable + wired colour editing under a theme (pin/unpin in ColorMenu) + +**Goal:** Let the user actually pin/unpin from the colour editor while a theme is active: remove the guard that currently disables the Tab route under a managed theme, make setting a colour under a theme pin that channel, and add an explicit pin-toggle plus help text. *Because* without this the render capability from Task 2 has no way to be exercised by a user. + +**Files:** `src/tui/App.tsx` (remove the managed-theme guard on `onTabSwap`), `src/tui/components/ColorMenu.tsx` (wire pin on colour edit, add pin-toggle keybind + help text). + +**Decisions (contract):** +- **Remove the guard:** at both `ItemsEditor` and `ColorMenu` render sites, pass `onTabSwap={handleTabSwap}` unconditionally (drop the `isThemeManaged ? undefined :`). The `isThemeManaged` computation may remain if used elsewhere; the Tab route must no longer be suppressed by it. +- **Theme detection in the editor:** a theme is active when `settings.powerline.enabled && settings.powerline.theme && settings.powerline.theme !== 'custom'` (same predicate as the renderer and the old guard). +- **Editing a colour under a theme pins that channel:** whenever the editor commits a colour to a channel (the `editingBackground` flag selects fg vs bg) and a theme is active, the corresponding pin flag is set (so the colour actually takes effect). Use `pinWidgetColor` / `setWidgetColor` from mutations. When no theme is active, behaviour is unchanged. +- **Pin-toggle keybind `(p)`:** toggles the pin on the highlighted widget's current channel (fg, or bg when `editingBackground`). Pinning uses `pinWidgetColor` with `seedColor = getWidget(widget.type).getDefaultColor()` when the channel colour is unset. Unpinning uses `unpinWidgetColor`. **Constraint:** `p` must not collide with existing ColorMenu keys (`h`, `a`, `g`, `r`, `c`, background toggle, tab, digits) — verify before wiring; pick another letter if it clashes. +- **Help text:** under a theme, surface the pin/unpin affordance; keep `(r)eset` labelled distinctly from unpin ("(r)eset" = forget styling; pin-toggle = theme override on/off). + +**Tests:** logic-level via the Task 3 helpers is already covered. Add a focused ColorMenu interaction test under `src/tui/components/**/__tests__/` in the style of the existing editor tests (e.g. `symbol-override-editor.test.tsx`) asserting that, with a theme active, committing a colour to a widget results in an `onUpdate` payload where that widget has both the colour and the matching pin set; and that pressing the pin-toggle key on a pinned widget clears the pin (via the resulting `onUpdate` payload). If driving Ink proves heavy, assert instead that the ColorMenu's commit handler produces the expected widget array through the extracted mutation helpers. + +**Constraints:** Do not restyle the editor or change its screens (that's the follow-up plan). Only: unguard the route, pin-on-edit, add the toggle + help text. ESC behaviour is left as-is for this plan. + +**Acceptance criteria:** Under a theme, a user can Tab into colour editing, set a colour, see it take effect (manual check via `bun run start` with a themed config), and unpin to return the channel to the theme. `bun run lint` clean; `bun test` green. + +**Risks:** Keybind collision (mitigated by the verify-before-wiring constraint). The editor's "current colour" display should read the effective colour so a freshly pinned widget shows its surfaced colour — confirm the preview reflects the pinned value. + +--- + +## Task 6 — Keep/remove overrides prompt on theme change + +**Goal:** When the active theme changes and any widget is pinned, prompt the user to keep or remove their overrides (sirmalloc's point 5), *so that* switching themes doesn't silently carry or silently drop custom colours. + +**Files:** the Powerline theme selector component (locate via the theme-selection screen; likely `src/tui/components/PowerlineSetup.tsx` or the theme selector it renders) + reuse `clearAllPins` from Task 3. + +**Decisions (contract):** +- Trigger: when the user changes the active theme (switch theme, or enable/disable) **and** at least one widget on the affected line(s) has `pinColor` or `pinBackgroundColor` set. +- Prompt options: **Keep** — leave pins as they are (pinned colours carry over and win against the new theme too). **Remove** — call `clearAllPins` on the affected widgets (unpin all; colour values are left dormant, consistent with the non-destructive unpin decision — "remove" means the overrides stop applying, not that the colours are deleted). +- If no widget is pinned, change the theme with no prompt (no behaviour change from today). + +**Tests:** `src/tui/components/__tests__/theme-switch-pins.test.ts` (new) at the logic level: given a widget list with pins and a "remove" choice, the resulting widget list has all pins cleared and all colour values intact (assert against `clearAllPins`); given "keep", the list is unchanged. Drive the confirm-dialog wiring with an interaction test only if the existing theme-selector already has one to model on. + +**Constraints:** "Remove" is `clearAllPins` only — do not delete colour values. Reuse the existing confirm-dialog mechanism (`ConfirmDialogState` / the pattern used by "(c)lear all") rather than inventing a new prompt style. + +**Acceptance criteria:** Switching themes with pins present prompts; "Remove" unpins all (colours preserved, dormant); "Keep" carries pins to the new theme; no prompt when nothing is pinned. `bun run lint` clean; `bun test` green. + +**Risks:** Locating the exact theme-change entry point — front-load a short search for where `powerline.theme` is written on selection. If theme changes can happen from more than one screen, ensure the prompt covers the user-facing path(s); note any path deliberately left unguarded. + +--- + +## Self-review notes + +- **Spec coverage:** point 1 (edit under theme) → Tasks 1/2/5; point 4 (renderer overrides per channel) → Task 2; point 5 (keep/remove prompt) → Task 6; hard requirement (no visual change + regression tests) → Task 2 (guarantee tests) + Task 1 (no migration). Points 2 and 3 (signpost, Color-Editing-mode restyle, ESC target) are explicitly deferred to a follow-up plan. +- **Guarantee anchor:** the single most important assertion is Task 2's "ignores an unpinned widget colour under a theme" — it encodes the no-change-on-upgrade requirement and is theme-hex-independent. +- **Interface consistency:** `pinColor` / `pinBackgroundColor` and the three mutation helpers are named identically everywhere above; `isBackground` is the channel selector throughout. From 3536ae624bd338cc0ccf5c35a43c704f887c568b Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Tue, 28 Jul 2026 20:34:37 +1000 Subject: [PATCH 10/52] feat(schema): add optional pinColor/pinBackgroundColor widget flags Per-channel pins let a widget's own colour override an active Powerline theme. Optional so existing settings load with no migration. Co-Authored-By: Claude Opus 4.8 --- src/types/Widget.ts | 2 ++ src/types/__tests__/widget-schema.test.ts | 24 +++++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 src/types/__tests__/widget-schema.test.ts diff --git a/src/types/Widget.ts b/src/types/Widget.ts index ad2bf5718..7d98e3485 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -18,6 +18,8 @@ export const WidgetItemSchema = z.object({ commandPath: z.string().optional(), maxWidth: z.number().optional(), preserveColors: z.boolean().optional(), + pinColor: z.boolean().optional(), + pinBackgroundColor: z.boolean().optional(), timeout: z.number().optional(), merge: z.union([z.boolean(), z.literal('no-padding')]).optional(), hide: z.boolean().optional(), diff --git a/src/types/__tests__/widget-schema.test.ts b/src/types/__tests__/widget-schema.test.ts new file mode 100644 index 000000000..c67cb4f54 --- /dev/null +++ b/src/types/__tests__/widget-schema.test.ts @@ -0,0 +1,24 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { WidgetItemSchema } from '../Widget'; + +describe('WidgetItemSchema pin flags', () => { + it('accepts pinColor and pinBackgroundColor', () => { + const parsed = WidgetItemSchema.parse({ + id: 'w1', type: 'model', color: 'red', + pinColor: true, pinBackgroundColor: false + }); + expect(parsed.pinColor).toBe(true); + expect(parsed.pinBackgroundColor).toBe(false); + }); + + it('loads a config without pin fields (existing settings, no migration)', () => { + const parsed = WidgetItemSchema.parse({ id: 'w1', type: 'model', color: 'red' }); + expect(parsed.pinColor).toBeUndefined(); + expect(parsed.pinBackgroundColor).toBeUndefined(); + }); +}); From 7119e600fb5f3dd4e48417da9737acfb81e1544d Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Tue, 28 Jul 2026 20:44:54 +1000 Subject: [PATCH 11/52] feat(renderer): honor per-widget color pins over the Powerline theme A pinned foreground/background keeps the widget's own colour instead of the theme's; unpinned channels are unchanged. The theme colour index still advances for pinned widgets so sibling widgets' colours don't shift. preserveColors keeps precedence over the fg pin. Guarantee: an unpinned colour persisted before a theme was enabled stays dormant, so existing themed configs render identically (no migration). Co-Authored-By: Claude Opus 4.8 --- .../renderer-theme-color-override.test.ts | 96 +++++++++++++++++++ src/utils/renderer.ts | 13 ++- 2 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 src/utils/__tests__/renderer-theme-color-override.test.ts diff --git a/src/utils/__tests__/renderer-theme-color-override.test.ts b/src/utils/__tests__/renderer-theme-color-override.test.ts new file mode 100644 index 000000000..4d65d37fb --- /dev/null +++ b/src/utils/__tests__/renderer-theme-color-override.test.ts @@ -0,0 +1,96 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { + DEFAULT_SETTINGS, + type Settings +} from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { + getColorAnsiCode, + getPowerlineTheme +} from '../colors'; +import { + calculateMaxWidthsFromPreRendered, + preRenderAllWidgets, + renderStatusLine +} from '../renderer'; + +const THEME = 'nord-aurora'; + +// nord-aurora truecolor palette, read from source of truth so tests don't hardcode hex. +const themeColors = getPowerlineTheme(THEME)?.[3]; +const themeFg = themeColors?.fg ?? []; + +function createSettings(): Settings { + return { + ...DEFAULT_SETTINGS, + colorLevel: 3, + defaultPadding: '', + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + theme: THEME + } + }; +} + +function renderThemed(widgets: WidgetItem[]): string { + const settings = createSettings(); + const context: RenderContext = { + isPreview: false, + lineIndex: 0, + globalPowerlineThemeIndex: 0 + }; + const preRenderedLines = preRenderAllWidgets([widgets], settings, context); + const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings); + const preRenderedWidgets = preRenderedLines[0] ?? []; + + return renderStatusLine(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths); +} + +describe('renderer per-widget theme color overrides', () => { + // Distinctive hex specs that resolve to real truecolor codes and are not part of + // the nord-aurora palette (named colours resolve to '' at truecolor, so avoid them). + const FG = 'hex:FF0000'; + const BG = 'hex:0000FF'; + + it('ignores an unpinned widget colour under a theme (no change on upgrade)', () => { + // The load-bearing guarantee: a colour persisted before a theme was enabled + // stays dormant. This assertion is theme-hex independent. + const line = renderThemed([{ id: 'w1', type: 'custom-text', customText: 'x', color: FG }]); + expect(line).not.toContain(getColorAnsiCode(FG, 'truecolor', false)); + // ...and the theme's own foreground is what applies instead. + expect(line).toContain(getColorAnsiCode(themeFg[0] ?? '', 'truecolor', false)); + }); + + it('applies a pinned widget foreground over the theme', () => { + const line = renderThemed([{ id: 'w1', type: 'custom-text', customText: 'x', color: FG, pinColor: true }]); + expect(line).toContain(getColorAnsiCode(FG, 'truecolor', false)); + expect(line).not.toContain(getColorAnsiCode(themeFg[0] ?? '', 'truecolor', false)); + }); + + it('pins background independently of foreground', () => { + const line = renderThemed([{ + id: 'w1', type: 'custom-text', customText: 'x', + color: FG, backgroundColor: BG, pinBackgroundColor: true + }]); + expect(line).toContain(getColorAnsiCode(BG, 'truecolor', true)); // bg overridden + expect(line).not.toContain(getColorAnsiCode(FG, 'truecolor', false)); // fg still themed + }); + + it('keeps sibling theme colours stable when one widget is pinned', () => { + // w2 is the 2nd themed widget, so it must use theme fg index 1. That only + // holds if the pinned w1 still advanced the theme colour cycle. + const line = renderThemed([ + { id: 'w1', type: 'custom-text', customText: 'a', color: 'red', pinColor: true }, + { id: 'w2', type: 'custom-text', customText: 'b' } + ]); + expect(line).toContain(getColorAnsiCode(themeFg[1] ?? '', 'truecolor', false)); + expect(line).not.toContain(getColorAnsiCode(themeFg[0] ?? '', 'truecolor', false)); + }); +}); diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index fe022737d..60194105e 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -283,13 +283,18 @@ function renderPowerlineStatusLine( const skipFgTheme = widget.type === 'custom-command' && widget.preserveColors; if (themeColors) { - if (!skipFgTheme) { + // A pinned channel keeps the widget's own colour (it wins over the theme). + // preserveColors (custom-command) still takes precedence for the foreground. + if (!skipFgTheme && !widget.pinColor) { fgColor = themeColors.fg[widgetColorIndex % themeColors.fg.length] ?? fgColor; } - bgColor = themeColors.bg[widgetColorIndex % themeColors.bg.length] ?? bgColor; + if (!widget.pinBackgroundColor) { + bgColor = themeColors.bg[widgetColorIndex % themeColors.bg.length] ?? bgColor; + } - // Only increment color index if this widget is not merged with the next one - // This ensures merged widgets share the same color + // Always advance the colour index (even for pinned widgets, which occupy + // their slot without using the colour) so other widgets' theme colours + // don't shift. Merged widgets share a colour, so don't advance for them. if (!mergesWithNext) { widgetColorIndex++; } From 818fe8ed42d56e599997a9770b2e10a19e4ab0f6 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Tue, 28 Jul 2026 20:47:17 +1000 Subject: [PATCH 12/52] feat(color-menu): add pin/unpin/clearAllPins mutation helpers pinWidgetColor sets a channel's pin and non-destructively surfaces the existing colour (seeds only when unset). unpinWidgetColor removes the pin flag but keeps the colour (not an undo). clearAllPins unpins every widget. Co-Authored-By: Claude Opus 4.8 --- .../__tests__/pin-mutations.test.ts | 61 +++++++++++++++++++ src/tui/components/color-menu/mutations.ts | 54 ++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/tui/components/color-menu/__tests__/pin-mutations.test.ts diff --git a/src/tui/components/color-menu/__tests__/pin-mutations.test.ts b/src/tui/components/color-menu/__tests__/pin-mutations.test.ts new file mode 100644 index 000000000..b57379875 --- /dev/null +++ b/src/tui/components/color-menu/__tests__/pin-mutations.test.ts @@ -0,0 +1,61 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { WidgetItem } from '../../../../types/Widget'; +import { + clearAllPins, + pinWidgetColor, + unpinWidgetColor +} from '../mutations'; + +describe('pin / unpin widget colour mutations', () => { + it('pin surfaces an existing colour without overwriting it', () => { + const w: WidgetItem[] = [{ id: 'w1', type: 'model', color: 'red' }]; + const out = pinWidgetColor(w, 'w1', false, 'cyan'); + expect(out[0]?.pinColor).toBe(true); + expect(out[0]?.color).toBe('red'); // seed ignored because a colour already exists + }); + + it('pin seeds the colour when none is set', () => { + const w: WidgetItem[] = [{ id: 'w1', type: 'model' }]; + const out = pinWidgetColor(w, 'w1', false, 'cyan'); + expect(out[0]?.pinColor).toBe(true); + expect(out[0]?.color).toBe('cyan'); + }); + + it('pin targets background independently', () => { + const w: WidgetItem[] = [{ id: 'w1', type: 'model', color: 'red' }]; + const out = pinWidgetColor(w, 'w1', true, 'blue'); + expect(out[0]?.pinBackgroundColor).toBe(true); + expect(out[0]?.backgroundColor).toBe('blue'); + expect(out[0]?.pinColor).toBeUndefined(); // fg untouched + }); + + it('unpin clears the flag but keeps the colour', () => { + const w: WidgetItem[] = [{ id: 'w1', type: 'model', color: 'blue', pinColor: true }]; + const out = unpinWidgetColor(w, 'w1', false); + expect(out[0]?.pinColor).toBeFalsy(); + expect(out[0]?.color).toBe('blue'); // NOT an undo — colour stays + }); + + it('clearAllPins removes pins from all widgets, keeps colours', () => { + const w: WidgetItem[] = [ + { id: 'w1', type: 'model', color: 'red', pinColor: true }, + { id: 'w2', type: 'git-branch', backgroundColor: 'blue', pinBackgroundColor: true } + ]; + const out = clearAllPins(w); + expect(out[0]?.pinColor).toBeFalsy(); + expect(out[1]?.pinBackgroundColor).toBeFalsy(); + expect(out[0]?.color).toBe('red'); + expect(out[1]?.backgroundColor).toBe('blue'); + }); + + it('does not mutate the input array or widgets', () => { + const w: WidgetItem[] = [{ id: 'w1', type: 'model', color: 'red' }]; + pinWidgetColor(w, 'w1', false, 'cyan'); + expect(w[0]?.pinColor).toBeUndefined(); + }); +}); diff --git a/src/tui/components/color-menu/mutations.ts b/src/tui/components/color-menu/mutations.ts index 855b79762..5dde1f3ed 100644 --- a/src/tui/components/color-menu/mutations.ts +++ b/src/tui/components/color-menu/mutations.ts @@ -30,6 +30,60 @@ export function setWidgetColor( }); } +export function pinWidgetColor( + widgets: WidgetItem[], + widgetId: string, + isBackground: boolean, + seedColor: string +): WidgetItem[] { + return updateWidgetById(widgets, widgetId, (widget) => { + if (isBackground) { + return { + ...widget, + pinBackgroundColor: true, + backgroundColor: widget.backgroundColor ?? seedColor + }; + } + + return { + ...widget, + pinColor: true, + color: widget.color ?? seedColor + }; + }); +} + +export function unpinWidgetColor( + widgets: WidgetItem[], + widgetId: string, + isBackground: boolean +): WidgetItem[] { + return updateWidgetById(widgets, widgetId, (widget) => { + if (isBackground) { + const { pinBackgroundColor, ...restWidget } = widget; + void pinBackgroundColor; // Intentionally unused + return restWidget; + } + + const { pinColor, ...restWidget } = widget; + void pinColor; // Intentionally unused + return restWidget; + }); +} + +export function clearAllPins(widgets: WidgetItem[]): WidgetItem[] { + return widgets.map((widget) => { + const { + pinColor, + pinBackgroundColor, + ...restWidget + } = widget; + void pinColor; // Intentionally unused + void pinBackgroundColor; // Intentionally unused + return restWidget; + }); +} + export function toggleWidgetBold(widgets: WidgetItem[], widgetId: string): WidgetItem[] { return updateWidgetById(widgets, widgetId, widget => ({ ...widget, From 322bf3644e4ab90e48878a53cfb38733f3fbd34b Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Tue, 28 Jul 2026 20:50:48 +1000 Subject: [PATCH 13/52] feat(color-menu): reset and clear-all now also drop pin flags Keeps (r)eset / (c)lear-all as a full styling wipe (distinct from unpin, which keeps the colour), so a reset widget can't be left pinned with no colour and render a default over the theme. Co-Authored-By: Claude Opus 4.8 --- .../__tests__/pin-mutations.test.ts | 28 +++++++++++++++++++ src/tui/components/color-menu/mutations.ts | 8 ++++++ 2 files changed, 36 insertions(+) diff --git a/src/tui/components/color-menu/__tests__/pin-mutations.test.ts b/src/tui/components/color-menu/__tests__/pin-mutations.test.ts index b57379875..d0a229c24 100644 --- a/src/tui/components/color-menu/__tests__/pin-mutations.test.ts +++ b/src/tui/components/color-menu/__tests__/pin-mutations.test.ts @@ -7,7 +7,9 @@ import { import type { WidgetItem } from '../../../../types/Widget'; import { clearAllPins, + clearAllWidgetStyling, pinWidgetColor, + resetWidgetStyling, unpinWidgetColor } from '../mutations'; @@ -59,3 +61,29 @@ describe('pin / unpin widget colour mutations', () => { expect(w[0]?.pinColor).toBeUndefined(); }); }); + +describe('reset / clear-all also drop pins', () => { + it('resetWidgetStyling clears pins as well as colours', () => { + const w: WidgetItem[] = [{ + id: 'w1', type: 'model', color: 'red', backgroundColor: 'blue', + pinColor: true, pinBackgroundColor: true, bold: true + }]; + const out = resetWidgetStyling(w, 'w1'); + expect(out[0]?.pinColor).toBeUndefined(); + expect(out[0]?.pinBackgroundColor).toBeUndefined(); + expect(out[0]?.color).toBeUndefined(); + expect(out[0]?.backgroundColor).toBeUndefined(); + }); + + it('clearAllWidgetStyling clears pins on every widget', () => { + const w: WidgetItem[] = [ + { id: 'w1', type: 'model', color: 'red', pinColor: true }, + { id: 'w2', type: 'git-branch', backgroundColor: 'blue', pinBackgroundColor: true } + ]; + const out = clearAllWidgetStyling(w); + expect(out[0]?.pinColor).toBeUndefined(); + expect(out[1]?.pinBackgroundColor).toBeUndefined(); + expect(out[0]?.color).toBeUndefined(); + expect(out[1]?.backgroundColor).toBeUndefined(); + }); +}); diff --git a/src/tui/components/color-menu/mutations.ts b/src/tui/components/color-menu/mutations.ts index 5dde1f3ed..7ff896e5b 100644 --- a/src/tui/components/color-menu/mutations.ts +++ b/src/tui/components/color-menu/mutations.ts @@ -121,12 +121,16 @@ export function resetWidgetStyling(widgets: WidgetItem[], widgetId: string): Wid backgroundColor, bold, dim, + pinColor, + pinBackgroundColor, ...restWidget } = widget; void color; // Intentionally unused void backgroundColor; // Intentionally unused void bold; // Intentionally unused void dim; // Intentionally unused + void pinColor; // Intentionally unused + void pinBackgroundColor; // Intentionally unused return restWidget; }); } @@ -138,12 +142,16 @@ export function clearAllWidgetStyling(widgets: WidgetItem[]): WidgetItem[] { backgroundColor, bold, dim, + pinColor, + pinBackgroundColor, ...restWidget } = widget; void color; // Intentionally unused void backgroundColor; // Intentionally unused void bold; // Intentionally unused void dim; // Intentionally unused + void pinColor; // Intentionally unused + void pinBackgroundColor; // Intentionally unused return restWidget; }); } From f4e3a3553a3fb90fb70a1cb2a3b222e42ced824c Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Tue, 28 Jul 2026 21:24:27 +1000 Subject: [PATCH 14/52] feat(color-menu): edit widget colours under an active theme Removes the managed-theme guard so the colour editor is reachable while a Powerline theme is active. Editing a colour (hex/ansi256/gradient/cycle) auto-pins that channel so it overrides the theme, and (p) explicitly pins/unpins the highlighted widget's current channel (surfacing its existing colour, seeding a default only when it has none). Help text shows (p)in/unpin under a theme. Co-Authored-By: Claude Opus 4.8 --- src/tui/App.tsx | 8 +- src/tui/components/ColorMenu.tsx | 51 +++++++++- .../components/__tests__/ColorMenu.test.tsx | 98 ++++++++++++++++++- 3 files changed, 145 insertions(+), 12 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index ca4aa4c31..ab1fbf543 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -693,10 +693,6 @@ export const App: React.FC = () => { setActiveWidgetId(widgetId); }, []); - const isThemeManaged = settings?.powerline.enabled - && settings.powerline.theme - && settings.powerline.theme !== 'custom'; - const handleTabSwap = useCallback(() => { setScreen(prev => (prev === 'items' ? 'colors' : 'items')); }, []); @@ -1136,7 +1132,7 @@ export const App: React.FC = () => { }} lineNumber={selectedLine + 1} settings={settings} - onTabSwap={isThemeManaged ? undefined : handleTabSwap} + onTabSwap={handleTabSwap} onWidgetHighlight={handleWidgetHighlight} initialWidgetId={activeWidgetId} /> @@ -1177,7 +1173,7 @@ export const App: React.FC = () => { // Go back to line selection for colors setScreen('colorLines'); }} - onTabSwap={isThemeManaged ? undefined : handleTabSwap} + onTabSwap={handleTabSwap} onWidgetHighlight={handleWidgetHighlight} initialWidgetId={activeWidgetId} /> diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index 252db36ab..44d60cecd 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -24,9 +24,12 @@ import { clearAllWidgetStyling, cycleWidgetColor, cycleWidgetDim, + pinWidgetColor, resetWidgetStyling, setWidgetColor, - toggleWidgetBold + toggleWidgetBold, + unpinWidgetColor, + updateWidgetById } from './color-menu/mutations'; export interface ColorMenuProps { @@ -78,6 +81,24 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin // Handle keyboard input const hasNoItems = colorableWidgets.length === 0; + const themeActive = settings.powerline.enabled + && !!settings.powerline.theme + && settings.powerline.theme !== 'custom'; + + // Editing a colour under an active theme pins that channel, so the edit + // actually overrides the theme instead of being silently ignored. + const commitColorEdit = (updated: WidgetItem[], widgetId: string, isBackground: boolean) => { + if (!themeActive) { + onUpdate(updated); + return; + } + onUpdate(updateWidgetById(updated, widgetId, widget => ( + isBackground + ? { ...widget, pinBackgroundColor: true } + : { ...widget, pinColor: true } + ))); + }; + useInput((input, key) => { // If no items, any key goes back if (hasNoItems) { @@ -106,7 +127,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); if (selectedWidget) { const newItems = setWidgetColor(widgets, selectedWidget.id, hexColor, editingBackground); - onUpdate(newItems); + commitColorEdit(newItems, selectedWidget.id, editingBackground); } setHexInputMode(false); setHexInput(''); @@ -143,7 +164,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin if (selectedWidget) { const newItems = setWidgetColor(widgets, selectedWidget.id, ansiColor, editingBackground); - onUpdate(newItems); + commitColorEdit(newItems, selectedWidget.id, editingBackground); setAnsi256InputMode(false); setAnsi256Input(''); } @@ -176,7 +197,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin const applyGradientValue = (value: string) => { const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); if (selectedWidget) { - onUpdate(setWidgetColor(widgets, selectedWidget.id, value, false)); + commitColorEdit(setWidgetColor(widgets, selectedWidget.id, value, false), selectedWidget.id, false); } exitGradient(); }; @@ -307,6 +328,25 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin } else if (input === 'c' || input === 'C') { // Show clear all confirmation setShowClearConfirm(true); + } else if ((input === 'p' || input === 'P') && themeActive) { + // Pin/unpin the highlighted widget's current channel so its colour + // overrides (or yields back to) the active theme. Pinning surfaces the + // widget's existing colour (seeding a default only when it has none). + if (highlightedItemId && highlightedItemId !== 'back') { + const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); + if (selectedWidget) { + const isPinned = editingBackground ? selectedWidget.pinBackgroundColor : selectedWidget.pinColor; + if (isPinned) { + onUpdate(unpinWidgetColor(widgets, selectedWidget.id, editingBackground)); + } else { + const widgetImpl = getWidget(selectedWidget.type); + const seedColor = editingBackground + ? (selectedWidget.backgroundColor ?? bgColors.find(color => color !== '') ?? 'bgBlack') + : (selectedWidget.color ?? widgetImpl?.getDefaultColor() ?? 'white'); + onUpdate(pinWidgetColor(widgets, selectedWidget.id, editingBackground, seedColor)); + } + } + } } else if (key.leftArrow || key.rightArrow) { // Cycle through colors with arrow keys if (highlightedItemId && highlightedItemId !== 'back') { @@ -320,7 +360,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin colors, backgroundColors: bgColors }); - onUpdate(newItems); + commitColorEdit(newItems, selectedWidget.id, editingBackground); } } } @@ -613,6 +653,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin {!editingBackground && settings.colorLevel >= 2 ? ' (g)radient,' : ''} {' '} (r)eset, (c)lear all, + {themeActive ? ' (p)in/unpin,' : ''} {onTabSwap ? ' ⇥ edit items,' : ''} {' '} ESC to go back diff --git a/src/tui/components/__tests__/ColorMenu.test.tsx b/src/tui/components/__tests__/ColorMenu.test.tsx index 2bd37c014..fb43e01d1 100644 --- a/src/tui/components/__tests__/ColorMenu.test.tsx +++ b/src/tui/components/__tests__/ColorMenu.test.tsx @@ -5,7 +5,8 @@ import { describe, expect, it, - vi + vi, + type Mock } from 'vitest'; import { DEFAULT_SETTINGS } from '../../../types/Settings'; @@ -119,4 +120,99 @@ describe('ColorMenu', () => { stderr.destroy(); } }); + + async function renderThemedColorMenu(onUpdate: Mock<(widgets: WidgetItem[]) => void>, themeActive: boolean) { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const widgets: WidgetItem[] = [{ id: '1', type: 'model' }]; + const instance = render( + React.createElement(ColorMenu, { + widgets, + settings: { + ...DEFAULT_SETTINGS, + colorLevel: 3, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + theme: themeActive ? 'nord-aurora' : 'custom' + } + }, + onUpdate, + onBack: vi.fn() + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + await flushInk(); + return { + instance, + stdin, + stdout, + stderr + }; + } + + function lastUpdated(onUpdate: Mock<(widgets: WidgetItem[]) => void>): WidgetItem[] | undefined { + return onUpdate.mock.calls.at(-1)?.[0]; + } + + it('auto-pins a widget colour when cycled under an active theme', async () => { + const onUpdate = vi.fn<(widgets: WidgetItem[]) => void>(); + const { instance, stdin, stdout, stderr } = await renderThemedColorMenu(onUpdate, true); + try { + stdin.write('\x1B[C'); // right arrow cycles the foreground colour + await flushInk(); + + expect(onUpdate).toHaveBeenCalled(); + expect(lastUpdated(onUpdate)?.find(widget => widget.id === '1')?.pinColor).toBe(true); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + + it('pins the highlighted widget when (p) is pressed under a theme', async () => { + const onUpdate = vi.fn<(widgets: WidgetItem[]) => void>(); + const { instance, stdin, stdout, stderr } = await renderThemedColorMenu(onUpdate, true); + try { + stdin.write('p'); + await flushInk(); + + expect(onUpdate).toHaveBeenCalled(); + expect(lastUpdated(onUpdate)?.find(widget => widget.id === '1')?.pinColor).toBe(true); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + + it('does not pin a colour edit when no theme is active', async () => { + const onUpdate = vi.fn<(widgets: WidgetItem[]) => void>(); + const { instance, stdin, stdout, stderr } = await renderThemedColorMenu(onUpdate, false); + try { + stdin.write('\x1B[C'); + await flushInk(); + + expect(lastUpdated(onUpdate)?.find(widget => widget.id === '1')?.pinColor).toBeUndefined(); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); }); From 8f42d40dffbd8e4c9b2bfe814f17b1201fee7563 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Tue, 28 Jul 2026 21:37:45 +1000 Subject: [PATCH 15/52] feat(powerline): prompt to keep or remove pin overrides on theme change When the user commits a theme change and any widget has pinned colour overrides, prompt whether to keep them (carry to the new theme) or remove them (clearAllPins) so the new theme fully applies. No prompt when nothing is pinned or the theme is unchanged. Co-Authored-By: Claude Opus 4.8 --- src/tui/components/PowerlineThemeSelector.tsx | 42 ++++++++++++++- .../__tests__/PowerlineThemeSelector.test.ts | 54 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/src/tui/components/PowerlineThemeSelector.tsx b/src/tui/components/PowerlineThemeSelector.tsx index 72bfd4bb0..295eb486f 100644 --- a/src/tui/components/PowerlineThemeSelector.tsx +++ b/src/tui/components/PowerlineThemeSelector.tsx @@ -22,6 +22,7 @@ import { List, type ListEntry } from './List'; +import { clearAllPins } from './color-menu/mutations'; export function buildPowerlineThemeItems( themes: string[], @@ -102,6 +103,7 @@ export const PowerlineThemeSelector: React.FC = ({ const currentTheme = settings.powerline.theme ?? 'custom'; const [selectedIndex, setSelectedIndex] = useState(Math.max(0, themes.indexOf(currentTheme))); const [showCustomizeConfirm, setShowCustomizeConfirm] = useState(false); + const [showRemovePinsConfirm, setShowRemovePinsConfirm] = useState(false); const originalThemeRef = useRef(currentTheme); const originalSettingsRef = useRef(settings); const latestSettingsRef = useRef(settings); @@ -135,7 +137,7 @@ export const PowerlineThemeSelector: React.FC = ({ }, [selectedIndex, themes]); useInput((input, key) => { - if (showCustomizeConfirm) { + if (showCustomizeConfirm || showRemovePinsConfirm) { return; } @@ -190,6 +192,36 @@ export const PowerlineThemeSelector: React.FC = ({ ); } + if (showRemovePinsConfirm) { + return ( + + ⚠ Custom Color Overrides + + Some widgets have pinned colors that override the theme. + Remove them so the new theme fully applies? + Yes removes the overrides; No keeps them. + + + { + onUpdate({ + ...settings, + lines: settings.lines.map(line => clearAllPins(line)) + }); + setShowRemovePinsConfirm(false); + onBack(); + }} + onCancel={() => { + setShowRemovePinsConfirm(false); + onBack(); + }} + /> + + + ); + } + return ( @@ -208,6 +240,14 @@ export const PowerlineThemeSelector: React.FC = ({ marginTop={1} items={themeItems} onSelect={() => { + const themeChanged = (themes[selectedIndex] ?? 'custom') !== originalThemeRef.current; + const hasPins = settings.lines.some(line => line.some( + widget => Boolean(widget.pinColor) || Boolean(widget.pinBackgroundColor) + )); + if (themeChanged && hasPins) { + setShowRemovePinsConfirm(true); + return; + } onBack(); }} onSelectionChange={(themeName, index) => { diff --git a/src/tui/components/__tests__/PowerlineThemeSelector.test.ts b/src/tui/components/__tests__/PowerlineThemeSelector.test.ts index 41ba914a5..2e54dfd59 100644 --- a/src/tui/components/__tests__/PowerlineThemeSelector.test.ts +++ b/src/tui/components/__tests__/PowerlineThemeSelector.test.ts @@ -157,4 +157,58 @@ describe('PowerlineThemeSelector helpers', () => { stderr.destroy(); } }); + + it('prompts to remove pinned overrides when the theme changes and clears them on confirm', async () => { + const themes = getPowerlineThemes(); + expect(themes.length).toBeGreaterThan(1); + + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const onUpdate = vi.fn(); + const onBack = vi.fn(); + const instance = render( + React.createElement(PowerlineThemeSelector, { + settings: { + ...DEFAULT_SETTINGS, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + theme: themes[0] + }, + lines: [[{ id: '1', type: 'model', color: 'red', pinColor: true }]] + }, + onUpdate, + onBack + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + stdin.write(''); // change the theme (live preview) + await flushInk(); + stdin.write('\r'); // Enter: commit -> keep/remove prompt (pins present, theme changed) + await flushInk(); + stdin.write('\r'); // Enter: choose "Yes" -> remove overrides + await flushInk(); + + const lastSettings = onUpdate.mock.calls.at(-1)?.[0]; + expect(lastSettings?.lines[0]?.[0]?.pinColor).toBeUndefined(); + expect(onBack).toHaveBeenCalled(); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); }); From 6afe3025beee1a3c89e24ad1f7478ce95fb4e085 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Tue, 28 Jul 2026 22:09:38 +1000 Subject: [PATCH 16/52] fix(color-menu): show pin state on the current-style row Adds a [PINNED] / [unpinned - theme applies] indicator to the current foreground/background row when a theme is active, so the editor is honest about pinned vs dormant. Fixes two phase-1 UX defects: no way to tell if a channel is pinned, and a dormant stored colour being shown as if effective. Co-Authored-By: Claude Opus 4.8 --- src/tui/components/ColorMenu.tsx | 11 ++++ .../components/__tests__/ColorMenu.test.tsx | 66 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index 44d60cecd..2e935ff2c 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -507,6 +507,16 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin selectedWidget?.dim === 'parens' ? '[DIM ()]' : null ].filter(indicator => indicator !== null).join(' '); + // Pins only matter under an active theme. When they do, show whether the current + // channel is pinned, and flag that an unpinned value is dormant (the theme renders, + // not the value shown above). + const currentChannelPinned = editingBackground + ? Boolean(selectedWidget?.pinBackgroundColor) + : Boolean(selectedWidget?.pinColor); + const pinIndicator = themeActive + ? (currentChannelPinned ? chalk.green('[PINNED]') : chalk.gray('[unpinned - theme applies]')) + : ''; + // Gradient selection mode takes over the whole view if (gradientMode) { const level = getColorLevelString(settings.colorLevel); @@ -677,6 +687,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin {' '} {colorDisplay} {styleIndicators && ` ${styleIndicators}`} + {pinIndicator && ` ${pinIndicator}`} ) : ( diff --git a/src/tui/components/__tests__/ColorMenu.test.tsx b/src/tui/components/__tests__/ColorMenu.test.tsx index fb43e01d1..cec3b053c 100644 --- a/src/tui/components/__tests__/ColorMenu.test.tsx +++ b/src/tui/components/__tests__/ColorMenu.test.tsx @@ -215,4 +215,70 @@ describe('ColorMenu', () => { stderr.destroy(); } }); + + async function renderCurrentStyleLine(widgets: WidgetItem[]): Promise<{ line: string; teardown: () => void }> { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const instance = render( + React.createElement(ColorMenu, { + widgets, + settings: { + ...DEFAULT_SETTINGS, + colorLevel: 3, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + theme: 'nord-aurora' + } + }, + onUpdate: vi.fn(), + onBack: vi.fn() + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + await flushInk(); + const frame = stdout.getOutput().split('Configure Colors').at(-1) ?? ''; + const line = frame.split('\n').find(entry => entry.includes('Current foreground')) ?? ''; + return { + line, + teardown: () => { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }; + } + + it('marks the current channel [PINNED] when it is pinned under a theme', async () => { + const { line, teardown } = await renderCurrentStyleLine([ + { id: '1', type: 'model', color: 'hex:FF0000', pinColor: true } + ]); + try { + expect(line).toContain('[PINNED]'); + } finally { + teardown(); + } + }); + + it('flags a dormant colour as unpinned under a theme', async () => { + const { line, teardown } = await renderCurrentStyleLine([ + { id: '1', type: 'model', color: 'hex:FF0000' } + ]); + try { + expect(line).toContain('unpinned'); + expect(line).not.toContain('[PINNED]'); + } finally { + teardown(); + } + }); }); From 91354a24a3df40c756838d7549b8eafcb9a77cf4 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Tue, 28 Jul 2026 22:46:50 +1000 Subject: [PATCH 17/52] feat(tui): signpost that color editing moved to the widget editor The top-level "Edit Colors" entry no longer opens the standalone color line-selector. It now lands on a notice screen explaining that color editing lives in the widget editor (Tab from a highlighted widget), with an action that jumps straight to the line selector for items. The 'colorLines'/'colors' screens are left in place - 'colors' is still reached via Tab from the widget editor - so the change is reversible. Main-menu navigation is extracted into getMainMenuScreenTarget() so the routing decision is unit-testable alongside the other App helpers. Co-Authored-By: Claude Opus 4.8 --- src/tui/App.tsx | 70 ++++++--- src/tui/__tests__/App.test.ts | 24 +++ .../components/ColorEditingMovedNotice.tsx | 62 ++++++++ src/tui/components/MainMenu.tsx | 2 +- .../ColorEditingMovedNotice.test.tsx | 144 ++++++++++++++++++ src/tui/components/index.ts | 1 + 6 files changed, 281 insertions(+), 22 deletions(-) create mode 100644 src/tui/components/ColorEditingMovedNotice.tsx create mode 100644 src/tui/components/__tests__/ColorEditingMovedNotice.test.tsx diff --git a/src/tui/App.tsx b/src/tui/App.tsx index ab1fbf543..225eed86d 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -71,6 +71,7 @@ import { import { loadClaudeStatusLineState } from './claude-status'; import { + ColorEditingMovedNotice, ColorMenu, ConfirmDialog, GlobalOverridesMenu, @@ -109,6 +110,7 @@ type AppScreen = 'main' | 'items' | 'colorLines' | 'colors' + | 'colorsMoved' | 'terminalWidth' | 'terminalConfig' | 'globalOverrides' @@ -403,6 +405,32 @@ export function getConfirmCancelScreen(confirmDialog: ConfirmDialogState | null) return confirmDialog?.cancelScreen ?? 'main'; } +/** + * Screen a main menu option navigates to directly, or null when the option + * needs a handler of its own (dialogs, installs, exiting). + */ +export function getMainMenuScreenTarget(value: MainMenuOption): AppScreen | null { + switch (value) { + case 'lines': + return 'lines'; + case 'colors': + // Color editing now lives in the widget editor; this entry only signposts it. + return 'colorsMoved'; + case 'powerline': + return 'powerline'; + case 'terminalConfig': + return 'terminalConfig'; + case 'globalOverrides': + return 'globalOverrides'; + case 'manageInstallation': + return 'manageInstallation'; + case 'configureStatusLine': + return 'refreshInterval'; + default: + return null; + } +} + export function clearInstallMenuSelection(menuSelections: Record): Record { if (menuSelections.install === undefined && menuSelections.installPackage === undefined) { return menuSelections; @@ -915,36 +943,22 @@ export const App: React.FC = () => { }; const handleMainMenuSelect = async (value: MainMenuOption) => { + const screenTarget = getMainMenuScreenTarget(value); + + if (screenTarget) { + setScreen(screenTarget); + return; + } + switch (value) { - case 'lines': - setScreen('lines'); - break; - case 'colors': - setScreen('colorLines'); - break; - case 'terminalConfig': - setScreen('terminalConfig'); - break; - case 'globalOverrides': - setScreen('globalOverrides'); - break; - case 'powerline': - setScreen('powerline'); - break; case 'install': handleInstallUninstall(); break; - case 'manageInstallation': - setScreen('manageInstallation'); - break; case 'checkUpdates': setUpdatesReturnScreen('main'); setScreen('updates'); handleUpdateCheck(); break; - case 'configureStatusLine': - setScreen('refreshInterval'); - break; case 'starGithub': setConfirmDialog({ message: `Open the ccstatusline GitHub repository in your browser?\n\n${GITHUB_REPO_URL}`, @@ -1137,6 +1151,20 @@ export const App: React.FC = () => { initialWidgetId={activeWidgetId} /> )} + {screen === 'colorsMoved' && ( + { + // Land on the same line selector the widget editor uses + setMenuSelections(prev => ({ ...prev, main: 0 })); + setScreen('lines'); + }} + onBack={() => { + // Save that we came from 'colors' menu (index 1) + setMenuSelections(prev => ({ ...prev, main: 1 })); + setScreen('main'); + }} + /> + )} {screen === 'colorLines' && ( { }); }); +describe('Main menu routing', () => { + it('sends the legacy Edit Colors entry to the signpost instead of the color line selector', () => { + expect(getMainMenuScreenTarget('colors')).toBe('colorsMoved'); + }); + + it('keeps the other direct navigation targets unchanged', () => { + expect(getMainMenuScreenTarget('lines')).toBe('lines'); + expect(getMainMenuScreenTarget('powerline')).toBe('powerline'); + expect(getMainMenuScreenTarget('terminalConfig')).toBe('terminalConfig'); + expect(getMainMenuScreenTarget('globalOverrides')).toBe('globalOverrides'); + expect(getMainMenuScreenTarget('manageInstallation')).toBe('manageInstallation'); + expect(getMainMenuScreenTarget('configureStatusLine')).toBe('refreshInterval'); + }); + + it('leaves options that need side effects to their own handlers', () => { + expect(getMainMenuScreenTarget('install')).toBeNull(); + expect(getMainMenuScreenTarget('checkUpdates')).toBeNull(); + expect(getMainMenuScreenTarget('starGithub')).toBeNull(); + expect(getMainMenuScreenTarget('save')).toBeNull(); + expect(getMainMenuScreenTarget('exit')).toBeNull(); + }); +}); + describe('Invalid-config TUI guards', () => { it('returns null when there is no config load error', () => { expect(buildConfigLoadWarning(null)).toBeNull(); diff --git a/src/tui/components/ColorEditingMovedNotice.tsx b/src/tui/components/ColorEditingMovedNotice.tsx new file mode 100644 index 000000000..5aed9e8ee --- /dev/null +++ b/src/tui/components/ColorEditingMovedNotice.tsx @@ -0,0 +1,62 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React from 'react'; + +import { + List, + type ListEntry +} from './List'; + +export type ColorEditingMovedAction = 'widgetEditor' | 'back'; + +export interface ColorEditingMovedNoticeProps { + onGoToWidgetEditor: () => void; + onBack: () => void; +} + +const MOVED_OPTIONS: ListEntry[] = [ + { + label: '📝 Go to Widget Editor', + value: 'widgetEditor', + description: 'Pick a line to edit its widgets, then press Tab to edit their colors' + }, + { + label: '← Back', + value: 'back', + description: 'Return to the main menu' + } +]; + +export const ColorEditingMovedNotice: React.FC = ({ onGoToWidgetEditor, onBack }) => { + useInput((_, key) => { + if (key.escape) { + onBack(); + } + }); + + return ( + + Edit Colors + + Color editing has moved into the Widget Editor. + Highlight a widget there and press Tab to switch between its items and its colors. + + { + if (value === 'widgetEditor') { + onGoToWidgetEditor(); + return; + } + + onBack(); + }} + color='cyan' + /> + + ); +}; diff --git a/src/tui/components/MainMenu.tsx b/src/tui/components/MainMenu.tsx index cf906c4d8..355527508 100644 --- a/src/tui/components/MainMenu.tsx +++ b/src/tui/components/MainMenu.tsx @@ -93,7 +93,7 @@ export function buildMainMenuItems( label: '🎨 Edit Colors', value: 'colors', description: - 'Customize colors for each widget including foreground, background, and bold styling' + 'Color editing has moved into the Widget Editor - this shows you how to get there' }, { label: '⚡ Powerline Setup', diff --git a/src/tui/components/__tests__/ColorEditingMovedNotice.test.tsx b/src/tui/components/__tests__/ColorEditingMovedNotice.test.tsx new file mode 100644 index 000000000..ce966f071 --- /dev/null +++ b/src/tui/components/__tests__/ColorEditingMovedNotice.test.tsx @@ -0,0 +1,144 @@ +import { render } from 'ink'; +import { PassThrough } from 'node:stream'; +import React from 'react'; +import { + describe, + expect, + it, + vi +} from 'vitest'; + +import { ColorEditingMovedNotice } from '../ColorEditingMovedNotice'; + +class MockTtyStream extends PassThrough { + isTTY = true; + columns = 160; + rows = 40; + + setRawMode() { + return this; + } + + ref() { + return this; + } + + unref() { + return this; + } +} + +interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string } + +function createMockStdin(): NodeJS.ReadStream { + return new MockTtyStream() as unknown as NodeJS.ReadStream; +} + +function createMockStdout(): CapturedWriteStream { + const stream = new MockTtyStream(); + const chunks: string[] = []; + + stream.on('data', (chunk: Buffer | string) => { + chunks.push(chunk.toString()); + }); + + return Object.assign(stream as unknown as NodeJS.WriteStream, { + getOutput() { + return chunks.join(''); + } + }); +} + +function flushInk() { + return new Promise((resolve) => { + setTimeout(resolve, 25); + }); +} + +async function renderNotice(handlers: { + onGoToWidgetEditor: () => void; + onBack: () => void; +}) { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const instance = render( + React.createElement(ColorEditingMovedNotice, handlers), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + await flushInk(); + + return { + stdin, + stdout, + teardown: () => { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }; +} + +describe('ColorEditingMovedNotice', () => { + it('tells the user color editing moved into the widget editor', async () => { + const { stdout, teardown } = await renderNotice({ + onGoToWidgetEditor: vi.fn(), + onBack: vi.fn() + }); + + try { + expect(stdout.getOutput()).toContain('moved into the Widget Editor'); + expect(stdout.getOutput()).toContain('Tab'); + } finally { + teardown(); + } + }); + + it('routes into the widget editor when the first option is selected', async () => { + const onGoToWidgetEditor = vi.fn(); + const onBack = vi.fn(); + const { stdin, teardown } = await renderNotice({ + onGoToWidgetEditor, + onBack + }); + + try { + stdin.write('\r'); + await flushInk(); + + expect(onGoToWidgetEditor).toHaveBeenCalledOnce(); + expect(onBack).not.toHaveBeenCalled(); + } finally { + teardown(); + } + }); + + it('goes back when escape is pressed', async () => { + const onGoToWidgetEditor = vi.fn(); + const onBack = vi.fn(); + const { stdin, teardown } = await renderNotice({ + onGoToWidgetEditor, + onBack + }); + + try { + stdin.write('\x1B'); + await flushInk(); + + expect(onBack).toHaveBeenCalledOnce(); + expect(onGoToWidgetEditor).not.toHaveBeenCalled(); + } finally { + teardown(); + } + }); +}); diff --git a/src/tui/components/index.ts b/src/tui/components/index.ts index fc8583e52..c657a3c65 100644 --- a/src/tui/components/index.ts +++ b/src/tui/components/index.ts @@ -1,4 +1,5 @@ // Barrel file - exports all components and their types +export * from './ColorEditingMovedNotice'; export * from './ColorMenu'; export * from './ConfirmDialog'; export * from './GlobalOverridesMenu'; From 2a0b44edc6651a37db673daecff51e98faee102b Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Tue, 28 Jul 2026 22:49:20 +1000 Subject: [PATCH 18/52] feat(tui): escape from color editing returns to the items line selector sirmalloc asked for escape from color editing to go back one menu to the line selector. Color editing is now reached by pressing Tab inside the widget editor, so "the line selector" is the one the widget editor backs out to ("Select Line to Edit Items") - escape lands in the same place whichever mode you were in, and the selected line is preserved. Tab behaviour is unchanged; the toggle and the shared back target are extracted as getTabSwapScreen()/getEditorBackScreen() so both are covered by tests. Co-Authored-By: Claude Opus 4.8 --- src/tui/App.tsx | 31 +++++++++++++++++++++++++++---- src/tui/__tests__/App.test.ts | 20 +++++++++++++++++++- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 225eed86d..60833fc1f 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -431,6 +431,27 @@ export function getMainMenuScreenTarget(value: MainMenuOption): AppScreen | null } } +/** + * Both widget editor modes back out one menu to the same line selector, so + * escape lands in the same place whichever mode you happened to be in. + */ +export function getEditorBackScreen(): AppScreen { + return 'lines'; +} + +/** Tab swaps between editing a line's widgets and editing their colors. */ +export function getTabSwapScreen(screen: AppScreen): AppScreen { + if (screen === 'items') { + return 'colors'; + } + + if (screen === 'colors') { + return 'items'; + } + + return screen; +} + export function clearInstallMenuSelection(menuSelections: Record): Record { if (menuSelections.install === undefined && menuSelections.installPackage === undefined) { return menuSelections; @@ -722,7 +743,7 @@ export const App: React.FC = () => { }, []); const handleTabSwap = useCallback(() => { - setScreen(prev => (prev === 'items' ? 'colors' : 'items')); + setScreen(getTabSwapScreen); }, []); const handleUpdateCheck = useCallback(() => { @@ -1142,7 +1163,7 @@ export const App: React.FC = () => { onBack={() => { // When going back to lines menu, preserve which line was selected setMenuSelections(prev => ({ ...prev, lines: selectedLine })); - setScreen('lines'); + setScreen(getEditorBackScreen()); }} lineNumber={selectedLine + 1} settings={settings} @@ -1198,8 +1219,10 @@ export const App: React.FC = () => { setSettings({ ...settings, lines: newLines }); }} onBack={() => { - // Go back to line selection for colors - setScreen('colorLines'); + // Colors are a mode of the widget editor, so escape backs out + // to the same line selector the items mode returns to + setMenuSelections(prev => ({ ...prev, lines: selectedLine })); + setScreen(getEditorBackScreen()); }} onTabSwap={handleTabSwap} onWidgetHighlight={handleWidgetHighlight} diff --git a/src/tui/__tests__/App.test.ts b/src/tui/__tests__/App.test.ts index 68db7100f..b57f5dc30 100644 --- a/src/tui/__tests__/App.test.ts +++ b/src/tui/__tests__/App.test.ts @@ -15,9 +15,11 @@ import { clearInstallMenuSelection, getConfirmCancelScreen, getCurrentInstallation, + getEditorBackScreen, getMainMenuScreenTarget, getPathInferredInstallation, - getPinnedVersionMismatch + getPinnedVersionMismatch, + getTabSwapScreen } from '../App'; import { buildMainMenuItems, @@ -278,6 +280,22 @@ describe('Main menu routing', () => { }); }); +describe('Widget editor mode navigation', () => { + it('backs out of the editor to the line selector for items', () => { + expect(getEditorBackScreen()).toBe('lines'); + }); + + it('keeps Tab toggling between the items and colors modes', () => { + expect(getTabSwapScreen('items')).toBe('colors'); + expect(getTabSwapScreen('colors')).toBe('items'); + }); + + it('leaves other screens alone when Tab is pressed', () => { + expect(getTabSwapScreen('main')).toBe('main'); + expect(getTabSwapScreen('lines')).toBe('lines'); + }); +}); + describe('Invalid-config TUI guards', () => { it('returns null when there is no config load error', () => { expect(buildConfigLoadWarning(null)).toBeNull(); From 35848861824d87e066c3d89333e0e16870f9aac0 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Tue, 28 Jul 2026 23:03:05 +1000 Subject: [PATCH 19/52] refactor(tui): drop the now-unreachable color line selector Nothing routes to the 'colorLines' screen any more - the main menu signposts the widget editor instead, and Tab/escape use the items line selector. Removing the screen leaves LineSelector's blockIfPowerlineActive prop (and the "colors are managed by the Powerline theme, press any key to go back" block it rendered) with no callers; that guard also contradicts this branch, which exists so colors can be pinned over an active theme. allowEditing goes the same way: the single remaining caller passes true, so the read-only variant of the line selector was unreachable too. Co-Authored-By: Claude Opus 4.8 --- src/tui/App.tsx | 23 --------- src/tui/components/LineSelector.tsx | 73 +++-------------------------- 2 files changed, 7 insertions(+), 89 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 60833fc1f..cf0c23c9f 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -108,7 +108,6 @@ interface FlashMessage { type AppScreen = 'main' | 'lines' | 'items' - | 'colorLines' | 'colors' | 'colorsMoved' | 'terminalWidth' @@ -1153,7 +1152,6 @@ export const App: React.FC = () => { }} initialSelection={menuSelections.lines} title='Select Line to Edit Items' - allowEditing={true} /> )} {screen === 'items' && ( @@ -1186,27 +1184,6 @@ export const App: React.FC = () => { }} /> )} - {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' && ( void; initialSelection?: number; title?: string; - blockIfPowerlineActive?: boolean; - settings?: Settings; - allowEditing?: boolean; } const LineSelector: React.FC = ({ @@ -34,10 +30,7 @@ const LineSelector: React.FC = ({ onBack, onLinesUpdate, initialSelection = 0, - title, - blockIfPowerlineActive = false, - settings, - allowEditing = false + title }) => { const [selectedIndex, setSelectedIndex] = useState(initialSelection); const [showDeleteDialog, setShowDeleteDialog] = useState(false); @@ -75,27 +68,12 @@ const LineSelector: React.FC = ({ onLinesUpdate(newLines); }; - // Check if powerline theme is managing colors - const powerlineEnabled = settings ? settings.powerline.enabled : false; - const powerlineTheme = settings ? settings.powerline.theme : undefined; - const isThemeManaged - = blockIfPowerlineActive - && powerlineEnabled - && powerlineTheme - && powerlineTheme !== 'custom'; - // Handle keyboard input useInput((input, key) => { if (showDeleteDialog) { return; } - // If theme-managed and blocking is enabled, any key goes back - if (isThemeManaged) { - onBack(); - return; - } - if (moveMode) { if (key.upArrow && localLines.length > 1) { const newLines = [...localLines]; @@ -127,17 +105,15 @@ const LineSelector: React.FC = ({ switch (input) { case 'a': - if (allowEditing) { - appendLine(); - } + appendLine(); return; case 'd': - if (allowEditing && localLines.length > 1 && selectedIndex < localLines.length) { + if (localLines.length > 1 && selectedIndex < localLines.length) { setShowDeleteDialog(true); } return; case 'm': - if (allowEditing && localLines.length > 1 && selectedIndex < localLines.length) { + if (localLines.length > 1 && selectedIndex < localLines.length) { setMoveMode(true); } return; @@ -148,39 +124,6 @@ const LineSelector: React.FC = ({ } }); - // Show powerline theme warning if applicable - if (isThemeManaged) { - return ( - - {title ?? 'Select Line'} - - - ⚠ Colors are currently managed by the Powerline theme: - {' ' - + powerlineTheme.charAt(0).toUpperCase() - + powerlineTheme.slice(1)} - - - - To customize colors, either: - - - - • Change to 'Custom' theme in Powerline Configuration → Themes - - - - - • Disable Powerline mode in Powerline Configuration - - - - Press any key to go back... - - - ); - } - if (showDeleteDialog && selectedLine) { const suffix = selectedLine.length > 0 @@ -247,11 +190,9 @@ const LineSelector: React.FC = ({ ↑↓ to move line, ESC or Enter to exit move mode ) : ( - {allowEditing ? ( - localLines.length > 1 - ? '(a) to append new line, (d) to delete line, (m) to move line, ESC to go back' - : '(a) to append new line, ESC to go back' - ) : 'ESC to go back'} + {localLines.length > 1 + ? '(a) to append new line, (d) to delete line, (m) to move line, ESC to go back' + : '(a) to append new line, ESC to go back'} )} From 498cc9b9ac6a4deecd5f04b3f81dd1192d5cee87 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 07:47:25 +1000 Subject: [PATCH 20/52] test(powerline-theme): wait for render state instead of fixed sleeps The selector's two ink-driven tests advanced on 25ms sleeps, so they failed whenever the machine was busy - the preview test failed every time when the file ran in isolation, and both started failing in full-suite runs once more ink tests were added alongside them. Each step now polls for the state it is actually waiting on (the preview update, the remove-pins prompt, the selector closing) with a labelled timeout, so a stall names the step that stalled. A short settle remains before each follow-up keystroke: ink writes the frame before the next screen's input handler attaches, so a keypress sent the instant the frame appears is dropped. Co-Authored-By: Claude Opus 4.8 --- .../__tests__/PowerlineThemeSelector.test.ts | 42 +++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/src/tui/components/__tests__/PowerlineThemeSelector.test.ts b/src/tui/components/__tests__/PowerlineThemeSelector.test.ts index 2e54dfd59..35df1eb7c 100644 --- a/src/tui/components/__tests__/PowerlineThemeSelector.test.ts +++ b/src/tui/components/__tests__/PowerlineThemeSelector.test.ts @@ -40,8 +40,21 @@ function createMockStdin(): NodeJS.ReadStream { return new MockTtyStream() as unknown as NodeJS.ReadStream; } -function createMockStdout(): NodeJS.WriteStream { - return new MockTtyStream() as unknown as NodeJS.WriteStream; +interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string } + +function createMockStdout(): CapturedWriteStream { + const stream = new MockTtyStream(); + const chunks: string[] = []; + + stream.on('data', (chunk: Buffer | string) => { + chunks.push(chunk.toString()); + }); + + return Object.assign(stream as unknown as NodeJS.WriteStream, { + getOutput() { + return chunks.join(''); + } + }); } function flushInk() { @@ -50,6 +63,24 @@ function flushInk() { }); } +/** + * Ink renders asynchronously, so a fixed sleep makes these tests fail whenever the + * machine is busy. Poll for the state the step is waiting on instead. + */ +async function waitFor(condition: () => boolean, label: string, timeoutMs = 2000): Promise { + const startedAt = Date.now(); + + while (!condition()) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error(`Timed out waiting for ${label}`); + } + + await new Promise((resolve) => { + setTimeout(resolve, 5); + }); + } +} + describe('PowerlineThemeSelector helpers', () => { afterEach(() => { vi.restoreAllMocks(); @@ -139,6 +170,8 @@ describe('PowerlineThemeSelector helpers', () => { expect(onUpdate).not.toHaveBeenCalled(); stdin.write('\u001B[B'); + await waitFor(() => onUpdate.mock.calls.length > 0, 'the theme preview update'); + // Settle, so an extra (unwanted) preview update would still be caught below await flushInk(); expect(onUpdate).toHaveBeenCalledTimes(1); @@ -194,11 +227,14 @@ describe('PowerlineThemeSelector helpers', () => { try { await flushInk(); stdin.write(''); // change the theme (live preview) + await waitFor(() => onUpdate.mock.calls.length > 0, 'the theme preview update'); + // Ink writes the frame before the next screen's input handler attaches await flushInk(); stdin.write('\r'); // Enter: commit -> keep/remove prompt (pins present, theme changed) + await waitFor(() => stdout.getOutput().includes('Remove them so the new theme fully applies?'), 'the remove-pins prompt'); await flushInk(); stdin.write('\r'); // Enter: choose "Yes" -> remove overrides - await flushInk(); + await waitFor(() => onBack.mock.calls.length > 0, 'the selector to close'); const lastSettings = onUpdate.mock.calls.at(-1)?.[0]; expect(lastSettings?.lines[0]?.[0]?.pinColor).toBeUndefined(); From 4ba0789a6aea87e4fd48fe27599b80a42c1d4b41 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 07:47:25 +1000 Subject: [PATCH 21/52] refactor(tui): share one widget row renderer between editor modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sirmalloc asked that color editing keep the widget editor's shape. The two editors built their rows separately, so the same widget rendered differently in each: "1. Model (merged→)" in one, a tinted "1: Model" in the other. Extract WidgetRow plus getWidgetRowLabel/getWidgetRowTags so both modes share numbering, indicator and the dim structure markers, and title the widget editor "Edit Line N [WIDGETS]" so the two modes share a title stem. ColorMenu adopts the same row in the following commit. Co-Authored-By: Claude Opus 4.8 --- src/tui/components/ItemsEditor.tsx | 66 +++------ src/tui/components/WidgetRow.tsx | 126 +++++++++++++++++ .../components/__tests__/ItemsEditor.test.tsx | 128 ++++++++++++++++++ .../components/__tests__/widget-row.test.ts | 86 ++++++++++++ 4 files changed, 359 insertions(+), 47 deletions(-) create mode 100644 src/tui/components/WidgetRow.tsx create mode 100644 src/tui/components/__tests__/ItemsEditor.test.tsx create mode 100644 src/tui/components/__tests__/widget-row.test.ts diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index c80de5fc6..c9fe58eb1 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -27,6 +27,11 @@ import { } from '../../utils/widgets'; import { ConfirmDialog } from './ConfirmDialog'; +import { + WidgetRow, + getWidgetRowLabel, + getWidgetRowTags +} from './WidgetRow'; import { handleMoveInputMode, handleNormalInputMode, @@ -265,28 +270,6 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB }); }); - const getWidgetDisplay = (widget: WidgetItem) => { - // Special handling for separators (not widgets) - if (widget.type === 'separator') { - const char = widget.character ?? '|'; - const charDisplay = char === ' ' ? '(space)' : char; - return `Separator ${charDisplay}`; - } - if (widget.type === 'flex-separator') { - return 'Flex Separator'; - } - - // Handle regular widgets - delegate to widget for display - const widgetImpl = getWidget(widget.type); - if (widgetImpl) { - const { displayText, modifierText } = widgetImpl.getEditorDisplay(widget); - // Return plain text without colors - return displayText + (modifierText ? ` ${modifierText}` : ''); - } - // Unknown widget type - return `Unknown: ${widget.type}`; - }; - const hasFlexSeparator = widgets.some(widget => widget.type === 'flex-separator'); const widthDetectionAvailable = canDetectTerminalWidth(); const pickerCategories = widgetPicker @@ -394,8 +377,9 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {lineNumber} {' '} - {moveMode && [MOVE MODE]} - {widgetPicker && {`[${pickerActionLabel.toUpperCase()}]`}} + [WIDGETS] + {moveMode && [MOVE MODE]} + {widgetPicker && {` [${pickerActionLabel.toUpperCase()}]`}} {(settings.powerline.enabled || Boolean(settings.defaultSeparator)) && ( @@ -567,31 +551,19 @@ 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 { displayText, modifierText } = widgetImpl?.getEditorDisplay(widget) ?? { displayText: getWidgetDisplay(widget) }; - const supportsRawValue = widgetImpl?.supportsRawValue() ?? false; + const { displayText, modifierText } = getWidgetRowLabel(widget); return ( - - - - {isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '} - - - - {`${index + 1}. ${displayText || getWidgetDisplay(widget)}`} - - {modifierText && ( - - {' '} - {modifierText} - - )} - {supportsRawValue && widget.rawValue && (raw value)} - {widget.merge === true && (merged→)} - {widget.merge === 'no-padding' && (merged-no-pad→)} - {widget.excludeFromAutoAlign && settings.powerline.enabled && settings.powerline.autoAlign && !isMergedIntoPreviousWidget(widgets, index) && (no-align)} - + ); })} {/* Display description for selected widget */} diff --git a/src/tui/components/WidgetRow.tsx b/src/tui/components/WidgetRow.tsx new file mode 100644 index 000000000..7e0b75b36 --- /dev/null +++ b/src/tui/components/WidgetRow.tsx @@ -0,0 +1,126 @@ +import { + Box, + Text +} from 'ink'; +import React from 'react'; + +import type { Settings } from '../../types/Settings'; +import type { + WidgetEditorDisplay, + WidgetItem +} from '../../types/Widget'; +import { getWidget } from '../../utils/widgets'; + +export interface WidgetRowProps { + /** 1-based position of the widget in its line. */ + number: number; + /** Row label. May already carry ANSI codes - set labelIsStyled when it does. */ + label: string; + isSelected: boolean; + /** Skips the selection colour on the label so pre-applied ANSI codes survive. */ + labelIsStyled?: boolean; + indicator?: string; + selectionColor?: string; + modifierText?: string; + tags?: string[]; +} + +function isMergedIntoPreviousWidget(widgets: WidgetItem[], index: number): boolean { + if (index <= 0) { + return false; + } + + return Boolean(widgets[index - 1]?.merge); +} + +/** The label both editor modes show for a widget, so a row reads the same in each. */ +export function getWidgetRowLabel(widget: WidgetItem): WidgetEditorDisplay { + if (widget.type === 'separator') { + const char = widget.character ?? '|'; + return { displayText: `Separator ${char === ' ' ? '(space)' : char}` }; + } + + if (widget.type === 'flex-separator') { + return { displayText: 'Flex Separator' }; + } + + const widgetImpl = getWidget(widget.type); + if (!widgetImpl) { + return { displayText: `Unknown: ${widget.type}` }; + } + + const { displayText, modifierText } = widgetImpl.getEditorDisplay(widget); + return { + displayText: displayText || widgetImpl.getDisplayName(), + modifierText + }; +} + +/** The dim structure markers shown after a row label in both editor modes. */ +export function getWidgetRowTags(widgets: WidgetItem[], index: number, settings: Settings): string[] { + const widget = widgets[index]; + if (!widget) { + return []; + } + + const widgetImpl = widget.type !== 'separator' && widget.type !== 'flex-separator' + ? getWidget(widget.type) + : null; + const tags: string[] = []; + + if (widget.rawValue && widgetImpl?.supportsRawValue()) { + tags.push('(raw value)'); + } + + if (widget.merge === true) { + tags.push('(merged→)'); + } + + if (widget.merge === 'no-padding') { + tags.push('(merged-no-pad→)'); + } + + if (widget.excludeFromAutoAlign + && settings.powerline.enabled + && settings.powerline.autoAlign + && !isMergedIntoPreviousWidget(widgets, index)) { + tags.push('(no-align)'); + } + + return tags; +} + +export const WidgetRow: React.FC = ({ + number, + label, + isSelected, + labelIsStyled = false, + indicator = '▶', + selectionColor = 'green', + modifierText, + tags = [] +}) => { + const highlightColor = isSelected ? selectionColor : undefined; + + return ( + + + {isSelected ? `${indicator} ` : ' '} + + {`${number}. `} + {label} + {modifierText && ( + + {' '} + {modifierText} + + )} + {tags.map(tag => ( + + {' '} + {tag} + + ))} + + ); +}; diff --git a/src/tui/components/__tests__/ItemsEditor.test.tsx b/src/tui/components/__tests__/ItemsEditor.test.tsx new file mode 100644 index 000000000..d53b79330 --- /dev/null +++ b/src/tui/components/__tests__/ItemsEditor.test.tsx @@ -0,0 +1,128 @@ +import { render } from 'ink'; +import { PassThrough } from 'node:stream'; +import React from 'react'; +import { + describe, + expect, + it, + vi +} from 'vitest'; + +import { DEFAULT_SETTINGS } from '../../../types/Settings'; +import type { WidgetItem } from '../../../types/Widget'; +import { ItemsEditor } from '../ItemsEditor'; + +class MockTtyStream extends PassThrough { + isTTY = true; + columns = 160; + rows = 40; + + setRawMode() { + return this; + } + + ref() { + return this; + } + + unref() { + return this; + } +} + +interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string } + +function createMockStdin(): NodeJS.ReadStream { + return new MockTtyStream() as unknown as NodeJS.ReadStream; +} + +function createMockStdout(): CapturedWriteStream { + const stream = new MockTtyStream(); + const chunks: string[] = []; + + stream.on('data', (chunk: Buffer | string) => { + chunks.push(chunk.toString()); + }); + + return Object.assign(stream as unknown as NodeJS.WriteStream, { + getOutput() { + return chunks.join(''); + } + }); +} + +function flushInk() { + return new Promise((resolve) => { + setTimeout(resolve, 25); + }); +} + +async function renderItemsEditor(widgets: WidgetItem[]) { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const instance = render( + React.createElement(ItemsEditor, { + widgets, + onUpdate: vi.fn(), + onBack: vi.fn(), + lineNumber: 1, + settings: DEFAULT_SETTINGS + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + await flushInk(); + + return { + output: stdout.getOutput(), + teardown: () => { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }; +} + +describe('ItemsEditor', () => { + it('names the line and the editing mode in the title', async () => { + const { output, teardown } = await renderItemsEditor([{ id: '1', type: 'model' }]); + + try { + expect(output).toContain('Edit Line 1'); + expect(output).toContain('[WIDGETS]'); + } finally { + teardown(); + } + }); + + it('numbers rows and keeps the structure markers', async () => { + const { output, teardown } = await renderItemsEditor([ + { + id: '1', + type: 'model', + merge: true + }, + { id: '2', type: 'separator', character: '|' }, + { id: '3', type: 'git-branch' } + ]); + + try { + expect(output).toContain('1. Model'); + expect(output).toContain('(merged→)'); + expect(output).toContain('2. Separator |'); + expect(output).toContain('3. Git Branch'); + } finally { + teardown(); + } + }); +}); diff --git a/src/tui/components/__tests__/widget-row.test.ts b/src/tui/components/__tests__/widget-row.test.ts new file mode 100644 index 000000000..81a7b4d23 --- /dev/null +++ b/src/tui/components/__tests__/widget-row.test.ts @@ -0,0 +1,86 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { DEFAULT_SETTINGS } from '../../../types/Settings'; +import type { WidgetItem } from '../../../types/Widget'; +import { + getWidgetRowLabel, + getWidgetRowTags +} from '../WidgetRow'; + +const AUTO_ALIGN_SETTINGS = { + ...DEFAULT_SETTINGS, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + autoAlign: true + } +}; + +describe('getWidgetRowLabel', () => { + it('names separators by their character', () => { + expect(getWidgetRowLabel({ id: '1', type: 'separator', character: '|' }).displayText) + .toBe('Separator |'); + expect(getWidgetRowLabel({ id: '1', type: 'separator', character: ' ' }).displayText) + .toBe('Separator (space)'); + expect(getWidgetRowLabel({ id: '1', type: 'flex-separator' }).displayText) + .toBe('Flex Separator'); + }); + + it('uses the widget editor display for real widgets', () => { + expect(getWidgetRowLabel({ id: '1', type: 'model' }).displayText).toBe('Model'); + }); + + it('flags unknown widget types', () => { + expect(getWidgetRowLabel({ id: '1', type: 'not-a-widget' }).displayText) + .toBe('Unknown: not-a-widget'); + }); +}); + +describe('getWidgetRowTags', () => { + it('has no tags for a plain widget', () => { + expect(getWidgetRowTags([{ id: '1', type: 'model' }], 0, DEFAULT_SETTINGS)).toEqual([]); + }); + + it('tags raw value and merge modes', () => { + const widgets: WidgetItem[] = [ + { + id: '1', + type: 'model', + rawValue: true, + merge: true + }, + { + id: '2', + type: 'model', + merge: 'no-padding' + }, + { id: '3', type: 'model' } + ]; + + expect(getWidgetRowTags(widgets, 0, DEFAULT_SETTINGS)).toEqual(['(raw value)', '(merged→)']); + expect(getWidgetRowTags(widgets, 1, DEFAULT_SETTINGS)).toEqual(['(merged-no-pad→)']); + }); + + it('tags excluded auto-align only when auto-align applies to the row', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'model' }, + { id: '2', type: 'model', excludeFromAutoAlign: true } + ]; + + expect(getWidgetRowTags(widgets, 1, AUTO_ALIGN_SETTINGS)).toEqual(['(no-align)']); + expect(getWidgetRowTags(widgets, 1, DEFAULT_SETTINGS)).toEqual([]); + }); + + it('drops the auto-align tag for a widget merged into the previous one', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'model', merge: true }, + { id: '2', type: 'model', excludeFromAutoAlign: true } + ]; + + expect(getWidgetRowTags(widgets, 1, AUTO_ALIGN_SETTINGS)).toEqual([]); + }); +}); From 5a2298ee8b76cf5a4689086e00c12f7ae914fce4 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 07:47:25 +1000 Subject: [PATCH 22/52] feat(color-menu): render color editing as a mode of the widget editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Color editing now reads as the same screen in a different mode rather than a separate one: - title is "Edit Line N [COLORS]", matching the widget editor's stem, with [BACKGROUND] as a sub-mode tag - rows come from the shared WidgetRow, so they carry the same numbering, indicator and structure markers, tinted with the color they hold - rows are numbered by position in the full line, so a widget keeps its number across modes and skipped widgets leave a gap - ink-select-input is gone; ColorMenu owns its up/down navigation, which also removes the remount-on-highlight hack and the duplicate static list used during hex/ansi256 entry - the in-list "← Back" row is dropped (the widget editor has none, and ESC is documented in both help texts) - (s)how separators folds into the help line and the VSCode contrast warning collapses to one line, so both modes put their list in the same place Row parity is deliberately not attempted: Tab only enters color editing from a colorable widget, so padding the list with unselectable rows would confuse more than the renumbering it would fix. Co-Authored-By: Claude Opus 4.8 --- src/tui/components/ColorMenu.tsx | 199 +++++++++--------- .../components/__tests__/ColorMenu.test.tsx | 110 +++++++++- 2 files changed, 209 insertions(+), 100 deletions(-) diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index 2e935ff2c..01a84e1bd 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -4,8 +4,10 @@ import { Text, useInput } from 'ink'; -import SelectInput from 'ink-select-input'; -import React, { useState } from 'react'; +import React, { + useEffect, + useState +} from 'react'; import { getColorLevelString } from '../../types/ColorLevel'; import type { Settings } from '../../types/Settings'; @@ -20,6 +22,11 @@ import { shouldInsertInput } from '../../utils/input-guards'; import { getWidget } from '../../utils/widgets'; import { ConfirmDialog } from './ConfirmDialog'; +import { + WidgetRow, + getWidgetRowLabel, + getWidgetRowTags +} from './WidgetRow'; import { clearAllWidgetStyling, cycleWidgetColor, @@ -34,7 +41,7 @@ import { export interface ColorMenuProps { widgets: WidgetItem[]; - lineIndex?: number; + lineIndex: number; settings: Settings; onUpdate: (widgets: WidgetItem[]) => void; onBack: () => void; @@ -58,16 +65,24 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin 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; - }); + // Rows keep their position in the full line so a widget carries the same number + // in both editor modes; filtered-out widgets simply leave a gap. + const colorableEntries = widgets + .map((widget, index) => ({ + widget, + index + })) + .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 colorableWidgets = colorableEntries.map(entry => entry.widget); const [highlightedItemId, setHighlightedItemId] = useState(() => { if (initialWidgetId) { const match = colorableWidgets.find(w => w.id === initialWidgetId); @@ -79,6 +94,35 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin }); const [editingBackground, setEditingBackground] = useState(false); + // Keep the highlight on a row that still exists - toggling separators can remove it + useEffect(() => { + if (colorableWidgets.length === 0) { + return; + } + + if (!colorableWidgets.some(widget => widget.id === highlightedItemId)) { + setHighlightedItemId(colorableWidgets[0]?.id ?? null); + } + }, [colorableWidgets, highlightedItemId]); + + useEffect(() => { + onWidgetHighlight?.(highlightedItemId); + }, [highlightedItemId, onWidgetHighlight]); + + const moveHighlight = (direction: 'up' | 'down') => { + if (colorableWidgets.length === 0) { + return; + } + + const currentIndex = colorableWidgets.findIndex(widget => widget.id === highlightedItemId); + const fromIndex = currentIndex === -1 ? 0 : currentIndex; + const nextIndex = direction === 'down' + ? (fromIndex + 1) % colorableWidgets.length + : (fromIndex - 1 + colorableWidgets.length) % colorableWidgets.length; + + setHighlightedItemId(colorableWidgets[nextIndex]?.id ?? null); + }; + // Handle keyboard input const hasNoItems = colorableWidgets.length === 0; const themeActive = settings.powerline.enabled @@ -347,6 +391,8 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin } } } + } else if (key.upArrow || key.downArrow) { + moveHighlight(key.downArrow ? 'down' : 'up'); } else if (key.leftArrow || key.rightArrow) { // Cycle through colors with arrow keys if (highlightedItemId && highlightedItemId !== 'back') { @@ -369,10 +415,15 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin if (hasNoItems) { return ( - - Configure Colors - {lineIndex !== undefined ? ` - Line ${lineIndex + 1}` : ''} - + + + Edit Line + {' '} + {lineIndex + 1} + {' '} + + [COLORS] + No colorable widgets in the status line. Add a widget first to continue. Press any key to go back... @@ -380,19 +431,6 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin ); } - 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(); @@ -402,10 +440,9 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin 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 + // Rows read like the widget editor's rows, tinted with the colour they carry + const menuRows = colorableEntries.map(({ widget, index }) => { + const { displayText, modifierText } = getWidgetRowLabel(widget); const level = getColorLevelString(settings.colorLevel); let defaultColor = 'white'; if (widget.type !== 'separator' && widget.type !== 'flex-separator') { @@ -414,27 +451,15 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin defaultColor = widgetImpl.getDefaultColor(); } } - const styledLabel = applyColors(label, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, level, widget.dim); + return { - label: styledLabel, - value: widget.id + id: widget.id, + number: index + 1, + label: applyColors(displayText, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, level, widget.dim), + modifierText, + tags: getWidgetRowTags(widgets, index, settings) }; }); - 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); - if (onWidgetHighlight) { - onWidgetHighlight(item.value === 'back' ? null : item.value); - } - }; // Get current color for highlighted item const selectedWidget = highlightedItemId && highlightedItemId !== 'back' @@ -520,7 +545,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin // Gradient selection mode takes over the whole view if (gradientMode) { const level = getColorLevelString(settings.colorLevel); - const widgetName = selectedWidget ? getItemLabel(selectedWidget) : ''; + const widgetName = selectedWidget ? getWidgetRowLabel(selectedWidget).displayText : ''; if (gradientCustomStep) { return ( @@ -620,10 +645,13 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin - Configure Colors - {lineIndex !== undefined ? ` - Line ${lineIndex + 1}` : ''} - {editingBackground && chalk.yellow(' [Background Mode]')} + Edit Line + {' '} + {lineIndex + 1} + {' '} + [COLORS] + {editingBackground && [BACKGROUND]} {globalOverrideMessage && ( {'. '} @@ -664,16 +692,13 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin {' '} (r)eset, (c)lear all, {themeActive ? ' (p)in/unpin,' : ''} - {onTabSwap ? ' ⇥ edit items,' : ''} + {!settings.powerline.enabled && !settings.defaultSeparator + ? ` (s)how separators: ${showSeparators ? 'ON' : 'OFF'},` + : ''} + {onTabSwap ? ' ⇥ edit widgets,' : ''} {' '} ESC to go back - {!settings.powerline.enabled && !settings.defaultSeparator && ( - - (s)how separators: - {showSeparators ? chalk.green('ON') : chalk.gray('OFF')} - - )} {selectedWidget ? ( @@ -697,43 +722,21 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin )} )} - - {(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}`} - )} + + {menuRows.map(row => ( + - )} + ))} - - ⚠ 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. + + ⚠ VSCode: if colors look wrong, set `terminal.integrated.minimumContrastRatio` to 1 ); diff --git a/src/tui/components/__tests__/ColorMenu.test.tsx b/src/tui/components/__tests__/ColorMenu.test.tsx index cec3b053c..9ebc7bb1b 100644 --- a/src/tui/components/__tests__/ColorMenu.test.tsx +++ b/src/tui/components/__tests__/ColorMenu.test.tsx @@ -58,6 +58,13 @@ function flushInk() { }); } +const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); + +/** Colour rows carry ANSI codes between the number and the label, so strip them to assert text. */ +function stripAnsi(value: string): string { + return value.replace(ANSI_PATTERN, ''); +} + describe('ColorMenu', () => { it('keeps bold and dim indicators on the current-style row', async () => { const stdin = createMockStdin(); @@ -80,6 +87,7 @@ describe('ColorMenu', () => { const instance = render( React.createElement(ColorMenu, { widgets, + lineIndex: 0, settings: { ...DEFAULT_SETTINGS, colorLevel: 3, @@ -106,7 +114,7 @@ describe('ColorMenu', () => { stdin.write('\x1B[B'); await flushInk(); - const latestFrame = stdout.getOutput().split('Configure Colors').at(-1) ?? ''; + const latestFrame = stdout.getOutput().split('Edit Line 1').at(-1) ?? ''; const currentStyleLine = latestFrame .split('\n') .find(line => line.includes('Current foreground')) ?? ''; @@ -129,6 +137,7 @@ describe('ColorMenu', () => { const instance = render( React.createElement(ColorMenu, { widgets, + lineIndex: 0, settings: { ...DEFAULT_SETTINGS, colorLevel: 3, @@ -223,6 +232,7 @@ describe('ColorMenu', () => { const instance = render( React.createElement(ColorMenu, { widgets, + lineIndex: 0, settings: { ...DEFAULT_SETTINGS, colorLevel: 3, @@ -245,7 +255,7 @@ describe('ColorMenu', () => { } ); await flushInk(); - const frame = stdout.getOutput().split('Configure Colors').at(-1) ?? ''; + const frame = stdout.getOutput().split('Edit Line 1').at(-1) ?? ''; const line = frame.split('\n').find(entry => entry.includes('Current foreground')) ?? ''; return { line, @@ -281,4 +291,100 @@ describe('ColorMenu', () => { teardown(); } }); + + async function renderPlainOutput(widgets: WidgetItem[]): Promise<{ output: string; teardown: () => void }> { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const instance = render( + React.createElement(ColorMenu, { + widgets, + lineIndex: 0, + settings: { + ...DEFAULT_SETTINGS, + colorLevel: 3, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true + } + }, + onUpdate: vi.fn(), + onBack: vi.fn() + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + await flushInk(); + + return { + output: stripAnsi(stdout.getOutput()), + teardown: () => { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }; + } + + it('names the line and the editing mode in the title', async () => { + const { output, teardown } = await renderPlainOutput([{ id: '1', type: 'model' }]); + + try { + expect(output).toContain('Edit Line 1'); + expect(output).toContain('[COLORS]'); + } finally { + teardown(); + } + }); + + it('numbers rows by their position in the line, leaving gaps for skipped widgets', async () => { + const { output, teardown } = await renderPlainOutput([ + { id: '1', type: 'model' }, + { id: '2', type: 'separator', character: '|' }, + { id: '3', type: 'git-branch' } + ]); + + try { + expect(output).toContain('1. Model'); + expect(output).toContain('3. Git Branch'); + expect(output).not.toContain('2. Separator'); + } finally { + teardown(); + } + }); + + it('keeps the widget editor structure markers on colour rows', async () => { + const { output, teardown } = await renderPlainOutput([ + { + id: '1', + type: 'model', + merge: true + }, + { id: '2', type: 'git-branch' } + ]); + + try { + expect(output).toContain('(merged→)'); + } finally { + teardown(); + } + }); + + it('has no back row, matching the widget editor list', async () => { + const { output, teardown } = await renderPlainOutput([{ id: '1', type: 'model' }]); + + try { + expect(output).not.toContain('← Back'); + } finally { + teardown(); + } + }); }); From 24e3b7fdff9799a7f7cd17940575cdcbc9407a18 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 07:51:04 +1000 Subject: [PATCH 23/52] chore: drop the now-unused ink-select-input dependency ColorMenu was the only consumer and it now renders the shared WidgetRow list, so nothing in the repo imports ink-select-input. Also switch the ColorMenu test's ANSI stripping to the strip-ansi package the other TUI tests already use, instead of a hand-rolled regex. Co-Authored-By: Claude Opus 4.8 --- bun.lock | 9 --------- package.json | 1 - src/tui/components/__tests__/ColorMenu.test.tsx | 8 +------- 3 files changed, 1 insertion(+), 17 deletions(-) diff --git a/bun.lock b/bun.lock index 1f68b00dd..89b68e6e7 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,6 @@ "https-proxy-agent": "^8.0.0", "ink": "6.2.0", "ink-gradient": "^4.0.0", - "ink-select-input": "^6.2.0", "pluralize": "^8.0.0", "react": "19.2.7", "react-devtools-core": "^7.0.1", @@ -670,8 +669,6 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], - "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], @@ -758,8 +755,6 @@ "ink-gradient": ["ink-gradient@4.0.1", "", { "dependencies": { "@types/gradient-string": "^1.1.6", "gradient-string": "^3.0.0", "strip-ansi": "^7.1.2" }, "peerDependencies": { "ink": ">=6", "react": ">=19.2.0" } }, "sha512-0ckdiM84zkfCdnTtcnq4BS3egIhUPPDoCqSx/7NUFsAVooBbdRuGnnWpk0fuaOTqU6rlZRh9F4LN1UI8fxd81Q=="], - "ink-select-input": ["ink-select-input@6.2.0", "", { "dependencies": { "figures": "^6.1.0", "to-rotated": "^1.0.0" }, "peerDependencies": { "ink": ">=5.0.0", "react": ">=18.0.0" } }, "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ=="], - "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], @@ -814,8 +809,6 @@ "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], - "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], @@ -1136,8 +1129,6 @@ "to-fast-properties": ["to-fast-properties@2.0.0", "", {}, "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog=="], - "to-rotated": ["to-rotated@1.0.0", "", {}, "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q=="], - "tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="], "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], diff --git a/package.json b/package.json index f1aaa9297..8b08824db 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,6 @@ "https-proxy-agent": "^8.0.0", "ink": "6.2.0", "ink-gradient": "^4.0.0", - "ink-select-input": "^6.2.0", "pluralize": "^8.0.0", "react": "19.2.7", "react-devtools-core": "^7.0.1", diff --git a/src/tui/components/__tests__/ColorMenu.test.tsx b/src/tui/components/__tests__/ColorMenu.test.tsx index 9ebc7bb1b..7e5b1a8e5 100644 --- a/src/tui/components/__tests__/ColorMenu.test.tsx +++ b/src/tui/components/__tests__/ColorMenu.test.tsx @@ -1,6 +1,7 @@ import { render } from 'ink'; import { PassThrough } from 'node:stream'; import React from 'react'; +import stripAnsi from 'strip-ansi'; import { describe, expect, @@ -58,13 +59,6 @@ function flushInk() { }); } -const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); - -/** Colour rows carry ANSI codes between the number and the label, so strip them to assert text. */ -function stripAnsi(value: string): string { - return value.replace(ANSI_PATTERN, ''); -} - describe('ColorMenu', () => { it('keeps bold and dim indicators on the current-style row', async () => { const stdin = createMockStdin(); From c8f834d9d7b4c172a87e9a11d1be8c82bd4f983c Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 07:56:33 +1000 Subject: [PATCH 24/52] style(color-menu): make the VSCode note read as a footnote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both editor modes put conditions that override what you do here next to the title - the powerline warning in the widget editor, the global colour override warning here. The VSCode contrast note is not that: it never changes with state and is environmental, so wearing the same ⚠ glyph made it read as a misplaced peer of those warnings rather than as a footnote. Also drops a stray '.' that separated the title from the global override warning. Co-Authored-By: Claude Opus 4.8 --- src/tui/components/ColorMenu.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index 01a84e1bd..ae824bdb3 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -654,7 +654,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin {editingBackground && [BACKGROUND]} {globalOverrideMessage && ( - {'. '} + {' '} {globalOverrideMessage} )} @@ -736,7 +736,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin ))} - ⚠ VSCode: if colors look wrong, set `terminal.integrated.minimumContrastRatio` to 1 + VSCode: if colors look wrong, set `terminal.integrated.minimumContrastRatio` to 1 ); From a1a63f0f591829c5637755884ebf15973ca6f6fe Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 08:03:32 +1000 Subject: [PATCH 25/52] refactor(powerline): one theme-slot assignment for renderer and editor The color editor needs to know which theme color a widget actually renders in, and re-deriving that would drift from the renderer the first time either side changed. assignPowerlineThemeSlots() now owns the rule - merged widgets share a slot, separators break a merge run, widgets that render nothing take no slot - and both callers read from it. The renderer replaces its inline index counter with a lookup, countPowerlineThemeSlots() is expressed in terms of the assignment, and getEffectiveThemeColors() maps widget id to the theme colors that win over the widget's own, leaving pinned channels (and a preserve-colors custom command's foreground) undefined so callers fall back to the stored color. getActiveThemeColors() also replaces the renderer's inline theme lookup, so the color-level key mapping lives in one place too. Co-Authored-By: Claude Opus 4.8 --- .../__tests__/effective-theme-colors.test.ts | 139 ++++++++++++++++++ .../__tests__/powerline-theme-index.test.ts | 32 ++++ src/utils/effective-theme-colors.ts | 88 +++++++++++ src/utils/powerline-theme-index.ts | 34 ++++- src/utils/renderer.ts | 48 +++--- 5 files changed, 313 insertions(+), 28 deletions(-) create mode 100644 src/utils/__tests__/effective-theme-colors.test.ts create mode 100644 src/utils/effective-theme-colors.ts diff --git a/src/utils/__tests__/effective-theme-colors.test.ts b/src/utils/__tests__/effective-theme-colors.test.ts new file mode 100644 index 000000000..652c22817 --- /dev/null +++ b/src/utils/__tests__/effective-theme-colors.test.ts @@ -0,0 +1,139 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { getPowerlineTheme } from '../colors'; +import { getEffectiveThemeColors } from '../effective-theme-colors'; + +const THEME_NAME = 'nord-aurora'; + +const THEMED_SETTINGS = { + ...DEFAULT_SETTINGS, + colorLevel: 3 as const, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + theme: THEME_NAME + } +}; + +function themeSlot(index: number): { fg: string; bg: string } { + const theme = getPowerlineTheme(THEME_NAME); + const level = theme?.['3']; + if (!level) { + throw new Error(`Theme ${THEME_NAME} has no truecolor level`); + } + + return { + fg: level.fg[index % level.fg.length] ?? '', + bg: level.bg[index % level.bg.length] ?? '' + }; +} + +describe('getEffectiveThemeColors', () => { + it('is empty when no theme is driving the colors', () => { + const widgets: WidgetItem[] = [{ id: '1', type: 'model' }]; + + expect(getEffectiveThemeColors(widgets, DEFAULT_SETTINGS).size).toBe(0); + expect(getEffectiveThemeColors(widgets, { + ...THEMED_SETTINGS, + powerline: { + ...THEMED_SETTINGS.powerline, + theme: 'custom' + } + }).size).toBe(0); + expect(getEffectiveThemeColors(widgets, { + ...THEMED_SETTINGS, + powerline: { + ...THEMED_SETTINGS.powerline, + enabled: false + } + }).size).toBe(0); + }); + + it('gives each widget the theme colors of its slot', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'model' }, + { id: '2', type: 'git-branch' } + ]; + const colors = getEffectiveThemeColors(widgets, THEMED_SETTINGS); + + expect(colors.get('1')).toEqual({ + fg: themeSlot(0).fg, + bg: themeSlot(0).bg + }); + expect(colors.get('2')).toEqual({ + fg: themeSlot(1).fg, + bg: themeSlot(1).bg + }); + }); + + it('shares one slot between merged widgets', () => { + const widgets: WidgetItem[] = [ + { + id: '1', + type: 'model', + merge: true + }, + { id: '2', type: 'git-branch' }, + { id: '3', type: 'context-length' } + ]; + const colors = getEffectiveThemeColors(widgets, THEMED_SETTINGS); + + expect(colors.get('2')).toEqual(colors.get('1')); + expect(colors.get('3')?.fg).toBe(themeSlot(1).fg); + }); + + it('skips separators without consuming a slot', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'model' }, + { id: '2', type: 'separator', character: '|' }, + { id: '3', type: 'git-branch' } + ]; + const colors = getEffectiveThemeColors(widgets, THEMED_SETTINGS); + + expect(colors.has('2')).toBe(false); + expect(colors.get('3')?.fg).toBe(themeSlot(1).fg); + }); + + it('omits channels the widget keeps for itself', () => { + const widgets: WidgetItem[] = [ + { + id: '1', + type: 'model', + color: 'hex:FF0000', + pinColor: true + }, + { + id: '2', + type: 'git-branch', + backgroundColor: 'hex:00FF00', + pinBackgroundColor: true + } + ]; + const colors = getEffectiveThemeColors(widgets, THEMED_SETTINGS); + + expect(colors.get('1')?.fg).toBeUndefined(); + expect(colors.get('1')?.bg).toBe(themeSlot(0).bg); + expect(colors.get('2')?.fg).toBe(themeSlot(1).fg); + expect(colors.get('2')?.bg).toBeUndefined(); + }); + + it('leaves a preserve-colors custom command its own foreground', () => { + const widgets: WidgetItem[] = [ + { + id: '1', + type: 'custom-command', + preserveColors: true + } + ]; + const colors = getEffectiveThemeColors(widgets, THEMED_SETTINGS); + + expect(colors.get('1')?.fg).toBeUndefined(); + expect(colors.get('1')?.bg).toBe(themeSlot(0).bg); + }); +}); diff --git a/src/utils/__tests__/powerline-theme-index.test.ts b/src/utils/__tests__/powerline-theme-index.test.ts index dece273ba..837c39f05 100644 --- a/src/utils/__tests__/powerline-theme-index.test.ts +++ b/src/utils/__tests__/powerline-theme-index.test.ts @@ -7,6 +7,7 @@ import { import type { WidgetItem } from '../../types/Widget'; import { advanceGlobalPowerlineThemeIndex, + assignPowerlineThemeSlots, countPowerlineThemeSlots, type PowerlineThemeSlotEntry } from '../powerline-theme-index'; @@ -53,6 +54,37 @@ describe('powerline theme index utils', () => { expect(countPowerlineThemeSlots(entries)).toBe(3); }); + it('assigns merged widgets the same slot and skips unrendered entries', () => { + const entries: PowerlineThemeSlotEntry[] = [ + entry({ id: '1', type: 'model', merge: true }), + entry({ id: '2', type: 'context-length' }), + entry({ id: '3', type: 'separator' }), + entry({ id: '4', type: 'git-branch' }, ''), + entry({ id: '5', type: 'git-changes' }) + ]; + + expect(assignPowerlineThemeSlots(entries)).toEqual([0, 0, -1, -1, 1]); + }); + + it('starts assignment from a carried-over index', () => { + const entries: PowerlineThemeSlotEntry[] = [ + entry({ id: '1', type: 'model' }), + entry({ id: '2', type: 'git-branch' }) + ]; + + expect(assignPowerlineThemeSlots(entries, 3)).toEqual([3, 4]); + }); + + it('breaks a merge run at a separator', () => { + const entries: PowerlineThemeSlotEntry[] = [ + entry({ id: '1', type: 'model', merge: true }), + entry({ id: '2', type: 'flex-separator' }), + entry({ id: '3', type: 'context-length' }) + ]; + + expect(assignPowerlineThemeSlots(entries)).toEqual([0, -1, 1]); + }); + it('advances a running global theme index', () => { const firstLine: PowerlineThemeSlotEntry[] = [ entry({ id: '1', type: 'model' }), diff --git a/src/utils/effective-theme-colors.ts b/src/utils/effective-theme-colors.ts new file mode 100644 index 000000000..f372974f1 --- /dev/null +++ b/src/utils/effective-theme-colors.ts @@ -0,0 +1,88 @@ +import { getColorLevelString } from '../types/ColorLevel'; +import type { Settings } from '../types/Settings'; +import type { WidgetItem } from '../types/Widget'; + +import { getPowerlineTheme } from './colors'; +import { + NO_THEME_SLOT, + assignPowerlineThemeSlots +} from './powerline-theme-index'; + +export interface ThemeChannelColors { + fg?: string; + bg?: string; +} + +/** + * Foreground/background palettes of the active powerline theme, or undefined when no + * theme is driving the colors (powerline off, no theme, or the 'custom' theme). + */ +export function getActiveThemeColors(settings: Settings): { fg: string[]; bg: string[] } | undefined { + if (!settings.powerline.enabled) { + return undefined; + } + + const themeName = settings.powerline.theme; + if (!themeName || themeName === 'custom') { + return undefined; + } + + const theme = getPowerlineTheme(themeName); + if (!theme) { + return undefined; + } + + const colorLevel = getColorLevelString(settings.colorLevel); + const colorLevelKey = colorLevel === 'ansi16' ? '1' : colorLevel === 'ansi256' ? '2' : '3'; + + return theme[colorLevelKey]; +} + +/** Does the theme leave this widget's foreground alone? */ +export function keepsOwnForeground(widget: WidgetItem): boolean { + return Boolean(widget.pinColor) || (widget.type === 'custom-command' && Boolean(widget.preserveColors)); +} + +/** + * Theme colors that win over each widget's own color, keyed by widget id. Empty when no + * theme is active. A channel the widget keeps for itself - a pinned color, or a custom + * command preserving its own output colors - is left undefined, so callers fall back to + * the widget's stored color for it. + * + * Slots come from assignPowerlineThemeSlots, the same assignment the renderer uses, so + * the editor cannot drift from what actually renders. Global color overrides are not + * applied here; the editor flags those separately. + */ +export function getEffectiveThemeColors(widgets: WidgetItem[], settings: Settings): Map { + const effective = new Map(); + const themeColors = getActiveThemeColors(settings); + + if (!themeColors) { + return effective; + } + + // The editor has no rendered output to inspect, so every non-separator widget is + // assumed to render - which is what the editor shows the user anyway. + const slots = assignPowerlineThemeSlots(widgets.map(widget => ({ + widget, + content: 'x' + }))); + + widgets.forEach((widget, index) => { + const slot = slots[index]; + if (slot === undefined || slot === NO_THEME_SLOT) { + return; + } + + effective.set(widget.id, { + fg: keepsOwnForeground(widget) + ? undefined + : themeColors.fg[slot % themeColors.fg.length], + bg: widget.pinBackgroundColor + ? undefined + : themeColors.bg[slot % themeColors.bg.length] + }); + }); + + return effective; +} diff --git a/src/utils/powerline-theme-index.ts b/src/utils/powerline-theme-index.ts index 6b26b770c..adb74db3a 100644 --- a/src/utils/powerline-theme-index.ts +++ b/src/utils/powerline-theme-index.ts @@ -5,28 +5,54 @@ export interface PowerlineThemeSlotEntry { widget: WidgetItem; } -export function countPowerlineThemeSlots(entries: PowerlineThemeSlotEntry[]): number { +/** Slot assigned to entries that take no theme color (separators, unrendered widgets). */ +export const NO_THEME_SLOT = -1; + +/** + * Theme color slot for each entry, in order. Merged widgets share a slot, separators + * break a merge run, and widgets that render nothing take no slot at all. + * + * This is the one place that decides which theme color a widget renders in - the + * powerline renderer and the color editor both read their answer from here. + */ +export function assignPowerlineThemeSlots(entries: PowerlineThemeSlotEntry[], startIndex = 0): number[] { + const slots: number[] = []; let previousVisibleWidget: WidgetItem | null = null; - let slotCount = 0; + let slotIndex = startIndex; + let assignedAny = false; for (const entry of entries) { if (entry.widget.type === 'separator' || entry.widget.type === 'flex-separator') { previousVisibleWidget = null; + slots.push(NO_THEME_SLOT); continue; } if (!entry.content) { + slots.push(NO_THEME_SLOT); continue; } if (!previousVisibleWidget?.merge) { - slotCount++; + if (assignedAny) { + slotIndex++; + } + + assignedAny = true; } + slots.push(slotIndex); previousVisibleWidget = entry.widget; } - return slotCount; + return slots; +} + +export function countPowerlineThemeSlots(entries: PowerlineThemeSlotEntry[]): number { + const assigned = assignPowerlineThemeSlots(entries).filter(slot => slot !== NO_THEME_SLOT); + const lastSlot = assigned.at(-1); + + return lastSlot === undefined ? 0 : lastSlot + 1; } export function advanceGlobalPowerlineThemeIndex(currentIndex: number, entries: PowerlineThemeSlotEntry[]): number { diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index 60194105e..e2d8fa181 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -22,14 +22,18 @@ import { applyColors, applyParensDim, bgToFg, - getColorAnsiCode, - getPowerlineTheme + getColorAnsiCode } from './colors'; import { calculateContextPercentage } from './context-percentage'; +import { getActiveThemeColors } from './effective-theme-colors'; import { isGradientSpec, parseGradientSpec } from './gradient'; +import { + NO_THEME_SLOT, + assignPowerlineThemeSlots +} from './powerline-theme-index'; import { getTerminalWidth } from './terminal'; import { getWidget } from './widgets'; @@ -128,17 +132,7 @@ function renderPowerlineStatusLine( ); // Get theme colors if a theme is set and not 'custom' - const themeName = config.theme as string | undefined; - let themeColors: { fg: string[]; bg: string[] } | undefined; - - if (themeName && themeName !== 'custom') { - const theme = getPowerlineTheme(themeName); - if (theme) { - const colorLevel = getColorLevelString(settings.colorLevel); - const colorLevelKey = colorLevel === 'ansi16' ? '1' : colorLevel === 'ansi256' ? '2' : '3'; - themeColors = theme[colorLevelKey]; - } - } + const themeColors = getActiveThemeColors(settings); // Get color level from settings const colorLevel = getColorLevelString(settings.colorLevel); @@ -173,7 +167,15 @@ function renderPowerlineStatusLine( originalIndex: number; widget: WidgetItem; }[] = []; - let widgetColorIndex = continueThemeAcrossLines ? globalThemeColorOffset : 0; + // Theme color slot per widget, assigned by the shared helper the color editor also + // uses, so what the editor previews cannot drift from what renders here. + const themeColorSlots = assignPowerlineThemeSlots( + widgets.map((widget, index) => ({ + widget, + content: preRenderedWidgets[index]?.content ?? '' + })), + continueThemeAcrossLines ? globalThemeColorOffset : 0 + ); const hasNextRenderedWidgetBeforeSeparator = (originalIndex: number): boolean => { for (let j = originalIndex + 1; j < widgets.length; j++) { @@ -282,21 +284,19 @@ function renderPowerlineStatusLine( // For custom commands with preserveColors, only skip foreground theme colors const skipFgTheme = widget.type === 'custom-command' && widget.preserveColors; - if (themeColors) { + const themeColorSlot = actualPreRenderedIndex !== undefined + ? themeColorSlots[actualPreRenderedIndex] ?? NO_THEME_SLOT + : NO_THEME_SLOT; + + if (themeColors && themeColorSlot !== NO_THEME_SLOT) { // A pinned channel keeps the widget's own colour (it wins over the theme). // preserveColors (custom-command) still takes precedence for the foreground. + // Pinned widgets still occupy their slot, so siblings' colours don't shift. if (!skipFgTheme && !widget.pinColor) { - fgColor = themeColors.fg[widgetColorIndex % themeColors.fg.length] ?? fgColor; + fgColor = themeColors.fg[themeColorSlot % themeColors.fg.length] ?? fgColor; } if (!widget.pinBackgroundColor) { - bgColor = themeColors.bg[widgetColorIndex % themeColors.bg.length] ?? bgColor; - } - - // Always advance the colour index (even for pinned widgets, which occupy - // their slot without using the colour) so other widgets' theme colours - // don't shift. Merged widgets share a colour, so don't advance for them. - if (!mergesWithNext) { - widgetColorIndex++; + bgColor = themeColors.bg[themeColorSlot % themeColors.bg.length] ?? bgColor; } } From cf582016a30914a75c00a352f8d98288a97b3193 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 08:07:00 +1000 Subject: [PATCH 26/52] feat(color-menu): show the colour a widget actually renders under a theme An unpinned channel under an active theme renders the theme's colour, but the editor showed the widget's dormant stored value - both on the current-style row and in the row tinting - so the editor disagreed with the preview about what was on screen. Both now read from getEffectiveThemeColors, so an unpinned row is tinted with its theme colour and the current-style row reports it as "(theme)" instead of a palette position that does not apply. Pinned channels are unchanged: they still show the widget's own colour with [PINNED]. Co-Authored-By: Claude Opus 4.8 --- src/tui/components/ColorMenu.tsx | 34 +++++- .../components/__tests__/ColorMenu.test.tsx | 101 ++++++++++++++++++ 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index ae824bdb3..5bdf9b4ed 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -17,6 +17,7 @@ import { getAvailableBackgroundColorsForUI, getAvailableColorsForUI } from '../../utils/colors'; +import { getEffectiveThemeColors } from '../../utils/effective-theme-colors'; import { GRADIENT_PRESET_NAMES } from '../../utils/gradient'; import { shouldInsertInput } from '../../utils/input-guards'; import { getWidget } from '../../utils/widgets'; @@ -64,6 +65,8 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin const [gradientHexInput, setGradientHexInput] = useState(''); const powerlineEnabled = settings.powerline.enabled; + // What each widget actually renders as under the active theme; empty without one + const effectiveThemeColors = getEffectiveThemeColors(widgets, settings); // Rows keep their position in the full line so a widget carries the same number // in both editor modes; filtered-out widgets simply leave a gap. @@ -440,7 +443,8 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin const bgColorOptions = getAvailableBackgroundColorsForUI(); const bgColors = bgColorOptions.map(c => c.value || ''); - // Rows read like the widget editor's rows, tinted with the colour they carry + // Rows are tinted with what actually renders: an unpinned channel shows the theme's + // colour, not the widget's dormant stored one. const menuRows = colorableEntries.map(({ widget, index }) => { const { displayText, modifierText } = getWidgetRowLabel(widget); const level = getColorLevelString(settings.colorLevel); @@ -452,10 +456,19 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin } } + const themeChannels = effectiveThemeColors.get(widget.id); + return { id: widget.id, number: index + 1, - label: applyColors(displayText, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, level, widget.dim), + label: applyColors( + displayText, + themeChannels?.fg ?? widget.color ?? defaultColor, + themeChannels?.bg ?? widget.backgroundColor, + widget.bold, + level, + widget.dim + ), modifierText, tags: getWidgetRowTags(widgets, index, settings) }; @@ -465,7 +478,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin const selectedWidget = highlightedItemId && highlightedItemId !== 'back' ? colorableWidgets.find(widget => widget.id === highlightedItemId) : null; - const currentColor = editingBackground + const storedColor = editingBackground ? (selectedWidget?.backgroundColor ?? '') // Empty string for 'none' : (selectedWidget ? (selectedWidget.color ?? (() => { if (selectedWidget.type !== 'separator' && selectedWidget.type !== 'flex-separator') { @@ -475,9 +488,18 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin return 'white'; })()) : 'white'); + // Under a theme an unpinned channel renders the theme's colour, so show that rather + // than the dormant stored value the user would otherwise think was in effect. + const selectedThemeChannels = selectedWidget ? effectiveThemeColors.get(selectedWidget.id) : undefined; + const themeChannelColor = editingBackground ? selectedThemeChannels?.bg : selectedThemeChannels?.fg; + const themeDrivesChannel = themeChannelColor !== undefined; + const currentColor = themeChannelColor ?? storedColor; + const colorList = editingBackground ? bgColors : colors; const colorIndex = colorList.indexOf(currentColor); - const colorNumber = colorIndex === -1 ? 'custom' : colorIndex + 1; + const colorNumber = themeDrivesChannel + ? 'theme' + : (colorIndex === -1 ? 'custom' : colorIndex + 1); let colorDisplay; if (editingBackground) { @@ -707,7 +729,9 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin {editingBackground ? 'background' : 'foreground'} {' '} ( - {colorNumber === 'custom' ? 'custom' : `${colorNumber}/${colorList.length}`} + {colorNumber === 'custom' || colorNumber === 'theme' + ? colorNumber + : `${colorNumber}/${colorList.length}`} ): {' '} {colorDisplay} diff --git a/src/tui/components/__tests__/ColorMenu.test.tsx b/src/tui/components/__tests__/ColorMenu.test.tsx index 7e5b1a8e5..6097c22ad 100644 --- a/src/tui/components/__tests__/ColorMenu.test.tsx +++ b/src/tui/components/__tests__/ColorMenu.test.tsx @@ -12,6 +12,10 @@ import { import { DEFAULT_SETTINGS } from '../../../types/Settings'; import type { WidgetItem } from '../../../types/Widget'; +import { + applyColors, + getPowerlineTheme +} from '../../../utils/colors'; import { ColorMenu } from '../ColorMenu'; class MockTtyStream extends PassThrough { @@ -372,6 +376,103 @@ describe('ColorMenu', () => { } }); + function nordLevel3() { + const level = getPowerlineTheme('nord-aurora')?.['3']; + if (!level) { + throw new Error('nord-aurora has no truecolor level'); + } + + return level; + } + + it('shows the theme colour on the current-style row when the channel is unpinned', async () => { + const themeFg = nordLevel3().fg[0] ?? ''; + const { line, teardown } = await renderCurrentStyleLine([ + { id: '1', type: 'model', color: 'hex:FF0000' } + ]); + + try { + const plain = stripAnsi(line); + expect(plain).toContain('(theme)'); + expect(plain).toContain(`#${themeFg.replace('hex:', '')}`); + expect(plain).not.toContain('#FF0000'); + } finally { + teardown(); + } + }); + + it('keeps showing the widget colour on the current-style row when pinned', async () => { + const { line, teardown } = await renderCurrentStyleLine([ + { + id: '1', + type: 'model', + color: 'hex:FF0000', + pinColor: true + } + ]); + + try { + const plain = stripAnsi(line); + expect(plain).toContain('#FF0000'); + expect(plain).not.toContain('(theme)'); + } finally { + teardown(); + } + }); + + it('tints an unpinned row with the theme colour, not its dormant colour', async () => { + const level = nordLevel3(); + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const instance = render( + React.createElement(ColorMenu, { + widgets: [{ + id: '1', + type: 'model', + color: 'hex:FF0000', + backgroundColor: 'hex:00FF00' + }], + lineIndex: 0, + settings: { + ...DEFAULT_SETTINGS, + colorLevel: 3, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + theme: 'nord-aurora' + } + }, + onUpdate: vi.fn(), + onBack: vi.fn() + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + const output = stdout.getOutput(); + const themed = applyColors('Model', level.fg[0], level.bg[0], undefined, 'truecolor', undefined); + const dormant = applyColors('Model', 'hex:FF0000', 'hex:00FF00', undefined, 'truecolor', undefined); + + expect(output).toContain(themed); + expect(output).not.toContain(dormant); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + it('has no back row, matching the widget editor list', async () => { const { output, teardown } = await renderPlainOutput([{ id: '1', type: 'model' }]); From 409d7f49ffcda1b0ab30e9ce74c28d8f2fc5f103 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 08:24:59 +1000 Subject: [PATCH 27/52] feat(items-editor): tint widget rows with the colour they render in Theme colours are positional - a widget's colour comes from its slot, and slots move when you reorder or merge widgets. That is precisely what the widget editor does, so it was the one place you could not see the effect without tabbing across to the colour editor and back. Rows are now tinted through the same styleWidgetRowLabel helper the colour editor uses, so outside move mode the two lists render identically - a new parity test asserts the rows are byte-identical, ANSI included. Selection is shown by the indicator alone, as it already is in colour mode. Move mode drops the tint and keeps the blue row, so the row being dragged stays trackable; the change of rendering doubles as the mode signal. A custom command preserving its own output colours is left untinted, since its colours are not ours to predict. This inverts the direction of sirmalloc's request - it changes the widget editor to match the colour editor rather than the reverse - and it costs the green selected-row highlight, so it is deliberately the last commit on the branch and can be dropped on its own. Co-Authored-By: Claude Opus 4.8 --- src/tui/components/ColorMenu.tsx | 22 +--- src/tui/components/ItemsEditor.tsx | 12 +- src/tui/components/WidgetRow.tsx | 32 +++++ .../components/__tests__/ItemsEditor.test.tsx | 106 +++++++++++++++- .../__tests__/editor-row-parity.test.tsx | 118 ++++++++++++++++++ 5 files changed, 266 insertions(+), 24 deletions(-) create mode 100644 src/tui/components/__tests__/editor-row-parity.test.tsx diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index 5bdf9b4ed..3232449d5 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -26,7 +26,8 @@ import { ConfirmDialog } from './ConfirmDialog'; import { WidgetRow, getWidgetRowLabel, - getWidgetRowTags + getWidgetRowTags, + styleWidgetRowLabel } from './WidgetRow'; import { clearAllWidgetStyling, @@ -447,28 +448,11 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin // colour, not the widget's dormant stored one. const menuRows = colorableEntries.map(({ widget, index }) => { const { displayText, modifierText } = getWidgetRowLabel(widget); - 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 themeChannels = effectiveThemeColors.get(widget.id); return { id: widget.id, number: index + 1, - label: applyColors( - displayText, - themeChannels?.fg ?? widget.color ?? defaultColor, - themeChannels?.bg ?? widget.backgroundColor, - widget.bold, - level, - widget.dim - ), + label: styleWidgetRowLabel(displayText, widget, settings, effectiveThemeColors.get(widget.id)), modifierText, tags: getWidgetRowTags(widgets, index, settings) }; diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index c9fe58eb1..34ee45aae 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -16,6 +16,7 @@ import type { WidgetItemType } from '../../types/Widget'; import { getBackgroundColorsForPowerline } from '../../utils/colors'; +import { getEffectiveThemeColors } from '../../utils/effective-theme-colors'; import { generateGuid } from '../../utils/guid'; import { canDetectTerminalWidth } from '../../utils/terminal'; import { @@ -30,7 +31,8 @@ import { ConfirmDialog } from './ConfirmDialog'; import { WidgetRow, getWidgetRowLabel, - getWidgetRowTags + getWidgetRowTags, + styleWidgetRowLabel } from './WidgetRow'; import { handleMoveInputMode, @@ -82,6 +84,9 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB } }, [selectedIndex, widgets, onWidgetHighlight]); + // Theme colours are positional, so show them here too - reordering or merging + // widgets is exactly what changes them + const effectiveThemeColors = getEffectiveThemeColors(widgets, settings); const widgetCatalog = getWidgetCatalog(settings); const widgetCategories = ['All', ...getWidgetCatalogCategories(widgetCatalog)]; @@ -557,7 +562,10 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB = ({ number, label, diff --git a/src/tui/components/__tests__/ItemsEditor.test.tsx b/src/tui/components/__tests__/ItemsEditor.test.tsx index d53b79330..19aef6b8f 100644 --- a/src/tui/components/__tests__/ItemsEditor.test.tsx +++ b/src/tui/components/__tests__/ItemsEditor.test.tsx @@ -8,8 +8,15 @@ import { vi } from 'vitest'; -import { DEFAULT_SETTINGS } from '../../../types/Settings'; +import { + DEFAULT_SETTINGS, + type Settings +} from '../../../types/Settings'; import type { WidgetItem } from '../../../types/Widget'; +import { + applyColors, + getPowerlineTheme +} from '../../../utils/colors'; import { ItemsEditor } from '../ItemsEditor'; class MockTtyStream extends PassThrough { @@ -57,7 +64,17 @@ function flushInk() { }); } -async function renderItemsEditor(widgets: WidgetItem[]) { +const THEMED_SETTINGS = { + ...DEFAULT_SETTINGS, + colorLevel: 3 as const, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + theme: 'nord-aurora' + } +}; + +async function renderItemsEditor(widgets: WidgetItem[], settings: Settings = DEFAULT_SETTINGS) { const stdin = createMockStdin(); const stdout = createMockStdout(); const stderr = createMockStdout(); @@ -67,7 +84,7 @@ async function renderItemsEditor(widgets: WidgetItem[]) { onUpdate: vi.fn(), onBack: vi.fn(), lineNumber: 1, - settings: DEFAULT_SETTINGS + settings }), { stdin, @@ -82,6 +99,8 @@ async function renderItemsEditor(widgets: WidgetItem[]) { await flushInk(); return { + stdin, + getOutput: () => stdout.getOutput(), output: stdout.getOutput(), teardown: () => { instance.unmount(); @@ -93,6 +112,15 @@ async function renderItemsEditor(widgets: WidgetItem[]) { }; } +function nordLevel3() { + const level = getPowerlineTheme('nord-aurora')?.['3']; + if (!level) { + throw new Error('nord-aurora has no truecolor level'); + } + + return level; +} + describe('ItemsEditor', () => { it('names the line and the editing mode in the title', async () => { const { output, teardown } = await renderItemsEditor([{ id: '1', type: 'model' }]); @@ -105,6 +133,78 @@ describe('ItemsEditor', () => { } }); + it('tints a row with the colour the widget renders in', async () => { + const { output, teardown } = await renderItemsEditor([ + { id: '1', type: 'model', color: 'hex:FF0000' } + ], { + ...DEFAULT_SETTINGS, + colorLevel: 3 + }); + + try { + expect(output).toContain(applyColors('Model', 'hex:FF0000', undefined, undefined, 'truecolor', undefined)); + } finally { + teardown(); + } + }); + + it('tints an unpinned row with the theme colour, matching the colour editor', async () => { + const level = nordLevel3(); + const { output, teardown } = await renderItemsEditor([ + { id: '1', type: 'model', color: 'hex:FF0000' } + ], THEMED_SETTINGS); + + try { + expect(output).toContain(applyColors('Model', level.fg[0], level.bg[0], undefined, 'truecolor', undefined)); + expect(output).not.toContain(applyColors('Model', 'hex:FF0000', undefined, undefined, 'truecolor', undefined)); + } finally { + teardown(); + } + }); + + it('leaves a preserve-colors custom command untinted', async () => { + const { output, teardown } = await renderItemsEditor([ + { + id: '1', + type: 'custom-command', + commandPath: 'echo hi', + preserveColors: true, + color: 'hex:FF0000' + } + ], { + ...DEFAULT_SETTINGS, + colorLevel: 3 + }); + + try { + expect(output).not.toContain(applyColors('Custom Command', 'hex:FF0000', undefined, undefined, 'truecolor', undefined)); + } finally { + teardown(); + } + }); + + it('drops the tint in move mode so the dragged row stays visible', async () => { + const level = nordLevel3(); + const { stdin, getOutput, teardown } = await renderItemsEditor([ + { id: '1', type: 'model' }, + { id: '2', type: 'git-branch' } + ], THEMED_SETTINGS); + + try { + const themedRow = applyColors('Model', level.fg[0], level.bg[0], undefined, 'truecolor', undefined); + expect(getOutput()).toContain(themedRow); + + stdin.write('\r'); // Enter starts move mode + await flushInk(); + + const frame = getOutput().split('[MOVE MODE]').at(-1) ?? ''; + expect(getOutput()).toContain('[MOVE MODE]'); + expect(frame).not.toContain(themedRow); + } finally { + teardown(); + } + }); + it('numbers rows and keeps the structure markers', async () => { const { output, teardown } = await renderItemsEditor([ { diff --git a/src/tui/components/__tests__/editor-row-parity.test.tsx b/src/tui/components/__tests__/editor-row-parity.test.tsx new file mode 100644 index 000000000..dc483ecc2 --- /dev/null +++ b/src/tui/components/__tests__/editor-row-parity.test.tsx @@ -0,0 +1,118 @@ +import { render } from 'ink'; +import { PassThrough } from 'node:stream'; +import React from 'react'; +import stripAnsi from 'strip-ansi'; +import { + describe, + expect, + it, + vi +} from 'vitest'; + +import { DEFAULT_SETTINGS } from '../../../types/Settings'; +import type { WidgetItem } from '../../../types/Widget'; +import { ColorMenu } from '../ColorMenu'; +import { ItemsEditor } from '../ItemsEditor'; + +class MockTtyStream extends PassThrough { + isTTY = true; + columns = 110; + rows = 40; + + setRawMode() { + return this; + } + + ref() { + return this; + } + + unref() { + return this; + } +} + +function flushInk() { + return new Promise((resolve) => { + setTimeout(resolve, 25); + }); +} + +const THEMED_SETTINGS = { + ...DEFAULT_SETTINGS, + colorLevel: 3 as const, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + theme: 'nord-aurora' + } +}; + +/** Widgets that appear in both modes, so the two lists are directly comparable. */ +const WIDGETS: WidgetItem[] = [ + { + id: '1', + type: 'model', + color: 'hex:FF0000', + pinColor: true + }, + { + id: '2', + type: 'git-branch', + merge: true + }, + { id: '3', type: 'context-percentage' } +]; + +async function renderRows(element: React.ReactElement): Promise { + const chunks: string[] = []; + const stdin = new MockTtyStream() as unknown as NodeJS.ReadStream; + const stdoutStream = new MockTtyStream(); + stdoutStream.on('data', (chunk: Buffer | string) => chunks.push(chunk.toString())); + const instance = render(element, { + stdin, + stdout: stdoutStream as unknown as NodeJS.WriteStream, + stderr: new MockTtyStream() as unknown as NodeJS.WriteStream, + debug: true, + exitOnCtrlC: false, + patchConsole: false + }); + + try { + await flushInk(); + + // debug mode writes a whole frame per render; take the last complete one + const frame = chunks.filter(chunk => chunk.includes('Edit Line 1')).at(-1) ?? ''; + + return frame + .split('\n') + .filter(line => /^\s*(▶\s+)?\d+\.\s/.test(stripAnsi(line))); + } finally { + instance.unmount(); + instance.cleanup(); + } +} + +describe('editor row parity', () => { + it('renders byte-identical rows in both editor modes', async () => { + const itemRows = await renderRows(React.createElement(ItemsEditor, { + widgets: WIDGETS, + onUpdate: vi.fn(), + onBack: vi.fn(), + lineNumber: 1, + settings: THEMED_SETTINGS, + onTabSwap: vi.fn() + })); + const colorRows = await renderRows(React.createElement(ColorMenu, { + widgets: WIDGETS, + lineIndex: 0, + settings: THEMED_SETTINGS, + onUpdate: vi.fn(), + onBack: vi.fn(), + onTabSwap: vi.fn() + })); + + expect(itemRows).toHaveLength(WIDGETS.length); + expect(colorRows).toEqual(itemRows); + }); +}); From 8df303b2498f1643e11b2b11b85589db7210fd94 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 08:48:17 +1000 Subject: [PATCH 28/52] refactor(color-menu): drop the dead back-row guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list no longer has a "← Back" row, so highlightedItemId can never be 'back' and the nine guards checking for it were unreachable conditions. Co-Authored-By: Claude Opus 4.8 --- src/tui/components/ColorMenu.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index 3232449d5..5fe70b5ce 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -316,19 +316,19 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin } } else if (input === 'h' || input === 'H') { // Enter hex input mode (only in truecolor mode) - if (highlightedItemId && highlightedItemId !== 'back' && settings.colorLevel === 3) { + if (highlightedItemId && 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) { + if (highlightedItemId && settings.colorLevel === 2) { setAnsi256InputMode(true); setAnsi256Input(''); } } else if (input === 'g' || input === 'G') { // Enter gradient selection mode (foreground only, needs a real color palette) - if (highlightedItemId && highlightedItemId !== 'back' && !editingBackground && settings.colorLevel >= 2) { + if (highlightedItemId && !editingBackground && settings.colorLevel >= 2) { setGradientMode(true); setGradientIndex(0); setGradientCustomStep(null); @@ -347,7 +347,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin setEditingBackground(!editingBackground); } } else if (input === 'b' || input === 'B') { - if (highlightedItemId && highlightedItemId !== 'back') { + if (highlightedItemId) { // Toggle bold for the highlighted item const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); if (selectedWidget) { @@ -356,7 +356,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin } } } else if (input === 'd' || input === 'D') { - if (highlightedItemId && highlightedItemId !== 'back') { + if (highlightedItemId) { // Cycle dim for the highlighted item: off -> whole -> parens -> off const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); if (selectedWidget) { @@ -365,7 +365,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin } } } else if (input === 'r' || input === 'R') { - if (highlightedItemId && highlightedItemId !== 'back') { + if (highlightedItemId) { // Reset all styling (color, background, and bold) for the highlighted item const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); if (selectedWidget) { @@ -380,7 +380,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin // Pin/unpin the highlighted widget's current channel so its colour // overrides (or yields back to) the active theme. Pinning surfaces the // widget's existing colour (seeding a default only when it has none). - if (highlightedItemId && highlightedItemId !== 'back') { + if (highlightedItemId) { const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); if (selectedWidget) { const isPinned = editingBackground ? selectedWidget.pinBackgroundColor : selectedWidget.pinColor; @@ -399,7 +399,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin moveHighlight(key.downArrow ? 'down' : 'up'); } else if (key.leftArrow || key.rightArrow) { // Cycle through colors with arrow keys - if (highlightedItemId && highlightedItemId !== 'back') { + if (highlightedItemId) { const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); if (selectedWidget) { const newItems = cycleWidgetColor({ @@ -459,7 +459,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin }); // Get current color for highlighted item - const selectedWidget = highlightedItemId && highlightedItemId !== 'back' + const selectedWidget = highlightedItemId ? colorableWidgets.find(widget => widget.id === highlightedItemId) : null; const storedColor = editingBackground From 8b0da75d6574106e90fae2231f9e3bec4fc083a3 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 08:48:17 +1000 Subject: [PATCH 29/52] docs(usage): document colour pinning and the colour editing mode The feature had no user-facing documentation: pinning a colour over a Powerline theme, the (p) keybind, the automatic pin when editing under a theme, and the fact that colour editing is now reached with Tab from the widget editor were all undocumented. Adds a "Pinning Colors Over a Powerline Theme" section covering the pin model (independent per channel, non-destructive unpin, dormant colours left alone) and notes the Tab route and the shared escape target in the widget editor keybinds. Co-Authored-By: Claude Opus 4.8 --- docs/USAGE.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index 42efb7f41..3761d65d6 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -118,6 +118,8 @@ Configure global formatting preferences that apply to all widgets: ## Widget Styling +Color editing is a mode of the widget editor. Highlight a widget in the line editor and press `⇥` to switch to **Edit Line N [COLORS]**; press `⇥` again to go back to **[WIDGETS]**. Both modes list the same widgets in the same order, and rows are tinted with the color each widget actually renders in. + The color editor can adjust foreground color, background color, bold, dim, and gradients per widget: - Use `←` / `→` to cycle the selected foreground or background color. @@ -126,6 +128,18 @@ The color editor can adjust foreground color, background color, bold, dim, and g - Press `d` to cycle dim styling: off → whole widget → parenthesized text only → off. - Press `r` to reset styling on the selected widget, or `c` to clear styling on every widget in the line. +### Pinning Colors Over a Powerline Theme + +A Powerline theme normally drives every widget's foreground and background, so per-widget colors have no effect. **Pinning** a color lets one widget opt out and keep its own: + +- Press `p` to pin or unpin the channel you are editing (foreground or background). The two channels pin independently, so a widget can keep its own foreground while the theme still supplies its background. +- Editing a color under an active theme pins that channel automatically, so the edit takes effect rather than being silently ignored. +- The current-style row shows `[PINNED]` for a pinned channel, or `[unpinned - theme applies]` when the theme wins. An unpinned channel displays the theme's color as `(theme)`, so the editor shows what actually renders. +- Unpinning keeps the stored color rather than discarding it, so re-pinning brings it back. +- A color set before a theme was enabled stays dormant and unpinned: existing themed configurations render exactly as they did before. + +Pins only matter while a theme is active. With Powerline off, or on the `custom` theme, each widget's own colors already apply. Changing themes prompts to keep or remove existing pins. + ## Gradient Colors A foreground color can be a multi-stop **gradient** instead of a solid. Colors interpolate in OKLab for perceptually even blends. A gradient value takes one of three forms, all prefixed `gradient:`: @@ -191,7 +205,8 @@ Common controls in the line editor: - `r` toggle raw value (supported widgets) - `m` cycle merge mode (`off` → `merge` → `merge no padding`) - `x` exclude the selected widget and the rest of its line from shared Powerline column widths (shown only when Powerline auto-alignment is enabled) -- `Esc` go back +- `⇥` switch to color editing for the selected widget, and back again (see [Widget Styling](#widget-styling)) +- `Esc` go back to the line selector, from either mode Widget picker: - type to search categories and widgets From 89a7389fe2a5eedb48715110d4a61a9ddbb01443 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 09:06:56 +1000 Subject: [PATCH 30/52] refactor(color-menu): move pin state onto the widget rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin state is per widget and per channel, but it was reported on a status line that only ever describes the highlighted widget - so you could not see which widgets carried overrides without visiting each one. That line also restated the channel being edited, which the header already showed in background mode, and paired "(theme)" with "[unpinned - theme applies]", saying the same thing twice. Pins now appear as row tags - (fg pinned), (bg pinned), (fg+bg pinned) - alongside (merged→) and friends, shown only when a theme is active and in both editor modes, since the row renderer is shared. The header names the channel symmetrically ([FOREGROUND] as well as [BACKGROUND]), and the status line is left with just the value it exists to show. isPowerlineThemeActive() replaces ColorMenu's inline theme check, so that condition now lives with the rest of the theme logic. Co-Authored-By: Claude Opus 4.8 --- docs/USAGE.md | 4 +- src/tui/components/ColorMenu.tsx | 24 ++---------- src/tui/components/WidgetRow.tsx | 19 +++++++++- .../components/__tests__/ColorMenu.test.tsx | 21 +++-------- .../components/__tests__/widget-row.test.ts | 37 +++++++++++++++++++ src/utils/effective-theme-colors.ts | 5 +++ 6 files changed, 70 insertions(+), 40 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index 3761d65d6..58b67c645 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -132,9 +132,9 @@ The color editor can adjust foreground color, background color, bold, dim, and g A Powerline theme normally drives every widget's foreground and background, so per-widget colors have no effect. **Pinning** a color lets one widget opt out and keep its own: -- Press `p` to pin or unpin the channel you are editing (foreground or background). The two channels pin independently, so a widget can keep its own foreground while the theme still supplies its background. +- Press `p` to pin or unpin the channel you are editing. The header shows which channel that is — `[FOREGROUND]` or `[BACKGROUND]`, toggled with `f`. The two channels pin independently, so a widget can keep its own foreground while the theme still supplies its background. - Editing a color under an active theme pins that channel automatically, so the edit takes effect rather than being silently ignored. -- The current-style row shows `[PINNED]` for a pinned channel, or `[unpinned - theme applies]` when the theme wins. An unpinned channel displays the theme's color as `(theme)`, so the editor shows what actually renders. +- Pinned widgets are marked on their own row — `(fg pinned)`, `(bg pinned)` or `(fg+bg pinned)` — in both editor modes, so you can see every override at a glance. An unpinned channel shows the theme's color as `(theme)` on the current-style row, so the editor displays what actually renders. - Unpinning keeps the stored color rather than discarding it, so re-pinning brings it back. - A color set before a theme was enabled stays dormant and unpinned: existing themed configurations render exactly as they did before. diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index 5fe70b5ce..cee4cf346 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -538,16 +538,6 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin selectedWidget?.dim === 'parens' ? '[DIM ()]' : null ].filter(indicator => indicator !== null).join(' '); - // Pins only matter under an active theme. When they do, show whether the current - // channel is pinned, and flag that an unpinned value is dormant (the theme renders, - // not the value shown above). - const currentChannelPinned = editingBackground - ? Boolean(selectedWidget?.pinBackgroundColor) - : Boolean(selectedWidget?.pinColor); - const pinIndicator = themeActive - ? (currentChannelPinned ? chalk.green('[PINNED]') : chalk.gray('[unpinned - theme applies]')) - : ''; - // Gradient selection mode takes over the whole view if (gradientMode) { const level = getColorLevelString(settings.colorLevel); @@ -657,7 +647,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin {' '} [COLORS] - {editingBackground && [BACKGROUND]} + {editingBackground ? ' [BACKGROUND]' : ' [FOREGROUND]'} {globalOverrideMessage && ( {' '} @@ -689,10 +679,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin ) : ( <> - ↑↓ to select, ←→ to cycle - {' '} - {editingBackground ? 'background' : 'foreground'} - , (f) to toggle bg/fg, (b)old, (d)im, + ↑↓ to select, ←→ to cycle color, (f) to toggle bg/fg, (b)old, (d)im, {settings.colorLevel === 3 ? ' (h)ex,' : settings.colorLevel === 2 ? ' (a)nsi256,' : ''} {!editingBackground && settings.colorLevel >= 2 ? ' (g)radient,' : ''} {' '} @@ -708,11 +695,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin {selectedWidget ? ( - Current - {' '} - {editingBackground ? 'background' : 'foreground'} - {' '} - ( + Current ( {colorNumber === 'custom' || colorNumber === 'theme' ? colorNumber : `${colorNumber}/${colorList.length}`} @@ -720,7 +703,6 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin {' '} {colorDisplay} {styleIndicators && ` ${styleIndicators}`} - {pinIndicator && ` ${pinIndicator}`} ) : ( diff --git a/src/tui/components/WidgetRow.tsx b/src/tui/components/WidgetRow.tsx index 4f2c44d56..5403b405f 100644 --- a/src/tui/components/WidgetRow.tsx +++ b/src/tui/components/WidgetRow.tsx @@ -11,7 +11,10 @@ import type { WidgetItem } from '../../types/Widget'; import { applyColors } from '../../utils/colors'; -import type { ThemeChannelColors } from '../../utils/effective-theme-colors'; +import { + isPowerlineThemeActive, + type ThemeChannelColors +} from '../../utils/effective-theme-colors'; import { getWidget } from '../../utils/widgets'; export interface WidgetRowProps { @@ -90,6 +93,20 @@ export function getWidgetRowTags(widgets: WidgetItem[], index: number, settings: tags.push('(no-align)'); } + // Pin state is per widget and per channel, so it belongs on the row rather than in a + // status line that only ever describes the highlighted widget. Pins do nothing + // without a theme to override, so the tag only appears when one is active. + if (isPowerlineThemeActive(settings)) { + const pinnedChannels = [ + widget.pinColor ? 'fg' : null, + widget.pinBackgroundColor ? 'bg' : null + ].filter(channel => channel !== null); + + if (pinnedChannels.length > 0) { + tags.push(`(${pinnedChannels.join('+')} pinned)`); + } + } + return tags; } diff --git a/src/tui/components/__tests__/ColorMenu.test.tsx b/src/tui/components/__tests__/ColorMenu.test.tsx index 6097c22ad..3a4c18dac 100644 --- a/src/tui/components/__tests__/ColorMenu.test.tsx +++ b/src/tui/components/__tests__/ColorMenu.test.tsx @@ -115,7 +115,7 @@ describe('ColorMenu', () => { const latestFrame = stdout.getOutput().split('Edit Line 1').at(-1) ?? ''; const currentStyleLine = latestFrame .split('\n') - .find(line => line.includes('Current foreground')) ?? ''; + .find(line => line.includes('Current (')) ?? ''; expect(currentStyleLine).toContain('[BOLD] [DIM ()]'); } finally { @@ -254,7 +254,7 @@ describe('ColorMenu', () => { ); await flushInk(); const frame = stdout.getOutput().split('Edit Line 1').at(-1) ?? ''; - const line = frame.split('\n').find(entry => entry.includes('Current foreground')) ?? ''; + const line = frame.split('\n').find(entry => entry.includes('Current (')) ?? ''; return { line, teardown: () => { @@ -267,24 +267,13 @@ describe('ColorMenu', () => { }; } - it('marks the current channel [PINNED] when it is pinned under a theme', async () => { + it('keeps pin state off the current-style row', async () => { const { line, teardown } = await renderCurrentStyleLine([ { id: '1', type: 'model', color: 'hex:FF0000', pinColor: true } ]); try { - expect(line).toContain('[PINNED]'); - } finally { - teardown(); - } - }); - - it('flags a dormant colour as unpinned under a theme', async () => { - const { line, teardown } = await renderCurrentStyleLine([ - { id: '1', type: 'model', color: 'hex:FF0000' } - ]); - try { - expect(line).toContain('unpinned'); - expect(line).not.toContain('[PINNED]'); + expect(line).not.toContain('PINNED'); + expect(line).not.toContain('unpinned'); } finally { teardown(); } diff --git a/src/tui/components/__tests__/widget-row.test.ts b/src/tui/components/__tests__/widget-row.test.ts index 81a7b4d23..f27b8401c 100644 --- a/src/tui/components/__tests__/widget-row.test.ts +++ b/src/tui/components/__tests__/widget-row.test.ts @@ -75,6 +75,43 @@ describe('getWidgetRowTags', () => { expect(getWidgetRowTags(widgets, 1, DEFAULT_SETTINGS)).toEqual([]); }); + it('names which channels are pinned under an active theme', () => { + const themed = { + ...DEFAULT_SETTINGS, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + theme: 'nord-aurora' + } + }; + const widgets: WidgetItem[] = [ + { id: '1', type: 'model', pinColor: true }, + { id: '2', type: 'model', pinBackgroundColor: true }, + { + id: '3', + type: 'model', + pinColor: true, + pinBackgroundColor: true + }, + { id: '4', type: 'model' } + ]; + + expect(getWidgetRowTags(widgets, 0, themed)).toEqual(['(fg pinned)']); + expect(getWidgetRowTags(widgets, 1, themed)).toEqual(['(bg pinned)']); + expect(getWidgetRowTags(widgets, 2, themed)).toEqual(['(fg+bg pinned)']); + expect(getWidgetRowTags(widgets, 3, themed)).toEqual([]); + }); + + it('hides pin tags when no theme is driving the colors', () => { + const widgets: WidgetItem[] = [{ + id: '1', + type: 'model', + pinColor: true + }]; + + expect(getWidgetRowTags(widgets, 0, DEFAULT_SETTINGS)).toEqual([]); + }); + it('drops the auto-align tag for a widget merged into the previous one', () => { const widgets: WidgetItem[] = [ { id: '1', type: 'model', merge: true }, diff --git a/src/utils/effective-theme-colors.ts b/src/utils/effective-theme-colors.ts index f372974f1..c0129f976 100644 --- a/src/utils/effective-theme-colors.ts +++ b/src/utils/effective-theme-colors.ts @@ -38,6 +38,11 @@ export function getActiveThemeColors(settings: Settings): { fg: string[]; bg: st return theme[colorLevelKey]; } +/** Is a powerline theme currently driving widget colors? Pins only matter when it is. */ +export function isPowerlineThemeActive(settings: Settings): boolean { + return getActiveThemeColors(settings) !== undefined; +} + /** Does the theme leave this widget's foreground alone? */ export function keepsOwnForeground(widget: WidgetItem): boolean { return Boolean(widget.pinColor) || (widget.type === 'custom-command' && Boolean(widget.preserveColors)); From c0583feceabedefb67f403d5e71a66c4d619564e Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 09:22:11 +1000 Subject: [PATCH 31/52] feat(color-menu): require an explicit pin before editing a themed colour Cycling a colour under a theme used to pin the channel automatically. That made a stray arrow key destructive: it pinned AND overwrote the widget's stored colour, and because unpinning is non-destructive by design, the original value was gone with no way back - exactly the dormant colour the no-appearance-change guarantee promises to leave alone. The colour keys sit one row from the navigation keys. Under a theme, the colour keys (arrows, hex, ansi256, gradient) now do nothing until the channel is pinned, and the current-style row says "- theme applies, press (p) to override" rather than leaving them looking broken. Bold, dim, reset and clear-all are not theme-driven and stay live, and nothing changes when no theme is active. Pinning now seeds from the theme colour the widget is actually rendering when it has no colour of its own, instead of the widget's default. That was the behaviour promised on the PR, it makes taking control leave the appearance untouched, and it means edits start from the value on screen rather than jumping to an unrelated one. commitColorEdit goes away with the auto-pin it existed for. Co-Authored-By: Claude Opus 4.8 --- docs/USAGE.md | 3 +- src/tui/components/ColorMenu.tsx | 97 +++++++++++-------- .../components/__tests__/ColorMenu.test.tsx | 90 +++++++++++++++-- 3 files changed, 141 insertions(+), 49 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index 58b67c645..8a4831133 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -133,7 +133,8 @@ The color editor can adjust foreground color, background color, bold, dim, and g A Powerline theme normally drives every widget's foreground and background, so per-widget colors have no effect. **Pinning** a color lets one widget opt out and keep its own: - Press `p` to pin or unpin the channel you are editing. The header shows which channel that is — `[FOREGROUND]` or `[BACKGROUND]`, toggled with `f`. The two channels pin independently, so a widget can keep its own foreground while the theme still supplies its background. -- Editing a color under an active theme pins that channel automatically, so the edit takes effect rather than being silently ignored. +- **A channel must be pinned before its color can be changed.** While the theme owns a channel, the color keys (`←`/`→`, `h`, `a`, `g`) do nothing and the current-style row says so. This keeps a stray arrow key from overwriting a color the theme is currently hiding. Bold, dim, reset and clear-all are not theme-driven and stay available. +- Pinning takes over the color you are looking at: your own color if the widget has one, otherwise the theme color it is currently rendering. Taking control never changes the appearance by itself. - Pinned widgets are marked on their own row — `(fg pinned)`, `(bg pinned)` or `(fg+bg pinned)` — in both editor modes, so you can see every override at a glance. An unpinned channel shows the theme's color as `(theme)` on the current-style row, so the editor displays what actually renders. - Unpinning keeps the stored color rather than discarding it, so re-pinning brings it back. - A color set before a theme was enabled stays dormant and unpinned: existing themed configurations render exactly as they did before. diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index cee4cf346..47e599f20 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -17,7 +17,10 @@ import { getAvailableBackgroundColorsForUI, getAvailableColorsForUI } from '../../utils/colors'; -import { getEffectiveThemeColors } from '../../utils/effective-theme-colors'; +import { + getEffectiveThemeColors, + isPowerlineThemeActive +} from '../../utils/effective-theme-colors'; import { GRADIENT_PRESET_NAMES } from '../../utils/gradient'; import { shouldInsertInput } from '../../utils/input-guards'; import { getWidget } from '../../utils/widgets'; @@ -37,8 +40,7 @@ import { resetWidgetStyling, setWidgetColor, toggleWidgetBold, - unpinWidgetColor, - updateWidgetById + unpinWidgetColor } from './color-menu/mutations'; export interface ColorMenuProps { @@ -129,22 +131,28 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin // Handle keyboard input const hasNoItems = colorableWidgets.length === 0; - const themeActive = settings.powerline.enabled - && !!settings.powerline.theme - && settings.powerline.theme !== 'custom'; - - // Editing a colour under an active theme pins that channel, so the edit - // actually overrides the theme instead of being silently ignored. - const commitColorEdit = (updated: WidgetItem[], widgetId: string, isBackground: boolean) => { - if (!themeActive) { - onUpdate(updated); - return; + const themeActive = isPowerlineThemeActive(settings); + + const isChannelPinned = (widget: WidgetItem): boolean => ( + editingBackground ? Boolean(widget.pinBackgroundColor) : Boolean(widget.pinColor) + ); + + /** + * Under a theme, a channel must be pinned before its colour can be edited. Editing an + * unpinned channel would overwrite a colour the theme is currently hiding - which is + * the very value the no-appearance-change guarantee promises to leave alone - and a + * stray arrow key sits one row from the navigation keys. + */ + const canEditColor = (widget: WidgetItem): boolean => !themeActive || isChannelPinned(widget); + + /** The widget a colour edit applies to, or null when the channel is not editable yet. */ + const getEditableWidget = (): WidgetItem | null => { + if (!highlightedItemId) { + return null; } - onUpdate(updateWidgetById(updated, widgetId, widget => ( - isBackground - ? { ...widget, pinBackgroundColor: true } - : { ...widget, pinColor: true } - ))); + + const widget = colorableWidgets.find(entry => entry.id === highlightedItemId); + return widget && canEditColor(widget) ? widget : null; }; useInput((input, key) => { @@ -175,7 +183,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); if (selectedWidget) { const newItems = setWidgetColor(widgets, selectedWidget.id, hexColor, editingBackground); - commitColorEdit(newItems, selectedWidget.id, editingBackground); + onUpdate(newItems); } setHexInputMode(false); setHexInput(''); @@ -212,7 +220,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin if (selectedWidget) { const newItems = setWidgetColor(widgets, selectedWidget.id, ansiColor, editingBackground); - commitColorEdit(newItems, selectedWidget.id, editingBackground); + onUpdate(newItems); setAnsi256InputMode(false); setAnsi256Input(''); } @@ -245,7 +253,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin const applyGradientValue = (value: string) => { const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); if (selectedWidget) { - commitColorEdit(setWidgetColor(widgets, selectedWidget.id, value, false), selectedWidget.id, false); + onUpdate(setWidgetColor(widgets, selectedWidget.id, value, false)); } exitGradient(); }; @@ -316,19 +324,19 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin } } else if (input === 'h' || input === 'H') { // Enter hex input mode (only in truecolor mode) - if (highlightedItemId && settings.colorLevel === 3) { + if (getEditableWidget() && settings.colorLevel === 3) { setHexInputMode(true); setHexInput(''); } } else if (input === 'a' || input === 'A') { // Enter ansi256 input mode (only in 256 color mode) - if (highlightedItemId && settings.colorLevel === 2) { + if (getEditableWidget() && settings.colorLevel === 2) { setAnsi256InputMode(true); setAnsi256Input(''); } } else if (input === 'g' || input === 'G') { // Enter gradient selection mode (foreground only, needs a real color palette) - if (highlightedItemId && !editingBackground && settings.colorLevel >= 2) { + if (getEditableWidget() && !editingBackground && settings.colorLevel >= 2) { setGradientMode(true); setGradientIndex(0); setGradientCustomStep(null); @@ -379,18 +387,19 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin } else if ((input === 'p' || input === 'P') && themeActive) { // Pin/unpin the highlighted widget's current channel so its colour // overrides (or yields back to) the active theme. Pinning surfaces the - // widget's existing colour (seeding a default only when it has none). + // widget's existing colour, falling back to the theme colour it is showing + // right now, so taking control never changes what is on screen. if (highlightedItemId) { const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); if (selectedWidget) { - const isPinned = editingBackground ? selectedWidget.pinBackgroundColor : selectedWidget.pinColor; - if (isPinned) { + if (isChannelPinned(selectedWidget)) { onUpdate(unpinWidgetColor(widgets, selectedWidget.id, editingBackground)); } else { const widgetImpl = getWidget(selectedWidget.type); + const themeChannels = effectiveThemeColors.get(selectedWidget.id); const seedColor = editingBackground - ? (selectedWidget.backgroundColor ?? bgColors.find(color => color !== '') ?? 'bgBlack') - : (selectedWidget.color ?? widgetImpl?.getDefaultColor() ?? 'white'); + ? (selectedWidget.backgroundColor ?? themeChannels?.bg ?? bgColors.find(color => color !== '') ?? 'bgBlack') + : (selectedWidget.color ?? themeChannels?.fg ?? widgetImpl?.getDefaultColor() ?? 'white'); onUpdate(pinWidgetColor(widgets, selectedWidget.id, editingBackground, seedColor)); } } @@ -399,19 +408,17 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin moveHighlight(key.downArrow ? 'down' : 'up'); } else if (key.leftArrow || key.rightArrow) { // Cycle through colors with arrow keys - if (highlightedItemId) { - 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 - }); - commitColorEdit(newItems, selectedWidget.id, editingBackground); - } + const selectedWidget = getEditableWidget(); + if (selectedWidget) { + const newItems = cycleWidgetColor({ + widgets, + widgetId: selectedWidget.id, + direction: key.rightArrow ? 'right' : 'left', + editingBackground, + colors, + backgroundColors: bgColors + }); + onUpdate(newItems); } } }); @@ -532,6 +539,11 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin colorDisplay = applyColors(displayName, currentColor, undefined, false, level); } } + // The theme owns this channel until the user takes it over, so say how rather than + // leaving the colour keys looking broken. + const overrideHint = selectedWidget && themeActive && !isChannelPinned(selectedWidget) + ? '- theme applies, press (p) to override' + : ''; const styleIndicators = [ selectedWidget?.bold ? '[BOLD]' : null, selectedWidget?.dim === true ? '[DIM]' : null, @@ -703,6 +715,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin {' '} {colorDisplay} {styleIndicators && ` ${styleIndicators}`} + {overrideHint && chalk.gray(` ${overrideHint}`)} ) : ( diff --git a/src/tui/components/__tests__/ColorMenu.test.tsx b/src/tui/components/__tests__/ColorMenu.test.tsx index 3a4c18dac..5248a3758 100644 --- a/src/tui/components/__tests__/ColorMenu.test.tsx +++ b/src/tui/components/__tests__/ColorMenu.test.tsx @@ -127,11 +127,14 @@ describe('ColorMenu', () => { } }); - async function renderThemedColorMenu(onUpdate: Mock<(widgets: WidgetItem[]) => void>, themeActive: boolean) { + async function renderThemedColorMenu( + onUpdate: Mock<(widgets: WidgetItem[]) => void>, + themeActive: boolean, + widgets: WidgetItem[] = [{ id: '1', type: 'model' }] + ) { const stdin = createMockStdin(); const stdout = createMockStdout(); const stderr = createMockStdout(); - const widgets: WidgetItem[] = [{ id: '1', type: 'model' }]; const instance = render( React.createElement(ColorMenu, { widgets, @@ -170,15 +173,63 @@ describe('ColorMenu', () => { return onUpdate.mock.calls.at(-1)?.[0]; } - it('auto-pins a widget colour when cycled under an active theme', async () => { + it('ignores colour cycling until the channel is pinned under a theme', async () => { + const onUpdate = vi.fn<(widgets: WidgetItem[]) => void>(); + const { instance, stdin, stdout, stderr } = await renderThemedColorMenu(onUpdate, true, [ + { id: '1', type: 'model', color: 'hex:FF0000' } + ]); + try { + stdin.write('\x1B[C'); // right arrow would cycle the foreground colour + await flushInk(); + + // the dormant colour must survive a stray arrow key + expect(onUpdate).not.toHaveBeenCalled(); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + + it('cycles the colour once the channel is pinned', async () => { + const onUpdate = vi.fn<(widgets: WidgetItem[]) => void>(); + const { instance, stdin, stdout, stderr } = await renderThemedColorMenu(onUpdate, true, [ + { + id: '1', + type: 'model', + color: 'hex:FF0000', + pinColor: true + } + ]); + try { + stdin.write('\x1B[C'); + await flushInk(); + + const updated = lastUpdated(onUpdate)?.find(widget => widget.id === '1'); + expect(updated?.pinColor).toBe(true); + expect(updated?.color).not.toBe('hex:FF0000'); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + + it('seeds a pin with the theme colour the widget was showing', async () => { + const themeFg = nordLevel3().fg[0]; const onUpdate = vi.fn<(widgets: WidgetItem[]) => void>(); const { instance, stdin, stdout, stderr } = await renderThemedColorMenu(onUpdate, true); try { - stdin.write('\x1B[C'); // right arrow cycles the foreground colour + stdin.write('p'); await flushInk(); - expect(onUpdate).toHaveBeenCalled(); - expect(lastUpdated(onUpdate)?.find(widget => widget.id === '1')?.pinColor).toBe(true); + const updated = lastUpdated(onUpdate)?.find(widget => widget.id === '1'); + expect(updated?.pinColor).toBe(true); + expect(updated?.color).toBe(themeFg); } finally { instance.unmount(); instance.cleanup(); @@ -267,6 +318,33 @@ describe('ColorMenu', () => { }; } + it('tells the user how to take over an unpinned channel', async () => { + const { line, teardown } = await renderCurrentStyleLine([ + { id: '1', type: 'model' } + ]); + try { + expect(stripAnsi(line)).toContain('press (p) to override'); + } finally { + teardown(); + } + }); + + it('drops the override hint once the channel is pinned', async () => { + const { line, teardown } = await renderCurrentStyleLine([ + { + id: '1', + type: 'model', + color: 'hex:FF0000', + pinColor: true + } + ]); + try { + expect(stripAnsi(line)).not.toContain('press (p) to override'); + } finally { + teardown(); + } + }); + it('keeps pin state off the current-style row', async () => { const { line, teardown } = await renderCurrentStyleLine([ { id: '1', type: 'model', color: 'hex:FF0000', pinColor: true } From 69a6cfe07dc908368416369bd24187a349f52c31 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Apr 2026 13:54:21 +1000 Subject: [PATCH 32/52] feat: add condition type system for rules engine Define operator types (numeric, string, boolean, existence), display labels, Condition interface, helper functions for reading condition fields from loosely-typed records, operator classification guards, and value-type-based operator filtering. --- src/types/Condition.ts | 155 +++++++++++++++ src/types/__tests__/Condition.test.ts | 275 ++++++++++++++++++++++++++ 2 files changed, 430 insertions(+) create mode 100644 src/types/Condition.ts create mode 100644 src/types/__tests__/Condition.test.ts diff --git a/src/types/Condition.ts b/src/types/Condition.ts new file mode 100644 index 000000000..3ca58313d --- /dev/null +++ b/src/types/Condition.ts @@ -0,0 +1,155 @@ +// Operator types as string literals for JSON serialization compatibility + +export type NumericOperator + = 'equals' + | 'greaterThan' + | 'greaterThanOrEqual' + | 'lessThan' + | 'lessThanOrEqual'; + +export type StringOperator + = 'equals' + | 'contains' + | 'startsWith' + | 'endsWith'; + +export type BooleanOperator + = 'equals'; + +export type ExistenceOperator + = 'isNull' + | 'isNotNull'; + +export type Operator = NumericOperator | StringOperator | BooleanOperator | ExistenceOperator; + +// Operator arrays for classification and filtering + +export const NUMERIC_OPERATORS: NumericOperator[] = [ + 'equals', + 'greaterThan', + 'greaterThanOrEqual', + 'lessThan', + 'lessThanOrEqual' +]; + +export const STRING_OPERATORS: StringOperator[] = [ + 'equals', + 'contains', + 'startsWith', + 'endsWith' +]; + +export const BOOLEAN_OPERATORS: BooleanOperator[] = [ + 'equals' +]; + +export const EXISTENCE_OPERATORS: ExistenceOperator[] = [ + 'isNull', + 'isNotNull' +]; + +export const ALL_OPERATORS: Operator[] = [ + ...NUMERIC_OPERATORS, + ...STRING_OPERATORS, + ...BOOLEAN_OPERATORS, + ...EXISTENCE_OPERATORS +]; + +// Deduplicated list of unique operator strings (since 'equals' appears in multiple categories) +const UNIQUE_OPERATORS: string[] = [...new Set(ALL_OPERATORS)]; + +// Display labels for each operator (for the condition editor UI) +export const OPERATOR_LABELS: Record = { + // Numeric / String / Boolean (shared) + 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', + // Existence + isNull: 'is null', + isNotNull: 'is not null' +}; + +// Condition interface for typed access +export interface Condition { + widget: string; + operator: Operator; + value: string | number | boolean | null; + not?: boolean; +} + +// Value type used to determine available operators +export type ConditionValueType = 'string' | 'number' | 'boolean' | 'unknown'; + +// --- Helper functions for reading condition fields from loosely-typed records --- + +/** Get the widget reference from a condition record (defaults to 'self') */ +export function getConditionWidget(when: Record): string { + const widget = when.widget; + return typeof widget === 'string' ? widget : 'self'; +} + +/** Get the operator from a condition record by scanning for known operator keys */ +export function getConditionOperator(when: Record): Operator | null { + for (const op of UNIQUE_OPERATORS) { + if (op in when) { + return op as Operator; + } + } + return null; +} + +/** Get the value associated with the operator in a condition record */ +export function getConditionValue(when: Record): string | number | boolean | null { + const op = getConditionOperator(when); + if (!op) { + return null; + } + return when[op] as string | number | boolean | null; +} + +/** Get the negation flag from a condition record (defaults to false) */ +export function getConditionNot(when: Record): boolean { + const not = when.not; + return typeof not === 'boolean' ? not : false; +} + +// --- Operator classification type guards --- + +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 isExistenceOperator(op: Operator): op is ExistenceOperator { + return EXISTENCE_OPERATORS.includes(op as ExistenceOperator); +} + +// --- Operator filtering by value type --- + +/** Get available operators for a given value type */ +export function getOperatorsForValueType(valueType: ConditionValueType): Operator[] { + switch (valueType) { + case 'number': + return [...NUMERIC_OPERATORS, ...EXISTENCE_OPERATORS]; + case 'string': + return [...STRING_OPERATORS, ...EXISTENCE_OPERATORS]; + case 'boolean': + return [...BOOLEAN_OPERATORS, ...EXISTENCE_OPERATORS]; + default: + // For unknown/undefined, default to string + existence operators + return [...STRING_OPERATORS, ...EXISTENCE_OPERATORS]; + } +} diff --git a/src/types/__tests__/Condition.test.ts b/src/types/__tests__/Condition.test.ts new file mode 100644 index 000000000..091dffc33 --- /dev/null +++ b/src/types/__tests__/Condition.test.ts @@ -0,0 +1,275 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { Operator } from '../Condition'; +import { + ALL_OPERATORS, + BOOLEAN_OPERATORS, + EXISTENCE_OPERATORS, + NUMERIC_OPERATORS, + OPERATOR_LABELS, + STRING_OPERATORS, + getConditionNot, + getConditionOperator, + getConditionValue, + getConditionWidget, + getOperatorsForValueType, + isBooleanOperator, + isExistenceOperator, + isNumericOperator, + isStringOperator +} from '../Condition'; + +describe('Operator arrays', () => { + it('numeric operators include expected values', () => { + expect(NUMERIC_OPERATORS).toEqual([ + 'equals', + 'greaterThan', + 'greaterThanOrEqual', + 'lessThan', + 'lessThanOrEqual' + ]); + }); + + it('string operators include expected values', () => { + expect(STRING_OPERATORS).toEqual([ + 'equals', + 'contains', + 'startsWith', + 'endsWith' + ]); + }); + + it('boolean operators include expected values', () => { + expect(BOOLEAN_OPERATORS).toEqual(['equals']); + }); + + it('existence operators include expected values', () => { + expect(EXISTENCE_OPERATORS).toEqual(['isNull', 'isNotNull']); + }); + + it('ALL_OPERATORS contains all operator arrays', () => { + for (const op of NUMERIC_OPERATORS) { + expect(ALL_OPERATORS).toContain(op); + } + for (const op of STRING_OPERATORS) { + expect(ALL_OPERATORS).toContain(op); + } + for (const op of BOOLEAN_OPERATORS) { + expect(ALL_OPERATORS).toContain(op); + } + for (const op of EXISTENCE_OPERATORS) { + expect(ALL_OPERATORS).toContain(op); + } + }); +}); + +describe('OPERATOR_LABELS', () => { + it('has a label for every unique operator', () => { + const uniqueOperators = new Set(ALL_OPERATORS); + for (const op of uniqueOperators) { + expect(OPERATOR_LABELS[op]).toBeDefined(); + expect(typeof OPERATOR_LABELS[op]).toBe('string'); + } + }); + + it('returns correct labels for numeric operators', () => { + expect(OPERATOR_LABELS.equals).toBe('equals'); + 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'); + }); + + it('returns correct labels for string operators', () => { + expect(OPERATOR_LABELS.contains).toBe('contains'); + expect(OPERATOR_LABELS.startsWith).toBe('starts with'); + expect(OPERATOR_LABELS.endsWith).toBe('ends with'); + }); + + it('returns correct labels for existence operators', () => { + expect(OPERATOR_LABELS.isNull).toBe('is null'); + expect(OPERATOR_LABELS.isNotNull).toBe('is not null'); + }); +}); + +describe('getConditionWidget', () => { + it('returns widget string from condition record', () => { + expect(getConditionWidget({ widget: 'context-percentage' })).toBe('context-percentage'); + }); + + it('defaults to "self" when widget is missing', () => { + expect(getConditionWidget({})).toBe('self'); + }); + + it('defaults to "self" when widget is not a string', () => { + expect(getConditionWidget({ widget: 42 })).toBe('self'); + expect(getConditionWidget({ widget: true })).toBe('self'); + expect(getConditionWidget({ widget: null })).toBe('self'); + }); +}); + +describe('getConditionOperator', () => { + it('finds numeric operators in condition record', () => { + expect(getConditionOperator({ greaterThan: 80 })).toBe('greaterThan'); + expect(getConditionOperator({ lessThanOrEqual: 50 })).toBe('lessThanOrEqual'); + }); + + it('finds string operators in condition record', () => { + expect(getConditionOperator({ contains: 'main' })).toBe('contains'); + expect(getConditionOperator({ startsWith: 'feat/' })).toBe('startsWith'); + }); + + it('finds boolean operators in condition record', () => { + expect(getConditionOperator({ equals: true })).toBe('equals'); + }); + + it('finds existence operators in condition record', () => { + expect(getConditionOperator({ isNull: true })).toBe('isNull'); + expect(getConditionOperator({ isNotNull: true })).toBe('isNotNull'); + }); + + it('returns null when no operator is found', () => { + expect(getConditionOperator({})).toBeNull(); + expect(getConditionOperator({ widget: 'test', not: true })).toBeNull(); + }); + + it('ignores non-operator keys', () => { + expect(getConditionOperator({ widget: 'test', not: false, foo: 'bar' })).toBeNull(); + }); +}); + +describe('getConditionValue', () => { + it('returns numeric value for numeric operators', () => { + expect(getConditionValue({ greaterThan: 80 })).toBe(80); + }); + + it('returns string value for string operators', () => { + expect(getConditionValue({ contains: 'main' })).toBe('main'); + }); + + it('returns boolean value for boolean operators', () => { + expect(getConditionValue({ equals: true })).toBe(true); + expect(getConditionValue({ equals: false })).toBe(false); + }); + + it('returns value for existence operators', () => { + expect(getConditionValue({ isNull: true })).toBe(true); + }); + + it('returns null when no operator is found', () => { + expect(getConditionValue({})).toBeNull(); + }); +}); + +describe('getConditionNot', () => { + it('returns true when not flag is true', () => { + expect(getConditionNot({ not: true })).toBe(true); + }); + + it('returns false when not flag is false', () => { + expect(getConditionNot({ not: false })).toBe(false); + }); + + it('defaults to false when not flag is missing', () => { + expect(getConditionNot({})).toBe(false); + }); + + it('defaults to false when not flag is not a boolean', () => { + expect(getConditionNot({ not: 'yes' })).toBe(false); + expect(getConditionNot({ not: 1 })).toBe(false); + expect(getConditionNot({ not: null })).toBe(false); + }); +}); + +describe('Operator type guards', () => { + it('isNumericOperator identifies numeric operators', () => { + expect(isNumericOperator('greaterThan')).toBe(true); + expect(isNumericOperator('lessThan')).toBe(true); + expect(isNumericOperator('greaterThanOrEqual')).toBe(true); + expect(isNumericOperator('lessThanOrEqual')).toBe(true); + expect(isNumericOperator('equals')).toBe(true); + }); + + it('isNumericOperator rejects non-numeric operators', () => { + expect(isNumericOperator('contains' as Operator)).toBe(false); + expect(isNumericOperator('isNull' as Operator)).toBe(false); + }); + + it('isStringOperator identifies string operators', () => { + expect(isStringOperator('contains')).toBe(true); + expect(isStringOperator('startsWith')).toBe(true); + expect(isStringOperator('endsWith')).toBe(true); + expect(isStringOperator('equals')).toBe(true); + }); + + it('isStringOperator rejects non-string operators', () => { + expect(isStringOperator('greaterThan' as Operator)).toBe(false); + expect(isStringOperator('isNotNull' as Operator)).toBe(false); + }); + + it('isBooleanOperator identifies boolean operators', () => { + expect(isBooleanOperator('equals')).toBe(true); + }); + + it('isBooleanOperator rejects non-boolean operators', () => { + expect(isBooleanOperator('contains' as Operator)).toBe(false); + expect(isBooleanOperator('isNull' as Operator)).toBe(false); + }); + + it('isExistenceOperator identifies existence operators', () => { + expect(isExistenceOperator('isNull')).toBe(true); + expect(isExistenceOperator('isNotNull')).toBe(true); + }); + + it('isExistenceOperator rejects non-existence operators', () => { + expect(isExistenceOperator('equals' as Operator)).toBe(false); + expect(isExistenceOperator('contains' as Operator)).toBe(false); + }); + + it('equals is classified as numeric, string, and boolean', () => { + const op: Operator = 'equals'; + expect(isNumericOperator(op)).toBe(true); + expect(isStringOperator(op)).toBe(true); + expect(isBooleanOperator(op)).toBe(true); + expect(isExistenceOperator(op)).toBe(false); + }); +}); + +describe('getOperatorsForValueType', () => { + it('returns numeric + existence operators for number type', () => { + const ops = getOperatorsForValueType('number'); + expect(ops).toEqual([...NUMERIC_OPERATORS, ...EXISTENCE_OPERATORS]); + }); + + it('returns string + existence operators for string type', () => { + const ops = getOperatorsForValueType('string'); + expect(ops).toEqual([...STRING_OPERATORS, ...EXISTENCE_OPERATORS]); + }); + + it('returns boolean + existence operators for boolean type', () => { + const ops = getOperatorsForValueType('boolean'); + expect(ops).toEqual([...BOOLEAN_OPERATORS, ...EXISTENCE_OPERATORS]); + }); + + it('returns string + existence operators for unknown type', () => { + const ops = getOperatorsForValueType('unknown'); + expect(ops).toEqual([...STRING_OPERATORS, ...EXISTENCE_OPERATORS]); + }); + + it('number operators include equals', () => { + const ops = getOperatorsForValueType('number'); + expect(ops).toContain('equals'); + }); + + it('all value types include existence operators', () => { + for (const valueType of ['string', 'number', 'boolean', 'unknown'] as const) { + const ops = getOperatorsForValueType(valueType); + expect(ops).toContain('isNull'); + expect(ops).toContain('isNotNull'); + } + }); +}); From a15f5a7a90aeeeb4401aa5b0f3018608f6576f3c Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Apr 2026 13:58:44 +1000 Subject: [PATCH 33/52] feat: add rules field to WidgetItem schema Add RuleApplySchema (appearance-only properties), RuleSchema (when condition + apply overrides + optional stop flag), and an optional rules array to WidgetItemSchema. Existing settings files without rules continue to load without migration. --- src/types/Widget.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/types/Widget.ts b/src/types/Widget.ts index 7d98e3485..9dbab2f0b 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -3,6 +3,23 @@ import { z } from 'zod'; import type { RenderContext } from './RenderContext'; import type { Settings } from './Settings'; +// Rule apply schema - restricted to appearance properties only +export const RuleApplySchema = z.object({ + color: z.string().optional(), + backgroundColor: z.string().optional(), + bold: z.boolean().optional(), + rawValue: z.boolean().optional(), + customText: z.string().optional(), + hide: z.boolean().optional() +}); + +// Rule schema - condition + appearance overrides +export const RuleSchema = z.object({ + when: z.record(z.string(), z.any()), + apply: RuleApplySchema, + stop: z.boolean().optional() +}); + // Widget item schema - accepts any string type for forward compatibility export const WidgetItemSchema = z.object({ id: z.string(), @@ -24,10 +41,13 @@ export const WidgetItemSchema = z.object({ merge: z.union([z.boolean(), z.literal('no-padding')]).optional(), hide: z.boolean().optional(), excludeFromAutoAlign: z.boolean().optional(), - metadata: z.record(z.string(), z.string()).optional() + metadata: z.record(z.string(), z.string()).optional(), + rules: z.array(RuleSchema).optional() }); // Inferred types from Zod schemas +export type RuleApply = z.infer; +export type Rule = z.infer; export type WidgetItem = z.infer; export type WidgetItemType = string; // Allow any string for forward compatibility From 57ae5d86f94b8877380b7dd94adb737de547c9d3 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Apr 2026 14:03:37 +1000 Subject: [PATCH 34/52] feat: add pure value parser functions for widget output conversion Implement 6 parser functions (parseTokenCount, parsePercentage, parseCurrency, parseSpeed, parseIntSafe, parseBooleanString) that convert raw widget render strings into typed values. All functions are pure, return null for unparseable input, and never throw. --- src/utils/__tests__/value-parsers.test.ts | 162 ++++++++++++++++++++ src/utils/value-parsers.ts | 173 ++++++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 src/utils/__tests__/value-parsers.test.ts create mode 100644 src/utils/value-parsers.ts diff --git a/src/utils/__tests__/value-parsers.test.ts b/src/utils/__tests__/value-parsers.test.ts new file mode 100644 index 000000000..188fd7eb1 --- /dev/null +++ b/src/utils/__tests__/value-parsers.test.ts @@ -0,0 +1,162 @@ +import { + describe, + expect, + test +} from 'vitest'; + +import { + parseBooleanString, + parseCurrency, + parseIntSafe, + parsePercentage, + parseSpeed, + parseTokenCount +} from '../value-parsers'; + +describe('parsePercentage', () => { + test('parses valid percentage strings', () => { + expect(parsePercentage('9.3%')).toBe(9.3); + expect(parsePercentage('100%')).toBe(100); + expect(parsePercentage('0%')).toBe(0); + expect(parsePercentage('0.5%')).toBe(0.5); + }); + + test('parses numbers without percent sign', () => { + expect(parsePercentage('42.5')).toBe(42.5); + expect(parsePercentage('100')).toBe(100); + }); + + test('returns null for invalid inputs', () => { + expect(parsePercentage('')).toBe(null); + expect(parsePercentage('abc')).toBe(null); + expect(parsePercentage('12.3.4%')).toBe(null); + expect(parsePercentage('%')).toBe(null); + }); +}); + +describe('parseCurrency', () => { + test('parses currency with dollar sign', () => { + expect(parseCurrency('$2.45')).toBe(2.45); + expect(parseCurrency('$100.00')).toBe(100); + expect(parseCurrency('$0.01')).toBe(0.01); + }); + + test('parses currency with various symbols', () => { + expect(parseCurrency('€10.00')).toBe(10); + expect(parseCurrency('£99.99')).toBe(99.99); + expect(parseCurrency('¥1000')).toBe(1000); + }); + + test('parses numbers without currency symbol', () => { + expect(parseCurrency('42.50')).toBe(42.5); + expect(parseCurrency('100')).toBe(100); + }); + + test('returns null for invalid inputs', () => { + expect(parseCurrency('')).toBe(null); + expect(parseCurrency('abc')).toBe(null); + expect(parseCurrency('$')).toBe(null); + expect(parseCurrency('€')).toBe(null); + }); +}); + +describe('parseTokenCount', () => { + test('parses numbers with K suffix', () => { + expect(parseTokenCount('15.2k')).toBe(15200); + expect(parseTokenCount('15.2K')).toBe(15200); + expect(parseTokenCount('1k')).toBe(1000); + expect(parseTokenCount('0.5K')).toBe(500); + }); + + test('parses numbers with M suffix', () => { + expect(parseTokenCount('1.5M')).toBe(1500000); + expect(parseTokenCount('1.5m')).toBe(1500000); + expect(parseTokenCount('2M')).toBe(2000000); + expect(parseTokenCount('0.1m')).toBe(100000); + }); + + test('parses numbers with B suffix', () => { + expect(parseTokenCount('1.5B')).toBe(1500000000); + expect(parseTokenCount('1.5b')).toBe(1500000000); + expect(parseTokenCount('1B')).toBe(1000000000); + }); + + test('parses plain numbers without suffix', () => { + expect(parseTokenCount('42')).toBe(42); + expect(parseTokenCount('1000')).toBe(1000); + expect(parseTokenCount('0')).toBe(0); + }); + + test('returns null for invalid inputs', () => { + expect(parseTokenCount('')).toBe(null); + expect(parseTokenCount('abc')).toBe(null); + expect(parseTokenCount('k')).toBe(null); + expect(parseTokenCount('1.2.3k')).toBe(null); + }); +}); + +describe('parseSpeed', () => { + test('parses valid speed strings', () => { + expect(parseSpeed('85.2 t/s')).toBe(85.2); + expect(parseSpeed('100 t/s')).toBe(100); + expect(parseSpeed('0 t/s')).toBe(0); + expect(parseSpeed('0.5 t/s')).toBe(0.5); + }); + + test('parses numbers without suffix', () => { + expect(parseSpeed('42.5')).toBe(42.5); + expect(parseSpeed('100')).toBe(100); + }); + + test('returns null for invalid inputs', () => { + expect(parseSpeed('')).toBe(null); + expect(parseSpeed('abc')).toBe(null); + expect(parseSpeed('t/s')).toBe(null); + expect(parseSpeed('12.3.4 t/s')).toBe(null); + }); +}); + +describe('parseBooleanString', () => { + test('parses true strings', () => { + expect(parseBooleanString('true')).toBe(true); + expect(parseBooleanString('True')).toBe(true); + expect(parseBooleanString('TRUE')).toBe(true); + }); + + test('parses false strings', () => { + expect(parseBooleanString('false')).toBe(false); + expect(parseBooleanString('False')).toBe(false); + expect(parseBooleanString('FALSE')).toBe(false); + }); + + test('returns null for non-boolean strings', () => { + expect(parseBooleanString('')).toBe(null); + expect(parseBooleanString('yes')).toBe(null); + expect(parseBooleanString('no')).toBe(null); + expect(parseBooleanString('1')).toBe(null); + expect(parseBooleanString('0')).toBe(null); + expect(parseBooleanString('abc')).toBe(null); + }); +}); + +describe('parseIntSafe', () => { + test('parses valid integer strings', () => { + expect(parseIntSafe('42')).toBe(42); + expect(parseIntSafe('0')).toBe(0); + expect(parseIntSafe('-10')).toBe(-10); + expect(parseIntSafe('1000')).toBe(1000); + }); + + test('returns null for floating-point numbers', () => { + expect(parseIntSafe('42.5')).toBe(null); + expect(parseIntSafe('0.1')).toBe(null); + expect(parseIntSafe('-10.5')).toBe(null); + }); + + test('returns null for invalid inputs', () => { + expect(parseIntSafe('')).toBe(null); + expect(parseIntSafe('abc')).toBe(null); + expect(parseIntSafe('12abc')).toBe(null); + expect(parseIntSafe('12.3.4')).toBe(null); + }); +}); diff --git a/src/utils/value-parsers.ts b/src/utils/value-parsers.ts new file mode 100644 index 000000000..978688cd4 --- /dev/null +++ b/src/utils/value-parsers.ts @@ -0,0 +1,173 @@ +/** + * Parse helpers for converting widget raw output to typed values. + * These are pure functions with no widget or context dependencies. + */ + +/** + * Parse a percentage string (e.g., '9.3%' → 9.3) + * Strips trailing '%' and returns the number. + */ +export function parsePercentage(value: string): number | null { + if (!value) { + return null; + } + + const trimmed = value.trim(); + const withoutPercent = trimmed.endsWith('%') ? trimmed.slice(0, -1) : trimmed; + + // Validate that it's a valid number format (optional minus, digits, optional single decimal point and more digits) + if (!/^-?\d+(\.\d+)?$/.test(withoutPercent)) { + return null; + } + + const parsed = parseFloat(withoutPercent); + + if (isNaN(parsed)) { + return null; + } + + return parsed; +} + +/** + * Parse a currency string (e.g., '$2.45' → 2.45, '€10.00' → 10.0) + * Strips leading non-digit characters (any currency symbol) and parses the rest. + */ +export function parseCurrency(value: string): number | null { + if (!value) { + return null; + } + + const trimmed = value.trim(); + // Strip leading non-digit characters (including currency symbols) + const withoutSymbol = trimmed.replace(/^[^\d.-]+/, ''); + + // Validate that it's a valid number format + if (!/^-?\d+(\.\d+)?$/.test(withoutSymbol)) { + return null; + } + + const parsed = parseFloat(withoutSymbol); + + if (isNaN(parsed)) { + return null; + } + + return parsed; +} + +/** + * Parse a token count string with K/M/B suffixes (case-insensitive) + * Examples: '15.2k' → 15200, '1.5M' → 1500000, '42' → 42 + */ +export function parseTokenCount(value: string): number | null { + if (!value) { + return null; + } + + const trimmed = value.trim(); + const match = /^(\d+(?:\.\d+)?)([kmb])?$/i.exec(trimmed); + + if (!match?.[1]) { + return null; + } + + const num = parseFloat(match[1]); + if (isNaN(num)) { + return null; + } + + const suffixRaw = match[2]; + if (!suffixRaw) { + return num; + } + + const suffix = suffixRaw.toLowerCase(); + const multipliers: Record = { + k: 1000, + m: 1000000, + b: 1000000000 + }; + + const multiplier = multipliers[suffix]; + if (multiplier === undefined) { + return null; + } + + return num * multiplier; +} + +/** + * Parse a speed string (e.g., '85.2 t/s' → 85.2) + * Strips trailing ' t/s' suffix and parses the number. + */ +export function parseSpeed(value: string): number | null { + if (!value) { + return null; + } + + const trimmed = value.trim(); + const withoutSuffix = trimmed.replace(/\s*t\/s$/i, ''); + + // Validate that it's a valid number format + if (!/^-?\d+(\.\d+)?$/.test(withoutSuffix)) { + return null; + } + + const parsed = parseFloat(withoutSuffix); + + if (isNaN(parsed)) { + return null; + } + + return parsed; +} + +/** + * Parse boolean strings ('true'/'false', case-insensitive) + * Returns null for anything else. + */ +export function parseBooleanString(value: string): boolean | null { + if (!value) { + return null; + } + + const trimmed = value.trim().toLowerCase(); + + if (trimmed === 'true') { + return true; + } + + if (trimmed === 'false') { + return false; + } + + return null; +} + +/** + * Parse an integer string safely. + * Returns null for non-integer or floating-point input. + */ +export function parseIntSafe(value: string): number | null { + if (!value) { + return null; + } + + const trimmed = value.trim(); + + // Check if the string contains a decimal point (floating-point) + if (trimmed.includes('.')) { + return null; + } + + const parsed = parseInt(trimmed, 10); + + // Validate that the parsed value is a valid integer + // and that the original string represents the same number + if (isNaN(parsed) || trimmed !== String(parsed)) { + return null; + } + + return parsed; +} From e33afea0049f52ddee80f6edf1861aac3fcada06 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Apr 2026 14:11:43 +1000 Subject: [PATCH 35/52] feat: add widget value extraction for rules engine condition evaluation Replace getNumericValue with getValueType/getValue on Widget interface. Add getWidgetValue dispatch function and getValueFromRender helper for typed value extraction that the rules engine will use to evaluate conditions. --- src/types/Widget.ts | 3 +- src/utils/__tests__/widget-values.test.ts | 274 ++++++++++++++++++++++ src/utils/widget-values.ts | 64 +++++ 3 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 src/utils/__tests__/widget-values.test.ts create mode 100644 src/utils/widget-values.ts diff --git a/src/types/Widget.ts b/src/types/Widget.ts index 9dbab2f0b..15dea5880 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -68,7 +68,8 @@ export interface Widget { supportsRawValue(): boolean; supportsColors(item: WidgetItem): boolean; handleEditorAction?(action: string, item: WidgetItem): WidgetItem | null; - getNumericValue?(context: RenderContext, item: WidgetItem): number | null; + getValueType?(): 'string' | 'number' | 'boolean'; + getValue?(context: RenderContext, item: WidgetItem): number | string | boolean | null; } export interface WidgetEditorProps { diff --git a/src/utils/__tests__/widget-values.test.ts b/src/utils/__tests__/widget-values.test.ts new file mode 100644 index 000000000..416c5352f --- /dev/null +++ b/src/utils/__tests__/widget-values.test.ts @@ -0,0 +1,274 @@ +import { + beforeEach, + describe, + expect, + test, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import type { + Widget, + WidgetItem +} from '../../types/Widget'; +import { clearGitCache } from '../git'; +import { + getValueFromRender, + getWidgetValue +} from '../widget-values'; +import * as widgetsModule from '../widgets'; + +function createMockWidget(overrides: Partial = {}): Widget { + return { + getDefaultColor: () => 'white', + getDescription: () => 'test', + getDisplayName: () => 'Test', + getCategory: () => 'Test', + getEditorDisplay: () => ({ displayText: 'test' }), + render: () => 'rendered', + supportsRawValue: () => true, + supportsColors: () => true, + ...overrides + }; +} + +describe('Widget Values', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + clearGitCache(); + }); + + describe('getWidgetValue dispatch: uses getValue when available', () => { + test('calls getValue and returns its result when widget implements it', () => { + const getValueSpy = vi.fn().mockReturnValue(42); + const mockWidget = createMockWidget({ getValue: getValueSpy }); + + vi.spyOn(widgetsModule, 'getWidget').mockReturnValue(mockWidget); + + const context: RenderContext = {}; + const item: WidgetItem = { id: 'test', type: 'test-widget' }; + const value = getWidgetValue('test-widget', context, item); + + expect(getValueSpy).toHaveBeenCalledWith(context, item); + expect(value).toBe(42); + expect(typeof value).toBe('number'); + }); + + test('returns boolean from getValue when widget returns boolean', () => { + const mockWidget = createMockWidget({ getValue: () => true }); + + vi.spyOn(widgetsModule, 'getWidget').mockReturnValue(mockWidget); + + const value = getWidgetValue('test-widget', {}, { id: 'test', type: 'test-widget' }); + expect(value).toBe(true); + expect(typeof value).toBe('boolean'); + }); + + test('returns null from getValue when widget returns null', () => { + const mockWidget = createMockWidget({ getValue: () => null }); + + vi.spyOn(widgetsModule, 'getWidget').mockReturnValue(mockWidget); + + const value = getWidgetValue('test-widget', {}, { id: 'test', type: 'test-widget' }); + expect(value).toBeNull(); + }); + + test('does not call render when getValue is available', () => { + const renderSpy = vi.fn().mockReturnValue('should-not-be-used'); + const mockWidget = createMockWidget({ + getValue: () => 99, + render: renderSpy + }); + + vi.spyOn(widgetsModule, 'getWidget').mockReturnValue(mockWidget); + + getWidgetValue('test-widget', {}, { id: 'test', type: 'test-widget' }); + expect(renderSpy).not.toHaveBeenCalled(); + }); + }); + + describe('getWidgetValue fallback: renders raw when getValue not implemented', () => { + test('returns string from model via raw render', () => { + const context: RenderContext = { data: { model: { id: 'claude-opus-4-6' } } }; + + const value = getWidgetValue('model', context, { id: 'test', type: 'model' }); + expect(value).toBe('claude-opus-4-6'); + expect(typeof value).toBe('string'); + }); + + test('uses rawValue mode when widget supports it', () => { + const renderSpy = vi.fn().mockReturnValue('raw-output'); + const mockWidget = createMockWidget({ + render: renderSpy, + supportsRawValue: () => true + }); + + vi.spyOn(widgetsModule, 'getWidget').mockReturnValue(mockWidget); + + const item: WidgetItem = { id: 'test', type: 'test-widget' }; + const value = getWidgetValue('test-widget', {}, item); + + expect(renderSpy).toHaveBeenCalledWith( + { ...item, rawValue: true }, + {}, + DEFAULT_SETTINGS + ); + expect(value).toBe('raw-output'); + }); + + test('renders normally when widget does not support rawValue', () => { + const renderSpy = vi.fn().mockReturnValue('normal-output'); + const mockWidget = createMockWidget({ + render: renderSpy, + supportsRawValue: () => false + }); + + vi.spyOn(widgetsModule, 'getWidget').mockReturnValue(mockWidget); + + const item: WidgetItem = { id: 'test', type: 'test-widget' }; + const value = getWidgetValue('test-widget', {}, item); + + expect(renderSpy).toHaveBeenCalledWith( + item, + {}, + DEFAULT_SETTINGS + ); + expect(value).toBe('normal-output'); + }); + + test('returns null for empty rendered output', () => { + const value = getWidgetValue('custom-text', {}, { id: 'test', type: 'custom-text' }); + expect(value).toBeNull(); + }); + + test('returns null when render returns null', () => { + const mockWidget = createMockWidget({ render: () => null }); + + vi.spyOn(widgetsModule, 'getWidget').mockReturnValue(mockWidget); + + const value = getWidgetValue('test-widget', {}, { id: 'test', type: 'test-widget' }); + expect(value).toBeNull(); + }); + + test('trims whitespace from rendered output', () => { + const mockWidget = createMockWidget({ render: () => ' some value ' }); + + vi.spyOn(widgetsModule, 'getWidget').mockReturnValue(mockWidget); + + const value = getWidgetValue('test-widget', {}, { id: 'test', type: 'test-widget' }); + expect(value).toBe('some value'); + }); + }); + + describe('getWidgetValue: returns null for unknown widget types', () => { + test('returns null for unknown widget type', () => { + const value = getWidgetValue('unknown-widget', {}, { id: 'test', type: 'unknown-widget' }); + expect(value).toBeNull(); + }); + + test('returns null for empty string widget type', () => { + const value = getWidgetValue('', {}, { id: 'test', type: '' }); + expect(value).toBeNull(); + }); + }); + + describe('getValueFromRender: applies parser correctly', () => { + test('parses rendered output as number', () => { + const mockWidget = createMockWidget({ render: () => '42' }); + + const result = getValueFromRender( + mockWidget, + {}, + { id: 'test', type: 'test' }, + (val) => { + const n = Number(val); + return isNaN(n) ? null : n; + } + ); + + expect(result).toBe(42); + }); + + test('parses rendered output as boolean', () => { + const mockWidget = createMockWidget({ render: () => 'true' }); + + const result = getValueFromRender( + mockWidget, + {}, + { id: 'test', type: 'test' }, + val => val === 'true' + ); + + expect(result).toBe(true); + }); + + test('returns null when render returns null', () => { + const mockWidget = createMockWidget({ render: () => null }); + + const result = getValueFromRender( + mockWidget, + {}, + { id: 'test', type: 'test' }, + (val) => { + const n = Number(val); + return isNaN(n) ? null : n; + } + ); + + expect(result).toBeNull(); + }); + + test('returns null when render returns empty string', () => { + const mockWidget = createMockWidget({ render: () => ' ' }); + + const result = getValueFromRender( + mockWidget, + {}, + { id: 'test', type: 'test' }, + (val) => { + const n = Number(val); + return isNaN(n) ? null : n; + } + ); + + expect(result).toBeNull(); + }); + + test('returns null when parser returns null for non-numeric string', () => { + const mockWidget = createMockWidget({ render: () => 'not-a-number' }); + + const result = getValueFromRender( + mockWidget, + {}, + { id: 'test', type: 'test' }, + (val) => { + const n = Number(val); + return isNaN(n) ? null : n; + } + ); + + expect(result).toBeNull(); + }); + + test('uses DEFAULT_SETTINGS when rendering', () => { + const renderSpy = vi.fn().mockReturnValue('100'); + const mockWidget = createMockWidget({ render: renderSpy }); + + const item: WidgetItem = { id: 'test', type: 'test' }; + getValueFromRender( + mockWidget, + {}, + item, + val => Number(val) + ); + + expect(renderSpy).toHaveBeenCalledWith( + { ...item, rawValue: true }, + {}, + DEFAULT_SETTINGS + ); + }); + }); +}); diff --git a/src/utils/widget-values.ts b/src/utils/widget-values.ts new file mode 100644 index 000000000..bed2743e8 --- /dev/null +++ b/src/utils/widget-values.ts @@ -0,0 +1,64 @@ +import type { RenderContext } from '../types/RenderContext'; +import { DEFAULT_SETTINGS } from '../types/Settings'; +import type { + Widget, + WidgetItem +} from '../types/Widget'; + +import { getWidget } from './widgets'; + +/** + * Shared helper that renders a widget with rawValue:true using DEFAULT_SETTINGS, + * then applies the provided parser to the output. + * + * Returns null if the widget renders null or empty string. + */ +export function getValueFromRender( + widget: Widget, + context: RenderContext, + item: WidgetItem, + parser: (value: string) => number | boolean | null +): number | boolean | null { + const renderItem = { ...item, rawValue: true }; + const rendered = widget.render(renderItem, context, DEFAULT_SETTINGS); + + if (rendered === null || rendered.trim() === '') { + return null; + } + + return parser(rendered.trim()); +} + +/** + * Extract a widget's value using typed dispatch. + * + * Strategy: + * 1. If the widget implements getValue(), call it and return the typed result + * 2. Otherwise, fall back to rendering in raw mode (if supported) and returning + * the string. This handles widgets like GitBranch, Model, etc. that don't + * declare a value type but still have useful string values for rules. + * + * This is the main entry point for rule condition evaluation. + */ +export function getWidgetValue( + widgetType: string, + context: RenderContext, + item: WidgetItem +): number | string | boolean | null { + const widget = getWidget(widgetType); + if (!widget) + return null; + + // Typed dispatch: if widget declares getValue(), use it directly + if (widget.getValue) { + return widget.getValue(context, item); + } + + // Fallback: render the widget and return the string output + // Use raw value mode if supported, otherwise render normally + const renderItem = widget.supportsRawValue() ? { ...item, rawValue: true } : item; + const rendered = widget.render(renderItem, context, DEFAULT_SETTINGS); + + // Return rendered text (null if empty) + return rendered && rendered.trim() !== '' ? rendered.trim() : null; +} From 67574ee98bd5a8d627703d6787029373143526c5 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Apr 2026 14:21:18 +1000 Subject: [PATCH 36/52] feat: migrate git widgets from getNumericValue to getValue/getValueType Rename getNumericValue() to getValue() and add getValueType() on all 8 git widgets to conform to the new Widget interface for rules engine condition evaluation. GitAheadBehind retains its total divergence (ahead + behind) semantic as a single meaningful number. --- src/widgets/GitAheadBehind.ts | 4 +++- src/widgets/GitConflicts.ts | 4 +++- src/widgets/GitStaged.ts | 4 +++- src/widgets/GitStagedFiles.ts | 4 +++- src/widgets/GitUnstaged.ts | 4 +++- src/widgets/GitUnstagedFiles.ts | 4 +++- src/widgets/GitUntracked.ts | 4 +++- src/widgets/GitUntrackedFiles.ts | 4 +++- 8 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/widgets/GitAheadBehind.ts b/src/widgets/GitAheadBehind.ts index b573acf07..5ba14029b 100644 --- a/src/widgets/GitAheadBehind.ts +++ b/src/widgets/GitAheadBehind.ts @@ -100,7 +100,9 @@ export class GitAheadBehindWidget implements Widget { return renderSymbolSlotsEditor(props, [AHEAD_SLOT, BEHIND_SLOT]); } - getNumericValue(context: RenderContext, _item: WidgetItem): number | null { + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, _item: WidgetItem): number | string | boolean | null { if (!isInsideGitWorkTree(context)) return null; const result = getGitAheadBehind(context); diff --git a/src/widgets/GitConflicts.ts b/src/widgets/GitConflicts.ts index 74d1121a9..080a29ceb 100644 --- a/src/widgets/GitConflicts.ts +++ b/src/widgets/GitConflicts.ts @@ -83,7 +83,9 @@ export class GitConflictsWidget implements Widget { return renderSymbolOverrideEditor(props, DEFAULT_SYMBOL); } - getNumericValue(context: RenderContext, _item: WidgetItem): number | null { + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, _item: WidgetItem): number | string | boolean | null { if (!isInsideGitWorkTree(context)) return null; return getGitConflictCount(context); diff --git a/src/widgets/GitStaged.ts b/src/widgets/GitStaged.ts index c9c91987e..62ff42dc0 100644 --- a/src/widgets/GitStaged.ts +++ b/src/widgets/GitStaged.ts @@ -80,7 +80,9 @@ export class GitStagedWidget implements Widget { return renderSymbolOverrideEditor(props, DEFAULT_SYMBOL); } - getNumericValue(context: RenderContext, _item: WidgetItem): number | null { + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, _item: WidgetItem): number | string | boolean | null { if (!isInsideGitWorkTree(context)) return null; const status = getGitStatus(context); diff --git a/src/widgets/GitStagedFiles.ts b/src/widgets/GitStagedFiles.ts index 71e53ce21..977698af8 100644 --- a/src/widgets/GitStagedFiles.ts +++ b/src/widgets/GitStagedFiles.ts @@ -53,7 +53,9 @@ export class GitStagedFilesWidget implements Widget { return getHideNoGitKeybinds(); } - getNumericValue(context: RenderContext, _item: WidgetItem): number | null { + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, _item: WidgetItem): number | string | boolean | null { if (!isInsideGitWorkTree(context)) return null; return getGitFileStatusCounts(context).staged; diff --git a/src/widgets/GitUnstaged.ts b/src/widgets/GitUnstaged.ts index f35d33df6..513e3ac92 100644 --- a/src/widgets/GitUnstaged.ts +++ b/src/widgets/GitUnstaged.ts @@ -80,7 +80,9 @@ export class GitUnstagedWidget implements Widget { return renderSymbolOverrideEditor(props, DEFAULT_SYMBOL); } - getNumericValue(context: RenderContext, _item: WidgetItem): number | null { + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, _item: WidgetItem): number | string | boolean | null { if (!isInsideGitWorkTree(context)) return null; const status = getGitStatus(context); diff --git a/src/widgets/GitUnstagedFiles.ts b/src/widgets/GitUnstagedFiles.ts index 95dbf0e0c..af37fc062 100644 --- a/src/widgets/GitUnstagedFiles.ts +++ b/src/widgets/GitUnstagedFiles.ts @@ -53,7 +53,9 @@ export class GitUnstagedFilesWidget implements Widget { return getHideNoGitKeybinds(); } - getNumericValue(context: RenderContext, _item: WidgetItem): number | null { + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, _item: WidgetItem): number | string | boolean | null { if (!isInsideGitWorkTree(context)) return null; return getGitFileStatusCounts(context).unstaged; diff --git a/src/widgets/GitUntracked.ts b/src/widgets/GitUntracked.ts index e92b83f87..d00344574 100644 --- a/src/widgets/GitUntracked.ts +++ b/src/widgets/GitUntracked.ts @@ -80,7 +80,9 @@ export class GitUntrackedWidget implements Widget { return renderSymbolOverrideEditor(props, DEFAULT_SYMBOL); } - getNumericValue(context: RenderContext, _item: WidgetItem): number | null { + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, _item: WidgetItem): number | string | boolean | null { if (!isInsideGitWorkTree(context)) return null; const status = getGitStatus(context); diff --git a/src/widgets/GitUntrackedFiles.ts b/src/widgets/GitUntrackedFiles.ts index f94d45a22..1f5318f11 100644 --- a/src/widgets/GitUntrackedFiles.ts +++ b/src/widgets/GitUntrackedFiles.ts @@ -53,7 +53,9 @@ export class GitUntrackedFilesWidget implements Widget { return getHideNoGitKeybinds(); } - getNumericValue(context: RenderContext, _item: WidgetItem): number | null { + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, _item: WidgetItem): number | string | boolean | null { if (!isInsideGitWorkTree(context)) return null; return getGitFileStatusCounts(context).untracked; From 957e3bd772ca5681ca7bb6b6d6441722c48e3b69 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Apr 2026 14:27:35 +1000 Subject: [PATCH 37/52] feat: add getValue/getValueType to token, context, cost, speed, and usage widgets Enable numeric value extraction for rules engine conditions across 15 widgets using the shared getValueFromRender helper and type-specific parsers. Usage widgets read directly from context data instead. --- src/widgets/CompactionCounter.ts | 6 ++++++ src/widgets/ContextLength.ts | 8 ++++++++ src/widgets/ContextPercentage.ts | 8 ++++++++ src/widgets/ContextPercentageUsable.ts | 8 ++++++++ src/widgets/ContextWindow.ts | 8 ++++++++ src/widgets/InputSpeed.ts | 8 ++++++++ src/widgets/OutputSpeed.ts | 8 ++++++++ src/widgets/SessionCost.ts | 8 ++++++++ src/widgets/SessionUsage.ts | 9 +++++++++ src/widgets/TokensCached.ts | 8 ++++++++ src/widgets/TokensInput.ts | 8 ++++++++ src/widgets/TokensOutput.ts | 8 ++++++++ src/widgets/TokensTotal.ts | 8 ++++++++ src/widgets/TotalSpeed.ts | 8 ++++++++ src/widgets/WeeklyUsage.ts | 9 +++++++++ 15 files changed, 120 insertions(+) diff --git a/src/widgets/CompactionCounter.ts b/src/widgets/CompactionCounter.ts index 6560aa9cb..5b27ba13a 100644 --- a/src/widgets/CompactionCounter.ts +++ b/src/widgets/CompactionCounter.ts @@ -333,6 +333,12 @@ export class CompactionCounterWidget implements Widget { return renderSymbolSlotsEditor(props, [RECLAIMED_SLOT]); } + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, _item: WidgetItem): number | string | boolean | null { + return context.compactionData?.count ?? 0; + } + supportsRawValue(): boolean { return false; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/ContextLength.ts b/src/widgets/ContextLength.ts index 85c9c3121..23970518b 100644 --- a/src/widgets/ContextLength.ts +++ b/src/widgets/ContextLength.ts @@ -7,6 +7,8 @@ import type { } from '../types/Widget'; import { getContextWindowContextLengthTokens } from '../utils/context-window'; import { formatTokens } from '../utils/renderer'; +import { parseTokenCount } from '../utils/value-parsers'; +import { getValueFromRender } from '../utils/widget-values'; export class ContextLengthWidget implements Widget { getDefaultColor(): string { return 'brightBlack'; } @@ -33,6 +35,12 @@ export class ContextLengthWidget implements Widget { return null; } + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, item: WidgetItem): number | string | boolean | null { + return getValueFromRender(this, context, item, parseTokenCount); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index f6940106f..582f6debf 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -7,6 +7,8 @@ import type { WidgetItem } from '../types/Widget'; import { calculateContextPercentageMetrics } from '../utils/context-percentage'; +import { parsePercentage } from '../utils/value-parsers'; +import { getValueFromRender } from '../utils/widget-values'; import { getContextInverseModifierText, @@ -75,6 +77,12 @@ export class ContextPercentageWidget implements Widget { ]; } + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, item: WidgetItem): number | string | boolean | null { + return getValueFromRender(this, context, item, parsePercentage); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/ContextPercentageUsable.ts b/src/widgets/ContextPercentageUsable.ts index 9dd8c6793..5c4a00191 100644 --- a/src/widgets/ContextPercentageUsable.ts +++ b/src/widgets/ContextPercentageUsable.ts @@ -11,6 +11,8 @@ import { getContextConfig, getModelContextIdentifier } from '../utils/model-context'; +import { parsePercentage } from '../utils/value-parsers'; +import { getValueFromRender } from '../utils/widget-values'; import { getContextInverseModifierText, @@ -87,6 +89,12 @@ export class ContextPercentageUsableWidget implements Widget { ]; } + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, item: WidgetItem): number | string | boolean | null { + return getValueFromRender(this, context, item, parsePercentage); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/ContextWindow.ts b/src/widgets/ContextWindow.ts index 3c2b102f7..fabb3fbc2 100644 --- a/src/widgets/ContextWindow.ts +++ b/src/widgets/ContextWindow.ts @@ -11,6 +11,8 @@ import { getModelContextIdentifier } from '../utils/model-context'; import { formatTokens } from '../utils/renderer'; +import { parseTokenCount } from '../utils/value-parsers'; +import { getValueFromRender } from '../utils/widget-values'; export class ContextWindowWidget implements Widget { getDefaultColor(): string { return 'brightBlack'; } @@ -40,6 +42,12 @@ export class ContextWindowWidget implements Widget { return item.rawValue ? formatTokens(total) : `Win: ${formatTokens(total)}`; } + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, item: WidgetItem): number | string | boolean | null { + return getValueFromRender(this, context, item, parseTokenCount); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/InputSpeed.ts b/src/widgets/InputSpeed.ts index f0f37f6a2..5c6d7031e 100644 --- a/src/widgets/InputSpeed.ts +++ b/src/widgets/InputSpeed.ts @@ -7,6 +7,8 @@ import type { WidgetEditorProps, WidgetItem } from '../types/Widget'; +import { parseSpeed } from '../utils/value-parsers'; +import { getValueFromRender } from '../utils/widget-values'; import { getSpeedWidgetCustomKeybinds, @@ -39,6 +41,12 @@ export class InputSpeedWidget implements Widget { return renderSpeedWidgetEditor(props); } + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, item: WidgetItem): number | string | boolean | null { + return getValueFromRender(this, context, item, parseSpeed); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/OutputSpeed.ts b/src/widgets/OutputSpeed.ts index 66aa8ef07..ba24c8b4c 100644 --- a/src/widgets/OutputSpeed.ts +++ b/src/widgets/OutputSpeed.ts @@ -7,6 +7,8 @@ import type { WidgetEditorProps, WidgetItem } from '../types/Widget'; +import { parseSpeed } from '../utils/value-parsers'; +import { getValueFromRender } from '../utils/widget-values'; import { getSpeedWidgetCustomKeybinds, @@ -39,6 +41,12 @@ export class OutputSpeedWidget implements Widget { return renderSpeedWidgetEditor(props); } + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, item: WidgetItem): number | string | boolean | null { + return getValueFromRender(this, context, item, parseSpeed); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/SessionCost.ts b/src/widgets/SessionCost.ts index e3dcdfed0..ccfd24939 100644 --- a/src/widgets/SessionCost.ts +++ b/src/widgets/SessionCost.ts @@ -5,6 +5,8 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { parseCurrency } from '../utils/value-parsers'; +import { getValueFromRender } from '../utils/widget-values'; export class SessionCostWidget implements Widget { getDefaultColor(): string { return 'green'; } @@ -31,6 +33,12 @@ export class SessionCostWidget implements Widget { return item.rawValue ? formattedCost : `Cost: ${formattedCost}`; } + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, item: WidgetItem): number | string | boolean | null { + return getValueFromRender(this, context, item, parseCurrency); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/SessionUsage.ts b/src/widgets/SessionUsage.ts index 2c9710db1..148a7a708 100644 --- a/src/widgets/SessionUsage.ts +++ b/src/widgets/SessionUsage.ts @@ -121,6 +121,15 @@ export class SessionUsageWidget implements Widget { return getUsagePercentCustomKeybinds(item); } + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, _item: WidgetItem): number | string | boolean | null { + const data = context.usageData ?? {}; + if (data.sessionUsage === undefined) + return null; + return Math.max(0, Math.min(100, data.sessionUsage)); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/TokensCached.ts b/src/widgets/TokensCached.ts index c1fd8bfb4..c050850bf 100644 --- a/src/widgets/TokensCached.ts +++ b/src/widgets/TokensCached.ts @@ -6,6 +6,8 @@ import type { WidgetItem } from '../types/Widget'; import { formatTokens } from '../utils/renderer'; +import { parseTokenCount } from '../utils/value-parsers'; +import { getValueFromRender } from '../utils/widget-values'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; @@ -29,6 +31,12 @@ export class TokensCachedWidget implements Widget { return null; } + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, item: WidgetItem): number | string | boolean | null { + return getValueFromRender(this, context, item, parseTokenCount); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/TokensInput.ts b/src/widgets/TokensInput.ts index dfebe6b47..98b159372 100644 --- a/src/widgets/TokensInput.ts +++ b/src/widgets/TokensInput.ts @@ -7,6 +7,8 @@ import type { } from '../types/Widget'; import { getContextWindowInputTotalTokens } from '../utils/context-window'; import { formatTokens } from '../utils/renderer'; +import { parseTokenCount } from '../utils/value-parsers'; +import { getValueFromRender } from '../utils/widget-values'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; @@ -35,6 +37,12 @@ export class TokensInputWidget implements Widget { return null; } + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, item: WidgetItem): number | string | boolean | null { + return getValueFromRender(this, context, item, parseTokenCount); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/TokensOutput.ts b/src/widgets/TokensOutput.ts index a9e297a93..6c8a45024 100644 --- a/src/widgets/TokensOutput.ts +++ b/src/widgets/TokensOutput.ts @@ -7,6 +7,8 @@ import type { } from '../types/Widget'; import { getContextWindowOutputTotalTokens } from '../utils/context-window'; import { formatTokens } from '../utils/renderer'; +import { parseTokenCount } from '../utils/value-parsers'; +import { getValueFromRender } from '../utils/widget-values'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; @@ -35,6 +37,12 @@ export class TokensOutputWidget implements Widget { return null; } + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, item: WidgetItem): number | string | boolean | null { + return getValueFromRender(this, context, item, parseTokenCount); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/TokensTotal.ts b/src/widgets/TokensTotal.ts index 5f6e57bda..5686c8914 100644 --- a/src/widgets/TokensTotal.ts +++ b/src/widgets/TokensTotal.ts @@ -6,6 +6,8 @@ import type { WidgetItem } from '../types/Widget'; import { formatTokens } from '../utils/renderer'; +import { parseTokenCount } from '../utils/value-parsers'; +import { getValueFromRender } from '../utils/widget-values'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; @@ -29,6 +31,12 @@ export class TokensTotalWidget implements Widget { return null; } + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, item: WidgetItem): number | string | boolean | null { + return getValueFromRender(this, context, item, parseTokenCount); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/TotalSpeed.ts b/src/widgets/TotalSpeed.ts index 000d9dc85..de35728af 100644 --- a/src/widgets/TotalSpeed.ts +++ b/src/widgets/TotalSpeed.ts @@ -7,6 +7,8 @@ import type { WidgetEditorProps, WidgetItem } from '../types/Widget'; +import { parseSpeed } from '../utils/value-parsers'; +import { getValueFromRender } from '../utils/widget-values'; import { getSpeedWidgetCustomKeybinds, @@ -39,6 +41,12 @@ export class TotalSpeedWidget implements Widget { return renderSpeedWidgetEditor(props); } + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, item: WidgetItem): number | string | boolean | null { + return getValueFromRender(this, context, item, parseSpeed); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/WeeklyUsage.ts b/src/widgets/WeeklyUsage.ts index 7523e381a..7ccb3c62a 100644 --- a/src/widgets/WeeklyUsage.ts +++ b/src/widgets/WeeklyUsage.ts @@ -121,6 +121,15 @@ export class WeeklyUsageWidget implements Widget { return getUsagePercentCustomKeybinds(item); } + getValueType(): 'number' { return 'number'; } + + getValue(context: RenderContext, _item: WidgetItem): number | string | boolean | null { + const data = context.usageData ?? {}; + if (data.weeklyUsage === undefined) + return null; + return Math.max(0, Math.min(100, data.weeklyUsage)); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } From 1c867e323e08236177ff048628cf4339f558fdea Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Apr 2026 14:57:30 +1000 Subject: [PATCH 38/52] feat: add rules engine core for condition evaluation and rule matching Implements the condition evaluation and rule matching logic that the renderer will use to apply conditional property overrides. Supports numeric, string, boolean, and existence operators with negation, cross-widget value resolution, multiple rule stacking with stop flag, and type-safe property merging without mutation. --- src/utils/__tests__/rules-engine.test.ts | 1474 ++++++++++++++++++++++ src/utils/rules-engine.ts | 262 ++++ 2 files changed, 1736 insertions(+) create mode 100644 src/utils/__tests__/rules-engine.test.ts create mode 100644 src/utils/rules-engine.ts diff --git a/src/utils/__tests__/rules-engine.test.ts b/src/utils/__tests__/rules-engine.test.ts new file mode 100644 index 000000000..77a246184 --- /dev/null +++ b/src/utils/__tests__/rules-engine.test.ts @@ -0,0 +1,1474 @@ +import { + beforeEach, + describe, + expect, + test, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import type { WidgetItem } from '../../types/Widget'; +import { applyRules } from '../rules-engine'; +import * as widgetValuesModule from '../widget-values'; + +describe('Rules Engine', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + 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 + } + } + }; + + // --- Basic rule evaluation --- + + test('returns original item when no rules', () => { + const item: WidgetItem = { + id: 'test', + type: 'context-percentage', + color: 'white' + }; + + const result = applyRules(item, mockContext, [item]); + expect(result).toEqual(item); + }); + + test('returns original item when rules array is empty', () => { + const item: WidgetItem = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result).toEqual(item); + }); + + test('applies color override when condition matches', () => { + const item: WidgetItem = { + 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: WidgetItem = { + 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('applies multiple properties from one rule', () => { + const item: WidgetItem = { + 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('does not mutate the original item', () => { + const item: WidgetItem = { + 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'); + expect(item.color).toBe('white'); + }); + + // --- Numeric operators --- + + describe('Numeric Operators', () => { + test('all numeric operators evaluate correctly', () => { + const testCases = [ + { operator: 'greaterThan', value: 70, expected: true }, + { operator: 'greaterThan', value: 80, expected: false }, + { 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: 80, expected: false }, + { 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: WidgetItem = { + 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('numeric operator with string widget value returns false (type mismatch)', () => { + const item: WidgetItem = { + id: 'test', + type: 'custom-text', + customText: '80', + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // custom-text renders a string, not a number -- type mismatch + expect(result.color).toBe('white'); + }); + + test('numeric equals does not match string value', () => { + // Mock getWidgetValue to return a string + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue('80'); + + const item: WidgetItem = { + id: 'test', + type: 'test-widget', + color: 'white', + rules: [ + { + when: { equals: 80 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('white'); + }); + }); + + // --- String operators --- + + describe('String Operators', () => { + test('contains operator matches substring', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue('feature/advanced-operators'); + + const item: WidgetItem = { + 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', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue('main'); + + const item: WidgetItem = { + 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', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue('feature/test'); + + const item: WidgetItem = { + 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', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue('bugfix/test'); + + const item: WidgetItem = { + 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: WidgetItem = { + 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: WidgetItem = { + 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', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue('main'); + + const item: WidgetItem = { + 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', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue('develop'); + + const item: WidgetItem = { + 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('string operators fail on numeric widgets (type mismatch)', () => { + const item: WidgetItem = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { contains: '80' }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('white'); + }); + + test('equals still works for numeric values', () => { + const item: WidgetItem = { + 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'); + }); + }); + + // --- Boolean operators --- + + describe('Boolean Operators', () => { + test('equals true matches boolean widget with true value', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue(true); + + const item: WidgetItem = { + id: 'test', + type: 'test-bool', + color: 'white', + rules: [ + { + when: { equals: true }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('red'); + }); + + test('equals true does not match boolean widget with false value', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue(false); + + const item: WidgetItem = { + id: 'test', + type: 'test-bool', + color: 'white', + rules: [ + { + when: { equals: true }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + + test('equals false matches boolean widget with false value', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue(false); + + const item: WidgetItem = { + id: 'test', + type: 'test-bool', + color: 'white', + rules: [ + { + when: { equals: false }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('green'); + }); + + test('boolean equals does not match numeric value (type mismatch)', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue(42); + + const item: WidgetItem = { + id: 'test', + type: 'test-widget', + color: 'white', + rules: [ + { + when: { equals: true }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + + test('boolean equals does not match string value (type mismatch)', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue('true'); + + const item: WidgetItem = { + id: 'test', + type: 'test-widget', + color: 'white', + rules: [ + { + when: { equals: true }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + }); + + // --- Existence operators --- + + describe('Existence Operators', () => { + test('isNull matches when widget value is null', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue(null); + + const item: WidgetItem = { + id: 'test', + type: 'test-widget', + color: 'white', + rules: [ + { + when: { isNull: true }, + apply: { color: 'gray' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('gray'); + }); + + test('isNull does not match when widget has a value', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue('hello'); + + const item: WidgetItem = { + id: 'test', + type: 'test-widget', + color: 'white', + rules: [ + { + when: { isNull: true }, + apply: { color: 'gray' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + + test('isNotNull matches when widget has a value', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue(42); + + const item: WidgetItem = { + id: 'test', + type: 'test-widget', + color: 'white', + rules: [ + { + when: { isNotNull: true }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('green'); + }); + + test('isNotNull does not match when widget value is null', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue(null); + + const item: WidgetItem = { + id: 'test', + type: 'test-widget', + color: 'white', + rules: [ + { + when: { isNotNull: true }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + + test('isNull matches for widget with empty rendered output (null from getWidgetValue)', () => { + // custom-text with no customText renders '' which getWidgetValue returns as null + const item: WidgetItem = { + id: 'test', + type: 'custom-text', + color: 'white', + rules: [ + { + when: { isNull: true }, + apply: { color: 'gray' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('gray'); + }); + + test('isNotNull matches string value', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue('hello'); + + const item: WidgetItem = { + id: 'test', + type: 'test-widget', + color: 'white', + rules: [ + { + when: { isNotNull: true }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('green'); + }); + + test('isNotNull matches boolean false (value exists but is false)', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue(false); + + const item: WidgetItem = { + id: 'test', + type: 'test-widget', + color: 'white', + rules: [ + { + when: { isNotNull: true }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('green'); + }); + + test('isNotNull matches zero (value exists but is 0)', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue(0); + + const item: WidgetItem = { + id: 'test', + type: 'test-widget', + color: 'white', + rules: [ + { + when: { isNotNull: true }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('green'); + }); + }); + + // --- Negation (NOT flag) --- + + describe('NOT Flag (Negation)', () => { + test('NOT with numeric operator inverts result', () => { + const item: WidgetItem = { + 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, NOT > 90 = true, condition matches + expect(result.color).toBe('green'); + }); + + test('NOT with numeric operator does not match when base condition is true', () => { + const item: WidgetItem = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 70, not: true }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // Value is 80, 80 > 70 = true, NOT true = false, does not match + expect(result.color).toBe('white'); + }); + + test('NOT with string equals acts as notEquals', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue('develop'); + + const item: WidgetItem = { + 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('NOT with contains operator (notContains)', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue('main'); + + const item: WidgetItem = { + 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', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue('bugfix/test'); + + const item: WidgetItem = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { startsWith: 'feature/', not: true }, + apply: { color: 'yellow' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('yellow'); + }); + + test('NOT with boolean equals', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue(false); + + const item: WidgetItem = { + id: 'test', + type: 'test-bool', + color: 'white', + rules: [ + { + when: { equals: true, not: true }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + // value is false, equals true = false, NOT false = true, matches + expect(result.color).toBe('green'); + }); + + test('NOT with isNull inverts to isNotNull behavior', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue('has-value'); + + const item: WidgetItem = { + id: 'test', + type: 'test-widget', + color: 'white', + rules: [ + { + when: { isNull: true, not: true }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + // isNull = false (has value), NOT false = true, matches + expect(result.color).toBe('green'); + }); + + test('NOT with isNotNull inverts to isNull behavior', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue(null); + + const item: WidgetItem = { + id: 'test', + type: 'test-widget', + color: 'white', + rules: [ + { + when: { isNotNull: true, not: true }, + apply: { color: 'gray' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + // isNotNull = false (null value), NOT false = true, matches + expect(result.color).toBe('gray'); + }); + }); + + // --- Multiple rules: stacking and stop flag --- + + describe('Multiple Rules (Stacking and Stop)', () => { + test('evaluates rules top-to-bottom, later rules override', () => { + const item: WidgetItem = { + 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]); + expect(result.color).toBe('red'); + }); + + test('respects stop flag -- stops after first matching stop rule', () => { + const item: WidgetItem = { + 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]); + expect(result.color).toBe('yellow'); + }); + + test('stop flag on non-matching rule does not stop evaluation', () => { + const item: WidgetItem = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 95 }, + apply: { color: 'yellow' }, + stop: true + }, + { + when: { greaterThan: 70 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // First rule does not match (80 < 95), second matches + expect(result.color).toBe('red'); + }); + + test('accumulates properties from multiple matching rules', () => { + const item: WidgetItem = { + 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: color yellow -> red (overridden), bold true + expect(result.color).toBe('red'); + expect(result.bold).toBe(true); + }); + + test('mixed matching and non-matching rules', () => { + const item: WidgetItem = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'yellow' } + }, + { + when: { greaterThan: 90 }, + apply: { color: 'red' } + }, + { + when: { lessThan: 85 }, + apply: { bold: true } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // Rule 1 matches (80 > 50), rule 2 doesn't (80 < 90), rule 3 matches (80 < 85) + expect(result.color).toBe('yellow'); + expect(result.bold).toBe(true); + }); + + test('multiple rules with mixed NOT flags', () => { + const item: WidgetItem = { + 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: 80 is NOT > 90 = matches, applies green + // Second: 80 > 70 = matches, applies bold + expect(result.color).toBe('green'); + expect(result.bold).toBe(true); + }); + }); + + // --- Cross-widget conditions --- + + describe('Cross-Widget Conditions', () => { + test('references another widget in the line by type', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockImplementation( + (widgetType: string) => { + if (widgetType === 'context-percentage') + return 80; + if (widgetType === 'git-branch') + return 'feature/test'; + return null; + } + ); + + const gitBranchWidget: WidgetItem = { + id: 'branch-1', + type: 'git-branch', + color: 'white' + }; + + const contextWidget: WidgetItem = { + id: 'ctx-1', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { widget: 'git-branch', contains: 'feature' }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(contextWidget, mockContext, [gitBranchWidget, contextWidget]); + expect(result.color).toBe('green'); + }); + + test('cross-widget condition returns false when widget not in line', () => { + const contextWidget: WidgetItem = { + id: 'ctx-1', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { widget: 'git-branch', contains: 'feature' }, + apply: { color: 'green' } + } + ] + }; + + // No git-branch widget in the line + const result = applyRules(contextWidget, mockContext, [contextWidget]); + expect(result.color).toBe('white'); + }); + + test('explicit self reference works', () => { + const item: WidgetItem = { + 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('implicit self reference (no widget property)', () => { + const item: WidgetItem = { + 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('cross-widget numeric condition', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockImplementation( + (widgetType: string) => { + if (widgetType === 'context-percentage') + return 80; + return null; + } + ); + + const item: WidgetItem = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { widget: 'context-percentage', greaterThan: 50 }, + apply: { color: 'red' } + } + ] + }; + + const contextWidget: WidgetItem = { + id: 'ctx-1', + type: 'context-percentage' + }; + + const result = applyRules(item, mockContext, [item, contextWidget]); + expect(result.color).toBe('red'); + }); + + test('cross-widget boolean condition', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockImplementation( + (widgetType: string) => { + if (widgetType === 'git-changes') + return true; + if (widgetType === 'context-percentage') + return 80; + return null; + } + ); + + const gitChangesWidget: WidgetItem = { + id: 'git-1', + type: 'git-changes', + color: 'white' + }; + + const contextWidget: WidgetItem = { + id: 'ctx-1', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { widget: 'git-changes', equals: true }, + apply: { color: 'yellow', bold: true } + } + ] + }; + + const result = applyRules(contextWidget, mockContext, [gitChangesWidget, contextWidget]); + expect(result.color).toBe('yellow'); + expect(result.bold).toBe(true); + }); + + test('cross-widget existence condition', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockImplementation( + (widgetType: string) => { + if (widgetType === 'git-branch') + return null; + if (widgetType === 'context-percentage') + return 80; + return null; + } + ); + + const branchWidget: WidgetItem = { + id: 'branch-1', + type: 'git-branch' + }; + + const contextWidget: WidgetItem = { + id: 'ctx-1', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { widget: 'git-branch', isNull: true }, + apply: { hide: true } + } + ] + }; + + const result = applyRules(contextWidget, mockContext, [branchWidget, contextWidget]); + expect(result.hide).toBe(true); + }); + + test('cross-widget with NOT flag', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockImplementation( + (widgetType: string) => { + if (widgetType === 'git-branch') + return 'develop'; + if (widgetType === 'context-percentage') + return 80; + return null; + } + ); + + const branchWidget: WidgetItem = { + id: 'branch-1', + type: 'git-branch' + }; + + const item: WidgetItem = { + id: 'ctx-1', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { widget: 'git-branch', equals: 'main', not: true }, + apply: { color: 'yellow' } + } + ] + }; + + const result = applyRules(item, mockContext, [branchWidget, item]); + // Branch is "develop" not "main", NOT inverts: matches + expect(result.color).toBe('yellow'); + }); + + test('uses first widget of matching type when duplicates exist', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockImplementation( + (_widgetType: string, _ctx: RenderContext, widgetItem: WidgetItem) => { + if (widgetItem.id === 'branch-1') + return 'main'; + if (widgetItem.id === 'branch-2') + return 'develop'; + return 80; + } + ); + + const branch1: WidgetItem = { id: 'branch-1', type: 'git-branch' }; + const branch2: WidgetItem = { id: 'branch-2', type: 'git-branch' }; + + const contextWidget: WidgetItem = { + id: 'ctx-1', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { widget: 'git-branch', equals: 'main' }, + apply: { color: 'cyan' } + } + ] + }; + + const result = applyRules( + contextWidget, + mockContext, + [branch1, branch2, contextWidget] + ); + // Should use branch-1 (first match), which returns 'main' + expect(result.color).toBe('cyan'); + }); + }); + + // --- Edge cases and error handling --- + + describe('Edge Cases', () => { + test('handles widgets without values (no numeric value for numeric operator)', () => { + const item: WidgetItem = { + id: 'test', + type: 'custom-text', + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('white'); + }); + + test('condition with no recognized operator does not match', () => { + const item: WidgetItem = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { unknownOp: 50 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('white'); + }); + + test('condition with null condition value does not match (non-existence op)', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue(42); + + const item: WidgetItem = { + id: 'test', + type: 'test-widget', + color: 'white', + rules: [ + { + when: { equals: null as unknown }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + + test('condition with null widget value and non-existence operator does not match', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue(null); + + const item: WidgetItem = { + id: 'test', + type: 'test-widget', + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + + test('type mismatch: string operator on number does not crash', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue(42); + + const item: WidgetItem = { + id: 'test', + type: 'test-widget', + color: 'white', + rules: [ + { + when: { startsWith: 'abc' }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + + test('type mismatch: numeric operator on boolean does not crash', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue(true); + + const item: WidgetItem = { + id: 'test', + type: 'test-widget', + color: 'white', + rules: [ + { + when: { greaterThan: 5 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + + test('type mismatch: numeric operator on string does not crash', () => { + vi.spyOn(widgetValuesModule, 'getWidgetValue').mockReturnValue('hello'); + + const item: WidgetItem = { + id: 'test', + type: 'test-widget', + color: 'white', + rules: [ + { + when: { lessThan: 10 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + + test('hide property can be applied by rules', () => { + const item: WidgetItem = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, + apply: { hide: true } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.hide).toBe(true); + }); + + test('customText property can be applied by rules', () => { + const item: WidgetItem = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, + apply: { customText: 'HIGH' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.customText).toBe('HIGH'); + }); + + test('rawValue property can be toggled by rules', () => { + const item: WidgetItem = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, + apply: { rawValue: true } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.rawValue).toBe(true); + }); + }); + + // --- Property merging --- + + describe('Property Merging', () => { + test('only overrides properties present in apply', () => { + const item: WidgetItem = { + id: 'test', + type: 'context-percentage', + color: 'white', + bold: true, + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('red'); + expect(result.bold).toBe(true); // Not touched by the rule + }); + + test('preserves all non-overridden properties', () => { + const item: WidgetItem = { + id: 'test-id', + type: 'context-percentage', + color: 'white', + customText: 'original', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.id).toBe('test-id'); + expect(result.type).toBe('context-percentage'); + expect(result.customText).toBe('original'); + expect(result.color).toBe('red'); + }); + + test('merging does not mutate the original item', () => { + const originalColor = 'white'; + const item: WidgetItem = { + id: 'test', + type: 'context-percentage', + color: originalColor, + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'red', bold: true } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('red'); + expect(item.color).toBe(originalColor); + expect(item.bold).toBeUndefined(); + }); + + test('backgroundColor can be applied', () => { + const item: WidgetItem = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, + apply: { backgroundColor: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.backgroundColor).toBe('red'); + }); + }); +}); diff --git a/src/utils/rules-engine.ts b/src/utils/rules-engine.ts new file mode 100644 index 000000000..667a544a4 --- /dev/null +++ b/src/utils/rules-engine.ts @@ -0,0 +1,262 @@ +import { + getConditionNot, + getConditionOperator, + getConditionValue, + getConditionWidget, + isBooleanOperator, + isExistenceOperator, + isNumericOperator, + isStringOperator, + type ExistenceOperator, + type NumericOperator, + type StringOperator +} from '../types/Condition'; +import type { RenderContext } from '../types/RenderContext'; +import type { + RuleApply, + WidgetItem +} from '../types/Widget'; + +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; + default: + return false; + } +} + +/** + * Evaluate a boolean condition against a value. + * + * Only matches when both widgetValue and conditionValue are booleans. + */ +function evaluateBooleanCondition( + widgetValue: boolean, + conditionValue: boolean +): boolean { + return widgetValue === conditionValue; +} + +/** + * Evaluate an existence condition against a value. + * + * isNull matches when the value is null. + * isNotNull matches when the value is not null. + */ +function evaluateExistenceCondition( + operator: ExistenceOperator, + widgetValue: number | string | boolean | null +): boolean { + switch (operator) { + case 'isNull': + return widgetValue === null; + case 'isNotNull': + return widgetValue !== null; + default: + return false; + } +} + +/** + * Evaluate a single rule condition + * + * @param when - The condition to evaluate (loosely-typed record from JSON) + * @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); + + // A rule with no recognized operator does not match + if (!operator) { + return false; + } + + // Existence operators don't require a condition value + // but non-existence operators with null condition value are invalid + if (!isExistenceOperator(operator) && conditionValue === null) { + return false; + } + + // 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) { + // Widget not found in the line -- condition evaluates to false + return false; + } + targetWidget = widgetInLine; + } + + // Get the target widget's value + const widgetValue = getWidgetValue(targetWidget.type, context, targetWidget); + + // Route existence operators first (they handle null values) + if (isExistenceOperator(operator)) { + const result = evaluateExistenceCondition(operator, widgetValue); + return notFlag ? !result : result; + } + + // For all other operators, null widget value means the condition cannot match + if (widgetValue === null) { + return false; + } + + let result: boolean; + + // 'equals' is shared between numeric, string, and boolean -- 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 if (typeof widgetValue === 'boolean' && typeof conditionValue === 'boolean') { + result = evaluateBooleanCondition(widgetValue, conditionValue); + } else { + // Type mismatch + return false; + } + } else if (isNumericOperator(operator)) { + if (typeof widgetValue !== 'number' || typeof conditionValue !== 'number') { + return false; + } + result = evaluateNumericCondition(operator, widgetValue, conditionValue); + } else if (isStringOperator(operator)) { + if (typeof widgetValue !== 'string' || typeof conditionValue !== 'string') { + return false; + } + result = evaluateStringCondition(operator, widgetValue, conditionValue); + } else if (isBooleanOperator(operator)) { + if (typeof widgetValue !== 'boolean' || typeof conditionValue !== 'boolean') { + return false; + } + result = evaluateBooleanCondition(widgetValue, conditionValue); + } else { + // Unknown operator type + return false; + } + + return notFlag ? !result : result; +} + +/** + * Merge rule.apply onto a widget item, producing a new object. + * + * Only properties present in apply override the widget's properties. + * Metadata is deep-merged so base widget metadata is preserved. + * The original widget item is never mutated. + */ +function mergeApply(widget: WidgetItem, apply: RuleApply): WidgetItem { + const merged = { ...widget, ...apply }; + + // Deep merge metadata if both sides have it + const applyRecord = apply as Record; + const applyMetadata = applyRecord.metadata; + if ( + applyMetadata !== undefined + && typeof applyMetadata === 'object' + && applyMetadata !== null + && widget.metadata + ) { + merged.metadata = { + ...widget.metadata, + ...(applyMetadata as Record) + }; + } + + return merged; +} + +/** + * Apply rules to a widget and return merged properties. + * + * Rules execute top-to-bottom. All matching rules' `apply` objects are merged + * (later rules override earlier ones for the same property). A matching rule + * with `stop: true` halts further evaluation. + * + * Returns a new widget item with rule overrides applied. The original item + * is never mutated. + * + * @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; + } + + let mergedItem = { ...item }; + + for (const rule of rules) { + const matches = evaluateCondition(rule.when, item, allWidgetsInLine, context); + + if (matches) { + mergedItem = mergeApply(mergedItem, rule.apply); + + if (rule.stop) { + break; + } + } + } + + return mergedItem; +} From 2ecd46acb43f2ea2e109abba77a5734551df4e2c Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Apr 2026 15:42:57 +1000 Subject: [PATCH 39/52] feat: wire rules engine into rendering pipeline Integrate applyRules() into preRenderAllWidgets() so widget rules are evaluated before each widget renders. Rule evaluation runs after the minimalist rawValue override, and rule overrides take precedence. Widgets with hide:true from rules are skipped entirely. The full lineWidgets array is passed to applyRules for cross-widget condition resolution. --- src/utils/__tests__/renderer-rules.test.ts | 485 +++++++++++++++++++++ src/utils/renderer.ts | 16 +- 2 files changed, 499 insertions(+), 2 deletions(-) create mode 100644 src/utils/__tests__/renderer-rules.test.ts diff --git a/src/utils/__tests__/renderer-rules.test.ts b/src/utils/__tests__/renderer-rules.test.ts new file mode 100644 index 000000000..427286f5d --- /dev/null +++ b/src/utils/__tests__/renderer-rules.test.ts @@ -0,0 +1,485 @@ +import { + beforeEach, + describe, + expect, + it, + vi, + type MockInstance +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { + DEFAULT_SETTINGS, + type Settings +} from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { preRenderAllWidgets } from '../renderer'; +import * as rulesEngineModule from '../rules-engine'; +import * as widgetsModule from '../widgets'; + +function createSettings(overrides: Partial = {}): Settings { + return { + ...DEFAULT_SETTINGS, + ...overrides, + powerline: { + ...DEFAULT_SETTINGS.powerline, + ...(overrides.powerline ?? {}) + } + }; +} + +/** + * Minimal widget implementation for testing. + * render() returns item.customText (or null if absent). + */ +function makeFakeWidget(defaultColor = 'white') { + return { + getDefaultColor: () => defaultColor, + getDescription: () => 'test', + getDisplayName: () => 'Test', + getCategory: () => 'test', + getEditorDisplay: () => ({ displayText: 'test' }), + render: (item: WidgetItem) => item.customText ?? null, + supportsRawValue: () => false, + supportsColors: () => true + }; +} + +/** Safely get a pre-rendered widget from a result */ +function getPreRendered(result: ReturnType, lineIndex: number, widgetIndex: number) { + const line = result[lineIndex]; + expect(line).toBeDefined(); + const widget = line?.at(widgetIndex); + expect(widget).toBeDefined(); + return widget; +} + +describe('renderer rules integration', () => { + const baseContext: RenderContext = { isPreview: false, terminalWidth: 200 }; + const settings = createSettings({ colorLevel: 0 }); + + let getWidgetSpy: MockInstance; + let applyRulesSpy: MockInstance; + + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + + // Default: getWidget returns a fake for any type + getWidgetSpy = vi.spyOn(widgetsModule, 'getWidget').mockImplementation(() => makeFakeWidget()); + + // Default: applyRules returns the widget unchanged + applyRulesSpy = vi.spyOn(rulesEngineModule, 'applyRules').mockImplementation((item: WidgetItem) => item); + }); + + // --- Baseline: no rules --- + + it('renders a widget with no rules normally (baseline)', () => { + const widget: WidgetItem = { + id: 'w1', + type: 'custom-text', + customText: 'hello' + }; + + const result = preRenderAllWidgets([[widget]], settings, baseContext); + + // applyRules should not be called (no rules on widget) + expect(applyRulesSpy).not.toHaveBeenCalled(); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(1); + const w = getPreRendered(result, 0, 0); + expect(w?.content).toBe('hello'); + }); + + // --- Matching rule overrides color --- + + it('renders with overridden color when a rule matches', () => { + const widget: WidgetItem = { + id: 'w1', + type: 'custom-text', + color: 'white', + customText: 'hello', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'red' } + } + ] + }; + + // applyRules returns widget with color changed to red + applyRulesSpy.mockImplementation(() => ({ + ...widget, + color: 'red' + })); + + const result = preRenderAllWidgets([[widget]], settings, baseContext); + + expect(applyRulesSpy).toHaveBeenCalledOnce(); + expect(result[0]).toHaveLength(1); + const w = getPreRendered(result, 0, 0); + expect(w?.widget.color).toBe('red'); + expect(w?.content).toBe('hello'); + }); + + // --- Non-matching rule renders normally --- + + it('renders normally when a rule does not match', () => { + const widget: WidgetItem = { + id: 'w1', + type: 'custom-text', + color: 'white', + customText: 'hello', + rules: [ + { + when: { greaterThan: 90 }, + apply: { color: 'red' } + } + ] + }; + + // applyRules returns widget unchanged (rule did not match) + applyRulesSpy.mockImplementation((item: WidgetItem) => item); + + const result = preRenderAllWidgets([[widget]], settings, baseContext); + + expect(applyRulesSpy).toHaveBeenCalledOnce(); + const w = getPreRendered(result, 0, 0); + expect(w?.widget.color).toBe('white'); + expect(w?.content).toBe('hello'); + }); + + // --- hide: true causes widget to be skipped --- + + it('skips widget when rule applies hide: true', () => { + const widget: WidgetItem = { + id: 'w1', + type: 'custom-text', + customText: 'hidden', + rules: [ + { + when: { greaterThan: 50 }, + apply: { hide: true } + } + ] + }; + + // applyRules returns widget with hide: true + applyRulesSpy.mockImplementation(() => ({ + ...widget, + hide: true + })); + + const result = preRenderAllWidgets([[widget]], settings, baseContext); + + expect(applyRulesSpy).toHaveBeenCalledOnce(); + // Widget should be entirely skipped -- empty pre-rendered line + expect(result[0]).toHaveLength(0); + }); + + it('keeps other widgets when one is hidden by rules', () => { + const visible: WidgetItem = { + id: 'w1', + type: 'custom-text', + customText: 'visible' + }; + const hidden: WidgetItem = { + id: 'w2', + type: 'custom-text', + customText: 'hidden', + rules: [ + { + when: { greaterThan: 50 }, + apply: { hide: true } + } + ] + }; + + applyRulesSpy.mockImplementation(() => ({ + ...hidden, + hide: true + })); + + const result = preRenderAllWidgets([[visible, hidden]], settings, baseContext); + + // Only the visible widget should remain + expect(result[0]).toHaveLength(1); + const w = getPreRendered(result, 0, 0); + expect(w?.content).toBe('visible'); + }); + + // --- Multiple rules stack correctly --- + + it('passes all line widgets to applyRules for cross-widget conditions', () => { + const widget1: WidgetItem = { + id: 'w1', + type: 'custom-text', + customText: 'A', + rules: [ + { + when: { widget: 'context-percentage', greaterThan: 50 }, + apply: { color: 'red' } + } + ] + }; + const widget2: WidgetItem = { + id: 'w2', + type: 'context-percentage', + customText: 'B' + }; + + applyRulesSpy.mockImplementation((item: WidgetItem) => ({ + ...item, + color: 'red' + })); + + preRenderAllWidgets([[widget1, widget2]], settings, baseContext); + + // applyRules should be called with the full line widgets array + expect(applyRulesSpy).toHaveBeenCalledOnce(); + const callArgs = applyRulesSpy.mock.calls[0]; + expect(callArgs).toBeDefined(); + // Third argument is the full lineWidgets array + const lineWidgetsArg = callArgs?.[2]; + expect(lineWidgetsArg).toHaveLength(2); + expect(lineWidgetsArg?.at(0)?.id).toBe('w1'); + expect(lineWidgetsArg?.at(1)?.id).toBe('w2'); + }); + + it('stacks multiple rule overrides correctly (later rule wins for same property)', () => { + const widget: WidgetItem = { + id: 'w1', + type: 'custom-text', + color: 'white', + customText: 'hello', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'yellow' } + }, + { + when: { greaterThan: 70 }, + apply: { color: 'red', bold: true } + } + ] + }; + + // Simulate stacked result: both rules matched, later overrides color + applyRulesSpy.mockImplementation(() => ({ + ...widget, + color: 'red', + bold: true + })); + + const result = preRenderAllWidgets([[widget]], settings, baseContext); + const w = getPreRendered(result, 0, 0); + + expect(w?.widget.color).toBe('red'); + expect(w?.widget.bold).toBe(true); + }); + + // --- Stop flag halts evaluation --- + + it('respects stop flag (verified via applyRules being called correctly)', () => { + const widget: WidgetItem = { + id: 'w1', + type: 'custom-text', + color: 'white', + customText: 'hello', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'yellow' }, + stop: true + }, + { + when: { greaterThan: 70 }, + apply: { color: 'red' } + } + ] + }; + + // applyRules returns result as if stop halted at first rule + applyRulesSpy.mockImplementation(() => ({ + ...widget, + color: 'yellow' + })); + + const result = preRenderAllWidgets([[widget]], settings, baseContext); + + expect(applyRulesSpy).toHaveBeenCalledOnce(); + const w = getPreRendered(result, 0, 0); + expect(w?.widget.color).toBe('yellow'); + }); + + // --- Separators are not evaluated for rules --- + + it('does not evaluate rules on separator widgets', () => { + const sep: WidgetItem = { + id: 'sep', + type: 'separator' + }; + + preRenderAllWidgets([[sep]], settings, baseContext); + + // applyRules should not be called for separators + expect(applyRulesSpy).not.toHaveBeenCalled(); + }); + + it('does not evaluate rules on flex-separator widgets', () => { + const sep: WidgetItem = { + id: 'sep', + type: 'flex-separator' + }; + + preRenderAllWidgets([[sep]], settings, baseContext); + + expect(applyRulesSpy).not.toHaveBeenCalled(); + }); + + // --- Rules apply AFTER minimalist mode --- + + it('applies rules after minimalist rawValue override', () => { + const widget: WidgetItem = { + id: 'w1', + type: 'custom-text', + customText: 'hello', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'red' } + } + ] + }; + + const minimalistContext: RenderContext = { ...baseContext, minimalist: true }; + + applyRulesSpy.mockImplementation((item: WidgetItem) => { + // Verify the widget passed to applyRules has rawValue set by minimalist mode + expect(item.rawValue).toBe(true); + return { ...item, color: 'red' }; + }); + + preRenderAllWidgets([[widget]], settings, minimalistContext); + + expect(applyRulesSpy).toHaveBeenCalledOnce(); + }); + + it('rule overrides take precedence over minimalist rawValue', () => { + const widget: WidgetItem = { + id: 'w1', + type: 'custom-text', + customText: 'hello', + rawValue: true, + rules: [ + { + when: { greaterThan: 50 }, + apply: { rawValue: false } + } + ] + }; + + const minimalistContext: RenderContext = { ...baseContext, minimalist: true }; + + // Rule override sets rawValue back to false + applyRulesSpy.mockImplementation(() => ({ + ...widget, + rawValue: false + })); + + const fakeWidget = makeFakeWidget(); + const renderSpy = vi.fn().mockReturnValue('rendered'); + fakeWidget.render = renderSpy; + getWidgetSpy.mockReturnValue(fakeWidget); + + preRenderAllWidgets([[widget]], settings, minimalistContext); + + // The widget passed to render should have rawValue: false (from rule override) + const renderedWidget = renderSpy.mock.calls[0]?.[0] as WidgetItem | undefined; + expect(renderedWidget?.rawValue).toBe(false); + }); + + // --- The overridden widget is passed to render --- + + it('passes the rule-overridden widget to widgetImpl.render()', () => { + const widget: WidgetItem = { + id: 'w1', + type: 'custom-text', + color: 'white', + customText: 'hello', + rules: [ + { + when: { greaterThan: 50 }, + apply: { customText: 'overridden' } + } + ] + }; + + const overriddenWidget = { ...widget, customText: 'overridden' }; + applyRulesSpy.mockImplementation(() => overriddenWidget); + + const fakeWidget = makeFakeWidget(); + const renderSpy = vi.fn().mockReturnValue('overridden'); + fakeWidget.render = renderSpy; + getWidgetSpy.mockReturnValue(fakeWidget); + + const result = preRenderAllWidgets([[widget]], settings, baseContext); + + // Verify the overridden widget was passed to render + const renderedWidget = renderSpy.mock.calls[0]?.[0] as WidgetItem | undefined; + expect(renderedWidget?.customText).toBe('overridden'); + const w = getPreRendered(result, 0, 0); + expect(w?.content).toBe('overridden'); + }); + + // --- Multiple lines --- + + it('evaluates rules independently per line', () => { + const widgetL1: WidgetItem = { + id: 'w1', + type: 'custom-text', + customText: 'line1', + rules: [{ when: { equals: 'line1' }, apply: { color: 'red' } }] + }; + const widgetL2: WidgetItem = { + id: 'w2', + type: 'custom-text', + customText: 'line2', + rules: [{ when: { equals: 'line2' }, apply: { color: 'blue' } }] + }; + + applyRulesSpy.mockImplementation((item: WidgetItem) => { + if (item.id === 'w1') + return { ...item, color: 'red' }; + if (item.id === 'w2') + return { ...item, color: 'blue' }; + return item; + }); + + const result = preRenderAllWidgets([[widgetL1], [widgetL2]], settings, baseContext); + + expect(applyRulesSpy).toHaveBeenCalledTimes(2); + const w1 = getPreRendered(result, 0, 0); + const w2 = getPreRendered(result, 1, 0); + expect(w1?.widget.color).toBe('red'); + expect(w2?.widget.color).toBe('blue'); + }); + + // --- Widget with empty rules array is treated like no rules --- + + it('does not call applyRules when rules array is empty', () => { + const widget: WidgetItem = { + id: 'w1', + type: 'custom-text', + customText: 'hello', + rules: [] + }; + + const result = preRenderAllWidgets([[widget]], settings, baseContext); + + expect(applyRulesSpy).not.toHaveBeenCalled(); + const w = getPreRendered(result, 0, 0); + expect(w?.content).toBe('hello'); + }); +}); diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index 2b1d8391e..4785bddba 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -37,6 +37,7 @@ import { NO_THEME_SLOT, assignPowerlineThemeSlots } from './powerline-theme-index'; +import { applyRules } from './rules-engine'; import { getTerminalWidth } from './terminal'; import { getWidget } from './widgets'; @@ -824,7 +825,18 @@ export function preRenderAllWidgets( continue; } - const effectiveWidget = context.minimalist ? { ...widget, rawValue: true } : widget; + let effectiveWidget = context.minimalist ? { ...widget, rawValue: true } : widget; + + // Evaluate rules: apply overrides from matching rules + if (effectiveWidget.rules && effectiveWidget.rules.length > 0) { + effectiveWidget = applyRules(effectiveWidget, context, lineWidgets); + } + + // If rules determined this widget should be hidden, skip it + if (effectiveWidget.hide) { + continue; + } + const widgetText = widgetImpl.render(effectiveWidget, context, settings) ?? ''; // Store the rendered content without padding (padding is applied later) @@ -833,7 +845,7 @@ export function preRenderAllWidgets( preRenderedLine.push({ content: widgetText, plainLength, - widget + widget: effectiveWidget }); } From 5dd19127e72bc2e7329a21abad75fc18c9ba2843 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Thu, 30 Apr 2026 08:08:25 +1000 Subject: [PATCH 40/52] fix: use rule-overridden widget for color application in both standard and powerline renderers preRenderAllWidgets correctly stored the rule-overridden widget, but renderStatusLine and renderPowerlineStatusLine read colors from the original widgets array, ignoring rule color overrides. --- src/utils/renderer.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index 4785bddba..2bd0e0f1f 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -238,7 +238,10 @@ function renderPowerlineStatusLine( } for (let i = 0; i < filteredWidgets.length; i++) { - const widget = filteredWidgets[i]; + // Use the pre-rendered widget (which has rule overrides applied) for color/style lookups + const actualPreRenderedIndex = preRenderedIndices[i]; + const preRendered = actualPreRenderedIndex !== undefined ? preRenderedWidgets[actualPreRenderedIndex] : undefined; + const widget = preRendered?.widget ?? filteredWidgets[i]; if (!widget) continue; let widgetText = ''; @@ -249,10 +252,6 @@ function renderPowerlineStatusLine( // These are filtered out in powerline mode continue; } - - // Use pre-rendered content - use the correct index from the mapping - const actualPreRenderedIndex = preRenderedIndices[i]; - const preRendered = actualPreRenderedIndex !== undefined ? preRenderedWidgets[actualPreRenderedIndex] : undefined; const widgetImpl = getWidget(widget.type); if (preRendered?.content) { widgetText = preRendered.content; @@ -1018,8 +1017,9 @@ export function renderStatusLine( let hasFlexSeparator = false; // Build elements based on configured widgets + // Use the pre-rendered widget (which has rule overrides applied) for color/style lookups for (let i = 0; i < widgets.length; i++) { - const widget = widgets[i]; + const widget = preRenderedWidgets[i]?.widget ?? widgets[i]; if (!widget) continue; @@ -1033,7 +1033,7 @@ export function renderStatusLine( let replacesSpacingSeparator = false; let crossedEmptyWidget = false; for (let j = i - 1; j >= 0; j--) { - const prevWidget = widgets[j]; + const prevWidget = preRenderedWidgets[j]?.widget ?? widgets[j]; if (!prevWidget) continue; if (prevWidget.type === 'separator') { @@ -1072,7 +1072,7 @@ export function renderStatusLine( if (settings.inheritSeparatorColors && !widget.color && !widget.backgroundColor) { // Only inherit if the separator doesn't have explicit colors set - const prevWidget = widgets[contentBeforeIndex]; + const prevWidget = preRenderedWidgets[contentBeforeIndex]?.widget ?? widgets[contentBeforeIndex]; if (prevWidget) { // Get the previous widget's colors let widgetColor = prevWidget.color; From 7b6eabb4b7a58a7dc0929daf2378fb2089635fdb Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Apr 2026 16:13:01 +1000 Subject: [PATCH 41/52] feat: add useRuleAccordion hook for shared accordion state management Provides expand/collapse/select state and helpers for navigating widget rules in both ItemsEditor and ColorMenu. Pure state-transition functions are exported for testing. Edge cases handled: deleted widget auto-collapse, out-of-bounds rule index clamping, wrap-around navigation. --- .../hooks/__tests__/useRuleAccordion.test.ts | 343 ++++++++++++++++++ src/tui/hooks/useRuleAccordion.ts | 217 +++++++++++ 2 files changed, 560 insertions(+) create mode 100644 src/tui/hooks/__tests__/useRuleAccordion.test.ts create mode 100644 src/tui/hooks/useRuleAccordion.ts diff --git a/src/tui/hooks/__tests__/useRuleAccordion.test.ts b/src/tui/hooks/__tests__/useRuleAccordion.test.ts new file mode 100644 index 000000000..fb4625298 --- /dev/null +++ b/src/tui/hooks/__tests__/useRuleAccordion.test.ts @@ -0,0 +1,343 @@ +import { + describe, + expect, + test +} from 'vitest'; + +import type { WidgetItem } from '../../../types/Widget'; +import { + collapse, + expand, + getRuleCount, + isExpanded, + reconcile, + selectNextRule, + selectPrevRule, + selectRule, + toggleExpand, + type AccordionState +} from '../useRuleAccordion'; + +// --- Test helpers --- + +function widget(id: string, ruleCount = 0): WidgetItem { + return { + id, + type: 'model', + rules: ruleCount > 0 + ? Array.from({ length: ruleCount }, () => ({ + when: { widget: 'self', equals: 'test' }, + apply: { color: 'red' } + })) + : undefined + }; +} + +function widgetWithEmptyRules(id: string): WidgetItem { + return { id, type: 'model', rules: [] }; +} + +const COLLAPSED: AccordionState = { expandedWidgetId: null, selectedRuleIndex: 0 }; + +function expanded(widgetId: string, selectedRuleIndex = 0): AccordionState { + return { expandedWidgetId: widgetId, selectedRuleIndex }; +} + +// --- getRuleCount --- + +describe('getRuleCount', () => { + test('returns 0 when rules is undefined', () => { + expect(getRuleCount(widget('a'))).toBe(0); + }); + + test('returns 0 when rules is an empty array', () => { + expect(getRuleCount(widgetWithEmptyRules('a'))).toBe(0); + }); + + test('returns the number of rules', () => { + expect(getRuleCount(widget('a', 3))).toBe(3); + }); +}); + +// --- expand --- + +describe('expand', () => { + test('sets expandedWidgetId and resets selectedRuleIndex to 0', () => { + expect(expand('w1')).toEqual({ expandedWidgetId: 'w1', selectedRuleIndex: 0 }); + }); +}); + +// --- collapse --- + +describe('collapse', () => { + test('sets expandedWidgetId to null', () => { + const result = collapse(); + expect(result.expandedWidgetId).toBeNull(); + expect(result.selectedRuleIndex).toBe(0); + }); +}); + +// --- toggleExpand --- + +describe('toggleExpand', () => { + test('expands when currently collapsed', () => { + expect(toggleExpand(COLLAPSED, 'w1')).toEqual(expanded('w1')); + }); + + test('collapses when toggling the same widget', () => { + expect(toggleExpand(expanded('w1'), 'w1')).toEqual(COLLAPSED); + }); + + test('switches to a different widget when one is already expanded', () => { + const result = toggleExpand(expanded('w1', 2), 'w2'); + expect(result).toEqual(expanded('w2', 0)); + }); +}); + +// --- selectRule --- + +describe('selectRule', () => { + test('sets the selected rule index', () => { + expect(selectRule(expanded('w1'), 3)).toEqual(expanded('w1', 3)); + }); + + test('preserves expanded widget', () => { + const result = selectRule(expanded('w1'), 5); + expect(result.expandedWidgetId).toBe('w1'); + expect(result.selectedRuleIndex).toBe(5); + }); +}); + +// --- selectPrevRule --- + +describe('selectPrevRule', () => { + const widgets = [widget('w1', 3), widget('w2', 2)]; + + test('moves selection up', () => { + const result = selectPrevRule(expanded('w1', 2), widgets); + expect(result.selectedRuleIndex).toBe(1); + }); + + test('wraps to last rule when at index 0', () => { + const result = selectPrevRule(expanded('w1', 0), widgets); + expect(result.selectedRuleIndex).toBe(2); // 3 rules, wraps to index 2 + }); + + test('is a no-op when collapsed', () => { + const result = selectPrevRule(COLLAPSED, widgets); + expect(result).toBe(COLLAPSED); + }); + + test('is a no-op when expanded widget has no rules', () => { + const noRulesWidgets = [widget('w1', 0)]; + const state = expanded('w1'); + const result = selectPrevRule(state, noRulesWidgets); + expect(result).toBe(state); + }); + + test('is a no-op when expanded widget does not exist', () => { + const state = expanded('missing'); + const result = selectPrevRule(state, widgets); + expect(result).toBe(state); + }); + + test('works with single rule (wraps to itself)', () => { + const singleRule = [widget('w1', 1)]; + const result = selectPrevRule(expanded('w1', 0), singleRule); + expect(result.selectedRuleIndex).toBe(0); + }); +}); + +// --- selectNextRule --- + +describe('selectNextRule', () => { + const widgets = [widget('w1', 3), widget('w2', 2)]; + + test('moves selection down', () => { + const result = selectNextRule(expanded('w1', 0), widgets); + expect(result.selectedRuleIndex).toBe(1); + }); + + test('wraps to first rule when at last index', () => { + const result = selectNextRule(expanded('w1', 2), widgets); + expect(result.selectedRuleIndex).toBe(0); // 3 rules, wraps from index 2 to 0 + }); + + test('is a no-op when collapsed', () => { + const result = selectNextRule(COLLAPSED, widgets); + expect(result).toBe(COLLAPSED); + }); + + test('is a no-op when expanded widget has no rules', () => { + const noRulesWidgets = [widget('w1', 0)]; + const state = expanded('w1'); + const result = selectNextRule(state, noRulesWidgets); + expect(result).toBe(state); + }); + + test('is a no-op when expanded widget does not exist', () => { + const state = expanded('missing'); + const result = selectNextRule(state, widgets); + expect(result).toBe(state); + }); + + test('works with single rule (wraps to itself)', () => { + const singleRule = [widget('w1', 1)]; + const result = selectNextRule(expanded('w1', 0), singleRule); + expect(result.selectedRuleIndex).toBe(0); + }); +}); + +// --- isExpanded --- + +describe('isExpanded', () => { + test('returns true when the widget is expanded', () => { + expect(isExpanded(expanded('w1'), 'w1')).toBe(true); + }); + + test('returns false for a different widget', () => { + expect(isExpanded(expanded('w1'), 'w2')).toBe(false); + }); + + test('returns false when collapsed', () => { + expect(isExpanded(COLLAPSED, 'w1')).toBe(false); + }); +}); + +// --- reconcile --- + +describe('reconcile', () => { + test('returns state unchanged when collapsed', () => { + const widgets = [widget('w1', 2)]; + const result = reconcile(COLLAPSED, widgets); + expect(result).toBe(COLLAPSED); + }); + + test('collapses when the expanded widget is deleted', () => { + const state = expanded('deleted', 1); + const widgets = [widget('w1', 2)]; + const result = reconcile(state, widgets); + expect(result.expandedWidgetId).toBeNull(); + expect(result.selectedRuleIndex).toBe(0); + }); + + test('clamps selectedRuleIndex when rules are removed', () => { + // Widget had 5 rules, user was on rule 4 (index), now widget has 2 rules + const state = expanded('w1', 4); + const widgets = [widget('w1', 2)]; + const result = reconcile(state, widgets); + expect(result.expandedWidgetId).toBe('w1'); + expect(result.selectedRuleIndex).toBe(1); // clamped to last rule (index 1) + }); + + test('resets index to 0 when all rules are removed', () => { + const state = expanded('w1', 2); + const widgets = [widget('w1', 0)]; + const result = reconcile(state, widgets); + expect(result.expandedWidgetId).toBe('w1'); // stays expanded (shows empty state) + expect(result.selectedRuleIndex).toBe(0); + }); + + test('returns state unchanged when selectedRuleIndex is still valid', () => { + const state = expanded('w1', 1); + const widgets = [widget('w1', 3)]; + const result = reconcile(state, widgets); + expect(result).toBe(state); + }); + + test('keeps expanded state for widget with no rules (empty state placeholder)', () => { + const state = expanded('w1', 0); + const widgets = [widgetWithEmptyRules('w1')]; + const result = reconcile(state, widgets); + expect(result).toBe(state); + expect(result.expandedWidgetId).toBe('w1'); + }); + + test('clamps correctly when selectedRuleIndex equals ruleCount', () => { + // Boundary: exactly at the edge + const state = expanded('w1', 3); + const widgets = [widget('w1', 3)]; + const result = reconcile(state, widgets); + expect(result.selectedRuleIndex).toBe(2); // 3 rules, max index is 2 + }); + + test('returns state unchanged when selectedRuleIndex is exactly the last valid index', () => { + const state = expanded('w1', 2); + const widgets = [widget('w1', 3)]; + const result = reconcile(state, widgets); + expect(result).toBe(state); + }); +}); + +// --- State transition sequences --- + +describe('state transition sequences', () => { + test('expand -> selectNext -> selectNext -> selectPrev', () => { + const widgets = [widget('w1', 3)]; + + let state = expand('w1'); + expect(state.selectedRuleIndex).toBe(0); + + state = selectNextRule(state, widgets); + expect(state.selectedRuleIndex).toBe(1); + + state = selectNextRule(state, widgets); + expect(state.selectedRuleIndex).toBe(2); + + state = selectPrevRule(state, widgets); + expect(state.selectedRuleIndex).toBe(1); + }); + + test('expand -> toggle same -> is collapsed', () => { + let state = expand('w1'); + expect(state.expandedWidgetId).toBe('w1'); + + state = toggleExpand(state, 'w1'); + expect(state.expandedWidgetId).toBeNull(); + }); + + test('expand w1 -> navigate rules -> toggle to w2 -> resets index', () => { + const widgets = [widget('w1', 3), widget('w2', 2)]; + + let state = expand('w1'); + state = selectNextRule(state, widgets); + state = selectNextRule(state, widgets); + expect(state.selectedRuleIndex).toBe(2); + + state = toggleExpand(state, 'w2'); + expect(state.expandedWidgetId).toBe('w2'); + expect(state.selectedRuleIndex).toBe(0); + }); + + test('expand -> delete widget -> reconcile collapses', () => { + let state = expand('w1'); + state = selectRule(state, 2); + + // Simulate widget deletion + const widgets = [widget('w2', 1)]; + state = reconcile(state, widgets); + + expect(state.expandedWidgetId).toBeNull(); + expect(state.selectedRuleIndex).toBe(0); + }); + + test('full wrap-around cycle (next)', () => { + const widgets = [widget('w1', 3)]; + let state = expand('w1'); + + state = selectNextRule(state, widgets); // 0 -> 1 + state = selectNextRule(state, widgets); // 1 -> 2 + state = selectNextRule(state, widgets); // 2 -> 0 (wrap) + expect(state.selectedRuleIndex).toBe(0); + }); + + test('full wrap-around cycle (prev)', () => { + const widgets = [widget('w1', 3)]; + let state = expand('w1'); + + state = selectPrevRule(state, widgets); // 0 -> 2 (wrap) + state = selectPrevRule(state, widgets); // 2 -> 1 + state = selectPrevRule(state, widgets); // 1 -> 0 + expect(state.selectedRuleIndex).toBe(0); + }); +}); diff --git a/src/tui/hooks/useRuleAccordion.ts b/src/tui/hooks/useRuleAccordion.ts new file mode 100644 index 000000000..ba9733514 --- /dev/null +++ b/src/tui/hooks/useRuleAccordion.ts @@ -0,0 +1,217 @@ +import { + useCallback, + useEffect, + useState +} from 'react'; + +import type { WidgetItem } from '../../types/Widget'; + +// --- Accordion state shape --- + +export interface AccordionState { + expandedWidgetId: string | null; + selectedRuleIndex: number; +} + +// --- Pure state-transition functions (exported for testing) --- + +/** Returns the number of rules on a widget (0 if undefined). */ +export function getRuleCount(widget: WidgetItem): number { + return widget.rules?.length ?? 0; +} + +/** Expand a widget's rules accordion, resetting the rule selection. */ +export function expand(widgetId: string): AccordionState { + return { expandedWidgetId: widgetId, selectedRuleIndex: 0 }; +} + +/** Collapse the accordion. */ +export function collapse(): AccordionState { + return { expandedWidgetId: null, selectedRuleIndex: 0 }; +} + +/** Toggle: expand if collapsed or different widget, collapse if same. */ +export function toggleExpand(state: AccordionState, widgetId: string): AccordionState { + if (state.expandedWidgetId === widgetId) { + return collapse(); + } + return expand(widgetId); +} + +/** Set the selected rule index directly. */ +export function selectRule(state: AccordionState, index: number): AccordionState { + return { ...state, selectedRuleIndex: index }; +} + +/** Move selection up with wrap-around. No-op if nothing is expanded or no rules exist. */ +export function selectPrevRule( + state: AccordionState, + widgets: WidgetItem[] +): AccordionState { + if (state.expandedWidgetId === null) { + return state; + } + + const widget = widgets.find(w => w.id === state.expandedWidgetId); + if (!widget) { + return state; + } + + const count = getRuleCount(widget); + if (count === 0) { + return state; + } + + const next = state.selectedRuleIndex - 1; + return { ...state, selectedRuleIndex: next < 0 ? count - 1 : next }; +} + +/** Move selection down with wrap-around. No-op if nothing is expanded or no rules exist. */ +export function selectNextRule( + state: AccordionState, + widgets: WidgetItem[] +): AccordionState { + if (state.expandedWidgetId === null) { + return state; + } + + const widget = widgets.find(w => w.id === state.expandedWidgetId); + if (!widget) { + return state; + } + + const count = getRuleCount(widget); + if (count === 0) { + return state; + } + + const next = state.selectedRuleIndex + 1; + return { ...state, selectedRuleIndex: next >= count ? 0 : next }; +} + +/** Check whether a specific widget is the one currently expanded. */ +export function isExpanded(state: AccordionState, widgetId: string): boolean { + return state.expandedWidgetId === widgetId; +} + +/** + * Reconcile accordion state after external changes to the widgets array. + * Auto-collapses when the expanded widget is deleted and clamps the + * selected-rule index when rules are removed. + */ +export function reconcile( + state: AccordionState, + widgets: WidgetItem[] +): AccordionState { + if (state.expandedWidgetId === null) { + return state; + } + + const widget = widgets.find(w => w.id === state.expandedWidgetId); + + // Expanded widget was deleted -- collapse + if (!widget) { + return collapse(); + } + + const count = getRuleCount(widget); + + // No rules -- keep expanded but reset index + if (count === 0) { + if (state.selectedRuleIndex !== 0) { + return { ...state, selectedRuleIndex: 0 }; + } + return state; + } + + // Clamp index if it exceeds the available rules + if (state.selectedRuleIndex >= count) { + return { ...state, selectedRuleIndex: count - 1 }; + } + + return state; +} + +// --- Hook interface --- + +export interface UseRuleAccordionOptions { + widgets: WidgetItem[]; + initialExpandedWidgetId?: string | null; + initialSelectedRuleIndex?: number; +} + +export interface UseRuleAccordionReturn { + expandedWidgetId: string | null; + selectedRuleIndex: number; + expand: (widgetId: string) => void; + collapse: () => void; + toggleExpand: (widgetId: string) => void; + selectRule: (index: number) => void; + selectPrevRule: () => void; + selectNextRule: () => void; + isExpanded: (widgetId: string) => boolean; + getRuleCount: (widget: WidgetItem) => number; +} + +// --- React hook --- + +export function useRuleAccordion({ + widgets, + initialExpandedWidgetId = null, + initialSelectedRuleIndex = 0 +}: UseRuleAccordionOptions): UseRuleAccordionReturn { + const [state, setState] = useState({ + expandedWidgetId: initialExpandedWidgetId, + selectedRuleIndex: initialSelectedRuleIndex + }); + + // Reconcile state when the widgets array changes externally + useEffect(() => { + setState(prev => reconcile(prev, widgets)); + }, [widgets]); + + const expandCb = useCallback((widgetId: string) => { + setState(expand(widgetId)); + }, []); + + const collapseCb = useCallback(() => { + setState(collapse()); + }, []); + + const toggleExpandCb = useCallback((widgetId: string) => { + setState(prev => toggleExpand(prev, widgetId)); + }, []); + + const selectRuleCb = useCallback((index: number) => { + setState(prev => selectRule(prev, index)); + }, []); + + const selectPrevRuleCb = useCallback(() => { + setState(prev => selectPrevRule(prev, widgets)); + }, [widgets]); + + const selectNextRuleCb = useCallback(() => { + setState(prev => selectNextRule(prev, widgets)); + }, [widgets]); + + const isExpandedCb = useCallback((widgetId: string) => { + return isExpanded(state, widgetId); + }, [state]); + + const getRuleCountCb = useCallback((widget: WidgetItem) => { + return getRuleCount(widget); + }, []); + + return { + expandedWidgetId: state.expandedWidgetId, + selectedRuleIndex: state.selectedRuleIndex, + expand: expandCb, + collapse: collapseCb, + toggleExpand: toggleExpandCb, + selectRule: selectRuleCb, + selectPrevRule: selectPrevRuleCb, + selectNextRule: selectNextRuleCb, + isExpanded: isExpandedCb, + getRuleCount: getRuleCountCb + }; +} From 1eade93fd4c6674389501b643d6db5cfc9163c29 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Apr 2026 16:49:00 +1000 Subject: [PATCH 42/52] feat: add ConditionEditor overlay component for rule condition editing Multi-step editor with widget type picker, operator selector filtered by value type, value input with type-aware validation, and negation toggle. Uses existing widget catalog picker pattern from ItemsEditor. --- src/tui/components/ConditionEditor.tsx | 561 +++++++++++++++++++++++++ 1 file changed, 561 insertions(+) create mode 100644 src/tui/components/ConditionEditor.tsx diff --git a/src/tui/components/ConditionEditor.tsx b/src/tui/components/ConditionEditor.tsx new file mode 100644 index 000000000..eccef1133 --- /dev/null +++ b/src/tui/components/ConditionEditor.tsx @@ -0,0 +1,561 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import { + OPERATOR_LABELS, + getConditionNot, + getConditionOperator, + getConditionValue, + getConditionWidget, + getOperatorsForValueType, + isBooleanOperator, + isExistenceOperator, + isNumericOperator, + isStringOperator, + type ConditionValueType, + type Operator +} from '../../types/Condition'; +import type { Settings } from '../../types/Settings'; +import { + filterWidgetCatalog, + getMatchSegments, + getWidget, + getWidgetCatalog, + getWidgetCatalogCategories +} from '../../utils/widgets'; + +import { + handlePickerInputMode, + normalizePickerState, + type WidgetPickerState +} from './items-editor/input-handlers'; + +export interface ConditionEditorProps { + widgetType: string; + condition: Record; + settings: Settings; + onSave: (condition: Record) => void; + onCancel: () => void; +} + +type EditorField = 'widget' | 'operator' | 'value'; + +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 [selectedWidget, setSelectedWidget] = useState(initialWidget); + const [operator, setOperator] = useState(initialOp); + const [valueInput, setValueInput] = useState(() => { + if (initialValue === null) { + return '50'; + } + return String(initialValue); + }); + const [notFlag, setNotFlag] = useState(initialNot); + const [field, setField] = useState('widget'); + const [widgetPicker, setWidgetPicker] = useState(null); + const [operatorIndex, setOperatorIndex] = useState(null); + + const widgetCatalog = getWidgetCatalog(settings); + const widgetCategories = ['All', ...getWidgetCatalogCategories(widgetCatalog)]; + + // Determine the value type for the selected widget + const getSelectedValueType = (): ConditionValueType => { + const targetType = selectedWidget === 'self' ? widgetType : selectedWidget; + const widgetImpl = getWidget(targetType); + return widgetImpl?.getValueType?.() ?? 'unknown'; + }; + + // Get available operators for the current widget's value type + const getAvailableOperators = (): Operator[] => { + return getOperatorsForValueType(getSelectedValueType()); + }; + + // Open widget picker + const openWidgetPicker = () => { + if (widgetCatalog.length === 0) { + return; + } + + setWidgetPicker(normalizePickerState({ + action: 'change', + level: 'category', + selectedCategory: 'All', + categoryQuery: '', + widgetQuery: '', + selectedType: selectedWidget === 'self' ? null : selectedWidget + }, widgetCatalog, widgetCategories)); + }; + + // Apply widget selection from picker + const applyWidgetPickerSelection = (selectedType: string) => { + setSelectedWidget(selectedType); + setWidgetPicker(null); + + // Reset operator if current one is incompatible with the new widget's value type + const widgetImpl = getWidget(selectedType); + const valueType: ConditionValueType = widgetImpl?.getValueType?.() ?? 'unknown'; + const available = getOperatorsForValueType(valueType); + if (!available.includes(operator)) { + const fallback = available[0]; + if (fallback) { + setOperator(fallback); + } + } + }; + + // Open operator picker (inline list) + const openOperatorPicker = () => { + const available = getAvailableOperators(); + const currentIndex = available.indexOf(operator); + setOperatorIndex(Math.max(0, currentIndex)); + }; + + // Get widget display name + const getWidgetDisplayName = (type: string): string => { + if (type === 'self') { + return 'This widget (self)'; + } + const widgetImpl = getWidget(type); + return widgetImpl ? widgetImpl.getDisplayName() : type; + }; + + // Check if value is valid for the current operator + const isValueValid = (): boolean => { + if (isExistenceOperator(operator)) { + return true; + } + if (isBooleanOperator(operator)) { + return valueInput.toLowerCase() === 'true' || valueInput.toLowerCase() === 'false'; + } + if (isNumericOperator(operator)) { + return !isNaN(Number(valueInput)) && valueInput.trim().length > 0; + } + // String operators + return valueInput.trim().length > 0; + }; + + // Build the condition record from current state + const buildCondition = (): Record | null => { + if (!isValueValid()) { + return null; + } + + const result: Record = {}; + + // Only include widget if it's not 'self' + if (selectedWidget !== 'self') { + result.widget = selectedWidget; + } + + // Include not flag if true + if (notFlag) { + result.not = true; + } + + // Set operator and value + if (isExistenceOperator(operator)) { + result[operator] = true; + } else if (isBooleanOperator(operator)) { + result[operator] = valueInput.toLowerCase() === 'true'; + } else if (isNumericOperator(operator)) { + result[operator] = Number(valueInput); + } else { + // String operator + result[operator] = valueInput.trim(); + } + + return result; + }; + + // Navigate between fields + const navigateField = (direction: 'left' | 'right') => { + const fields: EditorField[] = isExistenceOperator(operator) + ? ['widget', 'operator'] + : ['widget', 'operator', 'value']; + + const currentIndex = fields.indexOf(field); + if (direction === 'right') { + const nextIndex = (currentIndex + 1) % fields.length; + const nextField = fields[nextIndex]; + if (nextField) { + setField(nextField); + } + } else { + const nextIndex = (currentIndex - 1 + fields.length) % fields.length; + const nextField = fields[nextIndex]; + if (nextField) { + setField(nextField); + } + } + }; + + useInput((input, key) => { + // Widget picker takes over input + if (widgetPicker) { + handlePickerInputMode({ + input, + key, + widgetPicker, + widgetCatalog, + widgetCategories, + setWidgetPicker, + applyWidgetPickerSelection + }); + return; + } + + // Operator picker takes over input + if (operatorIndex !== null) { + const available = getAvailableOperators(); + + if (key.escape) { + setOperatorIndex(null); + } else if (key.upArrow) { + const newIndex = operatorIndex - 1 < 0 ? available.length - 1 : operatorIndex - 1; + setOperatorIndex(newIndex); + } else if (key.downArrow) { + const newIndex = operatorIndex + 1 >= available.length ? 0 : operatorIndex + 1; + setOperatorIndex(newIndex); + } else if (key.return) { + const selectedOp = available[operatorIndex]; + if (selectedOp) { + setOperator(selectedOp); + + // If switching to an existence operator, move field away from value + if (isExistenceOperator(selectedOp) && field === 'value') { + setField('operator'); + } + + // Reset value for boolean operators + if (isBooleanOperator(selectedOp) && !isBooleanOperator(operator)) { + setValueInput('true'); + } + } + setOperatorIndex(null); + } + return; + } + + // Main field-level input handling + if (key.escape) { + onCancel(); + return; + } + + if (key.return) { + const result = buildCondition(); + if (result) { + onSave(result); + } + return; + } + + // Negation toggle + if (input === 'n' && !key.ctrl && !key.meta && field !== 'value') { + setNotFlag(!notFlag); + return; + } + + if (key.leftArrow && field !== 'value') { + navigateField('left'); + return; + } + + if (key.rightArrow && field !== 'value') { + navigateField('right'); + return; + } + + if (key.tab) { + navigateField('right'); + return; + } + + // Field-specific input + if (field === 'widget') { + if (key.upArrow || key.downArrow) { + openWidgetPicker(); + } + } else if (field === 'operator') { + if (key.upArrow || key.downArrow) { + openOperatorPicker(); + } + } else { + // field === 'value' + if (key.leftArrow) { + navigateField('left'); + } else if (key.rightArrow) { + navigateField('right'); + } else if (key.backspace || key.delete) { + setValueInput(valueInput.slice(0, -1)); + } else if (isBooleanOperator(operator)) { + // Toggle true/false on any keypress + if (input === 't' || input === 'f') { + setValueInput(input === 't' ? 'true' : 'false'); + } + } else if (input && !key.ctrl && !key.meta) { + setValueInput(valueInput + input); + } + } + }); + + // --- Rendering --- + + const widgetLabel = getWidgetDisplayName(selectedWidget); + const opLabel = OPERATOR_LABELS[operator]; + const valid = isValueValid(); + + // Render operator picker overlay + if (operatorIndex !== null) { + const available = getAvailableOperators(); + + return ( + + + Select Operator + + + ↑↓ select, Enter apply, ESC cancel + + + {available.map((op, idx) => { + const isSelected = idx === operatorIndex; + const label = OPERATOR_LABELS[op]; + return ( + + {isSelected ? '▶ ' : ' '} + {label} + + ); + })} + + + ); + } + + // Render widget picker overlay + if (widgetPicker) { + const selectedPickerCategory = widgetPicker.selectedCategory ?? (widgetCategories[0] ?? 'All'); + const hasTopLevelSearch = widgetPicker.level === 'category' && widgetPicker.categoryQuery.trim().length > 0; + const topLevelSearchEntries = hasTopLevelSearch + ? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery) + : []; + const selectedTopLevelSearchEntry = topLevelSearchEntries.find( + entry => entry.type === widgetPicker.selectedType + ) ?? topLevelSearchEntries[0]; + const pickerEntries = filterWidgetCatalog( + widgetCatalog, + selectedPickerCategory, + widgetPicker.widgetQuery + ); + const selectedPickerEntry = pickerEntries.find( + entry => entry.type === widgetPicker.selectedType + ) ?? pickerEntries[0]; + + return ( + + + Select Widget to Evaluate + + + {widgetPicker.level === 'category' ? ( + <> + {hasTopLevelSearch ? ( + ↑↓ select widget match, Enter apply, ESC clear/cancel + ) : ( + ↑↓ select category, type to search all widgets, Enter continue, ESC cancel + )} + + Search: + {widgetPicker.categoryQuery || '(none)'} + + + ) : ( + <> + ↑↓ select widget, type to search widgets, Enter apply, ESC back + + + Category: + {' '} + {selectedPickerCategory} + {' '} + | Search: + {' '} + + {widgetPicker.widgetQuery || '(none)'} + + + )} + + + {widgetPicker.level === 'category' ? ( + hasTopLevelSearch ? ( + topLevelSearchEntries.length === 0 ? ( + No widgets match the search. + ) : ( + <> + {topLevelSearchEntries.map((entry, index) => { + const isSelected = entry.type === selectedTopLevelSearchEntry?.type; + const segments = getMatchSegments(entry.displayName, widgetPicker.categoryQuery); + return ( + + + + {isSelected ? '▶ ' : ' '} + + + {`${index + 1}. `} + {segments.map((seg, i) => ( + + {seg.text} + + ))} + + ); + })} + {selectedTopLevelSearchEntry && ( + + {selectedTopLevelSearchEntry.description} + + )} + + ) + ) : ( + widgetCategories.length === 0 ? ( + No categories available. + ) : ( + widgetCategories.map((category, index) => { + const filteredCategories = widgetCategories; + const isSelected = category === (widgetPicker.selectedCategory ?? filteredCategories[0]); + return ( + + + + {isSelected ? '▶ ' : ' '} + + + + {`${index + 1}. ${category}`} + + + ); + }) + ) + ) + ) : ( + pickerEntries.length === 0 ? ( + No widgets match the current category/search. + ) : ( + <> + {pickerEntries.map((entry, index) => { + const isSelected = entry.type === selectedPickerEntry?.type; + const segments = getMatchSegments(entry.displayName, widgetPicker.widgetQuery); + return ( + + + + {isSelected ? '▶ ' : ' '} + + + {`${index + 1}. `} + {segments.map((seg, i) => ( + + {seg.text} + + ))} + + ); + })} + {selectedPickerEntry && ( + + {selectedPickerEntry.description} + + )} + + ) + )} + + + ); + } + + // Render main condition editor + const valueType = getSelectedValueType(); + const fieldHints: Record = { + widget: '↑↓ open widget picker, ←→/Tab switch field, (n)egate, Enter save, ESC cancel', + operator: '↑↓ open operator picker, ←→/Tab switch field, (n)egate, Enter save, ESC cancel', + value: isStringOperator(operator) + ? 'type text, ←→ switch field, Tab switch field, Enter save, ESC cancel' + : isBooleanOperator(operator) + ? '(t)rue/(f)alse, ←→ switch field, Tab switch field, Enter save, ESC cancel' + : 'type number, ←→ switch field, Tab switch field, Enter save, ESC cancel' + }; + + return ( + + + Edit Condition + {notFlag && [NEGATED]} + + + + when + + {widgetLabel} + + + + {opLabel} + + {!isExistenceOperator(operator) && ( + <> + + + {isStringOperator(operator) && `"${valueInput || '(empty)'}"`} + {isBooleanOperator(operator) && (valueInput || '(empty)')} + {isNumericOperator(operator) && (valueInput || '(empty)')} + + + )} + {!valid && (invalid)} + + + + + {fieldHints[field]} + + + + + + {'Value type: '} + {valueType} + {' | Operators available: '} + {getAvailableOperators().length} + + + + ); +}; From 4f56a08a2a93bd19d327bada3096c2962f9a07d0 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 11:50:23 +1000 Subject: [PATCH 43/52] test(condition): drop Operator assertions the newer eslint rejects The upstream dev-dependency bumps carried in from feat/tab-swap-editors ship a typescript-eslint that now sees these `as Operator` casts as no-ops, so they trip no-unnecessary-type-assertion. The literals were already assignable to Operator; dropping the casts keeps the same tests. --- src/types/__tests__/Condition.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/types/__tests__/Condition.test.ts b/src/types/__tests__/Condition.test.ts index 091dffc33..306ec80cb 100644 --- a/src/types/__tests__/Condition.test.ts +++ b/src/types/__tests__/Condition.test.ts @@ -195,8 +195,8 @@ describe('Operator type guards', () => { }); it('isNumericOperator rejects non-numeric operators', () => { - expect(isNumericOperator('contains' as Operator)).toBe(false); - expect(isNumericOperator('isNull' as Operator)).toBe(false); + expect(isNumericOperator('contains')).toBe(false); + expect(isNumericOperator('isNull')).toBe(false); }); it('isStringOperator identifies string operators', () => { @@ -207,8 +207,8 @@ describe('Operator type guards', () => { }); it('isStringOperator rejects non-string operators', () => { - expect(isStringOperator('greaterThan' as Operator)).toBe(false); - expect(isStringOperator('isNotNull' as Operator)).toBe(false); + expect(isStringOperator('greaterThan')).toBe(false); + expect(isStringOperator('isNotNull')).toBe(false); }); it('isBooleanOperator identifies boolean operators', () => { @@ -216,8 +216,8 @@ describe('Operator type guards', () => { }); it('isBooleanOperator rejects non-boolean operators', () => { - expect(isBooleanOperator('contains' as Operator)).toBe(false); - expect(isBooleanOperator('isNull' as Operator)).toBe(false); + expect(isBooleanOperator('contains')).toBe(false); + expect(isBooleanOperator('isNull')).toBe(false); }); it('isExistenceOperator identifies existence operators', () => { @@ -226,8 +226,8 @@ describe('Operator type guards', () => { }); it('isExistenceOperator rejects non-existence operators', () => { - expect(isExistenceOperator('equals' as Operator)).toBe(false); - expect(isExistenceOperator('contains' as Operator)).toBe(false); + expect(isExistenceOperator('equals')).toBe(false); + expect(isExistenceOperator('contains')).toBe(false); }); it('equals is classified as numeric, string, and boolean', () => { From be8e980b7de8dea10263e41ec52e39b9d5d4278f Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 12:27:14 +1000 Subject: [PATCH 44/52] feat(items-editor): rebuild the rules accordion on the shared widget row Reinstates rule browsing in the widget editor, rewritten against the WidgetRow substrate that replaced the SelectInput list. Rules show as a count badge on the widget row and expand into sub-rows spelling out each condition, the properties it overrides and whether it stops rule matching. RuleRow mirrors WidgetRow: a shared row renderer plus the pure formatters behind it, so the colour editor can render rules identically when it is rebuilt. The accordion opens on (R), not the (x) the earlier implementation used - upstream has since bound (x) to auto-align exclusion, and the editor always supplies the accordion callback, so keeping (x) would have silently shadowed that. A regression test covers the pairing, since the existing exclude-align test omits the callback and stayed green while the running app would have broken. --- src/tui/components/ItemsEditor.tsx | 69 +++++++-- src/tui/components/RuleRow.tsx | 77 ++++++++++ src/tui/components/WidgetRow.tsx | 25 +++- .../components/__tests__/ItemsEditor.test.tsx | 135 +++++++++++++++++- .../components/__tests__/widget-row.test.ts | 33 ++++- .../__tests__/input-handlers.test.ts | 81 +++++++++++ .../components/items-editor/input-handlers.ts | 45 +++++- 7 files changed, 445 insertions(+), 20 deletions(-) create mode 100644 src/tui/components/RuleRow.tsx diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 34ee45aae..3d5a8cf81 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -26,18 +26,22 @@ import { getWidgetCatalog, getWidgetCatalogCategories } from '../../utils/widgets'; +import { useRuleAccordion } from '../hooks/useRuleAccordion'; import { ConfirmDialog } from './ConfirmDialog'; +import { RuleRow } from './RuleRow'; import { WidgetRow, getWidgetRowLabel, getWidgetRowTags, + getWidgetRuleBadge, styleWidgetRowLabel } from './WidgetRow'; import { handleMoveInputMode, handleNormalInputMode, handlePickerInputMode, + handleRuleInputMode, normalizePickerState, type CustomEditorWidgetState, type WidgetPickerAction, @@ -75,6 +79,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB const [customEditorWidget, setCustomEditorWidget] = useState(null); const [widgetPicker, setWidgetPicker] = useState(null); const [showClearConfirm, setShowClearConfirm] = useState(false); + const accordion = useRuleAccordion({ widgets }); const separatorChars = ['|', '-', ',', ' ']; useEffect(() => { @@ -255,6 +260,16 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB return; } + if (accordion.expandedWidgetId !== null + && handleRuleInputMode({ + key, + onPrevRule: accordion.selectPrevRule, + onNextRule: accordion.selectNextRule, + onCollapse: accordion.collapse + })) { + return; + } + handleNormalInputMode({ input, key, @@ -271,7 +286,8 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB getCustomKeybindsForWidget, setCustomEditorWidget, getUniqueBackgroundColor, - onTabSwap + onTabSwap, + onToggleAccordion: accordion.toggleExpand }); }); @@ -320,7 +336,10 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB if (isColorable && onTabSwap) { helpText += ', ⇥ edit colors'; } - helpText += ', ESC back'; + if (hasWidgets) { + helpText += ', (R)ules'; + } + helpText += accordion.expandedWidgetId !== null ? ', ESC collapse' : ', ESC back'; // Build custom keybinds text const customKeybindsText = customKeybinds.map(kb => kb.label).join(', '); @@ -557,21 +576,41 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {widgets.map((widget, index) => { const isSelected = index === selectedIndex; const { displayText, modifierText } = getWidgetRowLabel(widget); + const expanded = accordion.isExpanded(widget.id); return ( - + + + {expanded && ( + + {(widget.rules ?? []).length === 0 ? ( + + No rules + + ) : ( + widget.rules?.map((rule, ruleIndex) => ( + + )) + )} + + )} + ); })} {/* Display description for selected widget */} diff --git a/src/tui/components/RuleRow.tsx b/src/tui/components/RuleRow.tsx new file mode 100644 index 000000000..5b36af763 --- /dev/null +++ b/src/tui/components/RuleRow.tsx @@ -0,0 +1,77 @@ +import { + Box, + Text +} from 'ink'; +import React from 'react'; + +import { + OPERATOR_LABELS, + getConditionNot, + getConditionOperator, + getConditionValue, + getConditionWidget, + isExistenceOperator +} from '../../types/Condition'; +import type { + Rule, + RuleApply +} from '../../types/Widget'; + +/** The human-readable form of a rule's condition, e.g. "when context-percentage greater than 80". */ +export function formatConditionText(when: Record): string { + const widget = getConditionWidget(when); + const operator = getConditionOperator(when); + + if (!operator) { + return 'when (invalid condition)'; + } + + const prefix = getConditionNot(when) ? `when ${widget} NOT` : `when ${widget}`; + const operatorLabel = OPERATOR_LABELS[operator]; + + if (isExistenceOperator(operator)) { + return `${prefix} ${operatorLabel}`; + } + + return `${prefix} ${operatorLabel} ${String(getConditionValue(when))}`; +} + +/** The properties a rule overrides, e.g. "color: red, bold: true". Empty when it overrides nothing. */ +export function formatApplyProperties(apply: RuleApply): string { + // Typed as unknown because a settings file can carry a key that is present but undefined, + // which the inferred RuleApply type does not admit. + const entries: [string, unknown][] = Object.entries(apply); + + return entries + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}: ${String(value)}`) + .join(', '); +} + +export interface RuleRowProps { + rule: Rule; + isSelected: boolean; +} + +/** One rule beneath its widget in the accordion, shown the same way in both editor modes. */ +export const RuleRow: React.FC = ({ rule, isSelected }) => { + const color = isSelected ? 'cyan' : 'gray'; + const applyText = formatApplyProperties(rule.apply); + + return ( + + {isSelected ? '› ' : ' '} + {formatConditionText(rule.when)} + {applyText && ( + + {` -> ${applyText}`} + + )} + {rule.stop && ( + + {' [STOP]'} + + )} + + ); +}; diff --git a/src/tui/components/WidgetRow.tsx b/src/tui/components/WidgetRow.tsx index 5403b405f..8c3da1195 100644 --- a/src/tui/components/WidgetRow.tsx +++ b/src/tui/components/WidgetRow.tsx @@ -29,6 +29,8 @@ export interface WidgetRowProps { selectionColor?: string; modifierText?: string; tags?: string[]; + /** Feature marker shown after the tags, e.g. the rule count. */ + badge?: string | null; } function isMergedIntoPreviousWidget(widgets: WidgetItem[], index: number): boolean { @@ -110,6 +112,20 @@ export function getWidgetRowTags(widgets: WidgetItem[], index: number, settings: return tags; } +/** + * The rule-count badge shown after a row label in both editor modes. Rules are a feature + * a widget carries rather than a structural marker, so this reads separately from the dim + * tags and renders in its own colour. Null when the widget has no rules. + */ +export function getWidgetRuleBadge(widget: WidgetItem): string | null { + const count = widget.rules?.length ?? 0; + if (count === 0) { + return null; + } + + return `[${count} ${count === 1 ? 'rule' : 'rules'}]`; +} + /** * Paint a row label with the colour that widget renders in, so both editor modes agree. * themeChannels supplies the channels an active theme takes over; the widget's own colour @@ -147,7 +163,8 @@ export const WidgetRow: React.FC = ({ indicator = '▶', selectionColor = 'green', modifierText, - tags = [] + tags = [], + badge }) => { const highlightColor = isSelected ? selectionColor : undefined; @@ -170,6 +187,12 @@ export const WidgetRow: React.FC = ({ {tag} ))} + {badge && ( + + {' '} + {badge} + + )} ); }; diff --git a/src/tui/components/__tests__/ItemsEditor.test.tsx b/src/tui/components/__tests__/ItemsEditor.test.tsx index 19aef6b8f..405fff51e 100644 --- a/src/tui/components/__tests__/ItemsEditor.test.tsx +++ b/src/tui/components/__tests__/ItemsEditor.test.tsx @@ -64,6 +64,17 @@ function flushInk() { }); } +/** + * Ink re-registers its input handler from a passive effect, so a key sent right after a key + * that changed the editor's mode is still handled by the previous render's closure. Sequences + * that press a mode key and then act inside that mode have to let the effect flush first. + */ +async function settleInputHandler() { + for (let i = 0; i < 10; i++) { + await flushInk(); + } +} + const THEMED_SETTINGS = { ...DEFAULT_SETTINGS, colorLevel: 3 as const, @@ -78,11 +89,13 @@ async function renderItemsEditor(widgets: WidgetItem[], settings: Settings = DEF const stdin = createMockStdin(); const stdout = createMockStdout(); const stderr = createMockStdout(); + const onUpdate = vi.fn(); + const onBack = vi.fn(); const instance = render( React.createElement(ItemsEditor, { widgets, - onUpdate: vi.fn(), - onBack: vi.fn(), + onUpdate, + onBack, lineNumber: 1, settings }), @@ -100,6 +113,8 @@ async function renderItemsEditor(widgets: WidgetItem[], settings: Settings = DEF return { stdin, + onUpdate, + onBack, getOutput: () => stdout.getOutput(), output: stdout.getOutput(), teardown: () => { @@ -225,4 +240,120 @@ describe('ItemsEditor', () => { teardown(); } }); + + it('badges a row with how many rules the widget carries', async () => { + const { output, teardown } = await renderItemsEditor([ + { + id: '1', + type: 'model', + rules: [ + { when: { widget: 'context-percentage', greaterThan: 80 }, apply: { color: 'red' } }, + { when: { widget: 'context-percentage', greaterThan: 90 }, apply: { bold: true } } + ] + }, + { id: '2', type: 'git-branch' } + ]); + + try { + expect(output).toContain('[2 rules]'); + } finally { + teardown(); + } + }); + + it('expands the selected widget to show its rules', async () => { + const { stdin, getOutput, teardown } = await renderItemsEditor([ + { + id: '1', + type: 'model', + rules: [{ when: { widget: 'context-percentage', greaterThan: 80 }, apply: { color: 'red' } }] + } + ]); + + try { + expect(getOutput()).not.toContain('when context-percentage greater than 80'); + + stdin.write('R'); + await flushInk(); + + expect(getOutput()).toContain('when context-percentage greater than 80'); + } finally { + teardown(); + } + }); + + it('says so when an expanded widget has no rules yet', async () => { + const { stdin, getOutput, teardown } = await renderItemsEditor([{ id: '1', type: 'model' }]); + + try { + stdin.write('R'); + await flushInk(); + + expect(getOutput()).toContain('No rules'); + } finally { + teardown(); + } + }); + + it('moves the rule selection with the arrows while expanded', async () => { + const { stdin, getOutput, teardown } = await renderItemsEditor([ + { + id: '1', + type: 'model', + rules: [ + { when: { widget: 'context-percentage', greaterThan: 80 }, apply: { color: 'red' } }, + { when: { widget: 'context-percentage', greaterThan: 90 }, apply: { bold: true } } + ] + } + ]); + + try { + stdin.write('R'); + await settleInputHandler(); + stdin.write(''); // down arrow + await flushInk(); + + expect(getOutput()).toContain('› when context-percentage greater than 90'); + } finally { + teardown(); + } + }); + + it('collapses on escape instead of leaving the editor', async () => { + const { stdin, getOutput, onBack, teardown } = await renderItemsEditor([ + { + id: '1', + type: 'model', + rules: [{ when: { widget: 'context-percentage', greaterThan: 80 }, apply: { color: 'red' } }] + } + ]); + + try { + stdin.write('R'); + await settleInputHandler(); + + stdin.write('\x1B'); // escape + await settleInputHandler(); + + expect(onBack).not.toHaveBeenCalled(); + expect(getOutput().split('1. Model').at(-1)).not.toContain('when context-percentage greater than 80'); + } finally { + teardown(); + } + }); + + it('advertises the rules key, and the rule keys once expanded', async () => { + const { stdin, getOutput, teardown } = await renderItemsEditor([{ id: '1', type: 'model' }]); + + try { + expect(getOutput()).toContain('(R)ules'); + + stdin.write('R'); + await settleInputHandler(); + + expect(getOutput()).toContain('ESC collapse'); + } finally { + teardown(); + } + }); }); diff --git a/src/tui/components/__tests__/widget-row.test.ts b/src/tui/components/__tests__/widget-row.test.ts index f27b8401c..e9b2c1491 100644 --- a/src/tui/components/__tests__/widget-row.test.ts +++ b/src/tui/components/__tests__/widget-row.test.ts @@ -8,7 +8,8 @@ import { DEFAULT_SETTINGS } from '../../../types/Settings'; import type { WidgetItem } from '../../../types/Widget'; import { getWidgetRowLabel, - getWidgetRowTags + getWidgetRowTags, + getWidgetRuleBadge } from '../WidgetRow'; const AUTO_ALIGN_SETTINGS = { @@ -121,3 +122,33 @@ describe('getWidgetRowTags', () => { expect(getWidgetRowTags(widgets, 1, AUTO_ALIGN_SETTINGS)).toEqual([]); }); }); + +describe('getWidgetRuleBadge', () => { + it('has no badge for a widget without rules', () => { + expect(getWidgetRuleBadge({ id: '1', type: 'model' })).toBeNull(); + expect(getWidgetRuleBadge({ id: '1', type: 'model', rules: [] })).toBeNull(); + }); + + it('counts a single rule in the singular', () => { + const widget: WidgetItem = { + id: '1', + type: 'model', + rules: [{ when: { widget: 'context-percentage', greaterThan: 80 }, apply: { color: 'red' } }] + }; + + expect(getWidgetRuleBadge(widget)).toBe('[1 rule]'); + }); + + it('counts multiple rules in the plural', () => { + const widget: WidgetItem = { + id: '1', + type: 'model', + rules: [ + { when: { widget: 'context-percentage', greaterThan: 80 }, apply: { color: 'red' } }, + { when: { widget: 'context-percentage', greaterThan: 90 }, apply: { bold: true } } + ] + }; + + expect(getWidgetRuleBadge(widget)).toBe('[2 rules]'); + }); +}); 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 7ed1f5413..94cafff1f 100644 --- a/src/tui/components/items-editor/__tests__/input-handlers.test.ts +++ b/src/tui/components/items-editor/__tests__/input-handlers.test.ts @@ -11,6 +11,7 @@ import { handleMoveInputMode, handleNormalInputMode, handlePickerInputMode, + handleRuleInputMode, normalizePickerState, type WidgetPickerState } from '../input-handlers'; @@ -1111,3 +1112,83 @@ describe('items-editor input handlers', () => { }); }); }); + +describe('rules accordion shortcut', () => { + function normalModeArgs(overrides: Partial[0]>) { + return { + input: '', + key: {}, + widgets: [{ id: '1', type: 'tokens-input' }] as WidgetItem[], + selectedIndex: 0, + canExcludeAlign: true, + separatorChars: ['|', '-'], + onBack: vi.fn(), + onUpdate: vi.fn(), + setSelectedIndex: vi.fn(), + setMoveMode: vi.fn(), + setShowClearConfirm: vi.fn(), + openWidgetPicker: vi.fn(), + getCustomKeybindsForWidget: () => [], + setCustomEditorWidget: vi.fn(), + ...overrides + }; + } + + it('opens the accordion on R', () => { + const onToggleAccordion = vi.fn(); + + handleNormalInputMode(normalModeArgs({ input: 'R', onToggleAccordion })); + + expect(onToggleAccordion).toHaveBeenCalledWith('1'); + }); + + // The accordion shortcut has to stay clear of (x), which already toggles auto-align + // exclusion. The editor always supplies onToggleAccordion, so a clash here would only + // show up in the running app. + it('leaves x toggling auto-align exclusion while the accordion is wired up', () => { + const onUpdate = vi.fn(); + const onToggleAccordion = vi.fn(); + + handleNormalInputMode(normalModeArgs({ input: 'x', onUpdate, onToggleAccordion })); + + const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; + expect(updated?.[0]?.excludeFromAutoAlign).toBe(true); + expect(onToggleAccordion).not.toHaveBeenCalled(); + }); +}); + +describe('handleRuleInputMode', () => { + function createRuleHandlers() { + return { + onPrevRule: vi.fn(), + onNextRule: vi.fn(), + onCollapse: vi.fn() + }; + } + + it('moves the rule selection on the arrows and reports the key consumed', () => { + const handlers = createRuleHandlers(); + + expect(handleRuleInputMode({ key: { downArrow: true }, ...handlers })).toBe(true); + expect(handlers.onNextRule).toHaveBeenCalledTimes(1); + + expect(handleRuleInputMode({ key: { upArrow: true }, ...handlers })).toBe(true); + expect(handlers.onPrevRule).toHaveBeenCalledTimes(1); + }); + + it('collapses the accordion on escape rather than leaving the editor', () => { + const handlers = createRuleHandlers(); + + expect(handleRuleInputMode({ key: { escape: true }, ...handlers })).toBe(true); + expect(handlers.onCollapse).toHaveBeenCalledTimes(1); + }); + + it('leaves other keys to the widget-level handler', () => { + const handlers = createRuleHandlers(); + + expect(handleRuleInputMode({ key: { return: true }, ...handlers })).toBe(false); + expect(handlers.onPrevRule).not.toHaveBeenCalled(); + expect(handlers.onNextRule).not.toHaveBeenCalled(); + expect(handlers.onCollapse).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index 1996b624a..11295cd01 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -333,6 +333,42 @@ export function handleMoveInputMode({ } } +export interface HandleRuleInputModeArgs { + key: InputKey; + onPrevRule: () => void; + onNextRule: () => void; + onCollapse: () => void; +} + +/** + * Input for a widget whose rules accordion is open. The rule list takes over the arrows + * while it is expanded, so this reports whether it consumed the key and the caller skips + * the widget-level handler when it did. + */ +export function handleRuleInputMode({ + key, + onPrevRule, + onNextRule, + onCollapse +}: HandleRuleInputModeArgs): boolean { + if (key.escape) { + onCollapse(); + return true; + } + + if (key.upArrow) { + onPrevRule(); + return true; + } + + if (key.downArrow) { + onNextRule(); + return true; + } + + return false; +} + export interface HandleNormalInputModeArgs { input: string; key: InputKey; @@ -350,6 +386,7 @@ export interface HandleNormalInputModeArgs { setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void; getUniqueBackgroundColor?: (insertIndex: number) => string | undefined; onTabSwap?: () => void; + onToggleAccordion?: (widgetId: string) => void; } export function handleNormalInputMode({ @@ -368,7 +405,8 @@ export function handleNormalInputMode({ getCustomKeybindsForWidget, setCustomEditorWidget, getUniqueBackgroundColor, - onTabSwap + onTabSwap, + onToggleAccordion }: HandleNormalInputModeArgs): void { if (key.upArrow && widgets.length > 0) { setSelectedIndex(selectedIndex - 1 < 0 ? widgets.length - 1 : selectedIndex - 1); @@ -380,6 +418,11 @@ export function handleNormalInputMode({ openWidgetPicker('change'); } else if (key.return && widgets.length > 0) { setMoveMode(true); + } else if (input === 'R' && onToggleAccordion && widgets.length > 0) { + const widget = widgets[selectedIndex]; + if (widget) { + onToggleAccordion(widget.id); + } } else if (input === 'a') { openWidgetPicker('add'); } else if (input === 'i') { From b3d3e17a0416f6caa02a7331841c33b2525ab17a Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 12:47:29 +1000 Subject: [PATCH 45/52] feat(items-editor): add, edit and reorder rules from the accordion Completes rule authoring in the widget editor. While the accordion is open the rule list owns the keyboard: (a)dd, (d)elete, (s)top, (j)/(k) reorder, and (e) or Enter to open the condition editor. The help line switches to the rule keys and lists only what applies at the current rule count. Deleting the last rule leaves the accordion open on the empty state rather than collapsing, as the earlier implementation did, so the widget can be given a fresh rule without reopening it. The empty state already exists for expanding a widget that has no rules. ConditionEditor renders as an overlay the way a custom widget editor does, and owns input while it is up. --- src/tui/components/ItemsEditor.tsx | 111 +++++++++++---- .../components/__tests__/ItemsEditor.test.tsx | 39 ++++++ .../__tests__/input-handlers.test.ts | 129 +++++++++++++++++- .../components/items-editor/input-handlers.ts | 86 +++++++++++- 4 files changed, 332 insertions(+), 33 deletions(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 3d5a8cf81..cd953cf44 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -26,8 +26,12 @@ import { getWidgetCatalog, getWidgetCatalogCategories } from '../../utils/widgets'; -import { useRuleAccordion } from '../hooks/useRuleAccordion'; +import { + getRuleCount, + useRuleAccordion +} from '../hooks/useRuleAccordion'; +import { ConditionEditor } from './ConditionEditor'; import { ConfirmDialog } from './ConfirmDialog'; import { RuleRow } from './RuleRow'; import { @@ -80,6 +84,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB const [widgetPicker, setWidgetPicker] = useState(null); const [showClearConfirm, setShowClearConfirm] = useState(false); const accordion = useRuleAccordion({ widgets }); + const [editingRuleIndex, setEditingRuleIndex] = useState(null); const separatorChars = ['|', '-', ',', ' ']; useEffect(() => { @@ -230,6 +235,11 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB return; } + // Skip input handling while a rule condition is being edited - ConditionEditor owns it + if (editingRuleIndex !== null) { + return; + } + // Skip input handling when clear confirmation is active - let ConfirmDialog handle it if (showClearConfirm) { return; @@ -262,10 +272,17 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB if (accordion.expandedWidgetId !== null && handleRuleInputMode({ + input, key, + widgets, + selectedIndex, + selectedRuleIndex: accordion.selectedRuleIndex, onPrevRule: accordion.selectPrevRule, onNextRule: accordion.selectNextRule, - onCollapse: accordion.collapse + onCollapse: accordion.collapse, + onUpdate, + onSelectRule: accordion.selectRule, + onEditCondition: setEditingRuleIndex })) { return; } @@ -314,32 +331,47 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB ? (pickerEntries.find(entry => entry.type === widgetPicker.selectedType) ?? pickerEntries[0]) : null; - // 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, (k) clone, (d)elete, (c)lear line'; - } - if (canToggleRaw) { - helpText += ', (r)aw value'; - } - if (canMerge) { - helpText += ', (m)erge'; - } - if (canExcludeAlign) { - helpText += ', e(x)clude align'; - } - if (isColorable && onTabSwap) { - helpText += ', ⇥ edit colors'; - } - if (hasWidgets) { - helpText += ', (R)ules'; + // Build main help text (without custom keybinds). While the accordion is open the rule + // list owns the keyboard, so it describes its own keys rather than the widget-level ones. + let helpText: string; + if (accordion.expandedWidgetId !== null) { + const ruleCount = currentWidget ? getRuleCount(currentWidget) : 0; + const ruleHelpParts = ['↑↓ select rule', '(a)dd']; + if (ruleCount > 0) { + ruleHelpParts.push('(d)elete', '(s)top', '(e)dit/Enter condition'); + } + if (ruleCount > 1) { + ruleHelpParts.push('(j)/(k) reorder'); + } + ruleHelpParts.push('ESC collapse'); + helpText = ruleHelpParts.join(', '); + } else { + 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, (k) clone, (d)elete, (c)lear line'; + } + if (canToggleRaw) { + helpText += ', (r)aw value'; + } + if (canMerge) { + helpText += ', (m)erge'; + } + if (canExcludeAlign) { + helpText += ', e(x)clude align'; + } + if (isColorable && onTabSwap) { + helpText += ', ⇥ edit colors'; + } + if (hasWidgets) { + helpText += ', (R)ules'; + } + helpText += ', ESC back'; } - helpText += accordion.expandedWidgetId !== null ? ', ESC collapse' : ', ESC back'; // Build custom keybinds text const customKeybindsText = customKeybinds.map(kb => kb.label).join(', '); @@ -349,6 +381,31 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB ? 'Insert Widget' : 'Change Widget Type'; + // A rule condition is edited in its own overlay, the same way a custom widget editor is + if (editingRuleIndex !== null && currentWidget) { + const rule = currentWidget.rules?.[editingRuleIndex]; + if (rule) { + return ( + { + const newRules = [...(currentWidget.rules ?? [])]; + newRules[editingRuleIndex] = { ...rule, when: condition }; + const newWidgets = [...widgets]; + newWidgets[selectedIndex] = { ...currentWidget, rules: newRules }; + onUpdate(newWidgets); + setEditingRuleIndex(null); + }} + onCancel={() => { + setEditingRuleIndex(null); + }} + /> + ); + } + } + // If custom editor is active, render it instead of the normal UI if (customEditorWidget?.impl.renderEditor) { return customEditorWidget.impl.renderEditor({ diff --git a/src/tui/components/__tests__/ItemsEditor.test.tsx b/src/tui/components/__tests__/ItemsEditor.test.tsx index 405fff51e..9171de4c3 100644 --- a/src/tui/components/__tests__/ItemsEditor.test.tsx +++ b/src/tui/components/__tests__/ItemsEditor.test.tsx @@ -342,6 +342,45 @@ describe('ItemsEditor', () => { } }); + it('adds a rule to the selected widget from the accordion', async () => { + const { stdin, onUpdate, teardown } = await renderItemsEditor([{ id: '1', type: 'model' }]); + + try { + stdin.write('R'); + await settleInputHandler(); + + stdin.write('a'); + await settleInputHandler(); + + const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; + expect(updated?.[0]?.rules).toEqual([{ when: {}, apply: {} }]); + } finally { + teardown(); + } + }); + + it('opens the condition editor for the selected rule', async () => { + const { stdin, getOutput, teardown } = await renderItemsEditor([ + { + id: '1', + type: 'model', + rules: [{ when: { widget: 'context-percentage', greaterThan: 80 }, apply: { color: 'red' } }] + } + ]); + + try { + stdin.write('R'); + await settleInputHandler(); + + stdin.write('e'); + await settleInputHandler(); + + expect(getOutput()).toContain('Edit Condition'); + } finally { + teardown(); + } + }); + it('advertises the rules key, and the rule keys once expanded', async () => { const { stdin, getOutput, teardown } = await renderItemsEditor([{ id: '1', type: 'model' }]); 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 94cafff1f..adba60d1b 100644 --- a/src/tui/components/items-editor/__tests__/input-handlers.test.ts +++ b/src/tui/components/items-editor/__tests__/input-handlers.test.ts @@ -1162,31 +1162,150 @@ describe('handleRuleInputMode', () => { return { onPrevRule: vi.fn(), onNextRule: vi.fn(), - onCollapse: vi.fn() + onCollapse: vi.fn(), + onUpdate: vi.fn(), + onSelectRule: vi.fn(), + onEditCondition: vi.fn() + }; + } + + function ruleArgs(overrides: Record = {}) { + return { + input: '', + key: {}, + widgets: [{ + id: '1', + type: 'model', + rules: [ + { when: { widget: 'context-percentage', greaterThan: 80 }, apply: { color: 'red' } }, + { when: { widget: 'context-percentage', greaterThan: 90 }, apply: { bold: true } } + ] + }] as WidgetItem[], + selectedIndex: 0, + selectedRuleIndex: 0, + ...createRuleHandlers(), + ...overrides }; } + function updatedRules(onUpdate: ReturnType): WidgetItem['rules'] { + const widgets = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; + return widgets?.[0]?.rules; + } + + it('adds a rule after the selected one and moves onto it', () => { + const handlers = createRuleHandlers(); + const args = ruleArgs({ input: 'a', ...handlers }); + + expect(handleRuleInputMode(args)).toBe(true); + + expect(updatedRules(handlers.onUpdate)).toHaveLength(3); + expect(updatedRules(handlers.onUpdate)?.[1]).toEqual({ when: {}, apply: {} }); + expect(handlers.onSelectRule).toHaveBeenCalledWith(1); + }); + + it('adds the first rule to a widget that has none', () => { + const handlers = createRuleHandlers(); + const args = ruleArgs({ + input: 'a', + widgets: [{ id: '1', type: 'model' }] as WidgetItem[], + ...handlers + }); + + expect(handleRuleInputMode(args)).toBe(true); + expect(updatedRules(handlers.onUpdate)).toHaveLength(1); + expect(handlers.onSelectRule).toHaveBeenCalledWith(0); + }); + + it('deletes the selected rule and clamps the selection', () => { + const handlers = createRuleHandlers(); + const args = ruleArgs({ input: 'd', selectedRuleIndex: 1, ...handlers }); + + expect(handleRuleInputMode(args)).toBe(true); + + expect(updatedRules(handlers.onUpdate)).toHaveLength(1); + expect(updatedRules(handlers.onUpdate)?.[0]?.apply).toEqual({ color: 'red' }); + expect(handlers.onSelectRule).toHaveBeenCalledWith(0); + }); + + it('stays expanded when the last rule is deleted', () => { + const handlers = createRuleHandlers(); + const args = ruleArgs({ + input: 'd', + widgets: [{ + id: '1', + type: 'model', + rules: [{ when: {}, apply: {} }] + }] as WidgetItem[], + ...handlers + }); + + expect(handleRuleInputMode(args)).toBe(true); + expect(updatedRules(handlers.onUpdate)).toHaveLength(0); + expect(handlers.onCollapse).not.toHaveBeenCalled(); + }); + + it('toggles the stop flag on and back off', () => { + const on = createRuleHandlers(); + expect(handleRuleInputMode(ruleArgs({ input: 's', ...on }))).toBe(true); + expect(updatedRules(on.onUpdate)?.[0]?.stop).toBe(true); + + const off = createRuleHandlers(); + handleRuleInputMode(ruleArgs({ + input: 's', + widgets: [{ + id: '1', + type: 'model', + rules: [{ when: {}, apply: {}, stop: true }] + }] as WidgetItem[], + ...off + })); + expect(updatedRules(off.onUpdate)?.[0]?.stop).toBeUndefined(); + }); + + it('opens the condition editor on (e) and on Enter', () => { + const viaKey = createRuleHandlers(); + expect(handleRuleInputMode(ruleArgs({ input: 'e', selectedRuleIndex: 1, ...viaKey }))).toBe(true); + expect(viaKey.onEditCondition).toHaveBeenCalledWith(1); + + const viaEnter = createRuleHandlers(); + expect(handleRuleInputMode(ruleArgs({ key: { return: true }, selectedRuleIndex: 1, ...viaEnter }))).toBe(true); + expect(viaEnter.onEditCondition).toHaveBeenCalledWith(1); + }); + + it('reorders rules with (j) and (k), following the moved rule', () => { + const down = createRuleHandlers(); + expect(handleRuleInputMode(ruleArgs({ input: 'j', ...down }))).toBe(true); + expect(updatedRules(down.onUpdate)?.[0]?.apply).toEqual({ bold: true }); + expect(down.onSelectRule).toHaveBeenCalledWith(1); + + const up = createRuleHandlers(); + expect(handleRuleInputMode(ruleArgs({ input: 'k', selectedRuleIndex: 1, ...up }))).toBe(true); + expect(updatedRules(up.onUpdate)?.[0]?.apply).toEqual({ bold: true }); + expect(up.onSelectRule).toHaveBeenCalledWith(0); + }); + it('moves the rule selection on the arrows and reports the key consumed', () => { const handlers = createRuleHandlers(); - expect(handleRuleInputMode({ key: { downArrow: true }, ...handlers })).toBe(true); + expect(handleRuleInputMode(ruleArgs({ key: { downArrow: true }, ...handlers }))).toBe(true); expect(handlers.onNextRule).toHaveBeenCalledTimes(1); - expect(handleRuleInputMode({ key: { upArrow: true }, ...handlers })).toBe(true); + expect(handleRuleInputMode(ruleArgs({ key: { upArrow: true }, ...handlers }))).toBe(true); expect(handlers.onPrevRule).toHaveBeenCalledTimes(1); }); it('collapses the accordion on escape rather than leaving the editor', () => { const handlers = createRuleHandlers(); - expect(handleRuleInputMode({ key: { escape: true }, ...handlers })).toBe(true); + expect(handleRuleInputMode(ruleArgs({ key: { escape: true }, ...handlers }))).toBe(true); expect(handlers.onCollapse).toHaveBeenCalledTimes(1); }); it('leaves other keys to the widget-level handler', () => { const handlers = createRuleHandlers(); - expect(handleRuleInputMode({ key: { return: true }, ...handlers })).toBe(false); + expect(handleRuleInputMode(ruleArgs({ input: 'q', ...handlers }))).toBe(false); expect(handlers.onPrevRule).not.toHaveBeenCalled(); expect(handlers.onNextRule).not.toHaveBeenCalled(); expect(handlers.onCollapse).not.toHaveBeenCalled(); diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index 11295cd01..08f209102 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -1,5 +1,6 @@ import type { CustomKeybind, + Rule, Widget, WidgetItem, WidgetItemType @@ -334,10 +335,17 @@ export function handleMoveInputMode({ } export interface HandleRuleInputModeArgs { + input: string; key: InputKey; + widgets: WidgetItem[]; + selectedIndex: number; + selectedRuleIndex: number; onPrevRule: () => void; onNextRule: () => void; onCollapse: () => void; + onUpdate: (widgets: WidgetItem[]) => void; + onSelectRule: (index: number) => void; + onEditCondition: (ruleIndex: number) => void; } /** @@ -346,11 +354,32 @@ export interface HandleRuleInputModeArgs { * the widget-level handler when it did. */ export function handleRuleInputMode({ + input, key, + widgets, + selectedIndex, + selectedRuleIndex, onPrevRule, onNextRule, - onCollapse + onCollapse, + onUpdate, + onSelectRule, + onEditCondition }: HandleRuleInputModeArgs): boolean { + const currentWidget = widgets[selectedIndex]; + if (!currentWidget) { + return false; + } + + const rules = currentWidget.rules ?? []; + const ruleCount = rules.length; + + const commitRules = (newRules: Rule[]) => { + const newWidgets = [...widgets]; + newWidgets[selectedIndex] = { ...currentWidget, rules: newRules }; + onUpdate(newWidgets); + }; + if (key.escape) { onCollapse(); return true; @@ -366,6 +395,61 @@ export function handleRuleInputMode({ return true; } + if (input === 'a') { + const insertIndex = ruleCount > 0 ? selectedRuleIndex + 1 : 0; + const newRules = [...rules]; + newRules.splice(insertIndex, 0, { when: {}, apply: {} }); + commitRules(newRules); + onSelectRule(insertIndex); + return true; + } + + if (input === 'd' && ruleCount > 0) { + const newRules = rules.filter((_, i) => i !== selectedRuleIndex); + commitRules(newRules); + // Deliberately stays expanded on the empty list rather than collapsing, so the + // widget can be given a fresh rule without reopening the accordion. + onSelectRule(Math.max(0, Math.min(selectedRuleIndex, newRules.length - 1))); + return true; + } + + if (input === 's' && ruleCount > 0) { + const rule = rules[selectedRuleIndex]; + if (rule) { + const newRules = [...rules]; + if (rule.stop) { + const { stop, ...rest } = rule; + void stop; // Intentionally unused + newRules[selectedRuleIndex] = rest; + } else { + newRules[selectedRuleIndex] = { ...rule, stop: true }; + } + commitRules(newRules); + } + return true; + } + + if ((input === 'e' || key.return) && ruleCount > 0) { + onEditCondition(selectedRuleIndex); + return true; + } + + if ((input === 'j' || input === 'k') && ruleCount > 1) { + const targetIndex = input === 'j' + ? (selectedRuleIndex + 1 >= ruleCount ? 0 : selectedRuleIndex + 1) + : (selectedRuleIndex - 1 < 0 ? ruleCount - 1 : selectedRuleIndex - 1); + const newRules = [...rules]; + const moved = newRules[selectedRuleIndex]; + const target = newRules[targetIndex]; + if (moved && target) { + newRules[selectedRuleIndex] = target; + newRules[targetIndex] = moved; + commitRules(newRules); + onSelectRule(targetIndex); + } + return true; + } + return false; } From 8211a7d598d5638c4ecf622f05efaeec4b016478 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 12:56:14 +1000 Subject: [PATCH 46/52] feat(tui): hold the rules accordion above the editor modes The widget and colour editors unmount each other on a Tab swap, so an accordion owned by either one closes as soon as you swap. App now holds the state and seeds each editor with it, keeping the open widget and the selected rule across the swap. useRuleAccordion reports transitions through an onChange callback kept in a ref, so a caller passing an inline function does not rebuild every transition and defeat the useCallback identities the input handler depends on. ColorMenu is wired to the same state in the following commit. --- src/tui/App.tsx | 10 ++++ src/tui/components/ItemsEditor.tsx | 14 +++-- .../components/__tests__/ItemsEditor.test.tsx | 54 ++++++++++++++++++- src/tui/hooks/useRuleAccordion.ts | 53 ++++++++++++------ 4 files changed, 111 insertions(+), 20 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 611fe460f..1049bf326 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -104,6 +104,7 @@ import { List, type ListEntry } from './components/List'; +import type { AccordionState } from './hooks/useRuleAccordion'; const GITHUB_REPO_URL = 'https://github.com/sirmalloc/ccstatusline'; @@ -523,6 +524,7 @@ export const App: React.FC = () => { const [selectedLine, setSelectedLine] = useState(0); const [menuSelections, setMenuSelections] = useState>({}); const [activeWidgetId, setActiveWidgetId] = useState(null); + const [accordionState, setAccordionState] = useState({ expandedWidgetId: null, selectedRuleIndex: 0 }); const [confirmDialog, setConfirmDialog] = useState(null); const [isClaudeInstalled, setIsClaudeInstalled] = useState(false); const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80); @@ -771,6 +773,12 @@ export const App: React.FC = () => { setScreen(getTabSwapScreen); }, []); + // The two editing modes unmount each other on a Tab swap, so the accordion is held here + // and handed back, keeping the open widget and selected rule across the swap. + const handleAccordionChange = useCallback((state: AccordionState) => { + setAccordionState(state); + }, []); + const handleUpdateCheck = useCallback(() => { setUpdateCheckerState({ status: 'checking' }); const installation = settings @@ -1247,6 +1255,8 @@ export const App: React.FC = () => { onTabSwap={handleTabSwap} onWidgetHighlight={handleWidgetHighlight} initialWidgetId={activeWidgetId} + accordionState={accordionState} + onAccordionChange={handleAccordionChange} /> )} {screen === 'colorsMoved' && ( diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index cd953cf44..c13d802c6 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -28,7 +28,8 @@ import { } from '../../utils/widgets'; import { getRuleCount, - useRuleAccordion + useRuleAccordion, + type AccordionState } from '../hooks/useRuleAccordion'; import { ConditionEditor } from './ConditionEditor'; @@ -61,6 +62,8 @@ export interface ItemsEditorProps { onTabSwap?: () => void; onWidgetHighlight?: (widgetId: string | null) => void; initialWidgetId?: string | null; + accordionState?: AccordionState; + onAccordionChange?: (state: AccordionState) => void; } function isMergedIntoPreviousWidget(widgets: WidgetItem[], index: number): boolean { @@ -71,7 +74,7 @@ function isMergedIntoPreviousWidget(widgets: WidgetItem[], index: number): boole return Boolean(widgets[index - 1]?.merge); } -export const ItemsEditor: React.FC = ({ widgets, onUpdate, onBack, lineNumber, settings, onTabSwap, onWidgetHighlight, initialWidgetId }) => { +export const ItemsEditor: React.FC = ({ widgets, onUpdate, onBack, lineNumber, settings, onTabSwap, onWidgetHighlight, initialWidgetId, accordionState, onAccordionChange }) => { const [selectedIndex, setSelectedIndex] = useState(() => { if (initialWidgetId) { const index = widgets.findIndex(w => w.id === initialWidgetId); @@ -83,7 +86,12 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB const [customEditorWidget, setCustomEditorWidget] = useState(null); const [widgetPicker, setWidgetPicker] = useState(null); const [showClearConfirm, setShowClearConfirm] = useState(false); - const accordion = useRuleAccordion({ widgets }); + const accordion = useRuleAccordion({ + widgets, + initialExpandedWidgetId: accordionState?.expandedWidgetId ?? null, + initialSelectedRuleIndex: accordionState?.selectedRuleIndex ?? 0, + onChange: onAccordionChange + }); const [editingRuleIndex, setEditingRuleIndex] = useState(null); const separatorChars = ['|', '-', ',', ' ']; diff --git a/src/tui/components/__tests__/ItemsEditor.test.tsx b/src/tui/components/__tests__/ItemsEditor.test.tsx index 9171de4c3..5691b13b5 100644 --- a/src/tui/components/__tests__/ItemsEditor.test.tsx +++ b/src/tui/components/__tests__/ItemsEditor.test.tsx @@ -85,7 +85,11 @@ const THEMED_SETTINGS = { } }; -async function renderItemsEditor(widgets: WidgetItem[], settings: Settings = DEFAULT_SETTINGS) { +async function renderItemsEditor( + widgets: WidgetItem[], + settings: Settings = DEFAULT_SETTINGS, + extraProps: Record = {} +) { const stdin = createMockStdin(); const stdout = createMockStdout(); const stderr = createMockStdout(); @@ -97,7 +101,8 @@ async function renderItemsEditor(widgets: WidgetItem[], settings: Settings = DEF onUpdate, onBack, lineNumber: 1, - settings + settings, + ...extraProps }), { stdin, @@ -381,6 +386,51 @@ describe('ItemsEditor', () => { } }); + it('opens already expanded when handed accordion state', async () => { + const { output, teardown } = await renderItemsEditor( + [ + { + id: '1', + type: 'model', + rules: [ + { when: { widget: 'context-percentage', greaterThan: 80 }, apply: { color: 'red' } }, + { when: { widget: 'context-percentage', greaterThan: 90 }, apply: { bold: true } } + ] + } + ], + DEFAULT_SETTINGS, + { accordionState: { expandedWidgetId: '1', selectedRuleIndex: 1 } } + ); + + try { + expect(output).toContain('› when context-percentage greater than 90'); + } finally { + teardown(); + } + }); + + it('reports accordion changes so the other editor mode can pick them up', async () => { + const onAccordionChange = vi.fn(); + const { stdin, teardown } = await renderItemsEditor( + [{ + id: '1', + type: 'model', + rules: [{ when: { widget: 'context-percentage', greaterThan: 80 }, apply: { color: 'red' } }] + }], + DEFAULT_SETTINGS, + { onAccordionChange } + ); + + try { + stdin.write('R'); + await settleInputHandler(); + + expect(onAccordionChange).toHaveBeenCalledWith({ expandedWidgetId: '1', selectedRuleIndex: 0 }); + } finally { + teardown(); + } + }); + it('advertises the rules key, and the rule keys once expanded', async () => { const { stdin, getOutput, teardown } = await renderItemsEditor([{ id: '1', type: 'model' }]); diff --git a/src/tui/hooks/useRuleAccordion.ts b/src/tui/hooks/useRuleAccordion.ts index ba9733514..76cbed862 100644 --- a/src/tui/hooks/useRuleAccordion.ts +++ b/src/tui/hooks/useRuleAccordion.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, + useRef, useState } from 'react'; @@ -138,6 +139,11 @@ export interface UseRuleAccordionOptions { widgets: WidgetItem[]; initialExpandedWidgetId?: string | null; initialSelectedRuleIndex?: number; + /** + * Reports every state change so an owner above the editor can hold the accordion + * across a swap between the widget and colour editing modes, which unmount each other. + */ + onChange?: (state: AccordionState) => void; } export interface UseRuleAccordionReturn { @@ -158,41 +164,58 @@ export interface UseRuleAccordionReturn { export function useRuleAccordion({ widgets, initialExpandedWidgetId = null, - initialSelectedRuleIndex = 0 + initialSelectedRuleIndex = 0, + onChange }: UseRuleAccordionOptions): UseRuleAccordionReturn { const [state, setState] = useState({ expandedWidgetId: initialExpandedWidgetId, selectedRuleIndex: initialSelectedRuleIndex }); + // Keep the reporting callback in a ref so a caller passing an inline function does not + // rebuild every transition, which would defeat their useCallback identities. + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + const applyTransition = useCallback((transition: (prev: AccordionState) => AccordionState) => { + setState((prev) => { + const next = transition(prev); + if (next.expandedWidgetId !== prev.expandedWidgetId + || next.selectedRuleIndex !== prev.selectedRuleIndex) { + onChangeRef.current?.(next); + } + return next; + }); + }, []); + // Reconcile state when the widgets array changes externally useEffect(() => { - setState(prev => reconcile(prev, widgets)); - }, [widgets]); + applyTransition(prev => reconcile(prev, widgets)); + }, [widgets, applyTransition]); const expandCb = useCallback((widgetId: string) => { - setState(expand(widgetId)); - }, []); + applyTransition(() => expand(widgetId)); + }, [applyTransition]); const collapseCb = useCallback(() => { - setState(collapse()); - }, []); + applyTransition(() => collapse()); + }, [applyTransition]); const toggleExpandCb = useCallback((widgetId: string) => { - setState(prev => toggleExpand(prev, widgetId)); - }, []); + applyTransition(prev => toggleExpand(prev, widgetId)); + }, [applyTransition]); const selectRuleCb = useCallback((index: number) => { - setState(prev => selectRule(prev, index)); - }, []); + applyTransition(prev => selectRule(prev, index)); + }, [applyTransition]); const selectPrevRuleCb = useCallback(() => { - setState(prev => selectPrevRule(prev, widgets)); - }, [widgets]); + applyTransition(prev => selectPrevRule(prev, widgets)); + }, [widgets, applyTransition]); const selectNextRuleCb = useCallback(() => { - setState(prev => selectNextRule(prev, widgets)); - }, [widgets]); + applyTransition(prev => selectNextRule(prev, widgets)); + }, [widgets, applyTransition]); const isExpandedCb = useCallback((widgetId: string) => { return isExpanded(state, widgetId); From b096e83aa6ccd5e910efbcaf65a7cfb33563c926 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 13:03:50 +1000 Subject: [PATCH 47/52] feat(color-menu): browse a widget's rules from the colour editor Mirrors the widget editor's accordion: rule-count badges on the rows, (E) to open the highlighted widget, arrows to move between its rules and escape to collapse. Rendering goes through the same RuleRow, so a rule reads identically in both modes, and App hands the state across a Tab swap. Rules are authored in the widget editor, so this only opens widgets that already have rules - opening an empty one here would be a dead end - and it carries none of the (a)dd/(d)elete/(s)top keys, which are taken by ansi256, dim and show-separators in this menu. The accordion key is (E) in both editors rather than the (R) the earlier implementation used here: (R) is reset in this menu, and lowercase is effectively exhausted once widget custom keybinds are counted, so (E) is the one mnemonic key free on both sides. --- src/tui/App.tsx | 2 + src/tui/components/ColorMenu.tsx | 88 ++++++++-- src/tui/components/ItemsEditor.tsx | 2 +- .../components/__tests__/ColorMenu.test.tsx | 158 ++++++++++++++++++ .../components/__tests__/ItemsEditor.test.tsx | 18 +- .../__tests__/input-handlers.test.ts | 4 +- .../components/items-editor/input-handlers.ts | 2 +- 7 files changed, 249 insertions(+), 25 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 1049bf326..5f502d952 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -1293,6 +1293,8 @@ export const App: React.FC = () => { onTabSwap={handleTabSwap} onWidgetHighlight={handleWidgetHighlight} initialWidgetId={activeWidgetId} + accordionState={accordionState} + onAccordionChange={handleAccordionChange} /> )} {screen === 'terminalConfig' && ( diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index 47e599f20..a0eca0d71 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -24,12 +24,19 @@ import { import { GRADIENT_PRESET_NAMES } from '../../utils/gradient'; import { shouldInsertInput } from '../../utils/input-guards'; import { getWidget } from '../../utils/widgets'; +import { + getRuleCount, + useRuleAccordion, + type AccordionState +} from '../hooks/useRuleAccordion'; import { ConfirmDialog } from './ConfirmDialog'; +import { RuleRow } from './RuleRow'; import { WidgetRow, getWidgetRowLabel, getWidgetRowTags, + getWidgetRuleBadge, styleWidgetRowLabel } from './WidgetRow'; import { @@ -52,9 +59,17 @@ export interface ColorMenuProps { onTabSwap?: () => void; onWidgetHighlight?: (widgetId: string | null) => void; initialWidgetId?: string | null; + accordionState?: AccordionState; + onAccordionChange?: (state: AccordionState) => void; } -export const ColorMenu: React.FC = ({ widgets, lineIndex, settings, onUpdate, onBack, onTabSwap, onWidgetHighlight, initialWidgetId }) => { +export const ColorMenu: React.FC = ({ widgets, lineIndex, settings, onUpdate, onBack, onTabSwap, onWidgetHighlight, initialWidgetId, accordionState, onAccordionChange }) => { + const accordion = useRuleAccordion({ + widgets, + initialExpandedWidgetId: accordionState?.expandedWidgetId ?? null, + initialSelectedRuleIndex: accordionState?.selectedRuleIndex ?? 0, + onChange: onAccordionChange + }); const [showSeparators, setShowSeparators] = useState(false); const [hexInputMode, setHexInputMode] = useState(false); const [hexInput, setHexInput] = useState(''); @@ -315,6 +330,35 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin return; } + const highlightedWidget = highlightedItemId + ? colorableWidgets.find(entry => entry.id === highlightedItemId) + : undefined; + + // Rules are authored in the widget editor, so this only opens widgets that have some + if (input === 'E') { + if (highlightedWidget && getRuleCount(highlightedWidget) > 0) { + accordion.toggleExpand(highlightedWidget.id); + } + return; + } + + // While the accordion is open the rule list takes the arrows and escape; the colour + // keys stay live so they can be aimed at the selected rule. + if (accordion.expandedWidgetId !== null) { + if (key.escape) { + accordion.collapse(); + return; + } + if (key.upArrow) { + accordion.selectPrevRule(); + return; + } + if (key.downArrow) { + accordion.selectNextRule(); + return; + } + } + // Normal keyboard handling when there are items if (key.escape) { if (editingBackground) { @@ -461,10 +505,16 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin number: index + 1, label: styleWidgetRowLabel(displayText, widget, settings, effectiveThemeColors.get(widget.id)), modifierText, - tags: getWidgetRowTags(widgets, index, settings) + tags: getWidgetRowTags(widgets, index, settings), + badge: getWidgetRuleBadge(widget), + rules: widget.rules ?? [], + expanded: accordion.isExpanded(widget.id) }; }); + // The rules key only means something on a widget that has rules to open + const highlightedHasRules = menuRows.some(row => row.id === highlightedItemId && row.rules.length > 0); + // Get current color for highlighted item const selectedWidget = highlightedItemId ? colorableWidgets.find(widget => widget.id === highlightedItemId) @@ -700,9 +750,10 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin {!settings.powerline.enabled && !settings.defaultSeparator ? ` (s)how separators: ${showSeparators ? 'ON' : 'OFF'},` : ''} + {highlightedHasRules ? ' (E) rules,' : ''} {onTabSwap ? ' ⇥ edit widgets,' : ''} {' '} - ESC to go back + {accordion.expandedWidgetId !== null ? 'ESC to collapse' : 'ESC to go back'} {selectedWidget ? ( @@ -727,15 +778,28 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin )} {menuRows.map(row => ( - + + + {row.expanded && ( + + {row.rules.map((rule, ruleIndex) => ( + + ))} + + )} + ))} diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index c13d802c6..a669e7791 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -376,7 +376,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB helpText += ', ⇥ edit colors'; } if (hasWidgets) { - helpText += ', (R)ules'; + helpText += ', (E) rules'; } helpText += ', ESC back'; } diff --git a/src/tui/components/__tests__/ColorMenu.test.tsx b/src/tui/components/__tests__/ColorMenu.test.tsx index 5248a3758..788ba51a3 100644 --- a/src/tui/components/__tests__/ColorMenu.test.tsx +++ b/src/tui/components/__tests__/ColorMenu.test.tsx @@ -63,6 +63,164 @@ function flushInk() { }); } +/** + * Ink re-registers its input handler from a passive effect, so a key sent right after a key + * that changed the menu's mode is still handled by the previous render's closure. Sequences + * that press a mode key and then act inside that mode have to let the effect flush first. + */ +async function settleInputHandler() { + for (let i = 0; i < 10; i++) { + await flushInk(); + } +} + +const RULED_WIDGETS: WidgetItem[] = [ + { + id: '1', + type: 'model', + rules: [ + { when: { widget: 'context-percentage', greaterThan: 80 }, apply: { color: 'red' } }, + { when: { widget: 'context-percentage', greaterThan: 90 }, apply: { bold: true } } + ] + }, + { id: '2', type: 'git-branch' } +]; + +async function renderColorMenu( + widgets: WidgetItem[] = RULED_WIDGETS, + extraProps: Record = {} +) { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const onUpdate = vi.fn(); + const onBack = vi.fn(); + const instance = render( + React.createElement(ColorMenu, { + widgets, + lineIndex: 0, + settings: DEFAULT_SETTINGS, + onUpdate, + onBack, + ...extraProps + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + await flushInk(); + + return { + stdin, + onUpdate, + onBack, + getOutput: () => stdout.getOutput(), + teardown: () => { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }; +} + +describe('ColorMenu rules accordion', () => { + it('badges a row with how many rules the widget carries', async () => { + const { getOutput, teardown } = await renderColorMenu(); + + try { + expect(getOutput()).toContain('[2 rules]'); + } finally { + teardown(); + } + }); + + it('expands the highlighted widget to show its rules', async () => { + const { stdin, getOutput, teardown } = await renderColorMenu(); + + try { + expect(getOutput()).not.toContain('when context-percentage greater than 80'); + + stdin.write('E'); + await settleInputHandler(); + + expect(getOutput()).toContain('when context-percentage greater than 80'); + } finally { + teardown(); + } + }); + + it('moves the rule selection with the arrows while expanded', async () => { + const { stdin, getOutput, teardown } = await renderColorMenu(); + + try { + stdin.write('E'); + await settleInputHandler(); + + stdin.write('\x1B[B'); + await settleInputHandler(); + + expect(getOutput()).toContain('› when context-percentage greater than 90'); + } finally { + teardown(); + } + }); + + it('collapses on escape instead of leaving the colour editor', async () => { + const { stdin, onBack, teardown } = await renderColorMenu(); + + try { + stdin.write('E'); + await settleInputHandler(); + + stdin.write('\x1B'); + await settleInputHandler(); + + expect(onBack).not.toHaveBeenCalled(); + } finally { + teardown(); + } + }); + + // Rules cannot be created here, so opening a widget with none would be a dead end. + it('does not expand a widget that has no rules', async () => { + const onAccordionChange = vi.fn(); + const { stdin, teardown } = await renderColorMenu( + [{ id: '1', type: 'model' }], + { onAccordionChange } + ); + + try { + stdin.write('E'); + await settleInputHandler(); + + expect(onAccordionChange).not.toHaveBeenCalled(); + } finally { + teardown(); + } + }); + + it('carries the accordion in from the widget editor', async () => { + const { getOutput, teardown } = await renderColorMenu( + RULED_WIDGETS, + { accordionState: { expandedWidgetId: '1', selectedRuleIndex: 1 } } + ); + + try { + expect(getOutput()).toContain('› when context-percentage greater than 90'); + } finally { + teardown(); + } + }); +}); + describe('ColorMenu', () => { it('keeps bold and dim indicators on the current-style row', async () => { const stdin = createMockStdin(); diff --git a/src/tui/components/__tests__/ItemsEditor.test.tsx b/src/tui/components/__tests__/ItemsEditor.test.tsx index 5691b13b5..3b10066e1 100644 --- a/src/tui/components/__tests__/ItemsEditor.test.tsx +++ b/src/tui/components/__tests__/ItemsEditor.test.tsx @@ -278,7 +278,7 @@ describe('ItemsEditor', () => { try { expect(getOutput()).not.toContain('when context-percentage greater than 80'); - stdin.write('R'); + stdin.write('E'); await flushInk(); expect(getOutput()).toContain('when context-percentage greater than 80'); @@ -291,7 +291,7 @@ describe('ItemsEditor', () => { const { stdin, getOutput, teardown } = await renderItemsEditor([{ id: '1', type: 'model' }]); try { - stdin.write('R'); + stdin.write('E'); await flushInk(); expect(getOutput()).toContain('No rules'); @@ -313,7 +313,7 @@ describe('ItemsEditor', () => { ]); try { - stdin.write('R'); + stdin.write('E'); await settleInputHandler(); stdin.write(''); // down arrow await flushInk(); @@ -334,7 +334,7 @@ describe('ItemsEditor', () => { ]); try { - stdin.write('R'); + stdin.write('E'); await settleInputHandler(); stdin.write('\x1B'); // escape @@ -351,7 +351,7 @@ describe('ItemsEditor', () => { const { stdin, onUpdate, teardown } = await renderItemsEditor([{ id: '1', type: 'model' }]); try { - stdin.write('R'); + stdin.write('E'); await settleInputHandler(); stdin.write('a'); @@ -374,7 +374,7 @@ describe('ItemsEditor', () => { ]); try { - stdin.write('R'); + stdin.write('E'); await settleInputHandler(); stdin.write('e'); @@ -422,7 +422,7 @@ describe('ItemsEditor', () => { ); try { - stdin.write('R'); + stdin.write('E'); await settleInputHandler(); expect(onAccordionChange).toHaveBeenCalledWith({ expandedWidgetId: '1', selectedRuleIndex: 0 }); @@ -435,9 +435,9 @@ describe('ItemsEditor', () => { const { stdin, getOutput, teardown } = await renderItemsEditor([{ id: '1', type: 'model' }]); try { - expect(getOutput()).toContain('(R)ules'); + expect(getOutput()).toContain('(E) rules'); - stdin.write('R'); + stdin.write('E'); await settleInputHandler(); expect(getOutput()).toContain('ESC collapse'); 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 adba60d1b..879834c8d 100644 --- a/src/tui/components/items-editor/__tests__/input-handlers.test.ts +++ b/src/tui/components/items-editor/__tests__/input-handlers.test.ts @@ -1134,10 +1134,10 @@ describe('rules accordion shortcut', () => { }; } - it('opens the accordion on R', () => { + it('opens the accordion on E', () => { const onToggleAccordion = vi.fn(); - handleNormalInputMode(normalModeArgs({ input: 'R', onToggleAccordion })); + handleNormalInputMode(normalModeArgs({ input: 'E', onToggleAccordion })); expect(onToggleAccordion).toHaveBeenCalledWith('1'); }); diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index 08f209102..0b22c4969 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -502,7 +502,7 @@ export function handleNormalInputMode({ openWidgetPicker('change'); } else if (key.return && widgets.length > 0) { setMoveMode(true); - } else if (input === 'R' && onToggleAccordion && widgets.length > 0) { + } else if (input === 'E' && onToggleAccordion && widgets.length > 0) { const widget = widgets[selectedIndex]; if (widget) { onToggleAccordion(widget.id); From ed13824ab2eee8c51c7b223d3d1c323ace0954ef Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 13:09:49 +1000 Subject: [PATCH 48/52] feat(color-menu): aim colour edits at the selected rule With a rule selected in the accordion, the colour keys write to rule.apply instead of the widget: cycling, hex, ansi256, bold and reset all follow the selection. The widget's own styling stays as the unconditional base its rules override. Rule edits sit behind the existing pin gate rather than a new one. Under a theme the theme owns the channel, so a rule colour would be invisible until that channel is pinned - the same reason widget colours require a pin. Pin the channel and the widget overrides the theme, which is what lets a rule override it further. This needs no pin fields on RuleApply, since pins are per widget and channel. Dim is left out: RuleApply has no dim field, so there is nothing for a rule to override. --- src/tui/components/ColorMenu.tsx | 21 +++-- src/tui/components/ItemsEditor.tsx | 3 + .../components/__tests__/ColorMenu.test.tsx | 94 +++++++++++++++++++ .../components/__tests__/ItemsEditor.test.tsx | 7 +- .../color-menu/__tests__/mutations.test.ts | 67 +++++++++++++ src/tui/components/color-menu/mutations.ts | 82 ++++++++++++++-- 6 files changed, 258 insertions(+), 16 deletions(-) diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index a0eca0d71..b367b7c93 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -160,6 +160,14 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin */ const canEditColor = (widget: WidgetItem): boolean => !themeActive || isChannelPinned(widget); + /** + * The rule a colour edit lands on, or undefined to edit the widget itself. Only the + * expanded widget's rules are targets, so highlighting a different row edits that widget. + */ + const getTargetRuleIndex = (widgetId: string): number | undefined => ( + accordion.expandedWidgetId === widgetId ? accordion.selectedRuleIndex : undefined + ); + /** The widget a colour edit applies to, or null when the channel is not editable yet. */ const getEditableWidget = (): WidgetItem | null => { if (!highlightedItemId) { @@ -197,7 +205,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin const hexColor = `hex:${hexInput}`; const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); if (selectedWidget) { - const newItems = setWidgetColor(widgets, selectedWidget.id, hexColor, editingBackground); + const newItems = setWidgetColor(widgets, selectedWidget.id, hexColor, editingBackground, getTargetRuleIndex(selectedWidget.id)); onUpdate(newItems); } setHexInputMode(false); @@ -233,7 +241,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); if (selectedWidget) { - const newItems = setWidgetColor(widgets, selectedWidget.id, ansiColor, editingBackground); + const newItems = setWidgetColor(widgets, selectedWidget.id, ansiColor, editingBackground, getTargetRuleIndex(selectedWidget.id)); onUpdate(newItems); setAnsi256InputMode(false); @@ -268,7 +276,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin const applyGradientValue = (value: string) => { const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); if (selectedWidget) { - onUpdate(setWidgetColor(widgets, selectedWidget.id, value, false)); + onUpdate(setWidgetColor(widgets, selectedWidget.id, value, false, getTargetRuleIndex(selectedWidget.id))); } exitGradient(); }; @@ -403,7 +411,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin // Toggle bold for the highlighted item const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); if (selectedWidget) { - const newItems = toggleWidgetBold(widgets, selectedWidget.id); + const newItems = toggleWidgetBold(widgets, selectedWidget.id, getTargetRuleIndex(selectedWidget.id)); onUpdate(newItems); } } @@ -421,7 +429,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin // 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); + const newItems = resetWidgetStyling(widgets, selectedWidget.id, getTargetRuleIndex(selectedWidget.id)); onUpdate(newItems); } } @@ -460,7 +468,8 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin direction: key.rightArrow ? 'right' : 'left', editingBackground, colors, - backgroundColors: bgColors + backgroundColors: bgColors, + ruleIndex: getTargetRuleIndex(selectedWidget.id) }); onUpdate(newItems); } diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index a669e7791..9430001e4 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -351,6 +351,9 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB if (ruleCount > 1) { ruleHelpParts.push('(j)/(k) reorder'); } + if (onTabSwap) { + ruleHelpParts.push('⇥ edit colors'); + } ruleHelpParts.push('ESC collapse'); helpText = ruleHelpParts.join(', '); } else { diff --git a/src/tui/components/__tests__/ColorMenu.test.tsx b/src/tui/components/__tests__/ColorMenu.test.tsx index 788ba51a3..4fabf3b55 100644 --- a/src/tui/components/__tests__/ColorMenu.test.tsx +++ b/src/tui/components/__tests__/ColorMenu.test.tsx @@ -207,6 +207,100 @@ describe('ColorMenu rules accordion', () => { } }); + it('aims colour cycling at the selected rule while expanded', async () => { + const { stdin, onUpdate, teardown } = await renderColorMenu(); + + try { + stdin.write('E'); + await settleInputHandler(); + + stdin.write('\x1B[C'); // right arrow cycles colour + await settleInputHandler(); + + const updated = onUpdate.mock.calls.at(-1)?.[0] as WidgetItem[] | undefined; + expect(updated?.[0]?.color).toBeUndefined(); + expect(updated?.[0]?.rules?.[0]?.apply.color).toBeDefined(); + expect(updated?.[0]?.rules?.[0]?.apply.color).not.toBe('red'); + } finally { + teardown(); + } + }); + + it('toggles bold on the selected rule while expanded', async () => { + const { stdin, onUpdate, teardown } = await renderColorMenu(); + + try { + stdin.write('E'); + await settleInputHandler(); + + stdin.write('b'); + await settleInputHandler(); + + const updated = onUpdate.mock.calls.at(-1)?.[0] as WidgetItem[] | undefined; + expect(updated?.[0]?.bold).toBeUndefined(); + expect(updated?.[0]?.rules?.[0]?.apply.bold).toBe(true); + } finally { + teardown(); + } + }); + + // Under a theme the theme owns the channel, so a rule colour would be invisible until the + // channel is pinned. Rule edits sit behind the same gate as the widget's own colour. + it('ignores rule colour edits until the channel is pinned under a theme', async () => { + const themed = { + ...DEFAULT_SETTINGS, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + theme: 'nord-aurora' + } + }; + const { stdin, onUpdate, teardown } = await renderColorMenu(RULED_WIDGETS, { settings: themed }); + + try { + stdin.write('E'); + await settleInputHandler(); + + stdin.write('\x1B[C'); + await settleInputHandler(); + + expect(onUpdate).not.toHaveBeenCalled(); + } finally { + teardown(); + } + }); + + it('edits the rule colour once the channel is pinned', async () => { + const themed = { + ...DEFAULT_SETTINGS, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + theme: 'nord-aurora' + } + }; + const pinned: WidgetItem[] = [{ + ...RULED_WIDGETS[0], + id: '1', + type: 'model', + pinColor: true + }]; + const { stdin, onUpdate, teardown } = await renderColorMenu(pinned, { settings: themed }); + + try { + stdin.write('E'); + await settleInputHandler(); + + stdin.write('\x1B[C'); + await settleInputHandler(); + + const updated = onUpdate.mock.calls.at(-1)?.[0] as WidgetItem[] | undefined; + expect(updated?.[0]?.rules?.[0]?.apply.color).toBeDefined(); + } finally { + teardown(); + } + }); + it('carries the accordion in from the widget editor', async () => { const { getOutput, teardown } = await renderColorMenu( RULED_WIDGETS, diff --git a/src/tui/components/__tests__/ItemsEditor.test.tsx b/src/tui/components/__tests__/ItemsEditor.test.tsx index 3b10066e1..79d0d29f7 100644 --- a/src/tui/components/__tests__/ItemsEditor.test.tsx +++ b/src/tui/components/__tests__/ItemsEditor.test.tsx @@ -432,7 +432,11 @@ describe('ItemsEditor', () => { }); it('advertises the rules key, and the rule keys once expanded', async () => { - const { stdin, getOutput, teardown } = await renderItemsEditor([{ id: '1', type: 'model' }]); + const { stdin, getOutput, teardown } = await renderItemsEditor( + [{ id: '1', type: 'model' }], + DEFAULT_SETTINGS, + { onTabSwap: vi.fn() } + ); try { expect(getOutput()).toContain('(E) rules'); @@ -441,6 +445,7 @@ describe('ItemsEditor', () => { await settleInputHandler(); expect(getOutput()).toContain('ESC collapse'); + expect(getOutput()).toContain('⇥ edit colors'); } finally { teardown(); } diff --git a/src/tui/components/color-menu/__tests__/mutations.test.ts b/src/tui/components/color-menu/__tests__/mutations.test.ts index d4b7e3004..6bb629ae2 100644 --- a/src/tui/components/color-menu/__tests__/mutations.test.ts +++ b/src/tui/components/color-menu/__tests__/mutations.test.ts @@ -10,6 +10,7 @@ import { cycleWidgetColor, cycleWidgetDim, resetWidgetStyling, + setWidgetColor, toggleWidgetBold, updateWidgetById } from '../mutations'; @@ -153,3 +154,69 @@ describe('color-menu mutations', () => { expect(dimCycle[0]?.color).toBe('red'); }); }); + +describe('rule-targeted mutations', () => { + const ruled: WidgetItem[] = [{ + id: '1', + type: 'tokens-input', + color: 'blue', + rules: [ + { when: { widget: 'context-percentage', greaterThan: 80 }, apply: { color: 'red' } }, + { when: { widget: 'context-percentage', greaterThan: 90 }, apply: {} } + ] + }]; + + it('writes a colour onto the rule rather than the widget', () => { + const updated = setWidgetColor(ruled, '1', 'green', false, 1); + + expect(updated[0]?.color).toBe('blue'); + expect(updated[0]?.rules?.[1]?.apply.color).toBe('green'); + expect(updated[0]?.rules?.[0]?.apply.color).toBe('red'); + }); + + it('writes a background colour onto the rule', () => { + const updated = setWidgetColor(ruled, '1', 'bgRed', true, 0); + + expect(updated[0]?.backgroundColor).toBeUndefined(); + expect(updated[0]?.rules?.[0]?.apply.backgroundColor).toBe('bgRed'); + }); + + it('toggles bold on the rule', () => { + const on = toggleWidgetBold(ruled, '1', 0); + expect(on[0]?.bold).toBeUndefined(); + expect(on[0]?.rules?.[0]?.apply.bold).toBe(true); + + const off = toggleWidgetBold(on, '1', 0); + expect(off[0]?.rules?.[0]?.apply.bold).toBe(false); + }); + + it('resets only the rule it is aimed at', () => { + const updated = resetWidgetStyling(ruled, '1', 0); + + expect(updated[0]?.color).toBe('blue'); + expect(updated[0]?.rules?.[0]?.apply).toEqual({}); + expect(updated[0]?.rules?.[0]?.when).toEqual({ widget: 'context-percentage', greaterThan: 80 }); + }); + + it('cycles the rule colour, starting from the rule\'s own colour', () => { + const updated = cycleWidgetColor({ + widgets: ruled, + widgetId: '1', + direction: 'right', + editingBackground: false, + colors: ['red', 'green', 'yellow'], + backgroundColors: [], + ruleIndex: 0 + }); + + expect(updated[0]?.color).toBe('blue'); + expect(updated[0]?.rules?.[0]?.apply.color).toBe('green'); + }); + + it('leaves the widget alone when the rule index is out of range', () => { + const updated = setWidgetColor(ruled, '1', 'green', false, 5); + + expect(updated[0]?.color).toBe('blue'); + expect(updated[0]?.rules).toEqual(ruled[0]?.rules); + }); +}); diff --git a/src/tui/components/color-menu/mutations.ts b/src/tui/components/color-menu/mutations.ts index 7ff896e5b..35aa50724 100644 --- a/src/tui/components/color-menu/mutations.ts +++ b/src/tui/components/color-menu/mutations.ts @@ -1,4 +1,7 @@ -import type { WidgetItem } from '../../../types/Widget'; +import type { + RuleApply, + WidgetItem +} from '../../../types/Widget'; import { getWidget } from '../../../utils/widgets'; export function updateWidgetById( @@ -9,13 +12,43 @@ export function updateWidgetById( return widgets.map(widget => widget.id === widgetId ? updater(widget) : widget); } +/** + * Applies an update to one of a widget's rules. Rule edits target rule.apply so the widget's + * own styling stays as the unconditional base the rules override. Out-of-range indexes leave + * the widget untouched rather than growing the list. + */ +function updateRuleApply( + widget: WidgetItem, + ruleIndex: number, + applyUpdater: (apply: RuleApply) => RuleApply +): WidgetItem { + const rules = widget.rules; + if (!rules || ruleIndex < 0 || ruleIndex >= rules.length) { + return widget; + } + + return { + ...widget, + rules: rules.map((rule, index) => index === ruleIndex + ? { ...rule, apply: applyUpdater(rule.apply) } + : rule) + }; +} + export function setWidgetColor( widgets: WidgetItem[], widgetId: string, color: string, - editingBackground: boolean + editingBackground: boolean, + ruleIndex?: number ): WidgetItem[] { return updateWidgetById(widgets, widgetId, (widget) => { + if (ruleIndex !== undefined) { + return updateRuleApply(widget, ruleIndex, apply => (editingBackground + ? { ...apply, backgroundColor: color } + : { ...apply, color })); + } + if (editingBackground) { return { ...widget, @@ -84,11 +117,17 @@ export function clearAllPins(widgets: WidgetItem[]): WidgetItem[] { }); } -export function toggleWidgetBold(widgets: WidgetItem[], widgetId: string): WidgetItem[] { - return updateWidgetById(widgets, widgetId, widget => ({ - ...widget, - bold: !widget.bold - })); +export function toggleWidgetBold(widgets: WidgetItem[], widgetId: string, ruleIndex?: number): WidgetItem[] { + return updateWidgetById(widgets, widgetId, (widget) => { + if (ruleIndex !== undefined) { + return updateRuleApply(widget, ruleIndex, apply => ({ ...apply, bold: !apply.bold })); + } + + return { + ...widget, + bold: !widget.bold + }; + }); } export function cycleWidgetDim(widgets: WidgetItem[], widgetId: string): WidgetItem[] { @@ -114,8 +153,12 @@ export function cycleWidgetDim(widgets: WidgetItem[], widgetId: string): WidgetI }); } -export function resetWidgetStyling(widgets: WidgetItem[], widgetId: string): WidgetItem[] { +export function resetWidgetStyling(widgets: WidgetItem[], widgetId: string, ruleIndex?: number): WidgetItem[] { return updateWidgetById(widgets, widgetId, (widget) => { + if (ruleIndex !== undefined) { + return updateRuleApply(widget, ruleIndex, () => ({})); + } + const { color, backgroundColor, @@ -180,6 +223,8 @@ export interface CycleWidgetColorOptions { editingBackground: boolean; colors: string[]; backgroundColors: string[]; + /** Aims the edit at one of the widget's rules instead of the widget itself. */ + ruleIndex?: number; } export function cycleWidgetColor({ @@ -188,9 +233,28 @@ export function cycleWidgetColor({ direction, editingBackground, colors, - backgroundColors + backgroundColors, + ruleIndex }: CycleWidgetColorOptions): WidgetItem[] { return updateWidgetById(widgets, widgetId, (widget) => { + if (ruleIndex !== undefined) { + const palette = editingBackground ? backgroundColors : colors; + if (palette.length === 0) { + return widget; + } + + return updateRuleApply(widget, ruleIndex, (apply) => { + const current = (editingBackground ? apply.backgroundColor : apply.color) ?? ''; + const currentIndex = palette.indexOf(current); + const next = palette[getNextIndex(currentIndex === -1 ? 0 : currentIndex, palette.length, direction)]; + const value = next === '' ? undefined : next; + + return editingBackground + ? { ...apply, backgroundColor: value } + : { ...apply, color: value }; + }); + } + if (editingBackground) { if (backgroundColors.length === 0) { return widget; From 62557350bf9fbcdfd414d3ff5d074cff9f395125 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 13:17:26 +1000 Subject: [PATCH 49/52] fix(tui): spell out that the rules key needs shift Every other hint in these editors is a lowercase letter in parentheses, so "(E) rules" read as lowercase e - which does nothing, or triggers a widget's own (e)dit keybind on Custom Command, Custom Text, Link and Custom Symbol. Label it as the shifted key it is. --- src/tui/components/ColorMenu.tsx | 2 +- src/tui/components/ItemsEditor.tsx | 2 +- src/tui/components/__tests__/ItemsEditor.test.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index b367b7c93..e85c7d544 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -759,7 +759,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin {!settings.powerline.enabled && !settings.defaultSeparator ? ` (s)how separators: ${showSeparators ? 'ON' : 'OFF'},` : ''} - {highlightedHasRules ? ' (E) rules,' : ''} + {highlightedHasRules ? ' ⇧E rules,' : ''} {onTabSwap ? ' ⇥ edit widgets,' : ''} {' '} {accordion.expandedWidgetId !== null ? 'ESC to collapse' : 'ESC to go back'} diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 9430001e4..57a42c4e4 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -379,7 +379,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB helpText += ', ⇥ edit colors'; } if (hasWidgets) { - helpText += ', (E) rules'; + helpText += ', ⇧E rules'; } helpText += ', ESC back'; } diff --git a/src/tui/components/__tests__/ItemsEditor.test.tsx b/src/tui/components/__tests__/ItemsEditor.test.tsx index 79d0d29f7..1fe41f8c0 100644 --- a/src/tui/components/__tests__/ItemsEditor.test.tsx +++ b/src/tui/components/__tests__/ItemsEditor.test.tsx @@ -439,7 +439,7 @@ describe('ItemsEditor', () => { ); try { - expect(getOutput()).toContain('(E) rules'); + expect(getOutput()).toContain('⇧E rules'); stdin.write('E'); await settleInputHandler(); From 751790f3c9fa73e6269dbaff5a7a2c3282cdf41c Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 13:26:54 +1000 Subject: [PATCH 50/52] feat(tui): open the rules accordion with (+) Replaces the shifted (E). A symbol key sidesteps the case ambiguity that made the previous binding read as lowercase e, and + expanding a node is the usual convention for a tree. It is unbound in both editors, and the digit and text-input guards it passes through only run inside the hex, ansi256 and gradient modes, which return before this handler. --- src/tui/components/ColorMenu.tsx | 4 ++-- src/tui/components/ItemsEditor.tsx | 2 +- .../components/__tests__/ColorMenu.test.tsx | 16 ++++++++-------- .../components/__tests__/ItemsEditor.test.tsx | 18 +++++++++--------- .../__tests__/input-handlers.test.ts | 4 ++-- .../components/items-editor/input-handlers.ts | 2 +- 6 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index e85c7d544..a3dbdb1d2 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -343,7 +343,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin : undefined; // Rules are authored in the widget editor, so this only opens widgets that have some - if (input === 'E') { + if (input === '+') { if (highlightedWidget && getRuleCount(highlightedWidget) > 0) { accordion.toggleExpand(highlightedWidget.id); } @@ -759,7 +759,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin {!settings.powerline.enabled && !settings.defaultSeparator ? ` (s)how separators: ${showSeparators ? 'ON' : 'OFF'},` : ''} - {highlightedHasRules ? ' ⇧E rules,' : ''} + {highlightedHasRules ? ' (+) rules,' : ''} {onTabSwap ? ' ⇥ edit widgets,' : ''} {' '} {accordion.expandedWidgetId !== null ? 'ESC to collapse' : 'ESC to go back'} diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 57a42c4e4..1d03a787d 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -379,7 +379,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB helpText += ', ⇥ edit colors'; } if (hasWidgets) { - helpText += ', ⇧E rules'; + helpText += ', (+) rules'; } helpText += ', ESC back'; } diff --git a/src/tui/components/__tests__/ColorMenu.test.tsx b/src/tui/components/__tests__/ColorMenu.test.tsx index 4fabf3b55..07eb2eef7 100644 --- a/src/tui/components/__tests__/ColorMenu.test.tsx +++ b/src/tui/components/__tests__/ColorMenu.test.tsx @@ -148,7 +148,7 @@ describe('ColorMenu rules accordion', () => { try { expect(getOutput()).not.toContain('when context-percentage greater than 80'); - stdin.write('E'); + stdin.write('+'); await settleInputHandler(); expect(getOutput()).toContain('when context-percentage greater than 80'); @@ -161,7 +161,7 @@ describe('ColorMenu rules accordion', () => { const { stdin, getOutput, teardown } = await renderColorMenu(); try { - stdin.write('E'); + stdin.write('+'); await settleInputHandler(); stdin.write('\x1B[B'); @@ -177,7 +177,7 @@ describe('ColorMenu rules accordion', () => { const { stdin, onBack, teardown } = await renderColorMenu(); try { - stdin.write('E'); + stdin.write('+'); await settleInputHandler(); stdin.write('\x1B'); @@ -198,7 +198,7 @@ describe('ColorMenu rules accordion', () => { ); try { - stdin.write('E'); + stdin.write('+'); await settleInputHandler(); expect(onAccordionChange).not.toHaveBeenCalled(); @@ -211,7 +211,7 @@ describe('ColorMenu rules accordion', () => { const { stdin, onUpdate, teardown } = await renderColorMenu(); try { - stdin.write('E'); + stdin.write('+'); await settleInputHandler(); stdin.write('\x1B[C'); // right arrow cycles colour @@ -230,7 +230,7 @@ describe('ColorMenu rules accordion', () => { const { stdin, onUpdate, teardown } = await renderColorMenu(); try { - stdin.write('E'); + stdin.write('+'); await settleInputHandler(); stdin.write('b'); @@ -258,7 +258,7 @@ describe('ColorMenu rules accordion', () => { const { stdin, onUpdate, teardown } = await renderColorMenu(RULED_WIDGETS, { settings: themed }); try { - stdin.write('E'); + stdin.write('+'); await settleInputHandler(); stdin.write('\x1B[C'); @@ -288,7 +288,7 @@ describe('ColorMenu rules accordion', () => { const { stdin, onUpdate, teardown } = await renderColorMenu(pinned, { settings: themed }); try { - stdin.write('E'); + stdin.write('+'); await settleInputHandler(); stdin.write('\x1B[C'); diff --git a/src/tui/components/__tests__/ItemsEditor.test.tsx b/src/tui/components/__tests__/ItemsEditor.test.tsx index 1fe41f8c0..5270bf336 100644 --- a/src/tui/components/__tests__/ItemsEditor.test.tsx +++ b/src/tui/components/__tests__/ItemsEditor.test.tsx @@ -278,7 +278,7 @@ describe('ItemsEditor', () => { try { expect(getOutput()).not.toContain('when context-percentage greater than 80'); - stdin.write('E'); + stdin.write('+'); await flushInk(); expect(getOutput()).toContain('when context-percentage greater than 80'); @@ -291,7 +291,7 @@ describe('ItemsEditor', () => { const { stdin, getOutput, teardown } = await renderItemsEditor([{ id: '1', type: 'model' }]); try { - stdin.write('E'); + stdin.write('+'); await flushInk(); expect(getOutput()).toContain('No rules'); @@ -313,7 +313,7 @@ describe('ItemsEditor', () => { ]); try { - stdin.write('E'); + stdin.write('+'); await settleInputHandler(); stdin.write(''); // down arrow await flushInk(); @@ -334,7 +334,7 @@ describe('ItemsEditor', () => { ]); try { - stdin.write('E'); + stdin.write('+'); await settleInputHandler(); stdin.write('\x1B'); // escape @@ -351,7 +351,7 @@ describe('ItemsEditor', () => { const { stdin, onUpdate, teardown } = await renderItemsEditor([{ id: '1', type: 'model' }]); try { - stdin.write('E'); + stdin.write('+'); await settleInputHandler(); stdin.write('a'); @@ -374,7 +374,7 @@ describe('ItemsEditor', () => { ]); try { - stdin.write('E'); + stdin.write('+'); await settleInputHandler(); stdin.write('e'); @@ -422,7 +422,7 @@ describe('ItemsEditor', () => { ); try { - stdin.write('E'); + stdin.write('+'); await settleInputHandler(); expect(onAccordionChange).toHaveBeenCalledWith({ expandedWidgetId: '1', selectedRuleIndex: 0 }); @@ -439,9 +439,9 @@ describe('ItemsEditor', () => { ); try { - expect(getOutput()).toContain('⇧E rules'); + expect(getOutput()).toContain('(+) rules'); - stdin.write('E'); + stdin.write('+'); await settleInputHandler(); expect(getOutput()).toContain('ESC collapse'); 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 879834c8d..9e656a47b 100644 --- a/src/tui/components/items-editor/__tests__/input-handlers.test.ts +++ b/src/tui/components/items-editor/__tests__/input-handlers.test.ts @@ -1134,10 +1134,10 @@ describe('rules accordion shortcut', () => { }; } - it('opens the accordion on E', () => { + it('opens the accordion on +', () => { const onToggleAccordion = vi.fn(); - handleNormalInputMode(normalModeArgs({ input: 'E', onToggleAccordion })); + handleNormalInputMode(normalModeArgs({ input: '+', onToggleAccordion })); expect(onToggleAccordion).toHaveBeenCalledWith('1'); }); diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index 0b22c4969..cdeccbc6c 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -502,7 +502,7 @@ export function handleNormalInputMode({ openWidgetPicker('change'); } else if (key.return && widgets.length > 0) { setMoveMode(true); - } else if (input === 'E' && onToggleAccordion && widgets.length > 0) { + } else if (input === '+' && onToggleAccordion && widgets.length > 0) { const widget = widgets[selectedIndex]; if (widget) { onToggleAccordion(widget.id); From ea843f4fd21450276e7fd7b13b1fd508448ef91c Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 13:50:41 +1000 Subject: [PATCH 51/52] feat(tui): show a rule in the colour it renders A rule row is now painted through the same styleWidgetRowLabel as a widget row, over the widget the rule effectively produces - its overrides on top of the widget, the merge the renderer performs. Selection stays on the marker so the colour is free to show the rule's own styling. The colour editor's current-style row follows the same target. It read the widget's colour and bold while the colour keys were writing to the selected rule, so cycling a rule's colour changed the config with nothing moving on screen. It now describes whatever the keys are aimed at. Under a theme an unpinned channel still shows the theme colour here, which is honest: that is what the rule would render as until the channel is pinned. --- src/tui/components/ColorMenu.tsx | 37 +++++++++--- src/tui/components/ItemsEditor.tsx | 14 ++++- src/tui/components/RuleRow.tsx | 11 +++- src/tui/components/WidgetRow.tsx | 12 ++++ .../components/__tests__/ColorMenu.test.tsx | 56 ++++++++++++++++++- .../components/__tests__/ItemsEditor.test.tsx | 5 +- 6 files changed, 120 insertions(+), 15 deletions(-) diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index a3dbdb1d2..3b9972d50 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -31,9 +31,13 @@ import { } from '../hooks/useRuleAccordion'; import { ConfirmDialog } from './ConfirmDialog'; -import { RuleRow } from './RuleRow'; +import { + RuleRow, + formatConditionText +} from './RuleRow'; import { WidgetRow, + getRuleEffectiveWidget, getWidgetRowLabel, getWidgetRowTags, getWidgetRuleBadge, @@ -516,6 +520,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin modifierText, tags: getWidgetRowTags(widgets, index, settings), badge: getWidgetRuleBadge(widget), + widget, rules: widget.rules ?? [], expanded: accordion.isExpanded(widget.id) }; @@ -528,11 +533,21 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin const selectedWidget = highlightedItemId ? colorableWidgets.find(widget => widget.id === highlightedItemId) : null; + // The colour keys follow the accordion, so the current-style row has to describe the same + // target: the selected rule's overrides over the widget, or the widget on its own. + const selectedRuleIndex = selectedWidget ? getTargetRuleIndex(selectedWidget.id) : undefined; + const selectedRule = selectedRuleIndex !== undefined + ? selectedWidget?.rules?.[selectedRuleIndex] + : undefined; + const styleSource = selectedWidget && selectedRule + ? getRuleEffectiveWidget(selectedWidget, selectedRule) + : selectedWidget; + const storedColor = editingBackground - ? (selectedWidget?.backgroundColor ?? '') // Empty string for 'none' - : (selectedWidget ? (selectedWidget.color ?? (() => { - if (selectedWidget.type !== 'separator' && selectedWidget.type !== 'flex-separator') { - const widgetImpl = getWidget(selectedWidget.type); + ? (styleSource?.backgroundColor ?? '') // Empty string for 'none' + : (styleSource ? (styleSource.color ?? (() => { + if (styleSource.type !== 'separator' && styleSource.type !== 'flex-separator') { + const widgetImpl = getWidget(styleSource.type); return widgetImpl ? widgetImpl.getDefaultColor() : 'white'; } return 'white'; @@ -604,9 +619,9 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin ? '- theme applies, press (p) to override' : ''; const styleIndicators = [ - selectedWidget?.bold ? '[BOLD]' : null, - selectedWidget?.dim === true ? '[DIM]' : null, - selectedWidget?.dim === 'parens' ? '[DIM ()]' : null + styleSource?.bold ? '[BOLD]' : null, + styleSource?.dim === true ? '[DIM]' : null, + styleSource?.dim === 'parens' ? '[DIM ()]' : null ].filter(indicator => indicator !== null).join(' '); // Gradient selection mode takes over the whole view @@ -804,6 +819,12 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin key={ruleIndex} rule={rule} isSelected={ruleIndex === accordion.selectedRuleIndex} + styledCondition={styleWidgetRowLabel( + formatConditionText(rule.when), + getRuleEffectiveWidget(row.widget, rule), + settings, + effectiveThemeColors.get(row.id) + )} /> ))} diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 1d03a787d..4aa6b6806 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -34,9 +34,13 @@ import { import { ConditionEditor } from './ConditionEditor'; import { ConfirmDialog } from './ConfirmDialog'; -import { RuleRow } from './RuleRow'; +import { + RuleRow, + formatConditionText +} from './RuleRow'; import { WidgetRow, + getRuleEffectiveWidget, getWidgetRowLabel, getWidgetRowTags, getWidgetRuleBadge, @@ -673,6 +677,14 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB key={ruleIndex} rule={rule} isSelected={ruleIndex === accordion.selectedRuleIndex} + styledCondition={moveMode + ? undefined + : styleWidgetRowLabel( + formatConditionText(rule.when), + getRuleEffectiveWidget(widget, rule), + settings, + effectiveThemeColors.get(widget.id) + )} /> )) )} diff --git a/src/tui/components/RuleRow.tsx b/src/tui/components/RuleRow.tsx index 5b36af763..d4bdda4be 100644 --- a/src/tui/components/RuleRow.tsx +++ b/src/tui/components/RuleRow.tsx @@ -51,17 +51,24 @@ export function formatApplyProperties(apply: RuleApply): string { export interface RuleRowProps { rule: Rule; isSelected: boolean; + /** + * The condition text already painted with the colour this rule renders in, so a rule row + * previews its own styling the way a widget row does. Selection stays on the marker. + */ + styledCondition?: string; } /** One rule beneath its widget in the accordion, shown the same way in both editor modes. */ -export const RuleRow: React.FC = ({ rule, isSelected }) => { +export const RuleRow: React.FC = ({ rule, isSelected, styledCondition }) => { const color = isSelected ? 'cyan' : 'gray'; const applyText = formatApplyProperties(rule.apply); return ( {isSelected ? '› ' : ' '} - {formatConditionText(rule.when)} + + {styledCondition ?? formatConditionText(rule.when)} + {applyText && ( {` -> ${applyText}`} diff --git a/src/tui/components/WidgetRow.tsx b/src/tui/components/WidgetRow.tsx index 8c3da1195..64a98464c 100644 --- a/src/tui/components/WidgetRow.tsx +++ b/src/tui/components/WidgetRow.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { getColorLevelString } from '../../types/ColorLevel'; import type { Settings } from '../../types/Settings'; import type { + Rule, WidgetEditorDisplay, WidgetItem } from '../../types/Widget'; @@ -126,6 +127,17 @@ export function getWidgetRuleBadge(widget: WidgetItem): string | null { return `[${count} ${count === 1 ? 'rule' : 'rules'}]`; } +/** + * The widget a rule effectively renders as: the rule's overrides on top of the widget, the + * same merge the renderer performs. Lets a rule row be painted through styleWidgetRowLabel. + */ +export function getRuleEffectiveWidget(widget: WidgetItem, rule: Rule): WidgetItem { + return { + ...widget, + ...rule.apply + }; +} + /** * Paint a row label with the colour that widget renders in, so both editor modes agree. * themeChannels supplies the channels an active theme takes over; the widget's own colour diff --git a/src/tui/components/__tests__/ColorMenu.test.tsx b/src/tui/components/__tests__/ColorMenu.test.tsx index 07eb2eef7..9a47e21f7 100644 --- a/src/tui/components/__tests__/ColorMenu.test.tsx +++ b/src/tui/components/__tests__/ColorMenu.test.tsx @@ -167,7 +167,7 @@ describe('ColorMenu rules accordion', () => { stdin.write('\x1B[B'); await settleInputHandler(); - expect(getOutput()).toContain('› when context-percentage greater than 90'); + expect(stripAnsi(getOutput())).toContain('› when context-percentage greater than 90'); } finally { teardown(); } @@ -301,6 +301,58 @@ describe('ColorMenu rules accordion', () => { } }); + // Named colours come back unstyled under vitest because chalk sees no colour support, + // so this asserts on a hex colour, whose ANSI is built directly. + it('tints a rule row with the colour that rule renders in', async () => { + const widgets: WidgetItem[] = [{ + id: '1', + type: 'model', + color: 'hex:0000FF', + rules: [{ when: { widget: 'context-percentage', greaterThan: 80 }, apply: { color: 'hex:FF0000' } }] + }]; + const { stdin, getOutput, teardown } = await renderColorMenu(widgets, { + settings: { + ...DEFAULT_SETTINGS, + colorLevel: 3 + } + }); + + try { + stdin.write('+'); + await settleInputHandler(); + + expect(getOutput()).toContain( + applyColors('when context-percentage greater than 80', 'hex:FF0000', undefined, undefined, 'truecolor', undefined) + ); + } finally { + teardown(); + } + }); + + it('describes the selected rule, not the widget, in the current-style row', async () => { + const widgets: WidgetItem[] = [{ + id: '1', + type: 'model', + color: 'blue', + rules: [{ when: { widget: 'context-percentage', greaterThan: 80 }, apply: { color: 'red', bold: true } }] + }]; + const { stdin, getOutput, teardown } = await renderColorMenu(widgets); + + try { + stdin.write('+'); + await settleInputHandler(); + + const frame = getOutput().split('Edit Line 1').at(-1) ?? ''; + const currentStyleLine = frame.split('\n').find(line => line.includes('Current (')) ?? ''; + + expect(stripAnsi(currentStyleLine)).toContain('Red'); + expect(stripAnsi(currentStyleLine)).not.toContain('Blue'); + expect(currentStyleLine).toContain('[BOLD]'); + } finally { + teardown(); + } + }); + it('carries the accordion in from the widget editor', async () => { const { getOutput, teardown } = await renderColorMenu( RULED_WIDGETS, @@ -308,7 +360,7 @@ describe('ColorMenu rules accordion', () => { ); try { - expect(getOutput()).toContain('› when context-percentage greater than 90'); + expect(stripAnsi(getOutput())).toContain('› when context-percentage greater than 90'); } finally { teardown(); } diff --git a/src/tui/components/__tests__/ItemsEditor.test.tsx b/src/tui/components/__tests__/ItemsEditor.test.tsx index 5270bf336..405faad33 100644 --- a/src/tui/components/__tests__/ItemsEditor.test.tsx +++ b/src/tui/components/__tests__/ItemsEditor.test.tsx @@ -1,6 +1,7 @@ import { render } from 'ink'; import { PassThrough } from 'node:stream'; import React from 'react'; +import stripAnsi from 'strip-ansi'; import { describe, expect, @@ -318,7 +319,7 @@ describe('ItemsEditor', () => { stdin.write(''); // down arrow await flushInk(); - expect(getOutput()).toContain('› when context-percentage greater than 90'); + expect(stripAnsi(getOutput())).toContain('› when context-percentage greater than 90'); } finally { teardown(); } @@ -403,7 +404,7 @@ describe('ItemsEditor', () => { ); try { - expect(output).toContain('› when context-percentage greater than 90'); + expect(stripAnsi(output)).toContain('› when context-percentage greater than 90'); } finally { teardown(); } From 806e8ae1bbf4b74d1517358c5003604b0347b8a0 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Wed, 29 Jul 2026 13:59:57 +1000 Subject: [PATCH 52/52] docs: document widget rules USAGE.md gains a Widget Rules section covering what a rule is, how evaluation order and the stop flag work, the authoring keys, the operators each value type offers, the settings.json shape, and how styling a rule in the colour editor interacts with Powerline pinning. The keybind reference gains (+) and the rule-level shortcuts. The carried design spec gains an As Built section for the decisions the shipped code makes differently - the (+) keybind, the WidgetRow/RuleRow substrate, the pin gate on rule colours, dim having no rule equivalent - and the implementation plan is marked superseded in its TUI details rather than rewritten, since it is a record of the original sequencing. --- README.md | 1 + docs/USAGE.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/README.md b/README.md index e7ce83c1e..4dad24187 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,7 @@ - **📊 Real-time Metrics** - Display model name, git branch, token usage, Sonnet/Opus/Fable weekly usage, extra usage limits, voice input state, session duration, compaction count, block timer, and more - **🎨 Fully Customizable** - Choose what to display and customize colors for each element +- **🧩 Widget Rules** - Override a widget's color, visibility, or text when a live value meets a condition - **⚡ Powerline Support** - Beautiful Powerline-style rendering with arrow separators, caps, and custom fonts - **📐 Multi-line Support** - Configure multiple independent status lines - **🖥️ Interactive TUI** - Built-in configuration interface using React/Ink diff --git a/docs/USAGE.md b/docs/USAGE.md index 9cd46fe1d..27fee252b 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -161,6 +161,65 @@ Gradients apply at **two scopes**: Gradients self-degrade where they can't render: at Basic or No Color levels, gradient settings are preserved but render as plain text. In Powerline mode, global foreground gradients color widget text while separators and caps keep Powerline's normal foreground/background contrast rules; per-widget gradients collapse to their first stop when using 256-color or truecolor output. +## Widget Rules + +A widget can carry **rules** that override its own settings when a condition holds — turn a context percentage red once it climbs past 80%, hide a widget while its value is zero, or swap the text it shows. Rules are evaluated top to bottom on every render and later matches win, so put general rules first and specific ones last. A rule marked **stop** ends evaluation for that widget when it matches. + +Press `+` on a widget in either editor mode to open its rules. Rows carrying rules are badged, and each rule appears as an indented sub-row showing its condition, what it overrides, and whether it stops: + +``` +▶ 1. Context % [2 rules] + › when context-percentage greater than 80 -> color: red + when context-percentage greater than 90 -> color: red, bold: true [STOP] +``` + +Each rule row is painted in the color that rule renders in, so the list previews what it will do. + +### Authoring Rules + +Rules are created in the widget editor (`[WIDGETS]`). With a widget expanded: + +- `↑/↓` select a rule +- `a` add a rule below the selected one +- `d` delete the selected rule +- `s` toggle the rule's stop flag +- `e` or `Enter` edit the condition +- `j` / `k` reorder the selected rule (order is evaluation order) +- `⇥` switch to color editing with the same rule still selected +- `Esc` collapse + +Deleting the last rule leaves the accordion open on an empty list, so `a` can start again without reopening it. + +A condition names a widget to evaluate, an operator, and a value — the condition editor picks the widget from the same catalog the type picker uses, then offers only the operators that suit that widget's value type: + +- **Numeric** — `equals`, `greater than`, `greater than or equal`, `less than`, `less than or equal` +- **String** — `equals`, `contains`, `starts with`, `ends with` +- **Boolean** — `equals` +- **Existence** — `is null`, `is not null` + +Any condition can be negated. A rule can override `color`, `backgroundColor`, `bold`, `rawValue`, `customText` and `hide`. + +Rules are stored on the widget in `settings.json`: + +```json +{ + "id": "…", + "type": "context-percentage", + "rules": [ + { "when": { "widget": "context-percentage", "greaterThan": 80 }, "apply": { "color": "red" } }, + { "when": { "widget": "context-percentage", "greaterThan": 90 }, "apply": { "bold": true }, "stop": true } + ] +} +``` + +### Styling a Rule + +In the color editor, expanding a widget and selecting a rule aims the color keys at that rule: `←`/`→`, `f`, `b`, `h`, `a` and `r` write to the rule instead of the widget. The current-style row describes the selected rule, so `Current (…)` and `[BOLD]` reflect the rule rather than its widget. + +Rules cannot be created or deleted here — `a`, `d` and `s` keep their color-editor meanings — so `+` only opens widgets that already have rules. Dim has no rule equivalent, so `d` always edits the widget's dim. + +Under a Powerline theme, rule colors follow the same pin rule as widget colors (see [Pinning Colors Over a Powerline Theme](#pinning-colors-over-a-powerline-theme)): while the theme owns a channel, a rule color would never be visible, so the color keys do nothing until that channel is pinned. Pinning makes the widget override the theme, which is what lets its rules override it further. An unpinned rule row shows the theme's color, which is what it would actually render as. Bold is not theme-driven and stays available. + ## Claude Code Status Line Settings When ccstatusline is installed in Claude Code, the main menu includes **Configure Status Line**. Claude Code versions >=2.1.97 support `statusLine.refreshInterval`; ccstatusline can set it to `1-60` seconds, defaults fresh supported installs to `10` seconds, and removes the setting when the input is left empty. @@ -225,9 +284,12 @@ Common controls in the line editor: - `r` toggle raw value (supported widgets) - `m` cycle merge mode (`off` → `merge` → `merge no padding`) - `x` exclude the selected widget and the rest of its line from shared Powerline column widths (shown only when Powerline auto-alignment is enabled) +- `+` open the selected widget's rules (see [Widget Rules](#widget-rules)) - `⇥` switch to color editing for the selected widget, and back again (see [Widget Styling](#widget-styling)) - `Esc` go back to the line selector, from either mode +With a widget's rules open, the rule list takes over the keyboard and the footer switches to its own shortcuts — `↑/↓` select, `a` add, `d` delete, `s` stop, `e`/`Enter` edit condition, `j`/`k` reorder, `Esc` collapse. `⇥` still swaps editor modes and carries the open rule with it. + Widget picker: - type to search categories and widgets - supports substring, initialism, and fuzzy matching