Skip to content

Instantly share code, notes, and snippets.

@vassvik
Created March 2, 2026 11:20
Show Gist options
  • Select an option

  • Save vassvik/285cb7164dfd02d97368cd49cc1cca29 to your computer and use it in GitHub Desktop.

Select an option

Save vassvik/285cb7164dfd02d97368cd49cc1cca29 to your computer and use it in GitHub Desktop.
Claude Code custom two-line statusline with token metrics, cost, thinking, and session logging (PowerShell/Windows)

Claude Code Custom Statusline

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)]

What It Shows

Line 1

  • Ctx — Context window usage as two values: a precise % computed from token counts in current_usage, and the API's integer-rounded used_percentage. These differ slightly because the API accounts for overhead (e.g. tool definitions) not reflected in current_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)

Line 2

  • 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.

Setup

1. Create the PowerShell script

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
}

2. Configure Claude Code

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.

3. Restart Claude Code

The statusline appears after restart and refreshes on every API call.

Session Logging

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:

  • /compact reduces context but all cumulative counters (cost, tokens) carry forward unchanged.
  • /clear resets 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.

Plot script

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

How It Works

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.

Notes

  • 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

Troubleshooting

  • 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 disableAllHooks isn't true in 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment