A two-line statusline for Claude Code showing token metrics, cost, thinking activity, context usage, and session logging.
[Ctx:69.559%/70%] [$10.63750] Opus 4.6 C:\Projects\MyApp
[cw:174 cr:109538 | in:53526 (+3) out:40005 (+58)] [think:3832ch (+1087)]
- Ctx — Context window usage as two values: a precise % computed from token counts in
current_usage, and the API's integer-roundedused_percentage. These differ slightly because the API accounts for overhead (e.g. tool definitions) not reflected incurrent_usage. Color-coded: green < 50%, yellow 50-80%, red > 80% - $ — Cumulative session cost in USD
- Model — Active model name
- Directory — Working directory (abbreviated if long)
- cw — Cache write tokens this API call
- cr — Cache read tokens this API call
- in — Cumulative input tokens, with per-call uncached input in parens
- out — Cumulative output tokens, with per-call output in parens
- think — Total thinking characters across session, with last call's thinking in parens (only shown if any thinking has occurred). Note: this is counted from the summary of thinking that appears in the transcript, not the full internal thinking. The API doesn't return the actual thinking tokens separately — they're lumped into output tokens along with the visible output.
Save this as ~/.claude/statusline-command.ps1:
# Read JSON input from stdin
$raw = [Console]::In.ReadToEnd()
$inputData = $raw | ConvertFrom-Json
# ANSI escape character
$esc = [char]0x1b
# --- Context usage (computed from token counts for precision) ---
$ctxSize = $inputData.context_window.context_window_size
if ($null -eq $ctxSize -or $ctxSize -eq 0) { $ctxSize = 200000 }
$cu = $inputData.context_window.current_usage
$ctxUsedTokens = 0
if ($null -ne $cu) {
$ctxUsedTokens += $(if ($null -ne $cu.input_tokens) { $cu.input_tokens } else { 0 })
$ctxUsedTokens += $(if ($null -ne $cu.cache_creation_input_tokens) { $cu.cache_creation_input_tokens } else { 0 })
$ctxUsedTokens += $(if ($null -ne $cu.cache_read_input_tokens) { $cu.cache_read_input_tokens } else { 0 })
$ctxUsedTokens += $(if ($null -ne $cu.output_tokens) { $cu.output_tokens } else { 0 })
}
$contextUsed = $ctxUsedTokens / $ctxSize * 100
if ($contextUsed -lt 50) { $contextColor = "32" }
elseif ($contextUsed -lt 80) { $contextColor = "33" }
else { $contextColor = "31" }
$apiCtx = $inputData.context_window.used_percentage
if ($null -eq $apiCtx) { $apiCtx = 0 }
$contextDisplay = "[Ctx:{0:F3}%/{1}%]" -f $contextUsed, $apiCtx
# --- Session totals ---
$totalIn = $inputData.context_window.total_input_tokens
$totalOut = $inputData.context_window.total_output_tokens
if ($null -eq $totalIn) { $totalIn = 0 }
if ($null -eq $totalOut) { $totalOut = 0 }
# --- Per-call values ---
$cacheCreate = 0; $cacheRead = 0; $callOutput = 0; $regularInput = 0
if ($null -ne $cu) {
if ($null -ne $cu.cache_creation_input_tokens) { $cacheCreate = $cu.cache_creation_input_tokens }
if ($null -ne $cu.cache_read_input_tokens) { $cacheRead = $cu.cache_read_input_tokens }
if ($null -ne $cu.output_tokens) { $callOutput = $cu.output_tokens }
if ($null -ne $cu.input_tokens) { $regularInput = $cu.input_tokens }
}
$tokenDisplay = "[" +
"$esc[94mcw:${cacheCreate}$esc[0m " +
"$esc[32mcr:${cacheRead}$esc[0m" +
" | " +
"in:${totalIn} $esc[90m(+${regularInput})$esc[0m " +
"out:${totalOut} $esc[90m(+${callOutput})$esc[0m" +
"]"
# --- Thinking chars from transcript ---
$thinkingDisplay = ""
$transcriptPath = $inputData.transcript_path
if ($null -ne $transcriptPath -and (Test-Path $transcriptPath)) {
$totalThinkingChars = 0
foreach ($tline in [System.IO.File]::ReadLines($transcriptPath)) {
if ($tline.Contains('"thinking"')) {
try {
$tobj = $tline | ConvertFrom-Json
$content = $tobj.message.content
if ($null -ne $content) {
foreach ($block in $content) {
if ($block.type -eq "thinking" -and $null -ne $block.thinking) {
$totalThinkingChars += $block.thinking.Length
}
}
}
} catch {}
}
}
# Most recent assistant message's thinking
$latestCallThinking = 0
$lines = [System.IO.File]::ReadAllLines($transcriptPath)
for ($i = $lines.Length - 1; $i -ge 0; $i--) {
if ($lines[$i].Contains('"type":"assistant"') -or $lines[$i].Contains('"type": "assistant"')) {
try {
$tobj = $lines[$i] | ConvertFrom-Json
$content = $tobj.message.content
if ($null -ne $content) {
foreach ($block in $content) {
if ($block.type -eq "thinking" -and $null -ne $block.thinking) {
$latestCallThinking += $block.thinking.Length
}
}
}
} catch {}
break
}
}
if ($totalThinkingChars -gt 0) {
$thinkingDisplay = "[think:${totalThinkingChars}ch $esc[33m(+${latestCallThinking})$esc[93m]"
}
}
# --- Cost ---
$totalCost = $inputData.cost.total_cost_usd
if ($null -eq $totalCost) { $totalCost = 0 }
$costDisplay = '[${0:F5}]' -f $totalCost
# --- Model ---
$modelName = $inputData.model.display_name
# --- Directory (abbreviated) ---
$cwd = $inputData.workspace.current_dir
if ($cwd.Length -gt 35) {
$parts = $cwd -split '\\'
if ($parts.Length -gt 2) {
$cwd = "...\" + $parts[-2] + "\" + $parts[-1]
}
}
# --- Build output ---
$line1 = "$esc[${contextColor}m${contextDisplay}$esc[0m "
$line1 += "$esc[38;5;203m${costDisplay}$esc[0m "
$line1 += "$esc[36m${modelName}$esc[0m "
$line1 += "$esc[35m${cwd}$esc[0m"
$line2 = "$esc[37m${tokenDisplay}$esc[0m "
if ($thinkingDisplay) { $line2 += "$esc[93m${thinkingDisplay}$esc[0m" }
Write-Host $line1
Write-Host $line2
# --- Session CSV log ---
$logDir = Join-Path $HOME ".claude" "session-logs"
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
$hostname = $env:COMPUTERNAME
$sessionId = $inputData.session_id
$logFile = Join-Path $logDir "${hostname}_${sessionId}.csv"
if (-not (Test-Path $logFile)) {
"ts,ctx,cw,cr,in,out,usd" | Out-File $logFile -Encoding utf8
}
$ts = Get-Date -Format "yyyy-MM-ddTHH:mm:ss"
$usd = "{0:F5}" -f $totalCost
$csvValues = "${ctxUsedTokens},${cacheCreate},${cacheRead},${totalIn},${totalOut},${usd}"
$lastLine = if (Test-Path $logFile) { Get-Content $logFile -Tail 1 } else { "" }
$lastValues = if ($lastLine -match ',(.+)') { $Matches[1] } else { "" }
if ($csvValues -ne $lastValues) {
"${ts},${csvValues}" | Out-File $logFile -Append -Encoding utf8
}Add the statusLine key to ~/.claude/settings.json:
{
"statusLine": {
"type": "command",
"command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File 'C:/Users/YOUR_USERNAME/.claude/statusline-command.ps1'"
}
}Replace YOUR_USERNAME with your Windows username. Use forward slashes in the path.
Note (v2.1.47+): Claude Code runs statusline commands through Git Bash, not cmd.exe. You must use
powershell.exe(with.exe) and forward-slash paths wrapped in single quotes.
The statusline appears after restart and refreshes on every API call.
The script automatically logs a CSV time series per session to ~/.claude/session-logs/.
Filename: {COMPUTERNAME}_{session-id}.csv
ts,ctx,cw,cr,in,out,usd
2026-02-16T11:00:22,60600,407,59854,21777,14880,2.90646
| Column | Description | Scope |
|---|---|---|
ts |
Local timestamp (ISO 8601) | Per-row |
ctx |
Context window tokens used | Snapshot |
cw |
Cache write tokens | Per-call |
cr |
Cache read tokens | Per-call |
in |
Total input tokens | Cumulative |
out |
Total output tokens | Cumulative |
usd |
Session cost in USD | Cumulative |
Since the statusline refreshes multiple times per API call during streaming, consecutive rows may have identical values. The script deduplicates by skipping rows where all values are unchanged.
Quirks with cumulative counters:
/compactreduces context but all cumulative counters (cost, tokens) carry forward unchanged./clearresets context and creates a new session ID, but cumulative counters are not reset. This means the new CSV file starts with nonzero cumulative values./login(switching users) resets cost in USD to zero but not the token counters, and retains the same session ID.
These behaviors can make the session logs tricky to reason about without context.
Optional Python script for visualizing session logs. Requires numpy and matplotlib.
import csv, glob, os, sys
from datetime import datetime
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
LOG_DIR = os.path.dirname(os.path.abspath(__file__))
COLS = ["ctx", "cw", "cr", "in", "out", "usd"]
def load_sessions(filter_patterns=None):
files = glob.glob(os.path.join(LOG_DIR, "*.csv"))
sessions = {}
for f in files:
name = os.path.splitext(os.path.basename(f))[0]
if filter_patterns and not any(p in name for p in filter_patterns):
continue
try:
with open(f, "r", encoding="utf-8-sig") as fh:
rows = list(csv.DictReader(fh))
if not rows:
continue
data = {"ts": np.array([datetime.fromisoformat(r["ts"]) for r in rows])}
for col in COLS:
if col in rows[0]:
data[col] = np.array([float(r[col]) for r in rows])
sessions[name] = data
except Exception as e:
print(f"Skipping {name}: {e}")
return sessions
def short_label(name):
parts = name.split("_", 1)
if len(parts) == 2 and len(parts[1]) > 8:
return f"{parts[0]}_{parts[1][:8]}"
return name
def plot_raw(sessions):
fig, axes = plt.subplots(2, 3, figsize=(14, 7))
fig.suptitle("Raw Time Series per Session", fontsize=13)
for i, col in enumerate(COLS):
ax = axes[i // 3][i % 3]
for name, data in sessions.items():
if col in data:
ax.plot(data["ts"], data[col], label=short_label(name), alpha=0.8)
ax.set_title(col)
ax.tick_params(axis="x", rotation=30, labelsize=7)
ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
for row in axes:
for ax in row:
ax.grid(True, alpha=0.3)
axes[0][0].legend(fontsize=6, loc="upper left")
fig.tight_layout()
def plot_cross(sessions):
fig, axes = plt.subplots(3, 2, figsize=(14, 10))
fig.suptitle("Cross-Variable Plots", fontsize=13)
for name, data in sessions.items():
label = short_label(name)
ts = data["ts"]
if "usd" in data:
axes[0][0].plot(ts, data["usd"], label=label, alpha=0.8)
if "ctx" in data:
axes[1][0].plot(ts, data["ctx"], label=label, alpha=0.8)
if "ctx" in data and "usd" in data:
axes[2][0].plot(data["ctx"], data["usd"], label=label, alpha=0.8)
if "usd" in data:
axes[0][1].scatter(ts[1:], np.diff(data["usd"]), label=label, alpha=0.5, s=10)
if "ctx" in data:
axes[1][1].scatter(ts[1:], np.diff(data["ctx"]), label=label, alpha=0.5, s=10)
if "ctx" in data and "usd" in data:
axes[2][1].scatter(data["ctx"][1:], np.diff(data["usd"]), label=label, alpha=0.5, s=10)
axes[0][0].set_ylabel("USD"); axes[0][0].set_title("Time vs Cost")
axes[1][0].set_ylabel("Context Tokens"); axes[1][0].set_title("Time vs Context")
axes[2][0].set_xlabel("Context Tokens"); axes[2][0].set_ylabel("USD"); axes[2][0].set_title("Context vs Cost")
axes[0][1].set_ylabel("ΔUSD"); axes[0][1].set_title("Time vs ΔCost")
axes[1][1].set_ylabel("ΔContext"); axes[1][1].set_title("Time vs ΔContext")
axes[2][1].set_xlabel("Context Tokens"); axes[2][1].set_ylabel("ΔUSD"); axes[2][1].set_title("Context vs ΔCost")
for row in axes:
for ax in row:
ax.grid(True, alpha=0.3)
for col in range(2):
for row in range(2):
axes[row][col].tick_params(axis="x", rotation=30, labelsize=7)
axes[row][col].xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
axes[0][0].legend(fontsize=6, loc="upper left")
fig.tight_layout()
if __name__ == "__main__":
patterns = sys.argv[1:] if len(sys.argv) > 1 else None
sessions = load_sessions(patterns)
if not sessions:
print("No session logs found.")
sys.exit(1)
print(f"Loaded {len(sessions)} session(s)")
plot_raw(sessions)
plot_cross(sessions)
plt.show()Usage:
python plot.py # plot all sessions
python plot.py MV # only sessions from hostname MV
python plot.py de5a6d40 # only a specific session
Claude Code pipes a JSON blob to the statusline command via stdin on each refresh. The schema:
{
"session_id": "...",
"transcript_path": "/path/to/session.jsonl",
"model": { "id": "claude-opus-4-6", "display_name": "Opus 4.6" },
"workspace": { "current_dir": "...", "project_dir": "..." },
"version": "2.1.47",
"cost": {
"total_cost_usd": 10.637,
"total_duration_ms": 1763114,
"total_api_duration_ms": 227899
},
"context_window": {
"total_input_tokens": 5757,
"total_output_tokens": 4274,
"context_window_size": 200000,
"current_usage": {
"input_tokens": 3,
"cache_creation_input_tokens": 25,
"cache_read_input_tokens": 33380,
"output_tokens": 1
},
"used_percentage": 17,
"remaining_percentage": 83
}
}The thinking display is extracted from the session transcript JSONL (at transcript_path), since thinking character counts aren't exposed in the statusline JSON.
- Claude Code auto-compacts at 85% context (~167k/200k tokens) when auto-compact is enabled. With auto-compact off, it shows a "0% context remaining" warning at 90%, but can actually keep going until the computed context reaches ~99.9%
- Thinking only occurs on complex reasoning tasks — simple responses will show no thinking
- Session counters (cost, total tokens) survive context compaction; only context % drops
- Compaction itself has a cost — context can jump after compaction due to summary tokens being written as new cache
- Statusline not showing (v2.1.47+): Claude Code runs statusline commands through Git Bash. Use
powershell.exe(with.exe) and forward-slash paths. See claude-code#13517. - Statusline not showing (general): Restart Claude Code. Check that
disableAllHooksisn'ttruein settings. - Script errors: Test with
echo '{}' | powershell.exe -NoProfile -ExecutionPolicy Bypass -File 'C:/Users/YOU/.claude/statusline-command.ps1' - Thinking always 0: Normal for simple tasks — thinking only kicks in for complex reasoning.