← back to Terminal Status

verification/install/before/.claude/skills/color/scripts/color.sh

247 lines

#!/usr/bin/env bash
# /color — give this Claude Code session a fresh, visually-distinct color and
# apply it to the terminal tab so parallel Claude sessions are tellable apart.
#
# Mechanism: Claude Code's Bash tool runs detached with NO controlling tty
# (verified: `tty` => "not a tty", /dev/tty => "device not configured"), so
# OSC escapes on stdout/stderr never reach the terminal. The working path is
# to locate the `claude` process's own pty by walking process ancestry and
# write the OSC sequence straight to /dev/ttysNNN. OSC color/title codes are
# non-destructive (no cursor moves, no cell writes) so this is glitch-safe.
#
# Colors: golden-angle (137.508 deg) hue rotation => every session maximally
# far from the last on the hue wheel, never repeating. iTerm2 gets a tab tint;
# every terminal (incl. macOS Terminal.app) gets an emoji + name tab title.
set -u

# HARD RAIL (2026-09-08): a subagent has no tab of its own — its ancestry resolves to the
# PARENT session's pty, so painting would corrupt Steve's live pane (the input-field bug). No-op.
if [ -n "${CLAUDE_CODE_CHILD_SESSION:-}" ]; then
  echo "/color → subagent context — refusing to paint (would write to the parent session's live pane). no-op."
  exit 0
fi

