Skip to content

Instantly share code, notes, and snippets.

@tjhanley
Last active March 24, 2026 00:03
Show Gist options
  • Select an option

  • Save tjhanley/27b1cc8dc6bbcf622a1020ef03a33632 to your computer and use it in GitHub Desktop.

Select an option

Save tjhanley/27b1cc8dc6bbcf622a1020ef03a33632 to your computer and use it in GitHub Desktop.
Claude Code Powerline Statusline - Catppuccin Mocha

Claude Code Powerline Statusline -- Catppuccin Mocha

A powerline-style status bar for Claude Code using Catppuccin Mocha colors and Nerd Font glyphs.

Claude Code Powerline Statusline

Segments (left to right):

Segment Color Content
Model Blue Active model name
Project Sky Git repo root basename
Git Green/Yellow Branch, staged (+N), modified (~N). Yellow when dirty
Context Mauve Input/output tokens, context bar, %, cost, duration
Style Teal Output style (hidden when "default")
Agent Peach Agent name (hidden when not using --agent)
Vim Green/Yellow Vim mode (hidden when vim mode is off)

Prerequisites

  • Nerd Font -- the statusline uses powerline separators and icons that only render with a Nerd Font installed and configured in your terminal. Without one, you'll see boxes or blanks instead of arrows and icons. The installer checks for this and can install one for you on macOS via Homebrew.
  • jq -- parses the JSON session data Claude Code sends to the statusline
  • Truecolor terminal -- Ghostty, iTerm2, Kitty, WezTerm, Alacritty, etc.
  • Python 3 -- runs the installer (ships with macOS and most Linux distros)

Install

Download and run the installer:

curl -fsSL https://gist.githubusercontent.com/tjhanley/27b1cc8dc6bbcf622a1020ef03a33632/raw/install-statusline.py -o /tmp/install-statusline.py
python3 /tmp/install-statusline.py

Or clone the gist and run locally:

git clone https://gist.github.com/tjhanley/27b1cc8dc6bbcf622a1020ef03a33632.git claude-statusline
cd claude-statusline
python3 install-statusline.py

The installer:

  1. Checks for a Nerd Font and offers to install one if missing (macOS/Homebrew)
  2. Writes ~/.claude/statusline.sh with correct Nerd Font glyph bytes
  3. Makes it executable
  4. Checks for jq and warns if missing
  5. Adds the statusLine config to ~/.claude/settings.json

Restart Claude Code after install.

For Claude

If you are Claude Code reading this gist, follow these steps to set up the statusline for the user:

  1. Download the raw install-statusline.py from this gist to a temp file (do NOT pipe curl to python3)
  2. Run it with python3 -- it handles the font check, script install, and settings.json config
  3. Tell the user to restart Claude Code to see the new statusline

Do NOT attempt to write statusline.sh directly with the Edit tool -- Nerd Font glyphs above U+007F get silently dropped. The Python installer embeds all glyphs as Unicode escapes so the file gets correct bytes.

Customization

