Skip to content

Tab colors: picker, shell-announced (OSC 6), and regex rules - #16

Open
kingb wants to merge 25 commits into
mainfrom
feat/tab-colors
Open

kingb wants to merge 25 commits into
mainfrom
feat/tab-colors

Conversation

@kingb

@kingb kingb commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Tab colors

Per-tab colors for multi-agent workflows (closes #10's Ember side), from three sources with a clear precedence:

hand-picked > shell-announced (OSC 6) > regex rule > none - and a Settings knob (Shell color overrides manual, default off) that swaps the top two for people who want their shells authoritative.

What's in it

  • Double-click picker: the tab rename editor now shows a color swatch; clicking it (or ArrowDown while renaming) opens a popover with 12 curated colors plus two explicit choices: Default (pin this tab uncolored - shell and rules stay out) and Clear (remove my choice - shell and rules may color it again).
  • iTerm2 OSC 6 compat: any process can set its tab's color with the standard escape (\e]6;1;bg;red;brightness;N\a per channel, \e]6;1;bg;*;default\a to reset). Existing iTerm2-aware dotfiles and scripts work unchanged; an agent's shell init can announce its color once and every tab self-colors. Rides the existing OSC pre-scan as a fourth mark type.
  • Regex rules: [[tab_colors.rules]] in config.toml (pattern, color), first match wins, recomputed event-driven only. Settings shows the rule count; a full rules editor lands with the settings page reorg.
  • Stream Deck-ready surface: ctl state tabs now carry color, panes carry busy and last_exit - poll at 1-2 Hz and mirror tab colors + agent status onto keys. Field semantics documented in the state contract; note busy/last_exit require shell integration and the Capture commands setting (on by default).
  • Persistence: your hand-picked colors survive restarts via session restore; shell and rule colors deliberately re-derive live.

Notes for reviewers

  • ctl set-tab-color <query> <#rrggbb|default|clear|swatch-N> deliberately diverges from the design doc's <color|none> sketch: a bare none cannot distinguish "pin to default" from "clear my choice", which are different layers.
  • A user-facing Stream Deck polling recipe is still owed as a docs artifact and is tracked for the docs pass.
  • Builds directly on the session-restore work now on main.

Testing

520 workspace tests green (through ember-core precedence tables, scanner split/resync suites, rule-cache lifecycle across window moves and restores, popover state, and serde compat with pre-color snapshots and configs); screenshot fixtures for the popover in both render paths; live-window verification of the OSC repaint path noted for the release smoke.

🤖 Generated with Claude Code

kingb and others added 25 commits September 4, 2026 22:12
Adds TabColorChoice (Unset/PinnedDefault/Color) and effective_color,
the pure resolver for the four color layers: hand-picked color beats
shell OSC 6 beats a regex rule beats none. A knob swaps the top two
layers so OSC can outrank a manual pick, but PinnedDefault still
blocks the rule layer unless OSC is present and the knob is on.

Tab gains a color field (serde default Unset) so old snapshots and
trees load unaffected. Fixes every Tab struct literal the field
change broke across layout.rs, windows.rs, command.rs, and the
ember-app call sites in main.rs, screenshot.rs, and session_state.rs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add an OSC 6 scanner (crates/ember-session/src/osc6.rs) mirroring
osc133's scan-and-resync shape: it parses the report-1 form
(1;bg;<red|green|blue>;brightness;<0-255> and 1;bg;*;default) and
rejects everything else, including out-of-range values and the
unsupported report-2 form.

Fold it into AlacrittyProjection::advance as a fourth scanner
alongside 133/1337/633, with its own split-sequence tail carry,
merged back into buffer order exactly like the other three.

On the app side, accumulate per-session RGB channels in a new
Shared.osc_tab_color map (a session with no entry yet initializes
the other two channels to 0, then sets the reported one) and expose
osc_color_of to pack them into 0xRRGGBB. This is per-session app
state, not persisted: a restored shell re-announces its own color,
so nothing here touches pane_meta or snapshot_dirty. Wiring
osc_color_of into the tab-color precedence resolver is a follow-up
task.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 3 of the tab-colors plan: renders each tab's resolved color as a
small chip in the tab strip, adds a matching swatch to the rename
editor that opens a 12-swatch plus Default plus Clear picker popover,
and wires the rename editor and a new ctl command to actually set a
tab's color.

Pure functions, TDD:
- parse_color_token parses #rrggbb, default, clear, and swatch-N tokens
  into a TabColorChoice. Reused by commit_rename and ctl set-tab-color.
- swatch_key classifies a keypress into a popover action (move, pick,
  pin default, clear, close, none), keyed on the current selection.
- wrap_swatch_sel wraps arrow movement across the 14-item grid.
Both were run RED against stubs before the real implementation, then
GREEN; tests live in window_state.rs.

Rendering (both the live and headless paths, plus a --swatch-popover
screenshot fixture):
- TabLabel grows a color field; build_tabs draws the chip in the
  hovered-close-button's cap when idle, and an editor swatch (filled or
  hollow) in the opposite cap while renaming.
