ctxline-claude 1.6.2 → 1.7.1
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 +12 -5
- package/package.json +1 -1
- package/statusline.js +217 -194
package/README.md
CHANGED
|
@@ -10,13 +10,12 @@
|
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
12
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?
|
|
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?variant=secondary&size=sm&mode=dark"><img alt="Contributors" src="https://www.shieldcn.dev/github/contributors/MithunWijayasiri/ctxline-claude.svg?variant=secondary&size=sm&mode=light"></picture></a>
|
|
14
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://
|
|
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>
|
|
15
|
+
<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=secondary&size=sm&mode=dark"><img alt="Ko-fi" src="https://www.shieldcn.dev/badge/Ko--fi-support-FF5E5B.svg?logo=kofi&variant=secondary&size=sm&mode=light"></picture></a>
|
|
17
16
|
<br>
|
|
18
17
|
<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=
|
|
18
|
+
<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=secondary&size=xs&mode=dark"><img alt="npm Monthly Downloads" src="https://www.shieldcn.dev/npm/dm/ctxline-claude.svg?variant=secondary&size=xs&mode=light"></picture></a>
|
|
20
19
|
<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>
|
|
21
20
|
</p>
|
|
22
21
|
|
|
@@ -138,6 +137,7 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
|
|
|
138
137
|
| **Model limit** | Weekly limit scoped to a single model, when your account has one — labelled by the model's initial (`F` = Fable) |
|
|
139
138
|
| **Cost** | Running session cost in USD (e.g. `$0.42`) |
|
|
140
139
|
| **Task** | The in-progress todo, when there is one |
|
|
140
|
+
| **Update** | An extra row with the upgrade command when a newer release is on npm — checked once a week, in the background |
|
|
141
141
|
|
|
142
142
|
> [!NOTE]
|
|
143
143
|
> Usage bars change color automatically as you approach your limits.
|
|
@@ -152,7 +152,7 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
|
|
|
152
152
|
|
|
153
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:
|
|
154
154
|
|
|
155
|
-
`branch` · `effort` · `cost` · `task` · `usage` (5-hour + weekly + model-scoped)
|
|
155
|
+
`branch` · `effort` · `cost` · `task` · `update` · `usage` (5-hour + weekly + model-scoped)
|
|
156
156
|
|
|
157
157
|
Directory, model, and context always show; unknown names are ignored. Example below hides cost and the current task.
|
|
158
158
|
|
|
@@ -219,6 +219,13 @@ No, it's imperceptible. Almost every render reads a small local cache (sub-milli
|
|
|
219
219
|
|
|
220
220
|
</details>
|
|
221
221
|
|
|
222
|
+
<details>
|
|
223
|
+
<summary>What is the ⬆ segment, and what does it send?</summary>
|
|
224
|
+
|
|
225
|
+
An extra row appears below the statusline — `⬆ 1.7.0 available · npx ctxline-claude@latest` — so the upgrade command is right there to copy. It shows only while you're behind, and the command works whichever way you installed. Once a week a short-lived background process asks the public npm registry for the package's latest version number and writes it to a local cache; the statusline itself only ever reads that cache, so no render waits on the network. The request carries no session data, credentials, or identifiers — it's a plain GET for a public package, though as with any HTTP request the registry does see your IP address. Hide it (and skip the request entirely) with `CTXLINE_DISABLE=update`.
|
|
226
|
+
|
|
227
|
+
</details>
|
|
228
|
+
|
|
222
229
|
<details>
|
|
223
230
|
<summary>Does this use the same data as /usage?</summary>
|
|
224
231
|
|
package/package.json
CHANGED
package/statusline.js
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Claude Code
|
|
3
|
-
// Shows: directory | model | context usage | 5-hour + weekly + model-scoped usage | current task
|
|
4
|
-
// Auto-detects API key vs subscription usage
|
|
2
|
+
// Claude Code statusline: dir │ model │ context │ usage │ cost │ task
|
|
5
3
|
// https://github.com/MithunWijayasiri/ctxline-claude
|
|
6
4
|
|
|
7
5
|
const fs = require('fs');
|
|
8
6
|
const path = require('path');
|
|
9
7
|
const os = require('os');
|
|
10
8
|
const https = require('https');
|
|
11
|
-
const { execSync, execFileSync } = require('child_process');
|
|
9
|
+
const { execSync, execFileSync, spawn } = require('child_process');
|
|
10
|
+
|
|
11
|
+
// Lives here, not package.json: this file ships standalone to ~/.claude/hooks/. Must match package.json.
|
|
12
|
+
const VERSION = '1.7.1';
|
|
12
13
|
|
|
13
14
|
const IS_API_KEY = !!process.env.ANTHROPIC_API_KEY;
|
|
14
15
|
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// Unknown names are ignored. Disabling a segment also skips its work (git, todo read,
|
|
18
|
-
// usage fetch).
|
|
16
|
+
// Segment opt-out: comma list. Recognized: branch, effort, cost, task, update, usage.
|
|
17
|
+
// Disabling skips the work, not just the output; dir/model/context always render.
|
|
19
18
|
const DISABLED = new Set(
|
|
20
19
|
(process.env.CTXLINE_DISABLE || '')
|
|
21
20
|
.split(',')
|
|
@@ -23,39 +22,39 @@ const DISABLED = new Set(
|
|
|
23
22
|
.filter(Boolean)
|
|
24
23
|
);
|
|
25
24
|
|
|
26
|
-
//
|
|
25
|
+
// Context bar width in cells.
|
|
27
26
|
const BAR_WIDTH = 6;
|
|
28
27
|
|
|
29
|
-
//
|
|
30
|
-
// Tail-truncation keeps the start (ticket IDs like "TAMA5-32796" live there) visible.
|
|
28
|
+
// Branch names tail-truncated to this with "…", keeping leading ticket IDs visible.
|
|
31
29
|
const MAX_BRANCH_LEN = 24;
|
|
32
30
|
|
|
33
|
-
// Separator between segments on a rendered line.
|
|
34
31
|
const SEGMENT_SEP = ' │ ';
|
|
35
32
|
|
|
36
|
-
// Cells reserved at the terminal edge when deciding to wrap
|
|
37
|
-
// 0 = use the full COLUMNS; bump it if Claude Code reserves columns and the line
|
|
38
|
-
// truncates a char or two before wrapping.
|
|
33
|
+
// Cells reserved at the terminal edge when deciding to wrap; 0 = full width.
|
|
39
34
|
const WIDTH_MARGIN = 0;
|
|
40
35
|
|
|
41
36
|
// Cache configuration
|
|
42
37
|
const CACHE_DIR = path.join(os.homedir(), '.claude', 'cache');
|
|
43
38
|
const USAGE_CACHE_FILE = path.join(CACHE_DIR, 'usage-cache.json');
|
|
44
|
-
//
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
const STALE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
49
|
-
|
|
50
|
-
// Git ahead/behind cache (single repo entry, keyed by git dir). Throttles the one
|
|
51
|
-
// `git rev-list` subprocess so a burst of renders in a turn runs it once, not per render.
|
|
39
|
+
const FRESH_TTL_MS = 30000; // fresh: render cache, skip API
|
|
40
|
+
const STALE_TTL_MS = 10 * 60 * 1000; // stale: fallback only when a live call fails
|
|
41
|
+
|
|
42
|
+
// Single-entry ahead/behind cache: throttles the one git subprocess to once per render burst.
|
|
52
43
|
const GIT_CACHE_FILE = path.join(CACHE_DIR, 'git-cache.json');
|
|
53
44
|
const GIT_FRESH_TTL_MS = 5000; // 5s: reuse counts within a render burst
|
|
54
45
|
const GIT_STALE_TTL_MS = 60000; // 60s: fall back to last counts if git fails
|
|
55
46
|
const GIT_TIMEOUT_MS = 500; // hard cap on the rev-list subprocess (warm ~130ms)
|
|
56
47
|
|
|
57
|
-
//
|
|
58
|
-
|
|
48
|
+
// Update check: render only reads this cache; the registry fetch runs in a detached child.
|
|
49
|
+
const UPDATE_CACHE_FILE = path.join(CACHE_DIR, 'update-cache.json');
|
|
50
|
+
const UPDATE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days between successful checks
|
|
51
|
+
const UPDATE_RETRY_MS = 60 * 60 * 1000; // 1h backoff after a failed/killed check
|
|
52
|
+
const UPDATE_TIMEOUT_MS = 2000; // socket idle AND whole-request deadline
|
|
53
|
+
const REGISTRY_HOST = 'registry.npmjs.org';
|
|
54
|
+
const PACKAGE_NAME = 'ctxline-claude';
|
|
55
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+$/; // releases only: a prerelease never nudges
|
|
56
|
+
|
|
57
|
+
// Subagent mode reads only stdin (no fetch to race), so its read gets its own short cap.
|
|
59
58
|
const SUBAGENT_TIMEOUT_MS = 500;
|
|
60
59
|
|
|
61
60
|
// ANSI color codes
|
|
@@ -67,13 +66,10 @@ const colors = {
|
|
|
67
66
|
yellow: '\x1b[33m',
|
|
68
67
|
orange: '\x1b[38;5;208m',
|
|
69
68
|
red: '\x1b[31m',
|
|
70
|
-
purple: '\x1b[38;5;135m'
|
|
71
|
-
blink: '\x1b[5m'
|
|
69
|
+
purple: '\x1b[38;5;135m'
|
|
72
70
|
};
|
|
73
71
|
|
|
74
|
-
//
|
|
75
|
-
// < ultracode; only the top two are highlighted — "max" red, "ultracode" purple. Every
|
|
76
|
-
// other level (including xhigh) renders dim like the rest of the metadata.
|
|
72
|
+
// Levels rank low<medium<high<xhigh<max<ultracode; only max (red) and ultracode (purple) stand out.
|
|
77
73
|
function getEffortColor(level) {
|
|
78
74
|
const lvl = String(level).toLowerCase();
|
|
79
75
|
if (lvl === 'max') return colors.red;
|
|
@@ -82,27 +78,23 @@ function getEffortColor(level) {
|
|
|
82
78
|
}
|
|
83
79
|
|
|
84
80
|
function getUsageColor(percentage) {
|
|
85
|
-
if (percentage <
|
|
86
|
-
if (percentage <
|
|
81
|
+
if (percentage < 60) return colors.green;
|
|
82
|
+
if (percentage < 80) return colors.yellow;
|
|
87
83
|
if (percentage < 90) return colors.orange;
|
|
88
84
|
return colors.red;
|
|
89
85
|
}
|
|
90
86
|
|
|
91
|
-
//
|
|
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.
|
|
87
|
+
// Flat orange keeps several scoped bars readable as one group; >=90 red flags a nearly-spent cap.
|
|
94
88
|
function getScopedColor(percentage) {
|
|
95
89
|
return percentage >= 90 ? colors.red : colors.orange;
|
|
96
90
|
}
|
|
97
91
|
|
|
98
|
-
//
|
|
92
|
+
// Drop the context-window suffix: "Opus 5.5 (1M context)" -> "Opus 5.5".
|
|
99
93
|
function shortenModel(name) {
|
|
100
|
-
return name.replace(/\s
|
|
94
|
+
return name.replace(/\s*\([^)]*context\)/i, '');
|
|
101
95
|
}
|
|
102
96
|
|
|
103
|
-
//
|
|
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.
|
|
97
|
+
// Resolved model ID -> "Opus 5" / "Haiku 4.5" (strips prefixes + trailing -YYYYMMDD).
|
|
106
98
|
function shortenModelId(id) {
|
|
107
99
|
if (!id) return '';
|
|
108
100
|
const stripped = String(id).replace(/^(us\.)?(anthropic\.)?claude-/, '').replace(/-\d{8}$/, '');
|
|
@@ -118,8 +110,7 @@ function truncateBranch(name) {
|
|
|
118
110
|
return name.length > MAX_BRANCH_LEN ? name.slice(0, MAX_BRANCH_LEN - 1) + '…' : name;
|
|
119
111
|
}
|
|
120
112
|
|
|
121
|
-
//
|
|
122
|
-
// worktrees/submodules (".git" as a file pointing at the real dir). '' on any failure.
|
|
113
|
+
// Walks up from `dir` to the git dir (no subprocess); handles worktrees (".git" file). '' on failure.
|
|
123
114
|
function resolveGitDir(dir) {
|
|
124
115
|
let cur = dir;
|
|
125
116
|
let gitPath = '';
|
|
@@ -141,16 +132,14 @@ function resolveGitDir(dir) {
|
|
|
141
132
|
return gitPath;
|
|
142
133
|
}
|
|
143
134
|
|
|
144
|
-
//
|
|
145
|
-
// dependency-free). Detached HEAD -> short sha. Best-effort: '' on any failure.
|
|
135
|
+
// Branch read straight from .git/HEAD (no subprocess); detached HEAD -> short sha; '' on failure.
|
|
146
136
|
function getGitBranch(dir) {
|
|
147
137
|
try {
|
|
148
138
|
const gitDir = resolveGitDir(dir);
|
|
149
139
|
if (!gitDir) return '';
|
|
150
140
|
const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
|
|
151
141
|
const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/);
|
|
152
|
-
//
|
|
153
|
-
// in an untrusted archive could inject terminal escape sequences.
|
|
142
|
+
// HEAD is read raw, not git-validated: strip control chars (escape-sequence injection).
|
|
154
143
|
if (ref) return truncateBranch(ref[1].replace(/[\x00-\x1f\x7f]/g, ''));
|
|
155
144
|
if (/^[0-9a-f]{7,40}$/i.test(head)) return head.slice(0, 7); // detached HEAD -> short sha
|
|
156
145
|
return '';
|
|
@@ -159,8 +148,7 @@ function getGitBranch(dir) {
|
|
|
159
148
|
}
|
|
160
149
|
}
|
|
161
150
|
|
|
162
|
-
//
|
|
163
|
-
// invalidates it. Returns { age, ahead, behind } or null.
|
|
151
|
+
// Cached ahead/behind for gitDir; different repo invalidates. { age, ahead, behind } or null.
|
|
164
152
|
function readGitCache(gitDir) {
|
|
165
153
|
try {
|
|
166
154
|
const c = JSON.parse(fs.readFileSync(GIT_CACHE_FILE, 'utf8'));
|
|
@@ -179,10 +167,8 @@ function writeGitCache(gitDir, ahead, behind) {
|
|
|
179
167
|
} catch (e) {}
|
|
180
168
|
}
|
|
181
169
|
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
// detached / no git -> the subprocess errors -> null (segment omitted). On a slow/failed
|
|
185
|
-
// call, falls back to the last counts up to GIT_STALE_TTL_MS so they don't flicker.
|
|
170
|
+
// The only `git` subprocess, cache-fronted. null on no upstream/detached/failed (segment omitted);
|
|
171
|
+
// slow/failed call falls back to last counts up to GIT_STALE_TTL_MS so counts don't flicker.
|
|
186
172
|
function getGitAheadBehind(dir) {
|
|
187
173
|
const gitDir = resolveGitDir(dir);
|
|
188
174
|
if (!gitDir) return null;
|
|
@@ -193,8 +179,7 @@ function getGitAheadBehind(dir) {
|
|
|
193
179
|
}
|
|
194
180
|
|
|
195
181
|
try {
|
|
196
|
-
//
|
|
197
|
-
// `@{u}...HEAD` with --left-right --count prints "<behind>\t<ahead>" (left = upstream).
|
|
182
|
+
// No shell (faster cold spawn, @{u} literal); --left-right --count prints "<behind>\t<ahead>".
|
|
198
183
|
const out = execFileSync('git', ['rev-list', '--left-right', '--count', '@{u}...HEAD'], {
|
|
199
184
|
cwd: dir, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore']
|
|
200
185
|
}).trim();
|
|
@@ -214,9 +199,7 @@ function getGitAheadBehind(dir) {
|
|
|
214
199
|
}
|
|
215
200
|
}
|
|
216
201
|
|
|
217
|
-
// "↑N↓M"
|
|
218
|
-
// commits). Each part self-resets so it doesn't inherit the dim branch color. Omit a zero
|
|
219
|
-
// side; '' when in sync or null.
|
|
202
|
+
// "↑N↓M": ahead green, behind red, zero side omitted; '' when in sync or null.
|
|
220
203
|
function formatAheadBehind(ab) {
|
|
221
204
|
if (!ab) return '';
|
|
222
205
|
let s = '';
|
|
@@ -225,20 +208,16 @@ function formatAheadBehind(ab) {
|
|
|
225
208
|
return s;
|
|
226
209
|
}
|
|
227
210
|
|
|
228
|
-
// Colored "C<used> <bar>" (e.g. "C45 ███░░░")
|
|
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.
|
|
211
|
+
// Colored "C<used> <bar>" (e.g. "C45 ███░░░"); shared by main line and subagent rows.
|
|
232
212
|
function renderContextBar(used) {
|
|
233
213
|
const filled = Math.round((used / 100) * BAR_WIDTH);
|
|
234
214
|
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(BAR_WIDTH - filled);
|
|
235
215
|
|
|
236
|
-
// Context color: green <50 / yellow <65 / orange <80 / blink-red >=80.
|
|
237
216
|
let color;
|
|
238
217
|
if (used < 50) color = colors.green;
|
|
239
218
|
else if (used < 65) color = colors.yellow;
|
|
240
219
|
else if (used < 80) color = colors.orange;
|
|
241
|
-
else color = colors.
|
|
220
|
+
else color = colors.red;
|
|
242
221
|
|
|
243
222
|
return `${color}C${used} ${bar}${colors.reset}`;
|
|
244
223
|
}
|
|
@@ -254,10 +233,8 @@ function renderModelEffort(model, effort) {
|
|
|
254
233
|
return effort ? `${model}${getEffortColor(effort)} · ${effort}${colors.reset}` : model;
|
|
255
234
|
}
|
|
256
235
|
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
// countdown is always recomputed from resetsAt rather than frozen at fetch time.
|
|
260
|
-
// `color` overrides the threshold color — the model-scoped bars pass getScopedColor.
|
|
236
|
+
// "<label><pct> ↺ <countdown>" (e.g. "H81 ↺ 2h21m"), no bar. Called on every read so the
|
|
237
|
+
// countdown recomputes from resetsAt; `color` overrides the thresholds (scoped bars).
|
|
261
238
|
function buildUsageBar(label, percentage, resetsAt, color) {
|
|
262
239
|
let timeStr = '';
|
|
263
240
|
if (resetsAt) {
|
|
@@ -276,32 +253,16 @@ function buildUsageBar(label, percentage, resetsAt, color) {
|
|
|
276
253
|
return `${barColor}${label}${percentage}${colors.reset}${timePart}`;
|
|
277
254
|
}
|
|
278
255
|
|
|
279
|
-
// Model-scoped weekly limits
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
// { kind: "weekly_scoped", percent: 86, severity: "warning",
|
|
284
|
-
// resets_at: "...", scope: { model: { display_name: "Fable" } } }
|
|
285
|
-
//
|
|
286
|
-
// The label is the model's first initial (Fable -> F), so a new model family needs no
|
|
287
|
-
// code change. Older payloads instead exposed flat seven_day_<model> keys, kept below as
|
|
288
|
-
// a fallback for accounts still reporting that shape.
|
|
289
|
-
//
|
|
290
|
-
// NOTE: these appear only in the API payload. Claude Code's statusline stdin carries just
|
|
291
|
-
// five_hour and seven_day under rate_limits, so the scoped limits always come from the
|
|
292
|
-
// cache/API path even when stdin supplies the H and W bars.
|
|
256
|
+
// Model-scoped weekly limits, rendered after W. /usage payload `limits[]` entries:
|
|
257
|
+
// { kind: "weekly_scoped", percent, resets_at, scope: { model: { display_name } } }
|
|
258
|
+
// Label = first initial of the model name (Fable -> F). Legacy flat seven_day_<model> keys
|
|
259
|
+
// kept as fallback. Only ever in the API payload — stdin rate_limits never carries them.
|
|
293
260
|
const LEGACY_MODEL_WEEKLY_KEYS = [
|
|
294
261
|
{ key: 'seven_day_opus', label: 'O' },
|
|
295
262
|
{ key: 'seven_day_sonnet', label: 'S' }
|
|
296
263
|
];
|
|
297
264
|
|
|
298
|
-
//
|
|
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.
|
|
265
|
+
// Raw { fiveHour, weekly, models } -> rendered segments; scoped bars use getScopedColor.
|
|
305
266
|
function buildUsageBars(raw) {
|
|
306
267
|
const { fiveHour, weekly, models } = raw || {};
|
|
307
268
|
return {
|
|
@@ -311,18 +272,13 @@ function buildUsageBars(raw) {
|
|
|
311
272
|
};
|
|
312
273
|
}
|
|
313
274
|
|
|
314
|
-
//
|
|
315
|
-
// pipeline (cache validation + bar rendering) expects. Returns null when the value
|
|
316
|
-
// isn't a finite number, so callers can omit that bar instead of rendering "NaN%".
|
|
275
|
+
// Clamp to 0-100 int; null on non-finite so callers omit the bar instead of rendering "NaN%".
|
|
317
276
|
function normalizePercentage(value) {
|
|
318
277
|
if (!Number.isFinite(value)) return null;
|
|
319
278
|
return Math.max(0, Math.min(100, Math.round(value)));
|
|
320
279
|
}
|
|
321
280
|
|
|
322
|
-
//
|
|
323
|
-
// [{ label, percentage, resetsAt }], in payload order. Prefers the `limits` array;
|
|
324
|
-
// falls back to the legacy flat keys only when it yields nothing, so an account
|
|
325
|
-
// reporting both shapes doesn't render the same limit twice.
|
|
281
|
+
// limits[] -> [{ label, percentage, resetsAt }]; legacy flat keys only when limits yields nothing.
|
|
326
282
|
function parseScopedLimits(usage) {
|
|
327
283
|
const scoped = [];
|
|
328
284
|
|
|
@@ -349,13 +305,9 @@ function parseScopedLimits(usage) {
|
|
|
349
305
|
return scoped;
|
|
350
306
|
}
|
|
351
307
|
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
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.
|
|
308
|
+
// Usage from stdin `rate_limits` (Pro/Max only, absent at cold start) — skips the
|
|
309
|
+
// network/cache path entirely. resets_at is Unix epoch SECONDS (not ISO). Same raw shape as
|
|
310
|
+
// parseUsagePayload; models always [] — scoped limits never arrive via stdin.
|
|
359
311
|
function buildUsageFromStdin(data) {
|
|
360
312
|
const rl = data?.rate_limits;
|
|
361
313
|
if (!rl) return null;
|
|
@@ -364,9 +316,7 @@ function buildUsageFromStdin(data) {
|
|
|
364
316
|
if (!seg) return null;
|
|
365
317
|
const pct = normalizePercentage(seg.used_percentage);
|
|
366
318
|
if (pct == null) return null;
|
|
367
|
-
//
|
|
368
|
-
// or out-of-range value would make new Date(...).toISOString() throw, and this path
|
|
369
|
-
// runs outside outputStatus's try/catch. Fall back to resetsAt: null on anything bad.
|
|
319
|
+
// Defensive: this path runs outside outputStatus's try/catch — bad value -> null, never a throw.
|
|
370
320
|
let resetsAt = null;
|
|
371
321
|
const epoch = Number(seg.resets_at);
|
|
372
322
|
if (Number.isFinite(epoch) && epoch > 0) {
|
|
@@ -381,11 +331,9 @@ function buildUsageFromStdin(data) {
|
|
|
381
331
|
return { fiveHour, weekly: toEntry(rl.seven_day), models: [] };
|
|
382
332
|
}
|
|
383
333
|
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
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.
|
|
334
|
+
// /usage response body -> { fiveHour, weekly, models }, or null on unparseable JSON or a
|
|
335
|
+
// missing/non-finite five_hour utilization (that bar is required). Pure, so unit-testable
|
|
336
|
+
// directly — unlike getApiUsage, which needs a live socket.
|
|
389
337
|
function parseUsagePayload(body) {
|
|
390
338
|
try {
|
|
391
339
|
const usage = JSON.parse(body);
|
|
@@ -403,8 +351,7 @@ function parseUsagePayload(body) {
|
|
|
403
351
|
}
|
|
404
352
|
}
|
|
405
353
|
|
|
406
|
-
//
|
|
407
|
-
// finite 0-100 percentage and a parseable (or absent) resetsAt.
|
|
354
|
+
// Valid entry: finite 0-100 percentage, parseable (or absent) resetsAt.
|
|
408
355
|
function isValidUsageEntry(entry) {
|
|
409
356
|
if (!entry || typeof entry !== 'object') return false;
|
|
410
357
|
if (!Number.isFinite(entry.percentage) || entry.percentage < 0 || entry.percentage > 100) return false;
|
|
@@ -412,9 +359,7 @@ function isValidUsageEntry(entry) {
|
|
|
412
359
|
return true;
|
|
413
360
|
}
|
|
414
361
|
|
|
415
|
-
//
|
|
416
|
-
// ({ timestamp, data: { fiveHour: {percentage,resetsAt}, weekly: {...}|null } }).
|
|
417
|
-
// Returns { age, data } or null. Age-vs-TTL decisions are made by the caller.
|
|
362
|
+
// Cached usage -> { age, data } or null; caller applies TTLs. Invalid shape -> null.
|
|
418
363
|
function readCachedUsage() {
|
|
419
364
|
try {
|
|
420
365
|
if (!fs.existsSync(USAGE_CACHE_FILE)) return null;
|
|
@@ -422,10 +367,7 @@ function readCachedUsage() {
|
|
|
422
367
|
const cache = JSON.parse(fs.readFileSync(USAGE_CACHE_FILE, 'utf8'));
|
|
423
368
|
if (!cache || !Number.isFinite(cache.timestamp) || cache.timestamp <= 0) return null;
|
|
424
369
|
|
|
425
|
-
//
|
|
426
|
-
// omit either). This also rejects the legacy single-{percentage,resetsAt} format from
|
|
427
|
-
// older versions, which had no fiveHour key, so stale caches are ignored on read.
|
|
428
|
-
// A cache written before model bars existed simply has no models key — still valid.
|
|
370
|
+
// fiveHour required; weekly/models optional. Rejects legacy formats lacking fiveHour.
|
|
429
371
|
const data = cache.data;
|
|
430
372
|
if (!data || typeof data !== 'object') return null;
|
|
431
373
|
if (!isValidUsageEntry(data.fiveHour)) return null;
|
|
@@ -441,11 +383,8 @@ function readCachedUsage() {
|
|
|
441
383
|
}
|
|
442
384
|
}
|
|
443
385
|
|
|
444
|
-
//
|
|
445
|
-
//
|
|
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`.
|
|
386
|
+
// On-disk cache shape; pure so test/preview seeds produce writer-identical bytes. lastAttempt
|
|
387
|
+
// starts equal to timestamp: a successful write is itself an attempt.
|
|
449
388
|
function serializeUsageCache(data, timestamp = Date.now()) {
|
|
450
389
|
return JSON.stringify({ timestamp, data, lastAttempt: timestamp });
|
|
451
390
|
}
|
|
@@ -463,9 +402,8 @@ function setCachedUsage(data) {
|
|
|
463
402
|
}
|
|
464
403
|
}
|
|
465
404
|
|
|
466
|
-
// Age in ms since the last
|
|
467
|
-
//
|
|
468
|
-
// applies when no valid data has ever been cached (every attempt so far has failed).
|
|
405
|
+
// Age in ms since the last attempt (success or failure), or null. Read from the raw file, not
|
|
406
|
+
// readCachedUsage, so the cooldown applies even when no valid data has ever been cached.
|
|
469
407
|
function getLastAttemptAge() {
|
|
470
408
|
try {
|
|
471
409
|
if (!fs.existsSync(USAGE_CACHE_FILE)) return null;
|
|
@@ -477,9 +415,8 @@ function getLastAttemptAge() {
|
|
|
477
415
|
}
|
|
478
416
|
}
|
|
479
417
|
|
|
480
|
-
//
|
|
481
|
-
// failed refresh doesn't erase the last successful one.
|
|
482
|
-
// or a process exit mid-request still counts as an attempt for cooldown purposes.
|
|
418
|
+
// Stamp lastAttempt before the request so failed attempts still enter cooldown; preserves
|
|
419
|
+
// existing cached data so a failed refresh doesn't erase the last successful one.
|
|
483
420
|
function recordUsageAttempt() {
|
|
484
421
|
try {
|
|
485
422
|
if (!fs.existsSync(CACHE_DIR)) {
|
|
@@ -497,8 +434,115 @@ function recordUsageAttempt() {
|
|
|
497
434
|
}
|
|
498
435
|
}
|
|
499
436
|
|
|
437
|
+
// Strict "x.y.z" -> -1|0|1, null otherwise (prerelease never nudges — a nicety, not a guess).
|
|
438
|
+
function compareVersions(a, b) {
|
|
439
|
+
const parse = (v) => SEMVER_RE.test(String(v ?? '')) ? String(v).split('.').map(Number) : null;
|
|
440
|
+
const x = parse(a);
|
|
441
|
+
const y = parse(b);
|
|
442
|
+
if (!x || !y) return null;
|
|
443
|
+
for (let i = 0; i < 3; i++) {
|
|
444
|
+
if (x[i] !== y[i]) return x[i] > y[i] ? 1 : -1;
|
|
445
|
+
}
|
|
446
|
+
return 0;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Registry body -> "x.y.z" or null (404 bodies land here too).
|
|
450
|
+
function parseRegistryVersion(body) {
|
|
451
|
+
try {
|
|
452
|
+
const v = JSON.parse(body)?.version;
|
|
453
|
+
return SEMVER_RE.test(String(v ?? '')) ? String(v) : null;
|
|
454
|
+
} catch (e) {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function readUpdateCache() {
|
|
460
|
+
try {
|
|
461
|
+
const c = JSON.parse(fs.readFileSync(UPDATE_CACHE_FILE, 'utf8'));
|
|
462
|
+
return c && typeof c === 'object' ? c : null;
|
|
463
|
+
} catch (e) {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function writeUpdateCache(obj) {
|
|
469
|
+
try {
|
|
470
|
+
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
471
|
+
fs.writeFileSync(UPDATE_CACHE_FILE, JSON.stringify(obj), 'utf8');
|
|
472
|
+
} catch (e) {}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Cached latest when strictly newer than VERSION, else ''. Cache-only: render never touches the network.
|
|
476
|
+
function getLatestUpdate() {
|
|
477
|
+
const cached = readUpdateCache();
|
|
478
|
+
if (!cached) return '';
|
|
479
|
+
return compareVersions(cached.latest, VERSION) === 1 ? String(cached.latest) : '';
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Spawn the check in a detached child — the render never waits on the registry. lastAttempt is
|
|
483
|
+
// stamped before the spawn, so an offline/failed/killed child backs off UPDATE_RETRY_MS.
|
|
484
|
+
function refreshUpdateCheck() {
|
|
485
|
+
try {
|
|
486
|
+
const cached = readUpdateCache();
|
|
487
|
+
const now = Date.now();
|
|
488
|
+
if (cached) {
|
|
489
|
+
if (Number.isFinite(cached.checkedAt) && now - cached.checkedAt < UPDATE_TTL_MS) return;
|
|
490
|
+
if (Number.isFinite(cached.lastAttempt) && now - cached.lastAttempt < UPDATE_RETRY_MS) return;
|
|
491
|
+
}
|
|
492
|
+
writeUpdateCache({ ...(cached || {}), lastAttempt: now });
|
|
493
|
+
// windowsHide: no console flash on Windows; detached + unref: child outlives the render's exit.
|
|
494
|
+
spawn(process.execPath, [__filename, 'update-check'], {
|
|
495
|
+
detached: true, stdio: 'ignore', windowsHide: true
|
|
496
|
+
}).unref();
|
|
497
|
+
} catch (e) {}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Detached 'update-check' entry point: fetch, stamp cache, exit. A failed fetch leaves
|
|
501
|
+
// checkedAt untouched, so the UPDATE_RETRY_MS backoff (not the weekly TTL) governs the next try.
|
|
502
|
+
function runUpdateCheck() {
|
|
503
|
+
let settled = false;
|
|
504
|
+
let deadline;
|
|
505
|
+
|
|
506
|
+
const done = (latest) => {
|
|
507
|
+
if (settled) return;
|
|
508
|
+
settled = true;
|
|
509
|
+
clearTimeout(deadline);
|
|
510
|
+
if (latest) writeUpdateCache({ ...(readUpdateCache() || {}), latest, checkedAt: Date.now() });
|
|
511
|
+
process.exit(0);
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
try {
|
|
515
|
+
const req = https.request({
|
|
516
|
+
hostname: REGISTRY_HOST,
|
|
517
|
+
path: `/${PACKAGE_NAME}/latest`,
|
|
518
|
+
method: 'GET',
|
|
519
|
+
headers: { 'Accept': 'application/json' },
|
|
520
|
+
timeout: UPDATE_TIMEOUT_MS
|
|
521
|
+
}, (res) => {
|
|
522
|
+
let body = '';
|
|
523
|
+
res.on('data', chunk => body += chunk);
|
|
524
|
+
res.on('end', () => done(parseRegistryVersion(body)));
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
req.on('error', () => done(null));
|
|
528
|
+
req.on('timeout', () => {
|
|
529
|
+
req.destroy();
|
|
530
|
+
done(null);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// The timeout option is socket inactivity, not total — a trickling response needs this hard deadline.
|
|
534
|
+
deadline = setTimeout(() => {
|
|
535
|
+
req.destroy();
|
|
536
|
+
done(null);
|
|
537
|
+
}, UPDATE_TIMEOUT_MS);
|
|
538
|
+
|
|
539
|
+
req.end();
|
|
540
|
+
} catch (e) {
|
|
541
|
+
done(null);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
500
545
|
function getCredentials() {
|
|
501
|
-
// Try file first (legacy / Linux / Windows)
|
|
502
546
|
const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
503
547
|
if (fs.existsSync(credsPath)) {
|
|
504
548
|
try {
|
|
@@ -506,7 +550,7 @@ function getCredentials() {
|
|
|
506
550
|
} catch (e) {}
|
|
507
551
|
}
|
|
508
552
|
|
|
509
|
-
//
|
|
553
|
+
// macOS keychain fallback
|
|
510
554
|
if (os.platform() === 'darwin') {
|
|
511
555
|
try {
|
|
512
556
|
const raw = execSync('security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', { encoding: 'utf8', timeout: 1000 });
|
|
@@ -519,7 +563,6 @@ function getCredentials() {
|
|
|
519
563
|
|
|
520
564
|
function getApiUsage(callback) {
|
|
521
565
|
try {
|
|
522
|
-
// Read credentials (file or macOS keychain)
|
|
523
566
|
const creds = getCredentials();
|
|
524
567
|
if (!creds) {
|
|
525
568
|
return callback(null);
|
|
@@ -531,12 +574,10 @@ function getApiUsage(callback) {
|
|
|
531
574
|
return callback(null);
|
|
532
575
|
}
|
|
533
576
|
|
|
534
|
-
//
|
|
535
|
-
// API typically takes ~850ms, so 1200ms gives reasonable headroom
|
|
577
|
+
// Tighter timeout when the cache is warm — a fresh render already has data to print.
|
|
536
578
|
const hasCache = fs.existsSync(USAGE_CACHE_FILE);
|
|
537
579
|
const timeout = hasCache ? 1200 : 1500;
|
|
538
580
|
|
|
539
|
-
// Make API call with adaptive timeout
|
|
540
581
|
const req = https.request({
|
|
541
582
|
hostname: 'api.anthropic.com',
|
|
542
583
|
path: '/api/oauth/usage',
|
|
@@ -553,7 +594,6 @@ function getApiUsage(callback) {
|
|
|
553
594
|
res.on('data', chunk => data += chunk);
|
|
554
595
|
res.on('end', () => {
|
|
555
596
|
const resolved = parseUsagePayload(data);
|
|
556
|
-
// Cache the raw data (shared across sessions); callers render from it.
|
|
557
597
|
if (resolved) setCachedUsage(resolved);
|
|
558
598
|
callback(resolved);
|
|
559
599
|
});
|
|
@@ -575,27 +615,21 @@ function getApiUsage(callback) {
|
|
|
575
615
|
function getRawUsage(callback) {
|
|
576
616
|
const cached = readCachedUsage();
|
|
577
617
|
|
|
578
|
-
// Cache is fresh -> use it and skip the API entirely (fewer calls, faster).
|
|
579
618
|
if (cached && cached.age < FRESH_TTL_MS) {
|
|
580
619
|
return callback(cached.data);
|
|
581
620
|
}
|
|
582
621
|
|
|
583
|
-
//
|
|
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).
|
|
622
|
+
// Refresh attempted (even failed) within FRESH_TTL_MS -> cooldown: serve stale up to STALE_TTL_MS (issue #41).
|
|
587
623
|
const attemptAge = getLastAttemptAge();
|
|
588
624
|
if (attemptAge != null && attemptAge < FRESH_TTL_MS) {
|
|
589
625
|
return callback(cached && cached.age < STALE_TTL_MS ? cached.data : null);
|
|
590
626
|
}
|
|
591
627
|
|
|
592
|
-
// Cache is stale or missing and no attempt is in cooldown -> refresh from the API.
|
|
593
628
|
recordUsageAttempt();
|
|
594
629
|
getApiUsage((fresh) => {
|
|
595
630
|
if (fresh) {
|
|
596
631
|
callback(fresh);
|
|
597
632
|
} else if (cached && cached.age < STALE_TTL_MS) {
|
|
598
|
-
// API failed/timed out, but recent cache exists -> show it instead of nothing.
|
|
599
633
|
callback(cached.data);
|
|
600
634
|
} else {
|
|
601
635
|
callback(null);
|
|
@@ -603,9 +637,7 @@ function getRawUsage(callback) {
|
|
|
603
637
|
});
|
|
604
638
|
}
|
|
605
639
|
|
|
606
|
-
//
|
|
607
|
-
// Claude Code as tokens × per-model API pricing). Pure stdin — no network/cache.
|
|
608
|
-
// Returns "$0.00" rendered dim, or '' when absent/non-finite so the segment is omitted.
|
|
640
|
+
// "$0.00" (dim) from stdin cost.total_cost_usd — client-side estimate, no network; '' when absent.
|
|
609
641
|
function getCostSegment(data) {
|
|
610
642
|
const usd = data?.cost?.total_cost_usd;
|
|
611
643
|
if (!Number.isFinite(usd)) return '';
|
|
@@ -641,11 +673,8 @@ function visibleWidth(str) {
|
|
|
641
673
|
return [...str.replace(/\x1b\[[0-9;]*m/g, '')].length;
|
|
642
674
|
}
|
|
643
675
|
|
|
644
|
-
//
|
|
645
|
-
//
|
|
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.
|
|
676
|
+
// Two lines only when cols is known (COLUMNS, set by Claude Code v2.1.153+) and the single
|
|
677
|
+
// line overflows; unknown width or empty line2 stays single. cols is a parameter — no env read.
|
|
649
678
|
function layout(line1Parts, line2Parts, cols) {
|
|
650
679
|
const single = [...line1Parts, ...line2Parts].join(SEGMENT_SEP);
|
|
651
680
|
if (line2Parts.length === 0) return single;
|
|
@@ -655,12 +684,9 @@ function layout(line1Parts, line2Parts, cols) {
|
|
|
655
684
|
return single;
|
|
656
685
|
}
|
|
657
686
|
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
//
|
|
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.
|
|
687
|
+
// Everything the render needs that touches fs/child_process/env (git, todos, update cache,
|
|
688
|
+
// COLUMNS), so renderStatusLine stays pure. Own try/catch: called outside outputStatus's, and
|
|
689
|
+
// a malformed current_dir must still degrade to a renderable fallback.
|
|
664
690
|
function collectFacts(data) {
|
|
665
691
|
try {
|
|
666
692
|
const dir = data?.workspace?.current_dir || process.cwd();
|
|
@@ -669,15 +695,23 @@ function collectFacts(data) {
|
|
|
669
695
|
const sync = branch ? formatAheadBehind(getGitAheadBehind(dir)) : '';
|
|
670
696
|
const sessionId = data?.session_id || '';
|
|
671
697
|
const task = DISABLED.has('task') ? '' : getCurrentTask(sessionId);
|
|
698
|
+
const update = DISABLED.has('update') ? '' : getLatestUpdate();
|
|
672
699
|
const cols = parseInt(process.env.COLUMNS, 10);
|
|
673
|
-
return { dirname, branch, sync, task, cols };
|
|
700
|
+
return { dirname, branch, sync, task, update, cols };
|
|
674
701
|
} catch (e) {
|
|
675
|
-
return { dirname: '~', branch: '', sync: '', task: '', cols: undefined };
|
|
702
|
+
return { dirname: '~', branch: '', sync: '', task: '', update: '', cols: undefined };
|
|
676
703
|
}
|
|
677
704
|
}
|
|
678
705
|
|
|
679
|
-
//
|
|
680
|
-
//
|
|
706
|
+
// Own stdout row, not a segment: the copy-pasteable command is too wide to inline without
|
|
707
|
+
// forcing a wrap. Appended after layout() so it never joins the wrap decision.
|
|
708
|
+
function renderUpdateLine(latest) {
|
|
709
|
+
return `${colors.green}⬆ ${latest}${colors.reset} `
|
|
710
|
+
+ `${colors.dim}available ·${colors.reset} `
|
|
711
|
+
+ `${colors.bold}npx ${PACKAGE_NAME}@latest${colors.reset}`;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Pure: data + facts (see collectFacts) + usage bars -> rendered line(s); callable directly in tests.
|
|
681
715
|
function renderStatusLine(data, facts, usage) {
|
|
682
716
|
const model = shortenModel(data?.model?.display_name || 'Claude');
|
|
683
717
|
const effort = DISABLED.has('effort') ? '' : (data?.effort?.level || '');
|
|
@@ -701,10 +735,10 @@ function renderStatusLine(data, facts, usage) {
|
|
|
701
735
|
if (cost) line2.push(cost);
|
|
702
736
|
if (facts.task) line2.push(`${colors.dim}${facts.task}${colors.reset}`);
|
|
703
737
|
|
|
704
|
-
|
|
738
|
+
const body = layout(line1, line2, facts.cols);
|
|
739
|
+
return facts.update ? body + '\n' + renderUpdateLine(facts.update) : body;
|
|
705
740
|
}
|
|
706
741
|
|
|
707
|
-
// Main
|
|
708
742
|
function outputStatus(data, facts, usage) {
|
|
709
743
|
try {
|
|
710
744
|
process.stdout.write(renderStatusLine(data, facts, usage));
|
|
@@ -714,24 +748,19 @@ function outputStatus(data, facts, usage) {
|
|
|
714
748
|
}
|
|
715
749
|
|
|
716
750
|
function outputFallback(usage) {
|
|
717
|
-
const facts = { dirname: '~', branch: '', sync: '', task: '', cols: undefined };
|
|
751
|
+
const facts = { dirname: '~', branch: '', sync: '', task: '', update: '', cols: undefined };
|
|
718
752
|
process.stdout.write(renderStatusLine(null, facts, usage));
|
|
719
753
|
}
|
|
720
754
|
|
|
721
|
-
//
|
|
722
|
-
// Order: API-key users get none; otherwise prefer stdin `rate_limits` (no network),
|
|
723
|
-
// then fall back to the cache+API flow when stdin lacks it (cold start / non-Pro/Max).
|
|
755
|
+
// Usage bars: API-key users none; prefer stdin rate_limits, else cache+API (cold start / non-Pro/Max).
|
|
724
756
|
function resolveUsage(data, callback) {
|
|
725
757
|
if (IS_API_KEY || DISABLED.has('usage')) {
|
|
726
758
|
return callback(null);
|
|
727
759
|
}
|
|
728
760
|
const fromStdin = buildUsageFromStdin(data);
|
|
729
761
|
if (fromStdin) {
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
// usage read, which keeps at most one call per FRESH_TTL_MS regardless of render rate.
|
|
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.
|
|
762
|
+
// Scoped limits only exist in the API payload -> fetch from cache; a failed/slow call
|
|
763
|
+
// costs only those bars, never the H/W bars stdin already gave us.
|
|
735
764
|
return getRawUsage((cached) => {
|
|
736
765
|
callback(buildUsageBars({ ...fromStdin, models: cached?.models || [] }));
|
|
737
766
|
});
|
|
@@ -749,10 +778,8 @@ function parseInput(input) {
|
|
|
749
778
|
}
|
|
750
779
|
}
|
|
751
780
|
|
|
752
|
-
// Accumulate stdin
|
|
753
|
-
//
|
|
754
|
-
// breaking the never-throw contract). Shared by both entry points below, which
|
|
755
|
-
// differ only in timeoutMs.
|
|
781
|
+
// Accumulate stdin, call fn(input) exactly once — timeout, 'end', or 'error' whichever fires
|
|
782
|
+
// first (the error handler preserves the never-throw contract). Shared by both entry points.
|
|
756
783
|
function readStdinThen(timeoutMs, fn) {
|
|
757
784
|
let input = '';
|
|
758
785
|
let finished = false;
|
|
@@ -774,6 +801,7 @@ function readStdinThen(timeoutMs, fn) {
|
|
|
774
801
|
|
|
775
802
|
// Resolve usage for `data` (preferring stdin rate_limits), then render and exit.
|
|
776
803
|
function emit(data) {
|
|
804
|
+
if (!DISABLED.has('update')) refreshUpdateCheck();
|
|
777
805
|
resolveUsage(data, (usage) => {
|
|
778
806
|
if (data) {
|
|
779
807
|
outputStatus(data, collectFacts(data), usage);
|
|
@@ -784,10 +812,8 @@ function emit(data) {
|
|
|
784
812
|
});
|
|
785
813
|
}
|
|
786
814
|
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
// string: numbers below 1e12 are epoch-seconds (today's epoch-seconds ~1.7e9, epoch-ms
|
|
790
|
-
// ~1.7e12 — far enough apart that the threshold is unambiguous for any real timestamp).
|
|
815
|
+
// "45s" / "4m12s" / "2h5m". startTime's format is undocumented upstream, so accept epoch-seconds
|
|
816
|
+
// (< 1e12), epoch-ms, or an ISO string. Revisit if a real payload contradicts.
|
|
791
817
|
function formatElapsed(startTime) {
|
|
792
818
|
if (startTime == null) return '';
|
|
793
819
|
const ms = typeof startTime === 'number' && startTime < 1e12 ? startTime * 1000 : startTime;
|
|
@@ -828,10 +854,8 @@ function renderSubagentTask(t) {
|
|
|
828
854
|
return parts.join(SEGMENT_SEP);
|
|
829
855
|
}
|
|
830
856
|
|
|
831
|
-
// subagentStatusLine mode:
|
|
832
|
-
//
|
|
833
|
-
// Bad payload or a task that fails to render -> emit nothing, keeping default
|
|
834
|
-
// rendering for every task, rather than a partial/broken output.
|
|
857
|
+
// subagentStatusLine mode: one {id, content} JSON line per task. No usage/git/todos/cache
|
|
858
|
+
// work. Bad payload or a task that fails to render -> emit nothing (default rendering stays).
|
|
835
859
|
function emitSubagent(data) {
|
|
836
860
|
try {
|
|
837
861
|
const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
|
|
@@ -840,9 +864,7 @@ function emitSubagent(data) {
|
|
|
840
864
|
.map(t => JSON.stringify({ id: t.id, content: renderSubagentTask(t) }))
|
|
841
865
|
.join('\n');
|
|
842
866
|
if (out) {
|
|
843
|
-
// Exit from the write callback: process.exit() would drop output
|
|
844
|
-
// behind stdout backpressure. A write error (e.g. EPIPE) also lands here — the
|
|
845
|
-
// callback form reports it instead of throwing, and the answer is the same: exit 0.
|
|
867
|
+
// Exit from the write callback: process.exit() would drop output queued behind backpressure.
|
|
846
868
|
process.stdout.write(out + '\n', () => process.exit(0));
|
|
847
869
|
return;
|
|
848
870
|
}
|
|
@@ -850,14 +872,15 @@ function emitSubagent(data) {
|
|
|
850
872
|
process.exit(0);
|
|
851
873
|
}
|
|
852
874
|
|
|
853
|
-
//
|
|
854
|
-
// directly (the /usage response shape is the easiest thing here to get wrong, and it
|
|
855
|
-
// can't be reached through stdin). Running the script normally is unchanged.
|
|
875
|
+
// Guarded so tests can require() the exports instead of spawning the script.
|
|
856
876
|
if (require.main === module) {
|
|
857
|
-
const
|
|
877
|
+
const mode = process.argv[2];
|
|
878
|
+
const isSubagent = mode === 'subagent';
|
|
858
879
|
const finish = isSubagent ? emitSubagent : emit;
|
|
859
880
|
|
|
860
|
-
if (
|
|
881
|
+
if (mode === 'update-check') {
|
|
882
|
+
runUpdateCheck();
|
|
883
|
+
} else if (process.stdin.isTTY) {
|
|
861
884
|
finish(null);
|
|
862
885
|
} else {
|
|
863
886
|
const timeoutMs = isSubagent
|
|
@@ -866,5 +889,5 @@ if (require.main === module) {
|
|
|
866
889
|
readStdinThen(timeoutMs, (input) => finish(parseInput(input)));
|
|
867
890
|
}
|
|
868
891
|
} else {
|
|
869
|
-
module.exports = { parseScopedLimits, parseUsagePayload, serializeUsageCache, normalizePercentage, readStdinThen, renderStatusLine, renderSubagentTask };
|
|
892
|
+
module.exports = { parseScopedLimits, parseUsagePayload, serializeUsageCache, normalizePercentage, readStdinThen, renderStatusLine, renderSubagentTask, compareVersions, parseRegistryVersion, VERSION };
|
|
870
893
|
}
|