Skip to content

feat: color editing as a widget-editor mode, with per-widget overrides under Powerline themes - #346

Open
hangie wants to merge 39 commits into
sirmalloc:mainfrom
hangie:feat/tab-swap-editors
Open

hangie wants to merge 39 commits into
sirmalloc:mainfrom
hangie:feat/tab-swap-editors

Conversation

@hangie

@hangie hangie commented Apr 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR started as Tab navigation between the item and color editors. Following @sirmalloc's five points in the thread below, it now also implements per-widget color overrides under Powerline themes and reshapes color editing into a mode of the widget editor.

Related to #259.

The five points

1. Allow editing colors when a theme is applied; save modified colors with their widgets.

A widget can pin its foreground and/or background, independently. A pinned channel keeps the widget's own color and overrides the theme; an unpinned channel takes the theme. Colors are stored on the widget as they always were — the pin is two optional booleans (pinColor, pinBackgroundColor), so there is no new color storage and no settings migration.

The guard that blocked color editing under a managed theme is gone.

2. Main menu color entry becomes a signpost.

🎨 Edit Colors no longer opens the standalone color flow. It lands on a notice explaining that color editing lives in the widget editor, with an action that jumps straight to the line selector. The entry is left in place for you to remove when you're ready.

The old color line-selector screen became unreachable and was deleted, which also removed the "colors are managed by the Powerline theme, press any key to go back" block — the wall this PR exists to replace.

3. Tab to color editing, keeping the widget editor's shape; Esc back one menu.

Both editors now render their rows through one shared component, so a row is the same row in both modes: same numbering, same indicator, same structure markers ((merged→), (raw value), (no-align)), tinted with the color the widget actually renders in. Titles share a stem and differ by a mode tag — Edit Line 1 [WIDGETS] / Edit Line 1 [COLORS] [FOREGROUND]. A parity test asserts the rows are byte-identical between modes, ANSI included.

Esc backs out to the line selector from either mode, and the same one.

ink-select-input is gone from the color editor (it made a shared row impossible), which also removed a remount-on-highlight hack and a duplicate static list. Nothing else imported it, so it's dropped from package.json.

4. Color editing stays familiar; the rendering path honours per-widget overrides.

renderPowerlineStatusLine applies a pinned channel over the theme. preserveColors still wins for the foreground of a custom command. The theme color index still advances for pinned widgets, so pinning one widget doesn't shift its siblings' colors.

The editor now shows what actually renders: an unpinned channel displays the theme's color and reports it as (theme), rather than the dormant stored value it used to show. Both the row tinting and the status line read from one helper that derives colors from the same slot assignment the renderer uses, so the preview cannot drift from the output.

5. Theme switcher prompts to keep or remove overrides.

Changing the theme with pins present prompts to keep or clear them.

The hard requirement

Launching an existing statusline config with a given powerline theme will not change appearance when opened in the updated app.

Pins are opt-in and optional, so an existing config has none: every channel takes the theme exactly as before, and nothing is rewritten on load. The tricky case you named — colors persisted before a theme was enabled — stays dormant, because only a conscious pin makes a color win.

src/utils/__tests__/renderer-theme-color-override.test.ts codifies this, including the pre-existing-color case.

UX decisions worth a look

  • A channel must be pinned before its color can be edited. Editing an unpinned channel used to pin it automatically, which made a stray ←/→ destructive: it pinned and overwrote the dormant color, and since unpinning deliberately keeps the color, the original was unrecoverable. Under a theme the color keys now do nothing until you press p, and the status line says - theme applies, press (p) to override so they never look broken. Bold, dim, reset and clear-all aren't theme-driven and stay live. Nothing changes when no theme is active.
  • Pinning takes over what you're 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.
  • Unpinning is non-destructive: it hands the channel back to the theme and keeps your color, so re-pinning restores it.
  • Pin state is shown per row ((fg pinned), (bg pinned), (fg+bg pinned)) in both modes, so every override is visible at a glance rather than only for the highlighted widget.
  • Row parity was deliberately not attempted. The color list still shows only colorable widgets, so rows are numbered by position in the line and a skipped separator leaves a gap. Tab only enters color editing from a colorable widget, so you can never land on a row that rejects input; padding the list with unselectable rows seemed worse than the gap. Now that ink-select-input is gone, full parity is a small change if you'd prefer it.
  • The last commit is separable. feat(items-editor): tint widget rows with the colour they render in makes the widget editor adopt color-mode rendering, which costs its green selected-row highlight (selection is the ▶ indicator, as in color mode; move mode keeps its blue row). That inverts the direction of your request, so it sits last and can be dropped on its own without touching anything else.

