How I Built My Claude Code Status Line

Claude Code can show a status line at the bottom of the screen. It runs a shell command of your choice, sends it information about the session as JSON on stdin and shows whatever the command prints. Mine prints four rows. This is what it looks like, with sample data:
➜ my-project git:(main) ✗ ↑1 · Opus 5.5 ••••• high
ctx ━━━━━━╸━━━ 64% (640k) left │ 5h ━━━━━━╺━━━ 58% left ↻ 2h12m │ 7d ━━━╺━━━━━━ 32% left ↻ Sun ⚠ empty by Fri
CO₂ session 13 g · today (3) 41 g (≈ 2 hours of video streaming) · month (12) 312 g · all-time (40) 1.05 kg
Oslo 10° rain · today 10°–12° · sunrise 08:12 │ Persian: Beyhaqi Day (historian) tomorrow │ Swedish: FN-dagen (UN Day) in 2 daysIn the terminal it's in color, and several parts of it are clickable. Below I go through what each row shows and how the script works, and the setup section has every file and setting you need to get the exact same status line.
How it started
It started in April 2026, when I ran Claude Code's built-in /statusline command. That command writes a status line script for you from a description of what you want. An early version was a single line with the model, how much of the context was used, the effort level and the two usage limits with their reset times.
From there I kept asking Claude Code for changes: a CO₂ estimate, the remaining context, usage limits that stay in sync between terminals, everyday comparisons for the CO₂ numbers, a few rounds of bug hunting and, in October, a pass over the whole UX and a row with Persian and Swedish special days.
What each row shows
Several parts are OSC 8 links: Ctrl+click them (Cmd+click on macOS) in a terminal that supports them.
Row 1: where you are
➜ my-project git:(main) ✗ ↑1 · Opus 5.5 ••••• high- Folder: the name of the current folder, linked to the folder itself with a
file://link. - Branch:
git:(main). When Claude Code knows the repository (it reads it from theoriginremote), the branch links tohttps://<host>/<owner>/<repo>/tree/<branch>, which is the branch's page on GitHub. A branch without an upstream links to the repository's front page instead. In a detached HEAD, the short commit hash takes the branch's place. ✗: there are uncommitted changes in tracked files. Untracked files don't count.↑1and↓1: commits that haven't been pushed or pulled yet.- Model: the model's display name.
- Effort: one dot per effort level (low, medium, high, xhigh and max), lit up to the current one, so
highhas three colored dots and two gray ones. An effort value the script doesn't know is shown as gray text instead. ⚡fast: shown when fast mode is on.
All the git information comes from a single git status --porcelain=v2 --branch call.
Row 2: what's left
ctx ━━━━━━╸━━━ 64% (640k) left │ 5h ━━━━━━╺━━━ 58% left ↻ 2h12m │ 7d ━━━╺━━━━━━ 32% left ↻ Sun ⚠ empty by Frictx: how much of the context window is left, in percent and in tokens (640k of a 1M-token window here).5hand7d: how much is left of the 5-hour and 7-day usage limits. Claude Code only sends these on a Pro or Max subscription, and only after the first response in a session.↻: when that limit resets: in minutes (42m), in hours and minutes (2h12m) or, when it's more than a day away, as a weekday (Sun). If that weekday would be today's, which happens when the reset is about a week away, it shows days instead (7d).⚠: a warning that only appears when, at the average pace so far in that window, the limit runs out before it resets:⚠ empty in ~45m, or⚠ empty by Friwhen that's more than a day away. It stays hidden during the first 5% of the window, when there isn't enough to go on, and it gets redder the sooner the limit would run out.
The bars work like a battery: the colored part is what's left and the gray part is what's used, with a half-cell gap between them so you can see where one ends. Each bar is 10 cells wide and moves in half-cell steps of 5%, drawn with ━, ╸ and ╺. The color isn't picked from a few fixed colors. The hue slides from green (full) through yellow and orange to red (empty) and is converted from HSL to RGB. When COLORTERM is truecolor or 24bit, the script uses 24-bit color, and otherwise the closest color of the 256-color palette.
Usage limits belong to the account, but a terminal only learns them from its own responses, so an idle terminal would keep showing old numbers. That's why the script saves the newest numbers it has seen to ~/.claude/.statusline-limits, and every terminal shows the newest ones. A later reset time means a newer window, and within the same window the higher number is the newer one, because usage only goes up until the reset. Once a reset time has passed, that limit shows as full again.
Row 3: the estimated CO₂ footprint
CO₂ session 13 g · today (3) 41 g (≈ 2 hours of video streaming) · month (12) 312 g · all-time (40) 1.05 kgThis row estimates the CO₂ of my Claude Code use for this session, today, this month and all time, with the number of sessions in parentheses. Today's total also gets an everyday comparison from eco-comparisons.txt. The script picks the item whose count is closest to 3, so the number stays easy to picture.
The estimate starts from the session's cost, which Claude Code works out at list price (cost.total_cost_usd), on a subscription too. Every time the cost goes up, the script turns the increase into energy with a watt-hours-per-dollar figure for the current model, and the energy into CO₂ at 0.30 kg per kWh. Each model's figure combines its list prices with energy assumptions from the script's header, for Opus about 0.2 Wh per 1,000 input tokens and 1.2 Wh per 1,000 output tokens, and the script assumes that about three quarters of a session's cost is input. Fast mode costs twice as much per token, so the script halves the figure while it's on.
These are rough estimates built on published research and assumptions, not measurements. The header of the script lists the sources and the assumptions behind every number.
Each session gets a small JSON ledger in ~/.claude/eco-sessions/ with its cost, CO₂, estimated water use and a per-day breakdown, and the all-time totals are kept in ~/.claude/eco-alltime.json. Ledgers that have been idle for more than 32 days are added to ~/.claude/eco-archive.json and deleted. The optional eco-report.sh prints the details: CO₂ and water for the last 7 days (or as many as you ask for), every comparison and a per-day breakdown.
Row 4: weather, sun and special days
Oslo 10° rain · today 10°–12° · sunrise 08:12 │ Persian: Beyhaqi Day (historian) tomorrow │ Swedish: FN-dagen (UN Day) in 2 days- Weather: the temperature and conditions for the forecast hour closest to now, plus today's low and high, from SMHI, the Swedish weather service. It's for the place set at the top of the script, which is Oslo in the copy in this post. The temperatures are colored on a scale from blue at −15 °C to red at 30 °C, and the weather links to a Google search for SMHI and the place's name.
- Sun: the next sunrise or sunset. That's sunrise until the sun is up, then sunset, then tomorrow's sunrise. It's calculated locally, without the network, with the sunrise equation from the US Naval Observatory's Almanac for Computers.
- Special days: the next Persian and the next Swedish special day,
today,tomorroworin N days. They come fromspecial-days.txt, which lists 37 Persian and 44 Swedish days, and each name links to a Google search, so I can read about it.
The forecast is fetched in the background by the same script, started with --fetch-weather, and at most every 30 minutes, so the status line never waits for the network. A failed fetch is tried again after 5 minutes at the earliest, and a forecast older than 6 hours isn't shown. The special days and sun times are worked out by special-days.py once a day, or again when one of the two special-days files changes, and the result is cached.
It fits the window
Claude Code sets COLUMNS to the width of the terminal, and the script keeps 3 columns of it free for Claude Code's own padding. When a row doesn't fit, the script drops its least useful parts first, in this order:
- Row 2: the 7-day reset time, the 5-hour and 7-day bars, the token count, the 5-hour reset time, the context bar and the pace warning.
- Row 3: the comparison, the month, the session and the all-time total.
- Row 4: the explanations in parentheses, today's low and high and the weather description. Then
in 3 daysbecomes3d, and the sun time goes last.
If a row still doesn't fit after that, it's printed as it is. Row 1 isn't shortened, but folder and branch names longer than 28 characters are cut short with …. This is the same example in an 80-column window:
➜ my-project git:(main) ✗ ↑1 · Opus 5.5 ••••• high
ctx ━━━━━━╸━━━ 64% left │ 5h 58% left ↻ 2h12m │ 7d 32% left ⚠ empty by Fri
CO₂ session 13 g · today (3) 41 g · all-time (40) 1.05 kg
Oslo 10° │ Persian: Beyhaqi Day tomorrow │ Swedish: FN-dagen 2dI also use Claude Code's fullscreen renderer (/tui fullscreen), where notifications get a row of their own. In the classic renderer they share the status line's row and can cut it short in a narrow window.
How it works
Everything above is one bash script, statusline-command.sh, plus a small Python helper for the special days and the sun. A few parts of it took some care:
- One
jqcall for the input. It reads every field the script needs and prints them as shell assignments with@sh, which quotes each value, and the scriptevals the result. Missing fields come out empty or 0. Names that end up on the screen are stripped of control characters first, so they can't bring in escape sequences of their own. - Built-ins only for the math. The colors (HSL to RGB, and to the 256-color palette), the bars and the times use bash's integer arithmetic and
printf '%(...)T', so none of it starts an extra process. - A fixed number format. The script sets
LC_NUMERIC=CandLC_TIME=C, so numbers use a decimal point and day names are in English whatever the locale. Without it, a locale with a decimal comma makes bash'sprintfreject the forecast's temperatures, like12.3. - Safe with several terminals. The files it keeps are written to a temporary file first and then renamed into place, so no terminal ever reads a half-written file, even when Claude Code cancels a run halfway (it does that when a newer update arrives). Old ledgers are moved into the archive under a
flocklock, and the archive remembers the last batch it took in, so a session can't be counted twice. - Always exit status 0. Claude Code only shows the output when the script exits with status 0, so the last line is an
ifand not a[ … ] && …test, which would fail whenever row 4 is empty. - Never waiting for the network. The weather comes from the same script running in the background (through
setsidwhen it's available). The forecast is cached for 30 minutes, and the special days and sun times for the day. - Debugging.
touch ~/.claude/.statusline-debugmakes the script save its latest input, together with the terminal size, to~/.claude/statusline-debug.txt. Delete the flag file to stop it.
These are the files the script keeps in ~/.claude/:
| File | What's in it |
|---|---|
.statusline-limits | The newest 5-hour and 7-day usage any terminal has seen |
eco-sessions/<session id>.json | One CO₂ ledger per session |
eco-alltime.json | The all-time CO₂, water and session totals |
eco-archive.json | The totals of the ledgers that were archived |
.eco.lock | The lock for archiving old ledgers |
.weather-cache | The latest forecast: the temperature and weather symbol per hour |
.weather-try | When the last weather fetch was started |
.special-days-cache | Today's special days and sun times |
What you need
I use it on Linux. To run it, you need:
- Claude Code. I'm on version 2.1.306. Older versions may not send every field the script reads. The script doesn't break on a missing field, it just can't show that part.
- bash 4.2 or newer, because the script uses
printf '%(...)T'andmapfile. The/bin/bashthat comes with macOS is version 3.2, so on a Mac install a newer bash first, for example with Homebrew, and make sure it's thebashon yourPATH. - jq to read the JSON. I have version 1.8.1.
- Python 3.7 or newer for the special days and the sun times.
- git for the branch information and curl for the weather.
- A font with the box-drawing characters
━ ╸ ╺ │and the symbols➜ ✗ ↑ ↓ ↻ ⚠ ⚡ •.
24-bit color and clickable links are nice to have: without 24-bit color the script falls back to the closest of 256 colors, and in a terminal without OSC 8 support the links are plain text. setsid and flock are used when they're available, and the script works without them. The 5-hour and 7-day gauges only show up on a Pro or Max subscription, because that's when Claude Code sends them.
Set it up
1. Save the files
Everything goes in ~/.claude/, because that's where the scripts look for each other:
| File | What it does |
|---|---|
statusline-command.sh | The status line itself |
special-days.py | Works out the special days and the sun times for row 4 |
special-days.txt | The Persian and Swedish special days |
eco-comparisons.txt | The everyday comparisons for row 3 |
eco-report.sh | Optional: the detailed CO₂ report |
Copy each file with the Copy button on its code block, paste it into an editor and save it under the name in its heading.
~/.claude/statusline-command.sh
#!/usr/bin/env bash
# Claude Code status line — four rows:
#
# ➜ my-project git:(main) ✗ ↑1 · Opus 5.5 ••••• high
# ctx ━━━━━━╸━━━ 64% (640k) left │ 5h ━━━━━━╺━━━ 58% left ↻ 2h12m │ 7d ━━━╺━━━━━━ 32% left ↻ Sun ⚠ empty by Fri
# CO₂ session 13 g · today (3) 41 g (≈ 2 hours of video streaming) · month (12) 312 g · all-time (40) 1.05 kg
# Oslo 10° rain · today 10°–12° · sunrise 08:12 │ Persian: Beyhaqi Day (historian) tomorrow │ Swedish: FN-dagen (UN Day) in 2 days
#
# Row 1: where you are and who answers (effort meter: one dot per level, lit up to the current one).
# Row 2: what is left of the context window and of the 5-hour and 7-day usage limits.
# Row 3: the estimated CO₂ footprint.
# Row 4: the weather and the next sunrise or sunset (for place_name below), then today's or the
# next Persian and Swedish special day. The weather and the day names are links: Ctrl+click
# to read more. The days live in special-days.txt.
#
# Bars work like a battery: they show what is LEFT and drain as you use it, and their colour
# slides smoothly from green (full) through yellow and orange to red (empty). Set
# gauge_mode=used (below) to make them fill up with use instead.
# ↻ = time until that usage limit resets (a weekday when it is more than a day away).
# Rows shrink to fit the window ($COLUMNS): the least useful parts drop off first.
# ⚠ appears only when, at the current pace, a usage limit runs out before it resets.
# Water, session counts and the per-day history: bash ~/.claude/eco-report.sh [days]
# (inside Claude Code: type ! bash ~/.claude/eco-report.sh)
# Debugging: `touch ~/.claude/.statusline-debug` saves the last input to
# ~/.claude/statusline-debug.txt; delete the flag file to stop.
#
# ECO METHODOLOGY & SOURCES:
# Energy/token: Luccioni et al. 2023 ("Power Hungry Processing"), adjusted for
# H100/H200 hardware and 2025 inference optimizations (flash attention, speculative decoding)
# Opus ~0.2 Wh in / ~1.2 Wh out per 1K tokens · Sonnet ~0.06 / ~0.35 · Haiku ~0.014 / ~0.08
# Input vs output: Output tokens use ~6x more energy (autoregressive decode vs batch prefill)
# Carbon intensity: 0.30 kg CO2/kWh, an assumed AWS/GCP regional average (US grid: ~0.37, EIA 2023)
# Water: 3.0 L/kWh total (direct cooling + upstream, Li et al. 2023 "Making AI Less Thirsty")
# PUE: 1.15 assumed, baked into the energy estimates (Uptime Institute's 2024 survey average: 1.56)
# Everyday comparisons ("≈ 2 bananas"): ~/.claude/eco-comparisons.txt, each item with its source.
#
# What gets counted: Claude Code's running session cost (cost.total_cost_usd). It covers every
# request in the session — main conversation, subagents, /compact — and list prices weight the
# token types much like the energy figures above do (output ~5x input, cache reads 0.1x or less). Each
# rise in cost becomes energy through the current model's Wh per dollar (wh_per_usd below), so
# energy per token stays the same whatever a model generation costs. Older versions of this
# script used context_window token counts, which since Claude Code 2.1.132 describe only what
# is in the context right now, not the work done.
#
# These are ROUGH ESTIMATES — Anthropic does not publish per-token energy figures.
# Dots for decimals whatever the locale (with a decimal comma, printf rejects temperatures like 12.3),
# English day names.
export LC_NUMERIC=C LC_TIME=C
# ----------------------------------- Settings -----------------------------------
# Gauges on row 2: "left" = battery style, bars show what's left and drain as you use it;
# "used" = bars fill up as you use it. The colour follows what's left either way.
gauge_mode=left
# Where the weather (from SMHI, the Swedish weather service) and the sunrise and sunset on row 4 are for.
place_name="Oslo" place_lat=59.91 place_lon=10.75
claude_dir="$HOME/.claude"
weather_cache="$claude_dir/.weather-cache" # "fetched lat lon", then "time °C symbol" per hour
# The weather is fetched in the background, at most every 30 minutes, by this same script started
# as `statusline-command.sh --fetch-weather`, so the status line itself never waits for the network.
if [ "${1-}" = --fetch-weather ]; then
now=${EPOCHSECONDS:-$(date +%s)}
forecast=$(curl -fsS -m 20 "https://opendata-download-metfcst.smhi.se/api/category/snow1g/version/1/geotype/point/lon/$place_lon/lat/$place_lat/data.json" |
jq -r '.timeSeries[:48][] | "\(.time | fromdateiso8601) \(.data.air_temperature) \(.data.symbol_code)"') &&
[ -n "$forecast" ] && {
# The forecast starts at the coming hour, so keep today's hours that have already passed (from
# the last fetch for this place): today's low and high then cover the whole day.
printf -v hms '%(%H %M %S)T' "$now"; read -r hh mm ss <<<"$hms"
midnight=$(( now - 10#$hh * 3600 - 10#$mm * 60 - 10#$ss )) first=${forecast%% *} kept=""
if [ -f "$weather_cache" ]; then
{ read -r _ lat lon
[ "$lat $lon" = "$place_lat $place_lon" ] &&
while read -r t rest; do
[[ $t =~ ^[0-9]+$ ]] && (( t >= midnight && t < first )) && kept+="$t $rest"$'\n'
done
} < "$weather_cache"
fi
printf '%s %s %s\n%s%s\n' "$now" "$place_lat" "$place_lon" "$kept" "$forecast" > "$weather_cache.$$.tmp" &&
mv -f "$weather_cache.$$.tmp" "$weather_cache"
}
rm -f "$weather_cache.$$.tmp"
exit 0
fi
IFS= read -r -d '' input # all of stdin, without spawning cat
[ -e "$claude_dir/.statusline-debug" ] &&
printf 'COLUMNS=%s LINES=%s\n%s\n' "${COLUMNS-}" "${LINES-}" "$input" > "$claude_dir/statusline-debug.txt" 2>/dev/null
now=${EPOCHSECONDS:-$(date +%s)}
R=$'\e[0m' DIM=$'\e[2m' RED=$'\e[31m' GREEN=$'\e[32m' YELLOW=$'\e[33m' BYELLOW=$'\e[1;33m'
BLUE=$'\e[34m' MAGENTA=$'\e[35m' CYAN=$'\e[36m' BGREEN=$'\e[1;32m'
# Clickable text (OSC 8 links: Ctrl+click in the terminal).
link() { REPLY=$'\e]8;;'"$1"$'\a'"$2"$'\e]8;;\a'; } # $1 = URL, $2 = text → REPLY
url_encode() { # $1 → REPLY, %-encoding every byte except letters, digits and - . _ ~ /
local LC_ALL=C s=$1 i c out=""
for (( i = 0; i < ${#s}; i++ )); do
c=${s:i:1}
case $c in [A-Za-z0-9/._~-]) out+=$c ;; *) printf -v c '%%%02X' "'$c"; out+=$c ;; esac
done
REPLY=$out
}
# Atomic writes: write a temp file next to the target, then rename() it into place, so another
# session (or a run Claude Code cancels halfway) never sees a half-written ledger.
tmps=()
atomic_write() { # $1 = target, $2 = content
local tmp="$1.$$.eco-tmp"
tmps+=("$tmp")
printf '%s\n' "$2" > "$tmp" 2>/dev/null && mv -f "$tmp" "$1" 2>/dev/null
}
cleanup() { local t; for t in "${tmps[@]}"; do [ -e "$t" ] && rm -f "$t"; done; }
trap cleanup EXIT
trap 'exit 143' TERM # Claude Code cancels a run when a newer update arrives: stop right away
trap 'exit 130' INT
# --- Session data: one jq call; absent fields come out empty or 0 ---
cwd="" model="" model_id="" session_id="" effort="" fast=0 cost=0
ctx_pct=0 ctx_pct10=0 ctx_left="" r5="" r5_at="" r7="" r7_at=""
eval "$(jq -r '
def num: if type == "number" then . elif type == "string" then (tonumber? // 0) else 0 end;
def opt: if . == null then "" else num | round end; # optional integer, "" when absent
def tok: if . >= 999500 then ((. / 100000 | round) / 10 | tostring) + "M"
elif . >= 9950 then (. / 1000 | round | tostring) + "k"
elif . >= 1000 then ((. / 100 | round) / 10 | tostring) + "k"
else round | tostring end;
(.context_window // {}) as $c
| ([[($c.used_percentage | num), 0] | max, 100] | min) as $pct
| (($c.context_window_size | num) as $s | if $s > 0 then $s else 200000 end) as $size
| @sh "cwd=\(.workspace.current_dir // .cwd // "")",
@sh "model=\(.model.display_name // "")",
@sh "model_id=\(.model.id // "")",
@sh "session_id=\(.session_id // "")",
@sh "repo_host=\(.workspace.repo.host // "")",
@sh "repo_owner=\(.workspace.repo.owner // "")",
@sh "repo_name=\(.workspace.repo.name // "")",
@sh "effort=\((.effort | if type == "object" then .level else . end) // "" | tostring)",
"fast=\(if .fast_mode == true then 1 else 0 end)",
"cost=\(.cost.total_cost_usd | num)",
"ctx_pct=\($pct | round)",
"ctx_pct10=\($pct * 10 | round)",
@sh "ctx_left=\($size * (100 - $pct) / 100 | tok)",
@sh "r5=\(.rate_limits.five_hour.used_percentage | opt)",
@sh "r5_at=\(.rate_limits.five_hour.resets_at | opt)",
@sh "r7=\(.rate_limits.seven_day.used_percentage | opt)",
@sh "r7_at=\(.rate_limits.seven_day.resets_at | opt)"
' <<<"$input" 2>/dev/null)"
# Names reach the terminal as-is, so drop control characters (no stray escape sequences).
cwd=${cwd//[[:cntrl:]]/} model=${model//[[:cntrl:]]/} effort=${effort//[[:cntrl:]]/}
repo_host=${repo_host//[^A-Za-z0-9.:-]/} repo_owner=${repo_owner//[[:cntrl:][:space:]]/} repo_name=${repo_name//[[:cntrl:][:space:]]/}
[[ $session_id =~ ^[A-Za-z0-9._-]+$ ]] || session_id="" # it becomes a file name
# --- Usage limits are account-wide: share the newest ones between terminals ---
# A terminal only learns the 5h/7d usage from its own replies, so an idle one would keep showing
# old numbers. Every terminal saves what it has seen to one shared file and all of them show the
# newest: a later reset time means a newer window, and within the same window the higher usage is
# newer (usage only grows until the reset).
limits_file="$claude_dir/.statusline-limits"
newest_limit() { # $1/$2 = used/reset seen here, $3/$4 = from the shared file → REPLY="used reset" ("- -" = none)
local u1=$1 a1=$2 u2=$3 a2=$4 here=0 there=0
[[ $u1 =~ ^[0-9]+$ && $a1 =~ ^[0-9]+$ ]] && here=1
[[ $u2 =~ ^[0-9]+$ && $a2 =~ ^[0-9]+$ ]] && there=1
if (( ! here && ! there )); then REPLY="- -"
elif (( ! there )) || { (( here )) && (( a1 > a2 + 120 )); }; then REPLY="$u1 $a1" # newer window here
elif (( ! here )) || (( a2 > a1 + 120 )); then REPLY="$u2 $a2" # newer window there
elif (( u1 >= u2 )); then REPLY="$u1 $a1"; else REPLY="$u2 $a2"; fi # same window: higher usage
}
s5=- s5_at=- s7=- s7_at=-
[ -f "$limits_file" ] && read -r s5 s5_at s7 s7_at < "$limits_file" # "used reset used reset", "-" = none
newest_limit "$r5" "$r5_at" "$s5" "$s5_at"; read -r n5 n5_at <<<"$REPLY"
newest_limit "$r7" "$r7_at" "$s7" "$s7_at"; read -r n7 n7_at <<<"$REPLY"
[ "$n5 $n5_at $n7 $n7_at" != "$s5 $s5_at $s7 $s7_at" ] && [ "$n5$n7" != "--" ] &&
atomic_write "$limits_file" "$n5 $n5_at $n7 $n7_at"
r5=${n5#-} r5_at=${n5_at#-} r7=${n7#-} r7_at=${n7_at#-} # "-" back to empty
# A window whose reset time has passed has started over: nothing used yet.
[[ $r5_at =~ ^[0-9]+$ ]] && (( r5_at <= now )) && r5=0 r5_at=""
[[ $r7_at =~ ^[0-9]+$ ]] && (( r7_at <= now )) && r7=0 r7_at=""
cols=${COLUMNS:-}
[[ $cols =~ ^[0-9]+$ ]] && (( cols > 20 )) || cols=200
avail=$(( cols - 3 )) # Claude Code adds a little padding of its own
# ============================== Row 1: where you are ==============================
if [ -z "$cwd" ]; then short_dir="?"
elif [ "$cwd" = "$HOME" ]; then short_dir="~"
else short_dir=${cwd%/}; short_dir=${short_dir##*/}; short_dir=${short_dir:-/}
fi
(( ${#short_dir} > 28 )) && short_dir="${short_dir:0:27}…"
# Branch, uncommitted changes (✗) and commits not yet pushed/pulled (↑/↓), from one git call.
branch="" dirty="" ahead=0 behind=0 upstream=""
if [ -n "$cwd" ] && gs=$(git -C "$cwd" --no-optional-locks status --porcelain=v2 --branch --untracked-files=no 2>/dev/null); then
oid=""
while IFS= read -r l; do
case $l in
'# branch.head '*) branch=${l#'# branch.head '} ;;
'# branch.oid '*) oid=${l#'# branch.oid '} ;;
'# branch.upstream '*) upstream=${l#'# branch.upstream '} ;;
'# branch.ab '*) ab=${l#'# branch.ab +'}; ahead=${ab%% *}; behind=${ab##*-} ;;
'#'*|'') ;;
*) dirty=1 ;;
esac
done <<<"$gs"
[ "$branch" = "(detached)" ] && branch=${oid:0:7}
branch=${branch//[[:cntrl:]]/}
(( ${#branch} > 28 )) && branch="${branch:0:27}…"
[[ $ahead =~ ^[0-9]+$ ]] || ahead=0
[[ $behind =~ ^[0-9]+$ ]] || behind=0
fi
# The folder name opens the folder in the file manager; the branch opens it on GitHub (or GitLab)
# when it has been pushed, otherwise the repository's front page.
dir_text="${CYAN}${short_dir}${R}"
if [ -n "$cwd" ]; then url_encode "$cwd"; link "file://${HOSTNAME:-}$REPLY" "$dir_text"; dir_text=$REPLY; fi
l1="${BGREEN}➜${R} $dir_text"
if [ -n "$branch" ]; then
branch_text="${RED}${branch}${BLUE}"
if [ -n "$repo_host" ] && [ -n "$repo_owner" ] && [ -n "$repo_name" ]; then
repo_url="https://$repo_host/$repo_owner/$repo_name"
if [ -n "$upstream" ]; then url_encode "${upstream#*/}"; repo_url+="/tree/$REPLY"; fi
link "$repo_url" "$branch_text"; branch_text=$REPLY
fi
l1+=" ${BLUE}git:(${branch_text})${R}"
[ -n "$dirty" ] && l1+=" ${YELLOW}✗${R}"
(( ahead > 0 )) && l1+=" ${DIM}↑${ahead}${R}"
(( behind > 0 )) && l1+=" ${DIM}↓${behind}${R}"
fi
l1+=" ${DIM}·${R} ${MAGENTA}${model:-?}${R}"
# Effort meter: one dot per level (low → max), lit up to the current level, the rest grey.
levels=(low medium high xhigh max) lit=0
for i in "${!levels[@]}"; do [ "${levels[i]}" = "$effort" ] && lit=$(( i + 1 )); done
if (( lit )); then
printf -v on '%*s' "$lit" ''; printf -v off '%*s' $(( ${#levels[@]} - lit )) ''
l1+=" ${MAGENTA}${on// /•}${R}${DIM}${off// /•}${R} ${MAGENTA}${effort}${R}"
elif [ -n "$effort" ]; then
l1+=" ${DIM}${effort}${R}" # a level this script doesn't know (e.g. a numeric budget)
fi
(( fast )) && l1+=" ${YELLOW}⚡fast${R}" # fast mode: twice the price per token
# ========================= Row 2: context and usage limits =========================
# Colour is calculated from how much is LEFT: the hue slides from 0° red (empty) through 60°
# yellow (half) to 120° green (full), at a fixed saturation and lightness, and is converted
# from HSL to RGB. 24-bit colour when the terminal supports it ($COLORTERM), otherwise the
# nearest colour of the 256-colour palette, worked out from the same RGB.
grad_sat=65 grad_light=55 # percent: more saturation = more vivid, more lightness = paler
case ${COLORTERM:-} in truecolor|24bit) truecolor=1 ;; *) truecolor=0 ;; esac
grad_color() { # $1 = percent left (0–100) → REPLY = colour
local p=$1
(( p < 0 )) && p=0; (( p > 100 )) && p=100
hsl_color $(( p * 120 / 100 ))
}
hsl_color() { # $1 = hue in degrees (0 red, 60 yellow, 120 green, 180 cyan, 240 blue) → REPLY = colour
local h=$(( ($1 % 360 + 360) % 360 )) c m x r g b ri gi bi
c=$(( (100 - (grad_light > 50 ? 2 * grad_light - 100 : 100 - 2 * grad_light)) * grad_sat * 255 / 10000 )) # chroma
m=$(( grad_light * 255 / 100 - c / 2 )) # lightness offset, added to every channel
x=$(( c * (60 - (h % 120 > 60 ? h % 120 - 60 : 60 - h % 120)) / 60 )) # the channel that is changing
case $(( h / 60 )) in
0) r=$c g=$x b=0 ;; 1) r=$x g=$c b=0 ;; 2) r=0 g=$c b=$x ;;
3) r=0 g=$x b=$c ;; 4) r=$x g=0 b=$c ;; *) r=$c g=0 b=$x ;;
esac
(( r += m, g += m, b += m ))
if (( truecolor )); then REPLY=$'\e[38;2;'"$r;$g;${b}m"; return; fi
# Nearest entry of the 6×6×6 colour cube, whose channel levels are 0, 95, 135, 175, 215, 255.
ri=$(( r < 48 ? 0 : r < 115 ? 1 : (r - 35) / 40 ))
gi=$(( g < 48 ? 0 : g < 115 ? 1 : (g - 35) / 40 ))
bi=$(( b < 48 ? 0 : b < 115 ? 1 : (b - 35) / 40 ))
REPLY=$'\e[38;5;'"$(( 16 + 36 * ri + 6 * gi + bi ))m"
}
fmt_reset() { # $1 = reset time (epoch) → REPLY = "42m" / "2h12m" / "Sat" / "7d", or "" when unknown
local t=$1 d wd_t wd_now
REPLY=""
[[ $t =~ ^[0-9]+$ ]] && (( t > now )) || return
d=$(( t - now ))
if (( d < 3600 )); then REPLY="$(( (d + 59) / 60 ))m"
elif (( d < 86400 )); then REPLY="$(( d / 3600 ))h$(( d % 3600 / 60 ))m"
else
printf -v wd_t '%(%a)T' "$t"; printf -v wd_now '%(%a)T' "$now"
# A week away lands on today's weekday, which would read as "today": show days instead.
if [ "$wd_t" = "$wd_now" ]; then REPLY="$(( (d + 43200) / 86400 ))d"; else REPLY=$wd_t; fi
fi
}
# Bars: a thin line of 10 cells in half-cell steps (5% each). The coloured part is what's left
# (what's used, with gauge_mode=used), the rest grey, with a half-cell gap between them so the
# boundary stays visible.
make_bar() { # $1 = coloured share in percent × 10 → BAR_FILL (coloured part), BAR_EMPTY (grey part)
local halves=$(( ($1 * 2 + 50) / 100 )) full half rest pad
(( halves > 20 )) && halves=20; (( halves < 0 )) && halves=0
full=$(( halves / 2 )) half=$(( halves % 2 ))
printf -v BAR_FILL '%*s' "$full" ''; BAR_FILL=${BAR_FILL// /━}
(( half )) && BAR_FILL+='╸'
rest=$(( 10 - full - half )) BAR_EMPTY=""
if (( rest > 0 )); then
(( full && ! half )) && { BAR_EMPTY='╺'; rest=$(( rest - 1 )); }
printf -v pad '%*s' "$rest" ''; BAR_EMPTY+=${pad// /━}
fi
}
# One gauge: the number shown, its bar and its colour, from the percent used × 10.
gauge() { # $1 = name, $2 = percent used × 10 → <name>_show, <name>_fill, <name>_empty, <name>_c
local used10=$2 rem10 share10
(( used10 > 1000 )) && used10=1000; (( used10 < 0 )) && used10=0
rem10=$(( 1000 - used10 ))
if [ "$gauge_mode" = used ]; then share10=$used10; else share10=$rem10; fi
printf -v "$1_show" '%s' $(( (share10 + 5) / 10 ))
make_bar "$share10"; printf -v "$1_fill" '%s' "$BAR_FILL"; printf -v "$1_empty" '%s' "$BAR_EMPTY"
grad_color $(( (rem10 + 5) / 10 )); printf -v "$1_c" '%s' "$REPLY"
}
# Pace warning: compare how fast a limit is being used with how long until it resets, and warn
# only when, at the average pace so far, it runs out before the reset (e.g. "⚠ empty in ~45m").
pace_warn() { # $1 = percent used, $2 = reset time (epoch), $3 = window length (s) → REPLY = text, PACE_C = colour
local used=$1 at=$2 win=$3 left el full
REPLY="" PACE_C=""
[[ $used =~ ^[0-9]+$ && $at =~ ^[0-9]+$ ]] && (( used > 0 && used < 100 )) || return
left=$(( at - now )) el=$(( win - left ))
(( left > 0 && left <= win && el * 20 >= win )) || return # first 5% of the window: too early to tell
full=$(( (100 - used) * el / used )) # seconds until empty at this pace
(( full < left )) || return
grad_color $(( full * 100 / left )); PACE_C=$REPLY # redder the sooner it runs out
fmt_reset $(( now + full ))
case $REPLY in [A-Z]*) REPLY="⚠ empty by $REPLY" ;; *) REPLY="⚠ empty in ~$REPLY" ;; esac
}
r5_show="" r7_show=""
gauge ctx "$ctx_pct10"
[ -n "$r5" ] && gauge r5 $(( r5 * 10 ))
[ -n "$r7" ] && gauge r7 $(( r7 * 10 ))
fmt_reset "$r5_at"; r5_reset=$REPLY
fmt_reset "$r7_at"; r7_reset=$REPLY
pace_warn "$r5" "$r5_at" 18000; r5_warn=$REPLY r5_warn_c=$PACE_C # 5 hours
pace_warn "$r7" "$r7_at" 604800; r7_warn=$REPLY r7_warn_c=$PACE_C # 7 days
[ "$gauge_mode" = used ] && left_word="" || left_word=" left"
add_limit() { # $1 = label, $2 = gauge name (r5 / r7), $3 = reset time, $4 = show reset?
local show=$2_show fill=$2_fill empty=$2_empty c=$2_c warn=$2_warn wc=$2_warn_c
[ -n "${!show}" ] || return
l2_plain+=" │ $1 "; l2+=" ${DIM}│ $1${R} "
if (( show_lbars )); then l2_plain+="${!fill}${!empty} "; l2+="${!c}${!fill}${R}${DIM}${!empty}${R} "; fi
l2_plain+="${!show}%$left_word"; l2+="${!c}${!show}%${R}${DIM}${left_word}${R}"
if (( $4 )) && [ -n "$3" ]; then l2_plain+=" ↻ $3"; l2+=" ${DIM}↻ $3${R}"; fi
if (( show_pace )) && [ -n "${!warn}" ]; then l2_plain+=" ${!warn}"; l2+=" ${!wc}${!warn}${R}"; fi
}
show_bar=1 show_lbars=1 show_left=1 show_r5=1 show_r7=1 show_pace=1
build_l2() {
l2_plain=" ctx " l2=" ${DIM}ctx${R} "
if (( show_bar )); then
l2_plain+="$ctx_fill$ctx_empty "; l2+="${ctx_c}${ctx_fill}${R}${DIM}${ctx_empty}${R} "
fi
l2_plain+="${ctx_show}%"; l2+="${ctx_c}${ctx_show}%${R}"
if [ "$gauge_mode" = used ]; then
if (( show_left )) && [ -n "$ctx_left" ]; then l2_plain+=" · $ctx_left left"; l2+=" ${DIM}· ${ctx_left} left${R}"; fi
else # e.g. "60% (600k) left"
if (( show_left )) && [ -n "$ctx_left" ]; then l2_plain+=" ($ctx_left)"; l2+=" ${DIM}(${ctx_left})${R}"; fi
l2_plain+=" left"; l2+="${DIM} left${R}"
fi
add_limit 5h r5 "$r5_reset" "$show_r5"
add_limit 7d r7 "$r7_reset" "$show_r7"
}
build_l2
for f in show_r7 show_lbars show_left show_r5 show_bar show_pace; do # narrow window: drop these, in this order
(( ${#l2_plain} <= avail )) && break
printf -v "$f" 0; build_l2
done
# ============================== Row 3: eco footprint ==============================
eco_dir="$claude_dir/eco-sessions"
alltime_file="$claude_dir/eco-alltime.json" # all-time totals (also read by eco-report.sh)
archive_file="$claude_dir/eco-archive.json" # totals of sessions whose ledgers were deleted
lock_file="$claude_dir/.eco.lock"
[ -d "$eco_dir" ] || mkdir -p "$eco_dir" 2>/dev/null
printf -v today '%(%Y-%m-%d)T' "$now"
month_start="${today%-*}-01"
printf -v cutoff '%(%Y-%m-%d)T' $(( now - 32 * 86400 )) # ledgers idle longer than this get archived
# Wh per dollar of list price = 0.75 × (input Wh ÷ input price) + 0.25 × (output Wh ÷ output price),
# with the per-1K-token energy figures from the header; about three quarters of a Claude Code
# session's cost is on the input side (cache reads and writes).
case "$model_id" in
*opus-5-5*) wh_per_usd=52.5 ;; # Opus 5.5 $4 / $20 per million tokens
*opus-5*|*opus-4-[5-9]*) wh_per_usd=42 ;; # Opus 4.5 – 5 $5 / $25
*opus*) wh_per_usd=14 ;; # Opus 4 / 4.1 $15 / $75
*sonnet-5*) wh_per_usd=31.25 ;; # Sonnet 5 / 5.5 $2 / $10
*sonnet*) wh_per_usd=20.8 ;; # Sonnet 4.x $3 / $15
*haiku-5*) wh_per_usd=145 ;; # Haiku 5.5 $0.10 / $0.50
*haiku*) wh_per_usd=14.5 ;; # Haiku 4.5 $1 / $5
*fable*|*mythos*) wh_per_usd=21 ;; # Fable / Mythos $10 / $50, Opus-level energy assumed
*) wh_per_usd=30 ;;
esac
shopt -s nullglob
ledgers=("$eco_dir"/*.json)
totals=()
[ -f "$archive_file" ] && totals+=("$archive_file")
[ -f "$alltime_file" ] && totals+=("$alltime_file")
# One jq call reads every ledger: it updates this session's ledger and adds up today / month /
# all-time. Each session ledger keeps its running cost, its CO₂/water totals and a per-day
# breakdown, e.g. {"cost":1.87,"co2":29.45,"h2o":294.5,"date":"2026-10-15","days":{"2026-10-15":{...}}}.
# Files are read as raw lines and parsed with fromjson?, so one corrupt file can't break the rest.
sess_json="" arch_json="" all_json="" prune=0
s_disp="0 g" t_disp="0 g" t_mg=0 m_disp="0 g" a_disp="0 g" t_n=0 m_n=0 a_n=0
eval "$(jq -nrR \
--arg dir "$eco_dir" --arg arch_path "$archive_file" --arg all_path "$alltime_file" \
--arg sid "$session_id" --arg today "$today" --arg month "$month_start" --arg cutoff "$cutoff" \
--argjson cost "${cost:-0}" --argjson k "$wh_per_usd" --argjson fast "$fast" '
def num: if type == "number" then . else 0 end;
def obj: if type == "object" then . else null end;
def r($n): (. * pow(10; $n) | round) / pow(10; $n);
def fixed($n): (. * pow(10; $n) | round | tostring) as $s
| if $n == 0 then $s
else ("0" * ([$n + 1 - ($s | length), 0] | max)) + $s | .[:-$n] + "." + .[-$n:] end;
def mass: if . <= 0 then "0 g"
elif . < 9.95 then fixed(1) + " g"
elif . < 999.5 then fixed(0) + " g"
elif . < 9995 then (. / 1000 | fixed(2)) + " kg"
elif . < 99950 then (. / 1000 | fixed(1)) + " kg"
else (. / 1000 | fixed(0)) + " kg" end;
# Per-day CO₂/water; a ledger from before per-day tracking counts on its last-active date.
def days: (.days | obj) // (if (.date | type) == "string" then {(.date): {co2: .co2, h2o: .h2o}} else {} end);
reduce inputs as $line ({files: {}, arch: null, all: null};
input_filename as $f | ($line | fromjson? // null) as $j
| if $f == $arch_path then .arch = $j elif $f == $all_path then .all = $j else .files[$f] = $j end)
| .files as $files
| (.all | obj) as $all_old
| ($dir + "/" + $sid + ".json") as $sf
| ($files[$sf] | obj) as $p
# This session: the rise in cost since the last render becomes CO₂ (0.30 g/Wh) and water.
| (($p.co2 // 0) | num) as $pco2
| (if $fast == 1 then $k / 2 else $k end) as $kk # fast mode charges 2x for the same tokens
| (if $p != null and ($p | has("cost") | not)
then ([$pco2, $cost * $kk * 0.30] | max) - $pco2 # ledger from the old script: top it up, never count twice
else ([$cost - (($p.cost // 0) | num), 0] | max) * $kk * 0.30 end) as $dco2
| ($p // {} | days) as $d0
| {cost: ([(($p.cost // 0) | num), $cost] | max),
co2: ($pco2 + $dco2 | r(4)),
h2o: ((($p.h2o // 0) | num) + $dco2 * 10 | r(3)), # 3.0 ml/Wh ÷ 0.30 g/Wh = 10 ml per g
date: $today,
days: (if $dco2 > 0
then $d0 | .[$today] = {co2: ((.[$today].co2 // 0) + $dco2 | r(4)), h2o: ((.[$today].h2o // 0) + $dco2 * 10 | r(3))}
else $d0 end)} as $new
| ($sid != "" and $new.co2 > 0) as $keep # sessions that used nothing get no ledger
| ($files | if $keep then .[$sf] = $new else . end) as $files2
# Archive: sessions whose ledgers were already deleted. The first run derives it from the old
# all-time ledger minus the ledgers still on disk.
| (.arch | if type == "object" and (.co2 | type) == "number" then . else null end) as $a0
| ($a0 // (($all_old // {}) as $o | [$files[] | obj | select(. != null)] as $orig
| {co2: ([(($o.co2 // 0) | num) - ([$orig[].co2 | num] | add // 0), 0] | max | r(2)),
h2o: ([(($o.h2o // 0) | num) - ([$orig[].h2o | num] | add // 0), 0] | max | r(1)),
sessions: ([(($o.sessions // 0) | num) - ($orig | length), 0] | max),
pruned: []})) as $arch
| ($arch.pruned // []) as $done
| [$files2 | to_entries[] | select(.value | type == "object")
| select(.key | ltrimstr($dir + "/") | rtrimstr(".json") | IN($done[]) | not) | .value] as $live
| [$live[] | days | to_entries[]] as $de
| ([$de[] | select(.key == $today) | .value.co2 | num] | add // 0) as $t
| ([$de[] | select(.key >= $month) | .value.co2 | num] | add // 0) as $m
# Sessions with some CO₂ today / this month (the all-time count is in $all below).
| ([$live[] | select((days | .[$today].co2 // 0 | num) > 0)] | length) as $tn
| ([$live[] | select(([days | to_entries[] | select(.key >= $month) | .value.co2 | num] | add // 0) > 0)] | length) as $mn
| {co2: (($arch.co2 | num) + ([$live[].co2 | num] | add // 0) | r(2)),
h2o: (($arch.h2o | num) + ([$live[].h2o | num] | add // 0) | r(1)),
sessions: (($arch.sessions | num) + ([$live[] | select((.co2 | num) > 0)] | length))} as $all
| ([$files | to_entries[] | select(.key != $sf)
| select((.value | if type == "object" then (.date // "") | tostring else "" end) < $cutoff)]
| length > 0) as $prune
| @sh "sess_json=\(if $keep and $new != $p then $new | tojson else "" end)",
@sh "arch_json=\(if $a0 == null then $arch | tojson else "" end)",
@sh "all_json=\(if $all != $all_old then $all | tojson else "" end)",
@sh "s_disp=\($new.co2 | mass)",
@sh "t_disp=\($t | mass)",
"t_mg=\($t * 1000 | round)",
@sh "m_disp=\($m | mass)",
@sh "a_disp=\($all.co2 | mass)",
"t_n=\($tn)", "m_n=\($mn)", "a_n=\($all.sessions)",
"prune=\(if $prune then 1 else 0 end)"
' "${totals[@]}" "${ledgers[@]}" </dev/null 2>/dev/null)"
[ -n "$sess_json" ] && atomic_write "$eco_dir/$session_id.json" "$sess_json"
[ -n "$arch_json" ] && atomic_write "$archive_file" "$arch_json"
[ -n "$all_json" ] && atomic_write "$alltime_file" "$all_json"
# Ledgers idle for 32+ days: add them to the archive, then delete them. Under a lock, and the
# archive remembers the last batch it absorbed, so two sessions doing this at once (or a run
# cancelled between the two steps) can't count a session twice.
if [ "$prune" = 1 ] && [ -f "$archive_file" ]; then
{
command -v flock >/dev/null 2>&1 && flock -w 2 9
new_arch="" del=()
eval "$(jq -nrR --arg dir "$eco_dir" --arg arch_path "$archive_file" --arg sid "$session_id" --arg cutoff "$cutoff" '
def num: if type == "number" then . else 0 end;
def r($n): (. * pow(10; $n) | round) / pow(10; $n);
reduce inputs as $line ({files: {}, arch: null};
input_filename as $f | ($line | fromjson? // null) as $j
| if $f == $arch_path then .arch = $j else .files[$f] = $j end)
| (.arch | if type == "object" and (.co2 | type) == "number" then . else null end) as $a
| if $a == null then empty else
($a.pruned // []) as $done
| [.files | to_entries[] | select(.key != ($dir + "/" + $sid + ".json"))
| (.value | if type == "object" then . else {} end) as $v
| select(($v.date // "" | tostring) < $cutoff)
| {path: .key, id: (.key | ltrimstr($dir + "/") | rtrimstr(".json")),
co2: ($v.co2 | num), h2o: ($v.h2o | num)}] as $exp
| [$exp[] | select(.id | IN($done[]) | not)] as $add
| @sh "new_arch=\({co2: ($a.co2 + ([$add[].co2] | add // 0) | r(2)),
h2o: (($a.h2o | num) + ([$add[].h2o] | add // 0) | r(1)),
sessions: (($a.sessions | num) + ([$add[] | select(.co2 > 0)] | length)),
pruned: [$exp[].id]} | tojson)",
@sh "del=(\([$exp[].path]))"
end' "$archive_file" "${ledgers[@]}" </dev/null 2>/dev/null)"
[ -n "$new_arch" ] && atomic_write "$archive_file" "$new_arch" && (( ${#del[@]} )) && rm -f -- "${del[@]}"
# Temp files left behind by runs that were killed outright.
find "$eco_dir" "$claude_dir" -maxdepth 1 -name '*.eco-tmp' -mmin +1 -delete 2>/dev/null
} 9>>"$lock_file"
fi
# --- Today's CO₂ in everyday terms, e.g. "≈ 2 bananas" ---
# The items, each with its source, are in ~/.claude/eco-comparisons.txt (eco-report.sh uses the same
# file). The one whose count is nearest to 3 is shown, so the number stays easy to picture.
trim() { REPLY=${1#"${1%%[![:space:]]*}"}; REPLY=${REPLY%"${REPLY##*[![:space:]]}"}; }
cmp="" best=0
[[ $t_mg =~ ^[0-9]+$ ]] || t_mg=0
if [ -f "$claude_dir/eco-comparisons.txt" ] && (( t_mg > 0 )); then
while IFS='|' read -r g one many _; do
trim "$g"; g=$REPLY
[[ $g =~ ^[0-9]+(\.[0-9]+)?$ ]] || continue # comments and blank lines
frac=${g#*.}; [ "$frac" = "$g" ] && frac=""; frac=${frac}000
mg=$(( 10#${g%%.*} * 1000 + 10#${frac:0:3} )) # grams → milligrams, without floating point
(( mg > 0 )) || continue
r=$(( t_mg * 100 / mg )) # how many of the item, × 100
if (( r >= 300 )); then score=$(( r * 1000 / 300 )); elif (( r > 0 )); then score=$(( 300000 / r )); else score=999999999; fi
if (( best == 0 || score < best )); then
best=$score cnt=$r; trim "$one"; c_one=$REPLY; trim "$many"; c_many=$REPLY
fi
done < "$claude_dir/eco-comparisons.txt"
if (( best )); then
n=$(( (cnt + 50) / 100 ))
if (( n == 0 )); then cmp="less than one $c_one"
elif (( n == 1 )); then cmp="1 $c_one"
else cmp="$n $c_many"; fi
fi
fi
show_cmp=1 show_month=1 show_sess=1 show_all=1
add_l3() { # $1 = label, $2 = amount, $3 = number of sessions (optional), e.g. "today (3) 45 g"
local label=$1
[ -n "$3" ] && label+=" ($3)"
if [ -n "$l3_sep" ]; then l3_plain+=" · "; l3+=" ${DIM}·${R} "; else l3_plain+=" "; l3+=" "; l3_sep=1; fi
l3_plain+="$label $2"; l3+="${DIM}${label}${R} ${GREEN}$2${R}"
}
build_l3() {
l3_plain=" CO₂" l3=" ${DIM}CO₂${R}" l3_sep=""
(( show_sess )) && add_l3 session "$s_disp"
add_l3 today "$t_disp" "$t_n"
(( show_cmp )) && [ -n "$cmp" ] && { l3_plain+=" (≈ $cmp)"; l3+=" ${DIM}(≈ ${cmp})${R}"; }
(( show_month )) && add_l3 month "$m_disp" "$m_n"
(( show_all )) && add_l3 all-time "$a_disp" "$a_n"
}
build_l3
for f in show_cmp show_month show_sess show_all; do # narrow window: drop these, in this order
(( ${#l3_plain} <= avail )) && break
printf -v "$f" 0; build_l3
done
# ======================= Row 4: weather, sun and special days =======================
# Weather: SMHI's forecast for the nearest hour, fetched in the background (see --fetch-weather at
# the top). Sun times and the special days (from ~/.claude/special-days.txt: edit it to add your
# own) are worked out once a day by special-days.py and cached. The weather and the day names are
# links: Ctrl+click to read more.
weather_try="$claude_dir/.weather-try" # when a fetch was last started
w_lines=() w_temp="" w_sym="" w_deg="" w_desc=""
[ -f "$weather_cache" ] && mapfile -t w_lines < "$weather_cache"
read -r w_at w_lat w_lon <<<"${w_lines[0]-}"
[[ $w_at =~ ^[0-9]+$ ]] && [ "$w_lat $w_lon" = "$place_lat $place_lon" ] || w_at=0
if (( now - w_at >= 1800 )); then # older than 30 minutes: fetch a new one (tried at most every 5)
w_try=0; [ -f "$weather_try" ] && read -r w_try < "$weather_try"
[[ $w_try =~ ^[0-9]+$ ]] || w_try=0
if (( now - w_try >= 300 )) && command -v curl >/dev/null; then
atomic_write "$weather_try" "$now"
if command -v setsid >/dev/null; then setsid -f bash "${BASH_SOURCE[0]}" --fetch-weather </dev/null >/dev/null 2>&1
else bash "${BASH_SOURCE[0]}" --fetch-weather </dev/null >/dev/null 2>&1 & disown; fi
fi
fi
if (( now - w_at < 6 * 3600 )); then # the forecast hour nearest to now (none once 6 hours old)
for line in "${w_lines[@]:1}"; do
read -r t temp sym <<<"$line"
[[ $t =~ ^[0-9]+$ ]] || continue
(( t > now + 1800 )) && break
w_temp=$temp w_sym=$sym
done
[ -z "$w_temp" ] && read -r t w_temp w_sym <<<"${w_lines[1]-}"
fi
# Today's low and high (the local calendar day), from the hourly values in the cache.
w_min="" w_max=""
if (( now - w_at < 6 * 3600 )); then
printf -v hms '%(%H %M %S)T' "$now"; read -r hh mm ss <<<"$hms"
midnight=$(( now - 10#$hh * 3600 - 10#$mm * 60 - 10#$ss ))
for line in "${w_lines[@]:1}"; do
read -r t temp _ <<<"$line"
[[ $t =~ ^[0-9]+$ && $temp =~ ^-?[0-9]+(\.[0-9]+)?$ ]] || continue
(( t < midnight )) && continue
(( t >= midnight + 86400 )) && break
printf -v d '%.0f' "$temp"; [ "$d" = "-0" ] && d=0
if [ -z "$w_min" ] || (( d < w_min )); then w_min=$d; fi
if [ -z "$w_max" ] || (( d > w_max )); then w_max=$d; fi
done
fi
# Temperature colour, calculated like the gauges: hue 240° (blue) at -15 °C, down to 0° (red) at 30 °C.
temp_color() { # $1 = °C (whole number) → REPLY = colour
local hue=$(( (30 - $1) * 240 / 45 ))
(( hue < 0 )) && hue=0; (( hue > 240 )) && hue=240
hsl_color "$hue"
}
if [ -n "$w_min" ]; then temp_color "$w_min"; w_min_c=$REPLY; temp_color "$w_max"; w_max_c=$REPLY; fi
if [[ $w_temp =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; then
printf -v w_deg '%.0f' "$w_temp"; [ "$w_deg" = "-0" ] && w_deg=0
temp_color "$w_deg"; w_c=$REPLY
case $w_sym in # SMHI weather symbols
1) w_desc="clear" ;; 2) w_desc="mostly clear" ;; 3) w_desc="variable clouds" ;; 4) w_desc="partly cloudy" ;;
5) w_desc="cloudy" ;; 6) w_desc="overcast" ;; 7) w_desc="fog" ;;
8) w_desc="light showers" ;; 9) w_desc="showers" ;; 10) w_desc="heavy showers" ;; 11) w_desc="thunderstorm" ;;
12) w_desc="light sleet showers" ;; 13) w_desc="sleet showers" ;; 14) w_desc="heavy sleet showers" ;;
15) w_desc="light snow showers" ;; 16) w_desc="snow showers" ;; 17) w_desc="heavy snow showers" ;;
18) w_desc="light rain" ;; 19) w_desc="rain" ;; 20) w_desc="heavy rain" ;; 21) w_desc="thunder" ;;
22) w_desc="light sleet" ;; 23) w_desc="sleet" ;; 24) w_desc="heavy sleet" ;;
25) w_desc="light snow" ;; 26) w_desc="snow" ;; 27) w_desc="heavy snow" ;;
esac
url_encode "$place_name"; w_url="https://www.google.com/search?q=SMHI+$REPLY"
fi
days_data="$claude_dir/special-days.txt" days_py="$claude_dir/special-days.py"
days_cache="$claude_dir/.special-days-cache" # first line: the date and place it was made for
days_key="$today $place_lat $place_lon"
day_lines=()
if [ -f "$days_data" ] && [ -f "$days_py" ]; then
[ -f "$days_cache" ] && mapfile -t day_lines < "$days_cache"
if [ "${day_lines[0]-}" != "$days_key" ] || [[ $days_data -nt $days_cache || $days_py -nt $days_cache ]]; then
if out=$(python3 -I "$days_py" --date "$today" --sun "$place_lat,$place_lon" 2>/dev/null); then
atomic_write "$days_cache" "$days_key"$'\n'"$out"
mapfile -t day_lines <<<"$days_key"$'\n'"$out"
fi
fi
[ "${day_lines[0]-}" = "$days_key" ] || day_lines=() # never show a stale day count
fi
# The next sun event: sunrise until the sun is up, then sunset, then tomorrow's sunrise.
sun_next=""
for line in "${day_lines[@]:1}"; do
[[ $line == SUN$'\t'* ]] || continue
IFS=$'\t' read -r _ rise set rise2 <<<"$line"
[[ $rise =~ ^[0-9][0-9]:[0-9][0-9]$ && $set =~ ^[0-9][0-9]:[0-9][0-9]$ ]] || break
printf -v hm '%(%H%M)T' "$now"
if (( 10#$hm < 10#${rise/:/} )); then sun_next="sunrise $rise"
elif (( 10#$hm < 10#${set/:/} )); then sun_next="sunset $set"
elif [ -n "$rise2" ]; then sun_next="sunrise $rise2"; fi
break
done
add_l4() { l4_plain+="${l4_plain:+ │ }$1"; l4+="${l4:+ ${DIM}│${R} }$2"; } # $1 plain, $2 coloured
show_wdesc=1 show_range=1 show_sun=1 show_gloss=1 short_when=0
build_l4() {
local culture label line c n n_day name url names_p names_c when p col
l4_plain="" l4=""
if [ -n "$w_deg" ]; then # e.g. "Oslo 11° overcast · today 9°–14° · sunset 17:38"
p="$place_name ${w_deg}°" col="$place_name ${w_c}${w_deg}°${R}"
if (( show_wdesc )) && [ -n "$w_desc" ]; then p+=" $w_desc"; col+=" ${DIM}${w_desc}${R}"; fi
link "$w_url" "$col"; col=$REPLY
if (( show_range )) && [ -n "$w_min" ]; then
p+=" · today ${w_min}°–${w_max}°"
col+=" ${DIM}· today${R} ${w_min_c}${w_min}°${R}${DIM}–${R}${w_max_c}${w_max}°${R}"
fi
if (( show_sun )) && [ -n "$sun_next" ]; then p+=" · $sun_next"; col+=" ${DIM}· ${sun_next}${R}"; fi
add_l4 "$p" "$col"
elif (( show_sun )) && [ -n "$sun_next" ]; then
add_l4 "$sun_next" "${DIM}${sun_next}${R}"
fi
for culture in FA SE; do
names_p="" names_c="" n=""
for line in "${day_lines[@]:1}"; do
IFS=$'\t' read -r c n_day name url <<<"$line"
[ "$c" = "$culture" ] || continue
n=$n_day
(( show_gloss )) || name=${name%% (*} # drop the explanation in brackets
[ -n "$names_p" ] && { names_p+=" & "; names_c+=" ${DIM}&${R} "; }
names_p+=$name
link "$url" "$name"; names_c+=$REPLY
done
[ -n "$names_p" ] && [[ $n =~ ^[0-9]+$ ]] || continue
case $culture in FA) label="Persian" ;; *) label="Swedish" ;; esac
if (( n == 0 )); then when="today"
elif (( n == 1 )); then when="tomorrow"
elif (( short_when )); then when="${n}d"
else when="in $n days"; fi
if (( n == 0 )); then add_l4 "$label: $names_p $when" "${DIM}$label:${R} $names_c ${BYELLOW}today${R}"
else add_l4 "$label: $names_p $when" "${DIM}$label:${R} $names_c ${DIM}$when${R}"; fi
done
[ -n "$l4_plain" ] && { l4_plain=" $l4_plain"; l4=" $l4"; }
}
build_l4
for f in show_gloss show_range show_wdesc short_when show_sun; do # narrow window: give these up, in this order
(( ${#l4_plain} <= avail )) && break
if [ "$f" = short_when ]; then short_when=1; else printf -v "$f" 0; fi
build_l4
done
printf '%s\n%s\n%s\n' "$l1" "$l2" "$l3"
if [ -n "$l4" ]; then printf '%s\n' "$l4"; fi # exit status 0 even without row 4, or Claude Code hides the status line~/.claude/special-days.py
#!/usr/bin/env python3
"""Persian and Swedish special days (and sunrise/sunset) for the Claude Code status line (row 4).
Reads ~/.claude/special-days.txt and, for each culture, prints the nearest day (today, or
the next one coming up) as tab-separated fields:
culture days-until name google-search-url
With --sun LAT,LON it first prints today's sunrise and sunset and tomorrow's sunrise, in local time:
SUN 07:35 17:41 07:37
Usage:
python3 special-days.py [--date YYYY-MM-DD] [--sun LAT,LON] what the status line uses
python3 special-days.py --list [DAYS] [--date ...] every day in the next DAYS days (60)
"""
import datetime as dt
import math
import os
import sys
import urllib.parse
DATA = os.path.join(os.path.expanduser("~"), ".claude", "special-days.txt")
CULTURES = {"FA": "Persian", "SE": "Swedish"}
SEARCH_SUFFIX = {"FA": " Persian", "SE": " Sweden"}
DOW = {"mon": 0, "tue": 1, "wed": 2, "thu": 3, "fri": 4, "sat": 5, "sun": 6}
DAY = dt.timedelta(days=1)
def easter(year):
"""Easter Sunday in the Gregorian calendar (anonymous Gregorian algorithm)."""
a, b, c = year % 19, year // 100, year % 100
d, e = b // 4, b % 4
f = (b + 8) // 25
g = (b - f + 1) // 3
h = (19 * a + b - d - g + 15) % 30
i, k = c // 4, c % 4
l = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * l) // 451
month, day = divmod(h + l - 7 * m + 114, 31)
return dt.date(year, month, day + 1)
# Persian (Solar Hijri) calendar: the Gregorian date of Nowruz (1 Farvardin), with the
# arithmetic of the jalaali-js library (Borkowski / Pournader), valid for years 1-3177 AP.
BREAKS = [-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635,
2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178]
def _div(a, b): # truncating division, as in the reference implementation
return int(a / b)
def _mod(a, b):
return a - _div(a, b) * b
def nowruz(jy):
gy, leap_j, jp, jump = jy + 621, -14, BREAKS[0], 0
for jm in BREAKS[1:]:
jump = jm - jp
if jy < jm:
break
leap_j += _div(jump, 33) * 8 + _div(_mod(jump, 33), 4)
jp = jm
n = jy - jp
leap_j += _div(n, 33) * 8 + _div(_mod(n, 33) + 3, 4)
if _mod(jump, 33) == 4 and jump - n == 4:
leap_j += 1
leap_g = _div(gy, 4) - _div((_div(gy, 100) + 1) * 3, 4) - 150
return dt.date(gy, 3, 20 + leap_j - leap_g)
def persian(jy, jm, jd):
"""Day jd of month jm of Persian year jy (months 1-6 have 31 days, 7-11 have 30)."""
return nowruz(jy) + DAY * ((jm - 1) * 31 - (jm // 7) * (jm - 7) + jd - 1)
def sun_event(day, lat, lon, rising):
"""Local time of sunrise or sunset on `day` (sunrise equation from the US Naval Observatory's
Almanac for Computers, accurate to a minute or two), or None during midnight sun / polar night."""
sin = lambda d: math.sin(math.radians(d))
cos = lambda d: math.cos(math.radians(d))
t = day.timetuple().tm_yday + ((6 if rising else 18) - lon / 15) / 24
m = 0.9856 * t - 3.289 # the sun's mean anomaly
l = (m + 1.916 * sin(m) + 0.020 * sin(2 * m) + 282.634) % 360 # its true longitude
ra = math.degrees(math.atan(0.91764 * math.tan(math.radians(l)))) % 360
ra = (ra + (l // 90) * 90 - (ra // 90) * 90) / 15 # right ascension, in l's quadrant, hours
sin_dec = 0.39782 * sin(l)
cos_h = (cos(90.833) - sin_dec * sin(lat)) / (math.cos(math.asin(sin_dec)) * cos(lat))
if not -1 <= cos_h <= 1:
return None
h = (360 - math.degrees(math.acos(cos_h)) if rising else math.degrees(math.acos(cos_h))) / 15
ut = (h + ra - 0.06571 * t - 6.622 - lon / 15) % 24
midnight = dt.datetime.combine(day, dt.time(), tzinfo=dt.timezone.utc)
return (midnight + dt.timedelta(hours=ut)).astimezone() # to the computer's time zone
def dates(rule, year):
"""The dates a rule from special-days.txt falls on, around Gregorian year `year`."""
rule, _, every = rule.partition("@")
if every:
start, _, step = every.partition("/")
if (year - int(start)) % int(step or 1):
return []
if rule == "chaharshanbe-suri":
found = []
for jy in (year - 622, year - 621):
d = nowruz(jy + 1) - DAY
while d.weekday() != DOW["wed"]: # the last Wednesday of the Persian year ...
d -= DAY
found.append(d - DAY) # ... is celebrated the evening before
return found
if rule.startswith("easter"):
return [easter(year) + DAY * int(rule[6:] or 0)]
if ":" not in rule:
month, day = map(int, rule.split("-"))
return [dt.date(year, month, day)]
kind, arg = rule.split(":", 1)
if kind == "fa":
jm, jd = map(int, arg.split("-"))
return [persian(jy, jm, jd) for jy in (year - 622, year - 621)]
if kind in DOW: # the first such weekday on or after MM-DD
month, day = map(int, arg.split("-"))
d = dt.date(year, month, day)
return [d + DAY * ((DOW[kind] - d.weekday()) % 7)]
nth, dow = kind.split("-") # the Nth (or last) such weekday of the month
month = int(arg)
if nth == "last":
d = dt.date(year + month // 12, month % 12 + 1, 1) - DAY
return [d - DAY * ((d.weekday() - DOW[dow]) % 7)]
d = dt.date(year, month, 1)
return [d + DAY * ((DOW[dow] - d.weekday()) % 7 + 7 * (int(nth) - 1))]
def load():
days = []
with open(DATA, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = [p.strip() for p in line.split("|")]
if len(parts) < 3 or parts[0] not in CULTURES:
continue
culture, rule, name = parts[:3]
query = parts[3] if len(parts) > 3 and parts[3] else name.split(" (")[0] + SEARCH_SUFFIX[culture]
days.append((culture, rule, name, query))
return days
def upcoming(days, today, horizon):
"""(date, culture, name, query) for every day from today up to `horizon` days ahead."""
found = []
for culture, rule, name, query in days:
try:
when = [d for y in (today.year - 1, today.year, today.year + 1) for d in dates(rule, y)]
except (ValueError, KeyError, IndexError):
continue # a mistyped line is skipped, never breaks the status line
future = [d for d in when if d >= today]
if future and (min(future) - today).days <= horizon:
found.append((min(future), culture, name, query))
return sorted(found)
def main(argv):
today, listing, sun, args = dt.date.today(), None, None, list(argv)
while args:
arg = args.pop(0)
if arg == "--date":
today = dt.date.fromisoformat(args.pop(0))
elif arg == "--list":
listing = int(args.pop(0)) if args and args[0].isdigit() else 60
elif arg == "--sun":
sun = [float(x) for x in args.pop(0).split(",")]
if sun and listing is None:
times = [sun_event(today, *sun, True), sun_event(today, *sun, False),
sun_event(today + DAY, *sun, True)]
print("SUN\t" + "\t".join(t.strftime("%H:%M") if t else "" for t in times))
days = load()
if listing is not None:
for d, culture, name, _ in upcoming(days, today, listing):
print(f"{d.isoformat()} {d.strftime('%a')} {CULTURES[culture]:<8} {name}")
return
nearest = {}
for d, culture, name, query in upcoming(days, today, 400):
nearest.setdefault(culture, d)
if d == nearest[culture]: # every day of that culture on its nearest date
url = "https://www.google.com/search?q=" + urllib.parse.quote_plus(query)
print(f"{culture}\t{(d - today).days}\t{name}\t{url}")
if __name__ == "__main__":
main(sys.argv[1:])~/.claude/special-days.txt
# Special days for the Claude Code status line (row 4). One day per line:
#
# culture | when | name shown | Google search (optional)
#
# culture FA (Persian) or SE (Swedish). Lines starting with # are ignored.
# when MM-DD a fixed date, e.g. 12-13
# easter+N / easter-N N days after / before Easter Sunday
# fa:MM-DD a date in the Persian (Solar Hijri) calendar, e.g. fa:07-20 = 20 Mehr
# (months: 1 Farvardin, 2 Ordibehesht, 3 Khordad, 4 Tir, 5 Mordad,
# 6 Shahrivar, 7 Mehr, 8 Aban, 9 Azar, 10 Dey, 11 Bahman, 12 Esfand)
# dow:MM-DD the first weekday dow (mon..sun) on or after MM-DD, e.g. fri:06-19
# N-dow:MM the Nth (1-4, or "last") weekday of month MM, e.g. 2-sun:11
# chaharshanbe-suri the Tuesday evening before the last Wednesday of the Persian year
# ...@YYYY/N only in the years YYYY, YYYY+N, YYYY+2N, ... (e.g. election years)
# search defaults to the name without the part in brackets, plus "Persian" or "Sweden".
#
# Sources
# Swedish public holidays: Lag (1989:253) om allmänna helgdagar.
# Swedish flag days: Förordning (1982:270) om allmänna flaggdagar, as amended by SFS 2023:516
# (the European Parliament election day is a flag day too, but its date can't be calculated).
# Swedish food days and traditions: Kanelbullens dag, Våffeldagen, Kladdkakans dag (since 2008),
# Chokladbollens dag, Pepparkakans dag (since 1996), Surströmmingspremiären, Mårtensafton.
# Persian commemoration days: the official Iranian (Solar Hijri) calendar for 1405, as listed at
# bahesab.ir/time/1405. Religious (lunar-calendar) and political days are left out.
# Persian festivals that are not in the official calendar use their most common traditional dates:
# Tirgan 13 Tir (some calendars: 10 Tir), Mehregan 16 Mehr (some: 10 Mehr), Sadeh 10 Bahman,
# Sepandarmazgan 29 Bahman (Zoroastrian calendar: 5 Esfand). Cyrus the Great Day is unofficial.
# ---------------------------------------------------------------- Persian
FA | fa:01-01 | Nowruz (Persian New Year) | Nowruz
FA | fa:01-13 | Sizdah Bedar (Nature Day) | Sizdah Bedar
FA | fa:01-25 | Attar Day (poet) | Attar of Nishapur
FA | fa:02-01 | Saadi Day (poet) | Saadi Shirazi
FA | fa:02-03 | Sheikh Bahai Day (scholar, architect) | Sheikh Baha'i
FA | fa:02-10 | Persian Gulf Day | Persian Gulf National Day
FA | fa:02-25 | Ferdowsi Day (Shahnameh, Persian language) | Ferdowsi Shahnameh
FA | fa:02-28 | Khayyam Day (poet, mathematician) | Omar Khayyam
FA | fa:03-01 | Mulla Sadra Day (philosopher) | Mulla Sadra
FA | fa:03-26 | Mohtasham Kashani Day (poet) | Mohtasham Kashani
FA | fa:04-10 | Saib Tabrizi Day (poet) | Saib Tabrizi
FA | fa:04-13 | Tirgan (water festival) | Tirgan festival
FA | fa:04-22 | Khwarizmi Day (mathematician) | Muhammad ibn Musa al-Khwarizmi
FA | fa:05-08 | Suhrawardi Day (philosopher) | Shahab al-Din Suhrawardi
FA | fa:06-01 | Avicenna Day (physician) | Avicenna Ibn Sina
FA | fa:06-05 | Razi Day (physician, chemist) | Abu Bakr al-Razi
FA | fa:06-13 | Biruni Day (scientist) | Al-Biruni
FA | fa:06-27 | Shahriar Day (poet) | Shahriar poet
FA | fa:07-07 | Shams Tabrizi Day (mystic) | Shams Tabrizi
FA | fa:07-08 | Rumi Day (poet) | Rumi Molana
FA | fa:07-16 | Mehregan (autumn festival) | Mehregan festival
FA | fa:07-20 | Hafez Day (poet) | Hafez Shirazi
FA | fa:08-01 | Beyhaqi Day (historian) | Abolfazl Beyhaqi
FA | fa:08-07 | Cyrus the Great Day | Cyrus the Great Day
FA | fa:08-30 | Farabi Day (philosopher) | Al-Farabi
FA | fa:09-30 | Yalda Night (longest night) | Yalda Night
FA | fa:10-04 | Rudaki Day (poet) | Rudaki
FA | fa:10-17 | Khwaju Kermani Day (poet) | Khwaju Kermani
FA | fa:11-01 | Khaqani Day (poet) | Khaqani
FA | fa:11-06 | Safi al-Din Urmavi Day (musician) | Safi al-Din al-Urmawi
FA | fa:11-10 | Sadeh (fire festival) | Sadeh festival
FA | fa:11-29 | Sepandarmazgan (Persian day of love) | Sepandarmazgan
FA | fa:12-05 | Nasir al-Din Tusi Day (scientist) | Nasir al-Din al-Tusi
FA | fa:12-08 | Hadi Sabzavari Day (philosopher) | Hadi Sabzavari
FA | fa:12-21 | Nezami Day (poet) | Nizami Ganjavi
FA | fa:12-25 | Parvin E'tesami Day (poet) | Parvin E'tesami
FA | chaharshanbe-suri | Chaharshanbe Suri (fire festival, Tuesday evening) | Chaharshanbe Suri
# ---------------------------------------------------------------- Swedish
SE | 01-01 | Nyårsdagen (New Year's Day) | Nyårsdagen Sverige
SE | 01-06 | Trettondedag jul (Epiphany) | Trettondedag jul
SE | 01-13 | Tjugondedag Knut (end of Christmas) | Tjugondedag Knut
SE | 01-28 | Kungens namnsdag (the King's name day) | Konungens namnsdag 28 januari
SE | 02-06 | Samernas nationaldag (Sami National Day) | Sami National Day
SE | 02-14 | Alla hjärtans dag (Valentine's Day) | Alla hjärtans dag Sverige
SE | easter-47 | Fettisdagen (Semla Day) | Fettisdagen semla
SE | 03-12 | Kronprinsessans namnsdag (Crown Princess's name day) | Kronprinsessan Victoria namnsdag
SE | 03-25 | Våffeldagen (Waffle Day) | Våffeldagen
SE | easter-2 | Långfredagen (Good Friday) | Långfredagen Sverige
SE | easter-1 | Påskafton (Easter Eve) | Påskafton Sverige
SE | easter+0 | Påskdagen (Easter Sunday) | Påskdagen Sverige
SE | easter+1 | Annandag påsk (Easter Monday) | Annandag påsk
SE | 04-30 | Valborgsmässoafton (Walpurgis Night) | Valborgsmässoafton
SE | 04-30 | Kungens födelsedag (the King's birthday) | Kung Carl XVI Gustaf födelsedag
SE | 05-01 | Första maj (May Day) | Första maj Sverige
SE | 05-11 | Chokladbollens dag (Chocolate Ball Day) | Chokladbollens dag
SE | easter+39 | Kristi himmelsfärdsdag (Ascension Day) | Kristi himmelsfärdsdag
SE | last-sun:05 | Mors dag (Mother's Day) | Mors dag Sverige
SE | 05-29 | Veterandagen (Veterans' Day) | Veterandagen Sverige
SE | easter+49 | Pingstdagen (Whit Sunday) | Pingstdagen Sverige
SE | 06-06 | Sveriges nationaldag (National Day) | Sveriges nationaldag
SE | fri:06-19 | Midsommarafton (Midsummer Eve) | Midsommarafton
SE | sat:06-20 | Midsommardagen (Midsummer Day) | Midsommardagen
SE | 07-14 | Kronprinsessans födelsedag (Crown Princess's birthday) | Kronprinsessan Victoria födelsedag
SE | 08-08 | Drottningens namnsdag (the Queen's name day) | Drottning Silvia namnsdag
SE | 3-thu:08 | Surströmmingspremiären (fermented herring season) | Surströmmingspremiären
SE | 2-sun:09@2026/4 | Valdagen (general election day) | Riksdagsval Sverige
SE | 10-04 | Kanelbullens dag (Cinnamon Bun Day) | Kanelbullens dag
SE | 10-24 | FN-dagen (UN Day) | FN-dagen
SE | sat:10-31 | Alla helgons dag (All Saints' Day) | Alla helgons dag
SE | 11-06 | Gustav Adolfsdagen (Gustavus Adolphus Day) | Gustav Adolfsdagen
SE | 11-07 | Kladdkakans dag (Kladdkaka Day) | Kladdkakans dag
SE | 11-10 | Mårtensafton (St Martin's Eve) | Mårtensafton
SE | 2-sun:11 | Fars dag (Father's Day) | Fars dag Sverige
SE | sun:11-27 | Första advent (First Sunday of Advent) | Första advent
SE | 12-09 | Pepparkakans dag (Gingerbread Day) | Pepparkakans dag
SE | 12-10 | Nobeldagen (Nobel Day) | Nobeldagen
SE | 12-13 | Lucia | Lucia Sverige
SE | 12-23 | Drottningens födelsedag (the Queen's birthday) | Drottning Silvia födelsedag
SE | 12-24 | Julafton (Christmas Eve) | Julafton Sverige
SE | 12-25 | Juldagen (Christmas Day) | Juldagen Sverige
SE | 12-26 | Annandag jul (Boxing Day) | Annandag jul
SE | 12-31 | Nyårsafton (New Year's Eve) | Nyårsafton Sverige~/.claude/eco-comparisons.txt
# Everyday CO₂ comparisons, shared by the status line (row 3) and eco-report.sh. One per line:
#
# grams of CO₂ per item | one | many | source
#
# The status line picks the item whose count is nearest to 3 (e.g. "≈ 2 bananas"); the report
# lists them all. Things that run on electricity use the same 0.30 kg of CO₂ per kWh as the AI
# estimate in statusline-command.sh (grams = kWh × 300), so they compare like with like.
0.2 | Google search | Google searches | Google (2009): about 0.2 g of CO₂ per search
5.7 | phone charge | phone charges | US EPA: 0.019 kWh per smartphone charge, × 300 g/kWh
23 | hour of video streaming | hours of video streaming | IEA (2020): 0.077 kWh per hour of streaming in 2019, × 300 g/kWh
33 | kettle of water boiled | kettles of water boiled | physics: heating 1 litre from 15 to 100 °C takes about 0.11 kWh with losses, × 300 g/kWh
100 | banana | bananas | Our World in Data (Poore & Nemecek 2018): 0.86 kg CO₂e per kg of bananas, × a 120 g banana
160 | km by petrol car | km by petrol car | UK government, DESNZ 2023 factors: about 0.16 kg CO₂e per car-km (0.17 kg per passenger-mile at 1.5 people per car)~/.claude/eco-report.sh (optional)
Run it with bash ~/.claude/eco-report.sh, or bash ~/.claude/eco-report.sh 30 for the last 30 days. Inside Claude Code, type ! bash ~/.claude/eco-report.sh.
#!/bin/sh
# Eco impact summary for Claude Code sessions — the detail behind the status line's CO₂ row.
#
# Reads the LIVE per-session ledgers the status line writes (~/.claude/eco-sessions/*.json,
# kept for 32 days, each with a per-day breakdown) and the all-time totals it keeps in
# ~/.claude/eco-alltime.json, so this report always matches the status line.
#
# Usage: bash ~/.claude/eco-report.sh [days] (days to look back, today included; default: 7)
# Force C numeric locale. jq always emits "." decimals, but on a comma-decimal
# locale (e.g. en_DK) awk's atof reads "93.02" as 93 (stops at the ".") AND its
# printf prints "142,00" — so totals get truncated and look wrong. C locale fixes
# both. (Same reason the status line exports this.)
export LC_NUMERIC=C
ECO_DIR="${HOME}/.claude/eco-sessions"
ALLTIME="${HOME}/.claude/eco-alltime.json"
DAYS=${1:-7}
case "$DAYS" in ''|*[!0-9]*) DAYS=7 ;; esac
[ "$DAYS" -ge 1 ] || DAYS=1
if [ ! -d "$ECO_DIR" ]; then
echo "No eco data found at $ECO_DIR — start a Claude Code session first."
exit 1
fi
# First day of the window. GNU/uutils date first, then BSD (macOS) date as a fallback.
back=$((DAYS - 1))
cutoff=$(date -d "-${back} days" +%Y-%m-%d 2>/dev/null || date -v-"${back}"d +%Y-%m-%d 2>/dev/null)
echo ""
echo "====================================="
echo " Claude Code Eco Impact Report"
echo " Last ${DAYS} days (since ${cutoff:-the beginning})"
[ "$DAYS" -gt 32 ] && echo " (per-day history only goes back 32 days)"
echo "====================================="
# One jq call streams "date<TAB>co2<TAB>h2o<TAB>ledger" for every day with usage. Ledgers
# from before per-day tracking count on their last-active date. Files are read as raw lines
# and parsed with fromjson?, so a corrupt or half-written file is skipped, not fatal.
# co2 is stored in grams, h2o in millilitres.
set -- "$ECO_DIR"/*.json
[ -e "$1" ] || set --
jq -nRr '
inputs | fromjson? | select(type == "object")
| ((.days | if type == "object" then . else null end)
// (if (.date | type) == "string" then {(.date): {co2: .co2, h2o: .h2o}} else {} end))
| to_entries[] | select((.value.co2 // 0) > 0)
| [.key, .value.co2, (.value.h2o // 0), input_filename] | @tsv
' "$@" </dev/null 2>/dev/null | awk -F'\t' -v cutoff="$cutoff" -v cfile="${HOME}/.claude/eco-comparisons.txt" '
$1 != "" && (cutoff == "" || $1 >= cutoff) {
co2 += $2; water += $3
if (!($4 in seen)) { seen[$4] = 1; sessions++ }
if (!($1 in dco2)) days++
dco2[$1] += $2
if (!(($1, $4) in dseen)) { dseen[$1, $4] = 1; dn[$1]++ }
}
END {
print ""
if (sessions == 0) { print " No sessions found in this period."; exit }
printf " Sessions: %d across %d days\n", sessions, days
if (co2 >= 1000) printf " CO2 emitted: ~%.2f kg\n", co2 / 1000
else printf " CO2 emitted: ~%.2f g\n", co2
if (water >= 1000) printf " Water used: ~%.2f L\n", water / 1000
else printf " Water used: ~%.1f ml\n", water
print ""
print " --- Equivalents (same figures as the status line) ---"
# ~/.claude/eco-comparisons.txt: grams of CO2 per item | one | many | source
while ((getline line < cfile) > 0) {
if (line ~ /^[ \t]*(#|$)/) continue
split(line, f, "|"); g = f[1] + 0; many = f[3]; gsub(/^[ \t]+|[ \t]+$/, "", many)
if (g <= 0) continue
c = co2 / g
if (c < 10) printf " ≈ %.1f %s\n", c, many
else printf " ≈ %d %s\n", c + 0.5, many
}
close(cfile)
printf " ≈ %d glasses of water (250 ml each)\n", water / 250 + 0.5
print ""
print " --- Daily breakdown ---"
n = 0
for (d in dco2) sorted[++n] = d
for (i = 1; i <= n; i++) for (j = i + 1; j <= n; j++) if (sorted[i] > sorted[j]) { t = sorted[i]; sorted[i] = sorted[j]; sorted[j] = t }
for (i = 1; i <= n; i++) {
d = sorted[i]
printf " %s %d session(s) %.2fg CO2\n", d, dn[d], dco2[d]
}
}
'
# Footer: lifetime totals straight from the ledger the status line keeps.
echo ""
echo "====================================="
jq -r '[(.co2 // 0), (.h2o // 0), (.sessions // 0)] | @tsv' "$ALLTIME" 2>/dev/null | awk -F'\t' '
{ if ($1 >= 1000) c = sprintf("%.2f kg", $1 / 1000); else c = sprintf("%.1f g", $1)
if ($2 >= 1000) w = sprintf("%.2f L", $2 / 1000); else w = sprintf("%.0f ml", $2)
printf " All-time: %s CO2 · %s H2O (%d sessions)\n", c, w, $3; found = 1 }
END { if (!found) print " All-time: no data yet" }'
echo " Data: ~/.claude/eco-sessions/*.json (rough estimates, see statusline-command.sh)"
echo "====================================="
echo ""2. Check the files
Some editors don't end a file with a newline, and a bash while read loop skips a last line that has none. In eco-comparisons.txt that would quietly drop the last comparison. This adds the missing newline wherever it's needed:
cd ~/.claude && for f in statusline-command.sh special-days.py special-days.txt eco-comparisons.txt eco-report.sh; do [ -f "$f" ] && [ -n "$(tail -c 1 "$f")" ] && echo >> "$f"; doneThen compare the checksums (on macOS, use shasum -a 256 instead of sha256sum):
cd ~/.claude && sha256sum statusline-command.sh special-days.py special-days.txt eco-comparisons.txt eco-report.shIf you get these, your files are identical to the ones in this post:
8e767ade594b02aefd42ca4aa2c0c979ab3f79275c0c30d1db0f8520ee851e76 statusline-command.sh
4e2925e27fc52d988a0f9c196a349f5adbf9c61db276e1c168ad71a42da06412 special-days.py
5c6385e726566095d08fb3b2fe6d8f2b87801a2a70f90a4618f63fecba36192c special-days.txt
70a6fdcdb6ec1bea102fdd2e940f373757127df5b484a97eee80c512773c7368 eco-comparisons.txt
aef2bb027f598e2e13ebaffd660f40fa374f8440af943b617077fb141baa099c eco-report.sh3. Turn it on
Add the statusLine setting to ~/.claude/settings.json. If the file already has other settings, add it next to them:
{
"statusLine": {
"type": "command",
"command": "bash ~/.claude/statusline-command.sh",
"refreshInterval": 5
}
}Claude Code runs the command in a shell, so ~ works. It re-runs the script when something happens in the session, like a new message or /compact, and refreshInterval makes it also re-run every 5 seconds, so the reset countdowns, the weather and the limits shared from other terminals stay current while a session is idle. Claude Code picks up the change as soon as you save the file.
4. Try it
You can also run the script by hand with a sample input:
jq -n --arg dir "$PWD" '{workspace: {current_dir: $dir}, model: {id: "claude-opus-5-5", display_name: "Opus 5.5"}, effort: {level: "high"}, context_window: {used_percentage: 25, context_window_size: 200000}, cost: {total_cost_usd: 0.5}}' | bash ~/.claude/statusline-command.shIt should print four rows. The sample has no session_id, so the script doesn't save it, and the today, month and all-time totals stay as they are. The first run also starts the weather download in the background, so the weather shows up from the next run on.
Make it yours
The settings are at the top of statusline-command.sh:
place_name,place_latandplace_lon: where the weather and the sun times are for. The copy in this post uses Oslo as an example, so set them to your own place. SMHI's forecast covers roughly the Nordic countries and the Baltics. For a place outside that area SMHI has no forecast and the weather is left out, but the sun times don't depend on SMHI.gauge_mode=used: bars that fill up as you use things, instead of draining.grad_satandgrad_light: how vivid and how light the gradient colors are.
The special days are in special-days.txt, one per line, and the header of that file explains the date formats, such as fixed dates, days counted from Easter, dates in the Persian calendar, rules like "the first Friday on or after 19 June" or "the second Sunday of November", and days that only come every few years. The two cultures, FA and SE, are built into both special-days.py and statusline-command.sh, so adding a third one means changing both.
The everyday comparisons are in eco-comparisons.txt, one per line: grams of CO₂ per item, the item's name in singular and plural, and its source.
That's all of it: four files, an optional report and one setting.