Keyblade Status bar
python
Kingdom Hearts themed statusline and command menu for Claude Code.
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
✧ ████████████████ 100% 🗝 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()