Also included

  • One theme-slot assignment now serves both the renderer and the editor, so "which theme color does this widget get" is decided in one place.
  • Fixed a pre-existing flake in PowerlineThemeSelector.test.ts: two ink-driven tests advanced on fixed 25 ms sleeps and failed under load. They now poll for the state each step waits on. (Ink writes a frame before the next screen's input handler attaches, so a keypress sent the instant a frame appears is dropped — worth knowing for other TUI tests.)
  • docs/USAGE.md documents the color editing mode, the pin model, and the keybinds.

Not included, deliberately

Enabling Powerline permanently deletes manual separators and disabling doesn't restore them. That's pre-existing and yours to decide on, so it's raised separately as #531 rather than changed here.

Test plan

  • bun run lint clean (tsc + eslint, no warnings)
  • bun test — 1715 pass, 0 fail
  • bun run build succeeds
  • Regression test for the no-appearance-change guarantee, including pre-theme colors
  • Renderer tests for pinned fg/bg, preserveColors, and index advancement across pinned and merged widgets
  • Editor tests for the pin gate, pin seeding, theme-color display, row parity, and the mode tags
  • Manual TUI testing

hangie and others added 7 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.
@sirmalloc

Copy link
Copy Markdown
Owner

Running into some issues with this one. If you have a powerline managed theme turned on, go to the widget editor and press Tab, it takes you in to edit the colors (which should not be allowed when a managed theme is active). Changing the colors has no effect (as expected when a theme is active). Pressing ESC while editing the colors takes you to the screen with the message "⚠ Colors are currently managed by the Powerline theme: Minimal", and pressing any key takes you back to the main menu with the 'Edit Colors' item highlighted.

Throughout the TUI, the Esc key always takes you back to the previous screen, but pressing Tab to go to the color editor breaks that UX. I feel, at a bare minimum, going into color editing mode via Tab should take you out of color editing mode and back to the widget editor when either Tab or Esc is pressed to maintain the user's navigation context.

If a powerline theme is active, an appropriate solution might be to prompt the user to customize the theme (using the same 'c' key functionality when on the theme list), while warning them that the theme's colors for each widget will be copied and then they can customize them.

I'm not sure I fully like this flow though, as customizing a theme disables auto-applying the theme to newly added widgets. Everything added after customizing a theme gets the default, unthemed colors. So by presenting the Tab as a shortcut to easily customize widget colors, then prompting them to customize it at that point in the flow might confuse users why the theme they started with is no longer being applied.

I'm open to suggestions on this, I like the quick access but it doesn't work well when a theme is active.

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.
@hangie

hangie commented Apr 30, 2026

Copy link
Copy Markdown
Contributor Author

If you have a powerline managed theme turned on, go to the widget editor and press Tab, it takes you in to edit the colors (which should not be allowed when a managed theme is active).

Fixed in cbcd811 — when a managed theme is active, onTabSwap is no longer passed to either editor, suppressing both the hint and the keypress. This is consistent with the existing blockIfPowerlineActive guard on the canonical color editing path.

If a user wants per-widget color control, they can already customize the theme via Powerline Settings → Theme Selector → c, which bakes the theme colors into widget fields and switches to custom mode — Tab swap then becomes available.

Did you want a different behaviour, or is this consistent with your intent?

@hangie

hangie commented Apr 30, 2026

Copy link
Copy Markdown
Contributor Author

I feel, at a bare minimum, going into color editing mode via Tab should take you out of color editing mode and back to the widget editor when either Tab or Esc is pressed to maintain the user's navigation context.

Tab already swaps back, so that works. For ESC, the current behaviour was deliberate — Tab is treated as a lateral jump between parallel editors, not a navigation that creates a back-stack. So ESC follows the canonical path for whichever editor you're in (ColorMenu → colorLines → main).

The alternative would be to track that you arrived via Tab and have ESC return you to the originating editor. If we go that route, it would be a single breadcrumb — repeated Tab presses just toggle between editors, they don't accumulate, so you'd never need to ESC multiple times to unwind.

Happy to implement either approach — do you have a preference?

@hangie

hangie commented May 8, 2026

Copy link
Copy Markdown
Contributor Author

@sirmalloc - did you want me to implement the breadcrumbs as described? Or do you have other ideas?

@sirmalloc

Copy link
Copy Markdown
Owner

@sirmalloc - did you want me to implement the breadcrumbs as described? Or do you have other ideas?

@hangie Hang tight for a couple days on this one. I wanted to modify the theme rendering pipeline first to allow keeping a theme on and overriding colors for individual widgets, then I was considering removing the Color editor entirely from the main menu in favor of making the widget editor the sole entry point into color customization. I think it flows better and if we can apply colors while themes are on it'll feel pretty natural no matter what mode the user is in.

I'll probably keep the main menu item around for a couple weeks and direct people where to edit colors now so existing users aren't confused. Thanks for your patience on this one.

@sirmalloc
sirmalloc force-pushed the main branch 3 times, most recently from 4f7a07b to ec28376 Compare May 12, 2026 04:01
@sirmalloc

Copy link
Copy Markdown
Owner

Sorry I've been slow on this. Still on my radar, making time to get through all these PRs soon.

@hangie

hangie commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

@sirmalloc — checking in on this one. It's been a few weeks, so no rush, but wanted to see where your thinking landed.

I've kept the branch current with main (merges clean, tests green). From what I can see, the theme-rendering changes — keeping a theme on while overriding per-widget colors — haven't landed yet, so the current cbcd811 guard (disabling Tab-swap under a managed theme) still holds for today's behaviour.

Happy to treat this PR as a building block for the broader change you described, or to adjust it to fit whatever shape you land on. Do you want me to proceed with the breadcrumb ESC behaviour now, hold for your pipeline work, or rethink the entry point?

# Conflicts:
#	src/tui/components/ItemsEditor.tsx
#	src/tui/components/items-editor/input-handlers.ts
@hangie

hangie commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

@sirmalloc - any updates?
I think you could take this as your base for the editor approach and subsequent removal of the color editor from the top level menu. Reach out to discuss if you like. My other PR for the rules editor depends on this, and if you intend to take a different path instead of take this PR, then I'll have to abandon that or rewrite it on top of whatever changes you do differently for navigation and editors...

@sirmalloc

sirmalloc commented Jul 17, 2026 •

Copy link
Copy Markdown
Owner

@sirmalloc - any updates? I think you could take this as your base for the editor approach and subsequent removal of the color editor from the top level menu. Reach out to discuss if you like. My other PR for the rules editor depends on this, and if you intend to take a different path instead of take this PR, then I'll have to abandon that or rewrite it on top of whatever changes you do differently for navigation and editors...

@hangie I've been a bit busy lately with some career changes so I haven't been able to give the project proper attention aside from merging some low hanging fruit. Here's where I'm at, and if you want to try to update this PR to accommodate that, I'll be happy to merge it:

  1. Modify color logic to allow editing colors when a theme is applied. Save the modified colors only with their respective widgets. There might need to be some UX work around the powerline theme screen and the whole '(c)ustomize theme' concept.
  2. Make the main menu 'color editing' screen show users a message that the functionality has been moved to the widget editing screen, and offer an option to go there. We'll eventually remove this.
  3. Do the TAB to edit colors on the widget editing screen. Ideally the UI will keep most of it's 'shape' from the widget editor, but it'll now be in 'Color Editing mode', and allow pressing TAB to switch back. Esc will just go back one menu to the line selector.
  4. Color editing will stay mostly the same UX as it is now, and the rendering path will be fixed to override the powerline colors for a given widget's foreground / background.
  5. Powerline theme switcher should have a prompt when the theme is changed to keep or remove custom color overrides.

One hard requirement that should be codified into regression tests:

  1. Launching an existing statusline config with a given powerline theme will not change appearance when opened in the updated app. You have to be careful here because if the user had colors configured for a widget before enabling a powerline theme, they might still be persisted in the config but ignored during rendering. We need a solution so when the update drops, nobody has their colors changed automatically and think that things broke.

Again, sorry for dropping the ball on this one. Let me know if you want to discuss further, I'm open to suggestions.

@hangie

hangie commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

@sirmalloc — no worries at all on the timing, and thanks for laying out where you've landed. I'm glad to take this on and see it through. Your five points all make sense and I'm on board, including the details: ESC dropping back to the line selector, colour editing becoming a "Colour Editing mode" that keeps the widget editor's shape with TAB to switch back, the old top-level colour menu turning into a signpost, and the theme switcher prompting to keep/remove overrides when the theme changes. My managed-theme guard comes out as part of this — there'll be real behaviour behind it now.

Here's the shape I intend for the colour model, since your hard requirement (no appearance change for existing themed configs) is what drives it:

  • Per-widget overrides via a per-channel pin. A widget can pin its foreground and/or background independently so its own colour wins over the theme. The renderer gains that override step; anything unpinned still takes the theme.
  • Nothing changes on upgrade — and no migration. Existing widget colours stay dormant under a theme exactly as today; only a conscious pin makes a colour win. So opening an existing themed config renders identically, with no settings rewrite. I'll codify that as regression tests up front, per your hard requirement — including the tricky case of colours that were persisted before a theme was enabled.
  • Non-destructive editing. Pinning a widget surfaces your existing colour (or the current theme colour if you have none) as the starting point, so you edit from what's there rather than from scratch. Unpinning just hands the channel back to the theme — it never wipes your colour.
  • One colour per widget. A widget's colour is the same with or without a theme; the pin only decides whether the theme may override it. (Genuinely different colours with vs without a theme would be a clean additive later if it's ever wanted — but I don't think it is, and one colour matches your "override the theme per widget" framing.)

Your keep/remove prompt on a theme change (point 5) then maps cleanly onto clearing pins.

I'll follow up with a short written plan — data model, render precedence, the editing flow, and the regression-test shape — so you can gut-check it before I go deep. Happy to hop on a thread if that's easier around your schedule. Thanks again for getting this unblocked.

@sirmalloc

Copy link
Copy Markdown
Owner

@hangie Sounds good on all your points. I'd be happy to review either a full plan or just test the implementation, either way.

hangie and others added 4 commits July 28, 2026 20:27
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]>
hangie and others added 18 commits July 28, 2026 21:24
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]>
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 <[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]>
@hangie hangie changed the title feat: Tab-swap between item and color editors feat: color editing as a widget-editor mode, with per-widget overrides under Powerline themes Jul 28, 2026
hangie and others added 5 commits July 29, 2026 09:35
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]>
The colour editor computed powerline theme slots from different inputs than
the renderer, so under a theme it could preview - and pin - a colour the
status line never used.

getEffectiveThemeColors called assignPowerlineThemeSlots with placeholder
content for every widget and the default startIndex of 0, while the renderer
passes real pre-rendered content and the cross-line offset. Two divergences
followed: with continueThemeAcrossLines every line after the first was
previewed in the previous line's colours, and any widget that rendered empty
consumed a slot in the editor but not in the renderer, shifting everything
after it.

getEffectiveThemeColors now requires a ThemeSlotContext holding both inputs,
so no call site can silently default them. App owns a single memoised
pre-render and derives per-line contexts from it; StatusLinePreview consumes
that instead of pre-rendering again, which also stops custom commands being
executed twice per keystroke.

Also in the same area:

- applyCustomPowerlineTheme walked its own slot index, so (c)ustomize wrote
  different colours than the theme it promised to copy - merged widgets most
  visibly. It now uses the shared helper. It passes placeholder content on
  purpose, because it bakes colours in for every future render.
- renderer.ts re-implemented keepsOwnForeground inline. Both sides now read
  the one predicate, so the pin gate and the render cannot disagree.
- (r)eset and (c)lear-all bypassed the pin gate and deleted colours the theme
  was hiding, with no visible change to warn the user. Under a theme they now
  clear bold, dim and pinned channels only.
- Cycling a pinned channel onto the palette's Default entry left it pinned
  with no colour, suppressing the theme with nothing to replace it. Pinned
  channels no longer offer that entry.
