Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
\e]6;1;bg;red;brightness;N\aper channel,\e]6;1;bg;*;default\ato 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.[[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.ctl statetabs now carrycolor, panes carrybusyandlast_exit- poll at 1-2 Hz and mirror tab colors + agent status onto keys. Field semantics documented in the state contract; notebusy/last_exitrequire shell integration and the Capture commands setting (on by default).Notes for reviewers
ctl set-tab-color <query> <#rrggbb|default|clear|swatch-N>deliberately diverges from the design doc's<color|none>sketch: a barenonecannot distinguish "pin to default" from "clear my choice", which are different layers.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