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.
@@ -1,21 +1,62 @@
1
1
  #!/bin/bash
2
2
  # @hook: NOT-A-HOOK (statusLine renderer, invoked by settings.json statusLine.command)
3
- # Claude Code Status Line - All fields visible
3
+ # Claude Code Status Line — multi-runtime dispatcher + bash renderer
4
4
  # Purpose: Renders up to 6 structured lines of Claude Code status from stdin JSON.
5
+ # This file is BOTH the dispatcher (selects bash/node/python at the top)
6
+ # AND the always-available bash renderer (inline below the dispatch block).
5
7
  # Not a PreToolUse/PostToolUse/Notification hook — do NOT register in hooks[].
6
- # Shows: Identity | Context | Cost | Rate Limits | Repo/PR | Worktree
8
+ # Shows: Identity (folder + branch + model …) | Context | Cost | Rate Limits | Repo/PR | Worktree
7
9
  #
8
10
  # --------------------------------------------------------------------------
9
- # ENV-VAR TOGGLES set to 1 to suppress a render section
10
- # CBP_STATUSLINE_HIDE_IDENTITY=1 suppress line 1 (model, effort, session, agent)
11
- # CBP_STATUSLINE_HIDE_CONTEXT=1 suppress line 2 (context bar, tokens, cache)
12
- # CBP_STATUSLINE_HIDE_COST=1 suppress line 3 (cost, duration, lines)
13
- # CBP_STATUSLINE_HIDE_RATE_LIMITS=1 suppress line 4 (5h / 7d rate limits)
14
- # CBP_STATUSLINE_HIDE_REPO_PR=1 suppress line 5 (repo host/owner/name, PR)
15
- # CBP_STATUSLINE_HIDE_WORKTREE=1 suppress line 6 (worktree name/branch/path)
16
- # CBP_STATUSLINE_NO_COLOR=1 strip all ANSI colour codes (also honoured by $NO_COLOR)
11
+ # RENDERER SELECTION (per-device, gitignored)
12
+ # .codebyplan/statusline.local.json -> { "renderer": "bash" | "node" | "python" }
13
+ # Default bash. When the chosen runtime is unavailable, silently falls back to bash.
14
+ # Set/inspect via `codebyplan statusline [bash|node|python]`.
15
+ #
16
+ # DISPLAY OPTIONS (team-shared, committed)
17
+ # .codebyplan/statusline.json -> { "lines": {identity,context,cost,rate_limits,
18
+ # repo_pr,worktree}, "no_color": bool }
19
+ #
20
+ # ENV-VAR OVERRIDES (env > config > default)
21
+ # CBP_STATUSLINE_HIDE_IDENTITY=1 suppress line 1 (folder, branch, model, effort, …)
22
+ # CBP_STATUSLINE_HIDE_CONTEXT=1 suppress line 2 (context bar, tokens, cache)
23
+ # CBP_STATUSLINE_HIDE_COST=1 suppress line 3 (cost, duration, lines)
24
+ # CBP_STATUSLINE_HIDE_RATE_LIMITS=1 suppress line 4 (5h / 7d rate limits)
25
+ # CBP_STATUSLINE_HIDE_REPO_PR=1 suppress line 5 (repo host/owner/name, PR)
26
+ # CBP_STATUSLINE_HIDE_WORKTREE=1 suppress line 6 (worktree name/branch/path)
27
+ # CBP_STATUSLINE_NO_COLOR=1 strip all ANSI colour codes (also honoured by $NO_COLOR)
28
+ #
29
+ # TEST SEAMS (no effect in normal use)
30
+ # CBP_STATUSLINE_ROOT=<dir> override the .codebyplan/ lookup root (default: repo root)
31
+ # CBP_STATUSLINE_NOW=<epoch> override "now" for relative-time rendering (determinism)
17
32
  # --------------------------------------------------------------------------
18
33
 