- Adding a widget under a theme assigned a random background that the theme
  then hid, which pinning would later seed from. That assignment is now
  limited to the case where the widget's own background is what renders.

USAGE.md dropped the claim that pinning never changes appearance: it does
when the widget has a dormant colour, which is the case the feature exists
for. It now documents that, the reset/clear behaviour, and that theme colours
follow position among the widgets that actually render.

eslint.config.js ignores .claude/, whose nested worktrees are outside this
project's tsconfig and made `bun run lint` fail with parser errors.

bun run lint clean; bun test 1950 pass, 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Follow-up to c178ed7, covering the rest of the review findings.

Data loss and unreachable exits:

- The remove-pins prompt applied the theme on Yes, No and ESC alike, so the
  "ESC cancel" the screen advertises committed a theme the user was only
  previewing. ConfirmDialog gained an optional onEscape for dialogs whose
  "No" still commits something; the prompt now restores the original settings.
- That prompt is reached by Enter and had "Yes" preselected, so a repeated
  Enter wiped every pin. ConfirmDialog gained defaultChoice, defaulting to
  'yes' so no other dialog changes, and the prompt asks for 'no'.
- Selecting the 'custom' theme offered to remove pins that override nothing
  there, and (c)ustomize kept pins that had become redundant - invisible,
  because the row tag needs a real theme, and revived by the next theme
  change. The prompt now skips 'custom' and the customizer drops the pins.
