Skip to content

Instantly share code, notes, and snippets.

@saitonakamura
Last active August 18, 2026 18:22
Show Gist options
  • Select an option

  • Save saitonakamura/d45012ab163e4beb1b4818b4ca7043a8 to your computer and use it in GitHub Desktop.

Select an option

Save saitonakamura/d45012ab163e4beb1b4818b4ca7043a8 to your computer and use it in GitHub Desktop.
Claude Code status line: context window bar, cwd, model state (POSIX sh+jq and PowerShell)

Claude Code status line

One line at the bottom of the Claude Code TUI: context-window usage, cwd, model state, and — only when it is about to bite — a Claude.ai plan limit.

[██████····] 118k/200k 59%  ·  dotfiles/.config/claude  ·  Opus 5 1M/high fast
  • context window — a 10-cell bar plus used/size and a percentage, green under 60%, yellow from 60%, red from 80%.
  • cwd — relative to the project root when you're inside it (shown as <project-leaf>/rest), otherwise ~-relative, otherwise absolute.
  • model — display name with (1M context) collapsed to 1M, then the reasoning effort, and the fast / no-think toggles when they're on.
  • plan limits — the 5-hour and 7-day subscription windows. Usually absent: a window shows up only when the current pace would exhaust it before it resets. See below. Never shown at all for API-key users, or before the session's first API response.

Two implementations of the same output: statusline.sh (POSIX sh + jq) for macOS and Linux/WSL, statusline.ps1 (PowerShell) for Windows. Pick the one matching your box — they don't depend on each other.

The projection

This segment is an alarm, not a gauge. A percentage on its own was never worth the space — 90% with ten minutes left on the window is fine, 90% with four hours left is not — and what actually costs you is neither number but the gap between them. So nothing is shown while the pace is survivable, and when it is not:

[██████····] 118k/200k 59%  ·  dotfiles  ·  Opus 5 1M/high  ·  5h 71% 🚧 1h25m ⏳ 4m
  • 🚧 — the roadblock you are heading for: how long the budget lasts at the current pace, before the cap stops you.
  • ⏳ — how long you would then sit waiting, from hitting the cap to the reset. It trails because the pair reads in the order it happens, but it is the number that hurts and the one the colour encodes: green under 20 minutes, yellow under 90, red past that. Absolute rather than a fraction of the window — an hour of waiting is an hour whichever window ran out, which is why the 7-day window is almost always red once it trips at all.

Both glyphs come from the emoji font rather than the terminal font, so they are two cells wide and the space after each is load-bearing — without it the glyph overlaps the following digit. Verified by screenshot under PragmataPro + Noto in konsole; substitute plain words if your terminal disagrees.

The pace is the average since the window opened. Two things follow:

  • The window's start is derived, not given. The payload carries only resets_at, so the start is resets_at minus a fixed length — 5h for five_hour (hour-aligned; verified against a real payload, a reset at 05:00 with 3h02m left puts the start at 00:00), 7 days for seven_day. If the time to reset ever exceeds that length the assumption has broken, and nothing is shown rather than something guessed at.
  • It says nothing early on. In the first 5% of a window two minutes of typing extrapolates to a catastrophe, so no window is evaluated until then.

Being an average, it lags: a burst at the start of a window keeps weighing on the estimate long after you have stopped, and the alarm can hang around after you have already slowed down. Reading a recent rate instead would mean keeping samples between renders, which neither script does.

Install

Save the script somewhere stable and make it executable:

chmod +x /path/to/statusline.sh

Then register it in ~/.claude/settings.json:

{
  "statusLine": {
    "type": "command",
    "command": "/absolute/path/to/statusline.sh"
  }
}

The path must be absolute. The command string is handed to a shell whose identity isn't guaranteed, so ~ and $HOME may not expand. On Windows the command is pwsh -NoProfile -File C:\absolute\path\to\statusline.ps1.

If you run more than one Claude Code profile (a second CLAUDE_CONFIG_DIR, e.g. a separate work profile), each profile's settings.json needs its own registration.

The payload

Claude Code pipes a JSON status payload to the script on stdin. The fields these scripts read:

