session accent: derive from theme mode with collision avoidance

- 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
This commit is contained in:
Brit
2026-06-10 14:07:16 +02:00
parent bbe85b6661
commit 0160d0dbf2
8 changed files with 270 additions and 27 deletions
@@ -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;
@@ -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 };
},
};
@@ -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);
+84 -1
View File
@@ -1383,9 +1383,23 @@ const langMap: Record<string, SymbolKey> = {
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<ThemeColor, string>;
#bgColors: Record<ThemeBg, string>;
/** Resolved hex strings for foreground colors — populated at construction. */
readonly #hexFgColors: Record<ThemeColor, string>;
/** Resolved hex strings for background colors — populated at construction. */
readonly #hexBgColors: Record<ThemeBg, string>;
#symbols: SymbolMap;
#spinnerFramesOverrides: Partial<Record<SpinnerType, string[]>>;
/**
@@ -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<ThemeColor, string | number>,
bgColors: Record<ThemeBg, string | number>,
@@ -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<ThemeColor, string>;
this.#hexFgColors = {} as Record<ThemeColor, string>;
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<ThemeBg, string>;
this.#hexBgColors = {} as Record<ThemeBg, string>;
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}`);
@@ -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);
}
/**
@@ -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");
@@ -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<string, number> = {
"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);
}
}
});
});
@@ -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();