- Pins survived a theme being turned off with no way to see or clear them:
  the tag, the (p) key and the help text were all gated on an active theme,
  while the baked colour kept rendering. Tags now show as "(fg pinned,
  inactive)" and (p) unpins without a theme, though pinning still needs one.

Editor swap:

- editingBackground and showSeparators were local state, and Tab swaps by
  changing screen, so both silently reset - the header could read
  [FOREGROUND] while the user believed they were editing a background. App
  owns them now.
- Tab was refused on rows the colour editor can still highlight, making it a
  one-way door out of the widget editor. It is a mode switch, so it always
  works; the colour editor already falls back to its first colourable row.
- Tab was also swallowed on the empty colour list, sending the user to the
  line selector from the screen that says to go add a widget.
- Flex separators were listed as colourable, because getWidget returns null
  for them and the filter treats unknown types as colourable. They render no
  text and take no theme slot, so they are excluded - which also stops a
  meaningless pin latching hasPins on for good.

Row tinting reproduced the theme but not the rest of the renderer's
precedence, so rows advertised colours the status line does not use. Global
foreground/background overrides now win, globalBold ORs with the widget's own
bold, and under powerline a gradient collapses to its first stop.

Tests and docs: the editor-row-parity fixture was all-colourable, so it could
not catch divergence; a mixed fixture now pins the real contract - the widget
editor lists 1,2,3,4,5 where the colour editor lists 1,5, leaving gaps rather
than renumbering. USAGE.md no longer claims both modes list the same widgets.

Cleanup: isMergedIntoPreviousWidget exported instead of duplicated;
getEffectiveThemeColors and the derived row lists memoised, which also
stabilises the highlight-repair effect's dependencies; getEditorBackScreen()
became the EDITOR_BACK_SCREEN constant; getMainMenuScreenTarget enumerates
its action-only options behind a never check so a new entry cannot compile
clean and do nothing; a tinted selected row is underlined, since the
selection colour cannot apply to a label that carries its own; the dead digit
guard and the stale ink-select-input comments are gone.

.at(-1) and Object.hasOwn are replaced at all four runtime call sites rather
than just the one on the theme path - engines.node >=14 and the Node 14 build
target lower syntax but do not polyfill prototype methods, so fixing one site
would have achieved nothing.

