From dafcf61a34f74ba1ce90ed4f610121ffc68c755f Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 18 May 2026 21:56:48 +0200 Subject: [PATCH] fix: align theme truecolor detection --- .../src/modes/interactive/theme/theme.ts | 39 +++++-------------- .../coding-agent/test/theme-detection.test.ts | 24 +++++++++++- packages/tui/src/terminal-image.ts | 7 ++-- packages/tui/test/terminal-image.test.ts | 26 +++++++++++++ 4 files changed, 61 insertions(+), 35 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 77b34287..9fb8b031 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -1,6 +1,12 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import type { EditorTheme, MarkdownTheme, SelectListTheme } from "@earendil-works/pi-tui"; +import { + type EditorTheme, + getCapabilities, + type MarkdownTheme, + type SelectListTheme, + type SettingsListTheme, +} from "@earendil-works/pi-tui"; import chalk from "chalk"; import { type Static, Type } from "typebox"; import { Compile } from "typebox/compile"; @@ -158,33 +164,6 @@ type ColorMode = "truecolor" | "256color"; // Color Utilities // ============================================================================ -function detectColorMode(): ColorMode { - const colorterm = process.env.COLORTERM; - if (colorterm === "truecolor" || colorterm === "24bit") { - return "truecolor"; - } - // Windows Terminal supports truecolor - if (process.env.WT_SESSION) { - return "truecolor"; - } - const term = process.env.TERM || ""; - // Fall back to 256color for truly limited terminals - if (term === "dumb" || term === "" || term === "linux") { - return "256color"; - } - // Terminal.app also doesn't support truecolor - if (process.env.TERM_PROGRAM === "Apple_Terminal") { - return "256color"; - } - // GNU screen doesn't support truecolor unless explicitly opted in via COLORTERM=truecolor. - // TERM under screen is typically "screen", "screen-256color", or "screen.xterm-256color". - if (term === "screen" || term.startsWith("screen-") || term.startsWith("screen.")) { - return "256color"; - } - // Assume truecolor for everything else - virtually all modern terminals support it - return "truecolor"; -} - function hexToRgb(hex: string): { r: number; g: number; b: number } { const cleaned = hex.replace("#", ""); if (cleaned.length !== 6) { @@ -585,7 +564,7 @@ function loadThemeJson(name: string): ThemeJson { } function createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string): Theme { - const colorMode = mode ?? detectColorMode(); + const colorMode = mode ?? (getCapabilities().trueColor ? "truecolor" : "256color"); const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars); const fgColors: Record = {} as Record; const bgColors: Record = {} as Record; @@ -1227,7 +1206,7 @@ export function getEditorTheme(): EditorTheme { }; } -export function getSettingsListTheme(): import("@earendil-works/pi-tui").SettingsListTheme { +export function getSettingsListTheme(): SettingsListTheme { return { label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text), value: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)), diff --git a/packages/coding-agent/test/theme-detection.test.ts b/packages/coding-agent/test/theme-detection.test.ts index 119c6e61..582358bb 100644 --- a/packages/coding-agent/test/theme-detection.test.ts +++ b/packages/coding-agent/test/theme-detection.test.ts @@ -1,10 +1,16 @@ -import { describe, expect, it } from "vitest"; +import { resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui"; +import { afterEach, describe, expect, it } from "vitest"; import { detectTerminalBackground, + getThemeByName, getThemeForRgbColor, parseOsc11BackgroundColor, } from "../src/modes/interactive/theme/theme.js"; +afterEach(() => { + resetCapabilitiesCache(); +}); + describe("detectTerminalBackground", () => { it("uses the COLORFGBG background color index", () => { expect(detectTerminalBackground({ env: { COLORFGBG: "0;15" } })).toMatchObject({ @@ -32,6 +38,22 @@ describe("detectTerminalBackground", () => { }); }); +describe("theme color mode", () => { + it("uses terminal capabilities", () => { + setCapabilities({ images: null, trueColor: false, hyperlinks: false }); + const ansi256Theme = getThemeByName("dark"); + if (!ansi256Theme) throw new Error("dark theme not found"); + expect(ansi256Theme.getColorMode()).toBe("256color"); + expect(ansi256Theme.getFgAnsi("accent")).toMatch(/^\x1b\[38;5;\d+m$/); + + setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + const truecolorTheme = getThemeByName("dark"); + if (!truecolorTheme) throw new Error("dark theme not found"); + expect(truecolorTheme.getColorMode()).toBe("truecolor"); + expect(truecolorTheme.getFgAnsi("accent")).toMatch(/^\x1b\[38;2;\d+;\d+;\d+m$/); + }); +}); + describe("parseOsc11BackgroundColor", () => { it("parses 16-bit OSC 11 rgb responses", () => { expect(parseOsc11BackgroundColor("\x1b]11;rgb:0000/8000/ffff\x07")).toEqual({ r: 0, g: 128, b: 255 }); diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index b355f0f3..d6fe13bd 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -43,6 +43,7 @@ export function detectCapabilities(): TerminalCapabilities { const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || ""; const term = process.env.TERM?.toLowerCase() || ""; const colorTerm = process.env.COLORTERM?.toLowerCase() || ""; + const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit"; // tmux and screen swallow OSC 8 by default (passthrough is opt-in and wraps // sequences differently). Force hyperlinks off whenever we detect them, even @@ -50,8 +51,7 @@ export function detectCapabilities(): TerminalCapabilities { // also unreliable under tmux/screen, so leave `images: null` for safety. const inTmuxOrScreen = !!process.env.TMUX || term.startsWith("tmux") || term.startsWith("screen"); if (inTmuxOrScreen) { - const trueColor = colorTerm === "truecolor" || colorTerm === "24bit"; - return { images: null, trueColor, hyperlinks: false }; + return { images: null, trueColor: hasTrueColorHint, hyperlinks: false }; } if (process.env.KITTY_WINDOW_ID || termProgram === "kitty") { @@ -82,8 +82,7 @@ export function detectCapabilities(): TerminalCapabilities { // text" on terminals that swallow it, which means the URL disappears from // the rendered output. Default to the legacy `text (url)` behavior unless we // have positively identified a hyperlink-capable terminal above. - const trueColor = colorTerm === "truecolor" || colorTerm === "24bit"; - return { images: null, trueColor, hyperlinks: false }; + return { images: null, trueColor: hasTrueColorHint || !!process.env.WT_SESSION, hyperlinks: false }; } export function getCapabilities(): TerminalCapabilities { diff --git a/packages/tui/test/terminal-image.test.ts b/packages/tui/test/terminal-image.test.ts index 68c576bd..4f7c238b 100644 --- a/packages/tui/test/terminal-image.test.ts +++ b/packages/tui/test/terminal-image.test.ts @@ -27,6 +27,7 @@ const ENV_KEYS = [ "GHOSTTY_RESOURCES_DIR", "WEZTERM_PANE", "ITERM_SESSION_ID", + "WT_SESSION", "CMUX_WORKSPACE_ID", ] as const; @@ -271,6 +272,31 @@ describe("detectCapabilities", () => { assert.strictEqual(caps.hyperlinks, true); }); }); + + it("detects truecolor for Windows Terminal outside multiplexers", () => { + withEnv({ WT_SESSION: "session", TERM: "xterm-256color" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.trueColor, true); + }); + }); + + it("does not inherit Windows Terminal truecolor through tmux", () => { + withEnv({ WT_SESSION: "session", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.trueColor, false); + assert.strictEqual(caps.hyperlinks, false); + assert.strictEqual(caps.images, null); + }); + }); + + it("trusts explicit truecolor hints through tmux", () => { + withEnv({ COLORTERM: "truecolor", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.trueColor, true); + assert.strictEqual(caps.hyperlinks, false); + assert.strictEqual(caps.images, null); + }); + }); }); describe("Kitty image cursor movement", () => {