diff --git a/claude/settings.json b/claude/settings.json index f0566d5..527c2ca 100644 --- a/claude/settings.json +++ b/claude/settings.json @@ -10,7 +10,18 @@ "hooks": {}, "statusLine": { "type": "command", - "command": "sh \"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/statusline-command.sh\"" + "command": "bash \"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/statusline-command.sh\"" + }, + "enabledPlugins": { + "typesafe@typesafe-ai": true + }, + "extraKnownMarketplaces": { + "typesafe-ai": { + "source": { + "source": "github", + "repo": "typesafe-ai/skills" + } + } }, "effortLevel": "high", "tui": "default", diff --git a/claude/statusline-command.sh b/claude/statusline-command.sh old mode 100644 new mode 100755 index e0b9b05..4d1f3ee --- a/claude/statusline-command.sh +++ b/claude/statusline-command.sh @@ -1,52 +1,158 @@ -#!/bin/sh +#!/usr/bin/env bash +# NOTE:(@janezicmatej) layout adapted from github.com/nejcr/grind statusline.js: +# model + context, repo, 5h/7d limits, session id, optional dev-links row + input=$(cat) -model=$(echo "$input" | jq -r '.model.display_name // empty') -total_in=$(echo "$input" | jq -r '.context_window.total_input_tokens // 0') -total_out=$(echo "$input" | jq -r '.context_window.total_output_tokens // 0') -ctx=$(echo "$input" | jq -r '.context_window.used_percentage // empty') -five_hr=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty') -five_hr_reset=$(echo "$input" | jq -r '.rate_limits.five_hour.resets_at // empty') -seven_day=$(echo "$input" | jq -r '.rate_limits.seven_day.used_percentage // empty') -seven_day_reset=$(echo "$input" | jq -r '.rate_limits.seven_day.resets_at // empty') +# one jq call emits every field as a quoted shell assignment +eval "$(jq -r ' + def s: tostring | @sh; + "model=\(.model.display_name // "" | s)", + "used=\(.context_window.total_input_tokens // 0 | s)", + "total=\(.context_window.context_window_size // 0 | s)", + "ctx_pct=\(.context_window.used_percentage // "" | s)", + "cwd=\(.workspace.current_dir // "" | s)", + "project_dir=\(.workspace.project_dir // "" | s)", + "session_id=\(.session_id // "" | s)" +' <<<"$input")" -# format seconds until reset as human-readable -fmt_remaining() { - now=$(date +%s) - diff=$(( $1 - now )) - [ "$diff" -le 0 ] && return - h=$(( diff / 3600 )) - m=$(( (diff % 3600) / 60 )) - if [ "$h" -gt 0 ]; then - printf '%dh%dm' "$h" "$m" - else - printf '%dm' "$m" - fi +# claude only sends rate_limits after the first api response, so a fresh session +# would show none until its first prompt; every refresh that has them writes the +# last known limits next to the config, and a fresh session reads those back dimmed +cfg_dir=${CLAUDE_CONFIG_DIR:-$HOME/.claude} +limits_cache=$cfg_dir/statusline-limits.json +read_limits() { + jq -r ' + def s: tostring | @sh; + "five_pct=\(.five_hour.used_percentage // "" | s)", + "five_reset=\(.five_hour.resets_at // "" | s)", + "seven_pct=\(.seven_day.used_percentage // "" | s)", + "seven_reset=\(.seven_day.resets_at // "" | s)" + ' 2>/dev/null +} +current=$(jq -c '.rate_limits // {} | with_entries(select(.value.used_percentage != null))' <<<"$input") +stale="" +if [[ $current != "{}" ]]; then + eval "$(read_limits <<<"$current")" + # merged, not replaced: a turn carrying one window must not wipe the other; + # written then renamed so a concurrent session never reads it half-written + merged=$(jq -c --argjson cur "$current" '. * $cur' "$limits_cache" 2>/dev/null) || merged=$current + tmp=$limits_cache.$$.tmp + if { printf '%s\n' "$merged" > "$tmp"; } 2>/dev/null; then mv -f "$tmp" "$limits_cache" 2>/dev/null || rm -f "$tmp"; fi +elif [[ -f $limits_cache ]]; then + stale=1 + eval "$(read_limits < "$limits_cache")" +fi + +R=$'\e[0m' C=$'\e[36m' G=$'\e[32m' Y=$'\e[33m' RE=$'\e[31m' D=$'\e[2m' + +# "Claude 3.5 Sonnet" -> "Sonnet 3.5", "Claude Fable 5.1" -> "Fable 5.1" +model=$(sed -E -e 's/^Claude +([0-9.]+) +([A-Za-z0-9_]+)$/\2 \1/; t' -e 's/^Claude +//' <<<"${model:-unknown}") + +# 61234 -> 61.2k, half-up rounding to match Math.round +fmt() { awk -v n="$1" 'BEGIN { if (n >= 1000) printf "%.1fk", n / 1000; else printf "%d", n }'; } +round() { awk -v x="$1" 'BEGIN { printf "%d", int(x + 0.5) }'; } + +# same scale for context and limits: green under 50, yellow under 80, red above +color_for() { + if (( $1 >= 80 )); then printf %s "$RE"; elif (( $1 >= 50 )); then printf %s "$Y"; else printf %s "$G"; fi } -parts="" - -[ -n "$model" ] && parts="$model" - -if [ -n "$total_in" ] && [ "$total_in" != "0" ]; then - total=$((total_in + total_out)) - parts="${parts:+$parts | }tokens:${total}" +# repo: origin url from the host onwards (host/group/repo), else the folder name +repo="" +if url=$(git -C "$cwd" --no-optional-locks remote get-url origin 2>/dev/null); then + repo=$(sed -E \ + -e 's#^[^@/]+@([^:/]+):(.+)$#\1/\2#; t done' \ + -e 's#^[a-z+]+://([^@/]+@)?([^/:]+)(:[0-9]+)?/(.+)$#\2/\4#I; t done' \ + -e 's/.*//' -e ':done' -e 's#/+$##; s/\.git$//' <<<"$url") +fi +if [[ -z $repo ]]; then + top=$(git -C "$cwd" --no-optional-locks rev-parse --show-toplevel 2>/dev/null) + repo=$(basename "${top:-${project_dir:-$cwd}}") fi -if [ -n "$ctx" ]; then - parts="${parts:+$parts | }ctx:$(printf '%.0f' "$ctx")%" -fi +# limits, same shape as the context group: "5h 42% (15:36)" and "7d 81% (5d)" +# the 5h window resets within hours so the clock answers "when", the 7d one is +# far enough out that days are all that matters; a rolled-over window shows a dash +now=$(date +%s) +rl_part() { + local label=$1 pct=$2 reset=${3%.*} mode=$4 p s left + [[ -n $pct ]] || return 0 + s=$(( ${reset:-0} - now )) + if [[ -z $reset ]] || (( s <= 0 )); then + printf '%s%s —%s' "$D" "$label" "$R" + return 0 + fi + p=$(round "$pct") + case $mode in + clock) left=$(date -d "@$reset" +%H:%M) ;; + days) if (( s < 86400 )); then left="$(( (s + 3599) / 3600 ))h"; else left="$(( (s + 43200) / 86400 ))d"; fi ;; + esac + # last known values from the cache render fully dim to say they may be behind + local col; col=$(color_for "$p"); [[ -n $stale ]] && col=$D + printf '%s%s%s %s%s%%%s %s(%s)%s' "$D" "$label" "$R" "$col" "$p" "$R" "$D" "$left" "$R" +} -if [ -n "$five_hr" ]; then - five_hr_ttl="" - [ -n "$five_hr_reset" ] && five_hr_ttl=$(fmt_remaining "$five_hr_reset") - parts="${parts:+$parts | }5h:$(printf '%.0f' "$five_hr")%${five_hr_ttl:+($five_hr_ttl)}" -fi +groups=() -if [ -n "$seven_day" ]; then - seven_day_ttl="" - [ -n "$seven_day_reset" ] && seven_day_ttl=$(fmt_remaining "$seven_day_reset") - parts="${parts:+$parts | }7d:$(printf '%.0f' "$seven_day")%${seven_day_ttl:+($seven_day_ttl)}" +session="${C}${model}${R} ${G}$(fmt "$used")/$(fmt "$total")${R}" +if [[ -n $ctx_pct ]]; then + p=$(round "$ctx_pct") + session+=" ${D}(${R}$(color_for "$p")${p}%${R}${D})${R}" fi +groups+=("$session") -[ -n "$parts" ] && printf '%s' "$parts" +[[ -n $repo ]] && groups+=("${C}${repo}${R}") + +# limits are only sent once the session has talked to the api +part=$(rl_part 5h "$five_pct" "$five_reset" clock); [[ -n $part ]] && groups+=("$part") +part=$(rl_part 7d "$seven_pct" "$seven_reset" days); [[ -n $part ]] && groups+=("$part") + +# full session id so it can be pasted into claude --resume, dim and last +[[ -n $session_id ]] && groups+=("${D}${session_id}${R}") + +sep=" ${D}│${R} " +out="" +for g in "${groups[@]}"; do out+="${out:+$sep}$g"; done +printf '%s\n' "$out" + +# pinned dev links, opt-in per project: .claude/dev-links.json adds a second row +# a value is either "url" or {url, note}, note being a throwaway local credential +link_file="" +for d in "$project_dir" "$cwd"; do + [[ -n $d && -f $d/.claude/dev-links.json ]] && { link_file=$d/.claude/dev-links.json; break; } +done +[[ -n $link_file ]] || exit 0 + +pins=$(jq -r ' + to_entries[] + | { label: .key, + url: (if (.value | type) == "string" then .value + elif (.value | type) == "object" then .value.url else null end), + note: (if (.value | type) == "object" and (.value.note | type) == "string" then .value.note else "" end) } + | select((.url | type) == "string" and (.url | test("^https?://"))) + | [.label, .url, .note] | @tsv +' "$link_file" 2>/dev/null) || exit 0 +[[ -n $pins ]] || exit 0 + +# bare urls rather than osc 8: claude code resolves plain http:// on ctrl+click +# itself; COLUMNS is the only readable width since stdout is captured +cols=${COLUMNS:-120} +[[ $cols =~ ^[0-9]+$ ]] || cols=120 +gap=3 +row="" width=0 +while IFS=$'\t' read -r label url note; do + len=$(( ${#label} + 1 + ${#url} + (${#note} > 0 ? ${#note} + 3 : 0) )) + cost=$(( width ? len + gap : len )) + if (( width && width + cost > cols - 4 )); then + printf '%s\n' "$row" + row="" width=$len + else + width=$(( width + cost )) + fi + cell="${C}${label}${R} ${D}${url}${R}" + [[ -n $note ]] && cell+=" ${Y}${note}${R}" + row+="${row:+${D} · ${R}}$cell" +done <<<"$pins" +[[ -n $row ]] && printf '%s\n' "$row" +exit 0