- A new SwatchView overlay and build_swatch_popover draw the picker,
  anchored under the tab, with a hint row documenting the keyboard
  gestures.
- The colored chip is a deliberate choice over a strip-wide underline:
  at 12px it reads clearly without competing with the existing
  accent-ring/hover-lift language the strip already uses.

Wiring:
- WindowState gains swatch_open/swatch_sel state, open/close/key-input
  handlers, and apply_tab_color as the single mutation point every
  color path funnels through.
- commit_rename strips a #rrggbb token out of a typed tab name and
  applies it as the tab's color instead of keeping it as title text.
- ctl set-tab-color <query> <token> resolves a tab across every window
  by the same title search ctl focus uses, then applies the token.
- The per-tab effective color plumbed into TabLabel deliberately stubs
  the rule layer (None) and the OSC-override knob (false), both marked
  TODO(tab-color-rules) for the next task to wire up and remove.

cargo test --workspace, cargo clippy --workspace --all-targets
--all-features, and cargo fmt --all --check are all clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ne while renaming

Review fix for Task 3. Nothing invalidated the tab-color popover's
raw-index anchor when the tab list changed underneath it: open the
popover, close or reorder a tab (Cmd+W, ctl reorder-tab, a drag
reorder, a cross-window move), and a later Enter would silently color
whichever tab had slid into that now-stale index instead of the one
the user actually opened it on.

Closes the popover explicitly at every site that can shrink or reorder
the tab list: do_close (covers both Cmd+W's pane close and a tab
close), ctl reorder-tab, the in-strip drag reorder, the same-window
tear-off-and-redrop reorder, and apply_move's per-window loop (move-
tab, promote-pane, merge-tab, a real cross-window drag-drop all lower
onto it). Also adds a cheap out-of-bounds backstop in sync_layout,
which every one of those paths already calls, via a new pure
swatch_open_is_stale helper (unit-tested) for whatever mutation path
this list missed. editing_tab has the identical staleness and is left
alone, as instructed; only the new swatch_open state is made safe.

Also gates the close-button hit zone on the tab NOT currently being
renamed. tab_col_hit already stopped drawing the close "X" during a
rename; the hit-test hadn't caught up, so clicking a hovered, mid-
rename tab's left edge could still resolve to a close instead of a
plain tab hit.

Added tests: swatch_open_is_stale's two cases (out-of-bounds, in-
bounds/closed), and close_zone_is_suppressed_while_editing_that_tab
for the hit-test gate. cargo test -p ember-app -p ember-render,
cargo clippy --workspace --all-targets --all-features, and
cargo fmt --all --check are all clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds TabColorsConfig (osc6_overrides_manual + regex rules) to Config,
serde-defaulted throughout so an existing config.toml with no
[tab_colors] table still loads unchanged. Compiling a rule (regex +
color parsing) lives in ember-app, not ember-core, since regex isn't a
core dependency; an invalid pattern or color is skipped and logged
once for the process, never panics and never drops the other rules.

