ctxline-claude 1.2.2 → 1.6.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.
- package/README.md +28 -5
- package/package.json +1 -1
- package/statusline.js +265 -52
package/README.md
CHANGED
|
@@ -17,10 +17,13 @@
|
|
|
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">
|
|
23
|
-
<img src="preview.svg" alt="Claude Code Statusline">
|
|
26
|
+
<img src="docs/assets/preview.svg" alt="Claude Code Statusline">
|
|
24
27
|
</p>
|
|
25
28
|
|
|
26
29
|
<p align="center">
|
|
@@ -74,6 +77,17 @@ chmod +x ~/.claude/hooks/statusline.js
|
|
|
74
77
|
}
|
|
75
78
|
```
|
|
76
79
|
|
|
80
|
+
**Subagent rows.** The same script also renders per-task rows in the agent panel for running subagents — wire it as a separate `subagentStatusLine` command:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"subagentStatusLine": {
|
|
85
|
+
"type": "command",
|
|
86
|
+
"command": "node ~/.claude/hooks/statusline.js subagent"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
77
91
|
</details>
|
|
78
92
|
|
|
79
93
|
## Update
|
|
@@ -121,6 +135,7 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
|
|
|
121
135
|
| **Context** | Visual bar of context-window usage |
|
|
122
136
|
| **Current** | Live 5-hour session limit + reset countdown (subscription users) |
|
|
123
137
|
| **Weekly** | Weekly usage allowance + time until the weekly reset (subscription users) |
|
|
138
|
+
| **Model limit** | Weekly limit scoped to a single model, when your account has one — labelled by the model's initial (`F` = Fable) |
|
|
124
139
|
| **Cost** | Running session cost in USD (e.g. `$0.42`) |
|
|
125
140
|
| **Task** | The in-progress todo, when there is one |
|
|
126
141
|
|
|
@@ -137,7 +152,7 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
|
|
|
137
152
|
|
|
138
153
|
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
154
|
|
|
140
|
-
`branch` · `effort` · `cost` · `task` · `usage` (5-hour + weekly)
|
|
155
|
+
`branch` · `effort` · `cost` · `task` · `usage` (5-hour + weekly + model-scoped)
|
|
141
156
|
|
|
142
157
|
Directory, model, and context always show; unknown names are ignored. Example below hides cost and the current task.
|
|
143
158
|
|
|
@@ -181,7 +196,8 @@ To re-enable a segment, remove it from the list (or delete the variable) and res
|
|
|
181
196
|
|
|
182
197
|
## How it works
|
|
183
198
|
|
|
184
|
-
- **Source** — context comes from Claude Code's session data.
|
|
199
|
+
- **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.
|
|
200
|
+
- **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
201
|
- **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
202
|
- **Adaptive timing** — for the fallback fetch: 1.5s timeout on the first prompt (cold start), 1.2s after (connection reused).
|
|
187
203
|
- **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 +254,17 @@ No. Your credentials never leave your machine. On the fast path no token is read
|
|
|
238
254
|
|
|
239
255
|
</details>
|
|
240
256
|
|
|
241
|
-
## License
|
|
242
257
|
|
|
243
|
-
|
|
258
|
+
## Support
|
|
259
|
+
|
|
260
|
+
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.
|
|
261
|
+
|
|
262
|
+
Thank you for your support! ❤️
|
|
244
263
|
|
|
245
264
|
## Credits
|
|
246
265
|
|
|
247
266
|
Thanks to [@TahaSabir0](https://github.com/TahaSabir0) for the base config.
|
|
267
|
+
|
|
268
|
+
## License
|
|
269
|
+
|
|
270
|
+
MIT
|
package/package.json
CHANGED
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 |
|
|
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(
|
|
@@ -54,6 +54,10 @@ const GIT_FRESH_TTL_MS = 5000; // 5s: reuse counts within a render burs
|
|
|
54
54
|
const GIT_STALE_TTL_MS = 60000; // 60s: fall back to last counts if git fails
|
|
55
55
|
const GIT_TIMEOUT_MS = 500; // hard cap on the rev-list subprocess (warm ~130ms)
|
|
56
56
|
|
|
57
|
+
// Subagent mode reads only stdin (no usage API to race), so its stdin read gets a
|
|
58
|
+
// short hard cap of its own instead of the main-mode overallTimeout.
|
|
59
|
+
const SUBAGENT_TIMEOUT_MS = 500;
|
|
60
|
+
|
|
57
61
|
// ANSI color codes
|
|
58
62
|
const colors = {
|
|
59
63
|
reset: '\x1b[0m',
|
|
@@ -84,11 +88,31 @@ function getUsageColor(percentage) {
|
|
|
84
88
|
return colors.red;
|
|
85
89
|
}
|
|
86
90
|
|
|
91
|
+
// Model-scoped bars skip the H/W thresholds: a line can carry several at once, so a flat
|
|
92
|
+
// orange keeps them readable as one group. Red at >=90 is the one distinction kept — that
|
|
93
|
+
// bar is about to block the model it names.
|
|
94
|
+
function getScopedColor(percentage) {
|
|
95
|
+
return percentage >= 90 ? colors.red : colors.orange;
|
|
96
|
+
}
|
|
97
|
+
|
|
87
98
|
// Shorten verbose model names for the statusline: "Opus 4.8 (1M context)" -> "Opus 4.8 (1M)".
|
|
88
99
|
function shortenModel(name) {
|
|
89
100
|
return name.replace(/\s+context\)/i, ')');
|
|
90
101
|
}
|
|
91
102
|
|
|
103
|
+
// Shorten a resolved model ID (subagent task.model, e.g. "claude-opus-5") for the
|
|
104
|
+
// subagent row: "claude-opus-5" -> "Opus 5", "claude-haiku-4-5-20251001" -> "Haiku 4.5".
|
|
105
|
+
// Distinct from shortenModel, which trims a display name rather than parsing an ID.
|
|
106
|
+
function shortenModelId(id) {
|
|
107
|
+
if (!id) return '';
|
|
108
|
+
const stripped = String(id).replace(/^(us\.)?(anthropic\.)?claude-/, '').replace(/-\d{8}$/, '');
|
|
109
|
+
const [family, ...rest] = stripped.split('-');
|
|
110
|
+
if (!family) return stripped;
|
|
111
|
+
const name = family[0].toUpperCase() + family.slice(1);
|
|
112
|
+
const version = rest.join('.');
|
|
113
|
+
return version ? `${name} ${version}` : name;
|
|
114
|
+
}
|
|
115
|
+
|
|
92
116
|
// Tail-truncate an over-long branch name, preserving the leading ticket ID.
|
|
93
117
|
function truncateBranch(name) {
|
|
94
118
|
return name.length > MAX_BRANCH_LEN ? name.slice(0, MAX_BRANCH_LEN - 1) + '…' : name;
|
|
@@ -201,10 +225,11 @@ function formatAheadBehind(ab) {
|
|
|
201
225
|
return s;
|
|
202
226
|
}
|
|
203
227
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
228
|
+
// Colored "C<used> <bar>" (e.g. "C45 ███░░░") for an already-clamped 0-100 used
|
|
229
|
+
// percentage. Shared by the main context bar (derived from remaining%) and the
|
|
230
|
+
// subagent row (derived from tokenCount/contextWindowSize) so both use the same
|
|
231
|
+
// thresholds and bar style.
|
|
232
|
+
function renderContextBar(used) {
|
|
208
233
|
const filled = Math.round((used / 100) * BAR_WIDTH);
|
|
209
234
|
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(BAR_WIDTH - filled);
|
|
210
235
|
|
|
@@ -215,14 +240,20 @@ function getContextBar(remaining) {
|
|
|
215
240
|
else if (used < 80) color = colors.orange;
|
|
216
241
|
else color = colors.blink + colors.red;
|
|
217
242
|
|
|
218
|
-
// Compact label form: "C<used> <bar>" (e.g. "C45 ███░░░"), colored as a whole.
|
|
219
243
|
return `${color}C${used} ${bar}${colors.reset}`;
|
|
220
244
|
}
|
|
221
245
|
|
|
246
|
+
function getContextBar(remaining) {
|
|
247
|
+
const effectiveRemaining = remaining ?? 100;
|
|
248
|
+
const used = Math.max(0, Math.min(100, 100 - Math.round(effectiveRemaining)));
|
|
249
|
+
return renderContextBar(used);
|
|
250
|
+
}
|
|
251
|
+
|
|
222
252
|
// Render a compact usage segment from raw data: "<label><pct> ↺ <countdown>"
|
|
223
253
|
// (e.g. "H81 ↺ 2h21m") — no bar. Called on every read (live or cached) so the reset
|
|
224
254
|
// countdown is always recomputed from resetsAt rather than frozen at fetch time.
|
|
225
|
-
|
|
255
|
+
// `color` overrides the threshold color — the model-scoped bars pass getScopedColor.
|
|
256
|
+
function buildUsageBar(label, percentage, resetsAt, color) {
|
|
226
257
|
let timeStr = '';
|
|
227
258
|
if (resetsAt) {
|
|
228
259
|
const diffMins = Math.max(0, Math.floor((new Date(resetsAt) - new Date()) / 60000));
|
|
@@ -234,18 +265,41 @@ function buildUsageBar(label, percentage, resetsAt) {
|
|
|
234
265
|
else timeStr = `${mins}m`;
|
|
235
266
|
}
|
|
236
267
|
|
|
237
|
-
const
|
|
268
|
+
const barColor = color || getUsageColor(percentage);
|
|
238
269
|
const timePart = timeStr ? `${colors.dim} ↺ ${timeStr}${colors.reset}` : '';
|
|
239
270
|
|
|
240
|
-
return `${
|
|
271
|
+
return `${barColor}${label}${percentage}${colors.reset}${timePart}`;
|
|
241
272
|
}
|
|
242
273
|
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
|
|
274
|
+
// Model-scoped weekly limits (e.g. "Fable weekly limit at 86%"), rendered after the
|
|
275
|
+
// account-wide W bar. The /usage payload reports these in a `limits` array, each entry
|
|
276
|
+
// carrying the model in scope.model.display_name:
|
|
277
|
+
//
|
|
278
|
+
// { kind: "weekly_scoped", percent: 86, severity: "warning",
|
|
279
|
+
// resets_at: "...", scope: { model: { display_name: "Fable" } } }
|
|
280
|
+
//
|
|
281
|
+
// The label is the model's first initial (Fable -> F), so a new model family needs no
|
|
282
|
+
// code change. Older payloads instead exposed flat seven_day_<model> keys, kept below as
|
|
283
|
+
// a fallback for accounts still reporting that shape.
|
|
284
|
+
//
|
|
285
|
+
// NOTE: these appear only in the API payload. Claude Code's statusline stdin carries just
|
|
286
|
+
// five_hour and seven_day under rate_limits, so the scoped limits always come from the
|
|
287
|
+
// cache/API path even when stdin supplies the H and W bars.
|
|
288
|
+
const LEGACY_MODEL_WEEKLY_KEYS = [
|
|
289
|
+
{ key: 'seven_day_opus', label: 'O' },
|
|
290
|
+
{ key: 'seven_day_sonnet', label: 'S' }
|
|
291
|
+
];
|
|
292
|
+
|
|
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) {
|
|
246
299
|
return {
|
|
247
300
|
current: fiveHour ? buildUsageBar('H', fiveHour.percentage, fiveHour.resetsAt) : null,
|
|
248
|
-
weekly: weekly ? buildUsageBar('W', weekly.percentage, weekly.resetsAt) : null
|
|
301
|
+
weekly: weekly ? buildUsageBar('W', weekly.percentage, weekly.resetsAt) : null,
|
|
302
|
+
models: (models || []).map(m => buildUsageBar(m.label, m.percentage, m.resetsAt, getScopedColor(m.percentage)))
|
|
249
303
|
};
|
|
250
304
|
}
|
|
251
305
|
|
|
@@ -257,11 +311,42 @@ function normalizePercentage(value) {
|
|
|
257
311
|
return Math.max(0, Math.min(100, Math.round(value)));
|
|
258
312
|
}
|
|
259
313
|
|
|
314
|
+
// Extract the model-scoped weekly limits from a raw /usage payload as
|
|
315
|
+
// [{ label, percentage, resetsAt }], in payload order. Prefers the `limits` array;
|
|
316
|
+
// falls back to the legacy flat keys only when it yields nothing, so an account
|
|
317
|
+
// reporting both shapes doesn't render the same limit twice.
|
|
318
|
+
function parseScopedLimits(usage) {
|
|
319
|
+
const scoped = [];
|
|
320
|
+
|
|
321
|
+
if (Array.isArray(usage?.limits)) {
|
|
322
|
+
for (const entry of usage.limits) {
|
|
323
|
+
if (!entry || entry.kind !== 'weekly_scoped') continue;
|
|
324
|
+
const name = entry.scope?.model?.display_name;
|
|
325
|
+
const pct = normalizePercentage(entry.percent);
|
|
326
|
+
if (typeof name !== 'string' || !name.trim() || pct == null) continue;
|
|
327
|
+
scoped.push({
|
|
328
|
+
label: name.trim().charAt(0).toUpperCase(),
|
|
329
|
+
percentage: pct,
|
|
330
|
+
resetsAt: entry.resets_at || null
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
if (scoped.length) return scoped;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
for (const { key, label } of LEGACY_MODEL_WEEKLY_KEYS) {
|
|
337
|
+
const seg = usage?.[key];
|
|
338
|
+
const pct = seg ? normalizePercentage(seg.utilization) : null;
|
|
339
|
+
if (pct != null) scoped.push({ label, percentage: pct, resetsAt: seg.resets_at || null });
|
|
340
|
+
}
|
|
341
|
+
return scoped;
|
|
342
|
+
}
|
|
343
|
+
|
|
260
344
|
// Build usage bars from stdin `rate_limits` (Claude.ai Pro/Max, present only after the
|
|
261
345
|
// first API response of a session). Same data as the OAuth usage API, so reading it here
|
|
262
346
|
// skips the network/credentials/cache path entirely. `resets_at` is a Unix epoch in
|
|
263
|
-
// SECONDS (not ISO) — ×1000 before Date. Returns {
|
|
264
|
-
// rate_limits is absent or the required five_hour segment is unusable (caller falls
|
|
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.
|
|
265
350
|
function buildUsageFromStdin(data) {
|
|
266
351
|
const rl = data?.rate_limits;
|
|
267
352
|
if (!rl) return null;
|
|
@@ -284,8 +369,7 @@ function buildUsageFromStdin(data) {
|
|
|
284
369
|
|
|
285
370
|
const fiveHour = toEntry(rl.five_hour);
|
|
286
371
|
if (!fiveHour) return null; // five_hour is the required bar
|
|
287
|
-
|
|
288
|
-
return buildUsageBars(fiveHour, weekly);
|
|
372
|
+
return { fiveHour, weekly: toEntry(rl.seven_day) };
|
|
289
373
|
}
|
|
290
374
|
|
|
291
375
|
// Validate a single usage entry ({ percentage, resetsAt }). Returns true only for a
|
|
@@ -307,13 +391,18 @@ function readCachedUsage() {
|
|
|
307
391
|
const cache = JSON.parse(fs.readFileSync(USAGE_CACHE_FILE, 'utf8'));
|
|
308
392
|
if (!cache || !Number.isFinite(cache.timestamp) || cache.timestamp <= 0) return null;
|
|
309
393
|
|
|
310
|
-
// Validate data. fiveHour is required; weekly
|
|
311
|
-
// This also rejects the legacy single-{percentage,resetsAt} format from
|
|
312
|
-
// versions, which had no fiveHour key, so stale caches are ignored on read.
|
|
394
|
+
// Validate data. fiveHour is required; weekly and models are optional (the API may
|
|
395
|
+
// omit either). This also rejects the legacy single-{percentage,resetsAt} format from
|
|
396
|
+
// older versions, which had no fiveHour key, so stale caches are ignored on read.
|
|
397
|
+
// A cache written before model bars existed simply has no models key — still valid.
|
|
313
398
|
const data = cache.data;
|
|
314
399
|
if (!data || typeof data !== 'object') return null;
|
|
315
400
|
if (!isValidUsageEntry(data.fiveHour)) return null;
|
|
316
401
|
if (data.weekly != null && !isValidUsageEntry(data.weekly)) return null;
|
|
402
|
+
if (data.models != null) {
|
|
403
|
+
if (!Array.isArray(data.models)) return null;
|
|
404
|
+
if (!data.models.every(m => typeof m?.label === 'string' && isValidUsageEntry(m))) return null;
|
|
405
|
+
}
|
|
317
406
|
|
|
318
407
|
return { age: Date.now() - cache.timestamp, data };
|
|
319
408
|
} catch (e) {
|
|
@@ -412,9 +501,13 @@ function getApiUsage(callback) {
|
|
|
412
501
|
resetsAt: usage.seven_day.resets_at || null
|
|
413
502
|
} : null;
|
|
414
503
|
|
|
415
|
-
//
|
|
416
|
-
|
|
417
|
-
|
|
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);
|
|
418
511
|
} else {
|
|
419
512
|
callback(null);
|
|
420
513
|
}
|
|
@@ -436,28 +529,42 @@ function getApiUsage(callback) {
|
|
|
436
529
|
}
|
|
437
530
|
}
|
|
438
531
|
|
|
439
|
-
//
|
|
440
|
-
function
|
|
532
|
+
// Resolve raw usage data ({ fiveHour, weekly, models }), cache-first. Callers render it.
|
|
533
|
+
function getRawUsage(callback) {
|
|
441
534
|
const cached = readCachedUsage();
|
|
442
535
|
|
|
443
|
-
// Cache is fresh ->
|
|
536
|
+
// Cache is fresh -> use it and skip the API entirely (fewer calls, faster).
|
|
444
537
|
if (cached && cached.age < FRESH_TTL_MS) {
|
|
445
|
-
return callback(
|
|
538
|
+
return callback(cached.data);
|
|
446
539
|
}
|
|
447
540
|
|
|
448
541
|
// Cache is stale or missing -> refresh from the API.
|
|
449
|
-
getApiUsage((
|
|
450
|
-
if (
|
|
451
|
-
callback(
|
|
542
|
+
getApiUsage((fresh) => {
|
|
543
|
+
if (fresh) {
|
|
544
|
+
callback(fresh);
|
|
452
545
|
} else if (cached && cached.age < STALE_TTL_MS) {
|
|
453
546
|
// API failed/timed out, but recent cache exists -> show it instead of nothing.
|
|
454
|
-
callback(
|
|
547
|
+
callback(cached.data);
|
|
455
548
|
} else {
|
|
456
549
|
callback(null);
|
|
457
550
|
}
|
|
458
551
|
});
|
|
459
552
|
}
|
|
460
553
|
|
|
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
|
+
|
|
461
568
|
// Session cost from stdin `cost.total_cost_usd` (USD float, computed client-side by
|
|
462
569
|
// Claude Code as tokens × per-model API pricing). Pure stdin — no network/cache.
|
|
463
570
|
// Returns "$0.00" rendered dim, or '' when absent/non-finite so the segment is omitted.
|
|
@@ -537,6 +644,7 @@ function outputStatus(data, usage) {
|
|
|
537
644
|
const line2 = [];
|
|
538
645
|
if (usage?.current) line2.push(usage.current);
|
|
539
646
|
if (usage?.weekly) line2.push(usage.weekly);
|
|
647
|
+
if (usage?.models?.length) line2.push(...usage.models);
|
|
540
648
|
if (cost) line2.push(cost);
|
|
541
649
|
if (task) line2.push(`${colors.dim}${task}${colors.reset}`);
|
|
542
650
|
|
|
@@ -551,6 +659,7 @@ function outputFallback(usage) {
|
|
|
551
659
|
const parts = ['~', 'Claude', contextBar];
|
|
552
660
|
if (usage?.current) parts.push(usage.current);
|
|
553
661
|
if (usage?.weekly) parts.push(usage.weekly);
|
|
662
|
+
if (usage?.models?.length) parts.push(...usage.models);
|
|
554
663
|
process.stdout.write(parts.join(' \u2502 '));
|
|
555
664
|
}
|
|
556
665
|
|
|
@@ -563,7 +672,12 @@ function resolveUsage(data, callback) {
|
|
|
563
672
|
}
|
|
564
673
|
const fromStdin = buildUsageFromStdin(data);
|
|
565
674
|
if (fromStdin) {
|
|
566
|
-
|
|
675
|
+
// stdin covers H and W with no network. Model-scoped weekly limits only exist in the
|
|
676
|
+
// API payload, so they come from the cache — refreshed on the same TTL as every other
|
|
677
|
+
// 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));
|
|
680
|
+
});
|
|
567
681
|
}
|
|
568
682
|
getUsageWithCache(callback);
|
|
569
683
|
}
|
|
@@ -591,24 +705,123 @@ function emit(data) {
|
|
|
591
705
|
});
|
|
592
706
|
}
|
|
593
707
|
|
|
594
|
-
|
|
595
|
-
|
|
708
|
+
// now - startTime as "45s" / "4m12s" / "2h5m". '' when startTime is missing/unparseable.
|
|
709
|
+
// Format isn't documented by Claude Code, so accept epoch-seconds, epoch-ms, or an ISO
|
|
710
|
+
// string: numbers below 1e12 are epoch-seconds (today's epoch-seconds ~1.7e9, epoch-ms
|
|
711
|
+
// ~1.7e12 — far enough apart that the threshold is unambiguous for any real timestamp).
|
|
712
|
+
function formatElapsed(startTime) {
|
|
713
|
+
if (startTime == null) return '';
|
|
714
|
+
const ms = typeof startTime === 'number' && startTime < 1e12 ? startTime * 1000 : startTime;
|
|
715
|
+
const start = new Date(ms).getTime();
|
|
716
|
+
if (Number.isNaN(start)) return '';
|
|
717
|
+
|
|
718
|
+
const diffSec = Math.max(0, Math.floor((Date.now() - start) / 1000));
|
|
719
|
+
const hours = Math.floor(diffSec / 3600);
|
|
720
|
+
const mins = Math.floor((diffSec % 3600) / 60);
|
|
721
|
+
const secs = diffSec % 60;
|
|
722
|
+
if (hours > 0) return `${hours}h${mins}m`;
|
|
723
|
+
if (mins > 0) return `${mins}m${secs}s`;
|
|
724
|
+
return `${secs}s`;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// One subagentStatusLine row: "name │ Model · effort │ C<used> <bar> │ ⏱ <elapsed>".
|
|
728
|
+
// Every segment past name is conditional on its source being present/finite.
|
|
729
|
+
function renderSubagentTask(t) {
|
|
730
|
+
const parts = [t.label || t.name || t.description || 'agent'];
|
|
731
|
+
|
|
732
|
+
const model = shortenModelId(t.model);
|
|
733
|
+
// effort absent = subagent inherits the session effort; show model alone then.
|
|
734
|
+
const effort = t.effort != null ? String(t.effort) : '';
|
|
735
|
+
if (model) {
|
|
736
|
+
parts.push(effort
|
|
737
|
+
? `${model}${getEffortColor(effort)} · ${effort}${colors.reset}`
|
|
738
|
+
: model);
|
|
739
|
+
} else if (effort) {
|
|
740
|
+
parts.push(`${getEffortColor(effort)}${effort}${colors.reset}`);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (Number.isFinite(t.tokenCount) && Number.isFinite(t.contextWindowSize) && t.contextWindowSize > 0) {
|
|
744
|
+
const used = Math.max(0, Math.min(100, Math.round((t.tokenCount / t.contextWindowSize) * 100)));
|
|
745
|
+
parts.push(renderContextBar(used));
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const elapsed = formatElapsed(t.startTime);
|
|
749
|
+
if (elapsed) parts.push(`${colors.dim}⏱ ${elapsed}${colors.reset}`);
|
|
750
|
+
|
|
751
|
+
return parts.join(SEGMENT_SEP);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// subagentStatusLine mode: emit one {id, content} JSON line per task with an id, then
|
|
755
|
+
// exit. No usage/git/todos/cache work — the task objects carry everything needed.
|
|
756
|
+
// Bad payload or a task that fails to render -> emit nothing, keeping default
|
|
757
|
+
// rendering for every task, rather than a partial/broken output.
|
|
758
|
+
function emitSubagent(data) {
|
|
759
|
+
try {
|
|
760
|
+
const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
|
|
761
|
+
const out = tasks
|
|
762
|
+
.filter(t => t && t.id)
|
|
763
|
+
.map(t => JSON.stringify({ id: t.id, content: renderSubagentTask(t) }))
|
|
764
|
+
.join('\n');
|
|
765
|
+
if (out) {
|
|
766
|
+
// Exit from the write callback: process.exit() would drop output still queued
|
|
767
|
+
// behind stdout backpressure. A write error (e.g. EPIPE) also lands here — the
|
|
768
|
+
// callback form reports it instead of throwing, and the answer is the same: exit 0.
|
|
769
|
+
process.stdout.write(out + '\n', () => process.exit(0));
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
} catch (e) {}
|
|
773
|
+
process.exit(0);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Entry point, guarded so tests can require this file to exercise payload parsing
|
|
777
|
+
// directly (the /usage response shape is the easiest thing here to get wrong, and it
|
|
778
|
+
// can't be reached through stdin). Running the script normally is unchanged.
|
|
779
|
+
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);
|
|
806
|
+
} 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
|
+
});
|
|
824
|
+
}
|
|
596
825
|
} else {
|
|
597
|
-
|
|
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
|
-
});
|
|
826
|
+
module.exports = { parseScopedLimits, normalizePercentage };
|
|
614
827
|
}
|