Lesson 41

테마와 시각 스타일링 시스템

Claude Code가 ThemeName 문자열을 어떻게 색상이 입혀진 터미널 출력으로 바꾸는지 살펴봅니다. 여섯 팔레트 타입 시스템, chalk 레벨 클램핑, /color 명령어까지 이어지는 흐름입니다.

§1 큰 그림

Claude Code의 터미널 스타일링은 chalk를 얇게 감싼 래퍼가 아닙니다. 색상 지원을 잘못 보고하는 VS Code 내장 터미널, truecolor 배경을 조용히 버리는 tmux, 24비트 SGR 시퀀스를 제대로 처리하지 못하는 Apple Terminal까지, 적어도 세 가지 까다로운 환경을 버텨야 하도록 의도적으로 층을 나눈 시스템입니다. 각 레이어는 하나의 일만 맡고, 서로 깔끔하게 조합됩니다.

레이어 1, utils/theme.ts

시맨틱 색상 팔레트

이름이 붙은 6개의 테마가 있고, 각각은 약 70개의 시맨틱 토큰을 원시 색상값(RGB, hex, ANSI)에 매핑합니다. 모든 색상 결정의 단일 진실 공급원입니다.

레이어 2, ink/colorize.ts

Chalk 정규화

모듈 로드 시점에 터미널 환경을 감지하고, chalk의 색상 레벨을 올리거나 제한한 뒤, 색상 문자열을 알맞은 chalk 메서드로 보냅니다.

레이어 3, ink/styles.ts

레이아웃 + 스타일 타입

Ink의 Box/Text 컴포넌트가 받는 StylesTextStyles TypeScript 타입을 정의합니다. 터미널 레이아웃을 위한 CSS 비슷한 API입니다.

레이어 4, design-system/color.ts

테마 인지 컬러라이저

테마 키("claude" 같은 값)나 원시 색상 중 하나를 받아들이는 커링 헬퍼입니다. 호출 시점에 테마 키를 해석하고, 그다음 colorize()에 위임합니다.

명령어, /theme, /color

사용자 제어

/theme는 대화형 선택기를 엽니다. /color는 서브 에이전트 세션의 프롬프트 바 색상을 설정하며, swarm 팀원에게는 금지됩니다.

AgentColorManager

서브 에이전트 식별성

멀티 에이전트 세션에서 시각적 구분을 위해, 에이전트 타입 문자열을 8개의 테마 색상 슬롯(_FOR_SUBAGENTS_ONLY) 중 하나에 매핑합니다.

§2 테마 타입 시스템

핵심 데이터 구조는 utils/theme.ts에 있습니다. Theme 타입은 이름 붙은 문자열 슬롯 약 70개를 평평하게 나열한 레코드입니다. 각 슬롯에는 원시 색상값이 들어갑니다. 중첩도 없고, 토큰 안에 토큰도 없습니다. 이런 평평한 구조는 의도된 선택입니다. 어떤 컴포넌트든 어디서든 O(1)로 색상을 조회할 수 있고, 무한 해석 루프에 빠질 위험도 없습니다.

export type Theme = {
  // 브랜드 정체성
  claude: string          // rgb(215,119,87), "Claude 오렌지"
  claudeShimmer: string  // shimmer 애니메이션용 더 밝은 버전

  // UI 표면 역할
  promptBorder: string
  userMessageBackground: string
  selectionBg: string    // Alt-screen 텍스트 선택 강조

  // 시맨틱 역할
  success: string
  error: string
  warning: string

  // Diff 색상(작업당 4개 변형)
  diffAdded: string
  diffAddedDimmed: string
  diffAddedWord: string

  // 에이전트 색상, 범용 사용을 막기 위한 네이밍
  red_FOR_SUBAGENTS_ONLY: string
  blue_FOR_SUBAGENTS_ONLY: string
  // ... 6개 더

  // ultrathink 키워드 강조용 무지개 색상
  rainbow_red: string
  rainbow_red_shimmer: string
  // ... 무지개 슬롯 12개 더
}