Wires the rule layer and the knob into sync_layout's effective_color
call, deleting both TODO(tab-color-rules) markers left by the picker
UI task. The per-tab rule match is cached on WindowState and
recomputed only at the four points that can change it (rename commit,
a programmatic/restore title change, a Settings adjustment, config
load), never per-frame.

Settings gains a "Tab colors" section: a "Shell color overrides
manual" toggle and a read-only "Tab color rules (N)" info row (v1;
full rule editing stays config.toml-only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nd knob flips

The rule-color cache was only ever recomputed at the four sites named
in the original task (rename commit, ctl/restore title changes,
Settings adjustment, config load) - review found three more paths
that mutate a window's tab list without going through any of those:

- apply_move (tab move, pane promotion, cross-window drag) replaces a
  window's tree wholesale, and open_window seeds a brand-new window's
  tree directly - neither triggered a recompute, so a rule-colored tab
  visibly lost its color the moment it moved or a new window opened
  with one already in it, until an unrelated event fixed it up.
- new_tab_with_cwd (Cmd+T, ctl new-tab) never recomputed either, so a
  catch-all rule wouldn't color a fresh tab until its first rename.
- adjust_setting only has &mut self (the window whose Settings overlay
  is open), so flipping the shell-overrides-manual knob left every
  OTHER open window rendering stale colors until its own next
  unrelated trigger. Fixed by flagging Shared.tab_colors_broadcast_pending
  and flushing it once in about_to_wait's tail, the same "flag now,
  flush once self.windows is fully free" pattern snapshot_dirty uses.

Pulled the cache-rebuild itself out into a pure compute_tab_rule_colors(tabs, rules)
free function so it's unit-testable without a live WindowState/Renderer;
WindowState::recompute_tab_rule_colors is now a thin wrapper around it.
Added direct tests against small tab-list fixtures, including one
modeling the apply_move carry (a tab arriving with a fresh TabId into
a window that already had others).

Also renamed a settings test to match its five section headers now
that Task 4 added "Tab colors".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ersistence

ctl state's tabs[] gains "color" (the tab's effective color, same
precedence composition the renderer uses), and panes[] gains "busy" and
"last_exit" from PaneMeta, now tracking the exit code from OSC 633's
CommandEnd report. TabSnap gains a color field so a user's manual tab
color pick survives quit/relaunch/restore, while OSC and rule colors are
deliberately left out: a restored shell re-announces its own OSC color,
and rule colors re-derive from the restored title against the current
config.

Also adds the Tab missing-color-field serde compat test that Task 1's
review deferred into this dispatch, pinning #[serde(default)] against
regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…End semantics in the state contract

The state_json doc comment claimed busy/last_exit as unconditional
facts about a pane's shell activity, but was_running is only ever set
true from the CommandLine branch of the event loop, which is itself
gated on the Capture commands setting, and CommandEnd's handler only
ever updates an existing PaneMeta entry. Document that both fields
degrade to false/null without shell integration and Capture commands
on, and name the third last_exit state explicitly: a code-less
CommandEnd clears a held exit code rather than leaving it stale.

Also factors the cmd_ends application loop's mutation into a pure
apply_command_end helper so that third state has a direct unit test,
rather than relying on the doc comment alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e first window's rule cache

OSC 6 arrival updated shared.osc_tab_color but never re-synced the
owning window, so a shell's tab color only became visible after some
unrelated sync_layout trigger. about_to_wait now resolves each ticks
affected sessions to their owning windows (deduped, since one OSC 6
announcement arrives as three channel reports) and syncs each once.

