From 0160d0dbf2d2ea79be5b799e358507a0109be8ff Mon Sep 17 00:00:00 2001 From: Brit Date: Wed, 10 Jun 2026 14:07:16 +0200 Subject: [PATCH] session accent: derive from theme mode with collision avoidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dark themes: warm hues (0-120), Light themes: cool hues (180-300) - Collision check against 17 major theme colors with 10° threshold, clamped to the hue band so it never drifts into an unrelated range - Hex values stored at construction instead of round-tripping through ANSI escape parsing (removes #ansiToHex) - WCAG AA contrast bisection on light surfaces - Per-session uniqueness via name hash within the hue band --- .../modes/components/status-line/component.ts | 4 +- .../modes/components/status-line/segments.ts | 4 +- .../src/modes/interactive-mode.ts | 6 +- .../coding-agent/src/modes/theme/theme.ts | 85 ++++++++++++++++- .../coding-agent/src/utils/session-color.ts | 92 +++++++++++++++++-- .../interactive-mode-working-accent.test.ts | 24 ++++- .../coding-agent/test/session-color.test.ts | 80 ++++++++++++++-- .../test/status-line-overflow.test.ts | 2 +- 8 files changed, 270 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/src/modes/components/status-line/component.ts b/packages/coding-agent/src/modes/components/status-line/component.ts index 2f8e5f476..aa636b95d 100644 --- a/packages/coding-agent/src/modes/components/status-line/component.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -839,7 +839,9 @@ export class StatusLineComponent implements Component { const gapWidth = Math.max(1, topFillWidth - leftWidth - rightWidth); const sessionName = effectiveSettings.sessionAccent !== false ? this.session.sessionManager?.getSessionName() : undefined; - const accentHex = sessionName ? getSessionAccentHex(sessionName, theme.accentSurfaceLuminance) : undefined; + const accentHex = sessionName + ? getSessionAccentHex(sessionName, theme.getMajorThemeColorHexes(), theme.accentSurfaceLuminance) + : undefined; const gapColor = getSessionAccentAnsi(accentHex) ?? theme.getFgAnsi("border"); const gapFill = `${gapColor}${theme.boxRound.horizontal.repeat(gapWidth)}\x1b[39m`; return leftGroup + gapFill + rightGroup; diff --git a/packages/coding-agent/src/modes/components/status-line/segments.ts b/packages/coding-agent/src/modes/components/status-line/segments.ts index 918cfc501..c0c57835d 100644 --- a/packages/coding-agent/src/modes/components/status-line/segments.ts +++ b/packages/coding-agent/src/modes/components/status-line/segments.ts @@ -486,7 +486,9 @@ const sessionNameSegment: StatusLineSegment = { if (!name) return { content: "", visible: false }; const ansi = - getSessionAccentAnsi(getSessionAccentHex(name, theme.accentSurfaceLuminance)) ?? theme.getFgAnsi("accent"); + getSessionAccentAnsi( + getSessionAccentHex(name, theme.getMajorThemeColorHexes(), theme.accentSurfaceLuminance), + ) ?? theme.getFgAnsi("accent"); return { content: `${ansi}${sanitizeStatusText(name)}\x1b[39m`, visible: true }; }, }; diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index b1cfec78d..6638a6a40 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -1067,7 +1067,9 @@ export class InteractiveMode implements InteractiveModeContext { } else { const accentEnabled = !isSettingsInitialized() || settings.get("statusLine.sessionAccent") !== false; const sessionName = accentEnabled ? this.sessionManager.getSessionName() : undefined; - const hex = sessionName ? getSessionAccentHex(sessionName, theme.accentSurfaceLuminance) : undefined; + const hex = sessionName + ? getSessionAccentHex(sessionName, theme.getMajorThemeColorHexes(), theme.accentSurfaceLuminance) + : undefined; const ansi = getSessionAccentAnsi(hex); if (ansi) { this.editor.borderColor = (str: string) => `${ansi}${str}\x1b[39m`; @@ -2763,7 +2765,7 @@ export class InteractiveMode implements InteractiveModeContext { if (!key.sessionAccentEnabled || !key.sessionName) { return this.#cacheWorkingMessageAccent(key, undefined); } - const hex = getSessionAccentHex(key.sessionName, key.accentSurfaceLuminance); + const hex = getSessionAccentHex(key.sessionName, theme.getMajorThemeColorHexes(), key.accentSurfaceLuminance); const main = getSessionAccentAnsi(hex); const dim = getSessionAccentAnsi(adjustHsv(hex, { s: 0.55, v: 0.65 })); return this.#cacheWorkingMessageAccent(key, main && dim ? { main, dim } : undefined); diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index f9ebba809..d6fc507a2 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -1383,9 +1383,23 @@ const langMap: Record = { bin: "lang.binary", }; +/** + * Resolve a theme color value (hex string or 256-color index) to a CSS hex string. + * Empty string represents the default terminal color. + */ +function resolveToHex(value: string | number, isLight: boolean): string { + if (typeof value === "number") return ansi256ToHex(value); + if (value === "") return isLight ? "#000000" : "#e5e5e7"; + return value; +} + export class Theme { #fgColors: Record; #bgColors: Record; + /** Resolved hex strings for foreground colors — populated at construction. */ + readonly #hexFgColors: Record; + /** Resolved hex strings for background colors — populated at construction. */ + readonly #hexBgColors: Record; #symbols: SymbolMap; #spinnerFramesOverrides: Partial>; /** @@ -1398,7 +1412,6 @@ export class Theme { readonly statusLineLuminance: number | undefined; /** WCAG relative luminance of the status-line background — basis for accent contrast. */ readonly #statusLineContrastLuminance: number | undefined; - constructor( fgColors: Record, bgColors: Record, @@ -1409,13 +1422,19 @@ export class Theme { ) { this.statusLineLuminance = colorLuma(bgColors.statusLineBg); this.#statusLineContrastLuminance = relativeLuminance(bgColors.statusLineBg); + const slIsLight = this.statusLineLuminance !== undefined && this.statusLineLuminance > 0.5; + this.#fgColors = {} as Record; + this.#hexFgColors = {} as Record; for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) { this.#fgColors[key] = fgAnsi(value, mode); + this.#hexFgColors[key] = resolveToHex(value, slIsLight); } this.#bgColors = {} as Record; + this.#hexBgColors = {} as Record; for (const [key, value] of Object.entries(bgColors) as [ThemeBg, string | number][]) { this.#bgColors[key] = bgAnsi(value, mode); + this.#hexBgColors[key] = resolveToHex(value, slIsLight); } // Build symbol map from preset + overrides const baseSymbols = SYMBOL_PRESETS[symbolPreset]; @@ -1443,6 +1462,70 @@ export class Theme { return this.isLight ? this.#statusLineContrastLuminance : undefined; } + /** + * Get the resolved CSS hex string for a foreground theme color. + */ + getColorHex(color: ThemeColor): string { + const hex = this.#hexFgColors[color]; + if (hex === undefined) throw new Error(`Unknown theme color: ${color}`); + return hex || (this.isLight ? "#000000" : "#e5e5e7"); + } + + /** + * Get all foreground and background theme colors as CSS hex strings. + * Skips colors resolved to the default terminal color (unstyled). + */ + getAllThemeColorHexes(): string[] { + const hexes: string[] = []; + for (const hex of Object.values(this.#hexFgColors)) { + if (hex) hexes.push(hex); + } + for (const hex of Object.values(this.#hexBgColors)) { + if (hex) hexes.push(hex); + } + return hexes; + } + + /** + * Get the most visually dominant theme colors as CSS hex strings — accent, + * border, success, error, warning, heading, link, diff markers, etc. + * These are the colors the session accent could visually clash with. + * Skips colors resolved to the default terminal color (unstyled). + */ + getMajorThemeColorHexes(): string[] { + const majors: ThemeColor[] = [ + "accent", + "border", + "borderAccent", + "borderMuted", + "success", + "error", + "warning", + "mdHeading", + "mdLink", + "mdCode", + "mdCodeBlock", + "mdQuoteBorder", + "mdListBullet", + "toolDiffAdded", + "toolDiffRemoved", + "customMessageLabel", + "thinkingText", + ]; + const hexes: string[] = []; + for (const key of majors) { + const hex = this.#hexFgColors[key]; + if (hex) hexes.push(hex); + } + return hexes; + } + /** + * Get the resolved CSS hex string for the theme's accent color. + */ + getAccentColorHex(): string { + return this.getColorHex("accent"); + } + fg(color: ThemeColor, text: string): string { const ansi = this.#fgColors[color]; if (!ansi) throw new Error(`Unknown theme color: ${color}`); diff --git a/packages/coding-agent/src/utils/session-color.ts b/packages/coding-agent/src/utils/session-color.ts index 7718bdf2f..f3cf3c312 100644 --- a/packages/coding-agent/src/utils/session-color.ts +++ b/packages/coding-agent/src/utils/session-color.ts @@ -1,4 +1,4 @@ -import { hslToHex, relativeLuminance } from "@oh-my-pi/pi-utils"; +import { hexToHsv, hslToHex, relativeLuminance } from "@oh-my-pi/pi-utils"; /** * Derive a stable hue (0-359) from a string using djb2 hash. @@ -25,23 +25,97 @@ function accentLuminanceCap(surfaceLuminance: number): number { return Math.max(0, (surfaceLuminance + 0.05) / ACCENT_MIN_CONTRAST - 0.05); } +/** Minimum angular distance in hue degrees from any theme color to avoid visual collision. */ +const MIN_HUE_DISTANCE = 10; +/** Saturation threshold below which hue is meaningless (near-gray). */ +const MIN_SATURATION_FOR_HUE = 0.1; + +/** Angular distance between two hue values (0-360). */ +function hueDistance(a: number, b: number): number { + const d = Math.abs(a - b); + return Math.min(d, 360 - d); +} + /** - * Derive a stable CSS hex accent color from a session name. + * Parse hue (0-360) from a hex color string. + * Returns undefined for near-gray colors where hue is not meaningful. + */ +function hexToHue(hex: string): number | undefined { + const hsv = hexToHsv(hex); + if (hsv.s < MIN_SATURATION_FOR_HUE) return undefined; + return hsv.h; +} + +/** + * Find a hue at least {@link MIN_HUE_DISTANCE} from all occupied hues, + * clamped to [lo, hi] to prevent leaving the intended hue band. + * Returns `target` unchanged if no safe hue exists within bounds. + */ +function findSafeHue(target: number, occupied: number[], lo: number, hi: number): number { + if (occupied.length === 0) return target; + if (occupied.every(h => hueDistance(target, h) >= MIN_HUE_DISTANCE)) { + return target; + } + for (let d = 1; d <= hi - lo; d++) { + for (const dir of [1, -1]) { + const candidate = Math.max(lo, Math.min(hi, target + d * dir)); + if (occupied.every(h => hueDistance(candidate, h) >= MIN_HUE_DISTANCE)) { + return candidate; + } + } + } + // fallback: keep the original target if no safe spot exists within the band + return target; +} + +/** Hue range low and high for dark themes (warm: red → yellow → green). */ +const DARK_HUE_START = 0; +const DARK_HUE_END = 120; +/** Hue range low and high for light themes (cool: cyan → blue → purple). */ +const LIGHT_HUE_START = 180; +const LIGHT_HUE_END = 300; + +/** + * Derive a stable CSS hex accent color from a session name and the active theme. + * + * Picks a hue from a **dark/light-specific range** so the accent feels natural + * for the theme type (warm on dark, cool on light). The session name hash + * determines the exact hue within the range. The result is checked against + * all theme color hues and shifted if it lands within {@link MIN_HUE_DISTANCE} + * of an existing theme hue, but is clamped to the hue band so it never + * drifts into an unrelated part of the spectrum. * * On dark themes (`surfaceLuminance` undefined) the accent is vivid (high * saturation, high lightness). On light themes the lightness is reduced until the * accent's perceived luminance clears {@link ACCENT_MIN_CONTRAST} against the * actual surface it renders on — so it stays legible on near-white *and* mid-light - * backgrounds — while keeping the same per-session hue. + * backgrounds. + * + * @param name — session name for per-session uniqueness. + * @param themeColorHexes — all theme colors to check collision against. + * @param surfaceLuminance — undefined on dark themes; WCAG luminance of the + * status-line background on light themes. */ -export function getSessionAccentHex(name: string, surfaceLuminance?: number): string { - const hue = nameToHue(name); +export function getSessionAccentHex(name: string, themeColorHexes: string[], surfaceLuminance?: number): string { + // 1. Pick hue range based on theme mode + const hueStart = surfaceLuminance === undefined ? DARK_HUE_START : LIGHT_HUE_START; + const hueEnd = surfaceLuminance === undefined ? DARK_HUE_END : LIGHT_HUE_END; + const range = hueEnd - hueStart; + + // 2. Session name picks within the range + let targetHue = hueStart + (nameToHue(name) % range); + + // 3. Shift away if too close to any theme color — stays within [hueStart, hueEnd] + const themeHues = themeColorHexes.map(hexToHue).filter((h): h is number => h !== undefined); + targetHue = findSafeHue(targetHue, themeHues, hueStart, hueEnd); + + // 4. Lightness/contrast — vivid on dark, bisected for AA on light if (surfaceLuminance === undefined) { - return hslToHex(hue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS); + return hslToHex(targetHue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS); } const cap = accentLuminanceCap(surfaceLuminance); - const top = hslToHex(hue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS); + const top = hslToHex(targetHue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS); if ((relativeLuminance(top) ?? 0) <= cap) return top; // Bisect lightness: `lo` always yields luminance <= cap, `hi` always above it. @@ -49,13 +123,13 @@ export function getSessionAccentHex(name: string, surfaceLuminance?: number): st let hi = ACCENT_DARK_LIGHTNESS; for (let i = 0; i < 20; i++) { const mid = (lo + hi) / 2; - if ((relativeLuminance(hslToHex(hue, ACCENT_SATURATION, mid)) ?? 0) > cap) { + if ((relativeLuminance(hslToHex(targetHue, ACCENT_SATURATION, mid)) ?? 0) > cap) { hi = mid; } else { lo = mid; } } - return hslToHex(hue, ACCENT_SATURATION, lo); + return hslToHex(targetHue, ACCENT_SATURATION, lo); } /** diff --git a/packages/coding-agent/test/interactive-mode-working-accent.test.ts b/packages/coding-agent/test/interactive-mode-working-accent.test.ts index d1f3aeb66..611cd5563 100644 --- a/packages/coding-agent/test/interactive-mode-working-accent.test.ts +++ b/packages/coding-agent/test/interactive-mode-working-accent.test.ts @@ -97,10 +97,22 @@ describe("InteractiveMode working-message session accent cache", () => { const renamedName = "Beta session"; const { mode, sessionManager } = await createHarness(initialName); const initialAnsi = defined( - sessionColor.getSessionAccentAnsi(sessionColor.getSessionAccentHex(initialName, theme.accentSurfaceLuminance)), + sessionColor.getSessionAccentAnsi( + sessionColor.getSessionAccentHex( + initialName, + theme.getMajorThemeColorHexes(), + theme.accentSurfaceLuminance, + ), + ), ); const renamedAnsi = defined( - sessionColor.getSessionAccentAnsi(sessionColor.getSessionAccentHex(renamedName, theme.accentSurfaceLuminance)), + sessionColor.getSessionAccentAnsi( + sessionColor.getSessionAccentHex( + renamedName, + theme.getMajorThemeColorHexes(), + theme.accentSurfaceLuminance, + ), + ), ); const getHex = vi.spyOn(sessionColor, "getSessionAccentHex"); @@ -143,7 +155,13 @@ describe("InteractiveMode working-message session accent cache", () => { const sessionName = "Toggle session"; const { mode } = await createHarness(sessionName); const accentAnsi = defined( - sessionColor.getSessionAccentAnsi(sessionColor.getSessionAccentHex(sessionName, theme.accentSurfaceLuminance)), + sessionColor.getSessionAccentAnsi( + sessionColor.getSessionAccentHex( + sessionName, + theme.getMajorThemeColorHexes(), + theme.accentSurfaceLuminance, + ), + ), ); const getHex = vi.spyOn(sessionColor, "getSessionAccentHex"); diff --git a/packages/coding-agent/test/session-color.test.ts b/packages/coding-agent/test/session-color.test.ts index 9c38898d9..2b171fb6c 100644 --- a/packages/coding-agent/test/session-color.test.ts +++ b/packages/coding-agent/test/session-color.test.ts @@ -1,34 +1,55 @@ import { describe, expect, it } from "bun:test"; +import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; + import { getSessionAccentHex } from "@oh-my-pi/pi-coding-agent/utils/session-color"; -import { relativeLuminance } from "@oh-my-pi/pi-utils"; +import { hexToHsv, relativeLuminance } from "@oh-my-pi/pi-utils"; + +const NO_THEME_COLORS: string[] = []; const lum = (hex: string): number => relativeLuminance(hex) ?? 0; const contrast = (a: number, b: number): number => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); const names = Array.from({ length: 600 }, (_, i) => `analyze-debian-trixie-${i}`); -// Shipped light statusLineBg surfaces (WCAG luminance), near-white through mid-light. const SURFACES: Record = { "light-catppuccin crust (#dce0e8)": lum("#dce0e8"), "light-poimandres (#7390aa)": lum("#7390aa"), }; describe("getSessionAccentHex", () => { - it("is deterministic per name and surface", () => { - expect(getSessionAccentHex("analyze debian trixie")).toBe(getSessionAccentHex("analyze debian trixie")); - expect(getSessionAccentHex("x", 0.7)).toBe(getSessionAccentHex("x", 0.7)); + it("is deterministic per name and parameters", () => { + expect(getSessionAccentHex("analyze debian trixie", NO_THEME_COLORS)).toBe( + getSessionAccentHex("analyze debian trixie", NO_THEME_COLORS), + ); + expect(getSessionAccentHex("x", NO_THEME_COLORS, 0.7)).toBe(getSessionAccentHex("x", NO_THEME_COLORS, 0.7)); + }); + + it("uses warm hues (0-120) on dark themes", () => { + for (const name of names) { + const h = hexToHsv(getSessionAccentHex(name, NO_THEME_COLORS)).h; + expect(h).toBeGreaterThanOrEqual(0); + expect(h).toBeLessThanOrEqual(120); + } + }); + + it("uses cool hues (180-300) on light themes", () => { + for (const name of names) { + const h = hexToHsv(getSessionAccentHex(name, NO_THEME_COLORS, 0.5)).h; + expect(h).toBeGreaterThanOrEqual(180); + expect(h).toBeLessThanOrEqual(300); + } }); it("keeps vivid (bright) accents on dark themes (undefined surface)", () => { - const maxDark = Math.max(...names.map(n => lum(getSessionAccentHex(n)))); + const maxDark = Math.max(...names.map(n => lum(getSessionAccentHex(n, NO_THEME_COLORS)))); expect(maxDark).toBeGreaterThan(0.5); }); it("clears AA-large WCAG contrast against light surfaces, including mid-light", () => { for (const bg of Object.values(SURFACES)) { for (const name of names) { - const hex = getSessionAccentHex(name, bg); - expect(contrast(lum(hex), bg)).toBeGreaterThanOrEqual(2.99); // ~3:1, float margin + const hex = getSessionAccentHex(name, NO_THEME_COLORS, bg); + expect(contrast(lum(hex), bg)).toBeGreaterThanOrEqual(2.99); } } }); @@ -36,7 +57,48 @@ describe("getSessionAccentHex", () => { it("never produces a lighter accent on light themes than on dark for the same name", () => { const nearWhite = SURFACES["light-catppuccin crust (#dce0e8)"]; for (const name of names) { - expect(lum(getSessionAccentHex(name, nearWhite))).toBeLessThanOrEqual(lum(getSessionAccentHex(name)) + 1e-9); + expect(lum(getSessionAccentHex(name, NO_THEME_COLORS, nearWhite))).toBeLessThanOrEqual( + lum(getSessionAccentHex(name, NO_THEME_COLORS)) + 1e-9, + ); + } + }); +}); + +describe("getSessionAccentHex with real Theme", () => { + it("stays in the cool band and avoids theme hues on light-catppuccin", async () => { + const theme = await getThemeByName("light-catppuccin"); + if (!theme) return; // skip if theme not found + const colors = theme.getMajorThemeColorHexes(); + const surface = theme.accentSurfaceLuminance; + const themeHues = colors.map(c => hexToHsv(c).h).filter(h => hexToHsv(colors[0]).s >= 0.1); + + for (const name of ["alpha", "beta", "gamma", "delta", "epsilon", "zeta"]) { + const hex = getSessionAccentHex(name, colors, surface); + const h = hexToHsv(hex).h; + expect(h).toBeGreaterThanOrEqual(180); + expect(h).toBeLessThanOrEqual(300); + for (const th of themeHues) { + const dist = Math.min(Math.abs(h - th), 360 - Math.abs(h - th)); + expect(dist).toBeGreaterThanOrEqual(10); + } + } + }); + + it("stays in the warm band and avoids theme hues on dark-catppuccin", async () => { + const theme = await getThemeByName("dark-catppuccin"); + if (!theme) return; + const colors = theme.getMajorThemeColorHexes(); + const themeHues = colors.map(c => hexToHsv(c).h).filter(h => hexToHsv(colors[0]).s >= 0.1); + + for (const name of ["alpha", "beta", "gamma", "delta", "epsilon", "zeta"]) { + const hex = getSessionAccentHex(name, colors); + const h = hexToHsv(hex).h; + expect(h).toBeGreaterThanOrEqual(0); + expect(h).toBeLessThanOrEqual(120); + for (const th of themeHues) { + const dist = Math.min(Math.abs(h - th), 360 - Math.abs(h - th)); + expect(dist).toBeGreaterThanOrEqual(10); + } } }); }); diff --git a/packages/coding-agent/test/status-line-overflow.test.ts b/packages/coding-agent/test/status-line-overflow.test.ts index b6b490b89..0b0cd52d4 100644 --- a/packages/coding-agent/test/status-line-overflow.test.ts +++ b/packages/coding-agent/test/status-line-overflow.test.ts @@ -102,7 +102,7 @@ describe("status line session accent", () => { return component; } - const accentAnsi = getSessionAccentAnsi(getSessionAccentHex("Named session")); + const accentAnsi = getSessionAccentAnsi(getSessionAccentHex("Named session", theme.getMajorThemeColorHexes())); it("paints the gap with the session accent when enabled", () => { expect(accentAnsi).toBeDefined();