이 네이밍 규칙은 많은 것을 말해 줍니다. _FOR_SUBAGENTS_ONLY 접미사는 린트 단계의 가드레일처럼 작동해, 오용을 눈으로 grep 하듯 쉽게 찾을 수 있습니다. Shimmer 접미사는 그 색상이 정적 텍스트용이 아니라 펄스 애니메이션에서 더 밝은 단계로만 존재함을 뜻합니다. _FOR_SYSTEM_SPINNER 접미사는 Claude 자체 스피너에 쓰이는 파란색을, 사용자에게 보이는 권한 프롬프트와 분리합니다.

여섯 가지 테마

ThemeName 유니온에 매핑되는 구체적인 테마 객체는 정확히 여섯 개입니다.

export const THEME_NAMES = [
  'dark',
  'light',
  'light-daltonized',
  'dark-daltonized',
  'light-ansi',
  'dark-ansi',
] as const

export const THEME_SETTINGS = ['auto', ...THEME_NAMES] as const
// ThemeSetting은 config에 저장되고, ThemeName은 런타임에 해석된다

auto 설정은 config 저장소에서만 유효합니다. 런타임에 시스템의 다크/라이트 모드를 따라 dark 또는 light로 해석되고, 어떤 컴포넌트가 색상 토큰을 만지기 전에 구체적인 ThemeName으로 치환됩니다.

설계 메모

ANSI 테마(light-ansi, dark-ansi)는 ansi:redBright 같은 16개 표준 ANSI 색상 이름만 사용합니다. 이는 256색이나 truecolor를 지원하지 않는 터미널에서 중요합니다. 모든 색상이 사용자의 터미널 팔레트 커스터마이징을 그대로 존중하게 되기 때문입니다. 반대로 RGB 테마는 사용자 정의 터미널 팔레트의 영향을 피하려고 명시적인 rgb(r,g,b) 문자열을 사용합니다.

§3 Dark, Light, Daltonized에서 실제로 바뀌는 것

세 가지 계열(dark, light, daltonized)은 밝기만 다른 것이 아닙니다. daltonized 변형은 녹색과 빨간색의 구분을 체계적으로 파란색과 빨간색의 구분으로 바꿉니다. 가장 흔한 색각 이상인 deuteranopia가 녹색 채널에 영향을 주기 때문입니다. 아래는 세 가지 dark 변형에서 몇몇 핵심 토큰이 어떻게 바뀌는지 보여 줍니다.

토큰 dark dark-daltonized dark-ansi
claude rgb(215,119,87) rgb(255,153,51) ansi:redBright
success rgb(78,186,101) rgb(51,153,255), 파란색! ansi:greenBright
diffAdded rgb(34,92,43) rgb(0,68,102), 짙은 파란색! ansi:green
error rgb(255,107,128) rgb(255,102,102) ansi:redBright
selectionBg rgb(38,79,120) rgb(38,79,120) ansi:blue
autoAccept rgb(175,135,255) rgb(175,135,255) ansi:magentaBright

dark-daltonizedsuccess는 녹색이 아니라 밝은 파란색이라는 점에 주목해 보세요. deuteranopia가 있는 사람은 녹색을 보고 "좋음"을 안정적으로 판단할 수 없기 때문에, daltonized 테마는 완전히 다른 채널의 색인 파란색으로 대체합니다. diffAdded 토큰이 짙은 녹색에서 짙은 파란색으로 바뀌는 이유도 같습니다.

주의

daltonized 테마는 일반 dark 테마와 selectionBg, autoAccept 값을 완전히 똑같이 공유합니다. 녹색과 빨간색 구분에 의존하는 토큰만 바뀝니다. 즉, 어떤 색상이 "접근성상 중요"한지 이해하려고 두 테마 객체를 단순 diff 해서는 안 됩니다. 어떤 토큰이 의미 구분용이고 어떤 토큰이 순수 장식용인지까지 함께 따져야 합니다.

§4 colorize.ts, 터미널 환경 문제

