ctxline-claude 1.2.1 → 1.5.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.
Files changed (3) hide show
  1. package/README.md +16 -4
  2. package/package.json +1 -1
  3. package/statusline.js +148 -48
package/README.md CHANGED
@@ -17,6 +17,9 @@
17
17
  <a href="https://github.com/MithunWijayasiri/ctxline-claude/stargazers">
18
18
  <img src="https://img.shields.io/github/stars/MithunWijayasiri/ctxline-claude" alt="stars">
19
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>
20
23
  </p>
21
24
 
22
25
  <p align="center">
@@ -121,6 +124,7 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
121
124
  | **Context** | Visual bar of context-window usage |
122
125
  | **Current** | Live 5-hour session limit + reset countdown (subscription users) |
123
126
  | **Weekly** | Weekly usage allowance + time until the weekly reset (subscription users) |
127
+ | **Model limit** | Weekly limit scoped to a single model, when your account has one — labelled by the model's initial (`F` = Fable) |
124
128
  | **Cost** | Running session cost in USD (e.g. `$0.42`) |
125
129
  | **Task** | The in-progress todo, when there is one |
126
130
 
@@ -137,7 +141,7 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
137
141
 
138
142
  The statusline is zero-config by default. To **hide segments you don't want**, set the `CTXLINE_DISABLE` environment variable to a comma-separated list of any of:
139
143
 
140
- `branch` · `effort` · `cost` · `task` · `usage` (5-hour + weekly)
144
+ `branch` · `effort` · `cost` · `task` · `usage` (5-hour + weekly + model-scoped)
141
145
 
142
146
  Directory, model, and context always show; unknown names are ignored. Example below hides cost and the current task.
143
147
 
@@ -181,7 +185,8 @@ To re-enable a segment, remove it from the list (or delete the variable) and res
181
185
 
182
186
  ## How it works
183
187
 
184
- - **Source** — context comes from Claude Code's session data. Usage bars are read straight from the `rate_limits` field Claude Code pipes in (no network), falling back to `https://api.anthropic.com/api/oauth/usage` (the same `/usage` data — 5-hour and weekly limits) when that field isn't present yet. API-key users skip usage entirely.
188
+ - **Source** — context comes from Claude Code's session data. The 5-hour and weekly bars are read straight from the `rate_limits` field Claude Code pipes in (no network), falling back to `https://api.anthropic.com/api/oauth/usage` when that field isn't present yet. API-key users skip usage entirely.
189
+ - **Model-scoped limits** — `rate_limits` carries only `five_hour` and `seven_day`, so a model-scoped weekly limit can only come from `/usage` (its `limits` array). It's served from the same cache as everything else, so this costs at most one call per 30s no matter how often the line renders.
185
190
  - **No network on the fast path** — when `rate_limits` is in the session data, there's no API call at all. The fetch below only runs as a fallback (e.g. the first render of a session, before the field appears).
186
191
  - **Adaptive timing** — for the fallback fetch: 1.5s timeout on the first prompt (cold start), 1.2s after (connection reused).
187
192
  - **Caching** — the fallback fetch is cached at `~/.claude/cache/usage-cache.json`, shared across sessions. Within 30s the cache renders directly (the API call is skipped); if a live call fails, the last value (up to 10 min old) is shown so the bar never vanishes. The reset countdown recomputes every render.
@@ -238,10 +243,17 @@ No. Your credentials never leave your machine. On the fast path no token is read
238
243
 
239
244
  </details>
240
245
 
241
- ## License
242
246
 