34
+ # ============================================================
35
+ # DISPATCHER — resolve root + renderer, delegate when possible
36
+ # ============================================================
37
+ CBP_HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null)" || CBP_HOOK_DIR="."
38
+ CBP_ROOT="${CBP_STATUSLINE_ROOT:-}"
39
+ if [ -z "$CBP_ROOT" ]; then
40
+ CBP_ROOT="$(cd "$CBP_HOOK_DIR/../.." && pwd 2>/dev/null)" || CBP_ROOT="$PWD"
41
+ fi
42
+
43
+ CBP_RENDERER="bash"
44
+ CBP_LOCAL_CFG="$CBP_ROOT/.codebyplan/statusline.local.json"
45
+ if [ -f "$CBP_LOCAL_CFG" ] && command -v jq >/dev/null 2>&1; then
46
+ _r="$(jq -r '.renderer // empty' "$CBP_LOCAL_CFG" 2>/dev/null)"
47
+ case "$_r" in bash|node|python) CBP_RENDERER="$_r" ;; esac
48
+ fi
49
+
50
+ if [ "$CBP_RENDERER" = "node" ] && command -v node >/dev/null 2>&1 \
51
+ && [ -f "$CBP_HOOK_DIR/cbp-statusline.mjs" ]; then
52
+ CBP_STATUSLINE_ROOT="$CBP_ROOT" exec node "$CBP_HOOK_DIR/cbp-statusline.mjs"
53
+ fi
54
+ if [ "$CBP_RENDERER" = "python" ] && command -v python3 >/dev/null 2>&1 \
55
+ && [ -f "$CBP_HOOK_DIR/cbp-statusline.py" ]; then
56
+ CBP_STATUSLINE_ROOT="$CBP_ROOT" exec python3 "$CBP_HOOK_DIR/cbp-statusline.py"
57
+ fi
58
+ # Fall through → inline bash renderer (default + universal fallback).
59
+
19
60
  INPUT=$(cat)
20
61
 
21
62
  # ---- One jq invocation captures all ~40 fields --------------------------------
@@ -25,8 +66,6 @@ eval "$(echo "$INPUT" | jq -r '
25
66
  @sh "CWD=\(.cwd // "")",
26
67
  @sh "WS_CURRENT_DIR=\(.workspace.current_dir // "")",
27
68
  @sh "WS_PROJECT_DIR=\(.workspace.project_dir // "")",
28
- @sh "WS_ADDED_DIRS_LEN=\((.workspace.added_dirs // []) | length)",
29
- @sh "WS_GIT_WORKTREE=\(.workspace.git_worktree // "")",
30
69
  @sh "WS_REPO_HOST=\(.workspace.repo.host // "")",
31
70
  @sh "WS_REPO_OWNER=\(.workspace.repo.owner // "")",
32
71
  @sh "WS_REPO_NAME=\(.workspace.repo.name // "")",
@@ -35,11 +74,8 @@ eval "$(echo "$INPUT" | jq -r '
35
74
  @sh "API_DURATION=\(.cost.total_api_duration_ms // 0)",
36
75
  @sh "LINES_ADD=\(.cost.total_lines_added // 0)",
37
76
  @sh "LINES_DEL=\(.cost.total_lines_removed // 0)",
38
- @sh "CTX_TOT_IN=\(.context_window.total_input_tokens // 0)",
39
- @sh "CTX_TOT_OUT=\(.context_window.total_output_tokens // 0)",
40
77
  @sh "CTX_SIZE=\(.context_window.context_window_size // 200000)",
41
78
  @sh "CTX_PCT=\(.context_window.used_percentage // 0)",
42
- @sh "CTX_REM=\(.context_window.remaining_percentage // 100)",
43
79
  @sh "CUR_IN=\(.context_window.current_usage.input_tokens // 0)",
44
80
  @sh "CUR_OUT=\(.context_window.current_usage.output_tokens // 0)",
45
81
  @sh "CACHE_CREATE=\(.context_window.current_usage.cache_creation_input_tokens // 0)",
@@ -51,14 +87,7 @@ eval "$(echo "$INPUT" | jq -r '
51
87
  @sh "RATE_5H_RESETS=\(.rate_limits.five_hour.resets_at // 0)",
52
88
  @sh "RATE_7D_PCT=\(.rate_limits.seven_day.used_percentage // "")",
53
89
  @sh "RATE_7D_RESETS=\(.rate_limits.seven_day.resets_at // 0)",
54
- # Note: RATE_*_PCT use `// ""` (not `// 0`) so the `[ -n "$RATE_*_PCT" ]` absence gate
55
- # below distinguishes "API said 0% used" from "rate_limits object absent". Do not
56
- # change to `// 0` — it would silently suppress the rate-limit line on subscriber
57
- # accounts at 0% usage at session start.
58
- @sh "SESSION_ID=\(.session_id // "")",
59
90
  @sh "SESSION_NAME=\(.session_name // "")",
60
- @sh "TRANSCRIPT_PATH=\(.transcript_path // "")",
61
- @sh "VERSION=\(.version // "")",
62
91
  @sh "OUTPUT_STYLE=\(.output_style.name // "")",
63
92
  @sh "VIM_MODE=\(.vim.mode // "")",
64
93
  @sh "AGENT_NAME=\(.agent.name // "")",
@@ -68,13 +97,41 @@ eval "$(echo "$INPUT" | jq -r '
68
97
  @sh "WT_NAME=\(.worktree.name // "")",
69
98
  @sh "WT_PATH=\(.worktree.path // "")",
70
99
  @sh "WT_BRANCH=\(.worktree.branch // "")",
71
- @sh "WT_ORIG_CWD=\(.worktree.original_cwd // "")",
72
100
  @sh "WT_ORIG_BRANCH=\(.worktree.original_branch // "")"
73
101
  ')"
102
+ # Note: RATE_*_PCT use `// ""` (not `// 0`) so the absence gate below distinguishes
103
+ # "API said 0% used" from "rate_limits object absent". Do not change to `// 0`.
104
+
105
+ # ---- Config: line toggles + no_color from .codebyplan/statusline.json --------
106
+ CFG_IDENTITY=true; CFG_CONTEXT=true; CFG_COST=true
107
+ CFG_RATE_LIMITS=true; CFG_REPO_PR=true; CFG_WORKTREE=true; CFG_NO_COLOR=false
108
+ CBP_CFG="$CBP_ROOT/.codebyplan/statusline.json"
109
+ if [ -f "$CBP_CFG" ] && command -v jq >/dev/null 2>&1; then
110
+ # Use `!= false` / `== true` (NOT jq `//`): the `//` operator treats an explicit
111
+ # `false` as absent and would coerce a hidden line back to visible. `!= false`
112
+ # yields true for missing/true and false only for an explicit false.
113
+ eval "$(jq -r '
114
+ "CFG_IDENTITY=\(.lines.identity != false)",
115
+ "CFG_CONTEXT=\(.lines.context != false)",
116
+ "CFG_COST=\(.lines.cost != false)",
117
+ "CFG_RATE_LIMITS=\(.lines.rate_limits != false)",
118
+ "CFG_REPO_PR=\(.lines.repo_pr != false)",
119
+ "CFG_WORKTREE=\(.lines.worktree != false)",
120
+ "CFG_NO_COLOR=\(.no_color == true)"
121
+ ' "$CBP_CFG" 2>/dev/null)"
122
+ fi
123
+
124
+ # Effective line visibility: env HIDE=1 > config false > default show.
125
+ # Returns 0 (show) / 1 (hide). $1 = env suffix, $2 = config value (true/false).
126
+ should_show() {
127
+ local envvar="CBP_STATUSLINE_HIDE_$1"
128
+ if [ "${!envvar:-0}" = "1" ]; then return 1; fi
129
+ if [ "$2" = "false" ]; then return 1; fi
130
+ return 0
131
+ }
74
132
 
75
- # ---- Colour setup ------------------------------------------------------------
76
- # Strip colours when NO_COLOR or CBP_STATUSLINE_NO_COLOR is set
77
- if [ -n "${NO_COLOR:-}" ] || [ "${CBP_STATUSLINE_NO_COLOR:-0}" = "1" ]; then
133
+ # ---- Colour setup (env > config) ---------------------------------------------
134
+ if [ -n "${NO_COLOR:-}" ] || [ "${CBP_STATUSLINE_NO_COLOR:-0}" = "1" ] || [ "$CFG_NO_COLOR" = "true" ]; then
78
135
  RST=''; DIM=''; BOLD=''; GREEN=''; YELLOW=''; RED=''; CYAN=''; MAGENTA=''; BLUE=''
79
136
  else
80
137
  RST='\033[0m'
@@ -89,22 +146,28 @@ else
89
146
  fi
90
147
 
91
148
  # ---- Float-safe percentage comparison ----------------------------------------
92
- # Usage: awk_gte VALUE THRESHOLD (returns 0 = true, 1 = false)
93
149
  awk_gte() { awk -v v="$1" -v t="$2" 'BEGIN{exit !(v+0 >= t+0)}'; }
94
150
 
95
- # ---- Token/size formatter (K / M) -------------------------------------------
151
+ # ---- Token/size formatter (K / M) — integer round-half-up (cross-runtime) -----
96
152
  fmt_k() {
97
153
  local val=$1
98
154
  if [ "$val" -ge 1000000 ] 2>/dev/null; then
99
- printf "%.1fM" "$(echo "$val / 1000000" | bc -l)"
155
+ local t=$(( (val + 50000) / 100000 ))
156
+ printf "%d.%dM" $(( t / 10 )) $(( t % 10 ))
100
157
  elif [ "$val" -ge 1000 ] 2>/dev/null; then
101
- printf "%.1fK" "$(echo "$val / 1000" | bc -l)"
158
+ local t=$(( (val + 50) / 100 ))
159
+ printf "%d.%dK" $(( t / 10 )) $(( t % 10 ))
102
160
  else
103
161
  printf "%d" "$val"
104
162
  fi
105
163
  }
106
164
 
107
- # ---- Duration formatter (ms Xh Xm Xs) ------------------------------------
165
+ # ---- Cost formatter ($X.XXXX) integer round-half-up (cross-runtime) ---------
166
+ fmt_cost() {
167
+ awk -v c="$1" 'BEGIN{ n=int(c*10000 + 0.5); printf "$%d.%04d", int(n/10000), n%10000 }'
168
+ }
169
+
170
+ # ---- Duration formatter (ms → Xh Xm Xs) --------------------------------------
108
171
  fmt_dur() {
109
172
  local ms=$1
110
173
  local secs=$(( ms / 1000 ))
@@ -117,44 +180,59 @@ fmt_dur() {
117
180
  fi
118
181
  }
119
182
 
120
- # ---- Relative-time formatter (epoch → "now" / Xm / Xh / Xd) ----------------
121
- # Callers should gate on `epoch != 0` upstream (the absence sentinel from the
122
- # `// 0` jq fallback). Passing epoch=0 here yields "now" by the delta<=0 branch.
183
+ # ---- "now" with test seam ----------------------------------------------------
184
+ cbp_now() {
185
+ if [ -n "${CBP_STATUSLINE_NOW:-}" ]; then printf '%s' "$CBP_STATUSLINE_NOW"; else date +%s; fi
186
+ }
187
+
188
+ # ---- Relative-time formatter (epoch → "now" / Xm / Xh / Xd) -------------------
123
189
  fmt_rel_time() {
124
190
  local epoch=$1
125
- local now
126
- now=$(date +%s)
191
+ local now; now=$(cbp_now)
127
192
  local delta=$(( epoch - now ))
128
- if [ "$delta" -le 0 ]; then
129
- printf "now"
130
- elif [ "$delta" -ge 86400 ]; then
131
- printf "%dd" $(( delta / 86400 ))
132
- elif [ "$delta" -ge 3600 ]; then
133
- printf "%dh" $(( delta / 3600 ))
134
- else
135
- printf "%dm" $(( delta / 60 ))
136
- fi
193
+ if [ "$delta" -le 0 ]; then printf "now"
194
+ elif [ "$delta" -ge 86400 ]; then printf "%dd" $(( delta / 86400 ))
195
+ elif [ "$delta" -ge 3600 ]; then printf "%dh" $(( delta / 3600 ))
196
+ else printf "%dm" $(( delta / 60 )); fi
137
197
  }
138
198
 
199
+ # ---- Folder + branch (always-available "where am I") -------------------------
200
+ FOLDER=""
201
+ if [ -n "$CWD" ]; then FOLDER="$(basename "$CWD")"
202
+ elif [ -n "$WS_CURRENT_DIR" ]; then FOLDER="$(basename "$WS_CURRENT_DIR")"; fi
203
+ BRANCH="$WT_BRANCH"
204
+ if [ -z "$BRANCH" ] && [ -n "$CWD" ]; then
205
+ # Only trust a successful resolution: on an unborn branch git prints "HEAD"
206
+ # to stdout but exits non-zero — reset to empty so all renderers agree.
207
+ BRANCH="$(git -C "$CWD" rev-parse --abbrev-ref HEAD 2>/dev/null)" || BRANCH=""
208
+ fi
209
+
139
210
  # ============================================================
140
- # LINE 1 — Identity
211
+ # LINE 1 — Identity (folder + branch + model + flags)
141
212
  # ============================================================
142
- if [ "${CBP_STATUSLINE_HIDE_IDENTITY:-0}" != "1" ]; then
213
+ if should_show IDENTITY "$CFG_IDENTITY"; then
143
214
  L1=""
144
215
 
216
+ # Folder + branch — git-derived, visible even without a registered worktree
217
+ if [ -n "$FOLDER" ]; then
218
+ L1="${BOLD}${BLUE}${FOLDER}${RST}"
219
+ if [ -n "$BRANCH" ]; then
220
+ L1="${L1} ${DIM}⎇${RST}${CYAN}${BRANCH}${RST}"
221
+ fi
222
+ L1="${L1} "
223
+ fi
224
+
145
225
  # Prefix: wt > session > agent (pick first present)
146
226
  if [ -n "$WT_NAME" ]; then
147
- L1="${DIM}wt:${RST}${MAGENTA}${WT_NAME}${RST} "
227
+ L1="${L1}${DIM}wt:${RST}${MAGENTA}${WT_NAME}${RST} "
148
228
  elif [ -n "$SESSION_NAME" ]; then
149
- L1="${DIM}session:${RST}${MAGENTA}${SESSION_NAME}${RST} "
229
+ L1="${L1}${DIM}session:${RST}${MAGENTA}${SESSION_NAME}${RST} "
150
230
  elif [ -n "$AGENT_NAME" ]; then
151
- L1="${DIM}agent:${RST}${MAGENTA}${AGENT_NAME}${RST} "
231
+ L1="${L1}${DIM}agent:${RST}${MAGENTA}${AGENT_NAME}${RST} "
152
232
  fi
153
233
 
154
- # Model (display_name + id in parens when id differs or both present)
155
- if [ -n "$MODEL_NAME" ] && [ -n "$MODEL_ID" ] && [ "$MODEL_NAME" != "$MODEL_ID" ]; then
156
- L1="${L1}${BOLD}${CYAN}${MODEL_NAME}${RST} ${DIM}(${MODEL_ID})${RST}"
157
- elif [ -n "$MODEL_NAME" ]; then
234
+ # Model display name only (no redundant id-in-parens)
235
+ if [ -n "$MODEL_NAME" ]; then
158
236
  L1="${L1}${BOLD}${CYAN}${MODEL_NAME}${RST}"
159
237
  elif [ -n "$MODEL_ID" ]; then
160
238
  L1="${L1}${BOLD}${CYAN}${MODEL_ID}${RST}"
@@ -175,27 +253,11 @@ if [ "${CBP_STATUSLINE_HIDE_IDENTITY:-0}" != "1" ]; then
175
253
  L1="${L1} ${DIM}style:${RST}${OUTPUT_STYLE}"
176
254
  fi
177
255
 
178
- # Version
179
- if [ -n "$VERSION" ]; then
180
- L1="${L1} ${DIM}v:${VERSION}${RST}"
181
- fi
182
-
183
256
  # Vim mode
184
257
  if [ -n "$VIM_MODE" ]; then
185
258
  L1="${L1} ${DIM}[${VIM_MODE}]${RST}"
186
259
  fi
187
260
 
188
- # Session id (short 8-char prefix — distinguishes parallel sessions)
189
- if [ -n "$SESSION_ID" ]; then
190
- L1="${L1} ${DIM}sid:${SESSION_ID:0:8}${RST}"
191
- fi
192
-
193
- # Transcript file (basename only — full path would blow out the line)
194
- if [ -n "$TRANSCRIPT_PATH" ]; then
195
- L1="${L1} ${DIM}log:$(basename "$TRANSCRIPT_PATH")${RST}"
196
- fi
197
-
198
- # Only print when we have actual content
199
261
  if [ -n "$L1" ]; then
200
262
  printf "%b\n" "$L1"
201
263
  fi
@@ -204,8 +266,7 @@ fi
204
266
  # ============================================================
205
267
  # LINE 2 — Context window
206
268
  # ============================================================
207
- if [ "${CBP_STATUSLINE_HIDE_CONTEXT:-0}" != "1" ]; then
208
- # Context bar colour by usage
269
+ if should_show CONTEXT "$CFG_CONTEXT"; then
209
270
  if awk_gte "$CTX_PCT" 75; then
210
271
  BAR_COLOR="$RED"
211
272
  elif awk_gte "$CTX_PCT" 50; then
@@ -214,7 +275,6 @@ if [ "${CBP_STATUSLINE_HIDE_CONTEXT:-0}" != "1" ]; then
214
275
  BAR_COLOR="$GREEN"
215
276
  fi
216
277
 
217
- # 20-char progress bar (ceiling division so 99% shows 20/20, not 19/20)
218
278
  FILLED=$(( (CTX_PCT + 4) / 5 ))
219
279
  [ "$FILLED" -gt 20 ] && FILLED=20
220
280
  EMPTY=$(( 20 - FILLED ))
@@ -241,8 +301,8 @@ fi
241
301
  # ============================================================
242
302
  # LINE 3 — Cost
243
303
  # ============================================================
244
- if [ "${CBP_STATUSLINE_HIDE_COST:-0}" != "1" ]; then
245
- COST_F=$(printf '$%.4f' "$COST")
304
+ if should_show COST "$CFG_COST"; then
305
+ COST_F=$(fmt_cost "$COST")
246
306
  DUR_F=$(fmt_dur "$DURATION")
247
307
  API_DUR_F=$(fmt_dur "$API_DURATION")
248
308
 
@@ -253,8 +313,7 @@ fi
253
313
  # ============================================================
254
314
  # LINE 4 — Rate limits
255
315
  # ============================================================
256
- if [ "${CBP_STATUSLINE_HIDE_RATE_LIMITS:-0}" != "1" ]; then
257
- # Emit this line only when at least one window has resets_at != 0
316
+ if should_show RATE_LIMITS "$CFG_RATE_LIMITS"; then
258
317
  HAVE_RATE=0
259
318
  [ -n "$RATE_5H_PCT" ] && [ "$RATE_5H_RESETS" != "0" ] && HAVE_RATE=1
260
319
  [ -n "$RATE_7D_PCT" ] && [ "$RATE_7D_RESETS" != "0" ] && HAVE_RATE=1
@@ -298,15 +357,13 @@ fi
298
357
  # ============================================================
299
358
  # LINE 5 — Repo / PR
300
359
  # ============================================================
301
- if [ "${CBP_STATUSLINE_HIDE_REPO_PR:-0}" != "1" ]; then
360
+ if should_show REPO_PR "$CFG_REPO_PR"; then
302
361
  L5=""
303
362
 
304
- # Repo segment (host/owner/name when host is present)
305
363
  if [ -n "$WS_REPO_HOST" ]; then
306
364
  L5="${DIM}${WS_REPO_HOST}/${WS_REPO_OWNER}/${WS_REPO_NAME}${RST}"
307
365
  fi
308
366
 
309
- # PR segment
310
367
  if [ -n "$PR_NUMBER" ]; then
311
368
  PR_SEG="${DIM}PR${RST} ${CYAN}#${PR_NUMBER}${RST}"
312
369
  if [ -n "$PR_REVIEW_STATE" ]; then
@@ -330,13 +387,12 @@ fi
330
387
  # ============================================================
331
388
  # LINE 6 — Worktree
332
389
  # ============================================================
333
- if [ "${CBP_STATUSLINE_HIDE_WORKTREE:-0}" != "1" ]; then
390
+ if should_show WORKTREE "$CFG_WORKTREE"; then
334
391
  if [ -n "$WT_NAME" ]; then
335
392
  L6="${MAGENTA}${WT_NAME}${RST} ${DIM}@${RST} ${CYAN}${WT_BRANCH}${RST}"
336
393
  if [ -n "$WT_ORIG_BRANCH" ] && [ "$WT_ORIG_BRANCH" != "$WT_BRANCH" ]; then
337
394
  L6="${L6} ${DIM}(was: ${WT_ORIG_BRANCH})${RST}"
338
395
  fi
339
- # Truncate path if longer than 60 chars
340
396
  WT_PATH_DISP="$WT_PATH"
341
397
  if [ "${#WT_PATH}" -gt 60 ]; then
342
398
  WT_PATH_DISP="...${WT_PATH: -57}"
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+ // @hook: NOT-A-HOOK (subagentStatusLine renderer, invoked via the cbp-subagent-statusline.sh dispatcher)
3
+ // Claude Code Subagent Status Line — node renderer (ESM; .mjs forces ESM regardless
4
+ // of the host repo's package.json "type"). Byte-identical output to the bash renderer
5
+ // in cbp-subagent-statusline.sh and the python renderer in cbp-subagent-statusline.py.
6
+ // Output protocol: one {"id":...,"content":...} JSON line per task. See the .sh file
7
+ // for the full option/env/seam contract. Never throws to stdout.
8
+
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ function main() {
16
+ const input = fs.readFileSync(0, "utf8");
17
+ let data;
18
+ try {
19
+ data = JSON.parse(input);
20
+ } catch {
21
+ data = {};
22
+ }
23
+ if (data == null || typeof data !== "object") data = {};
24
+
25
+ let root = process.env.CBP_STATUSLINE_ROOT || process.argv[2] || "";
26
+ if (!root) root = path.resolve(__dirname, "..", "..");
27
+
28
+ const COLUMNS = Math.trunc(Number(data.columns ?? 0)) || 0;
29
+ const tasks = Array.isArray(data.tasks) ? data.tasks : [];
30
+
31
+ // ---- Config: no_color from statusline.json --------------------------------
32
+ let cfgNoColor = false;
33
+ try {
34
+ const raw = fs.readFileSync(
35
+ path.join(root, ".codebyplan", "statusline.json"),
36
+ "utf8"
37
+ );
38
+ const parsed = JSON.parse(raw);
39
+ if (
40
+ parsed &&
41
+ typeof parsed === "object" &&
42
+ typeof parsed.no_color === "boolean"
43
+ ) {
44
+ cfgNoColor = parsed.no_color;
45
+ }
46
+ } catch {
47
+ // absent / invalid → keep default
48
+ }
49
+
50
+ const noColor =
51
+ (process.env.NO_COLOR != null && process.env.NO_COLOR !== "") ||
52
+ process.env.CBP_SUBAGENT_STATUSLINE_NO_COLOR === "1" ||
53
+ cfgNoColor === true;
54
+ const C = noColor
55
+ ? { RST: "", DIM: "", BOLD: "", GREEN: "", RED: "", BLUE: "" }
56
+ : {
57
+ RST: "\x1b[0m",
58
+ DIM: "\x1b[2m",
59
+ BOLD: "\x1b[1m",
60
+ GREEN: "\x1b[32m",
61
+ RED: "\x1b[31m",
62
+ BLUE: "\x1b[34m",
63
+ };
64
+
65
+ const fmtK = (val) => {
66
+ const v = Number(val);
67
+ if (v >= 1000000) {
68
+ const t = Math.floor((v + 50000) / 100000);
69
+ return `${Math.floor(t / 10)}.${t % 10}M`;
70
+ } else if (v >= 1000) {
71
+ const t = Math.floor((v + 50) / 100);
72
+ return `${Math.floor(t / 10)}.${t % 10}K`;
73
+ }
74
+ return String(Math.trunc(v));
75
+ };
76
+
77
+ const cbpNow = () => {
78
+ if (
79
+ process.env.CBP_STATUSLINE_NOW != null &&
80
+ process.env.CBP_STATUSLINE_NOW !== ""
81
+ ) {
82
+ return Math.trunc(Number(process.env.CBP_STATUSLINE_NOW));
83
+ }
84
+ return Math.floor(Date.now() / 1000);
85
+ };
86
+
87
+ const fmtAgeSecs = (start) => {
88
+ let delta = cbpNow() - Math.trunc(Number(start));
89
+ if (delta < 0) delta = 0;
90
+ if (delta >= 3600)
91
+ return `${Math.floor(delta / 3600)}h${Math.floor((delta % 3600) / 60)}m`;
92
+ if (delta >= 60) return `${Math.floor(delta / 60)}m${delta % 60}s`;
93
+ return `${delta}s`;
94
+ };
95
+
96
+ const isAsciiLetter = (b) =>
97
+ (b >= 0x41 && b <= 0x5a) || (b >= 0x61 && b <= 0x7a);
98
+
99
+ // ANSI-aware byte-based truncation — mirrors the bash byte walk exactly.
100
+ const truncate = (rendered) => {
101
+ if (!(COLUMNS > 0)) return rendered;
102
+ const buf = Buffer.from(rendered, "utf8");
103
+ const rawLen = buf.length;
104
+ let target = COLUMNS - 3;
105
+ if (target < 0) target = 0;
106
+ let vis = 0;
107
+ let i = 0;
108
+ let inEsc = false;
109
+ while (i < rawLen && vis < target) {
110
+ const b = buf[i];
111
+ if (inEsc) {
112
+ if (isAsciiLetter(b)) inEsc = false;
113
+ } else if (b === 0x1b) {
114
+ inEsc = true;
115
+ } else {
116
+ vis += 1;
117
+ }
118
+ i += 1;
119
+ }
120
+ while (i < rawLen) {
121
+ const b = buf[i];
122
+ if (inEsc) {
123
+ if (isAsciiLetter(b)) inEsc = false;
124
+ i += 1;
125
+ } else if (b === 0x1b) {
126
+ inEsc = true;
127
+ i += 1;
128
+ } else {
129
+ break;
130
+ }
131
+ }
132
+ if (i < rawLen) {
133
+ const head = buf.subarray(0, i);
134
+ const tail = Buffer.from(C.RST + "...", "utf8");
135
+ return Buffer.concat([head, tail]).toString("utf8");
136
+ }
137
+ return rendered;
138
+ };
139
+
140
+ const out = [];
141
+ for (const task of tasks) {
142
+ const T_ID = task && task.id != null ? String(task.id) : "";
143
+ if (T_ID === "") continue;
144
+ const T_NAME = task.name != null ? String(task.name) : "";
145
+ const T_TYPE = task.type != null ? String(task.type) : "";
146
+ const T_STATUS = task.status != null ? String(task.status) : "";
147
+ const T_DESC = task.description != null ? String(task.description) : "";
148
+ const T_START = Math.trunc(Number(task.startTime ?? 0)) || 0;
149
+ const T_TOKENS = Math.trunc(Number(task.tokenCount ?? 0)) || 0;
150
+
151
+ let icon;
152
+ switch (T_STATUS) {
153
+ case "running":
154
+ icon = `${C.GREEN}●${C.RST}`;
155
+ break;
156
+ case "pending":
157
+ icon = `${C.DIM}○${C.RST}`;
158
+ break;
159
+ case "completed":
160
+ icon = `${C.BLUE}✓${C.RST}`;
161
+ break;
162
+ case "failed":
163
+ icon = `${C.RED}✗${C.RST}`;
164
+ break;
165
+ default:
166
+ icon = `${C.DIM}•${C.RST}`;
167
+ }
168
+
169
+ let row = `${icon} ${C.BOLD}${T_NAME}${C.RST}`;
170
+ if (T_TYPE) row += ` ${C.DIM}(${T_TYPE})${C.RST}`;
171
+ if (
172
+ process.env.CBP_SUBAGENT_STATUSLINE_HIDE_DESCRIPTION !== "1" &&
173
+ T_DESC
174
+ ) {
175
+ row += ` ${C.DIM}—${C.RST} ${T_DESC}`;
176
+ }
177
+ if (
178
+ process.env.CBP_SUBAGENT_STATUSLINE_HIDE_TOKENS !== "1" &&
179
+ T_TOKENS > 0
180
+ ) {
181
+ row += ` ${C.DIM}·${C.RST} ${C.DIM}tokens:${C.RST}${fmtK(T_TOKENS)}`;
182
+ }
183
+ if (T_START > 0) {
184
+ row += ` ${C.DIM}·${C.RST} ${C.DIM}age:${C.RST}${fmtAgeSecs(T_START)}`;
185
+ }
186
+
187
+ const rendered = truncate(row);
188
+ out.push(
189
+ `{"id":${JSON.stringify(T_ID)},"content":${JSON.stringify(rendered)}}`
190
+ );
191
+ }
192
+
193
+ process.stdout.write(out.length ? out.join("\n") + "\n" : "");
194
+ }
195
+
196
+ try {
197
+ main();
198
+ } catch {
199
+ process.exit(0);
200
+ }