여기서부터 엔지니어링이 흥미로워집니다. 이 파일은 서로 다른 두 터미널 환경 버그를 설명하는 긴 블록 주석 두 개로 시작하고, 어떤 색상도 렌더링되기 전에 모듈 로드 시점에서 둘 다 바로 수정합니다.

문제 1, VS Code는 색상 지원을 잘못 보고한다

function boostChalkLevelForXtermJs(): boolean {
  // xterm.js는 2017년부터 truecolor를 지원했지만, code-server/Coder
  // 컨테이너는 종종 COLORTERM=truecolor를 설정하지 않는다. chalk의 supports-color는
  // TERM_PROGRAM=vscode를 인식하지 못한다(iTerm.app/Apple_Terminal만 안다).
  // 그래서 -256color 정규식 경로로 떨어져 level 2가 된다.
  // level 2에서는 chalk.rgb()가 가장 가까운 6×6×6 큐브 색상으로 강등된다.
  // rgb(215,119,87)(Claude 오렌지) → idx 174 rgb(215,135,135), 바랜 연어색.
  if (process.env.TERM_PROGRAM === 'vscode' && chalk.level === 2) {
    chalk.level = 3
    return true
  }
  return false
}

export const CHALK_BOOSTED_FOR_XTERMJS = boostChalkLevelForXtermJs()

이 주석은 자세히 읽을 가치가 있습니다. Claude의 브랜드 오렌지 rgb(215,119,87)는 256색 모드에서 큐브 양자화가 엉뚱한 방향으로 반올림되기 때문에 바랜 연어색 rgb(215,135,135)으로 변합니다. 이 코드는 VS Code에서 브랜드 색이 망가지는 것을 받아들이지 않고, TERM_PROGRAM=vscode를 감지하면 chalk를 수동으로 level 3(truecolor)로 올립니다.

문제 2, tmux는 truecolor 배경을 버린다

function clampChalkLevelForTmux(): boolean {
  // tmux는 truecolor SGR (\e[48;2;r;g;bm)를 셀 버퍼에 올바르게 파싱하지만,
  // 바깥 터미널이 Tc/RGB 기능을 광고할 때만 클라이언트 쪽 emitter가 truecolor를
  // 외부 터미널로 다시 내보낸다. 기본 tmux 설정은 이것을 켜지 않는다.
  // 이 설정이 없으면 배경은 그냥 버려지고, bg=default가 되어
  // 다크 프로필에서는 검은색으로 보인다.
  // level 2로 제한하면 chalk가 256색(\e[48;5;Nm)을 내보내고,
  // 이 값은 tmux가 문제없이 통과시킨다. grey93(255)는 시각적으로 거의 같다.
  if (process.env.CLAUDE_CODE_TMUX_TRUECOLOR) return false
  if (process.env.TMUX && chalk.level > 2) {
    chalk.level = 2
    return true
  }
  return false
}

export const CHALK_CLAMPED_FOR_TMUX = clampChalkLevelForTmux()

이 두 호출의 순서는 중요합니다. boostChalkLevelForXtermJs가 먼저 실행됩니다. 누군가 tmux 안의 VS Code 터미널 안에서 Claude Code를 실행하면, 먼저 부스트가 일어나고 이어서 클램프가 다시 2로 낮춥니다. tmux에서는 부스트보다 클램프가 우선합니다. tmux의 패스스루 한계는 tmux 설정 자체를 바꾸지 않으면 우회할 수 없는 강한 제약이기 때문입니다. 탈출구는 CLAUDE_CODE_TMUX_TRUECOLOR=1이며, 이는 tmux 설정에서 terminal-overrides ,*:Tc를 제대로 잡아 둔 사용자가 클램프를 건너뛰게 해 줍니다.

구현 세부사항

두 export(CHALK_BOOSTED_FOR_XTERMJSCHALK_CLAMPED_FOR_TMUX)는 디버깅을 위해 export 상태로 남겨져 있습니다. 주석에 "unused이면 tree-shaken 된다"고 적혀 있는데, 이는 엔지니어가 진단 코드에서 import { CHALK_CLAMPED_FOR_TMUX } from './colorize'를 불러와 클램프가 실제로 발동했는지 확인할 수 있게 하면서도, 아무도 import하지 않는 프로덕션 빌드에는 런타임 비용을 추가하지 않기 위함입니다.

