← back to Terminal Status

verification/install/before/.claude/skills/allcolordots/allcolordots.sh

197 lines

#!/usr/bin/env bash
# /allcolordots — scan EVERY active terminal's color dot + label in one shot,
# and (new) present a clickable single-window ROSTER per color that JUMPS focus
# to any tab — the "gather my yellows in one place" navigator (path A).
#
# Prefer semantic LIVE iTerm2 names. If Codex overwrites the dot, recover the
# newest valid ~/.claude or ~/.codex/tab-dots record from this agent lifetime.
# "live" = a `claude`/`codex` process is running on that tty.
#
# Usage:
#   allcolordots.sh                 # grouped human table (orange > purple > yellow > green > pink)
#   allcolordots.sh --json          # JSON array: [{tty,color,label,live,pid}]  (navbar app)
#   allcolordots.sh --live          # table, but only ACTIVE (live) terminals
#   allcolordots.sh --yellow        # numbered ROSTER of just the 🟡 needs-direction tabs
#   allcolordots.sh --only <color>  # numbered roster of one color (yellow|orange|purple|green|pink)
#   allcolordots.sh jump <ttyNNN|N> # focus that tab (N = row# from the last roster)
set -u
# Emoji glob-matching (case *🟡*) needs a UTF-8 ctype; some launch contexts pass
# LC_CTYPE= empty which silently breaks the match -> every dot reads "none".
export LANG="${LANG:-en_US.UTF-8}" LC_ALL="en_US.UTF-8"
DOTS="$HOME/.claude/tab-dots"
CODEX_DOTS="$HOME/.codex/tab-dots"
ROSTER_CACHE="$DOTS/.last-roster"

color_of(){ case "$1" in *🟢*) echo green;; *🟡*) echo yellow;; *🟣*) echo purple;; *🟠*) echo orange;; *🩷*) echo pink;; *) echo none;; esac; }
emoji_of(){ case "$1" in orange) echo 🟠;; purple) echo 🟣;; yellow) echo 🟡;; green) echo 🟢;; pink) echo 🩷;; *) echo ⚪;; esac; }

# Codex rewrites its title while working. Recover its semantic state only from
# this tty's files written during the current agent lifetime; old tty numbers
# get reused. Read both painters' stores and choose by mtime, never by color.
saved_label(){
  [ -n "${2:-}" ] || return 0
  python3 - "$1" "$2" "$DOTS" "$CODEX_DOTS" <<'PY'
import datetime, pathlib, re, subprocess, sys
tty, pid, *directories = sys.argv[1:]
if not re.fullmatch(r"ttys[0-9]+", tty) or not pid.isdigit():
    sys.exit(0)
try:
    started = subprocess.check_output(
        ["ps", "-p", pid, "-o", "lstart="], text=True,
        stderr=subprocess.DEVNULL).strip()
    started = datetime.datetime.strptime(started, "%a %b %d %H:%M:%S %Y").timestamp()
except (ValueError, OSError, subprocess.CalledProcessError):
    sys.exit(0)
eligible = []
for directory in directories:
    try:
        path = pathlib.Path(directory) / (tty + ".dot")
        stamp = path.stat().st_mtime
        label = path.read_text(encoding="utf-8").strip()
    except (OSError, UnicodeError):
        continue
    if (stamp >= started and label.startswith(("🟢", "🟡", "🟣", "🟠", "🩷"))
            and not any(ord(c) < 32 or ord(c) == 127 for c in label)):
        eligible.append((stamp, label))
if eligible:
    print(max(eligible, key=lambda item: item[0])[1])
PY
}

# ---- LIVE session scan via iTerm2 (name carries the dot+label; freshest source) ----
# Emits: tty<TAB>name  (one per session). Falls back silently to .dot files if osascript fails.
live_sessions(){
  # NOTE: inside `tell application "iTerm2"`, the word `tab` resolves to iTerm2's
  # own `tab` object class — NOT the tab character — so `& tab &` emits the literal
  # string "tab". Use an explicit ASCII 9 for the field separator.
  osascript 2>/dev/null <<'OSA'
set SEP to (ASCII character 9)
tell application "iTerm2"
  set out to ""
  repeat with w in windows
    repeat with t in tabs of w
      repeat with s in sessions of t
        set out to out & (tty of s) & SEP & (name of s) & linefeed
      end repeat
    end repeat
  end repeat
  return out
end tell
OSA
}