field used for
context_window.context_window_size bar denominator; everything context-related is skipped when absent or 0
context_window.total_input_tokens bar numerator
context_window.used_percentage percentage — null until the first API response of the session, so both scripts fall back to computing it from the two counts
cwd current directory
workspace.project_dir project root, for the relative path
model.display_name model name
effort.level reasoning effort
fast_mode the fast toggle
thinking.enabled rendered as no-think when explicitly false
rate_limits.five_hour.{used_percentage,resets_at} the 5h window
rate_limits.seven_day.{used_percentage,resets_at} the 7d window

This is not a documented contract. These names were read out of the CLI binary (2.1.218; rate_limits re-checked against 2.1.231) rather than from any published schema, so they can change without notice. Both scripts are written to degrade rather than break: a field that's missing drops its segment, and unparseable input prints nothing and exits 0. Still worth re-checking against a real payload after a CLI upgrade — dump one with a temporary statusLine command of cat > /tmp/payload.json.

Notes on the implementations

statusline.sh does all rendering inside jq, so there's no shell arithmetic or word-splitting to get wrong; the shell part is a single jq -r invocation. It needs jq on PATH.

statusline.ps1 forces [Console]::OutputEncoding to UTF-8 without a BOM. When stdout is a pipe rather than a console, PowerShell defaults to the OEM code page (CP437 on the box this was written on), which encodes the bar's █ as a bare 0xDB — not valid UTF-8, so the whole line renders as tofu.

Path handling accepts either separator on both scripts, so a POSIX-shaped payload works under a Windows-installed pwsh and vice versa.

License

Public domain / CC0. Take it, change it, no attribution needed.