colorize() 디스패치 테이블

chalk 레벨이 올바르게 맞춰지고 나면, 실제 색상 디스패치는 단순한 파서입니다.

export const colorize = (
  str: string,
  color: string | undefined,
  type: ColorType,  // 'foreground' | 'background'
): string => {
  if (color.startsWith('ansi:')) {
    // chalk.red / chalk.bgRed 등으로 보낸다
    return type === 'foreground' ? chalk.red(str) : chalk.bgRed(str)
  }
  if (color.startsWith('#')) {
    return type === 'foreground'
      ? chalk.hex(color)(str)
      : chalk.bgHex(color)(str)
  }
  if (color.startsWith('ansi256')) {
    // ansi256(N)을 파싱해 chalk.ansi256(N)으로 보낸다
  }
  if (color.startsWith('rgb')) {
    // rgb(r,g,b)를 파싱해 chalk.rgb(r,g,b)로 보낸다
  }
}

문자열 접두사 기반 디스패치라는 점 덕분에 색상 포맷은 자기 설명적입니다. 테마 토큰을 해석한 모든 컴포넌트는 colorize에게 그 색상이 어떤 종류인지 바로 알려 주는 문자열을 받습니다. 별도의 타입 태그가 필요 없습니다.

§5 styles.ts, TypeScript 타입으로 표현한 터미널 레이아웃

ink/styles.tsStyles 타입은 터미널 렌더링용 Claude Code식 CSS 속성 객체라고 볼 수 있습니다. 레이아웃(Yoga 기반 flexbox), 크기, 테두리, overflow, 텍스트 래핑, 색상까지 모두 readonly TypeScript 속성으로 다룹니다.

export type TextStyles = {
  readonly color?: Color            // 테마 키가 아닌 원시 색상값
  readonly backgroundColor?: Color
  readonly dim?: boolean
  readonly bold?: boolean
  readonly italic?: boolean
  readonly underline?: boolean
  readonly strikethrough?: boolean
  readonly inverse?: boolean
}

// Color는 지원되는 모든 포맷의 판별 유니온이다:
export type Color = RGBColor | HexColor | Ansi256Color | AnsiColor
// 여기서 RGBColor = `rgb(${number},${number},${number})`
// 그리고 AnsiColor = 'ansi:black' | 'ansi:red' | ... (ANSI 이름 16개)

여기서 핵심적인 아키텍처 결정은 이것입니다. TextStyles.color는 언제나 원시 Color 값이지, 테마 키가 아닙니다. 소스 주석도 분명하게 말합니다. "색상은 원시 값이며, 테마 해석은 컴포넌트 레이어에서 일어난다." 따라서 styles.tscolorize.ts는 테마를 전혀 알지 못합니다. 이 둘은 순수한 메커니즘 계층입니다. 테마 토큰과 원시 색상을 잇는 다리는 컴포넌트 레이어의 design-system/color.ts뿐입니다.

Styles → Yoga 매핑

styles.ts의 default export는 Styles 객체를 LayoutNode(Yoga 레이아웃 엔진)에 적용하는 함수입니다. Ink에서 <Box flexDirection="row" padding={2}>를 작성할 때 실제로 호출되는 것이 이것입니다.

const styles = (
  node: LayoutNode,
  style: Styles = {},
  resolvedStyle?: Styles,  // diff 적용을 위한 현재 전체 스타일
): void => {
  applyPositionStyles(node, style)
  applyOverflowStyles(node, style)
  applyMarginStyles(node, style)
  applyPaddingStyles(node, style)
  applyFlexStyles(node, style)
  applyDimensionStyles(node, style)
  applyDisplayStyles(node, style)
  applyBorderStyles(node, style, resolvedStyle)
  applyGapStyles(node, style)
}

