fix: align theme truecolor detection
This commit is contained in:
@@ -1,6 +1,12 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
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 chalk from "chalk";
|
||||||
import { type Static, Type } from "typebox";
|
import { type Static, Type } from "typebox";
|
||||||
import { Compile } from "typebox/compile";
|
import { Compile } from "typebox/compile";
|
||||||
@@ -158,33 +164,6 @@ type ColorMode = "truecolor" | "256color";
|
|||||||
// Color Utilities
|
// 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 } {
|
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||||
const cleaned = hex.replace("#", "");
|
const cleaned = hex.replace("#", "");
|
||||||
if (cleaned.length !== 6) {
|
if (cleaned.length !== 6) {
|
||||||
@@ -585,7 +564,7 @@ function loadThemeJson(name: string): ThemeJson {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string): Theme {
|
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 resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);
|
||||||
const fgColors: Record<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;
|
const fgColors: Record<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;
|
||||||
const bgColors: Record<ThemeBg, string | number> = {} as Record<ThemeBg, string | number>;
|
const bgColors: Record<ThemeBg, string | number> = {} as Record<ThemeBg, string | number>;
|
||||||
@@ -1227,7 +1206,7 @@ export function getEditorTheme(): EditorTheme {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSettingsListTheme(): import("@earendil-works/pi-tui").SettingsListTheme {
|
export function getSettingsListTheme(): SettingsListTheme {
|
||||||
return {
|
return {
|
||||||
label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text),
|
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)),
|
value: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)),
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
detectTerminalBackground,
|
detectTerminalBackground,
|
||||||
|
getThemeByName,
|
||||||
getThemeForRgbColor,
|
getThemeForRgbColor,
|
||||||
parseOsc11BackgroundColor,
|
parseOsc11BackgroundColor,
|
||||||
} from "../src/modes/interactive/theme/theme.js";
|
} from "../src/modes/interactive/theme/theme.js";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetCapabilitiesCache();
|
||||||
|
});
|
||||||
|
|
||||||
describe("detectTerminalBackground", () => {
|
describe("detectTerminalBackground", () => {
|
||||||
it("uses the COLORFGBG background color index", () => {
|
it("uses the COLORFGBG background color index", () => {
|
||||||
expect(detectTerminalBackground({ env: { COLORFGBG: "0;15" } })).toMatchObject({
|
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", () => {
|
describe("parseOsc11BackgroundColor", () => {
|
||||||
it("parses 16-bit OSC 11 rgb responses", () => {
|
it("parses 16-bit OSC 11 rgb responses", () => {
|
||||||
expect(parseOsc11BackgroundColor("\x1b]11;rgb:0000/8000/ffff\x07")).toEqual({ r: 0, g: 128, b: 255 });
|
expect(parseOsc11BackgroundColor("\x1b]11;rgb:0000/8000/ffff\x07")).toEqual({ r: 0, g: 128, b: 255 });
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function detectCapabilities(): TerminalCapabilities {
|
|||||||
const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || "";
|
const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || "";
|
||||||
const term = process.env.TERM?.toLowerCase() || "";
|
const term = process.env.TERM?.toLowerCase() || "";
|
||||||
const colorTerm = process.env.COLORTERM?.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
|
// tmux and screen swallow OSC 8 by default (passthrough is opt-in and wraps
|
||||||
// sequences differently). Force hyperlinks off whenever we detect them, even
|
// 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.
|
// also unreliable under tmux/screen, so leave `images: null` for safety.
|
||||||
const inTmuxOrScreen = !!process.env.TMUX || term.startsWith("tmux") || term.startsWith("screen");
|
const inTmuxOrScreen = !!process.env.TMUX || term.startsWith("tmux") || term.startsWith("screen");
|
||||||
if (inTmuxOrScreen) {
|
if (inTmuxOrScreen) {
|
||||||
const trueColor = colorTerm === "truecolor" || colorTerm === "24bit";
|
return { images: null, trueColor: hasTrueColorHint, hyperlinks: false };
|
||||||
return { images: null, trueColor, hyperlinks: false };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.KITTY_WINDOW_ID || termProgram === "kitty") {
|
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
|
// text" on terminals that swallow it, which means the URL disappears from
|
||||||
// the rendered output. Default to the legacy `text (url)` behavior unless we
|
// the rendered output. Default to the legacy `text (url)` behavior unless we
|
||||||
// have positively identified a hyperlink-capable terminal above.
|
// have positively identified a hyperlink-capable terminal above.
|
||||||
const trueColor = colorTerm === "truecolor" || colorTerm === "24bit";
|
return { images: null, trueColor: hasTrueColorHint || !!process.env.WT_SESSION, hyperlinks: false };
|
||||||
return { images: null, trueColor, hyperlinks: false };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCapabilities(): TerminalCapabilities {
|
export function getCapabilities(): TerminalCapabilities {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const ENV_KEYS = [
|
|||||||
"GHOSTTY_RESOURCES_DIR",
|
"GHOSTTY_RESOURCES_DIR",
|
||||||
"WEZTERM_PANE",
|
"WEZTERM_PANE",
|
||||||
"ITERM_SESSION_ID",
|
"ITERM_SESSION_ID",
|
||||||
|
"WT_SESSION",
|
||||||
"CMUX_WORKSPACE_ID",
|
"CMUX_WORKSPACE_ID",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -271,6 +272,31 @@ describe("detectCapabilities", () => {
|
|||||||
assert.strictEqual(caps.hyperlinks, true);
|
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", () => {
|
describe("Kitty image cursor movement", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user