Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

tmuxで起動しているclaudeを横断して監視する

./claude-ps で起動。

#!/usr/bin/env bash
set -euo pipefail

# claude-ps: Monitor Claude Code sessions running in tmux panes

HOME_DIR="$HOME"
WATCH_INTERVAL=1

# Colors
BOLD='\033[1m'
GREEN='\033[32m'
YELLOW='\033[33m'
DIM='\033[2m'
RESET='\033[0m'

usage() {
  echo "Usage: claude-ps [-w|--watch [INTERVAL]]"
  echo "  -w, --watch [SEC]  Watch mode (default: ${WATCH_INTERVAL}s interval)"
  exit 0
}

check_deps() {
  if ! command -v tmux &>/dev/null; then
    echo "error: tmux is not installed" >&2
    exit 1
  fi
  if ! tmux list-sessions &>/dev/null 2>&1; then
    echo "error: no tmux sessions found" >&2
    exit 1
  fi
}

parse_args() {
  watch_mode=false
  while [[ $# -gt 0 ]]; do
    case "$1" in
      -w|--watch)
        watch_mode=true
        if [[ "${2:-}" =~ ^[0-9]+$ ]]; then
          WATCH_INTERVAL="$2"
          shift
        fi
        shift
        ;;
      -h|--help)
        usage
        ;;
      *)
        echo "Unknown option: $1" >&2
        usage
        ;;
    esac
  done
}

is_claude_pane() {
  local cmd="$1" title="$2"
  [[ "$cmd" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]] || [[ "$title" == *"Claude Code"* ]]
}

# Detect status from pane_title icon
# ✳ (e29cb3) / ✻ (e29cbb) = idle, Braille spinner (e2a080-e2a3bf) = running
detect_claude_status() {
  local title="$1"
  local icon_hex
  icon_hex=$(echo -n "$title" | head -c 3 | xxd -p)

  if [[ "$icon_hex" == "e29cb3" || "$icon_hex" == "e29cbb" ]]; then
    echo "idle"
  elif [[ "$icon_hex" == e2a0* || "$icon_hex" == e2a1* || "$icon_hex" == e2a2* || "$icon_hex" == e2a3* ]]; then
    echo "running"
  else
    echo "unknown"
  fi
}

extract_task() {
  local title="$1"
  local task
  task=$(echo "$title" | sed $'s/^[\xe2\xa0\x80-\xe2\xa3\xbf\xe2\x9c\xb3\xe2\x9c\xbb\xe2\x8f\xb5\xe2\x8f\xb6\xe2\x8f\xb7\xe2\x8f\xb8] *//')
  task="${task#Claude Code}"
  task="${task# - }"
  if [[ ${#task} -gt 45 ]]; then
    task="${task:0:42}..."
  fi
  echo "$task"
}

# List Claude panes as pipe-delimited lines: dir|status|task
get_list_claude_panes() {
  while IFS='|' read -r _pane cmd dir title; do
    if ! is_claude_pane "$cmd" "$title"; then
      continue
    fi
    local status short_dir task
    status=$(detect_claude_status "$title")
    short_dir="$(basename "$dir")"
    task=$(extract_task "$title")
    echo "${status}|${task}|${short_dir}"
  done < <(tmux list-panes -a -F "#{session_name}:#{window_index}.#{pane_index}|#{pane_current_command}|#{pane_current_path}|#{pane_title}")
}

render() {
  # header
  printf "${BOLD}  %-45s %s${RESET}\n" "TASK" "DIR"
  printf "%s\n" "$(printf '%.0s─' {1..60})"

  # panes
  local found=0
  while IFS='|' read -r status task short_dir; do
    found=$((found + 1))
    local status_icon status_color
    case "$status" in
      running) status_icon=""; status_color="$GREEN" ;;
      idle)    status_icon=""; status_color="$YELLOW" ;;
      *)       status_icon="?"; status_color="$DIM" ;;
    esac
    printf "${status_color}%s${RESET}  %-45s ${DIM}%s${RESET}\n" \
      "$status_icon" "$task" "$short_dir"
  done < <(get_list_claude_panes)
  if [[ $found -eq 0 ]]; then
    echo "No Claude Code sessions found in tmux."
  fi

  # footer
  printf "\n${DIM}Updated: $(date '+%H:%M:%S')${RESET}"
}

watch_loop() {
  trap 'tput cnorm; tput clear; exit 0' INT TERM
  tput civis
  tput clear
  while true; do
    local buf
    buf=$(render)
    tput home
    printf "${DIM}claude-ps  (every ${WATCH_INTERVAL}s, Ctrl-C to stop)${RESET}\n\n"
    printf '%s' "$buf"
    tput ed
    sleep "$WATCH_INTERVAL"
  done
}

# --- Main ---

check_deps
parse_args "$@"

if $watch_mode; then
  watch_loop
else
  render
  echo
fi