Skip to content

Instantly share code, notes, and snippets.

@saitonakamura
Last active July 28, 2026 08: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, and model state.

[██████····] 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.

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.

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

This is not a documented contract. These names were read out of the CLI binary (2.1.218) 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.
# 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
}
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
}
if ($parts.Count) { $parts -join "$dim · $reset" }
#!/usr/bin/env sh
# Claude Code status line: context window usage, cwd, model state.
# 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;
# 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 |
[$ctx, $pathpart, $modelpart]
| 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