# Streamline / lite mode — skip the AUTOMATIC SessionStart color assignment
# fleet-wide (the hook calls with no args). Explicit invocations like
# `/color --all` pass an arg and still run. rm the file to restore auto mode.
[ -f "$HOME/.claude/hooks-lite" ] && [ $# -eq 0 ] && exit 0

STATE_DIR="${HOME}/.claude"
STATE="${STATE_DIR}/color-session-index"
mkdir -p "$STATE_DIR"

idx=0
[[ -f "$STATE" ]] && idx="$(cat "$STATE" 2>/dev/null)"
[[ "$idx" =~ ^[0-9]+$ ]] || idx=0
session=$(( idx + 1 ))
printf '%s\n' "$session" > "$STATE"

rgb_for_idx() { # idx -> "R G B HUE" via golden-angle HSL(90%,58%)
  awk -v idx="$1" '
    function absf(v){ return v<0 ? -v : v }
    BEGIN{
      hue = idx*137.508; hue = hue - int(hue/360)*360
      s=0.90; l=0.58
      c=(1-absf(2*l-1))*s
      hp=hue/60.0
      m2=hp - int(hp/2)*2
      x=c*(1-absf(m2-1))
      if(hp<1){r=c;g=x;b=0} else if(hp<2){r=x;g=c;b=0}
      else if(hp<3){r=0;g=c;b=x} else if(hp<4){r=0;g=x;b=c}
      else if(hp<5){r=x;g=0;b=c} else {r=c;g=0;b=x}
      m=l-c/2
      printf "%d %d %d %d", int((r+m)*255+0.5),int((g+m)*255+0.5),int((b+m)*255+0.5),int(hue)
    }'
}

name_for_hue() { # hue -> "Name Emoji"
  local h="$1"
  if   (( h < 20  )); then echo "Red 🟥"
  elif (( h < 45  )); then echo "Orange 🟧"
  elif (( h < 70  )); then echo "Amber 🟨"
  elif (( h < 150 )); then echo "Green 🟩"
  elif (( h < 215 )); then echo "Teal 🟦"
  elif (( h < 255 )); then echo "Blue 🟦"
  elif (( h < 290 )); then echo "Indigo 🟪"
  elif (( h < 330 )); then echo "Violet 🟪"
  else                     echo "Magenta 🟥"
  fi
}

# --all: paint EVERY open tab running a live `claude` session, each with its
# own distinct golden-angle color. Claude processes report comm=node, so match
# on the full command line and take each unique controlling tty.
if [[ "${1:-}" == "--all" ]]; then
  ttys="$(ps -axo tty=,command= 2>/dev/null \
    | awk '$1 ~ /^ttys/ && $0 ~ /claude/ && $0 !~ /grep/ {print $1}' \
    | sort -u)"
  if [[ -z "$ttys" ]]; then
    echo "/color --all → no live claude ttys found"
    exit 0
  fi
  n=0
  for tt in $ttys; do
    tp="/dev/$tt"
    [[ -w "$tp" ]] || { echo "  $tt → not writable, skipped"; continue; }
    aidx=$(( session + n ))
    read -r R G B HUE < <(rgb_for_idx "$aidx")
    read -r NAME EMOJI < <(name_for_hue "$HUE")
    # NOTE: do NOT send a leading '6;1;bg;*;default' reset — on iTerm2 it swallows
    # the color set, leaving the tab default (bug found 2026-09-02, same as pinkdot).
    # Set RGB directly and write BOTH title channels (]1 icon/tab, ]2 window).
    {
      printf '\033]6;1;bg;red;brightness;%d\007'   "$R"
      printf '\033]6;1;bg;green;brightness;%d\007' "$G"
      printf '\033]6;1;bg;blue;brightness;%d\007'  "$B"
      printf '\033]1;%s\007' "$EMOJI"
      printf '\033]2;%s\007' "$EMOJI"
    } > "$tp" 2>/dev/null && echo "  $tt → ${NAME} ${EMOJI}  rgb(${R},${G},${B})"
    n=$(( n + 1 ))
  done
  printf '%s\n' "$(( session + n ))" > "$STATE"
  echo "/color --all → painted ${n} tab(s), counter now $(( session + n ))"
  exit 0
fi

# golden-angle hue -> HSL(90% sat, 58% light) -> RGB, pure awk (no python/flock dep)
read -r R G B HUE < <(awk -v idx="$idx" '
  function absf(v){ return v<0 ? -v : v }
  BEGIN{
    hue = idx*137.508; hue = hue - int(hue/360)*360
    s=0.90; l=0.58
    c=(1-absf(2*l-1))*s
    hp=hue/60.0
    m2=hp - int(hp/2)*2
    x=c*(1-absf(m2-1))
    if(hp<1){r=c;g=x;b=0} else if(hp<2){r=x;g=c;b=0}
    else if(hp<3){r=0;g=c;b=x} else if(hp<4){r=0;g=x;b=c}
    else if(hp<5){r=x;g=0;b=c} else {r=c;g=0;b=x}
    m=l-c/2
    printf "%d %d %d %d", int((r+m)*255+0.5),int((g+m)*255+0.5),int((b+m)*255+0.5),int(hue)
  }')

# hue -> coarse name + nearest emoji square
if   (( HUE < 20  )); then NAME=Red;     EMOJI=🟥
elif (( HUE < 45  )); then NAME=Orange;  EMOJI=🟧
elif (( HUE < 70  )); then NAME=Amber;   EMOJI=🟨
elif (( HUE < 150 )); then NAME=Green;   EMOJI=🟩
elif (( HUE < 215 )); then NAME=Teal;    EMOJI=🟦
elif (( HUE < 255 )); then NAME=Blue;    EMOJI=🟦
elif (( HUE < 290 )); then NAME=Indigo;  EMOJI=🟪
elif (( HUE < 330 )); then NAME=Violet;  EMOJI=🟪
else                       NAME=Magenta; EMOJI=🟥
fi

# locate the claude process's controlling pty by walking the ancestry
tty_path=""; p=$$
for _ in 1 2 3 4 5 6 7 8 9 10; do
  line="$(ps -o ppid=,tty= -p "$p" 2>/dev/null)" || break
  set -- $line
  pp="${1:-}"; tt="${2:-}"
  [[ -z "$pp" ]] && break
  if [[ "$tt" == ttys* ]]; then tty_path="/dev/$tt"; break; fi
  p="$pp"
done

# Recall the session TOPIC (the user's first prompt) so re-coloring mid-session
# does NOT clobber the topic with the color name — the color is the tint, the
# text stays the topic. color-title.sh already wrote the topic onto the tmux
# window name (with automatic-rename off), so read it straight back from there.
# Ignore our own color-name placeholder ("<emoji> Indigo #359").
topic=""
if [[ -n "${TMUX_PANE:-}" ]] && command -v tmux >/dev/null 2>&1; then
  cur="$(tmux display-message -p -t "$TMUX_PANE" '#{window_name}' 2>/dev/null)"
  if [[ -n "$cur" && ! "$cur" =~ (Red|Orange|Amber|Green|Teal|Blue|Indigo|Violet|Magenta)\ \#[0-9]+$ ]]; then
    topic="$cur"
  fi
fi

# Tab/window TEXT: prefer the topic; until the first prompt sets one, show just
# the emoji so the tab is visibly initialised without shouting the color name.
if [[ -n "$topic" ]]; then label="$topic"; else label="${EMOJI}"; fi
hex=$(printf '#%02X%02X%02X' "$R" "$G" "$B")

# Detect tmux. Inside tmux, OSC escapes are swallowed and never reach
# iTerm2's tab bar — so the user sees no color "at top". Surface the color
# on tmux's own window/pane instead.
inside_tmux=0
[[ -n "${TMUX:-}" ]] && inside_tmux=1
if (( ! inside_tmux )) && command -v tmux >/dev/null 2>&1; then
  p2=$$
  for _ in 1 2 3 4 5 6 7 8 9 10; do
    line2="$(ps -o ppid=,comm= -p "$p2" 2>/dev/null)" || break
    set -- $line2
    pp2="${1:-}"; cm2="${2:-}"
    [[ -z "$pp2" ]] && break
    case "$cm2" in *tmux*) inside_tmux=1; break;; esac
    p2="$pp2"
  done
fi

# tmux swallows the DCS-wrapped iTerm2 tab-color OSC unless passthrough is on.
# Default is OFF, so ensure it on every run — this is what makes the tab header
# actually tint (otherwise the escape is dropped and the tab shows no color).
if (( inside_tmux )) && command -v tmux >/dev/null 2>&1; then
  tmux set -g allow-passthrough on 2>/dev/null || true
fi

applied=0

if [[ -n "$tty_path" && -w "$tty_path" ]]; then
  if (( inside_tmux )); then
    # Inside tmux, raw OSC is consumed by tmux. With allow-passthrough on,
    # DCS-wrap (ESC P tmux ; <inner-with-doubled-ESC> ESC \) lets the
    # iTerm2 tab-tint + title OSC reach the outer terminal.
    {
      printf '\033Ptmux;\033\033]6;1;bg;red;brightness;%d\007\033\\'   "$R"
      printf '\033Ptmux;\033\033]6;1;bg;green;brightness;%d\007\033\\' "$G"
      printf '\033Ptmux;\033\033]6;1;bg;blue;brightness;%d\007\033\\'  "$B"
      printf '\033Ptmux;\033\033]1;%s\007\033\\' "$label"
      printf '\033Ptmux;\033\033]2;%s\007\033\\' "$label"
    } > "$tty_path" 2>/dev/null
  else
    {
      printf '\033]6;1;bg;red;brightness;%d\007'   "$R"
      printf '\033]6;1;bg;green;brightness;%d\007' "$G"
      printf '\033]6;1;bg;blue;brightness;%d\007'  "$B"
      printf '\033]1;%s\007' "$label"
      printf '\033]2;%s\007' "$label"
    } > "$tty_path" 2>/dev/null
  fi
  applied=1
fi

# tmux indicator path — recolors the tmux window name + current pane border
# so the color surfaces in tmux's status bar regardless of outer terminal.
# Window TEXT = topic if captured, else the color label until first prompt.
if [[ -n "$topic" ]]; then winname="$topic"; else winname="${EMOJI} ${NAME} #${session}"; fi
if (( inside_tmux )) && command -v tmux >/dev/null 2>&1; then
  # scope to THIS pane via $TMUX_PANE — without it, plain `rename-window`
  # targets the active window of the session, which is almost never the
  # window the new claude session is actually running in.
  tgt="${TMUX_PANE:-}"
  if [[ -n "$tgt" ]]; then
    # keep the topic sticky against tmux automatic-rename
    [[ -n "$topic" ]] && tmux set-window-option -q -t "$tgt" automatic-rename off 2>/dev/null || true
    tmux rename-window -t "$tgt" "$winname" 2>/dev/null || true
    tmux set-window-option -q -t "$tgt" window-status-current-style "bg=${hex},fg=#000000,bold" 2>/dev/null || true
    tmux set-option -p -q -t "$tgt" pane-active-border-style "fg=${hex},bold" 2>/dev/null || true
    tmux set-option -p -q -t "$tgt" pane-border-style "fg=${hex}" 2>/dev/null || true
  else
    [[ -n "$topic" ]] && tmux set-window-option -q automatic-rename off 2>/dev/null || true
    tmux rename-window "$winname" 2>/dev/null || true
    tmux set-window-option -q window-status-current-style "bg=${hex},fg=#000000,bold" 2>/dev/null || true
    tmux set-option -p -q pane-active-border-style "fg=${hex},bold" 2>/dev/null || true
    tmux set-option -p -q pane-border-style "fg=${hex}" 2>/dev/null || true
  fi
  applied=1
fi

if (( applied )); then
  suffix=""
  (( inside_tmux )) && suffix="  · tmux window+pane recolored"
  echo "/color → ${NAME} ${EMOJI}  rgb(${R},${G},${B})  ${hex}  · session #${session}${suffix}"
else
  echo "/color → ${NAME} ${EMOJI} (session #${session}) — terminal pty not found, color not applied"
fi