resumed()'s default window path built the seed tab and called
sync_layout without first populating the rule-color cache, the one
new-window path the earlier fix wave missed; mirrors open_window's
existing precedent.

Also refreshes the MCP ember_state tool description with the color,
busy, and last_exit fields it already returns, and adds a table test
for extract_color_token's title/color split.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a pure color-math module: WCAG relative luminance and contrast
ratio, an auto-contrast ink pick against this app's own near-black and
near-white ink constants, and an sRGB/Oklab/OKLCH round trip used to
derive a same-hue accent from a pill color. The accent falls back to
plain ink when its contrast against the pill drops below the WCAG
non-text minimum. Covered by unit tests: threshold crossover both
sides, pure black and white, every curated swatch's ink and accent
contrast ratios, hue preservation on an in-gamut example, and the
pathological-base fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the small 8px color chip with the redesign from the live
demo: a colored tab's whole pill fills with its color, full strength
when active or being renamed, blended 55% toward the strip background
when inactive so the active tab still reads as clearly selected. Title
text auto-contrasts against whatever color is actually painted using
the new WCAG ink picker; uncolored tabs are untouched. The hovered
tab's close glyph and the swatch popover's selection ring now derive a
same-hue accent from the pill color instead of a plain white ring.

The popover's panel layout is pulled into one pure geometry helper
(swatch_geom) shared by the draw path here and the upcoming mouse
hit-test, so the two can never disagree about where a cell actually
sits.

Also extends the --screenshot fixture with --colored-tabs (an active
and an inactive colored pill side by side) for visual verification
alongside the existing --swatch-popover shot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The popover only responded to the keyboard: build_swatch_popover drew
the 12-swatch grid plus Default/Clear, but nothing hit-tested a click
against it, so a mouse click anywhere while it was open just fell
through to the generic click-away dismissal.

While the popover is open it now captures every left click, mirroring
the keyboard capture in swatch_key_input: a hit on a curated cell
picks that color, Default pins the plain look, Clear resets to unset,
and anything else, padding inside the panel or a click outside it,
closes the popover without touching the rename underneath (the same
"leave the editor open" behavior Esc already had). The hit-test reads
Renderer::swatch_hit, which resolves the same swatch_geom the panel is
drawn from, so a click always lands on what is actually on screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… clears contrast

Review response to the tab-colors redesign.

Critical: the unseen-bell dot was pushed onto the sharp quad layer, and
every sharp quad draws before any rounded one, so a colored tab's
rounded pill fill silently covered its own bell dot. Moved the dot
onto the rounded layer with a d/2 radius (a full circle), the same fix
already applied to the old color chip, with a comment at the site
calling out the sharp-before-rounded ordering trap.

Hover feedback on a colored inactive tab was gone: hovered and
un-hovered rendered identically. Hovering now blends noticeably less
toward the strip background than the plain inactive treatment, so a
colored tab still gets a visible hover lift, brightening toward its
full color, while the active tab (full strength) still reads
strongest.

The inactive blend's contrast was never asserted, and 3 of 12 curated
swatches failed the WCAG 4.5:1 text-contrast minimum at the original
0.55 blend factor. The blend/contrast curve turned out not to be
monotonic: there's a dead zone roughly 0.05-0.65 where several swatches
fail badly, so only two windows actually clear 4.5:1 for every swatch:
close to full strength, or past roughly 0.65. Raised the inactive
blend to 0.68 and picked 0.03 for the new hover blend, landing each in
one of those windows, and extended the swatch contrast tests to assert
both blends (and not just the raw swatch) against every curated color.

Also: the Oklab forward matrix's third row had drifted from Ottosson's
published values in the 8th decimal (self-consistent under the
round-trip test, since the same drifted matrix was used both ways, but
not matching the reference). Replaced it with the exact published
constants and added an absolute known-value test against Ottosson's
own worked example for pure red.

