Repository navigation
Conversation
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]>
… hidden when unavailable
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
force-pushed
the
main
branch
3 times, most recently
from
May 12, 2026 04:01
4f7a07b to
ec28376
Compare
# Conflicts: # src/tui/App.tsx
# Conflicts: # src/tui/components/ItemsEditor.tsx # src/tui/components/items-editor/input-handlers.ts
Co-Authored-By: Claude Opus 4.8 <[email protected]>
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]>
…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.
hangie
force-pushed
the
feat/rules-engine-v2
branch
from
July 29, 2026 04:08
f472047 to
806e8ae
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
getValueType()/getValue()on Widget interface, sharedgetValueFromRenderhelper, pure value parsers (tokens, percentage, currency, speed, int, boolean)preRenderAllWidgets(), overrides applied before rendering and color applicationrulesarray on WidgetItem with typedwhen(condition) andapply(restricted to appearance properties)TUI (accordion pattern)
rule.applyWidget getValue implementations
getNumericValuetogetValue/getValueTypegetValueto ~15 token, context, cost, speed, and usage widgets using shared helpersDesign decisions
getValue/getValueTypeare optional interface methods. Existing widgets and contributor PRs are unaffected.applyis a restricted partial WidgetItem — only appearance properties (color, backgroundColor, bold, rawValue, customText, hide). Structural properties excluded from schema.getValuestill support string conditions (equals, contains, etc.) via render output. Only numeric/boolean operators require explicit implementation.getValueFromRender(widget, context, item, parser)helper eliminates duplicated render-then-parse boilerplate across widgets.Test plan