# ---- JUMP: focus the tab whose tty matches (select + activate; NOT a reparent) ----
jump_to(){
  local arg="$1" tty=""
  # index into the last roster?
  if printf '%s' "$arg" | grep -qE '^[0-9]+$' && [ -f "$ROSTER_CACHE" ]; then
    tty="$(awk -F'\t' -v n="$arg" 'NR==n{print $1}' "$ROSTER_CACHE")"
  fi
  [ -z "$tty" ] && tty="$arg"
  case "$tty" in /dev/*) :;; ttys*) tty="/dev/$tty";; *) tty="/dev/$tty";; esac
  osascript 2>&1 <<OSA
tell application "iTerm2"
  repeat with w in windows
    repeat with t in tabs of w
      repeat with s in sessions of t
        if (tty of s) is "$tty" then
          select w
          select t
          select s
          activate
          return "jumped -> $tty"
        end if
      end repeat
    end repeat
  end repeat
  return "not found: $tty"
end tell
OSA
}

# ---- collect normalized rows: tty \t color \t live \t pid \t label ----
collect_rows(){
  local live_raw; live_raw="$(live_sessions)"
  if [ -n "$live_raw" ]; then
    # LIVE path: iTerm2 names are the label; a claude/codex proc on the tty = live
    printf '%s\n' "$live_raw" | while IFS=$'\t' read -r tty name; do
      [ -z "$tty" ] && continue
      local short="${tty#/dev/}"
      local color; color="$(color_of "$name")"
      local pid; pid="$(ps -axo pid=,tty=,command= 2>/dev/null | awk -v t="$short" '$2==t && (/claude/||/codex/) && !/awk/ {print $1; exit}')"
      local live=false; [ -n "$pid" ] && live=true
      if [ "$color" = none ] && [ "$live" = true ]; then
        local saved; saved="$(saved_label "$short" "$pid")"
        if [ -n "$saved" ]; then name="$saved"; color="$(color_of "$saved")"; fi
      fi
      printf '%s\t%s\t%s\t%s\t%s\n' "$short" "$color" "$live" "${pid:-}" "$name"
    done
  else
    # FALLBACK path: persisted .dot files
    for f in "$DOTS"/*.dot; do
      [ -f "$f" ] || continue
      local tty; tty="$(basename "$f" .dot)"
      local label; label="$(cat "$f" 2>/dev/null)"
      local color; color="$(color_of "$label")"
      local pid; pid="$(ps -axo pid=,tty=,command= 2>/dev/null | awk -v t="$tty" '$2==t && (/claude/||/codex/) && !/awk/ {print $1; exit}')"
      local live=false; [ -n "$pid" ] && live=true
      printf '%s\t%s\t%s\t%s\t%s\n' "$tty" "$color" "$live" "${pid:-}" "$label"
    done
  fi
}

# ---- roster of ONE color: numbered, jump-ready, caches order for `jump N` ----
print_roster(){
  local want="$1" rows; rows="$(collect_rows | awk -F'\t' -v c="$want" '$2==c')"
  : > "$ROSTER_CACHE"
  local e; e="$(emoji_of "$want")"
  local n; n="$(printf '%s\n' "$rows" | grep -c .)"
  echo "=== $e $want roster ($n) — jump with:  allcolordots.sh jump <#|tty> ==="
  [ "$n" = 0 ] && { echo "  (none)"; return 0; }
  local i=0
  printf '%s\n' "$rows" | while IFS=$'\t' read -r tty color live pid label; do
    i=$((i+1))
    printf '%s\t%s\t%s\n' "$tty" "$label" "$live" >> "$ROSTER_CACHE"
    local flag=""; [ "$live" = true ] && flag="  ·live"
    printf '  %2d) %-9s %s%s\n' "$i" "$tty" "$label" "$flag"
  done
}

# ---------------- dispatch ----------------
case "${1:-}" in
  jump)   shift; jump_to "${1:-}"; exit $? ;;
  --yellow) print_roster yellow; exit 0 ;;
  --only)   print_roster "${2:-yellow}"; exit 0 ;;
  --json)
    collect_rows | python3 -c '
import sys,json
out=[]
for line in sys.stdin:
    line=line.rstrip("\n")
    if not line: continue
    tty,color,live,pid,label=(line.split("\t")+["","","","",""])[:5]
    out.append({"tty":tty,"color":color,"live":live=="true","pid":pid,"label":label})
pri={"orange":0,"purple":1,"yellow":2,"green":3,"pink":4,"none":5}
out.sort(key=lambda r:(pri.get(r["color"],9), r["tty"]))
print(json.dumps(out))
'
    exit 0 ;;
esac

# default / --live : grouped human table
LIVEONLY=0; [ "${1:-}" = "--live" ] && LIVEONLY=1
rows="$(collect_rows)"
[ "$LIVEONLY" = 1 ] && rows="$(printf '%s\n' "$rows" | awk -F'\t' '$3=="true"')"
total="$(printf '%s\n' "$rows" | grep -c . )"
echo "=== active-terminal dots ($total) ==="
for c in orange purple yellow green pink none; do
  block="$(printf '%s\n' "$rows" | awk -F'\t' -v c="$c" '$2==c')"
  [ -z "$block" ] && continue
  n="$(printf '%s\n' "$block" | grep -c .)"
  echo "$(emoji_of "$c") ${c} ($n)"
  printf '%s\n' "$block" | while IFS=$'\t' read -r tty color live pid label; do
    flag=""; [ "$live" = true ] && flag="  ·live"
    printf '   %-9s %s%s\n' "$tty" "$label" "$flag"
  done
done