/ ink / colorize.ts
colorize.ts
  1  import chalk from 'chalk'
  2  import type { Color, TextStyles } from './styles.js'
  3  
  4  /**
  5   * xterm.js (VS Code, Cursor, code-server, Coder) has supported truecolor
  6   * since 2017, but code-server/Coder containers often don't set
  7   * COLORTERM=truecolor. chalk's supports-color doesn't recognize
  8   * TERM_PROGRAM=vscode (it only knows iTerm.app/Apple_Terminal), so it falls
  9   * through to the -256color regex → level 2. At level 2, chalk.rgb()
 10   * downgrades to the nearest 6×6×6 cube color: rgb(215,119,87) (Claude
 11   * orange) → idx 174 rgb(215,135,135) — washed-out salmon.
 12   *
 13   * Gated on level === 2 (not < 3) to respect NO_COLOR / FORCE_COLOR=0 —
 14   * those yield level 0 and are an explicit "no colors" request. Desktop VS
 15   * Code sets COLORTERM=truecolor itself, so this is a no-op there (already 3).
 16   *
 17   * Must run BEFORE the tmux clamp — if tmux is running inside a VS Code
 18   * terminal, tmux's passthrough limitation wins and we want level 2.
 19   */
 20  function boostChalkLevelForXtermJs(): boolean {
 21    if (process.env.TERM_PROGRAM === 'vscode' && chalk.level === 2) {
 22      chalk.level = 3
 23      return true
 24    }
 25    return false
 26  }
 27  
 28  /**
 29   * tmux parses truecolor SGR (\e[48;2;r;g;bm) into its cell buffer correctly,
 30   * but its client-side emitter only re-emits truecolor to the outer terminal if
 31   * the outer terminal advertises Tc/RGB capability (via terminal-overrides).
 32   * Default tmux config doesn't set this, so tmux emits the cell to iTerm2/etc
 33   * WITHOUT the bg sequence — outer terminal's buffer has bg=default → black on
 34   * dark profiles. Clamping to level 2 makes chalk emit 256-color (\e[48;5;Nm),
 35   * which tmux passes through cleanly. grey93 (255) is visually identical to
 36   * rgb(240,240,240).
 37   *
 38   * Users who HAVE set `terminal-overrides ,*:Tc` get a technically-unnecessary
 39   * downgrade, but the visual difference is imperceptible. Querying
 40   * `tmux show -gv terminal-overrides` to detect this would add a subprocess on
 41   * startup — not worth it.
 42   *
 43   * $TMUX is a pty-lifecycle env var set by tmux itself; it never comes from
 44   * globalSettings.env, so reading it here is correct. chalk is a singleton, so
 45   * this clamps ALL truecolor output (fg+bg+hex) across the entire app.
 46   */
 47  function clampChalkLevelForTmux(): boolean {
 48    // bg.ts sets terminal-overrides :Tc before attach, so truecolor passes
 49    // through — skip the clamp. General escape hatch for anyone who's
 50    // configured their tmux correctly.
 51    if (process.env.CLAUDE_CODE_TMUX_TRUECOLOR) return false
 52    if (process.env.TMUX && chalk.level > 2) {
 53      chalk.level = 2
 54      return true
 55    }
 56    return false
 57  }
 58  // Computed once at module load — terminal/tmux environment doesn't change mid-session.
 59  // Order matters: boost first so the tmux clamp can re-clamp if tmux is running
 60  // inside a VS Code terminal. Exported for debugging — tree-shaken if unused.
 61  export const CHALK_BOOSTED_FOR_XTERMJS = boostChalkLevelForXtermJs()
 62  export const CHALK_CLAMPED_FOR_TMUX = clampChalkLevelForTmux()
 63  
 64  export type ColorType = 'foreground' | 'background'
 65  
 66  const RGB_REGEX = /^rgb\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)$/
 67  const ANSI_REGEX = /^ansi256\(\s?(\d+)\s?\)$/
 68  
 69  export const colorize = (
 70    str: string,
 71    color: string | undefined,
 72    type: ColorType,
 73  ): string => {
 74    if (!color) {
 75      return str
 76    }
 77  
 78    if (color.startsWith('ansi:')) {
 79      const value = color.substring('ansi:'.length)
 80      switch (value) {
 81        case 'black':
 82          return type === 'foreground' ? chalk.black(str) : chalk.bgBlack(str)
 83        case 'red':
 84          return type === 'foreground' ? chalk.red(str) : chalk.bgRed(str)
 85        case 'green':
 86          return type === 'foreground' ? chalk.green(str) : chalk.bgGreen(str)
 87        case 'yellow':
 88          return type === 'foreground' ? chalk.yellow(str) : chalk.bgYellow(str)
 89        case 'blue':
 90          return type === 'foreground' ? chalk.blue(str) : chalk.bgBlue(str)
 91        case 'magenta':
 92          return type === 'foreground' ? chalk.magenta(str) : chalk.bgMagenta(str)
 93        case 'cyan':
 94          return type === 'foreground' ? chalk.cyan(str) : chalk.bgCyan(str)
 95        case 'white':
 96          return type === 'foreground' ? chalk.white(str) : chalk.bgWhite(str)
 97        case 'blackBright':
 98          return type === 'foreground'
 99            ? chalk.blackBright(str)
100            : chalk.bgBlackBright(str)
101        case 'redBright':
102          return type === 'foreground'
103            ? chalk.redBright(str)
104            : chalk.bgRedBright(str)
105        case 'greenBright':
106          return type === 'foreground'
107            ? chalk.greenBright(str)
108            : chalk.bgGreenBright(str)
109        case 'yellowBright':
110          return type === 'foreground'
111            ? chalk.yellowBright(str)
112            : chalk.bgYellowBright(str)
113        case 'blueBright':
114          return type === 'foreground'
115            ? chalk.blueBright(str)
116            : chalk.bgBlueBright(str)
117        case 'magentaBright':
118          return type === 'foreground'
119            ? chalk.magentaBright(str)
120            : chalk.bgMagentaBright(str)
121        case 'cyanBright':
122          return type === 'foreground'
123            ? chalk.cyanBright(str)
124            : chalk.bgCyanBright(str)
125        case 'whiteBright':
126          return type === 'foreground'
127            ? chalk.whiteBright(str)
128            : chalk.bgWhiteBright(str)
129      }
130    }
131  
132    if (color.startsWith('#')) {
133      return type === 'foreground'
134        ? chalk.hex(color)(str)
135        : chalk.bgHex(color)(str)
136    }
137  
138    if (color.startsWith('ansi256')) {
139      const matches = ANSI_REGEX.exec(color)
140  
141      if (!matches) {
142        return str
143      }
144  
145      const value = Number(matches[1])
146  
147      return type === 'foreground'
148        ? chalk.ansi256(value)(str)
149        : chalk.bgAnsi256(value)(str)
150    }
151  
152    if (color.startsWith('rgb')) {
153      const matches = RGB_REGEX.exec(color)
154  
155      if (!matches) {
156        return str
157      }
158  
159      const firstValue = Number(matches[1])
160      const secondValue = Number(matches[2])
161      const thirdValue = Number(matches[3])
162  
163      return type === 'foreground'
164        ? chalk.rgb(firstValue, secondValue, thirdValue)(str)
165        : chalk.bgRgb(firstValue, secondValue, thirdValue)(str)
166    }
167  
168    return str
169  }
170  
171  /**
172   * Apply TextStyles to a string using chalk.
173   * This is the inverse of parsing ANSI codes - we generate them from structured styles.
174   * Theme resolution happens at component layer, not here.
175   */
176  export function applyTextStyles(text: string, styles: TextStyles): string {
177    let result = text
178  
179    // Apply styles in reverse order of desired nesting.
180    // chalk wraps text so later calls become outer wrappers.
181    // Desired order (outermost to innermost):
182    //   background > foreground > text modifiers
183    // So we apply: text modifiers first, then foreground, then background last.
184  
185    if (styles.inverse) {
186      result = chalk.inverse(result)
187    }
188  
189    if (styles.strikethrough) {
190      result = chalk.strikethrough(result)
191    }
192  
193    if (styles.underline) {
194      result = chalk.underline(result)
195    }
196  
197    if (styles.italic) {
198      result = chalk.italic(result)
199    }
200  
201    if (styles.bold) {
202      result = chalk.bold(result)
203    }
204  
205    if (styles.dim) {
206      result = chalk.dim(result)
207    }
208  
209    if (styles.color) {
210      // Color is now always a raw color value (theme resolution happens at component layer)
211      result = colorize(result, styles.color, 'foreground')
212    }
213  
214    if (styles.backgroundColor) {
215      // backgroundColor is now always a raw color value
216      result = colorize(result, styles.backgroundColor, 'background')
217    }
218  
219    return result
220  }
221  
222  /**
223   * Apply a raw color value to text.
224   * Theme resolution should happen at component layer, not here.
225   */
226  export function applyColor(text: string, color: Color | undefined): string {
227    if (!color) {
228      return text
229    }
230    return colorize(text, color, 'foreground')
231  }