232 lines
7.5 KiB
TypeScript
232 lines
7.5 KiB
TypeScript
import chalk from 'chalk'
|
||
import type { Color, TextStyles } from './styles.js'
|
||
|
||
/**
|
||
* xterm.js (VS Code, Cursor, code-server, Coder) has supported truecolor
|
||
* since 2017, but code-server/Coder containers often don't set
|
||
* COLORTERM=truecolor. chalk's supports-color doesn't recognize
|
||
* TERM_PROGRAM=vscode (it only knows iTerm.app/Apple_Terminal), so it falls
|
||
* through to the -256color regex → level 2. At level 2, chalk.rgb()
|
||
* downgrades to the nearest 6×6×6 cube color: rgb(215,119,87) (Claude
|
||
* orange) → idx 174 rgb(215,135,135) — washed-out salmon.
|
||
*
|
||
* Gated on level === 2 (not < 3) to respect NO_COLOR / FORCE_COLOR=0 —
|
||
* those yield level 0 and are an explicit "no colors" request. Desktop VS
|
||
* Code sets COLORTERM=truecolor itself, so this is a no-op there (already 3).
|
||
*
|
||
* Must run BEFORE the tmux clamp — if tmux is running inside a VS Code
|
||
* terminal, tmux's passthrough limitation wins and we want level 2.
|
||
*/
|
||
function boostChalkLevelForXtermJs(): boolean {
|
||
if (process.env.TERM_PROGRAM === 'vscode' && chalk.level === 2) {
|
||
chalk.level = 3
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* tmux parses truecolor SGR (\e[48;2;r;g;bm) into its cell buffer correctly,
|
||
* but its client-side emitter only re-emits truecolor to the outer terminal if
|
||
* the outer terminal advertises Tc/RGB capability (via terminal-overrides).
|
||
* Default tmux config doesn't set this, so tmux emits the cell to iTerm2/etc
|
||
* WITHOUT the bg sequence — outer terminal's buffer has bg=default → black on
|
||
* dark profiles. Clamping to level 2 makes chalk emit 256-color (\e[48;5;Nm),
|
||
* which tmux passes through cleanly. grey93 (255) is visually identical to
|
||
* rgb(240,240,240).
|
||
*
|
||
* Users who HAVE set `terminal-overrides ,*:Tc` get a technically-unnecessary
|
||
* downgrade, but the visual difference is imperceptible. Querying
|
||
* `tmux show -gv terminal-overrides` to detect this would add a subprocess on
|
||
* startup — not worth it.
|
||
*
|
||
* $TMUX is a pty-lifecycle env var set by tmux itself; it never comes from
|
||
* globalSettings.env, so reading it here is correct. chalk is a singleton, so
|
||
* this clamps ALL truecolor output (fg+bg+hex) across the entire app.
|
||
*/
|
||
function clampChalkLevelForTmux(): boolean {
|
||
// bg.ts sets terminal-overrides :Tc before attach, so truecolor passes
|
||
// through — skip the clamp. General escape hatch for anyone who's
|
||
// configured their tmux correctly.
|
||
if (process.env.CLAUDE_CODE_TMUX_TRUECOLOR) return false
|
||
if (process.env.TMUX && chalk.level > 2) {
|
||
chalk.level = 2
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
// Computed once at module load — terminal/tmux environment doesn't change mid-session.
|
||
// Order matters: boost first so the tmux clamp can re-clamp if tmux is running
|
||
// inside a VS Code terminal. Exported for debugging — tree-shaken if unused.
|
||
export const CHALK_BOOSTED_FOR_XTERMJS = boostChalkLevelForXtermJs()
|
||
export const CHALK_CLAMPED_FOR_TMUX = clampChalkLevelForTmux()
|
||
|
||
export type ColorType = 'foreground' | 'background'
|
||
|
||
const RGB_REGEX = /^rgb\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)$/
|
||
const ANSI_REGEX = /^ansi256\(\s?(\d+)\s?\)$/
|
||
|
||
export const colorize = (
|
||
str: string,
|
||
color: string | undefined,
|
||
type: ColorType,
|
||
): string => {
|
||
if (!color) {
|
||
return str
|
||
}
|
||
|
||
if (color.startsWith('ansi:')) {
|
||
const value = color.substring('ansi:'.length)
|
||
switch (value) {
|
||
case 'black':
|
||
return type === 'foreground' ? chalk.black(str) : chalk.bgBlack(str)
|
||
case 'red':
|
||
return type === 'foreground' ? chalk.red(str) : chalk.bgRed(str)
|
||
case 'green':
|
||
return type === 'foreground' ? chalk.green(str) : chalk.bgGreen(str)
|
||
case 'yellow':
|
||
return type === 'foreground' ? chalk.yellow(str) : chalk.bgYellow(str)
|
||
case 'blue':
|
||
return type === 'foreground' ? chalk.blue(str) : chalk.bgBlue(str)
|
||
case 'magenta':
|
||
return type === 'foreground' ? chalk.magenta(str) : chalk.bgMagenta(str)
|
||
case 'cyan':
|
||
return type === 'foreground' ? chalk.cyan(str) : chalk.bgCyan(str)
|
||
case 'white':
|
||
return type === 'foreground' ? chalk.white(str) : chalk.bgWhite(str)
|
||
case 'blackBright':
|
||
return type === 'foreground'
|
||
? chalk.blackBright(str)
|
||
: chalk.bgBlackBright(str)
|
||
case 'redBright':
|
||
return type === 'foreground'
|
||
? chalk.redBright(str)
|
||
: chalk.bgRedBright(str)
|
||
case 'greenBright':
|
||
return type === 'foreground'
|
||
? chalk.greenBright(str)
|
||
: chalk.bgGreenBright(str)
|
||
case 'yellowBright':
|
||
return type === 'foreground'
|
||
? chalk.yellowBright(str)
|
||
: chalk.bgYellowBright(str)
|
||
case 'blueBright':
|
||
return type === 'foreground'
|
||
? chalk.blueBright(str)
|
||
: chalk.bgBlueBright(str)
|
||
case 'magentaBright':
|
||
return type === 'foreground'
|
||
? chalk.magentaBright(str)
|
||
: chalk.bgMagentaBright(str)
|
||
case 'cyanBright':
|
||
return type === 'foreground'
|
||
? chalk.cyanBright(str)
|
||
: chalk.bgCyanBright(str)
|
||
case 'whiteBright':
|
||
return type === 'foreground'
|
||
? chalk.whiteBright(str)
|
||
: chalk.bgWhiteBright(str)
|
||
}
|
||
}
|
||
|
||
if (color.startsWith('#')) {
|
||
return type === 'foreground'
|
||
? chalk.hex(color)(str)
|
||
: chalk.bgHex(color)(str)
|
||
}
|
||
|
||
if (color.startsWith('ansi256')) {
|
||
const matches = ANSI_REGEX.exec(color)
|
||
|
||
if (!matches) {
|
||
return str
|
||
}
|
||
|
||
const value = Number(matches[1])
|
||
|
||
return type === 'foreground'
|
||
? chalk.ansi256(value)(str)
|
||
: chalk.bgAnsi256(value)(str)
|
||
}
|
||
|
||
if (color.startsWith('rgb')) {
|
||
const matches = RGB_REGEX.exec(color)
|
||
|
||
if (!matches) {
|
||
return str
|
||
}
|
||
|
||
const firstValue = Number(matches[1])
|
||
const secondValue = Number(matches[2])
|
||
const thirdValue = Number(matches[3])
|
||
|
||
return type === 'foreground'
|
||
? chalk.rgb(firstValue, secondValue, thirdValue)(str)
|
||
: chalk.bgRgb(firstValue, secondValue, thirdValue)(str)
|
||
}
|
||
|
||
return str
|
||
}
|
||
|
||
/**
|
||
* Apply TextStyles to a string using chalk.
|
||
* This is the inverse of parsing ANSI codes - we generate them from structured styles.
|
||
* Theme resolution happens at component layer, not here.
|
||
*/
|
||
export function applyTextStyles(text: string, styles: TextStyles): string {
|
||
let result = text
|
||
|
||
// Apply styles in reverse order of desired nesting.
|
||
// chalk wraps text so later calls become outer wrappers.
|
||
// Desired order (outermost to innermost):
|
||
// background > foreground > text modifiers
|
||
// So we apply: text modifiers first, then foreground, then background last.
|
||
|
||
if (styles.inverse) {
|
||
result = chalk.inverse(result)
|
||
}
|
||
|
||
if (styles.strikethrough) {
|
||
result = chalk.strikethrough(result)
|
||
}
|
||
|
||
if (styles.underline) {
|
||
result = chalk.underline(result)
|
||
}
|
||
|
||
if (styles.italic) {
|
||
result = chalk.italic(result)
|
||
}
|
||
|
||
if (styles.bold) {
|
||
result = chalk.bold(result)
|
||
}
|
||
|
||
if (styles.dim) {
|
||
result = chalk.dim(result)
|
||
}
|
||
|
||
if (styles.color) {
|
||
// Color is now always a raw color value (theme resolution happens at component layer)
|
||
result = colorize(result, styles.color, 'foreground')
|
||
}
|
||
|
||
if (styles.backgroundColor) {
|
||
// backgroundColor is now always a raw color value
|
||
result = colorize(result, styles.backgroundColor, 'background')
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* Apply a raw color value to text.
|
||
* Theme resolution should happen at component layer, not here.
|
||
*/
|
||
export function applyColor(text: string, color: Color | undefined): string {
|
||
if (!color) {
|
||
return text
|
||
}
|
||
return colorize(text, color, 'foreground')
|
||
}
|