ctxline-claude 1.6.0 → 1.6.2

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/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="docs/assets/apple-touch-icon.png" width="64" height="64" alt="">
3
+ </p>
4
+
1
5
  <h1 align="center">Claude Code Statusline</h1>
2
6
 
3
7
  <p align="center">
@@ -5,21 +9,15 @@
5
9
  </p>
6
10
 
7
11
  <p align="center">
8
- <a href="https://www.npmjs.com/package/ctxline-claude">
9
- <img src="https://img.shields.io/npm/v/ctxline-claude" alt="npm version">
10
- </a>
11
- <a href="https://www.npmjs.com/package/ctxline-claude">
12
- <img src="https://img.shields.io/npm/dm/ctxline-claude" alt="npm downloads">
13
- </a>
14
- <a href="LICENSE">
15
- <img src="https://img.shields.io/github/license/MithunWijayasiri/ctxline-claude" alt="license">
16
- </a>
17
- <a href="https://github.com/MithunWijayasiri/ctxline-claude/stargazers">
18
- <img src="https://img.shields.io/github/stars/MithunWijayasiri/ctxline-claude" alt="stars">
19
- </a>
20
- <a href="https://ko-fi.com/mithunwijayasiri">
21
- <img src="https://img.shields.io/badge/Ko--fi-support-ff5e5b?logo=ko-fi&logoColor=white" alt="Support on Ko-fi">
22
- </a>
12
+ <a href="https://github.com/MithunWijayasiri/ctxline-claude/stargazers"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/github/stars/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=dark"><img alt="GitHub Stars" src="https://www.shieldcn.dev/github/stars/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=light"></picture></a>
13
+ <a href="https://github.com/MithunWijayasiri/ctxline-claude/graphs/contributors"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/github/contributors/MithunWijayasiri/ctxline-claude.svg?theme=emerald&amp;variant=ghost&amp;size=sm&amp;mode=dark"><img alt="Contributors" src="https://www.shieldcn.dev/github/contributors/MithunWijayasiri/ctxline-claude.svg?theme=emerald&amp;variant=ghost&amp;size=sm&amp;mode=light"></picture></a>
14
+ <a href="https://github.com/MithunWijayasiri/ctxline-claude/commits/main"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/github/last-commit/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=dark"><img alt="Last commit" src="https://www.shieldcn.dev/github/last-commit/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=light"></picture></a>
15
+ <a href="https://github.com/MithunWijayasiri/ctxline-claude/actions"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/github/ci/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=dark"><img alt="CI" src="https://www.shieldcn.dev/github/ci/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=light"></picture></a>
16
+ <a href="https://ko-fi.com/mithunwijayasiri"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/badge/Ko--fi-support-FF5E5B.svg?logo=kofi&amp;variant=outline&amp;size=sm&amp;mode=dark"><img alt="Ko-fi" src="https://www.shieldcn.dev/badge/Ko--fi-support-FF5E5B.svg?logo=kofi&amp;variant=outline&amp;size=sm&amp;mode=light"></picture></a>
17
+ <br>
18
+ <a href="https://www.npmjs.com/package/ctxline-claude"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/npm/ctxline-claude.svg?variant=secondary&amp;size=xs&amp;mode=dark"><img alt="npm Version" src="https://www.shieldcn.dev/npm/ctxline-claude.svg?variant=secondary&amp;size=xs&amp;mode=light"></picture></a>
19
+ <a href="https://www.npmjs.com/package/ctxline-claude"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/npm/dm/ctxline-claude.svg?variant=ghost&amp;size=xs&amp;mode=dark"><img alt="npm Monthly Downloads" src="https://www.shieldcn.dev/npm/dm/ctxline-claude.svg?variant=ghost&amp;size=xs&amp;mode=light"></picture></a>
20
+ <a href="https://www.npmjs.com/package/ctxline-claude"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/npm/dt/ctxline-claude.svg?variant=secondary&amp;size=xs&amp;mode=dark"><img alt="npm Total Downloads" src="https://www.shieldcn.dev/npm/dt/ctxline-claude.svg?variant=secondary&amp;size=xs&amp;mode=light"></picture></a>
23
21
  </p>
24
22
 
25
23
  <p align="center">
@@ -88,6 +86,8 @@ chmod +x ~/.claude/hooks/statusline.js
88
86
  }
89
87
  ```
90
88
 
89
+ Note: the effort level only shows on a subagent row when that task has its own effort override — Claude Code doesn't currently report effort for a subagent that just inherits the session's setting.
90
+
91
91
  </details>
92
92
 
93
93
  ## Update
package/bin/install.js CHANGED
@@ -62,9 +62,17 @@ if (fs.existsSync(settingsFile)) {
62
62
  }
63
63
  }
64
64
 
65
+ // Path is quoted — the command runs through a shell, so an unquoted home dir with spaces would split.
66
+ const commandPath = scriptDest.replace(/\\/g, '/');
67
+
65
68
  settings.statusLine = {
66
69
  type: 'command',
67
- command: `node ${scriptDest.replace(/\\/g, '/')}`
70
+ command: `node "${commandPath}"`
71
+ };
72
+
73
+ settings.subagentStatusLine = {
74
+ type: 'command',
75
+ command: `node "${commandPath}" subagent`
68
76
  };
69
77
 
70
78
  fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
@@ -86,26 +94,38 @@ function runUninstall() {
86
94
  return;
87
95
  }
88
96
 
89
- // 1. Remove our statusLine entry from settings.json (preserving everything else)
97
+ // 1. Remove our statusLine/subagentStatusLine entries from settings.json (preserving everything else)
90
98
  if (fs.existsSync(settingsFile)) {
91
99
  try {
92
100
  const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
93
101
  const command = ((settings.statusLine && settings.statusLine.command) || '').replace(/\\/g, '/');
102
+ const subagentCommand = ((settings.subagentStatusLine && settings.subagentStatusLine.command) || '').replace(/\\/g, '/');
94
103
  // Match our hook path only (covers absolute + manual `~` installs), not any statusline.js.
95
104
  const isOurs = command.includes('/.claude/hooks/statusline.js');
105
+ const isSubagentOurs = subagentCommand.includes('/.claude/hooks/statusline.js');
106
+ let changed = false;
96
107
  if (settings.statusLine && isOurs) {
97
- const backup = `${settingsFile}.backup.${Date.now()}`;
98
- fs.copyFileSync(settingsFile, backup);
99
108
  delete settings.statusLine;
100
- fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
101
- console.log(`${green}✓ Removed statusLine from settings.json (backup: ${path.basename(backup)})${reset}`);
109
+ changed = true;
102
110
  } else if (settings.statusLine) {
103
111
  console.log(`${yellow}! settings.json has a different statusLine — leaving it untouched.${reset}`);
104
112
  } else {
105
113
  console.log(`${green}✓ No statusLine entry in settings.json${reset}`);
106
114
  }
115
+ if (settings.subagentStatusLine && isSubagentOurs) {
116
+ delete settings.subagentStatusLine;
117
+ changed = true;
118
+ } else if (settings.subagentStatusLine) {
119
+ console.log(`${yellow}! settings.json has a different subagentStatusLine — leaving it untouched.${reset}`);
120
+ }
121
+ if (changed) {
122
+ const backup = `${settingsFile}.backup.${Date.now()}`;
123
+ fs.copyFileSync(settingsFile, backup);
124
+ fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
125
+ console.log(`${green}✓ Removed statusLine/subagentStatusLine from settings.json (backup: ${path.basename(backup)})${reset}`);
126
+ }
107
127
  } catch (e) {
108
- console.log(`${red}✗ Could not parse settings.json — remove the "statusLine" block manually.${reset}`);
128
+ console.log(`${red}✗ Could not parse settings.json — remove the "statusLine"/"subagentStatusLine" blocks manually.${reset}`);
109
129
  }
110
130
  }
111
131
 
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "ctxline-claude",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "description": "A customizable statusline for Claude Code that tracks context usage and session limits",
5
5
  "bin": {
6
6
  "ctxline-claude": "bin/install.js"
7
7
  },
8
8
  "scripts": {
9
9
  "test": "node --test",
10
- "preview": "node scripts/preview.js"
10
+ "preview": "node scripts/preview.js",
11
+ "preview:svg": "node scripts/preview-svg.js"
11
12
  },
12
13
  "engines": {
13
14
  "node": ">=18"
package/statusline.js CHANGED
@@ -249,6 +249,11 @@ function getContextBar(remaining) {
249
249
  return renderContextBar(used);
250
250
  }
251
251
 
252
+ // "model" or "model · effort" (colored). Shared by the main line and subagent rows.
253
+ function renderModelEffort(model, effort) {
254
+ return effort ? `${model}${getEffortColor(effort)} · ${effort}${colors.reset}` : model;
255
+ }
256
+
252
257
  // Render a compact usage segment from raw data: "<label><pct> ↺ <countdown>"
253
258
  // (e.g. "H81 ↺ 2h21m") — no bar. Called on every read (live or cached) so the reset
254
259
  // countdown is always recomputed from resetsAt rather than frozen at fetch time.
@@ -290,12 +295,15 @@ const LEGACY_MODEL_WEEKLY_KEYS = [
290
295
  { key: 'seven_day_sonnet', label: 'S' }
291
296
  ];
292
297
 
293
- // Build the usage segments from raw entries. fiveHour/weekly are { percentage, resetsAt }
294
- // or null/absent; models is an array of { label, percentage, resetsAt } (possibly empty).
295
- // Returns { current, weekly, models } the first two rendered strings or null, models a
296
- // (possibly empty) array of rendered strings. Scoped bars use getScopedColor instead of the
297
- // H/W thresholds, so the full threshold palette stays exclusive to H/W.
298
- function buildUsageBars(fiveHour, weekly, models) {
298
+ // Build the usage segments from a raw { fiveHour, weekly, models } object the shared
299
+ // shape both buildUsageFromStdin and parseUsagePayload return. fiveHour/weekly are
300
+ // { percentage, resetsAt } or null/absent; models is an array of { label, percentage,
301
+ // resetsAt } (possibly empty). Returns { current, weekly, models } the first two
302
+ // rendered strings or null, models a (possibly empty) array of rendered strings. Scoped
303
+ // bars use getScopedColor instead of the H/W thresholds, so the full threshold palette
304
+ // stays exclusive to H/W.
305
+ function buildUsageBars(raw) {
306
+ const { fiveHour, weekly, models } = raw || {};
299
307
  return {
300
308
  current: fiveHour ? buildUsageBar('H', fiveHour.percentage, fiveHour.resetsAt) : null,
301
309
  weekly: weekly ? buildUsageBar('W', weekly.percentage, weekly.resetsAt) : null,
@@ -344,9 +352,10 @@ function parseScopedLimits(usage) {
344
352
  // Build usage bars from stdin `rate_limits` (Claude.ai Pro/Max, present only after the
345
353
  // first API response of a session). Same data as the OAuth usage API, so reading it here
346
354
  // skips the network/credentials/cache path entirely. `resets_at` is a Unix epoch in
347
- // SECONDS (not ISO) — ×1000 before Date. Returns raw { fiveHour, weekly } entries, or null
348
- // when rate_limits is absent or the required five_hour segment is unusable (caller falls
349
- // back). Model-scoped weekly limits are never present here — see LEGACY_MODEL_WEEKLY_KEYS.
355
+ // SECONDS (not ISO) — ×1000 before Date. Returns raw { fiveHour, weekly, models } same
356
+ // shape as parseUsagePayload — or null when rate_limits is absent or the required
357
+ // five_hour segment is unusable (caller falls back). models is always [] here: model-scoped
358
+ // weekly limits are never present in stdin — see LEGACY_MODEL_WEEKLY_KEYS.
350
359
  function buildUsageFromStdin(data) {
351
360
  const rl = data?.rate_limits;
352
361
  if (!rl) return null;
@@ -369,7 +378,29 @@ function buildUsageFromStdin(data) {
369
378
 
370
379
  const fiveHour = toEntry(rl.five_hour);
371
380
  if (!fiveHour) return null; // five_hour is the required bar
372
- return { fiveHour, weekly: toEntry(rl.seven_day) };
381
+ return { fiveHour, weekly: toEntry(rl.seven_day), models: [] };
382
+ }
383
+
384
+ // Parse a raw /usage API response body into { fiveHour, weekly, models } — same shape as
385
+ // buildUsageFromStdin — or null on unparseable JSON or a missing/non-finite five_hour
386
+ // utilization (that bar is required). Normalizes utilization first so a missing/non-finite
387
+ // value omits a bar instead of rendering "NaN%". Pure — no fs/network — so it's unit
388
+ // testable directly, unlike getApiUsage which needs a live socket.
389
+ function parseUsagePayload(body) {
390
+ try {
391
+ const usage = JSON.parse(body);
392
+ const fivePct = usage?.five_hour ? normalizePercentage(usage.five_hour.utilization) : null;
393
+ if (fivePct == null) return null;
394
+
395
+ const fiveHour = { percentage: fivePct, resetsAt: usage.five_hour.resets_at || null };
396
+ const weeklyPct = usage.seven_day ? normalizePercentage(usage.seven_day.utilization) : null;
397
+ const weekly = weeklyPct != null ? { percentage: weeklyPct, resetsAt: usage.seven_day.resets_at || null } : null;
398
+ const models = parseScopedLimits(usage);
399
+
400
+ return { fiveHour, weekly, models };
401
+ } catch (e) {
402
+ return null;
403
+ }
373
404
  }
374
405
 
375
406
  // Validate a single usage entry ({ percentage, resetsAt }). Returns true only for a
@@ -410,6 +441,15 @@ function readCachedUsage() {
410
441
  }
411
442
  }
412
443
 
444
+ // Serialize usage data into the on-disk cache shape ({ timestamp, data, lastAttempt }). Pure
445
+ // -- no fs -- so setCachedUsage and the test/preview cache seeds all produce exactly the same
446
+ // bytes the real writer would; a reader/writer format mismatch becomes structurally impossible
447
+ // instead of merely untested. `timestamp` defaults to now; tests override it to seed a stale
448
+ // cache. A successful write is itself an attempt, so `lastAttempt` starts equal to `timestamp`.
449
+ function serializeUsageCache(data, timestamp = Date.now()) {
450
+ return JSON.stringify({ timestamp, data, lastAttempt: timestamp });
451
+ }
452
+
413
453
  // Write usage data to cache (shared across all sessions)
414
454
  function setCachedUsage(data) {
415
455
  try {
@@ -417,12 +457,41 @@ function setCachedUsage(data) {
417
457
  fs.mkdirSync(CACHE_DIR, { recursive: true });
418
458
  }
419
459
 
420
- const cache = {
421
- timestamp: Date.now(),
422
- data: data
423
- };
460
+ fs.writeFileSync(USAGE_CACHE_FILE, serializeUsageCache(data), 'utf8');
461
+ } catch (e) {
462
+ // Silently fail
463
+ }
464
+ }
465
+
466
+ // Age in ms since the last API attempt (success or failure), or null if none recorded yet.
467
+ // Read directly from the raw file rather than via readCachedUsage so the cooldown still
468
+ // applies when no valid data has ever been cached (every attempt so far has failed).
469
+ function getLastAttemptAge() {
470
+ try {
471
+ if (!fs.existsSync(USAGE_CACHE_FILE)) return null;
472
+ const cache = JSON.parse(fs.readFileSync(USAGE_CACHE_FILE, 'utf8'));
473
+ if (!cache || !Number.isFinite(cache.lastAttempt) || cache.lastAttempt <= 0) return null;
474
+ return Date.now() - cache.lastAttempt;
475
+ } catch (e) {
476
+ return null;
477
+ }
478
+ }
424
479
 
425
- fs.writeFileSync(USAGE_CACHE_FILE, JSON.stringify(cache), 'utf8');
480
+ // Record that an API attempt is starting, preserving any existing cached data/timestamp so a
481
+ // failed refresh doesn't erase the last successful one. Written before the request so a hang
482
+ // or a process exit mid-request still counts as an attempt for cooldown purposes.
483
+ function recordUsageAttempt() {
484
+ try {
485
+ if (!fs.existsSync(CACHE_DIR)) {
486
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
487
+ }
488
+ let existing = null;
489
+ try {
490
+ existing = JSON.parse(fs.readFileSync(USAGE_CACHE_FILE, 'utf8'));
491
+ } catch (e) {}
492
+ const merged = existing && typeof existing === 'object' ? { ...existing } : {};
493
+ merged.lastAttempt = Date.now();
494
+ fs.writeFileSync(USAGE_CACHE_FILE, JSON.stringify(merged), 'utf8');
426
495
  } catch (e) {
427
496
  // Silently fail
428
497
  }
@@ -483,37 +552,10 @@ function getApiUsage(callback) {
483
552
 
484
553
  res.on('data', chunk => data += chunk);
485
554
  res.on('end', () => {
486
- try {
487
- const usage = JSON.parse(data);
488
-
489
- // 5-hour session usage is required; weekly (seven_day) is rendered when present.
490
- // Normalize utilization first so a missing/non-finite value omits the bar
491
- // instead of rendering "NaN%" or an out-of-range percentage.
492
- const fivePct = usage.five_hour ? normalizePercentage(usage.five_hour.utilization) : null;
493
- if (fivePct != null) {
494
- const fiveHour = {
495
- percentage: fivePct,
496
- resetsAt: usage.five_hour.resets_at || null
497
- };
498
- const weeklyPct = usage.seven_day ? normalizePercentage(usage.seven_day.utilization) : null;
499
- const weekly = weeklyPct != null ? {
500
- percentage: weeklyPct,
501
- resetsAt: usage.seven_day.resets_at || null
502
- } : null;
503
-
504
- // Model-scoped weekly limits, rendered only when the account reports them.
505
- const models = parseScopedLimits(usage);
506
-
507
- // Cache the raw data (shared across sessions); callers render from it.
508
- const resolved = { fiveHour, weekly, models };
509
- setCachedUsage(resolved);
510
- callback(resolved);
511
- } else {
512
- callback(null);
513
- }
514
- } catch (e) {
515
- callback(null);
516
- }
555
+ const resolved = parseUsagePayload(data);
556
+ // Cache the raw data (shared across sessions); callers render from it.
557
+ if (resolved) setCachedUsage(resolved);
558
+ callback(resolved);
517
559
  });
518
560
  });
519
561
 
@@ -538,7 +580,17 @@ function getRawUsage(callback) {
538
580
  return callback(cached.data);
539
581
  }
540
582
 
541
- // Cache is stale or missing -> refresh from the API.
583
+ // A refresh (successful or not) was attempted within FRESH_TTL_MS -> still in cooldown,
584
+ // don't hit the API again. Serve stale cached data if it's still within STALE_TTL_MS, else
585
+ // nothing. Without this, a repeatedly failing/timing-out refresh would re-hit the API on
586
+ // every render instead of backing off (issue #41).
587
+ const attemptAge = getLastAttemptAge();
588
+ if (attemptAge != null && attemptAge < FRESH_TTL_MS) {
589
+ return callback(cached && cached.age < STALE_TTL_MS ? cached.data : null);
590
+ }
591
+
592
+ // Cache is stale or missing and no attempt is in cooldown -> refresh from the API.
593
+ recordUsageAttempt();
542
594
  getApiUsage((fresh) => {
543
595
  if (fresh) {
544
596
  callback(fresh);
@@ -551,20 +603,6 @@ function getRawUsage(callback) {
551
603
  });
552
604
  }
553
605
 
554
- // Get usage, cache-first, rendered.
555
- function getUsageWithCache(callback) {
556
- getRawUsage((data) => {
557
- callback(data ? buildUsageBars(data.fiveHour, data.weekly, data.models) : null);
558
- });
559
- }
560
-
561
- // Model-scoped weekly limits only, cache-first. Used alongside the stdin H/W bars, which
562
- // can't carry them. Falls back to the stale cache and finally to [] so a failed or slow
563
- // call costs the scoped bars but never the bars stdin already gave us.
564
- function getScopedModels(callback) {
565
- getRawUsage((data) => callback(data?.models || []));
566
- }
567
-
568
606
  // Session cost from stdin `cost.total_cost_usd` (USD float, computed client-side by
569
607
  // Claude Code as tokens × per-model API pricing). Pure stdin — no network/cache.
570
608
  // Returns "$0.00" rendered dim, or '' when absent/non-finite so the segment is omitted.
@@ -604,63 +642,80 @@ function visibleWidth(str) {
604
642
  }
605
643
 
606
644
  // Responsive layout: one line when it fits the terminal, else line1 (identity + context)
607
- // on top and line2 (usage/cost/task) below. Splits only when COLUMNS is known (Claude Code
608
- // v2.1.153+) and the single line overflows — unknown width or an empty line2 stays single,
609
- // so there is no regression on older clients or wide terminals.
610
- function layout(line1Parts, line2Parts) {
645
+ // on top and line2 (usage/cost/task) below. Splits only when cols is known (Claude Code
646
+ // v2.1.153+ sets COLUMNS, read by collectFacts) and the single line overflows — unknown
647
+ // width or an empty line2 stays single, so there is no regression on older clients or wide
648
+ // terminals.
649
+ function layout(line1Parts, line2Parts, cols) {
611
650
  const single = [...line1Parts, ...line2Parts].join(SEGMENT_SEP);
612
651
  if (line2Parts.length === 0) return single;
613
- const cols = parseInt(process.env.COLUMNS, 10);
614
652
  if (Number.isFinite(cols) && cols > 0 && visibleWidth(single) > cols - WIDTH_MARGIN) {
615
653
  return line1Parts.join(SEGMENT_SEP) + '\n' + line2Parts.join(SEGMENT_SEP);
616
654
  }
617
655
  return single;
618
656
  }
619
657
 
620
- // Main
621
- function outputStatus(data, usage) {
658
+ // Gathers everything outputStatus needs that touches fs/child_process/env: git branch +
659
+ // ahead/behind (.git/HEAD, `git rev-list`), the in-progress task (~/.claude/todos), and the
660
+ // terminal width (COLUMNS). Kept separate from renderStatusLine so the render step is pure.
661
+ // Wrapped in its own try/catch (unlike renderStatusLine, it's called outside outputStatus's
662
+ // try/catch in emit()) — a malformed workspace.current_dir (e.g. non-string) can throw from
663
+ // path.basename or resolveGitDir, and this must still degrade to a renderable fallback.
664
+ function collectFacts(data) {
622
665
  try {
623
- const model = shortenModel(data?.model?.display_name || 'Claude');
624
666
  const dir = data?.workspace?.current_dir || process.cwd();
625
667
  const dirname = path.basename(dir);
626
668
  const branch = DISABLED.has('branch') ? '' : getGitBranch(dir);
627
669
  const sync = branch ? formatAheadBehind(getGitAheadBehind(dir)) : '';
628
- const effort = DISABLED.has('effort') ? '' : (data?.effort?.level || '');
629
670
  const sessionId = data?.session_id || '';
630
- const remaining = data?.context_window?.remaining_percentage;
631
-
632
- const contextBar = getContextBar(remaining);
633
- const cost = DISABLED.has('cost') ? '' : getCostSegment(data);
634
671
  const task = DISABLED.has('task') ? '' : getCurrentTask(sessionId);
672
+ const cols = parseInt(process.env.COLUMNS, 10);
673
+ return { dirname, branch, sync, task, cols };
674
+ } catch (e) {
675
+ return { dirname: '~', branch: '', sync: '', task: '', cols: undefined };
676
+ }
677
+ }
678
+
679
+ // Pure: data + facts (see collectFacts) + resolved usage bars -> the rendered line(s).
680
+ // No fs/child_process/network access, so it's callable directly in tests.
681
+ function renderStatusLine(data, facts, usage) {
682
+ const model = shortenModel(data?.model?.display_name || 'Claude');
683
+ const effort = DISABLED.has('effort') ? '' : (data?.effort?.level || '');
684
+ const remaining = data?.context_window?.remaining_percentage;
685
+
686
+ const contextBar = getContextBar(remaining);
687
+ const cost = DISABLED.has('cost') ? '' : getCostSegment(data);
688
+
689
+ // line1 = identity + context (always); line2 = usage/cost/task (wrap target).
690
+ const line1 = [];
691
+ line1.push(facts.branch
692
+ ? `${facts.dirname} ${colors.dim}⎇ ${facts.branch}${colors.reset}${facts.sync ? ' ' + facts.sync : ''}`
693
+ : facts.dirname);
694
+ line1.push(renderModelEffort(model, effort));
695
+ line1.push(contextBar);
635
696
 
636
- // line1 = identity + context (always); line2 = usage/cost/task (wrap target).
637
- const line1 = [];
638
- line1.push(branch
639
- ? `${dirname} ${colors.dim}⎇ ${branch}${colors.reset}${sync ? ' ' + sync : ''}`
640
- : dirname);
641
- line1.push(effort ? `${model}${getEffortColor(effort)} · ${effort}${colors.reset}` : model);
642
- line1.push(contextBar);
643
-
644
- const line2 = [];
645
- if (usage?.current) line2.push(usage.current);
646
- if (usage?.weekly) line2.push(usage.weekly);
647
- if (usage?.models?.length) line2.push(...usage.models);
648
- if (cost) line2.push(cost);
649
- if (task) line2.push(`${colors.dim}${task}${colors.reset}`);
650
-
651
- process.stdout.write(layout(line1, line2));
697
+ const line2 = [];
698
+ if (usage?.current) line2.push(usage.current);
699
+ if (usage?.weekly) line2.push(usage.weekly);
700
+ if (usage?.models?.length) line2.push(...usage.models);
701
+ if (cost) line2.push(cost);
702
+ if (facts.task) line2.push(`${colors.dim}${facts.task}${colors.reset}`);
703
+
704
+ return layout(line1, line2, facts.cols);
705
+ }
706
+
707
+ // Main
708
+ function outputStatus(data, facts, usage) {
709
+ try {
710
+ process.stdout.write(renderStatusLine(data, facts, usage));
652
711
  } catch (e) {
653
712
  process.stdout.write('Status unavailable');
654
713
  }
655
714
  }
656
715
 
657
716
  function outputFallback(usage) {
658
- const contextBar = getContextBar(undefined);
659
- const parts = ['~', 'Claude', contextBar];
660
- if (usage?.current) parts.push(usage.current);
661
- if (usage?.weekly) parts.push(usage.weekly);
662
- if (usage?.models?.length) parts.push(...usage.models);
663
- process.stdout.write(parts.join(' \u2502 '));
717
+ const facts = { dirname: '~', branch: '', sync: '', task: '', cols: undefined };
718
+ process.stdout.write(renderStatusLine(null, facts, usage));
664
719
  }
665
720
 
666
721
  // Resolve usage bars for a (possibly null) parsed stdin payload.
@@ -675,14 +730,15 @@ function resolveUsage(data, callback) {
675
730
  // stdin covers H and W with no network. Model-scoped weekly limits only exist in the
676
731
  // API payload, so they come from the cache — refreshed on the same TTL as every other
677
732
  // usage read, which keeps at most one call per FRESH_TTL_MS regardless of render rate.
678
- return getScopedModels((models) => {
679
- callback(buildUsageBars(fromStdin.fiveHour, fromStdin.weekly, models));
733
+ // Falls back to the stale cache and finally to [] so a failed or slow call costs only
734
+ // the scoped bars, never the H/W bars stdin already gave us.
735
+ return getRawUsage((cached) => {
736
+ callback(buildUsageBars({ ...fromStdin, models: cached?.models || [] }));
680
737
  });
681
738
  }
682
- getUsageWithCache(callback);
739
+ getRawUsage((raw) => callback(raw ? buildUsageBars(raw) : null));
683
740
  }
684
741
 
685
- // Process with timeout
686
742
  // Parse the accumulated stdin into a payload object, or null if empty/unparseable.
687
743
  function parseInput(input) {
688
744
  if (!input || input.length === 0) return null;
@@ -693,11 +749,34 @@ function parseInput(input) {
693
749
  }
694
750
  }
695
751
 
752
+ // Accumulate stdin then call fn(input) exactly once, on whichever fires first:
753
+ // timeout, 'end', or 'error' (an unhandled stdin error would otherwise throw,
754
+ // breaking the never-throw contract). Shared by both entry points below, which
755
+ // differ only in timeoutMs.
756
+ function readStdinThen(timeoutMs, fn) {
757
+ let input = '';
758
+ let finished = false;
759
+
760
+ const finish = () => {
761
+ if (finished) return;
762
+ finished = true;
763
+ clearTimeout(timeout);
764
+ fn(input);
765
+ };
766
+
767
+ const timeout = setTimeout(finish, timeoutMs);
768
+
769
+ process.stdin.setEncoding('utf8');
770
+ process.stdin.on('data', chunk => input += chunk);
771
+ process.stdin.on('end', finish);
772
+ process.stdin.on('error', finish);
773
+ }
774
+
696
775
  // Resolve usage for `data` (preferring stdin rate_limits), then render and exit.
697
776
  function emit(data) {
698
777
  resolveUsage(data, (usage) => {
699
778
  if (data) {
700
- outputStatus(data, usage);
779
+ outputStatus(data, collectFacts(data), usage);
701
780
  } else {
702
781
  outputFallback(usage);
703
782
  }
@@ -733,9 +812,7 @@ function renderSubagentTask(t) {
733
812
  // effort absent = subagent inherits the session effort; show model alone then.
734
813
  const effort = t.effort != null ? String(t.effort) : '';
735
814
  if (model) {
736
- parts.push(effort
737
- ? `${model}${getEffortColor(effort)} · ${effort}${colors.reset}`
738
- : model);
815
+ parts.push(renderModelEffort(model, effort));
739
816
  } else if (effort) {
740
817
  parts.push(`${getEffortColor(effort)}${effort}${colors.reset}`);
741
818
  }
@@ -777,51 +854,17 @@ function emitSubagent(data) {
777
854
  // directly (the /usage response shape is the easiest thing here to get wrong, and it
778
855
  // can't be reached through stdin). Running the script normally is unchanged.
779
856
  if (require.main === module) {
780
- if (process.argv[2] === 'subagent') {
781
- if (process.stdin.isTTY) {
782
- emitSubagent(null);
783
- } else {
784
- let input = '';
785
- let finished = false;
786
-
787
- // Single guarded exit shared by all three triggers: timeout, stdin 'end', and
788
- // stdin 'error' (which can fire before 'end' and would otherwise throw unhandled,
789
- // breaking the never-throw contract). Whatever accumulated so far gets rendered.
790
- const finish = () => {
791
- if (finished) return;
792
- finished = true;
793
- clearTimeout(timeout);
794
- emitSubagent(parseInput(input));
795
- };
796
-
797
- const timeout = setTimeout(finish, SUBAGENT_TIMEOUT_MS);
798
-
799
- process.stdin.setEncoding('utf8');
800
- process.stdin.on('data', chunk => input += chunk);
801
- process.stdin.on('end', finish);
802
- process.stdin.on('error', finish);
803
- }
804
- } else if (process.stdin.isTTY) {
805
- emit(null);
857
+ const isSubagent = process.argv[2] === 'subagent';
858
+ const finish = isSubagent ? emitSubagent : emit;
859
+
860
+ if (process.stdin.isTTY) {
861
+ finish(null);
806
862
  } else {
807
- let input = '';
808
- let timeoutReached = false;
809
-
810
- const overallTimeout = IS_API_KEY ? 500 : (fs.existsSync(USAGE_CACHE_FILE) ? 1300 : 1600);
811
-
812
- const timeout = setTimeout(() => {
813
- timeoutReached = true;
814
- emit(parseInput(input));
815
- }, overallTimeout);
816
-
817
- process.stdin.setEncoding('utf8');
818
- process.stdin.on('data', chunk => input += chunk);
819
- process.stdin.on('end', () => {
820
- if (timeoutReached) return;
821
- clearTimeout(timeout);
822
- emit(parseInput(input));
823
- });
863
+ const timeoutMs = isSubagent
864
+ ? SUBAGENT_TIMEOUT_MS
865
+ : (IS_API_KEY ? 500 : (fs.existsSync(USAGE_CACHE_FILE) ? 1300 : 1600));
866
+ readStdinThen(timeoutMs, (input) => finish(parseInput(input)));
824
867
  }
825
868
  } else {
826
- module.exports = { parseScopedLimits, normalizePercentage };
869
+ module.exports = { parseScopedLimits, parseUsagePayload, serializeUsageCache, normalizePercentage, readStdinThen, renderStatusLine, renderSubagentTask };
827
870
  }