fa2b2e16bc
El statusline solo se re-ejecutaba al cambiar los mensajes, asi que el estado de reposo que el Stop hook escribe ~2s despues (en background) no se reflejaba hasta que el usuario interactuaba. Se anade refreshInterval=2 para que el harness re- ejecute el statusline cada 2s tambien estando idle, mostrando el valor final sin necesidad de escribir y sin bloquear el turno. Para que el refresco continuo no sea caro en repos grandes, el bloque git se cachea por directorio con TTL de 6s (el estado git no cambia estando parado); los ticks idle reusan el cache (~0.1s vs ~0.33s recomputando). El goal file (la fase) se lee siempre fresco. Se revierte el intento previo de Stop sincrono (bloqueaba ~2s el control). Nota: Claude Code no soporta acotar el refresco a una ventana (p.ej. 10s y parar); refreshInterval es continuo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
342 lines
12 KiB
Bash
Executable File
342 lines
12 KiB
Bash
Executable File
#!/bin/bash
|
|
# Status Line para Claude Code - Optimizado para Vibecoding
|
|
# Muestra: modelo, contexto, git, costos, rate limits y más
|
|
|
|
# Leer JSON desde stdin
|
|
INPUT=$(cat)
|
|
|
|
# Colores ANSI
|
|
RESET='\033[0m'
|
|
BOLD='\033[1m'
|
|
DIM='\033[2m'
|
|
CYAN='\033[36m'
|
|
GREEN='\033[32m'
|
|
YELLOW='\033[33m'
|
|
RED='\033[31m'
|
|
BLUE='\033[34m'
|
|
MAGENTA='\033[35m'
|
|
WHITE='\033[37m'
|
|
GRAY='\033[90m'
|
|
|
|
# Extraer datos del JSON
|
|
MODEL=$(echo "$INPUT" | jq -r '.model.display_name // "Unknown"')
|
|
CONTEXT_PCT=$(echo "$INPUT" | jq -r '.context_window.used_percentage // 0' | xargs printf "%.0f")
|
|
CONTEXT_TOTAL=$(echo "$INPUT" | jq -r '.context_window.context_window_size // 200000')
|
|
CURRENT_DIR=$(echo "$INPUT" | jq -r '.workspace.current_dir // "~"' | sed "s|$HOME|~|")
|
|
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""')
|
|
|
|
# Purga: borra goal files de sesiones muertas (no tocados en >7 dias). El worker
|
|
# refresca el mtime en cada respuesta, asi que las sesiones vivas nunca caen.
|
|
find "$HOME/.claude/goals" -maxdepth 1 -name '*.json' -mtime +7 -delete 2>/dev/null
|
|
|
|
# Tokens de entrada y salida (current_usage puede ser null antes del primer API call)
|
|
INPUT_TOKENS=$(echo "$INPUT" | jq -r '.context_window.current_usage.input_tokens // 0')
|
|
OUTPUT_TOKENS=$(echo "$INPUT" | jq -r '.context_window.current_usage.output_tokens // 0')
|
|
CACHE_READ=$(echo "$INPUT" | jq -r '.context_window.current_usage.cache_read_input_tokens // 0')
|
|
CACHE_CREATION=$(echo "$INPUT" | jq -r '.context_window.current_usage.cache_creation_input_tokens // 0')
|
|
TOTAL_INPUT=$(echo "$INPUT" | jq -r '.context_window.total_input_tokens // 0')
|
|
TOTAL_OUTPUT=$(echo "$INPUT" | jq -r '.context_window.total_output_tokens // 0')
|
|
|
|
# Calcular contexto usado (suma de todos los tokens de entrada)
|
|
CONTEXT_USED=$((INPUT_TOKENS + CACHE_READ + CACHE_CREATION))
|
|
|
|
# Calcular porcentaje manualmente si el del JSON es 0
|
|
if [ "$CONTEXT_PCT" -eq 0 ] && [ "$CONTEXT_USED" -gt 0 ]; then
|
|
CONTEXT_PCT=$(echo "scale=0; $CONTEXT_USED * 100 / $CONTEXT_TOTAL" | bc)
|
|
fi
|
|
|
|
# Costos
|
|
TOTAL_COST=$(echo "$INPUT" | jq -r '.cost.total_cost_usd // 0' | xargs printf "%.3f")
|
|
SESSION_DURATION=$(echo "$INPUT" | jq -r '.cost.total_duration_ms // 0')
|
|
LINES_ADDED=$(echo "$INPUT" | jq -r '.cost.total_lines_added // 0')
|
|
LINES_REMOVED=$(echo "$INPUT" | jq -r '.cost.total_lines_removed // 0')
|
|
|
|
# Rate Limits
|
|
RATE_5H=$(echo "$INPUT" | jq -r '.rate_limits.five_hour.used_percentage // 0' | xargs printf "%.0f")
|
|
RATE_7D=$(echo "$INPUT" | jq -r '.rate_limits.seven_day.used_percentage // 0' | xargs printf "%.0f")
|
|
RESET_5H_EPOCH=$(echo "$INPUT" | jq -r '.rate_limits.five_hour.resets_at // 0')
|
|
RESET_7D_EPOCH=$(echo "$INPUT" | jq -r '.rate_limits.seven_day.resets_at // 0')
|
|
|
|
# Formatear resets (vacio si epoch=0)
|
|
RESET_5H=""
|
|
RESET_7D=""
|
|
[ "$RESET_5H_EPOCH" -gt 0 ] && RESET_5H=$(date -d "@$RESET_5H_EPOCH" +"%H:%M" 2>/dev/null)
|
|
[ "$RESET_7D_EPOCH" -gt 0 ] && RESET_7D=$(date -d "@$RESET_7D_EPOCH" +"%a %H:%M" 2>/dev/null)
|
|
|
|
# Git info (si estamos en un repo). Con cache de TTL corto: como el statusline
|
|
# se re-ejecuta cada pocos segundos (refreshInterval), recomputar git en cada
|
|
# tick es caro en repos grandes y el estado git no cambia estando idle. Se cachea
|
|
# por directorio y se recomputa solo si el cache tiene mas de 6s.
|
|
GIT_BRANCH=""
|
|
GIT_STATUS=""
|
|
GIT_CACHE="/tmp/fn_sl_git_$(printf '%s' "$CURRENT_DIR" | cksum | cut -d' ' -f1).cache"
|
|
GIT_CACHE_AGE=999
|
|
[ -f "$GIT_CACHE" ] && GIT_CACHE_AGE=$(( $(date +%s) - $(stat -c %Y "$GIT_CACHE" 2>/dev/null || echo 0) ))
|
|
if [ "$GIT_CACHE_AGE" -lt 6 ]; then
|
|
. "$GIT_CACHE"
|
|
elif git rev-parse --git-dir > /dev/null 2>&1; then
|
|
GIT_BRANCH=$(git branch --show-current 2>/dev/null || echo "detached")
|
|
|
|
# Obtener archivos staged, modified, untracked
|
|
STAGED=$(git diff --cached --numstat 2>/dev/null | wc -l)
|
|
MODIFIED=$(git diff --numstat 2>/dev/null | wc -l)
|
|
UNTRACKED=$(git ls-files --others --exclude-standard 2>/dev/null | wc -l)
|
|
|
|
# Commits ahead/behind
|
|
UPSTREAM=$(git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null)
|
|
if [ -n "$UPSTREAM" ]; then
|
|
AHEAD=$(git rev-list --count @{u}..HEAD 2>/dev/null || echo "0")
|
|
BEHIND=$(git rev-list --count HEAD..@{u} 2>/dev/null || echo "0")
|
|
else
|
|
AHEAD="0"
|
|
BEHIND="0"
|
|
fi
|
|
|
|
# Construir status compacto
|
|
GIT_STATUS=""
|
|
[ "$STAGED" -gt 0 ] && GIT_STATUS="${GIT_STATUS}+${STAGED} "
|
|
[ "$MODIFIED" -gt 0 ] && GIT_STATUS="${GIT_STATUS}~${MODIFIED} "
|
|
[ "$UNTRACKED" -gt 0 ] && GIT_STATUS="${GIT_STATUS}?${UNTRACKED} "
|
|
[ "$AHEAD" -gt 0 ] && GIT_STATUS="${GIT_STATUS}↑${AHEAD} "
|
|
[ "$BEHIND" -gt 0 ] && GIT_STATUS="${GIT_STATUS}↓${BEHIND} "
|
|
|
|
# Trim trailing space
|
|
GIT_STATUS=$(echo "$GIT_STATUS" | sed 's/ $//')
|
|
|
|
# Guardar en cache (quoting seguro para re-source).
|
|
printf 'GIT_BRANCH=%q\nGIT_STATUS=%q\n' "$GIT_BRANCH" "$GIT_STATUS" > "$GIT_CACHE" 2>/dev/null
|
|
fi
|
|
|
|
# Color estable por sesión (hash del session_id → paleta ANSI 256 legible).
|
|
# Cada terminal mantiene su color toda su vida; distinto entre terminales.
|
|
goal_color() {
|
|
local sid="$1"
|
|
local palette=(39 45 51 75 81 114 120 156 183 210 215 222 213 159 228)
|
|
local h
|
|
h=$(printf '%s' "$sid" | cksum | cut -d' ' -f1)
|
|
local idx=$(( h % ${#palette[@]} ))
|
|
printf '\033[1;38;5;%dm' "${palette[$idx]}"
|
|
}
|
|
|
|
# Fase de trabajo → icono | color ANSI | etiqueta visible.
|
|
# El slug (clave) lo escribe el agente del Stop hook; aqui se mapea a su estilo.
|
|
phase_style() {
|
|
case "$1" in
|
|
investigando) printf '🔎|36|investigando' ;;
|
|
planificando) printf '📋|34|planificando' ;;
|
|
haciendo) printf '🔨|33|haciendo' ;;
|
|
testeando) printf '🧪|35|testeando' ;;
|
|
puliendo) printf '✨|95|puliendo detalles' ;;
|
|
pendiente_revision) printf '👀|93|pendiente de revisión' ;;
|
|
bloqueado) printf '⛔|31|bloqueado' ;;
|
|
en_pausa) printf '⏸️|90|en pausa' ;;
|
|
hecho) printf '✅|32|hecho' ;;
|
|
iterando) printf '🔁|94|iterando' ;;
|
|
*) printf "•|90|$1" ;;
|
|
esac
|
|
}
|
|
|
|
# Función para crear barra de progreso
|
|
progress_bar() {
|
|
local pct=$1
|
|
local width=10
|
|
local filled=$(( pct * width / 100 ))
|
|
local empty=$(( width - filled ))
|
|
|
|
# Color según porcentaje
|
|
local color=$GREEN
|
|
if [ "$pct" -ge 80 ]; then
|
|
color=$RED
|
|
elif [ "$pct" -ge 60 ]; then
|
|
color=$YELLOW
|
|
fi
|
|
|
|
printf "${color}"
|
|
for ((i=0; i<filled; i++)); do printf "█"; done
|
|
printf "${GRAY}"
|
|
for ((i=0; i<empty; i++)); do printf "░"; done
|
|
printf "${RESET}"
|
|
}
|
|
|
|
# Función para formatear duración
|
|
format_duration() {
|
|
local ms=$1
|
|
local seconds=$((ms / 1000))
|
|
local minutes=$((seconds / 60))
|
|
local hours=$((minutes / 60))
|
|
|
|
if [ "$hours" -gt 0 ]; then
|
|
printf "%dh%dm" $hours $((minutes % 60))
|
|
elif [ "$minutes" -gt 0 ]; then
|
|
printf "%dm%ds" $minutes $((seconds % 60))
|
|
else
|
|
printf "%ds" $seconds
|
|
fi
|
|
}
|
|
|
|
# Función para formatear tokens (k para miles, M para millones)
|
|
format_tokens() {
|
|
local tokens=$1
|
|
if [ "$tokens" -ge 1000000 ]; then
|
|
printf "%.1fM" $(echo "scale=1; $tokens / 1000000" | bc)
|
|
elif [ "$tokens" -ge 1000 ]; then
|
|
printf "%.0fk" $(echo "scale=0; $tokens / 1000" | bc)
|
|
else
|
|
printf "%d" $tokens
|
|
fi
|
|
}
|
|
|
|
# Hora actual
|
|
CURRENT_TIME=$(date +"%H:%M")
|
|
|
|
# ===== CONSTRUIR STATUS LINE (2 LÍNEAS) =====
|
|
|
|
# ===== LÍNEA 1: Información Principal =====
|
|
LINE1=""
|
|
|
|
# 1. Modelo
|
|
LINE1="${LINE1}${BOLD}${CYAN}${MODEL}${RESET}"
|
|
|
|
# 2. Contexto con barra y usado/total
|
|
CONTEXT_USED_FMT=$(format_tokens $CONTEXT_USED)
|
|
CONTEXT_TOTAL_FMT=$(format_tokens $CONTEXT_TOTAL)
|
|
LINE1="${LINE1} ${GRAY}│${RESET} ${BOLD}CTX:${RESET} $(progress_bar $CONTEXT_PCT) ${YELLOW}${CONTEXT_PCT}%${RESET} ${DIM}(${CONTEXT_USED_FMT}/${CONTEXT_TOTAL_FMT})${RESET}"
|
|
|
|
# 3. Tokens entrada/salida (formateados)
|
|
INPUT_FMT=$(format_tokens $INPUT_TOKENS)
|
|
OUTPUT_FMT=$(format_tokens $OUTPUT_TOKENS)
|
|
LINE1="${LINE1} ${GRAY}│${RESET} ${CYAN}IN:${RESET}${INPUT_FMT} ${MAGENTA}OUT:${RESET}${OUTPUT_FMT}"
|
|
if [ "$CACHE_READ" -gt 0 ]; then
|
|
CACHE_FMT=$(format_tokens $CACHE_READ)
|
|
LINE1="${LINE1} ${DIM}(cache:${CACHE_FMT})${RESET}"
|
|
fi
|
|
|
|
# 4. Git (si existe)
|
|
if [ -n "$GIT_BRANCH" ]; then
|
|
LINE1="${LINE1} ${GRAY}│${RESET} ${BOLD}${MAGENTA}⎇ ${GIT_BRANCH}${RESET}"
|
|
if [ -n "$GIT_STATUS" ]; then
|
|
LINE1="${LINE1} ${DIM}[${GIT_STATUS}]${RESET}"
|
|
fi
|
|
fi
|
|
|
|
# 5. Hora actual
|
|
LINE1="${LINE1} ${GRAY}│${RESET} ${WHITE}${CURRENT_TIME}${RESET}"
|
|
|
|
# ===== LÍNEA 2: Detalles =====
|
|
LINE2=""
|
|
|
|
# 1. Costos
|
|
if (( $(echo "$TOTAL_COST > 0" | bc -l) )); then
|
|
LINE2="${LINE2}${BOLD}${GREEN}\$${TOTAL_COST}${RESET}"
|
|
else
|
|
LINE2="${LINE2}${DIM}\$0.000${RESET}"
|
|
fi
|
|
|
|
# 2. Ediciones
|
|
LINE2="${LINE2} ${GRAY}│${RESET} ${GREEN}+${LINES_ADDED}${RESET}${GRAY}/${RED}-${LINES_REMOVED}${RESET}"
|
|
|
|
# 3. Tokens totales acumulados (formateados)
|
|
TOTAL_IN_FMT=$(format_tokens $TOTAL_INPUT)
|
|
TOTAL_OUT_FMT=$(format_tokens $TOTAL_OUTPUT)
|
|
LINE2="${LINE2} ${GRAY}│${RESET} ${DIM}Total:${RESET} ${CYAN}↓${TOTAL_IN_FMT}${RESET}${GRAY}/${MAGENTA}↑${TOTAL_OUT_FMT}${RESET}"
|
|
|
|
# 4. Rate Limits (siempre mostrar)
|
|
LINE2="${LINE2} ${GRAY}│${RESET} ${BOLD}Limits:${RESET}"
|
|
|
|
# Color por burndown vs tasa esperada
|
|
# Tasa: % consumible permitido por unidad de tiempo (5h: 20%/h, 7d: 14%/dia)
|
|
# expected = tasa * unidades_restantes_hasta_reset
|
|
# available = 100 - used%
|
|
# verde: available >= expected (consumo bajo control)
|
|
# amarillo: available >= expected/2 (consumo agresivo)
|
|
# rojo: available < expected/2 (riesgo de agotar antes del reset)
|
|
NOW_EPOCH=$(date +%s)
|
|
burndown_color() {
|
|
local used_pct=$1
|
|
local secs_left=$2
|
|
local rate=$3
|
|
local secs_per_unit=$4
|
|
local available=$((100 - used_pct))
|
|
if [ "$secs_left" -le 0 ]; then
|
|
printf "%s" "$GREEN"; return
|
|
fi
|
|
local expected
|
|
expected=$(echo "scale=2; $rate * $secs_left / $secs_per_unit" | bc)
|
|
if (( $(echo "$available >= $expected" | bc -l) )); then
|
|
printf "%s" "$GREEN"
|
|
elif (( $(echo "$available >= $expected / 2" | bc -l) )); then
|
|
printf "%s" "$YELLOW"
|
|
else
|
|
printf "%s" "$RED"
|
|
fi
|
|
}
|
|
|
|
# 5h limit (tasa 20%/h)
|
|
SECS_5H=0
|
|
[ "$RESET_5H_EPOCH" -gt "$NOW_EPOCH" ] && SECS_5H=$((RESET_5H_EPOCH - NOW_EPOCH))
|
|
C5=$(burndown_color $RATE_5H $SECS_5H 20 3600)
|
|
RESET_5H_STR=""
|
|
[ -n "$RESET_5H" ] && RESET_5H_STR=" ${C5}→${RESET_5H}${RESET}"
|
|
LINE2="${LINE2} ${C5}5h:${RATE_5H}%${RESET}${RESET_5H_STR} ${GRAY}│${RESET}"
|
|
|
|
# 7d limit (tasa 14%/dia)
|
|
SECS_7D=0
|
|
[ "$RESET_7D_EPOCH" -gt "$NOW_EPOCH" ] && SECS_7D=$((RESET_7D_EPOCH - NOW_EPOCH))
|
|
C7=$(burndown_color $RATE_7D $SECS_7D 14 86400)
|
|
RESET_7D_STR=""
|
|
[ -n "$RESET_7D" ] && RESET_7D_STR=" ${C7}→${RESET_7D}${RESET}"
|
|
LINE2="${LINE2} ${C7}7d:${RATE_7D}%${RESET}${RESET_7D_STR}"
|
|
|
|
# 5. Duración sesión
|
|
if [ "$SESSION_DURATION" -gt 0 ]; then
|
|
DURATION_STR=$(format_duration $SESSION_DURATION)
|
|
LINE2="${LINE2} ${GRAY}│${RESET} ${DIM}⏱ ${DURATION_STR}${RESET}"
|
|
fi
|
|
|
|
# 6. Directorio actual
|
|
LINE2="${LINE2} ${GRAY}│${RESET} ${BLUE}${CURRENT_DIR}${RESET}"
|
|
|
|
# ===== LÍNEA 0: Objetivo (izq) + Fase (der) =====
|
|
# Solo si la sesión tiene archivo de objetivo con goal no vacío.
|
|
GOAL_FILE="$HOME/.claude/goals/${SESSION_ID}.json"
|
|
if [ -n "$SESSION_ID" ] && [ -f "$GOAL_FILE" ]; then
|
|
GOAL=$(jq -r '.goal // ""' "$GOAL_FILE" 2>/dev/null)
|
|
PHASE=$(jq -r '.phase // ""' "$GOAL_FILE" 2>/dev/null)
|
|
if [ -n "$GOAL" ]; then
|
|
GC=$(goal_color "$SESSION_ID")
|
|
LEFT_PLAIN="🎯 ${GOAL}"
|
|
LEFT="${GC}${LEFT_PLAIN}${RESET}"
|
|
|
|
LINE0="${LEFT}"
|
|
|
|
# Historial: emojis de los ultimos 7 estados PREVIOS (sin el actual, que
|
|
# se muestra completo a la derecha), atenuados y separados por │.
|
|
PREV=$(jq -r '(.history // []) | .[0:-1] | .[-7:] | .[]' "$GOAL_FILE" 2>/dev/null)
|
|
if [ -n "$PREV" ]; then
|
|
HJOIN=""
|
|
while IFS= read -r slug; do
|
|
[ -z "$slug" ] && continue
|
|
HS=$(phase_style "$slug")
|
|
HIC="${HS%%|*}"
|
|
HJOIN="${HJOIN}${HIC}"
|
|
done <<< "$PREV"
|
|
[ -n "$HJOIN" ] && LINE0="${LINE0} ${GRAY}│${RESET} ${DIM}${HJOIN}${RESET}"
|
|
fi
|
|
|
|
# Fase actual (completa, con color e icono).
|
|
if [ -n "$PHASE" ]; then
|
|
PS=$(phase_style "$PHASE")
|
|
PICON="${PS%%|*}"
|
|
REST="${PS#*|}"
|
|
PCOL="${REST%%|*}"
|
|
PLABEL="${REST#*|}"
|
|
LINE0="${LINE0} ${GRAY}│${RESET} \033[1;${PCOL}m${PICON} ${PLABEL}${RESET}"
|
|
fi
|
|
echo -e "$LINE0"
|
|
fi
|
|
fi
|
|
|
|
# Imprimir resultado (2 líneas)
|
|
echo -e "$LINE1"
|
|
echo -e "$LINE2"
|