bun run lint clean; bun test 1960 pass, 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Follow-up to a6de0b5, from a review of that commit.

Row tinting reproduced the renderer's precedence as if both render paths shared
it, so a row could still advertise a colour the status line does not use:

- A global background override was applied to every row, but only the standard
  path reads overrideBackgroundColor - renderPowerlineStatusLine never mentions
  it. Under a theme every row showed the override while the line showed the
  theme. It now applies with powerline off, which is where it renders.
- A gradient foreground override was treated as a solid per-widget colour, which
  neither path does: it is painted across the finished line, so each widget shows
  a slice of it. At ansi16 that pass is a no-op and the widget keeps its own
  colour, which the row now does too. Above ansi16 the row paints the gradient
  across its label rather than claiming one stop for every row, since no row can
  know its own slice.
- Collapsing a widget's own gradient under powerline went through a hand-rolled
  hex: conversion, and getColorAnsiCode's hex: branch ignores colorLevel - so it
  emitted truecolor at ansi16, where the renderer emits nothing, and at ansi256,
  where it emits a 256-color escape. applyColors gained collapseGradient, which
  takes the existing fall-through to getColorAnsiCode instead, and the local
  first-stop helper is gone.

Editor swap:

- Tab is checked before the input-mode guards so it works on an empty line, but
  it was also reaching across them: from half-typed hex it discarded the entry,
  and from the clear-all prompt it abandoned the dialog, both without asking.
  A confirmation or text entry now holds the key, as it did before Tab existed.
- Swapping from a row the colour editor cannot show - a flex separator, or a
  separator while hidden - made it fall back to its first colourable row and
  report that as the highlight, overwriting the caller's cursor. Two Tabs from
  row 4 landed on row 1. A fallback is a guess, not a choice, so it is no longer
  reported; the guess is spent once the highlight moves.

showSeparators moved to App in a6de0b5 and so no longer resets on entry, which
made it unresettable: the (s) toggle is refused under powerline or a default
separator, but the row filter was not, so rows turned on beforehand stayed
visible with a dead toggle. Both now read one canShowSeparators predicate,
along with the help text that offers the key.

bun run lint clean; bun test 1977 pass, 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Follow-up to 8a725bd, from a review of that commit. Two of the three were
introduced by it.

A default separator does not replace separator widgets already on a line. It
only supplies the character for separators that have none: renderer.ts:1053
uses widget.character ?? settings.defaultSeparator and colours with
widget.color ?? 'gray', so [text, separator '#', text] with defaultSeparator
'~' renders "AAA~#~BBB". Gating the row list on it therefore hid rows for
widgets that still render, and neither the list nor the (s) toggle could bring
them back - trading the dead toggle 8a725bd fixed for unreachable rows.

Separator rows are now listed whenever they render, which powerline alone
decides, and the toggle reads the same predicate so the two cannot disagree.
This does relax a pre-existing gate: (s) previously refused while a default
separator was set. That rule belongs to adding NEW separators, which is what
widgets.ts:46 governs; recolouring the ones already on the line is a different
question, and refusing it left rendered output unreachable.

Also:

- guessedHighlightId stored the guessed id directly, so "no guess" and "guessed
  null" were the same value. On a line with nothing colourable the guess is
  null, the guard did not fire, and onWidgetHighlight(null) cleared the caller's
  cursor - reintroducing the jump to row 1 that 8a725bd set out to prevent. The
  ref now wraps the id so the two states are distinct.
- The gradient override branch keyed off isGradientSpec, which is prefix-only,
  while what renders is decided by parseGradientSpec - null below two resolvable
  stops. For a spec like "gradient:FF0000" the row handed applyColors something
  that produces no code at all. The paths differ here, so both are reproduced:
  powerline keeps the widget's own foreground, while the standard path has
  already dropped it and then paints no gradient over it.

Verified by rendering each case through preRenderAllWidgets/renderStatusLine and
comparing the row to the line.

bun run lint clean; bun test 1981 pass, 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@hangie

hangie commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

