From fdc1bfdfc5ad8f3c1b24ea2e75a61bbf7c211129 Mon Sep 17 00:00:00 2001 From: Florian Brand <46377542+xeophon@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:09:00 +0200 Subject: [PATCH] fix(coding-agent): keep selection highlight visible against matching terminal backgrounds (#562) --- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/agents-view/agents-view-mode.ts | 2 +- .../components/child-agent-inspector.ts | 2 +- .../interactive/components/menu-panel.ts | 4 +- .../interactive/components/tree-selector.ts | 2 +- .../src/modes/interactive/theme/theme.ts | 78 ++++++ .../coding-agent/test/theme-adaptive.test.ts | 236 +++++++++++++++++- 7 files changed, 318 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index dbca510aa..f70a541d7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -16,6 +16,7 @@ - Changed session search to rank exact name and first-message matches before prefix, substring, and stricter transcript fuzzy matches. - Fixed daemon startup failing when an interrupted supervisor left an empty ownership directory. - Fixed `/effort xhigh` and `/effort max` being rejected as unknown levels when no model was active yet. +- Fixed selected rows in the agents view, child agent panel, and selectors being nearly invisible on terminals whose background closely matches the `selectedBg` theme color; the selection highlight now adapts to keep a minimum contrast against the detected terminal background. - Fixed startup blocking on a private Prime Inference model authorization request; the authorization is now cached next to `models.json` and stale entries refresh in the background. ## [0.3.3] - 2026-07-23 diff --git a/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts b/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts index 857ffac4b..f25872ae4 100644 --- a/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts +++ b/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts @@ -2377,7 +2377,7 @@ export class AgentsViewMode implements Component, Focusable { if (code) { return theme.bg("toolPanelBg", padded); } - return selected ? theme.bg("selectedBg", padded) : padded; + return selected ? theme.getSelectionBackgroundColor()(padded) : padded; } private isPendingDeleteRow(row: AgentsViewRow): boolean { diff --git a/packages/coding-agent/src/modes/interactive/components/child-agent-inspector.ts b/packages/coding-agent/src/modes/interactive/components/child-agent-inspector.ts index 10e4532ef..33bca0e87 100644 --- a/packages/coding-agent/src/modes/interactive/components/child-agent-inspector.ts +++ b/packages/coding-agent/src/modes/interactive/components/child-agent-inspector.ts @@ -586,7 +586,7 @@ export class ChildAgentSummaryComponent implements Component, Focusable { const truncated = this.truncate(line, contentWidth); const paddedContent = truncated + " ".repeat(Math.max(0, contentWidth - visibleWidth(truncated))); const padded = `${" ".repeat(this.paddingX)}${paddedContent}${" ".repeat(this.paddingX)}`; - return selected ? theme.bg("selectedBg", padded) : padded; + return selected ? theme.getSelectionBackgroundColor()(padded) : padded; } private truncate(line: string, width: number): string { diff --git a/packages/coding-agent/src/modes/interactive/components/menu-panel.ts b/packages/coding-agent/src/modes/interactive/components/menu-panel.ts index 4b88fca4e..f9e6899d2 100644 --- a/packages/coding-agent/src/modes/interactive/components/menu-panel.ts +++ b/packages/coding-agent/src/modes/interactive/components/menu-panel.ts @@ -383,9 +383,7 @@ export class MenuRow implements Component, FullWidthMenuComponent { } private rowLine(text: string, width: number, selected: boolean): string { - const background = selected - ? (content: string) => theme.bg("selectedBg", content) - : theme.getEditorBackgroundColor(); + const background = selected ? theme.getSelectionBackgroundColor() : theme.getEditorBackgroundColor(); return paddedBackgroundLine(text, width, ROW_PADDING_X, background); } } diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 48a283747..a4a196fa5 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -692,7 +692,7 @@ class TreeList implements Component { let line = cursor + theme.fg("dim", prefix) + foldMarker + pathMarker + label + labelTimestamp + content; if (isSelected) { - line = theme.bg("selectedBg", line); + line = theme.getSelectionBackgroundColor()(line); } lines.push(truncateToWidth(line, width)); } diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index accb0af28..dd4a48f7f 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -188,6 +188,10 @@ type ColorMode = "truecolor" | "256color"; const ADAPTIVE_LIGHT_BG_ACCENT: Rgb = { r: 0, g: 95, b: 135 }; const SURFACE_MIN_LUMINANCE_DELTA = 12; const SURFACE_CONTRAST_ALPHA = 0.08; +// Selection rows must stand out clearly, much more than passive surfaces. +const SELECTION_MIN_LUMINANCE_DELTA = 28; +const SELECTION_MAX_BLEND_ALPHA = 0.5; +const SELECTION_BLEND_STEP = 0.05; const BLACK: Rgb = { r: 0, g: 0, b: 0 }; const WHITE: Rgb = { r: 255, g: 255, b: 255 }; const CUBE_VALUES = [0, 95, 135, 175, 215, 255] as const; @@ -420,6 +424,80 @@ export class Theme { return this.surfaceBackgroundColor("toolPanelBg"); } + getSelectionBackgroundColor(): (str: string) => string { + const terminalBg = getDefaultTerminalColors()?.background; + const selectedBgValue = this.bgColorValues.get("selectedBg"); + // Basic ANSI colors (0-15) are terminal-defined; their rendered color is + // unknown, so no reliable contrast computation (or blend base) exists. + if (!terminalBg || (typeof selectedBgValue === "number" && selectedBgValue < 16)) { + return (str: string) => this.bg("selectedBg", str); + } + // An empty selectedBg renders as the terminal background itself; blend + // from there so the selection still gets an explicit contrasting color. + const selectionRgb = colorValueToRgb(selectedBgValue) ?? terminalBg; + + // Compare against what actually renders: on 256-color terminals the + // palette quantization can erase the contrast of the configured value, + // and gives the blends a quantized baseline to beat. + const terminalLuminance = luminance(terminalBg); + const renderedSelection = colorValueToRgb(bestAnsiColor(selectionRgb, this.mode)) ?? selectionRgb; + const selectionLuminance = luminance(renderedSelection); + const delta = Math.abs(selectionLuminance - terminalLuminance); + if (delta >= SELECTION_MIN_LUMINANCE_DELTA) { + return (str: string) => this.bg("selectedBg", str); + } + + // Blend away from the terminal background toward the endpoint on the + // selection's side of it. When that direction cannot reach the minimum + // delta (e.g. the selection already sits at the endpoint), fall back to + // the opposite endpoint, which requires crossing the background. + const endpoints = selectionLuminance >= terminalLuminance ? [WHITE, BLACK] : [BLACK, WHITE]; + let bestColor: string | number | undefined; + let bestDelta = delta; + for (const top of endpoints) { + const spread = luminance(top) - selectionLuminance; + if (spread === 0) { + continue; + } + const targetLuminance = terminalLuminance + Math.sign(spread) * SELECTION_MIN_LUMINANCE_DELTA; + const baseAlpha = Math.min(SELECTION_MAX_BLEND_ALPHA, (targetLuminance - selectionLuminance) / spread); + // Quantize before evaluating: on 256-color terminals the palette + // rounding can otherwise erase the delta the blend was chosen for. + // If the direct hit undershoots, keep stepping toward the cap — + // a stronger blend may quantize to a palette color that passes. + const alphas: number[] = []; + for (let alpha = baseAlpha; alpha < SELECTION_MAX_BLEND_ALPHA; alpha += SELECTION_BLEND_STEP) { + alphas.push(alpha); + } + alphas.push(SELECTION_MAX_BLEND_ALPHA); + for (const alpha of alphas) { + const adjustedColor = bestAnsiColor(blendColor(top, selectionRgb, alpha), this.mode); + const adjustedRgb = colorValueToRgb(adjustedColor); + if (adjustedColor === "" || !adjustedRgb) { + continue; + } + const resultDelta = Math.abs(luminance(adjustedRgb) - terminalLuminance); + if (resultDelta >= SELECTION_MIN_LUMINANCE_DELTA - 1) { + bestColor = adjustedColor; + bestDelta = resultDelta; + break; + } + if (resultDelta > bestDelta) { + bestColor = adjustedColor; + bestDelta = resultDelta; + } + } + if (bestColor !== undefined && bestDelta >= SELECTION_MIN_LUMINANCE_DELTA - 1) { + break; + } + } + if (bestColor === undefined) { + return (str: string) => this.bg("selectedBg", str); + } + const ansi = bgAnsi(bestColor, this.mode); + return (str: string) => `${ansi}${str}\x1b[49m`; + } + private surfaceBackgroundColor(color: ThemeBg): (str: string) => string { const terminalBg = getDefaultTerminalColors()?.background; const surfaceRgb = colorValueToRgb(this.bgColorValues.get(color)); diff --git a/packages/coding-agent/test/theme-adaptive.test.ts b/packages/coding-agent/test/theme-adaptive.test.ts index 84f62617d..c97015d98 100644 --- a/packages/coding-agent/test/theme-adaptive.test.ts +++ b/packages/coding-agent/test/theme-adaptive.test.ts @@ -1,6 +1,26 @@ import { clearDefaultTerminalColors, setDefaultTerminalColors } from "@earendil-works/pi-tui"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { getEditorTheme, initTheme, theme } from "../src/modes/interactive/theme/theme.js"; +import { getEditorTheme, initTheme, setThemeInstance, Theme, theme } from "../src/modes/interactive/theme/theme.js"; + +function ansi256IndexToRgb(index: number): { r: number; g: number; b: number } { + if (index >= 232) { + const v = 8 + (index - 232) * 10; + return { r: v, g: v, b: v }; + } + const cube = [0, 95, 135, 175, 215, 255]; + const n = index - 16; + return { r: cube[Math.floor(n / 36)]!, g: cube[Math.floor((n % 36) / 6)]!, b: cube[n % 6]! }; +} + +function luminanceRgb(color: { r: number; g: number; b: number }): number { + return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b; +} + +function extractRgbLuminance(rendered: string): number { + const match = /48;2;(\d+);(\d+);(\d+)m/.exec(rendered); + if (!match) throw new Error(`Expected truecolor background escape, got: ${JSON.stringify(rendered)}`); + return 0.299 * Number(match[1]) + 0.587 * Number(match[2]) + 0.114 * Number(match[3]); +} describe("adaptive TUI theme colors", () => { let previousColorTerm: string | undefined; @@ -73,6 +93,220 @@ describe("adaptive TUI theme colors", () => { expect(editorTheme.borderColor("x")).toBe(theme.fg("borderMuted", "x")); }); + it("keeps the theme selection background when the terminal background is unknown", () => { + expect(theme.getSelectionBackgroundColor()("x")).toBe(theme.bg("selectedBg", "x")); + }); + + it("keeps the theme selection background on clearly different terminal backgrounds", () => { + setDefaultTerminalColors({ + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + }); + + expect(theme.getSelectionBackgroundColor()("x")).toBe(theme.bg("selectedBg", "x")); + }); + + it("strengthens the selection background when it nearly matches the terminal background", () => { + setDefaultTerminalColors({ + foreground: { r: 235, g: 219, b: 178 }, + background: { r: 29, g: 32, b: 33 }, + }); + + const selection = theme.getSelectionBackgroundColor()("x"); + expect(selection).not.toBe(theme.bg("selectedBg", "x")); + expect(selection).toMatch(/\x1b\[48;2;\d+;\d+;\d+mx\x1b\[49m/); + }); + + it("darkens a selection background that is darker than a dark terminal background", () => { + setDefaultTerminalColors({ + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 60, g: 60, b: 60 }, + }); + + const rendered = theme.getSelectionBackgroundColor()("x"); + expect(rendered).not.toBe(theme.bg("selectedBg", "x")); + // Selection luminance 34.5 against a terminal luminance of 60 has delta 25.5; + // blending darker reaches the minimum without crossing the background. + expect(Math.abs(extractRgbLuminance(rendered) - 60)).toBeGreaterThanOrEqual(27.5); + expect(extractRgbLuminance(rendered)).toBeLessThan(34.5); + }); + + it("lightens a selection background that is brighter than a light terminal background", () => { + initTheme("light"); + setDefaultTerminalColors({ + foreground: { r: 0, g: 0, b: 0 }, + background: { r: 200, g: 200, b: 200 }, + }); + + const rendered = theme.getSelectionBackgroundColor()("x"); + expect(rendered).not.toBe(theme.bg("selectedBg", "x")); + // light theme selectedBg #d0d0e0 has luminance ~209.7, terminal has 200. + expect(extractRgbLuminance(rendered)).toBeGreaterThan(209.7); + expect(Math.abs(extractRgbLuminance(rendered) - 200)).toBeGreaterThanOrEqual(27.5); + }); + + it("crosses the background when the selection sits at the blend endpoint", () => { + setThemeInstance( + new Theme( + {} as ConstructorParameters[0], + { selectedBg: "#000000" } as ConstructorParameters[1], + "truecolor", + ), + ); + setDefaultTerminalColors({ + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 1, g: 1, b: 1 }, + }); + + const rendered = theme.getSelectionBackgroundColor()("x"); + expect(rendered).not.toBe(theme.bg("selectedBg", "x")); + // Blending toward black cannot change a black selection, so it must blend + // upward across the background to reach the minimum contrast. + expect(Math.abs(extractRgbLuminance(rendered) - 1)).toBeGreaterThanOrEqual(27.5); + + initTheme("prime"); + }); + + it("falls back across the background when the same-side blend is capped", () => { + // Prime selectedBg (#222226, luminance ~34.5) is slightly darker than this + // terminal background; blending darker is capped at 0.5 and cannot reach 28, + // so the highlight must cross upward instead. + setDefaultTerminalColors({ + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 35, g: 35, b: 35 }, + }); + + const rendered = theme.getSelectionBackgroundColor()("x"); + expect(rendered).not.toBe(theme.bg("selectedBg", "x")); + expect(Math.abs(extractRgbLuminance(rendered) - 35)).toBeGreaterThanOrEqual(27.5); + }); + + it("keeps contrast after 256-color quantization", () => { + setThemeInstance( + new Theme( + {} as ConstructorParameters[0], + { selectedBg: "#e93e4d" } as ConstructorParameters[1], + "256color", + ), + ); + // #e93e4d (luminance 114.9) on #2ca649 (luminance 118.9): the pre-quantized + // blend is fine, but naive quantization lands on #af5f5f, matching the + // terminal background exactly. + setDefaultTerminalColors({ + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 44, g: 166, b: 73 }, + }); + + const rendered = theme.getSelectionBackgroundColor()("x"); + const match = /48;5;(\d+)m/.exec(rendered); + if (!match) throw new Error(`Expected 256-color background escape, got: ${JSON.stringify(rendered)}`); + const quantized = ansi256IndexToRgb(Number(match[1])); + expect(Math.abs(luminanceRgb(quantized) - 118.9)).toBeGreaterThanOrEqual(27.5); + + initTheme("prime"); + }); + + it("searches stronger blends when 256-color quantization undershoots", () => { + setThemeInstance( + new Theme( + {} as ConstructorParameters[0], + { selectedBg: "#2f4d50" } as ConstructorParameters[1], + "256color", + ), + ); + // #2f4d50 (luminance 68.4) on #751dce (luminance 75.5): both exact-target + // blends quantize far below the threshold; a stronger blend toward white + // quantizes to #87afaf and clears it. + setDefaultTerminalColors({ + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 117, g: 29, b: 206 }, + }); + + const rendered = theme.getSelectionBackgroundColor()("x"); + const match = /48;5;(\d+)m/.exec(rendered); + if (!match) throw new Error(`Expected 256-color background escape, got: ${JSON.stringify(rendered)}`); + expect(Math.abs(luminanceRgb(ansi256IndexToRgb(Number(match[1]))) - 75.5)).toBeGreaterThanOrEqual(27.5); + + initTheme("prime"); + }); + + it("adapts when 256-color quantization erases the configured contrast", () => { + setThemeInstance( + new Theme( + {} as ConstructorParameters[0], + { selectedBg: "#e82d6a" } as ConstructorParameters[1], + "256color", + ), + ); + // #e82d6a (luminance ~108) on #d6005d (luminance ~74.6): raw delta 33 passes, + // but the selection quantizes to #d7005f which nearly matches the terminal. + setDefaultTerminalColors({ + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 214, g: 0, b: 93 }, + }); + + const rendered = theme.getSelectionBackgroundColor()("x"); + expect(rendered).not.toBe(theme.bg("selectedBg", "x")); + const match = /48;5;(\d+)m/.exec(rendered); + if (!match) throw new Error(`Expected 256-color background escape, got: ${JSON.stringify(rendered)}`); + expect(Math.abs(luminanceRgb(ansi256IndexToRgb(Number(match[1]))) - 74.6)).toBeGreaterThanOrEqual(27.5); + + initTheme("prime"); + }); + + it("leaves terminal-defined basic ANSI selection colors alone", () => { + setThemeInstance( + new Theme( + {} as ConstructorParameters[0], + { selectedBg: 0 } as ConstructorParameters[1], + "256color", + ), + ); + setDefaultTerminalColors({ + foreground: { r: 235, g: 219, b: 178 }, + background: { r: 29, g: 32, b: 33 }, + }); + + expect(theme.getSelectionBackgroundColor()("x")).toBe(theme.bg("selectedBg", "x")); + + initTheme("prime"); + }); + + it("derives a contrasting selection when selectedBg uses the terminal default", () => { + setThemeInstance( + new Theme( + {} as ConstructorParameters[0], + { selectedBg: "" } as ConstructorParameters[1], + "256color", + ), + ); + setDefaultTerminalColors({ + foreground: { r: 235, g: 219, b: 178 }, + background: { r: 29, g: 32, b: 33 }, + }); + + const rendered = theme.getSelectionBackgroundColor()("x"); + expect(rendered).not.toBe(theme.bg("selectedBg", "x")); + const match = /48;5;(\d+)m/.exec(rendered); + if (!match) throw new Error(`Expected 256-color background escape, got: ${JSON.stringify(rendered)}`); + // Terminal background luminance is 31.2; the derived highlight must clear it. + expect(Math.abs(luminanceRgb(ansi256IndexToRgb(Number(match[1]))) - 31.2)).toBeGreaterThanOrEqual(27.5); + + initTheme("prime"); + }); + + it("darkens a light selection background that nearly matches a light terminal background", () => { + initTheme("light"); + setDefaultTerminalColors({ + foreground: { r: 0, g: 0, b: 0 }, + background: { r: 210, g: 210, b: 220 }, + }); + + const selection = theme.getSelectionBackgroundColor()("x"); + expect(selection).not.toBe(theme.bg("selectedBg", "x")); + expect(selection).toMatch(/\x1b\[48;2;\d+;\d+;\d+mx\x1b\[49m/); + }); + it("uses COLORFGBG for automatic default theme selection when OSC colors are unavailable", () => { process.env.COLORFGBG = "0;15"; clearDefaultTerminalColors(); -- 2.54.0