ctxline-claude 1.6.1 → 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 +13 -15
- package/package.json +3 -2
- package/statusline.js +191 -148
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.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
<a href="https://www.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
<a href="
|
|
15
|
-
|
|
16
|
-
|
|
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&size=sm&mode=dark"><img alt="GitHub Stars" src="https://www.shieldcn.dev/github/stars/MithunWijayasiri/ctxline-claude.svg?variant=secondary&size=sm&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&variant=ghost&size=sm&mode=dark"><img alt="Contributors" src="https://www.shieldcn.dev/github/contributors/MithunWijayasiri/ctxline-claude.svg?theme=emerald&variant=ghost&size=sm&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&size=sm&mode=dark"><img alt="Last commit" src="https://www.shieldcn.dev/github/last-commit/MithunWijayasiri/ctxline-claude.svg?variant=secondary&size=sm&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&size=sm&mode=dark"><img alt="CI" src="https://www.shieldcn.dev/github/ci/MithunWijayasiri/ctxline-claude.svg?variant=secondary&size=sm&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&variant=outline&size=sm&mode=dark"><img alt="Ko-fi" src="https://www.shieldcn.dev/badge/Ko--fi-support-FF5E5B.svg?logo=kofi&variant=outline&size=sm&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&size=xs&mode=dark"><img alt="npm Version" src="https://www.shieldcn.dev/npm/ctxline-claude.svg?variant=secondary&size=xs&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&size=xs&mode=dark"><img alt="npm Monthly Downloads" src="https://www.shieldcn.dev/npm/dm/ctxline-claude.svg?variant=ghost&size=xs&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&size=xs&mode=dark"><img alt="npm Total Downloads" src="https://www.shieldcn.dev/npm/dt/ctxline-claude.svg?variant=secondary&size=xs&mode=light"></picture></a>
|
|
23
21
|
</p>
|
|
24
22
|
|
|
25
23
|
<p align="center">
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ctxline-claude",
|
|
3
|
-
"version": "1.6.
|
|
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
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
// (possibly empty)
|
|
297
|
-
//
|
|
298
|
-
|
|
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 }
|
|
348
|
-
// when rate_limits is absent or the required
|
|
349
|
-
// back).
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
//
|
|
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
|
|
608
|
-
// v2.1.153+) and the single line overflows — unknown
|
|
609
|
-
// so there is no regression on older clients or wide
|
|
610
|
-
|
|
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
|
-
//
|
|
621
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
|
659
|
-
|
|
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
|
-
|
|
679
|
-
|
|
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
|
-
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
}
|