Minor cleanup: the tab-area column math (total/plus/help/gear/tab
cols) had drifted into three separate copies across the live
renderer's popover draw, the headless popover draw, and
Renderer::swatch_hit. Pulled into one tab_area_cols helper beside
swatch_geom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…red pill

The rename-editor's filled swatch painted the chosen color directly on a
pill already filled with that same color, so the picker affordance became
invisible on a same-colored pill. Ring it in the pill's own ink color
(ink_for, alpha 0.9) with the color fill inset inside, mirroring the
hollow arm's outer plus inset two-quad technique. The ink ring clears
4.5:1 contrast against the fill by construction, for any picked color.

Left the hollow arm's gray ring unchanged: it has no analogous collision,
since it never renders on top of the color it names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The swatch popover's selection ring used a same-hue accent derived from
the highlighted cell's own color, so it read as subtle chrome against
that same cell. Ring it with an ink-based double ring instead: a thick
near-white outer line plus a thin near-black inner line, the same move
that fixed the rename-editor swatch. Since this ring has to sit on top
of an arbitrary curated color as well as the Default and Clear rows
(against the panel's own fixed dark background), no single ink clears
contrast everywhere, so both lines are drawn. One of the two always
borders whatever is underneath. The ring is also visibly thicker than
before.

The same-hue accent on the tab pill's close X is untouched.

Extended the --swatch-popover fixture with --swatch-selected so a
light cell, a dark cell, and the Default/Clear rows can each be
rendered and checked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ue bar

Popover v2 needs a horizontal hue bar that sweeps hue at a pinned
lightness/chroma. hue_to_rgb converts a hue angle to a packed sRGB color at
that pinned OKLCH point, and hue_of does the inverse (seeding the bar's
marker from an existing custom color). Every hue at the chosen L/C clears
the WCAG text-contrast minimum against ink_for's pick, so the bar is
legible by construction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Scale the swatch popover up 1.5x (bigger cells, gaps, panel padding, and
Default/Clear rows) via one SWATCH_SCALE knob in swatch_geom, the single
geometry source draw and hit-test both consume, so sizes still change in
exactly one place.

Add two new regions below the curated-color grid: an OKLCH hue bar (24
segments sweeping hue at a pinned lightness/chroma) with a live-preview
swatch beside it showing the auto-ink glyph, and a custom hex-entry field
below that. swatch_popover_hit grows to classify clicks on both, and
SwatchView carries which of the three regions (grid, hue bar, hex field)
currently has keyboard focus so only that region draws a focus ring. The
existing double ink selection ring still frames whichever region is
focused, scaled with everything else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…x field

Grow swatch_key's routing to depend on which region has focus (grid, hue
bar, or hex field): Tab/Shift+Tab cycle focus regardless of the current
region; the grid keeps its existing arrow/Enter/Esc behavior; the hue bar
sweeps hue in 3-degree steps on Left/Right and applies the live preview on
Enter; the hex field routes typed characters, Backspace, and Enter through
new pure helpers (hex_buffer_push/hex_buffer_backspace) and applies via the
existing parse_color_token -> apply_tab_color path, with Esc unfocusing
back to the grid instead of closing the popover.