resolvedStyle 파라미터는 특히 applyBorderStyles를 위해 존재합니다. 스타일 업데이트가 diff 형태로 적용될 때, 바뀐 속성만 들어가므로 borderStyle은 diff에 있어도 borderTop은 없을 수 있습니다. 이전 값이 바뀌지 않았기 때문입니다. resolved style은 이전의 전체 값을 함께 들고 와서, diff가 부분적이어도 함수가 네 방향 테두리를 모두 올바르게 설정할 수 있게 합니다.

눈에 띄는 속성 하나가 noSelect입니다. 풀스크린 모드에서 박스의 셀이 텍스트 선택에서 제외될지를 제어합니다. 'from-left-edge' 변형은 모든 행에서 0열부터 박스의 오른쪽 끝까지 제외 범위를 확장합니다. diff 패널 위를 클릭한 채 드래그할 때 줄 번호 접두사나 diff 기호가 실수로 클립보드에 복사되지 않게 하려는 설계입니다.

§6 design-system/color.ts, 테마와 원시 색상을 잇는 다리

이 파일은 작지만 아키텍처의 접착제 역할을 합니다. 테마 키 문자열이 원시 색상값으로 해석되는 곳은 오직 여기뿐입니다.

export function color(
  c: keyof Theme | Color | undefined,
  theme: ThemeName,
  type: ColorType = 'foreground',
): (text: string) => string {
  return text => {
    if (!c) return text

    // 원시 색상값은 테마 조회를 완전히 건너뛴다
    if (
      c.startsWith('rgb(') || c.startsWith('#') ||
      c.startsWith('ansi256(') || c.startsWith('ansi:')
    ) {
      return colorize(text, c, type)
    }

    // 테마 키 → 원시 색상 → chalk 출력
    return colorize(text, getTheme(theme)[c as keyof Theme], type)
  }
}

반환값은 문자열이 아니라 커링된 함수입니다. 그래서 컴포넌트는 컬러라이저를 한 번만 만들어 두고, 예를 들어 렌더 함수 상단에서, 여러 텍스트 문자열에 재사용할 수 있습니다. 문자열마다 테마를 반복 조회하지 않아도 됩니다. 또한 원시 색상 접두사를 검사하기 때문에 "claude" 같은 테마 키를 넘겨도 되고, "rgb(215,119,87)" 같은 원시 색상을 넘겨도 됩니다. 둘 다 자연스럽게 동작합니다.

§7 /theme와 /color 명령어

/theme 명령어

/theme 명령어는 Pane 안에 완전한 대화형 ThemePicker 컴포넌트를 렌더링합니다. 이 Pane은 color="permission"으로 감싸져 있는데, 권한 요청에 쓰이는 파랑/보라색 계열이라 선택기가 일반 출력과 시각적으로 구분됩니다.

// commands/theme/theme.tsx
export const call: LocalJSXCommandCall = async (onDone, _context) => {
  return <ThemePickerCommand onDone={onDone} />
}

function ThemePickerCommand({ onDone }: Props) {
  const [, setTheme] = useTheme()

  return (
    <Pane color="permission">
      <ThemePicker
        onThemeSelect={setting => {
          setTheme(setting)
          onDone(`테마가 ${setting}(으)로 설정되었습니다`)
        }}
        onCancel={() => onDone('테마 선택기를 닫았습니다', { display: 'system' })}
        skipExitHandling={true}
      />
    </Pane>
  )
}

ThemePicker 컴포넌트 자체는(components/ThemePicker.tsx) 라이브 프리뷰를 제공합니다. 방향키로 테마를 넘겨 보면서 확정하기 전에 UI가 다시 렌더링되는 모습을 볼 수 있습니다. 이는 저장된 설정과 구분되는 임시 테마 상태를 잡는 usePreviewTheme() 훅으로 동작합니다. Escape를 누르면 취소하고 이전 테마를 복원하며, Enter를 누르면 저장합니다.

/color 명령어

/color 명령어는 서브 에이전트 세션 전용입니다. 현재 세션의 프롬프트 바 색상을 설정해서 여러 Claude Code 에이전트가 동시에 실행될 때 시각적으로 구분해 줍니다.

