ctxline-claude 1.2.2 → 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 +145 -47
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.2",
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, ')');
@@ -222,7 +229,8 @@ function getContextBar(remaining) {
222
229
  // Render a compact usage segment from raw data: "<label><pct> ↺ <countdown>"
223
230
  // (e.g. "H81 ↺ 2h21m") — no bar. Called on every read (live or cached) so the reset
224
231
  // countdown is always recomputed from resetsAt rather than frozen at fetch time.
225
- function buildUsageBar(label, percentage, resetsAt) {
232
+ // `color` overrides the threshold color — the model-scoped bars pass getScopedColor.
233
+ function buildUsageBar(label, percentage, resetsAt, color) {
226
234
  let timeStr = '';
227
235
  if (resetsAt) {
228
236
  const diffMins = Math.max(0, Math.floor((new Date(resetsAt) - new Date()) / 60000));
@@ -234,18 +242,41 @@ function buildUsageBar(label, percentage, resetsAt) {
234
242
  else timeStr = `${mins}m`;
235
243
  }
236
244
 
237
- const color = getUsageColor(percentage);
245
+ const barColor = color || getUsageColor(percentage);
238
246
  const timePart = timeStr ? `${colors.dim} ↺ ${timeStr}${colors.reset}` : '';
239
247
 
240
- return `${color}${label}${percentage}${colors.reset}${timePart}`;
248
+ return `${barColor}${label}${percentage}${colors.reset}${timePart}`;
241
249
  }
242
250
 
243
- // Build both usage segments from raw entries. Each entry is { percentage, resetsAt } or
244
- // null/absent. Returns { current, weekly } where each is a rendered segment string or null.
245
- 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) {
246
276
  return {
247
277
  current: fiveHour ? buildUsageBar('H', fiveHour.percentage, fiveHour.resetsAt) : null,
248
- 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)))
249
280
  };
250
281
  }
251
282
 
@@ -257,11 +288,42 @@ function normalizePercentage(value) {
257
288
  return Math.max(0, Math.min(100, Math.round(value)));
258
289
  }
259
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
+
260
321
  // Build usage bars from stdin `rate_limits` (Claude.ai Pro/Max, present only after the
261
322
  // first API response of a session). Same data as the OAuth usage API, so reading it here
262
323
  // skips the network/credentials/cache path entirely. `resets_at` is a Unix epoch in
263
- // SECONDS (not ISO) — ×1000 before Date. Returns { current, weekly } bars, or null when
264
- // 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.
265
327
  function buildUsageFromStdin(data) {
266
328
  const rl = data?.rate_limits;
267
329
  if (!rl) return null;
@@ -284,8 +346,7 @@ function buildUsageFromStdin(data) {
284
346
 
285
347
  const fiveHour = toEntry(rl.five_hour);
286
348
  if (!fiveHour) return null; // five_hour is the required bar
287
- const weekly = toEntry(rl.seven_day);
288
- return buildUsageBars(fiveHour, weekly);
349
+ return { fiveHour, weekly: toEntry(rl.seven_day) };
289
350
  }
290
351
 
291
352
  // Validate a single usage entry ({ percentage, resetsAt }). Returns true only for a
@@ -307,13 +368,18 @@ function readCachedUsage() {
307
368
  const cache = JSON.parse(fs.readFileSync(USAGE_CACHE_FILE, 'utf8'));
308
369
  if (!cache || !Number.isFinite(cache.timestamp) || cache.timestamp <= 0) return null;
309
370
 
310
- // Validate data. fiveHour is required; weekly is optional (the API may omit it).
311
- // This also rejects the legacy single-{percentage,resetsAt} format from older
312
- // 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.
313
375
  const data = cache.data;
314
376
  if (!data || typeof data !== 'object') return null;
315
377
  if (!isValidUsageEntry(data.fiveHour)) return null;
316
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
+ }
317
383
 
318
384
  return { age: Date.now() - cache.timestamp, data };
319
385
  } catch (e) {
@@ -412,9 +478,13 @@ function getApiUsage(callback) {
412
478
  resetsAt: usage.seven_day.resets_at || null
413
479
  } : null;
414
480
 
415
- // Cache the raw data (shared across sessions); render the bars from it.
416
- setCachedUsage({ fiveHour, weekly });
417
- 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);
418
488
  } else {
419
489
  callback(null);
420
490
  }
@@ -436,28 +506,42 @@ function getApiUsage(callback) {
436
506
  }
437
507
  }
438
508
 
439
- // Get usage, cache-first.
440
- function getUsageWithCache(callback) {
509
+ // Resolve raw usage data ({ fiveHour, weekly, models }), cache-first. Callers render it.
510
+ function getRawUsage(callback) {
441
511
  const cached = readCachedUsage();
442
512
 
443
- // 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).
444
514
  if (cached && cached.age < FRESH_TTL_MS) {
445
- return callback(buildUsageBars(cached.data.fiveHour, cached.data.weekly));
515
+ return callback(cached.data);
446
516
  }
447
517
 
448
518
  // Cache is stale or missing -> refresh from the API.
449
- getApiUsage((freshBars) => {
450
- if (freshBars) {
451
- callback(freshBars);
519
+ getApiUsage((fresh) => {
520
+ if (fresh) {
521
+ callback(fresh);
452
522
  } else if (cached && cached.age < STALE_TTL_MS) {
453
523
  // API failed/timed out, but recent cache exists -> show it instead of nothing.
454
- callback(buildUsageBars(cached.data.fiveHour, cached.data.weekly));
524
+ callback(cached.data);
455
525
  } else {
456
526
  callback(null);
457
527
  }
458
528
  });
459
529
  }
460
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
+
461
545
  // Session cost from stdin `cost.total_cost_usd` (USD float, computed client-side by
462
546
  // Claude Code as tokens × per-model API pricing). Pure stdin — no network/cache.
463
547
  // Returns "$0.00" rendered dim, or '' when absent/non-finite so the segment is omitted.
@@ -537,6 +621,7 @@ function outputStatus(data, usage) {
537
621
  const line2 = [];
538
622
  if (usage?.current) line2.push(usage.current);
539
623
  if (usage?.weekly) line2.push(usage.weekly);
624
+ if (usage?.models?.length) line2.push(...usage.models);
540
625
  if (cost) line2.push(cost);
541
626
  if (task) line2.push(`${colors.dim}${task}${colors.reset}`);
542
627
 
@@ -551,6 +636,7 @@ function outputFallback(usage) {
551
636
  const parts = ['~', 'Claude', contextBar];
552
637
  if (usage?.current) parts.push(usage.current);
553
638
  if (usage?.weekly) parts.push(usage.weekly);
639
+ if (usage?.models?.length) parts.push(...usage.models);
554
640
  process.stdout.write(parts.join(' \u2502 '));
555
641
  }
556
642
 
@@ -563,7 +649,12 @@ function resolveUsage(data, callback) {
563
649
  }
564
650
  const fromStdin = buildUsageFromStdin(data);
565
651
  if (fromStdin) {
566
- 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
+ });
567
658
  }
568
659
  getUsageWithCache(callback);
569
660
  }
@@ -591,24 +682,31 @@ function emit(data) {
591
682
  });
592
683
  }
593
684
 
594
- if (process.stdin.isTTY) {
595
- 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
+ }
596
710
  } else {
597
- let input = '';
598
- let timeoutReached = false;
599
-
600
- const overallTimeout = IS_API_KEY ? 500 : (fs.existsSync(USAGE_CACHE_FILE) ? 1300 : 1600);
601
-
602
- const timeout = setTimeout(() => {
603
- timeoutReached = true;
604
- emit(parseInput(input));
605
- }, overallTimeout);
606
-
607
- process.stdin.setEncoding('utf8');
608
- process.stdin.on('data', chunk => input += chunk);
609
- process.stdin.on('end', () => {
610
- if (timeoutReached) return;
611
- clearTimeout(timeout);
612
- emit(parseInput(input));
613
- });
711
+ module.exports = { parseScopedLimits, normalizePercentage };
614
712
  }