@sirmalloc - let me know what you think. Implementation complete.

# Conflicts:
#	bun.lock
#	src/tui/components/ItemsEditor.tsx
#	src/tui/components/color-menu/mutations.ts
#	src/utils/jsonl-metrics.ts
#	src/utils/renderer.ts
@eric-engberg

Copy link
Copy Markdown
Contributor

I merged this branch with current main to try it out, and went through it in detail. The core holds up well:

  • Configs without pins render byte-for-byte the same as on main, across a few thousand generated theme / color level / merge / override combinations.
  • The editor's theme colors match what the status line renders.

Here's what I found along the way.

Bugs

  1. Customize (c in the theme selector) throws away pinned colors.
    • applyCustomPowerlineTheme writes the theme's fg/bg into every slotted widget, then drops the pins, so each pinned channel's own color is replaced by the theme's.
    • With Nord Aurora, a foreground pinned to hex:FF0000 comes out hex:ECEFF4, and a background pinned to hex:0000FF comes out hex:EBCB8B.
    • The comment there says the pins become redundant because every widget now carries its own color, but the color written is the theme's, not the pin's.
    • Fix: keep color when pinColor is set, and backgroundColor when pinBackgroundColor is, before dropping the pins.
    • The customize test only checks that color is defined, so it passes either way.
  2. Pinning can pin an empty color.
    • With no theme active, cycling the foreground to "Default" stores color: "".
    • Pinning it later under a theme keeps "" (widget.color ?? seedColor), so the widget renders in the terminal's default foreground. That breaks the rule that a pinned channel always names a color.
    • Fix: treat "" as unset when seeding.
  3. Background mode sticks. The channel being edited now lives in App, so after f it stays [BACKGROUND] for every later line and visit. Esc there then switches to foreground instead of leaving, though USAGE says Esc goes back from either mode. Resetting the channel to foreground whenever a line is opened would still keep it across Tab round trips, which is why it moved into App.

Behavior worth a look

  1. Tab in the label editor. On current main, the label editor from feat(widgets): add editable label prefix to text-label widgets #601 uses Tab to reset the label to its default and save. Since Tab switches editor modes everywhere else, reaching for it there wipes a custom label. Resetting the field without saving would avoid that.
  2. The ⇥ hint is missing on separator rows, though Tab works from any row (and USAGE says so).
  3. Empty-line wording in color mode. On an empty line it says "No colorable widgets in the status line" (it means this line) and "Press any key to go back...", without mentioning ⇥.
  4. New widgets under the Custom theme now get a random segment background. getUniqueBackgroundColor changed from checking theme === 'custom' to isPowerlineThemeActive, which is false for Custom. Named themes getting none is the intended part; the Custom part looks incidental.
  5. The color mode help line is 177 characters with Powerline off (124 before), so it wraps to three lines at 80 columns.

Housekeeping

  1. The branch no longer merges cleanly with main.
    • Conflicts:
    • main's lint now rejects the bare void x; statements. Its no-unused-vars setting already ignores unused rest siblings, so they can go.
    • main's LineSelector tests use the allowEditing and blockIfPowerlineActive props this PR removes.
  2. A few changes aren't part of the feature:
    • docs/superpowers/plans/2026-07-22-theme-aware-color-overrides.md, an agent execution plan that no longer matches the code;
    • the **/.claude/ ignore in eslint.config.js;
    • the git-remote.ts change from .at(-1) back to index access.
  3. About 30 of the new test waits are fixed 25 ms sleeps (flushInk), in the ColorMenu, ItemsEditor, ColorEditingMovedNotice and editor-row-parity tests.
    • Some negative checks after a sleep pass without testing anything, e.g. "does not swap editors from hex input mode".
    • main now has a shared helper that waits for each key's effect instead: src/tui/__tests__/helpers/wait-for-ink.ts.
  4. ColorMenu's input handler is one useInput of about 270 lines.
    • The highlighted widget is looked up with the same find eight times.
    • The color name formatting and the hex input validation are duplicated.
    • Splitting it per mode, the way items-editor/input-handlers.ts does, would make it easier to follow.

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.

3 participants