// commands/color/color.ts
export async function call(onDone, context, args) {
  // 팀원은 자기 색상을 직접 설정할 수 없고, 팀 리더만 할당할 수 있다
  if (isTeammate()) {
    onDone('색상을 설정할 수 없습니다. 이 세션은 swarm 팀원 세션입니다...', { display: 'system' })
    return null
  }

  const colorArg = args.trim().toLowerCase()

  // 'default', 'reset', 'none', 'gray', 'grey'는 모두 회색으로 리셋한다
  if (RESET_ALIASES.includes(colorArg)) {
    await saveAgentColor(sessionId, 'default', fullPath)
    // 즉시 반영되도록 AppState를 업데이트한다
    context.setAppState(prev => ({
      ...prev,
      standaloneAgentContext: { ...prev.standaloneAgentContext, color: undefined }
    }))
    return null
  }

  // 유효한 색상: AGENT_COLORS = ['red','blue','green','yellow','purple','orange','pink','cyan']
  await saveAgentColor(sessionId, colorArg, fullPath)
  context.setAppState(prev => ({
    ...prev,
    standaloneAgentContext: { ...prev.standaloneAgentContext, color: colorArg }
  }))
}
핵심 세부사항

이 색상은 세션 재시작 후에도 유지되도록 트랜스크립트 파일(saveAgentColor(sessionId, colorArg, fullPath))에 저장되고, 동시에 setAppState를 통해 즉시 적용됩니다. "default" 센티널은 빈 문자열이 아닙니다. sessionStorage.ts의 truthiness 가드가 리셋도 계속 저장하도록, 리터럴 문자열 "default"를 사용합니다. 빈 문자열은 falsy라서 기록되지 않을 수 있습니다.

§8 AgentColorManager, 서브 에이전트의 시각적 식별성

멀티 에이전트(swarm) 세션에서 Claude Code는 에이전트들을 서로 시각적으로 구분해야 합니다. agentColorManager.ts는 에이전트 타입 문자열을 테마 색상 슬롯에 매핑하는 일을 맡습니다.

export const AGENT_COLORS: readonly AgentColorName[] = [
  'red', 'blue', 'green', 'yellow',
  'purple', 'orange', 'pink', 'cyan'
]

export const AGENT_COLOR_TO_THEME_COLOR = {
  red:    'red_FOR_SUBAGENTS_ONLY',
  blue:   'blue_FOR_SUBAGENTS_ONLY',
  // ... 사람이 읽는 이름을 Theme 키로 매핑한다
} as const satisfies Record<AgentColorName, keyof Theme>

satisfies 제약이 핵심입니다. 상수 타입을 Record<AgentColorName, keyof Theme>으로 넓혀 버리지 않으면서도, 맵의 모든 항목이 Theme 타입의 유효한 키를 가리키는지 컴파일 타임에 보장합니다. 누군가 새 에이전트 색상을 추가하면서 Theme 타입에 해당 _FOR_SUBAGENTS_ONLY 슬롯을 넣는 것을 잊으면 빌드가 실패합니다.

general-purpose 에이전트 타입은 getAgentColor에서 undefined를 반환합니다. 의도적으로 색을 주지 않는 것입니다. 범용 세션은 시각적으로 따로 구분하지 않기 때문입니다. 코드 리뷰, 테스팅 같은 특화된 에이전트 타입만 색상 풀에서 값을 할당받습니다.

§9 전체 색상 해석 데이터 흐름

flowchart TD A[사용자가 /theme로 테마를 선택] --> B[useTheme setTheme 호출] B --> C[ThemeSetting이 settings.json에 저장됨] C --> D[런타임에 ThemeName 해석
auto → dark 또는 light] D --> E[getTheme이 ThemeName으로 Theme 객체 반환] E --> F[컴포넌트가 color 함수를 호출
design-system/color.ts] F --> G{c가 원시 색상인가?
rgb/hash/ansi로 시작하는가} G -- 예 --> H[colorize를 직접 호출] G -- 아니오 --> I[getTheme으로 테마 키의
원시 값을 조회] I --> H[colorize에 원시 색상 문자열과 type 전달] H --> J{chalk.level} J -- 3 truecolor --> K[chalk.rgb / chalk.hex] J -- 2 256-color --> L[chalk.ansi256] J -- 0/1 무색상 --> M[일반 문자열] K --> N[터미널로 ANSI 이스케이프 시퀀스 전송] L --> N M --> N subgraph 모듈 로드 시점 O[boostChalkLevelForXtermJs
TERM_PROGRAM=vscode 그리고 level=2 → level=3] P[clampChalkLevelForTmux
TMUX 그리고 level gt 2 → level=2] O --> P end