#!/usr/bin/env pwsh
# Claude Code status line: context window usage, cwd, model state, plan limits.
# Reads the status JSON on stdin and prints a single line for the bottom of the TUI.
$ErrorActionPreference = 'Stop'
# Claude Code reads this script's stdout as UTF-8, but [Console]::OutputEncoding
# defaults to the OEM code page when stdout is a pipe rather than a console — on
# this box CP437, which encodes the bar's U+2588 as a bare 0xDB and U+00B7 as
# 0xFA. Both are invalid UTF-8 lead bytes, so the status line renders as tofu.
# UTF8Encoding($false): no BOM, or the preamble lands mid-line.
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
$e = [char]27
$dim = "$e[2m"; $reset = "$e[0m"
$cyan = "$e[36m"; $yellow = "$e[33m"
function Format-Tokens([double] $n) {
if ($n -ge 1000000) { '{0:0.##}M' -f ($n / 1000000) }
elseif ($n -ge 1000) { '{0:0}k' -f ($n / 1000) }
else { '{0:0}' -f $n }
}
# Either separator: the payload uses the box's native one, and a POSIX pwsh
# would silently never match a hardcoded '\'.
function Test-Under([string] $path, [string] $root) {
$ic = [StringComparison]::OrdinalIgnoreCase
$path.StartsWith("$root\", $ic) -or $path.StartsWith("$root/", $ic)
}
# cwd relative to the project root, else relative to home
function Format-Path([string] $cwd, [string] $projectDir) {
if (-not $cwd) { return $null }
$cwd = $cwd.TrimEnd('\', '/')
$ic = [StringComparison]::OrdinalIgnoreCase
if ($projectDir) {
$root = $projectDir.TrimEnd('\', '/')
$leaf = Split-Path $root -Leaf
if ($cwd.Equals($root, $ic)) { return $leaf }
# the remainder keeps whatever separator the payload used
if (Test-Under $cwd $root) { return $leaf + $cwd.Substring($root.Length) }
}
$h = $HOME.TrimEnd('\', '/')
if ($cwd.Equals($h, $ic)) { return '~' }
if (Test-Under $cwd $h) { return '~' + $cwd.Substring($h.Length) }
return $cwd
}
# Coarse duration: "3d4h", "1h07m", "42m". Only ever printed for things minutes
# away or more, a minute of drift is fine.
function Format-Duration([double] $seconds) {
$s = [int] [math]::Floor($seconds)
if ($s -le 0) { return 'now' }
if ($s -lt 60) { return '<1m' }
if ($s -ge 86400) { return '{0}d{1}h' -f [math]::Floor($s / 86400), [math]::Floor(($s % 86400) / 3600) }
if ($s -ge 3600) { return '{0}h{1:00}m' -f [math]::Floor($s / 3600), [math]::Floor(($s % 3600) / 60) }
return '{0}m' -f [math]::Floor($s / 60)
}
# One claude.ai plan-limit window — an alarm, not a gauge. It returns $null
# unless the current pace would exhaust the window before it resets, so the usual
# line carries nothing here at all. A percentage on its own was never worth the
# space (90% with ten minutes left is fine, 90% with four hours left is not), and
# what costs you is neither number but the gap between them.
#
# So the colour is the size of that gap — how long you would sit blocked, waiting
# for the reset. Under 20 minutes is a coffee (green), under 90 a dent in the day
# (yellow), past that the day is gone (red). Absolute rather than a fraction of
# the window: an hour of waiting is an hour whichever window ran out, which is
# why the 7-day one is almost always red once it trips at all.
#
# The pace is the average since the window opened. The payload only gives the end
# of a window, so its start is derived from a fixed $length: five_hour is 5h,
# hour-aligned, and seven_day 7d. A $tleft larger than $length means that
# assumption broke, so nothing is shown rather than something guessed at — same
# for the first 5% of a window, where two minutes of typing extrapolates to a
# catastrophe.
#
# $dry — seconds until the cap at this pace: the runway still ahead of you.
# Leads, behind the barrier you are heading for, so the pair reads in
# the order it happens.
# $wait — seconds spent blocked between hitting the cap and the reset, behind an
# hourglass. Trails, but it is the number that hurts and the one the
# colour encodes.
#
# Both glyphs come from the emoji font rather than the terminal font and are two
# cells wide; the space after each one is load-bearing, or the glyph sits on top
# of the following digit. The barrier is non-BMP, so this file must be read as
# UTF-8 — true for `pwsh`, not for Windows PowerShell 5.1, which is another
# reason the registration in windows.md calls `pwsh` explicitly.
function Format-Limit([string] $label, [double] $length, $window) {
if (-not $window -or $null -eq $window.used_percentage -or $null -eq $window.resets_at) {
return $null
}
$u = [double] $window.used_percentage
$tleft = [double] $window.resets_at - [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$elapsed = $length - $tleft
if ($u -le 0 -or $tleft -le 0 -or $tleft -gt $length -or $elapsed -lt $length * 0.05) {
return $null
}
$dry = $elapsed * (100 - $u) / $u
if ($dry -ge $tleft) { return $null }
$wait = $tleft - $dry
$color = if ($wait -lt 1200) { "$e[32m" } elseif ($wait -lt 5400) { "$e[33m" } else { "$e[31m" }
return "$dim$label $reset$color$([math]::Round($u))%$reset" +
" 🚧 $color$(Format-Duration $dry)$reset" +
" ⏳ $color$(Format-Duration $wait)$reset"
}
try {
$payload = [Console]::In.ReadToEnd() | ConvertFrom-Json
} catch {
exit 0
}
$parts = @()
# context window
$cw = $payload.context_window
if ($cw -and $cw.context_window_size) {
$size = [double] $cw.context_window_size
$used = [double] ($cw.total_input_tokens ?? 0)
# used_percentage is null until the first API response lands this session
$pct = if ($null -ne $cw.used_percentage) {
[int] $cw.used_percentage
} else {
[int] [math]::Min(100, [math]::Round($used / $size * 100))
}
$color = if ($pct -ge 80) { "$e[31m" } elseif ($pct -ge 60) { "$e[33m" } else { "$e[32m" }
$cells = 10
$filled = [math]::Min($cells, [math]::Ceiling($pct / 100 * $cells))
$bar = ('█' * $filled) + ('·' * ($cells - $filled))
$parts += "$dim[$reset$color$bar$reset$dim]$reset $(Format-Tokens $used)$dim/$(Format-Tokens $size)$reset $color$pct%$reset"
}
# cwd
$path = Format-Path $payload.cwd $payload.workspace.project_dir
if ($path) { $parts += "$cyan$path$reset" }
# model state: name, effort, and the toggles worth noticing
$model = $payload.model.display_name
if ($model) {
$model = $model -replace '\s*\((\d+[MK]) context\)', ' $1'
$state = "$model"
if ($payload.effort.level) { $state += "$dim/$($payload.effort.level)$reset" }
if ($payload.fast_mode) { $state += " $yellow" + 'fast' + $reset }
if ($payload.thinking -and -not $payload.thinking.enabled) { $state += " $dim" + 'no-think' + $reset }
$parts += $state
}
# claude.ai plan limits: the 5-hour and 7-day windows. Subscribers only, and
# absent from the payload until the first API response of the session.
$rl = $payload.rate_limits
if ($rl) {
# the outer @() keeps a single surviving window an array, not a bare string
$windows = @(@((Format-Limit '5h' 18000 $rl.five_hour),
(Format-Limit '7d' 604800 $rl.seven_day)) |
Where-Object { $_ })
if ($windows.Count) { $parts += ($windows -join "$dim · $reset") }
}
if ($parts.Count) { $parts -join "$dim · $reset" }
#!/usr/bin/env sh
# Claude Code status line: context window usage, cwd, model state, plan limits.
# POSIX counterpart of statusline.ps1 for macOS and WSL. Same stdin payload
# contract — the field names are read out of the CLI binary, not documented, so
# re-verify against a real payload per box. Rendering is done entirely in jq so
# there is no fragile shell arithmetic or word-splitting.
# See .issues/claude-statusline/001-cross-platform.md.
jq -r '
def rtrim: sub("[/\\\\]+$"; "");
def rep($s; $n): [range(0; $n)] | map($s) | join("");
def fmt($n):
if $n >= 1000000 then (($n/1000000*100|round)/100|tostring) + "M"
elif $n >= 1000 then (($n/1000)|round|tostring) + "k"
else ($n|round|tostring) end;
def pad2($n): ("0" + ($n|tostring))[-2:];
# Coarse duration: "3d4h", "1h07m", "42m". Only ever printed for things minutes
# away or more, a minute of drift is fine.
def dur($s0): ($s0|floor) as $s
| if $s <= 0 then "now"
elif $s < 60 then "<1m"
elif $s >= 86400 then (($s/86400)|floor|tostring) + "d" + ((($s%86400)/3600)|floor|tostring) + "h"
elif $s >= 3600 then (($s/3600)|floor|tostring) + "h" + pad2((($s%3600)/60)|floor) + "m"
else (($s/60)|floor|tostring) + "m" end;
# Either separator: the payload uses the box native one; matching only "/"
# would silently miss a Windows-shaped root, and vice versa.
def under($p; $r): ($p|ascii_downcase) as $pl | ($r|ascii_downcase) as $rl
| ($pl|startswith($rl + "/")) or ($pl|startswith($rl + "\\"));
"" as $dim | "" as $rst |
"" as $cyan| "" as $yel |
"" as $red | "" as $grn |
. as $p |
# ---- context window ----
( ($p.context_window // {}) as $cw
| if ($cw.context_window_size // 0) > 0 then
($cw.context_window_size|tonumber) as $size
| ($cw.total_input_tokens // 0 | tonumber) as $used
| ( if $cw.used_percentage != null then $cw.used_percentage
else ([100, ($used/$size*100|round)] | min) end | round ) as $pct
| ( if $pct >= 80 then $red elif $pct >= 60 then $yel else $grn end ) as $bc
| ([10, ($pct/100*10|ceil)] | min) as $filled
| (rep("█"; $filled) + rep("·"; 10 - $filled)) as $bar
| ( $dim + "[" + $rst + $bc + $bar + $rst + $dim + "]" + $rst
+ " " + fmt($used) + $dim + "/" + fmt($size) + $rst
+ " " + $bc + ($pct|tostring) + "%" + $rst )
else null end ) as $ctx |
# ---- cwd relative to project root, else home ----
( ($p.cwd // "") as $cwd0
| if $cwd0 == "" then null
else ($cwd0|rtrim) as $c
| ( ($p.workspace.project_dir // "") as $proj
| if $proj != "" then ($proj|rtrim) as $root
| ($root | [splits("[/\\\\]")] | last) as $leaf
| if ($c|ascii_downcase) == ($root|ascii_downcase) then $leaf
elif under($c; $root) then $leaf + $c[($root|length):]
else null end
else null end ) as $viaproj
| if $viaproj != null then $viaproj
else (env.HOME // "" | rtrim) as $h
| if $h != "" and ($c|ascii_downcase) == ($h|ascii_downcase) then "~"
elif $h != "" and under($c; $h) then "~" + $c[($h|length):]
else $c end
end
end ) as $pathraw |
( if ($pathraw // "") == "" then null else $cyan + $pathraw + $rst end ) as $pathpart |
# ---- model name, effort, toggles worth noticing ----
( ($p.model.display_name // "") as $mname
| if $mname == "" then null
else ($mname | gsub("\\s*\\((?<d>[0-9]+[MK]) context\\)"; " \(.d)"))
+ (if ($p.effort.level // "") != "" then $dim + "/" + $p.effort.level + $rst else "" end)
+ (if $p.fast_mode then " " + $yel + "fast" + $rst else "" end)
+ (if ($p.thinking != null) and (($p.thinking.enabled) | not) then " " + $dim + "no-think" + $rst else "" end)
end ) as $modelpart |
# ---- claude.ai plan limits: the 5-hour and 7-day windows ----
# Subscribers only, and absent until the first API response of the session.
#
# An alarm, not a gauge: a window shows up **only** when the current pace would
# exhaust it before it resets, so the usual line carries nothing here at all.
# A percentage on its own was never worth the space — 90% with ten minutes left
# on the window is fine, 90% with four hours left is not — and what actually
# costs you is neither number but the gap between them.
#
# So the colour is the size of that gap: how long you would sit blocked,
# waiting for the reset. Under 20 minutes is a coffee (green), under 90 a dent
# in the day (yellow), past that the day is gone (red). Thresholds are absolute
# rather than a fraction of the window, because an hour of waiting is an hour
# whichever window ran out — which is why the 7-day one, once it trips at all,
# is almost always red.
#
# The pace is the average since the window opened. The payload only gives the
# end of a window, so its start is derived from a fixed length ($len):
# five_hour is 5h, hour-aligned (verified against a real payload — reset at
# 05:00 with 3h02m left puts the start at 00:00), and seven_day 7d. A $tleft
# larger than $len means that assumption broke, so nothing is shown rather than
# something guessed at. Same for the first 5% of a window, where two minutes of
# typing extrapolates to a catastrophe.
#
# $dry — seconds until the cap at this pace: the runway still ahead of you.
# Leads, behind the barrier you are heading for, so the pair reads in
# the order it happens.
# $wait — seconds spent blocked between hitting the cap and the reset, behind
# an hourglass. Trails, but it is the number that hurts and the one
# the colour encodes.
#
# Both glyphs come from the emoji font rather than PragmataPro and are two
# cells wide; the space after each one is load-bearing, or the glyph sits on
# top of the following digit. Verified by screenshot on the fedora box.
def limit($lbl; $len; $w):
if ($w // null) == null or ($w.used_percentage // null) == null
or ($w.resets_at // null) == null then null
else ($w.used_percentage) as $u
| (($w.resets_at) - now | floor) as $tleft
| ($len - $tleft) as $el
| if $u <= 0 or $tleft <= 0 or $tleft > $len or $el < ($len * 0.05) then null
else ($el * (100 - $u) / $u) as $dry
| if $dry >= $tleft then null
else ($tleft - $dry) as $wait
| ( if $wait < 1200 then $grn
elif $wait < 5400 then $yel
else $red end ) as $lc
| $dim + $lbl + " " + $rst + $lc + ($u|round|tostring) + "%" + $rst
+ " 🚧 " + $lc + dur($dry) + $rst
+ " ⏳ " + $lc + dur($wait) + $rst
end
end
end;
( ($p.rate_limits // {}) as $rl
| [limit("5h"; 18000; $rl.five_hour), limit("7d"; 604800; $rl.seven_day)]
| map(select(. != null))
| if length == 0 then null else join($dim + " · " + $rst) end ) as $limits |
[$ctx, $pathpart, $modelpart, $limits]
| map(select(. != null and . != ""))
| join($dim + " · " + $rst)
' 2>/dev/null || true
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment