codebyplan 1.10.3 → 1.11.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.
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env python3
2
+ # @hook: NOT-A-HOOK (statusLine renderer, invoked via the cbp-statusline.sh dispatcher)
3
+ # Claude Code Status Line — python renderer.
4
+ # Byte-identical output to the bash renderer in cbp-statusline.sh and the node
5
+ # renderer in cbp-statusline.mjs. See that .sh file for the full option/env/seam
6
+ # contract. Selected via .codebyplan/statusline.local.json -> {"renderer":"python"}.
7
+ #
8
+ # Reads stdin JSON; resolves .codebyplan/ root from CBP_STATUSLINE_ROOT (set by the
9
+ # dispatcher) or argv[1], else the script's ../.. directory. Never throws to stdout.
10
+
11
+ import json
12
+ import math
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ import time
17
+
18
+
19
+ def _get(obj, keys, dflt):
20
+ # jq `//` semantics: default on null/missing (and false → false).
21
+ cur = obj
22
+ for k in keys:
23
+ if not isinstance(cur, dict):
24
+ return dflt
25
+ cur = cur.get(k)
26
+ return dflt if cur is None else cur
27
+
28
+
29
+ def main():
30
+ raw_input = sys.stdin.read()
31
+ try:
32
+ data = json.loads(raw_input)
33
+ except Exception:
34
+ data = {}
35
+ if not isinstance(data, dict):
36
+ data = {}
37
+
38
+ # ---- Root resolution (matches the dispatcher contract) -------------------
39
+ root = os.environ.get("CBP_STATUSLINE_ROOT") or (sys.argv[1] if len(sys.argv) > 1 else "")
40
+ if not root:
41
+ root = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
42
+
43
+ MODEL_ID = _get(data, ["model", "id"], "")
44
+ MODEL_NAME = _get(data, ["model", "display_name"], "")
45
+ CWD = _get(data, ["cwd"], "")
46
+ WS_CURRENT_DIR = _get(data, ["workspace", "current_dir"], "")
47
+ WS_REPO_HOST = _get(data, ["workspace", "repo", "host"], "")
48
+ WS_REPO_OWNER = _get(data, ["workspace", "repo", "owner"], "")
49
+ WS_REPO_NAME = _get(data, ["workspace", "repo", "name"], "")
50
+ COST = _get(data, ["cost", "total_cost_usd"], 0)
51
+ DURATION = _get(data, ["cost", "total_duration_ms"], 0)
52
+ API_DURATION = _get(data, ["cost", "total_api_duration_ms"], 0)
53
+ LINES_ADD = _get(data, ["cost", "total_lines_added"], 0)
54
+ LINES_DEL = _get(data, ["cost", "total_lines_removed"], 0)
55
+ CTX_SIZE = _get(data, ["context_window", "context_window_size"], 200000)
56
+ CTX_PCT = _get(data, ["context_window", "used_percentage"], 0)
57
+ CUR_IN = _get(data, ["context_window", "current_usage", "input_tokens"], 0)
58
+ CUR_OUT = _get(data, ["context_window", "current_usage", "output_tokens"], 0)
59
+ CACHE_CREATE = _get(data, ["context_window", "current_usage", "cache_creation_input_tokens"], 0)
60
+ CACHE_READ = _get(data, ["context_window", "current_usage", "cache_read_input_tokens"], 0)
61
+ EXCEEDS_200K = _get(data, ["exceeds_200k_tokens"], False)
62
+ EFFORT = _get(data, ["effort", "level"], "")
63
+ THINKING = _get(data, ["thinking", "enabled"], False)
64
+ RATE_5H_PCT = _get(data, ["rate_limits", "five_hour", "used_percentage"], "")
65
+ RATE_5H_RESETS = _get(data, ["rate_limits", "five_hour", "resets_at"], 0)
66
+ RATE_7D_PCT = _get(data, ["rate_limits", "seven_day", "used_percentage"], "")
67
+ RATE_7D_RESETS = _get(data, ["rate_limits", "seven_day", "resets_at"], 0)
68
+ SESSION_NAME = _get(data, ["session_name"], "")
69
+ OUTPUT_STYLE = _get(data, ["output_style", "name"], "")
70
+ VIM_MODE = _get(data, ["vim", "mode"], "")
71
+ AGENT_NAME = _get(data, ["agent", "name"], "")
72
+ PR_NUMBER = _get(data, ["pr", "number"], "")
73
+ PR_URL = _get(data, ["pr", "url"], "")
74
+ PR_REVIEW_STATE = _get(data, ["pr", "review_state"], "")
75
+ WT_NAME = _get(data, ["worktree", "name"], "")
76
+ WT_PATH = _get(data, ["worktree", "path"], "")
77
+ WT_BRANCH = _get(data, ["worktree", "branch"], "")
78
+ WT_ORIG_BRANCH = _get(data, ["worktree", "original_branch"], "")
79
+
80
+ # ---- Config: line toggles + no_color -------------------------------------
81
+ cfg = {
82
+ "identity": True, "context": True, "cost": True,
83
+ "rate_limits": True, "repo_pr": True, "worktree": True, "no_color": False,
84
+ }
85
+ try:
86
+ with open(os.path.join(root, ".codebyplan", "statusline.json"), "r", encoding="utf-8") as fh:
87
+ parsed = json.load(fh)
88
+ if isinstance(parsed, dict):
89
+ if isinstance(parsed.get("no_color"), bool):
90
+ cfg["no_color"] = parsed["no_color"]
91
+ lines = parsed.get("lines")
92
+ if isinstance(lines, dict):
93
+ for k in ["identity", "context", "cost", "rate_limits", "repo_pr", "worktree"]:
94
+ if isinstance(lines.get(k), bool):
95
+ cfg[k] = lines[k]
96
+ except Exception:
97
+ pass # absent / invalid → keep defaults
98
+
99
+ def should_show(env_suffix, cfg_value):
100
+ # env HIDE=1 > config false > default show
101
+ if os.environ.get("CBP_STATUSLINE_HIDE_" + env_suffix) == "1":
102
+ return False
103
+ if cfg_value is False:
104
+ return False
105
+ return True
106
+
107
+ # ---- Colour setup (env > config) -----------------------------------------
108
+ no_color = (
109
+ (os.environ.get("NO_COLOR") not in (None, ""))
110
+ or os.environ.get("CBP_STATUSLINE_NO_COLOR") == "1"
111
+ or cfg["no_color"] is True
112
+ )
113
+ if no_color:
114
+ RST = DIM = BOLD = GREEN = YELLOW = RED = CYAN = MAGENTA = BLUE = ""
115
+ else:
116
+ RST = "\x1b[0m"
117
+ DIM = "\x1b[2m"
118
+ BOLD = "\x1b[1m"
119
+ GREEN = "\x1b[32m"
120
+ YELLOW = "\x1b[33m"
121
+ RED = "\x1b[31m"
122
+ CYAN = "\x1b[36m"
123
+ MAGENTA = "\x1b[35m"
124
+ BLUE = "\x1b[34m"
125
+
126
+ # ---- Numeric helpers (integer round-half-up; cross-runtime identical) -----
127
+ def num_str(n):
128
+ try:
129
+ x = float(n)
130
+ except (TypeError, ValueError):
131
+ return str(n)
132
+ if x.is_integer():
133
+ return str(int(x))
134
+ return str(n)
135
+
136
+ def fmt_k(val):
137
+ v = float(val)
138
+ if v >= 1000000:
139
+ t = math.floor((v + 50000) / 100000)
140
+ return "%d.%dM" % (t // 10, t % 10)
141
+ elif v >= 1000:
142
+ t = math.floor((v + 50) / 100)
143
+ return "%d.%dK" % (t // 10, t % 10)
144
+ return str(int(v))
145
+
146
+ def fmt_cost(c):
147
+ n = math.floor(float(c) * 10000 + 0.5)
148
+ return "$%d.%04d" % (n // 10000, n % 10000)
149
+
150
+ def fmt_dur(ms):
151
+ secs = math.trunc(float(ms) / 1000)
152
+ if secs >= 3600:
153
+ return "%dh%dm" % (secs // 3600, (secs % 3600) // 60)
154
+ if secs >= 60:
155
+ return "%dm%ds" % (secs // 60, secs % 60)
156
+ return "%ds" % secs
157
+
158
+ def cbp_now():
159
+ nv = os.environ.get("CBP_STATUSLINE_NOW")
160
+ if nv not in (None, ""):
161
+ return math.trunc(float(nv))
162
+ return math.floor(time.time())
163
+
164
+ def fmt_rel_time(epoch):
165
+ delta = math.trunc(float(epoch)) - cbp_now()
166
+ if delta <= 0:
167
+ return "now"
168
+ if delta >= 86400:
169
+ return "%dd" % (delta // 86400)
170
+ if delta >= 3600:
171
+ return "%dh" % (delta // 3600)
172
+ return "%dm" % (delta // 60)
173
+
174
+ def gte(v, t):
175
+ try:
176
+ return float(v) >= t
177
+ except (TypeError, ValueError):
178
+ return False
179
+
180
+ # ---- Folder + branch -----------------------------------------------------
181
+ folder = ""
182
+ if CWD:
183
+ folder = os.path.basename(CWD)
184
+ elif WS_CURRENT_DIR:
185
+ folder = os.path.basename(WS_CURRENT_DIR)
186
+ branch = WT_BRANCH
187
+ if not branch and CWD:
188
+ try:
189
+ res = subprocess.run(
190
+ ["git", "-C", CWD, "rev-parse", "--abbrev-ref", "HEAD"],
191
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
192
+ )
193
+ branch = res.stdout.rstrip() if res.returncode == 0 else ""
194
+ except Exception:
195
+ branch = ""
196
+
197
+ out = []
198
+
199
+ # ===== LINE 1 — Identity =====
200
+ if should_show("IDENTITY", cfg["identity"]):
201
+ l1 = ""
202
+ if folder:
203
+ l1 = "%s%s%s%s" % (BOLD, BLUE, folder, RST)
204
+ if branch:
205
+ l1 += " %s⎇%s%s%s%s" % (DIM, RST, CYAN, branch, RST)
206
+ l1 += " "
207
+ if WT_NAME:
208
+ l1 += "%swt:%s%s%s%s " % (DIM, RST, MAGENTA, WT_NAME, RST)
209
+ elif SESSION_NAME:
210
+ l1 += "%ssession:%s%s%s%s " % (DIM, RST, MAGENTA, SESSION_NAME, RST)
211
+ elif AGENT_NAME:
212
+ l1 += "%sagent:%s%s%s%s " % (DIM, RST, MAGENTA, AGENT_NAME, RST)
213
+ if MODEL_NAME:
214
+ l1 += "%s%s%s%s" % (BOLD, CYAN, MODEL_NAME, RST)
215
+ elif MODEL_ID:
216
+ l1 += "%s%s%s%s" % (BOLD, CYAN, MODEL_ID, RST)
217
+ if EFFORT:
218
+ l1 += " %seffort:%s%s" % (DIM, RST, EFFORT)
219
+ if THINKING is True:
220
+ l1 += " %sthinking:on%s" % (YELLOW, RST)
221
+ if OUTPUT_STYLE and OUTPUT_STYLE != "default":
222
+ l1 += " %sstyle:%s%s" % (DIM, RST, OUTPUT_STYLE)
223
+ if VIM_MODE:
224
+ l1 += " %s[%s]%s" % (DIM, VIM_MODE, RST)
225
+ if l1:
226
+ out.append(l1)
227
+
228
+ # ===== LINE 2 — Context window =====
229
+ if should_show("CONTEXT", cfg["context"]):
230
+ if gte(CTX_PCT, 75):
231
+ bar_color = RED
232
+ elif gte(CTX_PCT, 50):
233
+ bar_color = YELLOW
234
+ else:
235
+ bar_color = GREEN
236
+
237
+ filled = math.floor((math.trunc(float(CTX_PCT)) + 4) / 5)
238
+ if filled > 20:
239
+ filled = 20
240
+ empty = 20 - filled
241
+ bar = ("▓" * filled) + ("░" * empty)
242
+
243
+ l2 = "%s%s%s %s%s%%%s%s/%s%s" % (
244
+ bar_color, bar, RST, bar_color, num_str(CTX_PCT), RST, DIM, fmt_k(CTX_SIZE), RST,
245
+ )
246
+ l2 += " %sin:%s%s%s%s %sout:%s%s%s%s %scache_cr:%s%s %scache_rd:%s%s" % (
247
+ DIM, RST, BLUE, fmt_k(CUR_IN), RST,
248
+ DIM, RST, MAGENTA, fmt_k(CUR_OUT), RST,
249
+ DIM, RST, fmt_k(CACHE_CREATE),
250
+ DIM, RST, fmt_k(CACHE_READ),
251
+ )
252
+ if EXCEEDS_200K is True:
253
+ l2 += " %s⚠ 200k+%s" % (YELLOW, RST)
254
+ out.append(l2)
255
+
256
+ # ===== LINE 3 — Cost =====
257
+ if should_show("COST", cfg["cost"]):
258
+ l3 = "%s%s%s %sdur:%s%s %sapi:%s%s %s+%s%s %s-%s%s %slines%s" % (
259
+ GREEN, fmt_cost(COST), RST,
260
+ DIM, RST, fmt_dur(DURATION),
261
+ DIM, RST, fmt_dur(API_DURATION),
262
+ GREEN, num_str(LINES_ADD), RST,
263
+ RED, num_str(LINES_DEL), RST,
264
+ DIM, RST,
265
+ )
266
+ out.append(l3)
267
+
268
+ # ===== LINE 4 — Rate limits =====
269
+ if should_show("RATE_LIMITS", cfg["rate_limits"]):
270
+ has_5h = RATE_5H_PCT != "" and str(RATE_5H_RESETS) != "0"
271
+ has_7d = RATE_7D_PCT != "" and str(RATE_7D_RESETS) != "0"
272
+ if has_5h or has_7d:
273
+ l4 = ""
274
+ if has_5h:
275
+ if gte(RATE_5H_PCT, 80):
276
+ c5 = RED
277
+ elif gte(RATE_5H_PCT, 60):
278
+ c5 = YELLOW
279
+ else:
280
+ c5 = GREEN
281
+ l4 = "%s5h:%s%s%s%%%s %s(resets in %s)%s" % (
282
+ DIM, RST, c5, num_str(RATE_5H_PCT), RST, DIM, fmt_rel_time(RATE_5H_RESETS), RST,
283
+ )
284
+ if has_7d:
285
+ if gte(RATE_7D_PCT, 80):
286
+ c7 = RED
287
+ elif gte(RATE_7D_PCT, 60):
288
+ c7 = YELLOW
289
+ else:
290
+ c7 = GREEN
291
+ seg7 = "%s7d:%s%s%s%%%s %s(resets in %s)%s" % (
292
+ DIM, RST, c7, num_str(RATE_7D_PCT), RST, DIM, fmt_rel_time(RATE_7D_RESETS), RST,
293
+ )
294
+ l4 = ("%s %s|%s %s" % (l4, DIM, RST, seg7)) if l4 else seg7
295
+ out.append(l4)
296
+
297
+ # ===== LINE 5 — Repo / PR =====
298
+ if should_show("REPO_PR", cfg["repo_pr"]):
299
+ l5 = ""
300
+ if WS_REPO_HOST:
301
+ l5 = "%s%s/%s/%s%s" % (DIM, WS_REPO_HOST, WS_REPO_OWNER, WS_REPO_NAME, RST)
302
+ if PR_NUMBER != "":
303
+ pr_seg = "%sPR%s %s#%s%s" % (DIM, RST, CYAN, num_str(PR_NUMBER), RST)
304
+ if PR_REVIEW_STATE:
305
+ pr_seg += " %s%s%s" % (DIM, PR_REVIEW_STATE, RST)
306
+ if PR_URL:
307
+ pr_seg += " %s%s%s" % (BLUE, PR_URL, RST)
308
+ l5 = ("%s %s|%s %s" % (l5, DIM, RST, pr_seg)) if l5 else pr_seg
309
+ if l5:
310
+ out.append(l5)
311
+
312
+ # ===== LINE 6 — Worktree =====
313
+ if should_show("WORKTREE", cfg["worktree"]):
314
+ if WT_NAME:
315
+ l6 = "%s%s%s %s@%s %s%s%s" % (MAGENTA, WT_NAME, RST, DIM, RST, CYAN, WT_BRANCH, RST)
316
+ if WT_ORIG_BRANCH and WT_ORIG_BRANCH != WT_BRANCH:
317
+ l6 += " %s(was: %s)%s" % (DIM, WT_ORIG_BRANCH, RST)
318
+ wt_path_disp = WT_PATH
319
+ if len(WT_PATH) > 60:
320
+ wt_path_disp = "..." + WT_PATH[-57:]
321
+ l6 += " %s%s%s" % (DIM, wt_path_disp, RST)
322
+ out.append(l6)
323
+
324
+ sys.stdout.write(("\n".join(out) + "\n") if out else "")
325
+
326
+
327
+ try:
328
+ main()
329
+ except Exception:
330
+ # Statusline must never error to stdout.
331
+ sys.exit(0)