statuslin.es

Keyblade Status bar

GitTokensCostLimitsMulti-LinePythonNetwork AccessReads Token

Kingdom Hearts themed statusline and command menu for Claude Code.

Updated 2026-06-30

Shows:GitTokensCostLimitsMulti-LinePythonNetwork AccessReads Token

0 copies

Preview

Clean repo
███████████▋ 73% 🗝 Ultima Weapon ✦ app ∙ main ████████████████████████ 100% 「SAVE POINT」 ✶ Limit Form ◉ 41
New session
████████████████ 100% 🗝 Ultima Weapon ✦ app ∙ main ████████████████████████ 100% 「SAVE POINT」 ✶ Limit Form ◉ 0
Dirty branch
██████▋ 42% 🗝 Oathkeeper ✦ app ∙ feat/auth ████████████████████████ 100% ✶ Wisdom Form ◉ 41
Near-full
0% 「MP CHARGE」 🗝 Ultima Weapon ✦ app ∙ main ████████████████████████ 100% 「SAVE POINT」 ✶ Anti Form ◉ 412
1M context
███▋ 23% 🗝 Starlight ✦ app ∙ main ████████████████████████ 100% 「SAVE POINT」 ✶ Master Form ◉ 41
Post-compact
███████████████▉ 99% 🗝 Kingdom Key ✦ app ∙ main ████████████████████████ 100% 「SAVE POINT」 ✶ Limit Form ◉ 41
Worktree
████████▊ 55% 🗝 Ultima Weapon ✦ feature ∙ worktree-feature ████████████████████████ 100% 「SAVE POINT」 ✶ Valor Form ◉ 41
Non-git
███████████▋ 73% 🗝 Ultima Weapon ✦ scratch ████████████████████████ 100% 「SAVE POINT」 ✶ Limit Form ◉ 41

Source

#!/usr/bin/env python3
"""keyblade.py — Kingdom Hearts themed statusline for Claude Code."""

import json
import math
import os
import subprocess
import sys
import tempfile
import time
import urllib.request

# ─── Configuration ───────────────────────────────────────────────

DEFAULT_CONFIG = {
    "theme": "classic",
    "color_mode": "auto",
    "hp_source": "5_hour",
    "hp_budget_usd": 5.00,
    "hp_usage_cache_ttl": 60,
    "show_drive": True,
    "drive_max_lines": 500,
    "drive_source": "lines",
    "drive_bar_width": 14,
    "drive_include_untracked": True,
    "level_per": 100,
    "level_curve": "linear",
    "level_max": 99,
    "level_source": "lines",
    "keyblade_names": {
        "opus": "Ultima Weapon",
        "sonnet": "Oathkeeper",
        "haiku": "Kingdom Key",
    },
    "show_munny": True,
    "show_world": True,
    "show_branch": True,
    "show_timer": True,
    "show_drive_form": True,
    "drive_form_names": {
        "low": "Valor Form",
        "medium": "Wisdom Form",
        "high": "Limit Form",
        "xhigh": "Master Form",
        "max": "Final Form",
    },
    "drive_form_colors": {
        "low": "red",
        "medium": "blue",
        "high": "bright_cyan",
        "xhigh": "bright_yellow",
        "max": "bright_white",
    },
    "world_fallback": "Traverse Town",
    "world_map": {},
    "colors": {
        "hp": "green",
        "mp": "blue",
        "munny": "yellow",
        "keyblade": "cyan",
        "drive": "magenta",
    },
}

# ─── ANSI Color Helpers ─────────────────────────────────────────

# Basic 16-color ANSI (maximum compatibility)
ANSI_BASIC = {
    "reset": "\033[0m",
    "bold": "\033[1m",
    "dim": "\033[2m",
    "green": "\033[32m",
    "blue": "\033[34m",
    "cyan": "\033[36m",
    "yellow": "\033[33m",
    "red": "\033[31m",
    "magenta": "\033[35m",
    "white": "\033[37m",
    "bright_green": "\033[92m",
    "bright_blue": "\033[94m",
    "bright_cyan": "\033[96m",
    "bright_yellow": "\033[93m",
    "bright_white": "\033[97m",
    "bright_orange": "\033[38;5;208m",
    # Background colors for bar tracks (basic: just use dim)
    "bg_green": "\033[42m",
    "bg_blue": "\033[44m",
    "bg_red": "\033[41m",
    "bg_magenta": "\033[45m",
    "bg_yellow": "\033[43m",
    "bg_bright_yellow": "\033[43m",
    "bg_bright_white": "\033[47m",
    "bg_bright_orange": "\033[43m",
    "bg_dim": "\033[100m",
    # Frame color
    "frame": "\033[90m",
    # Icon-specific colors
    "icon_heart": "\033[91m",
    "icon_mp": "\033[94m",
}

# True color (24-bit RGB) — extracted from KH game assets
ANSI_TRUECOLOR = {
    "reset": "\033[0m",
    "bold": "\033[1m",
    "dim": "\033[2m",
    "green": "\033[38;2;142;188;79m",       # #8EBC4F — KH HP bar green
    "blue": "\033[38;2;24;95;173m",          # #185FAD — KH MP bar blue
    "cyan": "\033[38;2;100;200;220m",        # #64C8DC — KH menu/keyblade cyan
    "yellow": "\033[38;2;248;193;105m",      # #F8C169 — KH munny gold
    "red": "\033[38;2;225;82;57m",           # #E15239 — KH critical/Valor red
    "magenta": "\033[38;2;219;168;205m",     # #DBA8CD — KH MP Charge pink
    "white": "\033[38;2;220;220;230m",       # #DCDCE6 — soft white
    "bright_green": "\033[38;2;160;210;90m", # #A0D25A — brighter KH green
    "bright_blue": "\033[38;2;60;130;210m",  # #3C82D2 — Wisdom Form blue
    "bright_cyan": "\033[38;2;130;220;240m", # #82DCF0 — bright KH cyan
    "bright_yellow": "\033[38;2;255;215;80m",# #FFD750 — Master Form gold
    "bright_white": "\033[38;2;245;245;255m",# #F5F5FF — Final Form silver-white
    "bright_orange": "\033[38;2;240;150;50m",# #F09632 — HP warning amber
    # Background colors for bar tracks (darkened versions of foreground)
    "bg_green": "\033[48;2;35;50;20m",        # dark KH green
    "bg_blue": "\033[48;2;8;25;55m",          # dark KH blue
    "bg_red": "\033[48;2;55;20;15m",          # dark KH red
    "bg_magenta": "\033[48;2;50;35;45m",      # dark KH pink
    "bg_yellow": "\033[48;2;60;50;18m",       # dark KH gold
    "bg_bright_yellow": "\033[48;2;60;50;18m",# dark Master Form gold
    "bg_bright_white": "\033[48;2;40;40;45m", # dark Final Form
    "bg_bright_orange": "\033[48;2;60;38;12m",# dark HP warning amber
    "bg_dim": "\033[48;2;25;25;25m",          # dark gray (Anti Form)
    # Frame color (muted gray for bar brackets)
    "frame": "\033[38;2;100;100;100m",
    # Icon-specific colors (always the same regardless of bar state)
    "icon_heart": "\033[38;2;255;100;80m",    # bright red heart
    "icon_mp": "\033[38;2;80;150;230m",       # bright blue sparkle
}


def _detect_color_mode():
    """Detect terminal color capability.

    Returns 'truecolor', 'basic', or 'none'.
    """
    if os.environ.get("NO_COLOR") is not None:
        return "none"
    if os.environ.get("CLICOLOR") == "0":
        return "none"
    colorterm = os.environ.get("COLORTERM", "")
    if colorterm in ("truecolor", "24bit"):
        return "truecolor"
    return "basic"


def _resolve_ansi(color_mode_override=None):
    """Resolve ANSI color dict based on mode."""
    mode = color_mode_override or _detect_color_mode()
    if mode == "none":
        return {k: "" for k in ANSI_BASIC}
    if mode == "truecolor":
        return dict(ANSI_TRUECOLOR)
    return dict(ANSI_BASIC)


# Initial resolution — may be overridden by config in load_config()
ANSI = _resolve_ansi()

# ─── Unicode Constants ───────────────────────────────────────────

BAR_FULL = "\u2588"    # █ Full block
BAR_EMPTY = "\u2591"   # ░ Light shade (visible empty track)
BAR_BLOCKS = [" ", "\u258f", "\u258e", "\u258d", "\u258c", "\u258b", "\u258a", "\u2589", "\u2588"]
#              0/8    1/8       2/8       3/8       4/8       5/8       6/8       7/8       8/8

KEYBLADE_ICON = "\U0001f5dd"  # 🗝 Old key — keyblades are keys, not swords
MUNNY_ICON = "\u25c9"         # ◉ Fisheye — munny orbs are round jewels
HEART_ICON = "\u2665"         # ♥ Heart — hearts are core KH
MP_ICON = "\u2727"            # ✧ White four-pointed star — magic sparkle
WORLD_ICON = "\u2726"         # ✦ Four-pointed star — worlds glow on the world map
TIMER_ICON = "\u23f1"         # ⏱ Stopwatch — session/journey timer
DRIVE_ICON = "\u25c6"         # ◆ Diamond — the in-game Drive gauge shape
FORM_ICON = "\u2736"          # ✶ Six-pointed star — Drive Form transformation aura
EXP_ICON = "\u265b"           # ♛ Crown — Sora's crown necklace
PARTY_ICON = "\u2666"         # ♦ Diamond suit — party member indicator


# ─── Config Loading ──────────────────────────────────────────────

def load_config():
    """Load config with fallback to defaults."""
    config_dir = os.environ.get(
        "CLAUDE_CONFIG_DIR", os.path.expanduser("~/.claude")
    )
    config_path = os.path.join(config_dir, "hooks", "keyblade", "config.json")

    config = dict(DEFAULT_CONFIG)

    try:
        with open(config_path) as f:
            user_config = json.load(f)
        config.update(user_config)
        # Deep merge nested dicts
        for key in ("colors", "keyblade_names", "drive_form_names", "drive_form_colors"):
            if key in DEFAULT_CONFIG and key in user_config:
                merged = dict(DEFAULT_CONFIG[key])
                merged.update(user_config[key])
                config[key] = merged
    except (FileNotFoundError, json.JSONDecodeError, PermissionError):
        pass

    # Apply color_mode from config (override auto-detection)
    global ANSI
    mode = config.get("color_mode", "auto")
    if mode == "auto":
        ANSI = _resolve_ansi()
    else:
        ANSI = _resolve_ansi(mode)

    return config



# ─── State File ──────────────────────────────────────────────────

STATE_FILE = os.path.join(tempfile.gettempdir(), "keyblade_state.json")


def _read_state():
    """Read the shared state file. Returns dict."""
    try:
        with open(STATE_FILE) as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError, ValueError):
        return {}


def _write_state(state):
    """Write the shared state file."""
    try:
        with open(STATE_FILE, "w") as f:
            json.dump(state, f)
    except OSError:
        pass


def _project_key(data):
    """Get project directory name for keying per-project state."""
    ws = data.get("workspace", {})
    d = ws.get("current_dir", "") or ws.get("project_dir", "")
    return os.path.basename(d) if d else "_default"


def _read_project_state(data):
    """Read per-project state (level_up, save_point)."""
    state = _read_state()
    key = _project_key(data)
    return state.get("projects", {}).get(key, {})


def _write_project_state(data, project_state):
    """Write per-project state, preserving global and other project state."""
    state = _read_state()
    key = _project_key(data)
    if "projects" not in state:
        state["projects"] = {}
    state["projects"][key] = project_state
    _write_state(state)


# ─── Data Helpers ────────────────────────────────────────────────


