ctxline-claude 0.0.2 → 0.0.6

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 +8 -7
  2. package/package.json +1 -1
  3. package/statusline.js +124 -34
package/README.md CHANGED
@@ -105,14 +105,15 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
105
105
  | **Weekly** | Weekly usage allowance + time until the weekly reset (subscription users) |
106
106
  | **Task** | The in-progress todo, when there is one |
107
107
 
108
- > \[!NOTE\] Usage bars change color automatically as you approach your
109
- > limits.
108
+ > [!NOTE]
109
+ > Usage bars change color automatically as you approach your limits.
110
110
 
111
111
  ## How it works
112
112
 
113
- - **Source** — context comes from Claude Code's session data; both usage bars are fetched from `https://api.anthropic.com/api/oauth/usage` (the `/usage` data — 5-hour and weekly limits). API-key users skip the usage fetch.
114
- - **Adaptive timing** — 1.5s timeout on the first prompt (cold start), 1.2s after (connection reused).
115
- - **Caching** — usage 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.
113
+ - **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.
114
+ - **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).
115
+ - **Adaptive timing** — for the fallback fetch: 1.5s timeout on the first prompt (cold start), 1.2s after (connection reused).
116
+ - **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.
116
117
  - **Never breaks** — every failure path falls back silently; the statusline always prints.
117
118
 
118
119
  ## FAQ
@@ -120,7 +121,7 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
120
121
  <details>
121
122
  <summary>Does this use the same data as /usage?</summary>
122
123
 
123
- Yes. Usage information comes directly from Anthropic's usage API.
124
+ Yes — the same 5-hour and weekly limits. It reads them from the session data Claude Code provides when available, and falls back to Anthropic's usage API (the endpoint `/usage` uses) otherwise.
124
125
 
125
126
  </details>
126
127
 
@@ -141,7 +142,7 @@ No. All failures are handled silently and the statusline always renders.
141
142
  <details>
142
143
  <summary>Does it expose my API keys / auth tokens?</summary>
143
144
 
144
- No. Your credentials never leave your machine. The OAuth token is read locally (from `~/.claude/.credentials.json` or the macOS keychain) only to authenticate the request to Anthropic's own usage API — the same endpoint `/usage` uses. Nothing is sent to any third party, logged, or cached; only the resulting usage percentages are stored locally.
145
+ No. Your credentials never leave your machine. On the fast path no token is read at all — usage comes straight from the session data. Only on the fallback fetch is the OAuth token read locally (from `~/.claude/.credentials.json` or the macOS keychain), used solely to authenticate the request to Anthropic's own usage API — the same endpoint `/usage` uses. Nothing is sent to any third party, logged, or cached; only the resulting usage percentages are stored locally.
145
146
 
146
147
  </details>
147
148
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctxline-claude",
3
- "version": "0.0.2",
3
+ "version": "0.0.6",
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
@@ -15,6 +15,10 @@ const IS_API_KEY = !!process.env.ANTHROPIC_API_KEY;
15
15
  // Shared width (cells) for all progress bars: context, current, weekly.
16
16
  const BAR_WIDTH = 6;
17
17
 
18
+ // Max characters shown for the git branch; longer names are tail-truncated with "…".
19
+ // Tail-truncation keeps the start (ticket IDs like "TAMA5-32796" live there) visible.
20
+ const MAX_BRANCH_LEN = 24;
21
+
18
22
  // Cache configuration
19
23
  const CACHE_DIR = path.join(os.homedir(), '.claude', 'cache');
20
24
  const USAGE_CACHE_FILE = path.join(CACHE_DIR, 'usage-cache.json');
@@ -33,9 +37,20 @@ const colors = {
33
37
  yellow: '\x1b[33m',
34
38
  orange: '\x1b[38;5;208m',
35
39
  red: '\x1b[31m',
40
+ purple: '\x1b[38;5;135m',
36
41
  blink: '\x1b[5m'
37
42
  };
38
43
 
44
+ // Color for the thinking-effort indicator. Levels rank low < medium < high < xhigh < max
45
+ // < ultracode; only the top two are highlighted — "max" red, "ultracode" purple. Every
46
+ // other level (including xhigh) renders dim like the rest of the metadata.
47
+ function getEffortColor(level) {
48
+ const lvl = String(level).toLowerCase();
49
+ if (lvl === 'max') return colors.red;
50
+ if (lvl === 'ultracode') return colors.purple;
51
+ return colors.dim;
52
+ }
53
+
39
54
  function getUsageColor(percentage) {
40
55
  if (percentage < 50) return colors.green;
41
56
  if (percentage < 75) return colors.yellow;
@@ -48,11 +63,50 @@ function shortenModel(name) {
48
63
  return name.replace(/\s+context\)/i, ')');
49
64
  }