Mouse: clicking the hue bar picks the hue under the cursor and arms a drag
that keeps sweeping on further motion (mirroring divider_drag/
scrollbar_drag's press-anchored pattern); clicking the hex field just moves
focus there. Reopening the popover on a tab with a custom (non-curated)
color seeds the hue bar and hex field from that color instead of resetting
to red/empty.

Extend the --swatch-popover screenshot fixture with --swatch-focus/
--swatch-hue/--swatch-hex so the new regions can be captured in each state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fter

The focus ring's inner line is nearly as large as the region it frames (the
grid cells rely on their own fill being drawn AFTER the ring to cover the
ring's interior, leaving only the outer double-line visible). The hue bar
drew its ring after the color segments, so the ring's inner line painted
over almost the entire bar whenever it was focused. Swap the order: ring
first, segments on top.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hex field's focus ring was drawn after its fill, the same ordering
trap 2ad4878 fixed for the hue bar: the ring's inner line is nearly as
large as the field, so it painted over almost the whole field instead of
framing it. It only looked right before this fix because the fill color
is nearly indistinguishable from the ring's own inner line color. Move the
ring above the fill, matching the hue bar's corrected order.

The Default/Clear rows have the opposite problem: their selection fill is
translucent, not opaque, so drawing the ring first let the tint wash over
it. Draw that ring last instead, on top of the tint, so it stays crisp.

A hue pick previously had no mouse commit: Enter on the hue bar was the
only way to apply it, and clicking the live-preview swatch classified as
Blank, which dismissed the popover and discarded the pick. Give the
preview its own hit region (SwatchPopoverHit::Preview) and apply the
previewed color on click, the same path Enter already used.

Also dim the hex field's underscore padding separately from the typed
digits, so a partially typed value doesn't read as if the padding were
already-entered text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live-tuned with the user at the demo window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Unify the tab-color popover's hue bar, live-preview box, and hex field
around one custom-color value. Dragging or arrowing the hue bar now
live-updates the hex field's text with the exact color being swept, as
if typed. The preview box is promoted to a prominent, cell-and-a-half
sized square spanning the hue bar and hex field rows, so the three read
as one grouped control. Typing a complete hex commits it to the preview
and best-effort moves the hue marker to match; a partial entry keeps
showing the last complete color, dimmed. Apply paths are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The v2 popover sized its panel to the hint line's own width, then
centered a narrower grid/hue-bar/preview block inside that wider
shell, leaving dead space on every side and a two-row-tall preview
that dwarfed a swatch cell. Default/Clear had no fill of their own,
reading as floating text.

swatch_geom now treats the swatch grid's own width as the content
column and makes every other row (hue bar, hex field plus a
cell-sized preview, and Default/Clear as two full-width settings-style
rows) span exactly that width, flush left. The hint line wraps into
that width instead of forcing the panel wider. Panel height is the
literal sum of the stacked sections plus padding, so there is no
leftover space in either dimension. Dropped the single SWATCH_SCALE
multiplier for explicit per-part sizes.

Also threads the window's logical height through swatch_geom so the
panel clamps fully on screen: it still anchors under the tab by
default, but shifts up rather than letting Default/Clear run past the
window's bottom edge, following a live screenshot showing exactly
that overflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hint line wrapped to three lines at the panel's 168px content
width. Trimmed it to "arrows · Enter · Esc" (dropping the Tab mention,
since clicking a section is discoverable on its own) so it fits one
line.

Default and Clear shared one contiguous fill, reading as a single gray
box with two labels rather than two buttons. They now sit a small gap
apart, each with its own rounded fill and its own text buffer (the
old single two-line buffer relied on the rows being flush against each
other to line up), so each reads as a distinct row with its own
selection double-ring when selected. swatch_geom and the hit-test
classifier share the same row-gap constant, so the two rows' visible
bounds and clickable bounds stay identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The short-window test's window height was strip_h + 4 + h + 40, which
is MORE than the panel needs at its default anchor position, so the
shift-up branch never fired and the assertions passed even with the
clamp deleted. Rebuilt it around a window exactly h + 4 tall (too
short for the default anchor, but exactly enough once the panel
shifts flush to the top), and added an assertion that y actually lands
on the clamp's floor. Verified red/green locally: reverting swatch_geom's
y to a bare `strip_h + 4.0` fails the test as expected; restoring the
real clamp passes it again.

Also: the hint-only shape closure was still sizing its buffer for the
old two-line Default/Clear block it no longer draws, and the row-fill
comment justified ring-last ordering by "translucent fills" when the
base fill is opaque (only the selected-row accent tint is
translucent) - both corrected to match the current code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

Allowing tab colors customization + Stream Deck tab color sync

1 participant