def resolve_keyblade(model_id, model_display, config):
    """Map model to KH keyblade name."""
    names = config.get("keyblade_names", DEFAULT_CONFIG["keyblade_names"])
    model_lower = ((model_id or "") + " " + (model_display or "")).lower()
    if "opus" in model_lower:
        return names.get("opus", "Ultima Weapon")
    if "sonnet" in model_lower:
        return names.get("sonnet", "Oathkeeper")
    if "haiku" in model_lower:
        return names.get("haiku", "Kingdom Key")
    return "Starlight"


def get_plan_usage(config):
    """Fetch plan usage from Anthropic API with file-based caching."""
    ttl = config.get("hp_usage_cache_ttl", 60)

    # Check cache first
    state = _read_state()
    cache = state.get("usage_cache", {})
    if time.time() - cache.get("ts", 0) < ttl:
        return cache.get("five_hour", 0), cache.get("seven_day", 0)

    # Extract OAuth token — macOS Keychain or Linux credential file
    try:
        token = ""
        if sys.platform == "darwin":
            r = subprocess.run(
                ["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
                capture_output=True, text=True, timeout=5,
            )
            if r.returncode == 0:
                creds = json.loads(r.stdout.strip())
                token = creds.get("claudeAiOauth", {}).get("accessToken", "")
        else:
            # Linux: try reading from Claude Code credential store
            cred_paths = [
                os.path.expanduser("~/.claude/credentials.json"),
                os.path.join(
                    os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")),
                    "claude-code", "credentials.json",
                ),
            ]
            for cred_path in cred_paths:
                try:
                    with open(cred_path) as f:
                        creds = json.load(f)
                    token = creds.get("claudeAiOauth", {}).get("accessToken", "")
                    if token:
                        break
                except (FileNotFoundError, json.JSONDecodeError, KeyError):
                    continue
        if not token:
            raise ValueError("no OAuth token available")

        # Call usage API
        req = urllib.request.Request(
            "https://api.anthropic.com/api/oauth/usage",
            headers={
                "Authorization": f"Bearer {token}",
                "anthropic-beta": "oauth-2025-04-20",
            },
        )
        with urllib.request.urlopen(req, timeout=5) as resp:
            body = json.loads(resp.read())

        five_hour = body.get("five_hour", {}).get("utilization", 0) or 0
        seven_day = body.get("seven_day", {}).get("utilization", 0) or 0

        # Write cache
        state = _read_state()
        state["usage_cache"] = {"ts": time.time(), "five_hour": five_hour, "seven_day": seven_day}
        _write_state(state)

        return five_hour, seven_day
    except (subprocess.SubprocessError, OSError, json.JSONDecodeError,
            KeyError, ValueError, urllib.error.URLError):
        pass

    # Token lookup or API call failed — prefer last known values over 0%
    # (a fresh 0% would render as 100% HP, flashing full on session start).
    if cache:
        return cache.get("five_hour", 0), cache.get("seven_day", 0)
    return 0, 0


def calculate_hp(data, config):
    """Calculate HP from configured source. Goes down as usage increases.

    Sources:
      5_hour      — 5-hour plan usage window (Max/Pro)
      7_day       — 7-day plan usage window (Max/Pro)
      cost_budget — session cost vs hp_budget_usd (API key users)
    """
    source = config.get("hp_source", "5_hour")

    if source == "cost_budget":
        budget = config.get("hp_budget_usd", 5.00)
        spent = data.get("cost", {}).get("total_cost_usd", 0) or 0
        if budget <= 0:
            return 100.0
        return max(0.0, min(100.0, (budget - spent) / budget * 100))

    # Plan usage sources (Max/Pro)
    five_hour, seven_day = get_plan_usage(config)
    if source == "7_day":
        return max(0.0, min(100.0, 100.0 - seven_day))
    # Default: 5_hour
    return max(0.0, min(100.0, 100.0 - five_hour))


def _read_transcript_context_tokens(transcript_path):
    """Derive current context token count by scanning the transcript tail.

    Claude Code's statusline payload lags one turn behind after /compact and
    may be missing on session start / after /clear. The transcript file is
    the source of truth: the most recent assistant `message.usage` gives the
    real input-token count, and an `isCompactSummary` line marks when /compact
    reset the context.

    Returns total tokens or None if unreadable.
    """
    try:
        with open(transcript_path, "rb") as f:
            f.seek(0, 2)
            size = f.tell()
            if size == 0:
                return None
            read_size = min(size, 512 * 1024)
            f.seek(size - read_size)
            tail = f.read().decode("utf-8", errors="replace")
    except (OSError, ValueError):
        return None

    lines = [l for l in tail.split("\n") if l.strip()]
    last_usage_tokens = None
    last_usage_idx = -1
    last_compact_idx = -1
    last_compact_obj = None

    for idx, line in enumerate(lines):
        try:
            obj = json.loads(line)
        except (json.JSONDecodeError, ValueError):
            continue
        if obj.get("isCompactSummary"):
            last_compact_idx = idx
            last_compact_obj = obj
        msg = obj.get("message")
        if isinstance(msg, dict):
            u = msg.get("usage")
            if isinstance(u, dict):
                tok = (
                    (u.get("input_tokens") or 0)
                    + (u.get("cache_read_input_tokens") or 0)
                    + (u.get("cache_creation_input_tokens") or 0)
                )
                if tok > 0:
                    last_usage_tokens = tok
                    last_usage_idx = idx

    # Compact marker more recent than the last usage → /compact just ran and
    # the user hasn't taken a turn yet. Estimate from the summary content size.
    if last_compact_obj is not None and last_compact_idx > last_usage_idx:
        content = last_compact_obj.get("message", {}).get("content", "")
        if isinstance(content, list):
            parts = []
            for c in content:
                if isinstance(c, dict):
                    parts.append(c.get("text", "") or "")
                else:
                    parts.append(str(c))
            content = "".join(parts)
        # Rough chars-per-token ≈ 4. Floor at 1000 to avoid absurdly-low reads.
        return max(1000, len(content) // 4)

    return last_usage_tokens


def calculate_mp(data):
    """Calculate MP from context window remaining percentage.

    Prefers scanning the transcript file because the `context_window` field
    in the statusline payload is stale on session start, after /clear, and
    for one turn after /compact.
    """
    ctx = data.get("context_window", {}) or {}
    window_size = ctx.get("context_window_size") or 200_000

    transcript_path = data.get("transcript_path")
    if transcript_path:
        tokens = _read_transcript_context_tokens(transcript_path)
        if tokens is not None and window_size > 0:
            used_pct = min(100.0, tokens / window_size * 100.0)
            return max(0.0, 100.0 - used_pct)

    return ctx.get("remaining_percentage", 100) or 100


def world_and_branch(data, config=None):
    """Return (world_name, branch) separately for responsive display."""
    if config is None:
        config = DEFAULT_CONFIG
    fallback = config.get("world_fallback", "Traverse Town")
    ws = data.get("workspace", {})
    current = ws.get("current_dir", "") or ws.get("project_dir", "")
    if not current:
        return fallback, ""
    dirname = os.path.basename(current)
    if not dirname:
        return fallback, ""

    # Apply world_map: custom name for this directory
    wmap = config.get("world_map", {})
    name = wmap.get(dirname, dirname)

    branch = ""
    try:
        if config.get("show_branch", True):
            r = subprocess.run(
                ["git", "rev-parse", "--abbrev-ref", "HEAD"],
                capture_output=True, text=True, cwd=current, timeout=3,
            )
            branch = r.stdout.strip() if r.returncode == 0 else ""
    except (subprocess.SubprocessError, OSError):
        pass

    return name, branch


def world_name(data, config=None):
    """Convert workspace directory to world name with git branch.

    Config options:
      show_branch    — append :branch to world name
      world_fallback — name when no directory found
      world_map      — map directory names to custom names
    """
    name, branch = world_and_branch(data, config)
    if branch:
        return f"{name} \u2219 {branch}"
    return name



def format_duration(ms):
    """Format milliseconds as a journey timer."""
    seconds = int(ms) // 1000
    minutes = seconds // 60
    hours = minutes // 60
    if hours > 0:
        return f"{hours}h{minutes % 60:02d}m"
    if minutes > 0:
        return f"{minutes}m{seconds % 60:02d}s"
    return f"{seconds}s"


def _level_value(data, config):
    """Get the raw value used for level calculation based on level_source."""
    source = config.get("level_source", "lines")
    cost = data.get("cost", {})
    added = cost.get("total_lines_added", 0) or 0
    removed = cost.get("total_lines_removed", 0) or 0
    if source == "added_only":
        return added
    if source == "commits":
        return cost.get("total_commits", 0) or 0
    if source == "files":
        return cost.get("total_files_changed", 0) or 0
    # Default: "lines" (added + removed)
    return added + removed


def calculate_level(data, config=None):
    """Calculate level from configured source and curve.

    Sources: lines (added+removed), added_only, commits, files
    Curves:  linear (every N), exponential (each level costs more)
    """
    if config is None:
        config = DEFAULT_CONFIG
    per = config.get("level_per", 100)
    curve = config.get("level_curve", "linear")
    cap = config.get("level_max", 99)
    value = _level_value(data, config)

    if per <= 0:
        per = 100

    if curve == "exponential":
        # Each level requires `per * level` more (triangular growth)
        # Total for level L = per * (1 + 2 + ... + (L-1)) = per * L*(L-1)/2
        # Solve: value = per * L*(L-1)/2 → L ≈ (1 + sqrt(1 + 8*value/per)) / 2
        level = int((1 + math.sqrt(1 + 8 * value / per)) / 2)
    else:
        # Linear: every `per` units = +1 level
        level = value // per + 1

    return min(level, cap)


def calculate_exp(data, config=None):
    """Calculate EXP — same source as level (lines, added_only, commits, files)."""
    if config is None:
        config = DEFAULT_CONFIG
    return _level_value(data, config)


def calculate_drive(data, config=None):
    """Get uncommitted file and line counts from git.

    Config options:
      drive_include_untracked — count untracked (new) files
    """
    if config is None:
        config = DEFAULT_CONFIG
    ws = data.get("workspace", {})
    work_dir = ws.get("current_dir", "") or ws.get("project_dir", "")
    if not work_dir:
        return 0, 0
    try:
        include_untracked = config.get("drive_include_untracked", True)

        # Count uncommitted files
        r = subprocess.run(
            ["git", "status", "--porcelain"],
            capture_output=True, text=True, cwd=work_dir, timeout=3,
        )
        if r.returncode == 0 and r.stdout.strip():
            status_lines = [l for l in r.stdout.strip().split("\n") if l.strip()]
            if not include_untracked:
                status_lines = [l for l in status_lines if not l.startswith("??")]
            files = len(status_lines)
        else:
            files = 0

        # Count changed lines (staged + unstaged)
        lines = 0
        for args in [["git", "diff", "--numstat"], ["git", "diff", "--cached", "--numstat"]]:
            r = subprocess.run(args, capture_output=True, text=True, cwd=work_dir, timeout=3)
            for line in r.stdout.strip().split("\n"):
                if not line.strip():
                    continue
                parts = line.split("\t")
                if len(parts) >= 2:
                    try:
                        a = int(parts[0]) if parts[0] != "-" else 0
                        d = int(parts[1]) if parts[1] != "-" else 0
                        lines += a + d
                    except ValueError:
                        pass

        # Count lines in untracked files (git diff can't see these)
        if include_untracked:
            r = subprocess.run(
                ["git", "ls-files", "--others", "--exclude-standard"],
                capture_output=True, text=True, cwd=work_dir, timeout=3,
            )
            if r.returncode == 0 and r.stdout.strip():
                for fpath in r.stdout.strip().split("\n"):
                    if not fpath.strip():
                        continue
                    try:
                        fr = subprocess.run(
                            ["wc", "-l", fpath],
                            capture_output=True, text=True, cwd=work_dir, timeout=2,
                        )
                        if fr.returncode == 0:
                            lines += int(fr.stdout.strip().split()[0])
                    except (subprocess.SubprocessError, OSError, ValueError, IndexError):
                        pass

        return files, lines
    except (subprocess.SubprocessError, OSError):
        return 0, 0


def hp_color(pct):
    """Return ANSI color based on HP percentage."""
    if pct > 50:
        return ANSI["green"]
    if pct > 20:
        return ANSI["bright_orange"]
    return ANSI["red"]


def hp_danger_marker(pct):
    """Return KH-style danger marker for HP percentage."""
    if pct < 15:
        return f" {ANSI['red']}{ANSI['bold']}\033[7m\u300cDANGER\u300d\033[27m{ANSI['reset']}"
    if pct < 20:
        return f" {ANSI['red']}{ANSI['bold']}\u300cDANGER\u300d{ANSI['reset']}"
    if pct <= 50:
        return f" {ANSI['bright_orange']}\u26a0{ANSI['reset']}"
    return ""


def mp_charge_state(mp_pct):
    """Check if MP is in Charge state (KH2 mechanic).

    When MP drops below 10%, the bar enters 'MP CHARGE' mode —
    magenta color with a different label, like KH2.
    """
    return mp_pct < 10


def mp_label_and_color(mp_pct, colors):
    """Return (label, color) for MP bar, handling MP Charge state."""
    if mp_charge_state(mp_pct):
        return "MP", "magenta"
    return "MP", colors.get("mp", "blue")


def mp_charge_marker(mp_pct):
    """Return MP Charge marker if in charge state."""
    if mp_charge_state(mp_pct):
        return f" {ANSI['magenta']}{ANSI['bold']}\u300cMP CHARGE\u300d{ANSI['reset']}"
    return ""


LEVEL_UP_DURATION = 10  # seconds to show level-up notification


def check_level_up(level, data=None):
    """Check if level increased since last render. Returns True if leveled up recently."""
    if data is None:
        data = {}
    pstate = _read_project_state(data)
    lvl_state = pstate.get("level_up", {})
    prev_level = lvl_state.get("level", 0)
    leveled_at = lvl_state.get("ts", 0)

    now = time.time()

    if level > prev_level:
        pstate["level_up"] = {"level": level, "ts": now}
        _write_project_state(data, pstate)
        return True

    if level == prev_level and (now - leveled_at) < LEVEL_UP_DURATION:
        return True

    if level != prev_level:
        pstate["level_up"] = {"level": level, "ts": 0}
        _write_project_state(data, pstate)

    return False


def level_up_marker(level, data=None):
    """Return level-up notification if recently leveled up."""
    if check_level_up(level, data):
        return f" {ANSI['bright_yellow']}{ANSI['bold']}\u300cLEVEL UP!\u300d{ANSI['reset']}"
    return ""


SAVE_POINT_DURATION = 10  # seconds to show save point notification


def check_save_point(drive_files, drive_lines, data=None):
    """Check if working tree just became clean. Returns True within notification window."""
    if data is None:
        data = {}
    is_clean = (drive_files == 0 and drive_lines == 0)
    pstate = _read_project_state(data)
    sp_state = pstate.get("save_point", {})
    was_clean = sp_state.get("clean", False)
    saved_at = sp_state.get("ts", 0)

    now = time.time()

    if is_clean and not was_clean:
        pstate["save_point"] = {"clean": True, "ts": now}
        _write_project_state(data, pstate)
        return True

    if is_clean and was_clean and (now - saved_at) < SAVE_POINT_DURATION:
        return True

    if is_clean != was_clean:
        pstate["save_point"] = {"clean": is_clean, "ts": 0}
        _write_project_state(data, pstate)

    return False


def save_point_marker(drive_files, drive_lines, data=None):
    """Return Save Point badge if working tree just became clean."""
    if check_save_point(drive_files, drive_lines, data):
        return f" {ANSI['bright_green']}{ANSI['bold']}\u300cSAVE POINT\u300d{ANSI['reset']}"
    return ""


def is_anti_form(hp_pct, mp_pct, drive_pct):
    """Check if Anti Form should activate (KH2 hidden penalty state).

    Triggers when conditions are dire:
    - HP < 5% AND Drive gauge > 90%, OR
    - Context window (MP) < 5%
    """
    return (hp_pct < 5 and drive_pct > 90) or mp_pct < 5


def resolve_effort_level(data, config=None):
    """Resolve raw effort level string.

    Priority:
      1. Statusbar JSON 'effort' or 'reasoning_effort' (future-proof)
      2. ~/.claude/settings.json 'effortLevel'
      3. CLAUDE_CODE_EFFORT_LEVEL env var
      4. Default: 'high'
    """
    # 1. Check statusbar data (future-proof)
    effort = data.get("effort") or data.get("reasoning_effort")

    # 2. Read from Claude Code settings
    if not effort:
        config_dir = os.environ.get(
            "CLAUDE_CONFIG_DIR", os.path.expanduser("~/.claude")
        )
        settings_path = os.path.join(config_dir, "settings.json")
        try:
            with open(settings_path) as f:
                settings = json.load(f)
            effort = settings.get("effortLevel")
        except (FileNotFoundError, json.JSONDecodeError, PermissionError):
            pass

    # 3. Check environment variable
    if not effort:
        effort = os.environ.get("CLAUDE_CODE_EFFORT_LEVEL")

    # 4. Default to high
    if not effort:
        effort = "high"

    # Claude Code v2.1+ wraps effort as {"level": "..."}; older versions
    # and settings.json still use a plain string.
    if isinstance(effort, dict):
        effort = effort.get("level") or "high"

    return str(effort).lower()


def resolve_drive_form(data, config=None):
    """Resolve current Drive Form name from reasoning effort level."""
    if config is None:
        config = DEFAULT_CONFIG
    names = config.get("drive_form_names", DEFAULT_CONFIG["drive_form_names"])
    effort = resolve_effort_level(data, config)
    return names.get(effort, names.get("high", "Master Form"))


def resolve_drive_form_color_name(data, config=None):
    """Get color name for current Drive Form (canonical KH2 colors)."""
    if config is None:
        config = DEFAULT_CONFIG
    effort = resolve_effort_level(data, config)
    form_colors = config.get("drive_form_colors", DEFAULT_CONFIG["drive_form_colors"])
    return form_colors.get(effort, form_colors.get("high", "yellow"))


# ─── Bar Rendering ───────────────────────────────────────────────

def render_bar(percentage, width, color, show_pct=True, icon="", icon_color=""):
    """Render a KH-style bar with background track and icon.

    Args:
        percentage: Fill percentage (0-100)
        width: Bar width in characters
        color: Color name for the fill (key in ANSI dict)
        show_pct: Show percentage text after bar
        icon: Icon character (e.g. HEART_ICON) — rendered in its own color
        icon_color: ANSI key for icon color (e.g. "icon_heart")
    """
    c = ANSI.get(color, ANSI["green"])
    rst = ANSI["reset"]
    bld = ANSI["bold"]
    bg = ANSI.get(f"bg_{color}", "")

    pct = max(0.0, min(100.0, percentage))
    value = pct / 100.0 * width
    full = int(value)
    partial_idx = round((value - full) * 8)
    if partial_idx == 8:
        full += 1
        partial_idx = 0

    # Build smooth bar — skip partials thinner than 3/8 (visually indistinct from empty)
    partial = BAR_BLOCKS[partial_idx] if partial_idx >= 3 and full < width else ""
    empty = width - full - (1 if partial else 0)

    # Fill portion in bar color, empty portion with background-colored track
    fill_part = c + bld + (BAR_FULL * full) + partial
    if bg:
        empty_part = bg + (" " * max(0, empty)) + rst
    else:
        empty_part = ANSI["dim"] + (BAR_EMPTY * max(0, empty)) + rst

    bar = fill_part + empty_part

    # Icon in its own color, no brackets, no label text
    ic = ANSI.get(icon_color, c) if icon_color else c
    icon_str = f"{ic}{icon}{rst} " if icon else ""
    pct_str = f" {pct:.0f}%" if show_pct else ""
    return f"{icon_str}{bar}{c}{pct_str}{rst}"


# ─── Theme: Classic KH HUD (2 lines) ────────────────────────────

def render_classic(data, config):
    """Classic Kingdom Hearts HUD — HP bar, MP bar, keyblade, munny."""
    hp_pct = calculate_hp(data, config)
    mp_pct = calculate_mp(data)

    model = data.get("model", {})
    keyblade = resolve_keyblade(
        model.get("id", ""), model.get("display_name", ""), config
    )

    cost = data.get("cost", {}).get("total_cost_usd", 0) or 0
    munny = int(cost * 100)

    colors = config.get("colors", DEFAULT_CONFIG["colors"])
    rst = ANSI["reset"]
    bld = ANSI["bold"]

    kc = ANSI.get(colors.get("keyblade", "cyan"), ANSI["cyan"])
    mc = ANSI.get(colors.get("munny", "yellow"), ANSI["yellow"])

    # Drive data (needed for Anti Form + Save Point)
    drive_files, drive_lines = calculate_drive(data, config)
    drive_max = config.get("drive_max_lines", 500)
    drive_pct = min(100.0, (drive_lines / drive_max * 100)) if drive_max > 0 else 0

    # Anti Form check
    if is_anti_form(hp_pct, mp_pct, drive_pct):
        form_name = "Anti Form"
        drive_color_name = "dim"
    else:
        form_name = resolve_drive_form(data, config)
        drive_color_name = resolve_drive_form_color_name(data, config)
    dc = ANSI.get(drive_color_name, ANSI["yellow"])

    # Line 1: MP bar + Keyblade + World
    _, mp_clr = mp_label_and_color(mp_pct, colors)
    mp_bar = render_bar(mp_pct, 16, mp_clr, icon=MP_ICON, icon_color="icon_mp")
    mp_marker = mp_charge_marker(mp_pct)
    line1_parts = [f"  {mp_bar}{mp_marker}  {kc}{KEYBLADE_ICON}  {bld}{keyblade}{rst}"]
    if config.get("show_world", True):
        world = world_name(data, config)
        line1_parts.append(f"{bld}{WORLD_ICON} {world}{rst}")
    line1 = "  ".join(line1_parts)

    # Line 2: HP bar + Save Point + Drive Form + Munny
    color_name = "green"
    if hp_pct <= 50:
        color_name = "bright_orange" if hp_pct > 20 else "red"
    hp_bar = render_bar(hp_pct, 24, color_name, icon=HEART_ICON, icon_color="icon_heart")
    hp_marker = hp_danger_marker(hp_pct)
    sp_marker = save_point_marker(drive_files, drive_lines, data)
    line2_parts = [f"  {hp_bar}{hp_marker}{sp_marker}"]
    if config.get("show_drive_form", True):
        line2_parts.append(f"{dc}{FORM_ICON} {form_name}{rst}")
    if config.get("show_munny", True):
        line2_parts.append(f"{mc}{MUNNY_ICON} {munny}{rst}")
    line2 = "  ".join(line2_parts)

    return line1 + "\n" + line2


# ─── Theme: Minimal KH (1 line) ─────────────────────────────────

def render_minimal(data, config):
    """Minimal KH — single line, subtle references."""
    hp_pct = calculate_hp(data, config)
    mp_pct = calculate_mp(data)

    model = data.get("model", {})
    keyblade = resolve_keyblade(
        model.get("id", ""), model.get("display_name", ""), config
    )

    cost = data.get("cost", {}).get("total_cost_usd", 0) or 0
    munny = int(cost * 100)

    colors = config.get("colors", DEFAULT_CONFIG["colors"])
    kc = ANSI.get(colors.get("keyblade", "cyan"), ANSI["cyan"])
    mc = ANSI.get(colors.get("munny", "yellow"), ANSI["yellow"])
    mpc = ANSI.get(colors.get("mp", "blue"), ANSI["blue"])
    rst = ANSI["reset"]
    bld = ANSI["bold"]
    dim = ANSI["dim"]

    # Drive data (needed for Anti Form + Save Point)
    drive_files, drive_lines = calculate_drive(data, config)
    drive_max = config.get("drive_max_lines", 500)
    drive_pct = min(100.0, (drive_lines / drive_max * 100)) if drive_max > 0 else 0

    hc = hp_color(hp_pct)
    hp_str = f"{hc}{bld}{hp_pct:.0f}%{rst}" if hp_pct <= 20 else f"{hc}{hp_pct:.0f}%{rst}"
    hp_str += hp_danger_marker(hp_pct)
    hp_str += save_point_marker(drive_files, drive_lines, data)

    # Anti Form check
    if is_anti_form(hp_pct, mp_pct, drive_pct):
        form_name = "Anti Form"
        drive_color_name = "dim"
    else:
        form_name = resolve_drive_form(data, config)
        drive_color_name = resolve_drive_form_color_name(data, config)
    dc = ANSI.get(drive_color_name, ANSI["yellow"])

    parts = [f"{kc}{KEYBLADE_ICON}  {keyblade}{rst}"]

    if config.get("show_drive_form", True):
        # Strip " Form" suffix for compact display
        short_form = form_name.replace(" Form", "")
        parts.append(f"{dc}{FORM_ICON} {short_form}{rst}")

    if config.get("show_world", True):
        world = world_name(data, config)
        parts.append(f"{bld}{WORLD_ICON} {world}{rst}")

    parts.append(f"{HEART_ICON} {hp_str}")
    if mp_charge_state(mp_pct):
        parts.append(f"{ANSI['magenta']}{MP_ICON} {mp_pct:.0f}% \u300cCHARGE\u300d{rst}")
    else:
        parts.append(f"{mpc}{MP_ICON} {mp_pct:.0f}%{rst}")

    if config.get("show_munny", True):
        parts.append(f"{mc}{MUNNY_ICON} {munny}{rst}")

    return "  " + "  ".join(parts)


# ─── Theme: Full RPG (3 lines) ──────────────────────────────────

def render_full_rpg(data, config):
    """Full RPG HUD — HP/MP, keyblade, world, munny, timer, EXP, drive, level."""
    hp_pct = calculate_hp(data, config)
    mp_pct = calculate_mp(data)
    cost_data = data.get("cost", {})
    cost = cost_data.get("total_cost_usd", 0) or 0
    munny = int(cost * 100)

    model = data.get("model", {})
    keyblade = resolve_keyblade(
        model.get("id", ""), model.get("display_name", ""), config
    )

    colors = config.get("colors", DEFAULT_CONFIG["colors"])
    kc = ANSI.get(colors.get("keyblade", "cyan"), ANSI["cyan"])
    mc = ANSI.get(colors.get("munny", "yellow"), ANSI["yellow"])
    rst = ANSI["reset"]
    bld = ANSI["bold"]
    dim = ANSI["dim"]

    exp = calculate_exp(data, config)
    level = calculate_level(data, config)
    drive_files, drive_lines = calculate_drive(data, config)
    duration_ms = cost_data.get("total_duration_ms", 0) or 0

    # Line 1: MP bar (with Charge state) + Keyblade + World
    _, mp_clr = mp_label_and_color(mp_pct, colors)
    mp_bar = render_bar(mp_pct, 16, mp_clr, icon=MP_ICON, icon_color="icon_mp")
    mp_marker = mp_charge_marker(mp_pct)
    world = world_name(data, config)
    line1_parts = [f"  {mp_bar}{mp_marker}  {kc}{KEYBLADE_ICON}  {bld}{keyblade}{rst}"]
    line1_parts.append(f"{bld}{WORLD_ICON} {world}{rst}")
    line1 = "  ".join(line1_parts)

    # Line 2: HP bar + Level + EXP + Level-Up + Save Point
    color_name = "green"
    if hp_pct <= 50:
        color_name = "bright_orange" if hp_pct > 20 else "red"
    hp_bar = render_bar(hp_pct, 24, color_name, icon=HEART_ICON, icon_color="icon_heart")
    hp_marker = hp_danger_marker(hp_pct)
    lvl_up = level_up_marker(level, data)
    sp_marker = save_point_marker(drive_files, drive_lines, data)
    line2_parts = [
        f"  {hp_bar}{hp_marker}",
        f"{bld}LV {level}{rst} ({EXP_ICON} {exp}){lvl_up}{sp_marker}",
    ]
    line2 = "  ".join(line2_parts)

    # Line 3: Drive (uncommitted bar) + Munny + Timer + Party
    drive_width = config.get("drive_bar_width", 14)
    line3_parts = []
    if config.get("show_drive", True):
        drive_max = config.get("drive_max_lines", 500)
        drive_src = config.get("drive_source", "lines")
        if drive_src == "files":
            drive_val = drive_files
        elif drive_src == "both":
            drive_val = drive_files + drive_lines
        else:
            drive_val = drive_lines
        drive_pct = min(100.0, (drive_val / drive_max * 100)) if drive_max > 0 else 0
        # Anti Form check
        if is_anti_form(hp_pct, mp_pct, drive_pct):
            form_name = "Anti Form"
            drive_color_name = "dim"
        elif config.get("show_drive_form", True):
            form_name = resolve_drive_form(data, config)
            drive_color_name = resolve_drive_form_color_name(data, config)
        else:
            form_name = "Drive"
            drive_color_name = resolve_drive_form_color_name(data, config)
        drive_bar = render_bar(drive_pct, drive_width, drive_color_name, icon=DRIVE_ICON)
        dc = ANSI.get(drive_color_name, ANSI["yellow"])
        line3_parts.append(f"  {drive_bar}  {dc}{FORM_ICON} {form_name}{rst}")

    if config.get("show_munny", True):
        line3_parts.append(f"{mc}{MUNNY_ICON} {munny}{rst}")

    if config.get("show_timer", True):
        journey = format_duration(duration_ms)
        line3_parts.append(f"{bld}{TIMER_ICON} {journey}{rst}")

    agent = data.get("agent") or {}
    agent_name = agent.get("name", "")
    if agent_name:
        line3_parts.append(f"{PARTY_ICON} {agent_name}")

    line3 = "  ".join(line3_parts)

    return line1 + "\n" + line2 + "\n" + line3


# ─── Main ────────────────────────────────────────────────────────

RENDERERS = {
    "classic": render_classic,
    "minimal": render_minimal,
    "full_rpg": render_full_rpg,
}

FALLBACK = f"{ANSI['cyan']}{KEYBLADE_ICON}  Keyblade{ANSI['reset']}"


def main():
    try:
        raw = sys.stdin.read()
        data = json.loads(raw) if raw.strip() else {}
    except (json.JSONDecodeError, ValueError):
        print(FALLBACK)
        return

    config = load_config()
    theme = config.get("theme", "classic")
    renderer = RENDERERS.get(theme, render_classic)

    try:
        output = renderer(data, config)
        print(output)
    except Exception:
        print(FALLBACK)


if __name__ == "__main__":
    main()

What it shows

  • Context window remaining as an MP bar with a percentage
  • Claude plan usage remaining as an HP bar (5-hour window by default, 7-day or a session-cost budget via config)
  • The current model as a Kingdom Hearts keyblade name (Opus is Ultima Weapon, Sonnet is Oathkeeper, Haiku is Kingdom Key, anything else is Starlight)
  • Current directory name styled as a world, with the git branch appended
  • Session cost as munny (dollars times 100)
  • Reasoning effort level as a Drive Form name (Valor, Wisdom, Limit, Master, or Final Form)
  • A SAVE POINT badge when the git working tree just became clean
  • A DANGER marker when HP drops below 20 percent (a warning triangle shows between 20 and 50 percent instead), and an MP CHARGE marker when context remaining drops below 10 percent
  • An Anti Form label when context is nearly exhausted, or when plan usage is nearly exhausted and the Drive gauge is above 90 percent
  • In the full_rpg theme: a level and EXP counter from lines changed, a Drive gauge of uncommitted changes, a session timer, and the active sub-agent name
  • A LEVEL UP notification when the calculated level increases

Requirements

  • Python 3 (standard library only)
  • git on PATH for branch name and uncommitted-change counts
  • wc for counting lines in untracked files
  • Network access to api.anthropic.com to fetch plan usage for the HP bar
  • On macOS, the security command to read the Claude Code OAuth token from the Keychain; on Linux, a Claude Code credentials file
  • A terminal that renders Unicode block characters and the key emoji; no Nerd Font needed
  • Truecolor terminal for the full Kingdom Hearts palette; falls back to 16-color ANSI, and NO_COLOR or CLICOLOR=0 disables color

Behavior notes

  • The keyblade name follows the model: Opus shows Ultima Weapon, Sonnet shows Oathkeeper, Haiku shows Kingdom Key, and Fable 5 falls through to Starlight
  • The Drive Form follows the effort level: low shows Valor Form, medium shows Wisdom Form, high shows Limit Form, xhigh shows Master Form; when effort is absent it defaults to high (Limit Form)
  • In the near-full-context scenario the MP bar hits 0 percent, an MP CHARGE marker appears, and the Drive Form is overridden to a dimmed Anti Form
  • The SAVE POINT badge disappears in the dirty-branch scenario and appears in every clean-tree scenario
  • When context fields are null or missing (new session, just after /compact) the MP bar shows 100 percent
  • The MP percentage is derived by scanning the transcript file when readable, so it can differ from the payload's remaining_percentage field
  • Munny tracks session cost: $0.41 renders as 41, $4.12 as 412, and $0 as 0
  • In a non-git directory the world shows only the directory name with no branch; in a worktree it shows the worktree directory and its branch
  • The HP bar comes from the Anthropic usage API rather than the payload, so it stays at 100 percent even when the payload reports hot rate limits (as when the API is unreachable and no cache exists)
  • Payload fields for vim mode, PR status, session name, output style, and rate_limits are never read or displayed
  • The sub-agent name in the sub-agent scenario is not shown because the default classic theme omits it; only the full_rpg theme renders it

More status lines

Dreambase Panel

150 copies

╭─ CONTEXT ────────────────────────────────────────────╮ ████▊░░░░░░░░░░░░░░░░░ 22% of 200K · GOOD ├─ STATS ──────────────────────────────────────────────┤ 44.0K 1.4K $0.41 ⏱ 10m 12s +128/-34 ├─ REPO ───────────────────────────────────────────────┤ Opus 4.8 · main · 16:21 ╰──────────────────────────────────────────────────────╯
app · main · Opus-4.8 · effort high · ctx 22% · 5h 26% ↻2h6m · 7d 7% ↻2d0h
Opus 4.8 [high] ~/app main 16:20:55 | ⛁ ██░░░░░░░░ 22% | 5h ●◔○○○ 26% ↻2h7m | 7d ◔○○○○○○ 7% ↻2d1h │ $0.41 ⏱ 10m
Opus 4.8 main app 44k/200k $0.41

Activity Feed

36 copies

Opus 4.8 high ctx 22% app (main) PR #1287 $0.41 · 10m +128 -34 Session 74% left Resets in 2h 6m Weekly 93% left Resets in 2d 0h Fable 85% left Resets in 2d 0h ◐ Read:index.ts ✓ Read×1 ✓ Bash×1 ✓ Edit×1 ✓ Grep×1 ◐ code-reviewer Review the change for regressions
████▊░░░░░░░░░░░░░░░░░ 22% of 200K · GOOD 44.0K 1.4K $0.41 ⏱ 10m 12s +128/-34 Opus 4.8 · main · 16:22