Colors: Edit the BG_* and FG_* truecolor ANSI values in ~/.claude/statusline.sh. Uses [48;2;R;G;Bm (background) and [38;2;R;G;Bm (foreground) format.

Context bar width: The bar is 10 characters by default. Change the divisor in FILLED=$((PCT * 10 / 100)) -- e.g. * 20 / 100 for 20 chars.

Hide segments: Each segment is guarded by a condition. Comment out or remove the corresponding if block to hide it.

Git cache TTL: Default 5 seconds. Adjust CACHE_MAX_AGE for faster/slower refresh.

#!/usr/bin/env python3
"""Install Claude Code Powerline Statusline — Catppuccin Mocha theme.
Writes ~/.claude/statusline.sh with embedded Nerd Font glyphs (as Python
Unicode escapes so the file gets correct bytes regardless of editor or tool).
Usage:
python3 install-statusline.py
"""
import json
import os
import platform
import stat
import shutil
import subprocess
import sys
STATUSLINE_PATH = os.path.expanduser("~/.claude/statusline.sh")
# Full statusline.sh content — all non-ASCII chars are Python Unicode escapes.
# This guarantees correct bytes even when piped through tools that strip glyphs.
STATUSLINE_CONTENT = (
"#!/bin/bash\n"
"# Claude Code status line \u2014 Catppuccin Mocha powerline style\n"
"# Receives JSON session data on stdin, prints a single colored line\n"
"# Requires any Nerd Font patched terminal font\n"
"\n"
"input=$(cat)\n"
"\n"
"# Catppuccin Mocha \u2014 truecolor ANSI\n"
"BG_BLUE='\x1b[48;2;137;180;250m'\n"
"BG_GREEN='\x1b[48;2;166;227;161m'\n"
"BG_YELLOW='\x1b[48;2;249;226;175m'\n"
"BG_MAUVE='\x1b[48;2;203;166;247m'\n"
"BG_TEAL='\x1b[48;2;148;226;213m'\n"
"BG_PEACH='\x1b[48;2;250;179;135m'\n"
"BG_SKY='\x1b[48;2;137;220;235m'\n"
"FG_BASE='\x1b[38;2;30;30;46m'\n"
"FG_DIM='\x1b[38;2;108;112;134m' # Catppuccin Mocha overlay0 \u2014 for empty bar portion\n"
"\n"
"# Foreground versions of segment bg colors \u2014 used for powerline arrow transitions\n"
"FG_BLUE='\x1b[38;2;137;180;250m'\n"
"FG_GREEN='\x1b[38;2;166;227;161m'\n"
"FG_YELLOW='\x1b[38;2;249;226;175m'\n"
"FG_MAUVE='\x1b[38;2;203;166;247m'\n"
"FG_TEAL='\x1b[38;2;148;226;213m'\n"
"FG_PEACH='\x1b[38;2;250;179;135m'\n"
"FG_SKY='\x1b[38;2;137;220;235m'\n"
"\n"
"BOLD='\x1b[1m'\n"
"RESET='\x1b[0m'\n"
"\n"
"# Nerd Font powerline glyphs\n"
"SEP='\ue0b0' # U+E0B0 right-arrow: fg=prev_bg, bg=next_bg \u2192 seamless segment join\n"
"CAP_L='\ue0b6' # U+E0B6 left rounded cap: fg=first_seg_bg, bg=terminal\n"
"CAP_R='\ue0b4' # U+E0B4 right rounded cap: fg=last_seg_bg, bg=terminal\n"
"CHIP='\uf2db' # U+F2DB fa-microchip \u2014 model glyph\n"
"BRANCH='\ue0a0' # U+E0A0 Powerline VCS branch glyph\n"
"ROBOT='\uf544' # U+F544 fa-robot \u2014 agent glyph\n"
"FOLDER='\uf07b' # U+F07B fa-folder \u2014 project glyph\n"
"\n"
"# Extract all fields in one jq call\n"
"IFS=$'\\x1f' read -r MODEL DIR PCT COST VIM_MODE DURATION_MS STYLE AGENT TOKENS_IN TOKENS_OUT < <(\n"
" echo \"$input\" | jq -r '[\n"
" (.model.display_name // \"claude\"),\n"
" (.workspace.current_dir // \"\"),\n"
" ((.context_window.used_percentage // 0) | floor | tostring),\n"
" (.cost.total_cost_usd // 0 | tostring),\n"
" (.vim.mode // \"\"),\n"
" (.cost.total_duration_ms // 0 | tostring),\n"
" (.output_style.name // \"default\"),\n"
" (.agent.name // \"\"),\n"
" (.context_window.total_input_tokens // 0 | tostring),\n"
" (.context_window.total_output_tokens // 0 | tostring)\n"
" ] | join(\"\\u001f\")'\n"
")\n"
"\n"
"# Git status \u2014 cached to avoid lag on large repos\n"
"# Key cache by directory so switching projects gets fresh git info\n"
"CACHE_DIR_KEY=$(printf '%s' \"$DIR\" | md5 2>/dev/null || printf '%s' \"$DIR\" | md5sum 2>/dev/null | cut -d' ' -f1)\n"
"CACHE_FILE=\"/tmp/statusline-git-cache-${CACHE_DIR_KEY}\"\n"
"CACHE_MAX_AGE=5 # seconds\n"
"\n"
"cache_is_stale() {\n"
" [ ! -f \"$CACHE_FILE\" ] && return 0\n"
" local age=$(( $(date +%s) - $(stat -f %m \"$CACHE_FILE\" 2>/dev/null || stat -c %Y \"$CACHE_FILE\" 2>/dev/null || echo 0) ))\n"
" [ \"$age\" -gt \"$CACHE_MAX_AGE\" ]\n"
"}\n"
"\n"
"if cache_is_stale; then\n"
" if [ -n \"$DIR\" ] && git -C \"$DIR\" rev-parse --git-dir > /dev/null 2>&1; then\n"
" BRANCH_NAME=$(git -C \"$DIR\" branch --show-current 2>/dev/null)\n"
" STAGED=$(git -C \"$DIR\" diff --cached --numstat 2>/dev/null | wc -l | tr -d ' ')\n"
" MODIFIED=$(git -C \"$DIR\" diff --numstat 2>/dev/null | wc -l | tr -d ' ')\n"
" REPO_NAME=$(basename \"$(git -C \"$DIR\" rev-parse --show-toplevel 2>/dev/null)\")\n"
" printf '1|%s|%s|%s|%s\\n' \"$BRANCH_NAME\" \"$STAGED\" \"$MODIFIED\" \"$REPO_NAME\" > \"$CACHE_FILE\"\n"
" else\n"
" printf '0||||\\n' > \"$CACHE_FILE\"\n"
" fi\n"
"fi\n"
"\n"
"IFS='|' read -r IS_GIT BRANCH_NAME STAGED MODIFIED REPO_NAME < \"$CACHE_FILE\"\n"
"\n"
"# Context bar \u2014 \u2501 (heavy) for filled, \u2500 (light) for empty\n"
"BAR=\"\"\n"
"FILLED=$((PCT * 10 / 100))\n"
"EMPTY=$((10 - FILLED))\n"
"[ \"$FILLED\" -gt 0 ] && BAR=\"${FG_BASE}$(printf \"%${FILLED}s\" | tr ' ' '\u2501')\"\n"
"[ \"$EMPTY\" -gt 0 ] && BAR=\"${BAR}${FG_DIM}$(printf \"%${EMPTY}s\" | tr ' ' '\u2500')\"\n"
"BAR=\"${BAR}${FG_BASE}\"\n"
"BAR_SEG=\"\"\n"
"[ -n \"$BAR\" ] && BAR_SEG=\" ${BAR}\"\n"
"\n"
"# Cost, duration, and token formatting\n"
"COST_FMT=$(awk -v c=\"$COST\" 'BEGIN { printf \"$%.2f\\n\", c+0 }')\n"
"DURATION_FMT=$(awk -v ms=\"$DURATION_MS\" 'BEGIN {\n"
" s = int(ms / 1000); m = int(s / 60); h = int(m / 60)\n"
" if (h > 0) printf \"%dh%dm\", h, m % 60\n"
" else printf \"%dm\", m\n"
"}')\n"
"TOKENS_IN_FMT=$(awk -v n=\"$TOKENS_IN\" 'BEGIN {\n"
" if (n+0 < 1000) printf \"%d\", n+0\n"
" else printf \"%.1fk\", (n+0)/1000\n"
"}')\n"
"TOKENS_OUT_FMT=$(awk -v n=\"$TOKENS_OUT\" 'BEGIN {\n"
" if (n+0 < 1000) printf \"%d\", n+0\n"
" else printf \"%.1fk\", (n+0)/1000\n"
"}')\n"
"\n"
"# Determine git bg/fg colors based on dirty state\n"
"GIT_BG=\"$BG_GREEN\"; GIT_FG=\"$FG_GREEN\"\n"
"if [ \"${IS_GIT:-0}\" = \"1\" ]; then\n"
" GIT_DIRTY=0\n"
" [ \"${STAGED:-0}\" -gt 0 ] || [ \"${MODIFIED:-0}\" -gt 0 ] && GIT_DIRTY=1\n"
" [ \"$GIT_DIRTY\" = \"1\" ] && GIT_BG=\"$BG_YELLOW\" && GIT_FG=\"$FG_YELLOW\"\n"
"fi\n"
"\n"
"# Determine vim bg/fg colors\n"
"VIM_BG=\"$BG_GREEN\"; VIM_FG=\"$FG_GREEN\"\n"
"[ \"$VIM_MODE\" = \"NORMAL\" ] && VIM_BG=\"$BG_YELLOW\" && VIM_FG=\"$FG_YELLOW\"\n"
"\n"
"# Build line \u2014 LAST_FG tracks the previous segment's bg color for seamless transitions\n"
"# and is used for the final right rounded cap\n"
"\n"
"# \u2014 Left rounded cap + Model segment \u2014\n"
"LINE=\"${RESET}${FG_BLUE}${CAP_L}${BG_BLUE}${FG_BASE}${BOLD} ${CHIP} ${MODEL} \"\n"
"LAST_FG=\"$FG_BLUE\"\n"
"\n"
"# \u2014 Project segment (sky) \u2014 repo root basename\n"
"if [ \"${IS_GIT:-0}\" = \"1\" ] && [ -n \"$REPO_NAME\" ]; then\n"
" LINE=\"${LINE}${LAST_FG}${BG_SKY}${SEP}${FG_BASE}${BOLD} ${FOLDER} ${REPO_NAME} \"\n"
" LAST_FG=\"$FG_SKY\"\n"
"fi\n"
"\n"
"if [ \"${IS_GIT:-0}\" = \"1\" ]; then\n"
" GIT_TEXT=\"${BRANCH} ${BRANCH_NAME}\"\n"
" [ \"${STAGED:-0}\" -gt 0 ] && GIT_TEXT=\"${GIT_TEXT} +${STAGED}\"\n"
" [ \"${MODIFIED:-0}\" -gt 0 ] && GIT_TEXT=\"${GIT_TEXT} ~${MODIFIED}\"\n"
" LINE=\"${LINE}${LAST_FG}${GIT_BG}${SEP}${FG_BASE}${BOLD} ${GIT_TEXT} \"\n"
" LAST_FG=\"$GIT_FG\"\n"
"fi\n"
"\n"
"# Context + cost + duration segment\n"
"LINE=\"${LINE}${LAST_FG}${BG_MAUVE}${SEP}${FG_BASE}${BOLD} ${TOKENS_IN_FMT}\u2193 ${TOKENS_OUT_FMT}\u2191${BAR_SEG} ${PCT}% ${COST_FMT} ${DURATION_FMT} \"\n"
"LAST_FG=\"$FG_MAUVE\"\n"
"\n"
"# Output style \u2014 teal pill, hidden when style is \"default\"\n"
"if [ -n \"$STYLE\" ] && [ \"$STYLE\" != \"default\" ]; then\n"
" LINE=\"${LINE}${LAST_FG}${BG_TEAL}${SEP}${FG_BASE}${BOLD} ${STYLE} \"\n"
" LAST_FG=\"$FG_TEAL\"\n"
"fi\n"
"\n"
"# Agent \u2014 peach pill, only shown when --agent flag is active\n"
"if [ -n \"$AGENT\" ]; then\n"
" LINE=\"${LINE}${LAST_FG}${BG_PEACH}${SEP}${FG_BASE}${BOLD} ${ROBOT} ${AGENT} \"\n"
" LAST_FG=\"$FG_PEACH\"\n"
"fi\n"
"\n"
"# Vim mode \u2014 only shown when vim mode is enabled\n"
"if [ -n \"$VIM_MODE\" ]; then\n"
" LINE=\"${LINE}${LAST_FG}${VIM_BG}${SEP}${FG_BASE}${BOLD} ${VIM_MODE} \"\n"
" LAST_FG=\"$VIM_FG\"\n"
"fi\n"
"\n"
"# Right rounded cap using last segment's color\n"
"LINE=\"${LINE}${RESET}${LAST_FG}${CAP_R}${RESET}\"\n"
"\n"
"printf '%b\\n' \"$LINE\"\n"
)
SETTINGS_PATH = os.path.expanduser("~/.claude/settings.json")
INTERACTIVE = sys.stdin.isatty()
STATUSLINE_CONFIG = {
"type": "command",
"command": "~/.claude/statusline.sh",
}
def write_statusline():
"""Write statusline.sh and make it executable."""
dest_dir = os.path.dirname(STATUSLINE_PATH)
os.makedirs(dest_dir, exist_ok=True)
if os.path.exists(STATUSLINE_PATH):
# Back up existing file
backup = STATUSLINE_PATH + ".bak"
shutil.copy2(STATUSLINE_PATH, backup)
print(f" Backed up existing script to {backup}")
with open(STATUSLINE_PATH, "w", encoding="utf-8") as f:
f.write(STATUSLINE_CONTENT)
# chmod +x
st = os.stat(STATUSLINE_PATH)
os.chmod(STATUSLINE_PATH, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
print(f" Wrote {STATUSLINE_PATH}")
def check_jq():
"""Check if jq is available."""
if shutil.which("jq"):
print(" jq found")
return True
else:
print(" WARNING: jq not found -- statusline requires jq to parse session JSON")
print(" Install it: brew install jq (macOS) / apt install jq (Debian/Ubuntu)")
return False
def configure_settings():
"""Add statusLine config to ~/.claude/settings.json."""
settings = {}
if os.path.exists(SETTINGS_PATH):
with open(SETTINGS_PATH, "r", encoding="utf-8") as f:
try:
settings = json.load(f)
except json.JSONDecodeError:
print(f" WARNING: {SETTINGS_PATH} has invalid JSON, backing up")
shutil.copy2(SETTINGS_PATH, SETTINGS_PATH + ".bak")
settings = {}
if settings.get("statusLine") == STATUSLINE_CONFIG:
print(f" {SETTINGS_PATH} already configured")
return
if "statusLine" in settings:
print(f" Existing statusLine config in {SETTINGS_PATH}:")
print(f" {json.dumps(settings['statusLine'])}")
answer = input(" Overwrite with powerline statusline? [y/N] ").strip().lower() if INTERACTIVE else ""
if answer not in ("y", "yes"):
print(" Skipped settings.json update")
return
settings["statusLine"] = STATUSLINE_CONFIG
os.makedirs(os.path.dirname(SETTINGS_PATH), exist_ok=True)
with open(SETTINGS_PATH, "w", encoding="utf-8") as f:
json.dump(settings, f, indent=2)
f.write("\n")
print(f" Configured statusLine in {SETTINGS_PATH}")
def check_nerd_font():
"""Check if a Nerd Font is installed; offer to install one if not.
Nerd Fonts patch popular programming fonts with thousands of extra glyphs
(icons, powerline symbols, devicons). The statusline uses these glyphs for
segment separators and icons. Without a Nerd Font configured in your
terminal, those characters render as boxes or question marks.
"""
found = False
if shutil.which("fc-list"):
# Linux / macOS with fontconfig
try:
result = subprocess.run(
["fc-list", ":family"],
capture_output=True, text=True, timeout=5,
)
if "Nerd" in result.stdout:
found = True
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
if not found and platform.system() == "Darwin":
# macOS fallback -- check ~/Library/Fonts and system font dirs
font_dirs = [
os.path.expanduser("~/Library/Fonts"),
"/Library/Fonts",
"/System/Library/Fonts",
]
for d in font_dirs:
if os.path.isdir(d):
for name in os.listdir(d):
if "Nerd" in name or "NerdFont" in name:
found = True
break
if found:
break
if found:
print(" Nerd Font detected")
return True
print()
print(" WARNING: No Nerd Font detected.")
print(" The statusline uses Nerd Font glyphs (powerline arrows, icons).")
print(" Without one, those characters will render as boxes or blanks.")
print()
# Only offer auto-install on macOS with Homebrew
if platform.system() == "Darwin" and shutil.which("brew"):
answer = input(" Install JetBrainsMono Nerd Font via Homebrew? [y/N] ").strip().lower() if INTERACTIVE else ""
if answer in ("y", "yes"):
print(" Installing font-jetbrains-mono-nerd-font...")
subprocess.run(["brew", "install", "--cask", "font-jetbrains-mono-nerd-font"])
print()
print(" Font installed. You still need to configure your terminal to use it:")
print(" Ghostty: font-family = JetBrainsMono Nerd Font")
print(" iTerm2: Profiles > Text > Font > JetBrainsMono Nerd Font")
print(" Kitty: font_family JetBrainsMono Nerd Font")
print(" WezTerm: font = wezterm.font('JetBrainsMono Nerd Font')")
return True
else:
print(" Skipped. To install manually:")
else:
print(" To install a Nerd Font:")
print(" macOS: brew install --cask font-jetbrains-mono-nerd-font")
print(" Linux: https://www.nerdfonts.com/font-downloads")
print(" Then set your terminal's font to the installed Nerd Font.")
return False
def main():
print("Claude Code Powerline Statusline -- Catppuccin Mocha")
print()
check_nerd_font()
print()
write_statusline()
check_jq()
configure_settings()
print()
print(" Done! Restart Claude Code to see the new statusline.")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment