@raavalabs/claude-statusline 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/config.default.json +6 -0
- package/fonts/FONTS.md +159 -0
- package/package.json +49 -0
- package/powerline.py +613 -0
- package/setup.sh +285 -0
package/powerline.py
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Claude Code Powerline Status Line.
|
|
3
|
+
|
|
4
|
+
A customizable status line for Claude Code with 3 styles (powerline, capsule, tui)
|
|
5
|
+
and 4 themes (dark, nord, tokyonight, gruvbox). Reads JSON from stdin,
|
|
6
|
+
renders a 2-line status bar with model info, git status, context usage,
|
|
7
|
+
rate limits, and session metrics.
|
|
8
|
+
|
|
9
|
+
No external dependencies — uses only Python 3 stdlib.
|
|
10
|
+
|
|
11
|
+
https://github.com/demigod97/claude-statusline
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
# ─── Nerd Font Glyphs ─────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
PL_RIGHT = "\ue0b0" # Powerline right arrow
|
|
24
|
+
PL_RIGHT_THIN = "\ue0b1" # Powerline right thin arrow
|
|
25
|
+
CAP_LEFT = "\ue0b6" # Rounded left cap
|
|
26
|
+
CAP_RIGHT = "\ue0b4" # Rounded right cap
|
|
27
|
+
GIT_BRANCH = "\ue0a0" # Git branch icon
|
|
28
|
+
|
|
29
|
+
# ─── Box Drawing (Heavy) ──────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
BOX_TL = "\u250f" # ┏
|
|
32
|
+
BOX_TR = "\u2513" # ┓
|
|
33
|
+
BOX_BL = "\u2517" # ┗
|
|
34
|
+
BOX_BR = "\u251b" # ┛
|
|
35
|
+
BOX_H = "\u2501" # ━
|
|
36
|
+
BOX_V = "\u2503" # ┃
|
|
37
|
+
BOX_TJ = "\u2533" # ┳ (top T-junction for mid separator)
|
|
38
|
+
BOX_BJ = "\u253b" # ┻ (bottom T-junction)
|
|
39
|
+
BOX_LJ = "\u2523" # ┣ (left T-junction)
|
|
40
|
+
BOX_RJ = "\u252b" # ┫ (right T-junction)
|
|
41
|
+
|
|
42
|
+
# ─── Bar Characters ───────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
BAR_FULL = "\u2588" # █
|
|
45
|
+
BAR_EMPTY = "\u2591" # ░
|
|
46
|
+
|
|
47
|
+
# ─── ANSI Helpers ─────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
RESET = "\x1b[0m"
|
|
50
|
+
BOLD = "\x1b[1m"
|
|
51
|
+
DIM = "\x1b[2m"
|
|
52
|
+
|
|
53
|
+
def _hex_to_rgb(h: str) -> tuple:
|
|
54
|
+
h = h.lstrip("#")
|
|
55
|
+
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
|
56
|
+
|
|
57
|
+
def ansi_fg(hex_color: str) -> str:
|
|
58
|
+
r, g, b = _hex_to_rgb(hex_color)
|
|
59
|
+
return f"\x1b[38;2;{r};{g};{b}m"
|
|
60
|
+
|
|
61
|
+
def ansi_bg(hex_color: str) -> str:
|
|
62
|
+
r, g, b = _hex_to_rgb(hex_color)
|
|
63
|
+
return f"\x1b[48;2;{r};{g};{b}m"
|
|
64
|
+
|
|
65
|
+
def styled(text: str, fg: str, bg: str) -> str:
|
|
66
|
+
return f"{ansi_fg(fg)}{ansi_bg(bg)} {text} {RESET}"
|
|
67
|
+
|
|
68
|
+
# ─── Theme Definitions ────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
THEMES = {
|
|
71
|
+
"dark": {
|
|
72
|
+
"dir": {"bg": "#8b4513", "fg": "#ffffff"},
|
|
73
|
+
"git": {"bg": "#444444", "fg": "#90EE90"},
|
|
74
|
+
"model": {"bg": "#1a1a1a", "fg": "#87CEEB"},
|
|
75
|
+
"session": {"bg": "#000000", "fg": "#00ffff"},
|
|
76
|
+
"context": {"bg": "#1a1a1a", "fg": "#aaaaaa"},
|
|
77
|
+
"rate": {"bg": "#1a1a1a", "fg": "#d0d0d0"},
|
|
78
|
+
"tokens": {"bg": "#1a1a1a", "fg": "#888888"},
|
|
79
|
+
"lines": {"bg": "#1a1a1a", "fg": "#aaaaaa"},
|
|
80
|
+
"vim": {"bg": "#005f00", "fg": "#00ff00"},
|
|
81
|
+
"duration": {"bg": "#1a1a1a", "fg": "#666666"},
|
|
82
|
+
},
|
|
83
|
+
"nord": {
|
|
84
|
+
"dir": {"bg": "#434c5e", "fg": "#d8dee9"},
|
|
85
|
+
"git": {"bg": "#3b4252", "fg": "#a3be8c"},
|
|
86
|
+
"model": {"bg": "#4c566a", "fg": "#81a1c1"},
|
|
87
|
+
"session": {"bg": "#2e3440", "fg": "#88c0d0"},
|
|
88
|
+
"context": {"bg": "#2e3440", "fg": "#d8dee9"},
|
|
89
|
+
"rate": {"bg": "#2e3440", "fg": "#d8dee9"},
|
|
90
|
+
"tokens": {"bg": "#2e3440", "fg": "#4c566a"},
|
|
91
|
+
"lines": {"bg": "#2e3440", "fg": "#88c0d0"},
|
|
92
|
+
"vim": {"bg": "#a3be8c", "fg": "#2e3440"},
|
|
93
|
+
"duration": {"bg": "#2e3440", "fg": "#4c566a"},
|
|
94
|
+
},
|
|
95
|
+
"tokyonight": {
|
|
96
|
+
"dir": {"bg": "#2f334d", "fg": "#82aaff"},
|
|
97
|
+
"git": {"bg": "#1e2030", "fg": "#c3e88d"},
|
|
98
|
+
"model": {"bg": "#1a1b26", "fg": "#7aa2f7"},
|
|
99
|
+
"session": {"bg": "#1a1b26", "fg": "#bb9af7"},
|
|
100
|
+
"context": {"bg": "#1a1b26", "fg": "#a9b1d6"},
|
|
101
|
+
"rate": {"bg": "#1a1b26", "fg": "#a9b1d6"},
|
|
102
|
+
"tokens": {"bg": "#1a1b26", "fg": "#565f89"},
|
|
103
|
+
"lines": {"bg": "#1a1b26", "fg": "#73daca"},
|
|
104
|
+
"vim": {"bg": "#9ece6a", "fg": "#1a1b26"},
|
|
105
|
+
"duration": {"bg": "#1a1b26", "fg": "#565f89"},
|
|
106
|
+
},
|
|
107
|
+
"gruvbox": {
|
|
108
|
+
"dir": {"bg": "#504945", "fg": "#ebdbb2"},
|
|
109
|
+
"git": {"bg": "#3c3836", "fg": "#b8bb26"},
|
|
110
|
+
"model": {"bg": "#665c54", "fg": "#83a598"},
|
|
111
|
+
"session": {"bg": "#282828", "fg": "#8ec07c"},
|
|
112
|
+
"context": {"bg": "#282828", "fg": "#ebdbb2"},
|
|
113
|
+
"rate": {"bg": "#282828", "fg": "#ebdbb2"},
|
|
114
|
+
"tokens": {"bg": "#282828", "fg": "#665c54"},
|
|
115
|
+
"lines": {"bg": "#282828", "fg": "#fe8019"},
|
|
116
|
+
"vim": {"bg": "#b8bb26", "fg": "#282828"},
|
|
117
|
+
"duration": {"bg": "#282828", "fg": "#665c54"},
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# ─── Context Color Thresholds ─────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
def context_fg(pct: float) -> str:
|
|
124
|
+
if pct >= 90:
|
|
125
|
+
return "#ff5050" # red
|
|
126
|
+
if pct >= 75:
|
|
127
|
+
return "#ffc832" # orange
|
|
128
|
+
if pct >= 50:
|
|
129
|
+
return "#ffff64" # yellow
|
|
130
|
+
return "#64ff64" # green
|
|
131
|
+
|
|
132
|
+
def rate_fg(pct: float) -> str:
|
|
133
|
+
if pct >= 80:
|
|
134
|
+
return "#ff5050"
|
|
135
|
+
if pct >= 60:
|
|
136
|
+
return "#ffc832"
|
|
137
|
+
if pct >= 40:
|
|
138
|
+
return "#ffff64"
|
|
139
|
+
return "#64ff64"
|
|
140
|
+
|
|
141
|
+
# ─── Token Formatting ─────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
def fmt_tokens(n: int) -> str:
|
|
144
|
+
if n >= 1_000_000:
|
|
145
|
+
return f"{n / 1_000_000:.1f}M"
|
|
146
|
+
if n >= 1_000:
|
|
147
|
+
return f"{n / 1_000:.0f}k"
|
|
148
|
+
return str(n)
|
|
149
|
+
|
|
150
|
+
def fmt_duration(ms: int) -> str:
|
|
151
|
+
if ms <= 0:
|
|
152
|
+
return ""
|
|
153
|
+
secs = ms // 1000
|
|
154
|
+
mins = secs // 60
|
|
155
|
+
if mins < 60:
|
|
156
|
+
return f"{mins}m" if mins > 0 else "<1m"
|
|
157
|
+
hours = mins // 60
|
|
158
|
+
rem = mins % 60
|
|
159
|
+
return f"{hours}h {rem}m"
|
|
160
|
+
|
|
161
|
+
def fmt_reset_time(resets_at) -> str:
|
|
162
|
+
if not resets_at:
|
|
163
|
+
return ""
|
|
164
|
+
now = time.time()
|
|
165
|
+
diff = resets_at - now
|
|
166
|
+
if diff <= 0:
|
|
167
|
+
return ""
|
|
168
|
+
mins = int(diff // 60)
|
|
169
|
+
if mins < 60:
|
|
170
|
+
return f"{mins}m"
|
|
171
|
+
hours = mins // 60
|
|
172
|
+
rem = mins % 60
|
|
173
|
+
return f"{hours}h {rem}m" if rem > 0 else f"{hours}h"
|
|
174
|
+
|
|
175
|
+
# ─── Bar Rendering ────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
def render_bar(pct: float, width: int = 10) -> str:
|
|
178
|
+
p = min(100.0, max(0.0, pct))
|
|
179
|
+
filled = round(p / 100 * width)
|
|
180
|
+
return BAR_FULL * filled + BAR_EMPTY * (width - filled)
|
|
181
|
+
|
|
182
|
+
def colored_bar(pct: float, width: int = 10) -> str:
|
|
183
|
+
bar = render_bar(pct, width)
|
|
184
|
+
color = context_fg(pct)
|
|
185
|
+
return f"{ansi_fg(color)}{bar}{RESET}"
|
|
186
|
+
|
|
187
|
+
def rate_bar(pct: float, width: int = 4) -> str:
|
|
188
|
+
bar = render_bar(pct, width)
|
|
189
|
+
color = rate_fg(pct)
|
|
190
|
+
return f"{ansi_fg(color)}{bar}{RESET}"
|
|
191
|
+
|
|
192
|
+
# ─── ANSI Visible Length ───────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
|
|
195
|
+
|
|
196
|
+
def visible_len(s: str) -> int:
|
|
197
|
+
stripped = ANSI_ESCAPE_RE.sub("", s)
|
|
198
|
+
width = 0
|
|
199
|
+
for ch in stripped:
|
|
200
|
+
cp = ord(ch)
|
|
201
|
+
if (0x1100 <= cp <= 0x115F or
|
|
202
|
+
cp in (0x2329, 0x232A) or
|
|
203
|
+
0x2E80 <= cp <= 0xA4CF or
|
|
204
|
+
0xAC00 <= cp <= 0xD7A3 or
|
|
205
|
+
0xF900 <= cp <= 0xFAFF or
|
|
206
|
+
0xFE10 <= cp <= 0xFE6F or
|
|
207
|
+
0xFF00 <= cp <= 0xFF60 or
|
|
208
|
+
0xFFE0 <= cp <= 0xFFE6 or
|
|
209
|
+
0x1F300 <= cp <= 0x1FAFF or
|
|
210
|
+
0x20000 <= cp <= 0x3FFFD):
|
|
211
|
+
width += 2
|
|
212
|
+
else:
|
|
213
|
+
width += 1
|
|
214
|
+
return width
|
|
215
|
+
|
|
216
|
+
def pad_right(s: str, target_width: int) -> str:
|
|
217
|
+
current = visible_len(s)
|
|
218
|
+
if current >= target_width:
|
|
219
|
+
return s
|
|
220
|
+
return s + " " * (target_width - current)
|
|
221
|
+
|
|
222
|
+
# ─── Git Cache ─────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
GIT_CACHE_TTL = 5
|
|
225
|
+
|
|
226
|
+
def _extract_session_id(transcript_path: str) -> str:
|
|
227
|
+
if transcript_path:
|
|
228
|
+
base = os.path.splitext(os.path.basename(transcript_path))[0]
|
|
229
|
+
if base:
|
|
230
|
+
return base
|
|
231
|
+
return ""
|
|
232
|
+
|
|
233
|
+
def _run_git(args: list, cwd: str) -> str:
|
|
234
|
+
try:
|
|
235
|
+
result = subprocess.run(
|
|
236
|
+
["git"] + args,
|
|
237
|
+
capture_output=True, text=True, timeout=1, cwd=cwd,
|
|
238
|
+
)
|
|
239
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
240
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
241
|
+
return ""
|
|
242
|
+
|
|
243
|
+
def _fetch_git_status(cwd: str) -> dict:
|
|
244
|
+
if not _run_git(["rev-parse", "--git-dir"], cwd):
|
|
245
|
+
return {}
|
|
246
|
+
|
|
247
|
+
branch = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd) or ""
|
|
248
|
+
porcelain = _run_git(["--no-optional-locks", "status", "--porcelain"], cwd)
|
|
249
|
+
|
|
250
|
+
staged = 0
|
|
251
|
+
modified = 0
|
|
252
|
+
untracked = 0
|
|
253
|
+
for line in porcelain.splitlines():
|
|
254
|
+
if len(line) < 2:
|
|
255
|
+
continue
|
|
256
|
+
x, y = line[0], line[1]
|
|
257
|
+
if x == "?":
|
|
258
|
+
untracked += 1
|
|
259
|
+
else:
|
|
260
|
+
if x in "MADRC":
|
|
261
|
+
staged += 1
|
|
262
|
+
if y in "MD":
|
|
263
|
+
modified += 1
|
|
264
|
+
|
|
265
|
+
dirty = staged > 0 or modified > 0 or untracked > 0
|
|
266
|
+
|
|
267
|
+
ahead = behind = 0
|
|
268
|
+
ab = _run_git(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"], cwd)
|
|
269
|
+
if ab and "\t" in ab:
|
|
270
|
+
parts = ab.split("\t")
|
|
271
|
+
if len(parts) == 2:
|
|
272
|
+
try:
|
|
273
|
+
behind, ahead = int(parts[0]), int(parts[1])
|
|
274
|
+
except ValueError:
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
"branch": branch,
|
|
279
|
+
"dirty": dirty,
|
|
280
|
+
"staged": staged,
|
|
281
|
+
"modified": modified,
|
|
282
|
+
"untracked": untracked,
|
|
283
|
+
"ahead": ahead,
|
|
284
|
+
"behind": behind,
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
def get_git_info(cwd: str, session_id: str) -> dict:
|
|
288
|
+
if not cwd:
|
|
289
|
+
return {}
|
|
290
|
+
|
|
291
|
+
cache_path = os.path.join(
|
|
292
|
+
os.environ.get("TMPDIR", "/tmp"),
|
|
293
|
+
f"claude-powerline-git-{session_id}.json",
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
st = os.stat(cache_path)
|
|
298
|
+
if time.time() - st.st_mtime < GIT_CACHE_TTL:
|
|
299
|
+
with open(cache_path) as f:
|
|
300
|
+
return json.load(f)
|
|
301
|
+
except (OSError, json.JSONDecodeError):
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
result = _fetch_git_status(cwd)
|
|
305
|
+
|
|
306
|
+
tmp = cache_path + ".tmp"
|
|
307
|
+
try:
|
|
308
|
+
with open(tmp, "w") as f:
|
|
309
|
+
json.dump(result, f)
|
|
310
|
+
os.replace(tmp, cache_path)
|
|
311
|
+
except OSError:
|
|
312
|
+
pass
|
|
313
|
+
|
|
314
|
+
return result
|
|
315
|
+
|
|
316
|
+
# ─── Stdin Parsing ─────────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
def parse_stdin() -> dict:
|
|
319
|
+
try:
|
|
320
|
+
raw = sys.stdin.read()
|
|
321
|
+
if not raw.strip():
|
|
322
|
+
return {}
|
|
323
|
+
return json.loads(raw)
|
|
324
|
+
except (json.JSONDecodeError, IOError):
|
|
325
|
+
return {}
|
|
326
|
+
|
|
327
|
+
def get_model(data: dict) -> str:
|
|
328
|
+
return ((data.get("model") or {}).get("display_name") or "").strip() or "Unknown"
|
|
329
|
+
|
|
330
|
+
def get_cwd(data: dict) -> str:
|
|
331
|
+
return data.get("cwd") or (data.get("workspace") or {}).get("current_dir") or ""
|
|
332
|
+
|
|
333
|
+
def get_context_pct(data: dict) -> float:
|
|
334
|
+
cw = data.get("context_window") or {}
|
|
335
|
+
pct = cw.get("used_percentage")
|
|
336
|
+
if isinstance(pct, (int, float)):
|
|
337
|
+
return min(100.0, max(0.0, float(pct)))
|
|
338
|
+
return 0.0
|
|
339
|
+
|
|
340
|
+
def get_rate_limits(data: dict) -> dict:
|
|
341
|
+
rl = data.get("rate_limits") or {}
|
|
342
|
+
result = {}
|
|
343
|
+
for key, field in [("5h", "five_hour"), ("7d", "seven_day")]:
|
|
344
|
+
window = rl.get(field)
|
|
345
|
+
if window and isinstance(window.get("used_percentage"), (int, float)):
|
|
346
|
+
result[key] = {
|
|
347
|
+
"pct": round(min(100, max(0, window["used_percentage"]))),
|
|
348
|
+
"resets_at": window.get("resets_at"),
|
|
349
|
+
}
|
|
350
|
+
return result
|
|
351
|
+
|
|
352
|
+
def get_token_counts(data: dict) -> dict:
|
|
353
|
+
usage = ((data.get("context_window") or {}).get("current_usage")) or {}
|
|
354
|
+
inp = usage.get("input_tokens", 0) or 0
|
|
355
|
+
out = usage.get("output_tokens", 0) or 0
|
|
356
|
+
cache_create = usage.get("cache_creation_input_tokens", 0) or 0
|
|
357
|
+
cache_read = usage.get("cache_read_input_tokens", 0) or 0
|
|
358
|
+
total = inp + out + cache_create + cache_read
|
|
359
|
+
if total == 0:
|
|
360
|
+
return {}
|
|
361
|
+
return {"in": inp, "out": out, "cache": cache_create + cache_read}
|
|
362
|
+
|
|
363
|
+
def get_lines_changed(data: dict) -> tuple:
|
|
364
|
+
cost = data.get("cost") or {}
|
|
365
|
+
added = cost.get("total_lines_added")
|
|
366
|
+
removed = cost.get("total_lines_removed")
|
|
367
|
+
if added is None and removed is None:
|
|
368
|
+
return (0, 0)
|
|
369
|
+
return (added or 0, removed or 0)
|
|
370
|
+
|
|
371
|
+
def get_duration_ms(data: dict) -> int:
|
|
372
|
+
return (data.get("cost") or {}).get("total_duration_ms", 0) or 0
|
|
373
|
+
|
|
374
|
+
def get_session_name(data: dict) -> str:
|
|
375
|
+
return (data.get("session_name") or "").strip()
|
|
376
|
+
|
|
377
|
+
def get_vim_mode(data: dict) -> str:
|
|
378
|
+
vim = data.get("vim") or {}
|
|
379
|
+
return (vim.get("mode") or "").strip()
|
|
380
|
+
|
|
381
|
+
# ─── Segment Data ──────────────────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
class Segment:
|
|
384
|
+
__slots__ = ("text", "key")
|
|
385
|
+
|
|
386
|
+
def __init__(self, text: str, key: str):
|
|
387
|
+
self.text = text
|
|
388
|
+
self.key = key
|
|
389
|
+
|
|
390
|
+
def build_line1_segments(data: dict, git: dict, config: dict) -> list:
|
|
391
|
+
segments = []
|
|
392
|
+
cwd = get_cwd(data)
|
|
393
|
+
path_levels = config.get("path_levels", 2)
|
|
394
|
+
|
|
395
|
+
if cwd:
|
|
396
|
+
parts = cwd.rstrip("/\\").split("/")
|
|
397
|
+
parts = [p for p in parts if p]
|
|
398
|
+
dir_text = "/".join(parts[-path_levels:]) if len(parts) >= path_levels else "/".join(parts) or "/"
|
|
399
|
+
segments.append(Segment(dir_text, "dir"))
|
|
400
|
+
|
|
401
|
+
if git and git.get("branch"):
|
|
402
|
+
git_parts = [f"{GIT_BRANCH} {git['branch']}"]
|
|
403
|
+
if git.get("dirty"):
|
|
404
|
+
git_parts.append("\u25cf")
|
|
405
|
+
if git.get("ahead", 0) > 0:
|
|
406
|
+
git_parts.append(f"\u2191{git['ahead']}")
|
|
407
|
+
if git.get("behind", 0) > 0:
|
|
408
|
+
git_parts.append(f"\u2193{git['behind']}")
|
|
409
|
+
if git.get("staged", 0) > 0:
|
|
410
|
+
git_parts.append(f"+{git['staged']}")
|
|
411
|
+
if git.get("modified", 0) > 0:
|
|
412
|
+
git_parts.append(f"~{git['modified']}")
|
|
413
|
+
if git.get("untracked", 0) > 0:
|
|
414
|
+
git_parts.append(f"?{git['untracked']}")
|
|
415
|
+
segments.append(Segment(" ".join(git_parts), "git"))
|
|
416
|
+
|
|
417
|
+
model = get_model(data)
|
|
418
|
+
segments.append(Segment(model, "model"))
|
|
419
|
+
|
|
420
|
+
session = get_session_name(data)
|
|
421
|
+
if session:
|
|
422
|
+
segments.append(Segment(session, "session"))
|
|
423
|
+
|
|
424
|
+
return segments
|
|
425
|
+
|
|
426
|
+
def build_line2_parts(data: dict, theme: dict, config: dict) -> list:
|
|
427
|
+
parts = []
|
|
428
|
+
bar_width = config.get("bar_width", 10)
|
|
429
|
+
|
|
430
|
+
pct = get_context_pct(data)
|
|
431
|
+
ctx_color = context_fg(pct)
|
|
432
|
+
bar = render_bar(pct, bar_width)
|
|
433
|
+
parts.append(f"{ansi_fg(ctx_color)}{bar} {pct:.0f}%{RESET}")
|
|
434
|
+
|
|
435
|
+
rl = get_rate_limits(data)
|
|
436
|
+
rl_strs = []
|
|
437
|
+
for label, info in [("5h", rl.get("5h")), ("7d", rl.get("7d"))]:
|
|
438
|
+
if info:
|
|
439
|
+
rb = rate_bar(info["pct"], 4)
|
|
440
|
+
rl_strs.append(f"{label}: {rb} {ansi_fg(rate_fg(info['pct']))}{info['pct']}%{RESET}")
|
|
441
|
+
if rl_strs:
|
|
442
|
+
parts.append(" ".join(rl_strs))
|
|
443
|
+
|
|
444
|
+
tokens = get_token_counts(data)
|
|
445
|
+
if tokens:
|
|
446
|
+
t_parts = [f"in:{fmt_tokens(tokens['in'])}", f"out:{fmt_tokens(tokens['out'])}"]
|
|
447
|
+
if tokens["cache"] > 0:
|
|
448
|
+
t_parts.append(f"c:{fmt_tokens(tokens['cache'])}")
|
|
449
|
+
tok_color = theme.get("tokens", {}).get("fg", "#888888")
|
|
450
|
+
parts.append(f"{ansi_fg(tok_color)}{' '.join(t_parts)}{RESET}")
|
|
451
|
+
|
|
452
|
+
added, removed = get_lines_changed(data)
|
|
453
|
+
if added > 0 or removed > 0:
|
|
454
|
+
lc_parts = []
|
|
455
|
+
if added > 0:
|
|
456
|
+
lc_parts.append(f"{ansi_fg('#64ff64')}+{added}{RESET}")
|
|
457
|
+
if removed > 0:
|
|
458
|
+
lc_parts.append(f"{ansi_fg('#ff5050')}-{removed}{RESET}")
|
|
459
|
+
parts.append(" ".join(lc_parts))
|
|
460
|
+
|
|
461
|
+
vim_mode = get_vim_mode(data)
|
|
462
|
+
if vim_mode:
|
|
463
|
+
vim_colors = theme.get("vim", {})
|
|
464
|
+
vim_fg = vim_colors.get("fg", "#00ff00")
|
|
465
|
+
vim_bg = vim_colors.get("bg", "#005f00")
|
|
466
|
+
parts.append(f"{ansi_fg(vim_fg)}{ansi_bg(vim_bg)} {vim_mode} {RESET}")
|
|
467
|
+
|
|
468
|
+
dur = get_duration_ms(data)
|
|
469
|
+
dur_str = fmt_duration(dur)
|
|
470
|
+
if dur_str:
|
|
471
|
+
dur_color = theme.get("duration", {}).get("fg", "#666666")
|
|
472
|
+
parts.append(f"{ansi_fg(dur_color)}{dur_str}{RESET}")
|
|
473
|
+
|
|
474
|
+
return parts
|
|
475
|
+
|
|
476
|
+
# ─── Style: Powerline ─────────────────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
def render_powerline_line(segments: list, theme: dict) -> str:
|
|
479
|
+
if not segments:
|
|
480
|
+
return ""
|
|
481
|
+
out = ""
|
|
482
|
+
for i, seg in enumerate(segments):
|
|
483
|
+
colors = theme.get(seg.key)
|
|
484
|
+
if not colors:
|
|
485
|
+
continue
|
|
486
|
+
s_fg = ansi_fg(colors["fg"])
|
|
487
|
+
s_bg = ansi_bg(colors["bg"])
|
|
488
|
+
out += f"{s_bg}{s_fg} {seg.text} "
|
|
489
|
+
|
|
490
|
+
if i + 1 < len(segments):
|
|
491
|
+
next_seg = segments[i + 1]
|
|
492
|
+
next_colors = theme.get(next_seg.key)
|
|
493
|
+
if next_colors:
|
|
494
|
+
out += f"{ansi_fg(colors['bg'])}{ansi_bg(next_colors['bg'])}{PL_RIGHT}"
|
|
495
|
+
else:
|
|
496
|
+
out += f"{RESET}{ansi_fg(colors['bg'])}{PL_RIGHT}"
|
|
497
|
+
else:
|
|
498
|
+
out += f"{RESET}{ansi_fg(colors['bg'])}{PL_RIGHT}"
|
|
499
|
+
out += RESET
|
|
500
|
+
return out
|
|
501
|
+
|
|
502
|
+
# ─── Style: Capsule ───────────────────────────────────────────────────────────
|
|
503
|
+
|
|
504
|
+
def render_capsule_line(segments: list, theme: dict) -> str:
|
|
505
|
+
if not segments:
|
|
506
|
+
return ""
|
|
507
|
+
pills = []
|
|
508
|
+
for seg in segments:
|
|
509
|
+
colors = theme.get(seg.key)
|
|
510
|
+
if not colors:
|
|
511
|
+
continue
|
|
512
|
+
pill = (
|
|
513
|
+
f"{ansi_fg(colors['bg'])}{CAP_LEFT}"
|
|
514
|
+
f"{ansi_bg(colors['bg'])}{ansi_fg(colors['fg'])} {seg.text} "
|
|
515
|
+
f"{RESET}{ansi_fg(colors['bg'])}{CAP_RIGHT}{RESET}"
|
|
516
|
+
)
|
|
517
|
+
pills.append(pill)
|
|
518
|
+
return " ".join(pills)
|
|
519
|
+
|
|
520
|
+
# ─── Style: TUI ───────────────────────────────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
def get_terminal_width() -> int:
|
|
523
|
+
try:
|
|
524
|
+
return shutil.get_terminal_size(fallback=(80, 24)).columns
|
|
525
|
+
except (ValueError, OSError):
|
|
526
|
+
return 80
|
|
527
|
+
|
|
528
|
+
def render_tui_box(line1: str, line2: str, theme: dict) -> str:
|
|
529
|
+
width = get_terminal_width()
|
|
530
|
+
inner = width - 4
|
|
531
|
+
|
|
532
|
+
border_fg = theme.get("model", {}).get("fg", "#87CEEB")
|
|
533
|
+
bc = ansi_fg(border_fg)
|
|
534
|
+
|
|
535
|
+
l1 = pad_right(line1, inner)
|
|
536
|
+
l2 = pad_right(line2, inner)
|
|
537
|
+
|
|
538
|
+
top = f"{bc}{BOX_TL}{BOX_H * (width - 2)}{BOX_TR}{RESET}"
|
|
539
|
+
mid1 = f"{bc}{BOX_V}{RESET} {l1} {bc}{BOX_V}{RESET}"
|
|
540
|
+
mid_sep = f"{bc}{BOX_LJ}{BOX_H * (width - 2)}{BOX_RJ}{RESET}"
|
|
541
|
+
mid2 = f"{bc}{BOX_V}{RESET} {l2} {bc}{BOX_V}{RESET}"
|
|
542
|
+
bottom = f"{bc}{BOX_BL}{BOX_H * (width - 2)}{BOX_BR}{RESET}"
|
|
543
|
+
|
|
544
|
+
return "\n".join([top, mid1, mid_sep, mid2, bottom])
|
|
545
|
+
|
|
546
|
+
# ─── Config ────────────────────────────────────────────────────────────────────
|
|
547
|
+
|
|
548
|
+
DEFAULT_CONFIG = {
|
|
549
|
+
"style": "powerline",
|
|
550
|
+
"theme": "dark",
|
|
551
|
+
"path_levels": 2,
|
|
552
|
+
"bar_width": 10,
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
def _find_config_path() -> str:
|
|
556
|
+
"""Resolve config path: user home first, then script directory."""
|
|
557
|
+
home_config = os.path.expanduser("~/.claude/statusline/config.json")
|
|
558
|
+
if os.path.isfile(home_config):
|
|
559
|
+
return home_config
|
|
560
|
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
561
|
+
local_config = os.path.join(script_dir, "config.json")
|
|
562
|
+
if os.path.isfile(local_config):
|
|
563
|
+
return local_config
|
|
564
|
+
return home_config
|
|
565
|
+
|
|
566
|
+
def load_config() -> dict:
|
|
567
|
+
config = dict(DEFAULT_CONFIG)
|
|
568
|
+
config_path = _find_config_path()
|
|
569
|
+
try:
|
|
570
|
+
with open(config_path) as f:
|
|
571
|
+
user = json.load(f)
|
|
572
|
+
config.update(user)
|
|
573
|
+
except (OSError, json.JSONDecodeError):
|
|
574
|
+
pass
|
|
575
|
+
return config
|
|
576
|
+
|
|
577
|
+
# ─── Main ──────────────────────────────────────────────────────────────────────
|
|
578
|
+
|
|
579
|
+
def main():
|
|
580
|
+
config = load_config()
|
|
581
|
+
data = parse_stdin()
|
|
582
|
+
|
|
583
|
+
style = config.get("style", "powerline")
|
|
584
|
+
theme_name = config.get("theme", "dark")
|
|
585
|
+
theme = THEMES.get(theme_name, THEMES["dark"])
|
|
586
|
+
|
|
587
|
+
transcript_path = data.get("transcript_path", "")
|
|
588
|
+
session_id = _extract_session_id(transcript_path) or str(os.getpid())
|
|
589
|
+
|
|
590
|
+
cwd = get_cwd(data)
|
|
591
|
+
git = get_git_info(cwd, session_id) if cwd else {}
|
|
592
|
+
|
|
593
|
+
line1_segments = build_line1_segments(data, git, config)
|
|
594
|
+
line2_parts = build_line2_parts(data, theme, config)
|
|
595
|
+
line2_str = f" {DIM}\u2502{RESET} ".join(line2_parts)
|
|
596
|
+
|
|
597
|
+
if style == "tui":
|
|
598
|
+
tui_l1_parts = []
|
|
599
|
+
for seg in line1_segments:
|
|
600
|
+
colors = theme.get(seg.key)
|
|
601
|
+
if colors:
|
|
602
|
+
tui_l1_parts.append(f"{ansi_fg(colors['fg'])}{seg.text}{RESET}")
|
|
603
|
+
tui_line1 = f" {DIM}\u2502{RESET} ".join(tui_l1_parts)
|
|
604
|
+
print(render_tui_box(tui_line1, line2_str, theme))
|
|
605
|
+
elif style == "capsule":
|
|
606
|
+
print(render_capsule_line(line1_segments, theme))
|
|
607
|
+
print(line2_str)
|
|
608
|
+
else:
|
|
609
|
+
print(render_powerline_line(line1_segments, theme))
|
|
610
|
+
print(line2_str)
|
|
611
|
+
|
|
612
|
+
if __name__ == "__main__":
|
|
613
|
+
main()
|