§10 심층 분석

왜 TypeScript 색상 객체 대신 RGB 문자열인가?

색상 시스템은 모든 색상 값을 구조화된 객체가 아닌 문자열("rgb(215,119,87)", "#d77757", "ansi:red")로 저장합니다. 코드 스멜처럼 보일 수 있지만 실제 장점이 있습니다.

  • Theme 타입은 단지 { [key: string]: string }입니다. 설정 저장용 JSON으로 직렬화하기 쉽습니다.
  • Color TypeScript 타입은 템플릿 리터럴 타입(`rgb(${number},${number},${number})`)을 사용해서 런타임 파싱 없이 컴파일 타임 검증을 얻습니다.
  • 문자열 접두사(rgb(, #, ansi:)는 자기 설명적인 판별자입니다. 별도 타입 태그 없이도 분기할 수 있습니다.
  • Chalk는 이미 문자열(hex, rgb, ansi 색상 이름)을 기대합니다. 객체로 감싸면 이점 없이 한 번 더 벗기는 단계만 늘어납니다.

유일한 비용은 색상 적용마다 colorize()에서 정규식 파싱이 일어난다는 점입니다. 하지만 색상 적용은 이미 터미널 I/O를 수행하는 렌더 루프에서 발생하므로, 정규식 비용은 I/O에 비해 무시할 수 있습니다.

shimmer 애니메이션 패턴

많은 테마 토큰은 쌍으로 존재합니다. claude / claudeShimmer, inactive / inactiveShimmer 같은 식입니다. shimmer 변형은 항상 조금 더 밝거나, 다크 모드에서는, 조금 더 채도가 높습니다. 그래서 컴포넌트가 두 값 사이를 오갈 때 거친 깜빡임이 아니라 "호흡"이나 "펄스" 애니메이션처럼 읽히도록 조정됩니다.

예를 들어 claude = rgb(215,119,87)(Claude 오렌지), claudeShimmer = rgb(235,159,127)입니다. 각 채널이 20만큼 더 밝아서 시각적으로 구분되기에는 충분하지만, 전혀 다른 색처럼 보일 정도는 아닙니다.

ultrathink 키워드의 레인보우 색상도 같은 패턴을 따릅니다. 7개 색조 × 2개 가중치(기본 + shimmer) = 14개 테마 슬롯입니다. Claude Code가 메시지에서 "ultrathink"라는 단어를 감지하면 레인보우 색상을 순환합니다. shimmer 변형은 여기에 강도의 교차 변화를 더해 시각적으로 더 역동적인 효과를 만듭니다.

Apple Terminal과 256색 폴백

colorize.ts가 VS Code 부스트와 tmux 클램핑을 처리하는 한편, utils/theme.ts 자체에는 별도의 Apple Terminal 처리도 있습니다.

// Apple Terminal용으로 256색 레벨의 chalk 인스턴스를 만든다
// Apple Terminal은 24비트 색상 이스케이프 시퀀스를 잘 처리하지 못한다
const chalkForChart =
  env.terminal === 'Apple_Terminal'
    ? new Chalk({ level: 2 }) // 256색
    : chalk

export function themeColorToAnsi(themeColor: string): string {
  const rgbMatch = themeColor.match(/rgb\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)/)
  if (rgbMatch) {
    const colored = chalkForChart.rgb(r, g, b)('X')
    return colored.slice(0, colored.indexOf('X'))
  }
}

이 함수는 특별히 asciichart 렌더링, 즉 UI의 비용/토큰 사용량 그래프에 쓰입니다. chalk의 전역 레벨 감지에 의존하는 대신, Apple Terminal을 위해 레벨 2에 고정된 별도의 Chalk 인스턴스를 만듭니다. "이스케이프 시퀀스 추출" 트릭, 단일 문자 'X'를 렌더링한 뒤 그 앞부분만 잘라 내는 방식은, chalk가 공개 API로 노출하지 않는 시작 SGR 시퀀스를 얻어 오는 영리한 방법입니다.

왜 /color 명령어가 swarm 팀원에게 금지되는가

swarm(멀티 에이전트) 세션에는 "팀 리더" Claude Code 인스턴스와 하나 이상의 "팀원" 인스턴스가 있습니다. 팀 리더는 AgentColorManager를 통해 팀원에게 색상을 할당합니다. 그래서 UI는 멀티 에이전트 트랜스크립트에서 어느 에이전트가 말하고 있는지 색상으로 구분할 수 있습니다.

팀원이 직접 /color를 호출할 수 있다면, 팀 리더의 색상 할당과 충돌하거나 이를 덮어쓸 수 있어서 swarm 표시의 시각적 일관성이 깨질 수 있습니다. color.ts의 가드는 다음과 같습니다.

if (isTeammate()) {
  onDone('색상을 설정할 수 없습니다. 이 세션은 swarm 팀원 세션입니다.
팀원 색상은 팀 리더가 할당합니다.', { display: 'system' })
  return null
}

isTeammate() 체크는 bootstrap/state.ts에서 읽습니다. 세션은 시작할 때 자신이 팀원으로 실행됐는지 이미 알고 있습니다. 이 값은 어떤 사용자 상호작용보다 먼저 설정되므로, 에이전트가 첫 행동으로 /color를 시도해도 이 가드는 안정적으로 동작합니다.

핵심 요점

  • 테마 시스템은 세 가지 뚜렷한 단계로 구성됩니다. 팔레트 정의(theme.ts의 이름 붙은 6개 Theme 객체), 환경 정규화(모듈 로드 시 colorize.ts에서 일어나는 chalk 레벨 클램핑/부스팅), 렌더링(컴포넌트 렌더 시점의 테마 키 → 원시 색상 → chalk 출력)입니다.
  • colorize.ts의 두 가지 chalk 레벨 조정, VS Code 부스트와 tmux 클램핑은 import 시점에 한 번만 실행되고 이후 모든 색상 출력에 영향을 줍니다. 순서도 의도적입니다. 부스트가 먼저 실행돼야 vscode 안의 tmux가 올바르게 다시 클램핑됩니다.
  • 테마 해석은 색상 렌더링과 분리됩니다. styles.tscolorize.ts는 테마를 전혀 알지 못합니다. 오직 design-system/color.ts만이 테마 키와 원시 값을 연결합니다.
  • daltonized 테마는 모든 시맨틱 success/diff-added 토큰에서 녹색 대신 파란색을 사용합니다. 개별 색상 미세 조정이 아니라 체계적인 접근성 결정입니다.
  • Theme 타입의 _FOR_SUBAGENTS_ONLY, Shimmer 네이밍 규칙은 의도적인 가드레일입니다. 런타임 체크가 아니라 네이밍 규율로 강제됩니다.
  • /color 명령어는 리셋이 세션 재시작 뒤에도 유지되도록, 빈 문자열이 아니라 센티널 문자열 "default"를 써서 트랜스크립트 파일에 저장합니다.

이해도 확인

Q1. boostChalkLevelForXtermJs()가 해결하는 문제는 무엇인가요?
Q2. tmux 클램핑이 VS Code 부스트 이후에 실행되는 이유는 무엇인가요?
Q3. daltonized 테마에서 success 색상이 다른 녹색 색조가 아닌 파란색으로 변경된 이유는 무엇인가요?
Q4. design-system/color.ts가 색상이 적용된 문자열이 아닌 커링된 함수를 반환하는 이유는 무엇인가요?
Q5. /color reset이 호출될 때, 빈 문자열 대신 "default"를 트랜스크립트에 저장하는 이유는 무엇인가요?