243
- MIT
247
+ ## Support
248
+
249
+ If you find this project useful, consider supporting its development on [Ko-fi](https://ko-fi.com/mithunwijayasiri). Your donations help keep the project maintained, improve existing features, and fund new open-source tools.
250
+
251
+ Thank you for your support! ❤️
244
252
 
245
253
  ## Credits
246
254
 
247
255
  Thanks to [@TahaSabir0](https://github.com/TahaSabir0) for the base config.
256
+
257
+ ## License
258
+
259
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctxline-claude",
3
- "version": "1.2.1",
3
+ "version": "1.5.0",
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"
package/statusline.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // Claude Code Enhanced Statusline
3
- // Shows: directory | model | context usage | current (5-hour) + weekly usage | current task
3
+ // Shows: directory | model | context usage | 5-hour + weekly + model-scoped usage | current task
4
4
  // Auto-detects API key vs subscription usage
5
5
  // https://github.com/MithunWijayasiri/ctxline-claude
6
6
 
@@ -13,7 +13,7 @@ const { execSync, execFileSync } = require('child_process');
13
13
  const IS_API_KEY = !!process.env.ANTHROPIC_API_KEY;
14
14
 
15
15
  // Optional segment opt-out: CTXLINE_DISABLE is a comma list of segments to hide.
16
- // Recognized: branch, effort, cost, task, usage (H+W). dir/model/context always render.
16
+ // Recognized: branch, effort, cost, task, usage (H+W+model-scoped). dir/model/context always render.
17
17
  // Unknown names are ignored. Disabling a segment also skips its work (git, todo read,
18
18
  // usage fetch).
19
19
  const DISABLED = new Set(
@@ -84,6 +84,13 @@ function getUsageColor(percentage) {
84
84
  return colors.red;
85
85
  }
86
86
 
87
+ // Model-scoped bars skip the H/W thresholds: a line can carry several at once, so a flat
88
+ // orange keeps them readable as one group. Red at >=90 is the one distinction kept — that
89
+ // bar is about to block the model it names.
90
+ function getScopedColor(percentage) {
91
+ return percentage >= 90 ? colors.red : colors.orange;
92
+ }
93
+
87
94
  // Shorten verbose model names for the statusline: "Opus 4.8 (1M context)" -> "Opus 4.8 (1M)".
88
95
  function shortenModel(name) {
89
96
  return name.replace(/\s+context\)/i, ')');
@@ -125,7 +132,9 @@ function getGitBranch(dir) {
125
132
  if (!gitDir) return '';
126
133
  const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
127
134
  const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/);
128
- if (ref) return truncateBranch(ref[1]);
135
+ // Strip control chars: HEAD is read raw (not git-validated), so a hand-crafted file
136
+ // in an untrusted archive could inject terminal escape sequences.
137
+ if (ref) return truncateBranch(ref[1].replace(/[\x00-\x1f\x7f]/g, ''));
129
138
  if (/^[0-9a-f]{7,40}$/i.test(head)) return head.slice(0, 7); // detached HEAD -> short sha
130
139
  return '';
131
140
  } catch (e) {
@@ -220,7 +229,8 @@ function getContextBar(remaining) {
220
229
  // Render a compact usage segment from raw data: "<label><pct> ↺ <countdown>"
221
230
  // (e.g. "H81 ↺ 2h21m") — no bar. Called on every read (live or cached) so the reset
222
231
  // countdown is always recomputed from resetsAt rather than frozen at fetch time.
223
- function buildUsageBar(label, percentage, resetsAt) {
232
+ // `color` overrides the threshold color — the model-scoped bars pass getScopedColor.
233
+ function buildUsageBar(label, percentage, resetsAt, color) {
224
234
  let timeStr = '';
225
235
  if (resetsAt) {
226
236
  const diffMins = Math.max(0, Math.floor((new Date(resetsAt) - new Date()) / 60000));
@@ -232,18 +242,41 @@ function buildUsageBar(label, percentage, resetsAt) {
232
242
  else timeStr = `${mins}m`;
233
243
  }
234
244
 
235
- const color = getUsageColor(percentage);
245
+ const barColor = color || getUsageColor(percentage);
236
246
  const timePart = timeStr ? `${colors.dim} ↺ ${timeStr}${colors.reset}` : '';
237
247
 
238
- return `${color}${label}${percentage}${colors.reset}${timePart}`;
248
+ return `${barColor}${label}${percentage}${colors.reset}${timePart}`;
239
249
  }
240
250
 
241
- // Build both usage segments from raw entries. Each entry is { percentage, resetsAt } or
242
- // null/absent. Returns { current, weekly } where each is a rendered segment string or null.
243
- function buildUsageBars(fiveHour, weekly) {
251
+ // Model-scoped weekly limits (e.g. "Fable weekly limit at 86%"), rendered after the
252
+ // account-wide W bar. The /usage payload reports these in a `limits` array, each entry
253
+ // carrying the model in scope.model.display_name:
254
+ //
255
+ // { kind: "weekly_scoped", percent: 86, severity: "warning",
256
+ // resets_at: "...", scope: { model: { display_name: "Fable" } } }
257
+ //
258
+ // The label is the model's first initial (Fable -> F), so a new model family needs no
259
+ // code change. Older payloads instead exposed flat seven_day_<model> keys, kept below as
260
+ // a fallback for accounts still reporting that shape.
261
+ //
262
+ // NOTE: these appear only in the API payload. Claude Code's statusline stdin carries just
263
+ // five_hour and seven_day under rate_limits, so the scoped limits always come from the
264
+ // cache/API path even when stdin supplies the H and W bars.
265
+ const LEGACY_MODEL_WEEKLY_KEYS = [
266
+ { key: 'seven_day_opus', label: 'O' },
267
+ { key: 'seven_day_sonnet', label: 'S' }
268
+ ];
269
+
270
+ // Build the usage segments from raw entries. fiveHour/weekly are { percentage, resetsAt }
271
+ // or null/absent; models is an array of { label, percentage, resetsAt } (possibly empty).
272
+ // Returns { current, weekly, models } — the first two rendered strings or null, models a
273
+ // (possibly empty) array of rendered strings. Scoped bars use getScopedColor instead of the
274
+ // H/W thresholds, so the full threshold palette stays exclusive to H/W.
275
+ function buildUsageBars(fiveHour, weekly, models) {
244
276
  return {
245
277
  current: fiveHour ? buildUsageBar('H', fiveHour.percentage, fiveHour.resetsAt) : null,
246
- weekly: weekly ? buildUsageBar('W', weekly.percentage, weekly.resetsAt) : null
278
+ weekly: weekly ? buildUsageBar('W', weekly.percentage, weekly.resetsAt) : null,
279
+ models: (models || []).map(m => buildUsageBar(m.label, m.percentage, m.resetsAt, getScopedColor(m.percentage)))
247
280
  };
248
281
  }
249
282
 
@@ -255,11 +288,42 @@ function normalizePercentage(value) {
255
288
  return Math.max(0, Math.min(100, Math.round(value)));
256
289
  }
257
290
 
291
+ // Extract the model-scoped weekly limits from a raw /usage payload as
292
+ // [{ label, percentage, resetsAt }], in payload order. Prefers the `limits` array;
293
+ // falls back to the legacy flat keys only when it yields nothing, so an account
294
+ // reporting both shapes doesn't render the same limit twice.
295
+ function parseScopedLimits(usage) {
296
+ const scoped = [];
297
+
298
+ if (Array.isArray(usage?.limits)) {
299
+ for (const entry of usage.limits) {
300
+ if (!entry || entry.kind !== 'weekly_scoped') continue;
301
+ const name = entry.scope?.model?.display_name;
302
+ const pct = normalizePercentage(entry.percent);
303
+ if (typeof name !== 'string' || !name.trim() || pct == null) continue;
304
+ scoped.push({
305
+ label: name.trim().charAt(0).toUpperCase(),
306
+ percentage: pct,
307
+ resetsAt: entry.resets_at || null
308
+ });
309
+ }
310
+ if (scoped.length) return scoped;
311
+ }
312
+
313
+ for (const { key, label } of LEGACY_MODEL_WEEKLY_KEYS) {
314
+ const seg = usage?.[key];
315
+ const pct = seg ? normalizePercentage(seg.utilization) : null;
316
+ if (pct != null) scoped.push({ label, percentage: pct, resetsAt: seg.resets_at || null });
317
+ }
318
+ return scoped;
319
+ }
320
+
258
321
  // Build usage bars from stdin `rate_limits` (Claude.ai Pro/Max, present only after the
259
322
  // first API response of a session). Same data as the OAuth usage API, so reading it here
260
323
  // skips the network/credentials/cache path entirely. `resets_at` is a Unix epoch in
261
- // SECONDS (not ISO) — ×1000 before Date. Returns { current, weekly } bars, or null when
262
- // rate_limits is absent or the required five_hour segment is unusable (caller falls back).
324
+ // SECONDS (not ISO) — ×1000 before Date. Returns raw { fiveHour, weekly } entries, or null
325
+ // when rate_limits is absent or the required five_hour segment is unusable (caller falls
326
+ // back). Model-scoped weekly limits are never present here — see LEGACY_MODEL_WEEKLY_KEYS.
263
327
  function buildUsageFromStdin(data) {
264
328
  const rl = data?.rate_limits;
265
329
  if (!rl) return null;
@@ -282,8 +346,7 @@ function buildUsageFromStdin(data) {
282
346
 
283
347
  const fiveHour = toEntry(rl.five_hour);
284
348
  if (!fiveHour) return null; // five_hour is the required bar
285
- const weekly = toEntry(rl.seven_day);
286
- return buildUsageBars(fiveHour, weekly);
349
+ return { fiveHour, weekly: toEntry(rl.seven_day) };
287
350
  }
288
351
 
289
352
  // Validate a single usage entry ({ percentage, resetsAt }). Returns true only for a
@@ -305,13 +368,18 @@ function readCachedUsage() {
305
368
  const cache = JSON.parse(fs.readFileSync(USAGE_CACHE_FILE, 'utf8'));
306
369
  if (!cache || !Number.isFinite(cache.timestamp) || cache.timestamp <= 0) return null;
307
370
 
308
- // Validate data. fiveHour is required; weekly is optional (the API may omit it).
309
- // This also rejects the legacy single-{percentage,resetsAt} format from older
310
- // versions, which had no fiveHour key, so stale caches are ignored on read.
371
+ // Validate data. fiveHour is required; weekly and models are optional (the API may
372
+ // omit either). This also rejects the legacy single-{percentage,resetsAt} format from
373
+ // older versions, which had no fiveHour key, so stale caches are ignored on read.
374
+ // A cache written before model bars existed simply has no models key — still valid.
311
375
  const data = cache.data;
312
376
  if (!data || typeof data !== 'object') return null;
313
377
  if (!isValidUsageEntry(data.fiveHour)) return null;
314
378
  if (data.weekly != null && !isValidUsageEntry(data.weekly)) return null;
379
+ if (data.models != null) {
380
+ if (!Array.isArray(data.models)) return null;
381
+ if (!data.models.every(m => typeof m?.label === 'string' && isValidUsageEntry(m))) return null;
382
+ }
315
383
 
316
384
  return { age: Date.now() - cache.timestamp, data };
317
385
  } catch (e) {
@@ -410,9 +478,13 @@ function getApiUsage(callback) {
410
478
  resetsAt: usage.seven_day.resets_at || null
411
479
  } : null;
412
480
 
413
- // Cache the raw data (shared across sessions); render the bars from it.
414
- setCachedUsage({ fiveHour, weekly });
415
- callback(buildUsageBars(fiveHour, weekly));
481
+ // Model-scoped weekly limits, rendered only when the account reports them.
482
+ const models = parseScopedLimits(usage);
483
+
484
+ // Cache the raw data (shared across sessions); callers render from it.
485
+ const resolved = { fiveHour, weekly, models };
486
+ setCachedUsage(resolved);
487
+ callback(resolved);
416
488
  } else {
417
489
  callback(null);
418
490
  }
@@ -434,28 +506,42 @@ function getApiUsage(callback) {
434
506
  }
435
507
  }
436
508
 
437
- // Get usage, cache-first.
438
- function getUsageWithCache(callback) {
509
+ // Resolve raw usage data ({ fiveHour, weekly, models }), cache-first. Callers render it.
510
+ function getRawUsage(callback) {
439
511
  const cached = readCachedUsage();
440
512
 
441
- // Cache is fresh -> render it and skip the API entirely (fewer calls, faster).
513
+ // Cache is fresh -> use it and skip the API entirely (fewer calls, faster).
442
514
  if (cached && cached.age < FRESH_TTL_MS) {
443
- return callback(buildUsageBars(cached.data.fiveHour, cached.data.weekly));
515
+ return callback(cached.data);
444
516
  }
445
517
 
446
518
  // Cache is stale or missing -> refresh from the API.
447
- getApiUsage((freshBars) => {
448
- if (freshBars) {
449
- callback(freshBars);
519
+ getApiUsage((fresh) => {
520
+ if (fresh) {
521
+ callback(fresh);
450
522
  } else if (cached && cached.age < STALE_TTL_MS) {
451
523
  // API failed/timed out, but recent cache exists -> show it instead of nothing.
452
- callback(buildUsageBars(cached.data.fiveHour, cached.data.weekly));
524
+ callback(cached.data);
453
525
  } else {
454
526
  callback(null);
455
527
  }
456
528
  });
457
529
  }
458
530
 
531
+ // Get usage, cache-first, rendered.
532
+ function getUsageWithCache(callback) {
533
+ getRawUsage((data) => {
534
+ callback(data ? buildUsageBars(data.fiveHour, data.weekly, data.models) : null);
535
+ });
536
+ }
537
+
538
+ // Model-scoped weekly limits only, cache-first. Used alongside the stdin H/W bars, which
539
+ // can't carry them. Falls back to the stale cache and finally to [] so a failed or slow
540
+ // call costs the scoped bars but never the bars stdin already gave us.
541
+ function getScopedModels(callback) {
542
+ getRawUsage((data) => callback(data?.models || []));
543
+ }
544
+
459
545
  // Session cost from stdin `cost.total_cost_usd` (USD float, computed client-side by
460
546
  // Claude Code as tokens × per-model API pricing). Pure stdin — no network/cache.
461
547
  // Returns "$0.00" rendered dim, or '' when absent/non-finite so the segment is omitted.
@@ -535,6 +621,7 @@ function outputStatus(data, usage) {
535
621
  const line2 = [];
536
622
  if (usage?.current) line2.push(usage.current);
537
623
  if (usage?.weekly) line2.push(usage.weekly);
624
+ if (usage?.models?.length) line2.push(...usage.models);
538
625
  if (cost) line2.push(cost);
539
626
  if (task) line2.push(`${colors.dim}${task}${colors.reset}`);
540
627
 
@@ -549,6 +636,7 @@ function outputFallback(usage) {
549
636
  const parts = ['~', 'Claude', contextBar];
550
637
  if (usage?.current) parts.push(usage.current);
551
638
  if (usage?.weekly) parts.push(usage.weekly);
639
+ if (usage?.models?.length) parts.push(...usage.models);
552
640
  process.stdout.write(parts.join(' \u2502 '));
553
641
  }
554
642
 
@@ -561,7 +649,12 @@ function resolveUsage(data, callback) {
561
649
  }
562
650
  const fromStdin = buildUsageFromStdin(data);
563
651
  if (fromStdin) {
564
- return callback(fromStdin);
652
+ // stdin covers H and W with no network. Model-scoped weekly limits only exist in the
653
+ // API payload, so they come from the cache — refreshed on the same TTL as every other
654
+ // usage read, which keeps at most one call per FRESH_TTL_MS regardless of render rate.
655
+ return getScopedModels((models) => {
656
+ callback(buildUsageBars(fromStdin.fiveHour, fromStdin.weekly, models));
657
+ });
565
658
  }
566
659
  getUsageWithCache(callback);
567
660
  }
@@ -589,24 +682,31 @@ function emit(data) {
589
682
  });
590
683
  }
591
684
 
592
- if (process.stdin.isTTY) {
593
- emit(null);
685
+ // Entry point, guarded so tests can require this file to exercise payload parsing
686
+ // directly (the /usage response shape is the easiest thing here to get wrong, and it
687
+ // can't be reached through stdin). Running the script normally is unchanged.
688
+ if (require.main === module) {
689
+ if (process.stdin.isTTY) {
690
+ emit(null);
691
+ } else {
692
+ let input = '';
693
+ let timeoutReached = false;
694
+
695
+ const overallTimeout = IS_API_KEY ? 500 : (fs.existsSync(USAGE_CACHE_FILE) ? 1300 : 1600);
696
+
697
+ const timeout = setTimeout(() => {
698
+ timeoutReached = true;
699
+ emit(parseInput(input));
700
+ }, overallTimeout);
701
+
702
+ process.stdin.setEncoding('utf8');
703
+ process.stdin.on('data', chunk => input += chunk);
704
+ process.stdin.on('end', () => {
705
+ if (timeoutReached) return;
706
+ clearTimeout(timeout);
707
+ emit(parseInput(input));
708
+ });
709
+ }
594
710
  } else {
595
- let input = '';
596
- let timeoutReached = false;
597
-
598
- const overallTimeout = IS_API_KEY ? 500 : (fs.existsSync(USAGE_CACHE_FILE) ? 1300 : 1600);
599
-
600
- const timeout = setTimeout(() => {
601
- timeoutReached = true;
602
- emit(parseInput(input));
603
- }, overallTimeout);
604
-
605
- process.stdin.setEncoding('utf8');
606
- process.stdin.on('data', chunk => input += chunk);
607
- process.stdin.on('end', () => {
608
- if (timeoutReached) return;
609
- clearTimeout(timeout);
610
- emit(parseInput(input));
611
- });
711
+ module.exports = { parseScopedLimits, normalizePercentage };
612
712
  }