@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/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()