Skip to content

feat: widget rules engine with conditional property overrides - #347

Draft
hangie wants to merge 55 commits into
sirmalloc:mainfrom
hangie:feat/rules-engine-v2
Draft

hangie wants to merge 55 commits into
sirmalloc:mainfrom
hangie:feat/rules-engine-v2

Conversation

@hangie

@hangie hangie commented Apr 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds a rules engine that lets widgets change color, visibility, and display properties based on live context. For example, turn the context percentage red when it exceeds 80%, or hide a widget when a condition isn't met.

Rules are configured per-widget through the TUI using the same accordion pattern in both the item editor and the color editor. The item editor handles rule structure (add, delete, reorder, conditions), while the color editor handles rule appearance (color, backgroundColor, bold overrides) — the same keybinds work at both widget and rule level.

Closes #38

Depends on: #346 (Tab-swap between editors)

What's included

Rules engine (data layer)

  • Condition types — string, numeric, boolean, and existence operators with typed evaluation
  • Value extraction — getValueType() / getValue() on Widget interface, shared getValueFromRender helper, pure value parsers (tokens, percentage, currency, speed, int, boolean)
  • Rule evaluation — condition matching with cross-widget references, rule stacking, stop flag, property merging
  • Renderer integration — rules evaluated in preRenderAllWidgets(), overrides applied before rendering and color application
  • Schema — rules array on WidgetItem with typed when (condition) and apply (restricted to appearance properties)

TUI (accordion pattern)

  • Shared accordion hook — expand/collapse, rule navigation, state preserved across Tab-swap
  • ItemsEditor — rule count badges, accordion sub-rows, add/delete/reorder rules, toggle stop, condition editor overlay
  • ColorMenu — same accordion UI, rule-level color editing using existing color keybinds targeting rule.apply
  • ConditionEditor — overlay with widget type picker, operator selector (filtered by value type), value input, negation toggle

Widget getValue implementations

  • Migrated 8 git widgets from getNumericValue to getValue/getValueType
  • Added getValue to ~15 token, context, cost, speed, and usage widgets using shared helpers
  • All other widgets get string-based rules for free via render fallback

Design decisions

  • No base class changes — getValue/getValueType are optional interface methods. Existing widgets and contributor PRs are unaffected.
  • apply is a restricted partial WidgetItem — only appearance properties (color, backgroundColor, bold, rawValue, customText, hide). Structural properties excluded from schema.
  • String fallback for all widgets — widgets that don't implement getValue still support string conditions (equals, contains, etc.) via render output. Only numeric/boolean operators require explicit implementation.
  • Accordion in both editors — identical expand/collapse pattern. Item editor manages rule structure, color editor manages rule appearance. Tab-swap preserves accordion state.
  • DRY value extraction — shared getValueFromRender(widget, context, item, parser) helper eliminates duplicated render-then-parse boilerplate across widgets.

Test plan

  • Condition types — 42 tests covering operators, helpers, classification
  • Value parsers — 21 tests for all parser functions with edge cases
  • Widget value extraction — 18 tests for dispatch, fallback, null handling
  • Rules engine core — 69 tests for all operators, negation, stacking, stop, cross-widget, type mismatches
  • Renderer integration — 15 tests for rule overrides, hide, stacking, stop
  • Accordion hook — 39 tests for state transitions and edge cases
  • All 1354 existing tests pass
  • Manual TUI testing: create rules, edit conditions, edit colors, Tab-swap with accordion, verify status line renders with overrides

hangie and others added 8 commits April 29, 2026 09:54
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.
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.
- 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
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Verify Tab key behavior: calls onTabSwap for colorable widgets,
skips separators and flex-separators, and does nothing when
onTabSwap is not provided.
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.
@sirmalloc
sirmalloc force-pushed the main branch 3 times, most recently from 4f7a07b to ec28376 Compare May 12, 2026 04:01
hangie and others added 17 commits July 13, 2026 12:24
# Conflicts:
#	src/tui/components/ItemsEditor.tsx
#	src/tui/components/items-editor/input-handlers.ts
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
hangie and others added 28 commits July 29, 2026 08:07
…heme

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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
Two conflicts, both from this branch's own refactors meeting new work on
main:

- src/tui/App.tsx: main added applyTuiImport beside the navigation helpers
  this branch extracted, and added exportConfig/importConfig cases to the
  main-menu switch that this branch had replaced with
  getMainMenuScreenTarget. Kept both additions, and moved main's two new
  options into the helper with the other direct navigations rather than
  leaving two mechanisms for the same job. Covered by App.test.ts.
- PowerlineThemeSelector.test.ts: main independently hit the same ink
  flake and added waitForInkCondition, polling with a silent 1s timeout.
  Kept main's name so future merges do not reintroduce it, with this
  branch's semantics - a named step and a throw on timeout, so a stall
  reports which step stalled instead of failing an assertion later. The
  label is optional, so main's call style still compiles.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
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.
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.
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.
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.
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.
…sage 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.
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.
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.
…d 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add dynamic color feature based on widget values

1 participant