Skip to content

feat: unified TUI editing with rules engine and typed widget values#306

Open
hangie wants to merge 54 commits intosirmalloc:mainfrom
hangie:feat/unified-editing-v2
Open

feat: unified TUI editing with rules engine and typed widget values#306
hangie wants to merge 54 commits intosirmalloc:mainfrom
hangie:feat/unified-editing-v2

Conversation

@hangie
Copy link
Copy Markdown
Contributor

@hangie hangie commented Apr 15, 2026

Summary

  • Unified editing experience — merged color editing into the main Items Editor via Tab toggle, removing the separate Edit Colors screen and ColorMenu component entirely
  • Widget rules engine — conditional property overrides (color, bold, hide, etc.) based on widget values with numeric, string, boolean, and set operators
  • Typed widget values — each widget declares its value type (number, boolean, or string fallback) so the rules engine evaluates conditions using the widget's own typed value extraction instead of fragile render-and-parse heuristics
  • Condition editor operator filtering — the operator picker now shows only relevant operator categories based on the target widget's declared value type
  • Improved TUI navigation — arrow key navigation (←→ for back/forward, ↑↓ wrap), Enter/focus mode for editors, confirmation dialog on unsaved exit, normalized keybind matching

Details

Rules Engine

  • Rules defined per-widget, evaluated top-to-bottom with optional stop flag
  • Supports cross-widget conditions (e.g., change color of one widget based on another's value)
  • Operators: >, >=, <, <=, =, (numeric); contains, startsWith, endsWith, equals, isEmpty (string); isTrue, isFalse (boolean); in, notIn (set)
  • Preview mode uses consistent mock data for deterministic rule evaluation

Typed Widget Values

  • 15 number widgets: ContextPercentage, ContextPercentageUsable, ContextLength, Tokens (Input/Output/Cached/Total), SessionCost, Speeds (Input/Output/Total), TerminalWidth, SessionUsage, WeeklyUsage, GitConflicts
  • 6 boolean widgets: GitStaged, GitUnstaged, GitUntracked, GitIsFork, GitWorktreeMode, GitChanges
  • Shared parse helpers (value-parsers.ts) for consistent value extraction
  • Removed getNumericValue from Widget interface, replaced with getValueType()/getValue()
  • Removed parseNumericValue heuristics and supportsNumericValue — widgets own their own parsing
  • GitAheadBehind: removed incorrect sum-based numeric value (compound pair, no single interpretation)

TUI Improvements

  • Color editing integrated into ItemsEditor (Tab to toggle modes)
  • ← arrow navigates back from any screen
  • ↑↓ wraps at list boundaries
  • PowerlineSeparatorEditor uses Enter/focus mode
  • Confirmation dialog when exiting with unsaved changes

Test plan

  • 965 tests passing (1 pre-existing unrelated failure in usage-fetch proxy detection)
  • Lint clean (no project file errors)
  • Verify rules evaluate correctly in preview for number, boolean, and string widgets
  • Verify operator picker filters categories based on widget value type
  • Test TUI navigation: ← back, Tab color toggle, Enter focus mode

🤖 Generated with Claude Code

hangie and others added 30 commits April 13, 2026 17:37
Implements conditional property override system for widgets (sirmalloc#38):
- Rules execute top-to-bottom with optional stop flags
- Numeric, string, boolean, and set operators
- Cross-widget conditions (change one widget based on another's value)
- Generic hide property for all widgets
- TUI editors for rules and conditions
- Renderer integration for rule-applied colors, bold, and hide

Includes RulesEditor, ConditionEditor, ColorEditor TUI components,
rules engine core with widget value extraction, and full test coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… operators

Add missing string operators (equals, not equals, isEmpty, notEmpty) to the
rules engine. Replace symbol-based operator labels (>, ≥, <, ≤, =, ≠) with
readable text labels (greater than, equals, etc.) for better usability. Reorder
operator picker to pair each operator with its negation. Fix condition editor
displaying wrong label for boolean false conditions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wires up the mode toggle infrastructure so Tab key switches between
'items' and 'color' modes in ItemsEditor, following the pattern
established in RulesEditor. Uses widget's supportsColors() method to
determine if Tab toggle is applicable for the selected widget.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…upport colors

Prevents input misrouting by detecting at the top of the useInput handler
that editorMode is 'color' but the currently selected widget doesn't support
colors, and resetting back to 'items' mode. Also updates the Tab block
comment to clarify it always consumes the Tab key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When editorMode is 'color', intercept input before widget picker/move mode
handlers and delegate to the shared handleColorInput. Up/down arrows still
navigate widgets, ESC cancels hex/ansi256 sub-modes or returns to items mode,
and all other keys (left/right cycle colors, f/b/h/a/r) go through the
shared color handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…olor mode

Extract updateWidget callback before the ESC check and collapse the ESC
branch so that sub-mode ESC falls through to handleColorInput rather than
duplicating the inline closure. Add defensive comment on the unconditional
return at the end of the color block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add visual feedback when in color mode: magenta mode indicator in title
bar, current color info display with styled preview, hex/ANSI256 input
prompts, styled widget labels using applyColors, and magenta selector
arrow. Separators shown as dimmed in color mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Build help text dynamically via buildHelpText() that switches between
items mode and color mode keybinds. Color mode shows cycle/bold/reset
hints plus hex/ansi256 conditionally on colorLevel. ESC now shows its
destination in both modes. Rename "(x) exceptions" to "(x) rules".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove the separate 'Edit Colors' entry point from MainMenu since color
editing is now unified into the ItemsEditor via Tab toggle. Update
menuSelections index references in App.tsx for terminalConfig (3→2) and
globalOverrides (4→3) to account for the removed item.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The colorLines and colors AppScreen values became unreachable after Task 2.1
removed the 'Edit Colors' menu entry. This commit removes those screen states
from the AppScreen type union, their JSX render blocks, and the ColorMenu import
from the barrel import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ColorMenu and its array-based mutation helpers are now fully replaced
by the shared handleColorInput handler in the unified editing flow.
Removes ColorMenu.tsx, color-menu/mutations.ts, and its test file,
and drops the ColorMenu export from the components barrel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tor module

Create dedicated files for rule property input handling and condition/property
formatting, extracted from RulesEditor.tsx for reuse by ItemsEditor's accordion mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add remaining rule-level input handlers to rules-editor/input-handlers.ts:
- handleRuleColorInput: wraps shared handleColorInput with rule-aware
  onUpdate/onReset callbacks that route through extractWidgetOverrides
- handleRuleMoveMode: swap rules on arrow keys, exit on Enter/ESC
- addRule/deleteRule: CRUD with selection adjustment
- handleRuleEditorComplete: custom editor completion routed through
  extractWidgetOverrides for rule.apply diff storage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the RulesEditor overlay with inline accordion state management.
The x key now toggles expandedWidgetId (by widget id, survives reordering)
and resets all rule-level state on expansion. Remove RulesEditor import
and overlay rendering block.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Route keyboard input to rule-level handlers when expandedWidgetId is set.
Handles condition editor passthrough, rule move mode, rule color mode with
ESC peeling, and rule property mode with add/delete/condition editor keys.
Updated handleEditorComplete to route through handleRuleEditorComplete when
rules are expanded. No fallthrough to widget-level handlers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Render rules inline below their parent widget when expanded via 'x' key.
Each rule line shows indented selector, styled label with rule colors,
condition summary, stop indicator, and applied properties. Parent widget
loses selector arrow and (N rules) annotation when expanded. Selector
colors match mode: green for property, magenta for color, blue for move.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…play

- buildHelpText() now checks expandedWidgetId first and returns context-appropriate
  help for rule move mode, rule color mode, and rule property mode (with widget
  custom keybinds, raw value, and merge hints)
- Title bar shows "Edit Line N — Rules for {widgetDisplayName}" when rules are
  expanded; mode indicators (MOVE/COLOR) respect whether we are in rule-level or
  widget-level context
- Rule-level color info display shows current foreground/background using
  mergeWidgetWithRuleApply(baseWidget, rule.apply) as the temp widget
- Hex/ANSI256 input prompts rendered at rule level using ruleColorEditorState

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add ConditionEditor import and render it as an early-return overlay when
ruleConditionEditorIndex is set and an expanded widget exists. The onSave
callback updates the rule's when condition in the full widgets array and
calls onUpdate, then clears the editor index.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaced by accordion rules editor inline in ItemsEditor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Normal mode: → expands rules for selected widget, ← collapses if
expanded. Move mode: ←→ opens the type picker. The `x` key no longer
triggers rules expansion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
In items mode, replace '←→ open type picker' with '→ expand rules' and
remove '(x) rules' since → now handles rule expansion directly. In move
mode, add '←→ change type' alongside the existing move instructions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When an accordion is expanded (→ pressed) on a widget with zero rules,
display an indented dimmed message '(no rules — press 'a' to add)'
instead of rendering nothing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ve mode

Move ←→ condition editor opening from rule property mode to rule move
mode (focus mode), mirroring the widget-level pattern. In property mode,
← now collapses rules and → is a no-op. In move mode, ←→ opens the
condition editor and exits move mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove stale ←→ edit condition from rule property mode (where left arrow
now collapses) and add it to rule move mode where the keybind actually
lives. Also add ← collapse hint to rule property mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Right arrow drills into a line (same as Enter/select), left arrow
goes back (same as ESC). Both are disabled during move mode and
delete confirmation dialogs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When at the widget level in items mode with no rules expanded, pressing
← now navigates back to LineSelector (same behavior as ESC).
The existing collapse behavior (← when rules are expanded) is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Default wrapNavigation to true so ↑↓ wraps around the list
- Add onBack prop (optional); ← fires onBack when provided
- Add → as alias for Enter to trigger onSelect on the selected item

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…List

The List component now handles ← (fires onBack) and → (fires onSelect)
natively. Remove the duplicate leftArrow/rightArrow handlers from
LineSelector's useInput and pass onBack={onBack} to the List so the
built-in navigation takes over. ESC still calls onBack via useInput for
the non-List cases (move mode, theme-managed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
hangie and others added 24 commits April 13, 2026 21:50
Pass onBack to the List component in PowerlineSetup, TerminalOptionsMenu,
TerminalWidthMenu, InstallMenu, and PowerlineThemeSelector so that ←
exits these screens, matching existing ESC behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…neSeparatorEditor

Adds key.leftArrow alongside key.escape in both non-List screens so users
can press ← to navigate back. In edit/hex-input modes, ← cancels the edit
(matching ESC). In PowerlineSeparatorEditor normal mode, ← is already used
for cycling presets so only hex-input-mode cancel is added there.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
With ← arrow key now handling back navigation via List's onBack prop,
the "← Back" menu items are no longer needed. Remove showBackButton
prop from List, the useMemo that appended back items, and all dead
'back' value checks in onSelect/onSelectionChange handlers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…operty mode →

- Rule property mode: → now opens condition editor (was a silent no-op)
- Rule move mode: removed ←→ handler; ↑↓ reorder and Enter/ESC exit only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
In property mode, show '→ edit condition' so users know how to access
condition editing. In move mode, remove the stale '←→ edit condition'
reference since that shortcut no longer applies there.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…diting

Free ← for back navigation by moving preset cycling and toggle-invert
into a focus mode entered via Enter. Normal mode now uses wrapping ↑↓
navigation and ← for back. Focus mode provides ←→ preset cycling,
↑↓ reorder, and (t)oggle invert for separators.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add `const cmd = input.toLowerCase()` at the start of keybind sections
in GlobalOverridesMenu, PowerlineSetup, PowerlineSeparatorEditor, and
PowerlineThemeSelector. Replace all `input === 'x' || input === 'X'`
patterns with `cmd === 'x'`. Text input modes (hex digits, padding,
separator text) continue to use the original `input` to preserve case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Makes focus mode more discoverable by accurately describing that Enter
enters edit mode (which supports type changes and more, not just moving).
Move mode help text is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Create reusable parse helpers for converting widget raw output to typed values. These pure functions will be used by widgets in their getValue() implementations.

Parsers added:
- parsePercentage: strips % suffix and returns number
- parseCurrency: strips currency symbols and returns number
- parseTokenCount: handles K/M/B suffixes (case-insensitive)
- parseSpeed: strips t/s suffix and returns number
- parseBooleanString: parses true/false strings (case-insensitive)
- parseIntSafe: safely parses integer strings, rejects floats

All parsers include strict validation and comprehensive test coverage (21 tests, 73 assertions).
…t interface

Extends the Widget interface to support typed value extraction for rule evaluation:
- Add getValueType() to declare the value type (string, number, or boolean)
- Add getValue() to extract the typed value for rule evaluation
- Remove getNumericValue() (replaced by getValue)

The return type of getValue() must align with what getValueType() declares:
- If getValueType() returns 'number', getValue() must return number | null
- If getValueType() returns 'string', getValue() must return string | null
- If getValueType() returns 'boolean', getValue() must return boolean | null

This is a convention enforced by documentation, not TypeScript (since the return type is a union).

Updated tests to cover rules with numeric, string, and boolean comparisons.

Expected type errors in src/utils/widget-values.ts will be fixed in Phase 3.
Implement getValueType() and getValue() on 7 number widgets:
- ContextPercentage, ContextPercentageUsable (use parsePercentage)
- ContextLength (uses parseTokenCount)
- TokensInput, TokensOutput, TokensCached, TokensTotal (use parseTokenCount)

All widgets follow the pattern:
- getValueType() returns 'number'
- getValue() calls render() with rawValue: true and parses result

Add comprehensive test coverage:
- getValue tests for all 7 widgets
- Tests cover live data, preview mode, and null handling
- Tests verify percentage parsing for context widgets
- Tests verify token count parsing with K/M suffixes
- New ContextLength.test.ts file created

All 365 widget tests passing.
The gitData field was accidentally re-added during the widget getValue
implementation. It is unused dead code — git widgets use the module-level
cache in git.ts, not context-level data.
Add typed value extraction to SessionCost, TerminalWidth, and the three speed widgets (InputSpeed, OutputSpeed, TotalSpeed).

All widgets now declare getValueType(): 'number' and implement getValue() using appropriate parsers:
- SessionCost uses parseCurrency to strip currency symbols
- Speed widgets use parseSpeed to strip 't/s' suffix
- TerminalWidth uses parseIntSafe for plain integer parsing

Tests cover live data, preview mode, and missing data cases for all widgets.
- Add getValueType() returning 'number' to both widgets
- Implement getValue() to extract percentage from context.usageData
- Return hardcoded preview values (20 for session, 12 for weekly) in preview mode
- Handle null/error cases by returning null
- Clamp percentages to 0-100 range
- Extract directly from context data, not from rendered output
- Add comprehensive test coverage for getValue() method
…GitUntracked, GitIsFork, GitWorktreeMode, GitChanges)

Add getValueType() returning 'boolean' and getValue() to all six boolean
widgets. Remove getNumericValue() from GitStaged, GitUnstaged, and
GitUntracked. GitChanges uses direct git data check instead of render
since it doesn't support rawValue.
…rom GitAheadBehind

- Add getValueType() and getValue() to GitConflicts widget
  - Returns numeric conflict count via parseIntSafe
  - Replaces deprecated getNumericValue() method
- Remove getNumericValue() from GitAheadBehind widget
  - Widget's raw value is a compound pair ('2,3') with no single numeric interpretation
  - Previous sum behavior (ahead + behind) was semantically incorrect
  - Widget will now work with string operators via fallback
- Update widget-values.ts to support new getValue pattern
  - Checks for getValue/getValueType instead of deprecated getNumericValue
  - Maintains backward compatibility during transition
- Add comprehensive tests for GitConflicts.getValue()
  - Tests conflict count extraction, preview mode, and no-git-repo cases
Replace guess-based value extraction with dispatch on widget's declared
value type. getWidgetValue() now calls widget.getValue() when available,
falling back to rendering in raw mode for widgets without getValue().

Remove parseNumericValue, supportsNumericValue, getWidgetNumericValue,
getWidgetStringValue, getWidgetBooleanValue, and DEBUG_RULES logging.
Rewrite tests to cover typed dispatch and string fallback paths.
Update cross-widget tests to use isTrue operator instead of greaterThan
for git-changes (now a boolean widget). Add test coverage for:
- git-staged boolean widget with isTrue
- Boolean coercion from numeric values (non-zero = true, zero = false)
- isFalse on boolean widgets
- Cross-widget conditions with typed boolean and numeric values
The comments on total_input_tokens and used_percentage were ambiguous
about which widget each value corresponds to. Clarified that 9.3% is
the total context percentage and 11.6% is the usable context percentage
at 80% usable threshold.
The gitData field on RenderContext was removed as dead code earlier,
but was re-introduced in StatusLinePreview.tsx during widget getValue
implementation. Remove the references again since the field no longer
exists on RenderContext.
When a widget declares a value type (number, boolean, string), the
operator picker now only shows relevant operator categories:
- Number widgets: Numeric, Set
- String widgets: String, Set
- Boolean widgets: Boolean only
- Untyped widgets: all categories (unchanged)

When the user changes the target widget, the current operator is
automatically reset if it's incompatible with the new widget's type.
…mocks

Two issues that caused CI failures:

1. Duplicate imports: subagent added a separate import of DEFAULT_SETTINGS
   from '../types/Settings' instead of merging into the existing Settings
   type import. This caused eslint-plugin-import to crash on the duplicate
   import path. Merged into single import across 18 widget files.

2. Partial vi.mock: GitIsFork.test.ts and widget-values.test.ts replaced
   the entire git-remote module with only getForkStatus, leaving listRemotes
   undefined. This leaked across test files in the same Vitest worker,
   causing git-remote.test.ts failures in CI. Replaced with vi.spyOn.
ESLint auto-fix for issues introduced by subagents:
- Multi-line imports where >1 elements (import-newlines/enforce)
- Newline before return in single-line if blocks (nonblock-statement-body-position)
- Trailing newlines at end of files (eol-last)
- Unnecessary type arguments in ConditionEditor (no-unnecessary-type-arguments)
- Object curly newline formatting in test files
@hangie
Copy link
Copy Markdown
Contributor Author

hangie commented Apr 15, 2026

Implements #38
Rules engine based custom behaviour

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.

1 participant