50
65
 
66
+ // Tail-truncate an over-long branch name, preserving the leading ticket ID.
67
+ function truncateBranch(name) {
68
+ return name.length > MAX_BRANCH_LEN ? name.slice(0, MAX_BRANCH_LEN - 1) + '…' : name;
69
+ }
70
+
71
+ // Resolve the current git branch by reading .git/HEAD directly (no `git` subprocess —
72
+ // keeps the render fast and dependency-free). Walks up from `dir` to find the repo,
73
+ // handles worktrees (.git as a file) and detached HEAD (short sha). Best-effort: '' on any failure.
74
+ function getGitBranch(dir) {
75
+ try {
76
+ let cur = dir;
77
+ let gitPath = '';
78
+ for (let i = 0; i < 50 && cur; i++) {
79
+ const candidate = path.join(cur, '.git');
80
+ if (fs.existsSync(candidate)) { gitPath = candidate; break; }
81
+ const parent = path.dirname(cur);
82
+ if (parent === cur) break; // reached filesystem root
83
+ cur = parent;
84
+ }
85
+ if (!gitPath) return '';
86
+
87
+ let gitDir = gitPath;
88
+ if (fs.statSync(gitPath).isFile()) {
89
+ // Worktree/submodule: ".git" is a file like "gitdir: /path/to/.git/worktrees/x".
90
+ const m = fs.readFileSync(gitPath, 'utf8').match(/gitdir:\s*(.+)/);
91
+ if (!m) return '';
92
+ gitDir = path.resolve(path.dirname(gitPath), m[1].trim());
93
+ }
94
+
95
+ const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
96
+ const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/);
97
+ if (ref) return truncateBranch(ref[1]);
98
+ if (/^[0-9a-f]{7,40}$/i.test(head)) return head.slice(0, 7); // detached HEAD -> short sha
99
+ return '';
100
+ } catch (e) {
101
+ return '';
102
+ }
103
+ }
104
+
51
105
  function getContextBar(remaining) {
52
106
  const effectiveRemaining = remaining ?? 100;
53
107
  const used = Math.max(0, Math.min(100, 100 - Math.round(effectiveRemaining)));
54
108
 
55
- const filled = Math.floor((used / 100) * BAR_WIDTH);
109
+ const filled = Math.round((used / 100) * BAR_WIDTH);
56
110
  const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(BAR_WIDTH - filled);
57
111
 
58
112
  let coloredBar;
@@ -109,6 +163,37 @@ function normalizePercentage(value) {
109
163
  return Math.max(0, Math.min(100, Math.round(value)));
110
164
  }
111
165
 
166
+ // Build usage bars from stdin `rate_limits` (Claude.ai Pro/Max, present only after the
167
+ // first API response of a session). Same data as the OAuth usage API, so reading it here
168
+ // skips the network/credentials/cache path entirely. `resets_at` is a Unix epoch in
169
+ // SECONDS (not ISO) — ×1000 before Date. Returns { current, weekly } bars, or null when
170
+ // rate_limits is absent or the required five_hour segment is unusable (caller falls back).
171
+ function buildUsageFromStdin(data) {
172
+ const rl = data?.rate_limits;
173
+ if (!rl) return null;
174
+
175
+ const toEntry = (seg) => {
176
+ if (!seg) return null;
177
+ const pct = normalizePercentage(seg.used_percentage);
178
+ if (pct == null) return null;
179
+ // resets_at is a Unix epoch in SECONDS. Coerce + validate defensively: a non-numeric
180
+ // or out-of-range value would make new Date(...).toISOString() throw, and this path
181
+ // runs outside outputStatus's try/catch. Fall back to resetsAt: null on anything bad.
182
+ let resetsAt = null;
183
+ const epoch = Number(seg.resets_at);
184
+ if (Number.isFinite(epoch) && epoch > 0) {
185
+ const d = new Date(epoch * 1000);
186
+ if (!Number.isNaN(d.getTime())) resetsAt = d.toISOString();
187
+ }
188
+ return { percentage: pct, resetsAt };
189
+ };
190
+
191
+ const fiveHour = toEntry(rl.five_hour);
192
+ if (!fiveHour) return null; // five_hour is the required bar
193
+ const weekly = toEntry(rl.seven_day);
194
+ return buildUsageBars(fiveHour, weekly);
195
+ }
196
+
112
197
  // Validate a single usage entry ({ percentage, resetsAt }). Returns true only for a
113
198
  // finite 0-100 percentage and a parseable (or absent) resetsAt.
114
199
  function isValidUsageEntry(entry) {
@@ -309,14 +394,16 @@ function outputStatus(data, usage) {
309
394
  const model = shortenModel(data?.model?.display_name || 'Claude');
310
395
  const dir = data?.workspace?.current_dir || process.cwd();
311
396
  const dirname = path.basename(dir);
397
+ const branch = getGitBranch(dir);
398
+ const effort = data?.effort?.level || '';
312
399
  const sessionId = data?.session_id || '';
313
400
  const remaining = data?.context_window?.remaining_percentage;
314
401
 
315
402
  const contextBar = getContextBar(remaining);
316
403
  const task = getCurrentTask(sessionId);
317
404
  const parts = [];
318
- parts.push(dirname);
319
- parts.push(model);
405
+ parts.push(branch ? `${dirname} ${colors.dim}⎇ ${branch}${colors.reset}` : dirname);
406
+ parts.push(effort ? `${model}${getEffortColor(effort)} · ${effort}${colors.reset}` : model);
320
407
  parts.push(`CTX ${contextBar}`);
321
408
 
322
409
  if (usage?.current) parts.push(`5h ${usage.current}`);
@@ -337,21 +424,45 @@ function outputFallback(usage) {
337
424
  process.stdout.write(parts.join(' \u2502 '));
338
425
  }
339
426
 
340
- // Wrapper that skips usage fetch for API key users
341
- function getUsage(callback) {
427
+ // Resolve usage bars for a (possibly null) parsed stdin payload.
428
+ // Order: API-key users get none; otherwise prefer stdin `rate_limits` (no network),
429
+ // then fall back to the cache+API flow when stdin lacks it (cold start / non-Pro/Max).
430
+ function resolveUsage(data, callback) {
342
431
  if (IS_API_KEY) {
343
- callback(null);
344
- } else {
345
- getUsageWithCache(callback);
432
+ return callback(null);
346
433
  }
434
+ const fromStdin = buildUsageFromStdin(data);
435
+ if (fromStdin) {
436
+ return callback(fromStdin);
437
+ }
438
+ getUsageWithCache(callback);
347
439
  }
348
440
 
349
441
  // Process with timeout
350
- if (process.stdin.isTTY) {
351
- getUsage((usage) => {
352
- outputFallback(usage);
442
+ // Parse the accumulated stdin into a payload object, or null if empty/unparseable.
443
+ function parseInput(input) {
444
+ if (!input || input.length === 0) return null;
445
+ try {
446
+ return JSON.parse(input);
447
+ } catch (e) {
448
+ return null;
449
+ }
450
+ }
451
+
452
+ // Resolve usage for `data` (preferring stdin rate_limits), then render and exit.
453
+ function emit(data) {
454
+ resolveUsage(data, (usage) => {
455
+ if (data) {
456
+ outputStatus(data, usage);
457
+ } else {
458
+ outputFallback(usage);
459
+ }
353
460
  process.exit(0);
354
461
  });
462
+ }
463
+
464
+ if (process.stdin.isTTY) {
465
+ emit(null);
355
466
  } else {
356
467
  let input = '';
357
468
  let timeoutReached = false;
@@ -360,19 +471,7 @@ if (process.stdin.isTTY) {
360
471
 
361
472
  const timeout = setTimeout(() => {
362
473
  timeoutReached = true;
363
- getUsage((usage) => {
364
- if (input.length > 0) {
365
- try {
366
- const data = JSON.parse(input);
367
- outputStatus(data, usage);
368
- } catch (e) {
369
- outputFallback(usage);
370
- }
371
- } else {
372
- outputFallback(usage);
373
- }
374
- process.exit(0);
375
- });
474
+ emit(parseInput(input));
376
475
  }, overallTimeout);
377
476
 
378
477
  process.stdin.setEncoding('utf8');
@@ -380,15 +479,6 @@ if (process.stdin.isTTY) {
380
479
  process.stdin.on('end', () => {
381
480
  if (timeoutReached) return;
382
481
  clearTimeout(timeout);
383
-
384
- getUsage((usage) => {
385
- try {
386
- const data = JSON.parse(input);
387
- outputStatus(data, usage);
388
- } catch (e) {
389
- outputFallback(usage);
390
- }
391
- process.exit(0);
392
- });
482
+ emit(parseInput(input));
393
483
  });
394
484
  }