claude-simple-status 1.2.0 → 1.3.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 +7 -3
- package/package.json +1 -1
- package/statusline.mjs +165 -6
package/README.md
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
|
|
12
12
|
A simple, no-frills statusline for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) that shows what matters: **project name, git branch, model, context usage, quota, and API costs**.
|
|
13
13
|
|
|
14
|
-

|
|
15
|
+
|
|
16
|
+

|
|
15
17
|
|
|
16
18
|
## Features
|
|
17
19
|
|
|
@@ -19,6 +21,8 @@ A simple, no-frills statusline for [Claude Code](https://docs.anthropic.com/en/d
|
|
|
19
21
|
- **Cross-platform** — works on macOS, Linux, and Windows
|
|
20
22
|
- **Non-blocking** — returns cached data instantly, refreshes quota in the background
|
|
21
23
|
- **Color-coded** — green/orange/red percentages at a glance
|
|
24
|
+
- **Context velocity** — estimates remaining turns until context compaction (`42% →~8t`), with directional arrows showing if burn rate is accelerating (↑), steady (→), or decelerating (↓)
|
|
25
|
+
- **Quota pressure** — reset time changes color based on projected burn rate: green (safe), orange (cutting it close), red (will hit the limit before reset). The 7d percentage color is also overridden when the projection says danger
|
|
22
26
|
- **Project name** — bold uppercase project directory name so you never mix up sessions
|
|
23
27
|
- **Git-aware** — shows the current branch name in repos (cached 30s to reduce overhead)
|
|
24
28
|
- **API cost tracking** — pay-as-you-go API users see cumulative session cost instead of quota
|
|
@@ -98,12 +102,12 @@ To uninstall, remove `~/.claude/statusline/` and the `"statusLine"` block from s
|
|
|
98
102
|
1. Receives model/context/cost info from Claude Code via stdin (JSON)
|
|
99
103
|
2. Reads cached quota data and returns immediately (never blocks the UI)
|
|
100
104
|
3. If the cache is stale (>2 minutes), refreshes from Anthropic's OAuth API in the background
|
|
101
|
-
4.
|
|
105
|
+
4. Tracks context usage and quota utilization over time to compute velocity/burn rate predictions
|
|
102
106
|
5. Outputs a formatted statusline with ANSI colors
|
|
103
107
|
|
|
104
108
|
**Subscription users** see quota percentages and reset times. **API (pay-as-you-go) users** see cumulative session cost (e.g. `$4.72`) — calculated by Claude Code from actual token usage, no external pricing lookups needed.
|
|
105
109
|
|
|
106
|
-
Quota data is cached to the system temp directory and refreshed every 2 minutes.
|
|
110
|
+
Quota data is cached to the system temp directory and refreshed every 2 minutes. Context and quota history are tracked across invocations to power the predictive features — both reset automatically when a new window or compaction is detected.
|
|
107
111
|
|
|
108
112
|
## Troubleshooting
|
|
109
113
|
|
package/package.json
CHANGED
package/statusline.mjs
CHANGED
|
@@ -45,6 +45,11 @@ const CACHE_MAX_AGE = 120; // seconds - when to fetch
|
|
|
45
45
|
const CACHE_STALE_AGE = 300; // seconds - when to show "--" instead of old values
|
|
46
46
|
const GIT_BRANCH_CACHE = join(tmpdir(), 'claude-statusline-branches.json');
|
|
47
47
|
const GIT_BRANCH_MAX_AGE = 30; // seconds
|
|
48
|
+
const CONTEXT_HISTORY_FILE = join(tmpdir(), 'claude-statusline-context.json');
|
|
49
|
+
const CONTEXT_COMPACT_THRESHOLD = 95; // % at which compaction typically fires
|
|
50
|
+
const CONTEXT_MAX_SAMPLES = 20; // rolling window of turn deltas
|
|
51
|
+
const QUOTA_HISTORY_FILE = join(tmpdir(), 'claude-statusline-quota-history.json');
|
|
52
|
+
const QUOTA_MAX_READINGS = 30; // ~1h of data at 120s refresh intervals
|
|
48
53
|
|
|
49
54
|
// Color a percentage value based on thresholds
|
|
50
55
|
function colorPct(val) {
|
|
@@ -188,6 +193,130 @@ function getGitBranch() {
|
|
|
188
193
|
}
|
|
189
194
|
}
|
|
190
195
|
|
|
196
|
+
// Track context % over time and estimate remaining turns until compaction
|
|
197
|
+
// Returns { arrow, turnsLeft } or null if not enough data
|
|
198
|
+
function getContextVelocity(projectDir, contextUsed) {
|
|
199
|
+
if (!projectDir || typeof contextUsed !== 'number') return null;
|
|
200
|
+
|
|
201
|
+
const now = Date.now();
|
|
202
|
+
const history = readJsonFile(CONTEXT_HISTORY_FILE) || {};
|
|
203
|
+
const entry = history[projectDir] || { readings: [], deltas: [] };
|
|
204
|
+
|
|
205
|
+
// Append current reading
|
|
206
|
+
const last = entry.readings[entry.readings.length - 1];
|
|
207
|
+
entry.readings.push({ pct: contextUsed, ts: now });
|
|
208
|
+
|
|
209
|
+
// Detect a new "turn": context jumped since last reading
|
|
210
|
+
// (statusline is polled every ~2s, but context only changes between turns)
|
|
211
|
+
if (last && contextUsed > last.pct) {
|
|
212
|
+
const delta = contextUsed - last.pct;
|
|
213
|
+
// Ignore tiny noise (<0.1%) and impossibly large jumps (>30% = probably a new session)
|
|
214
|
+
if (delta >= 0.1 && delta <= 30) {
|
|
215
|
+
entry.deltas.push(delta);
|
|
216
|
+
if (entry.deltas.length > CONTEXT_MAX_SAMPLES) {
|
|
217
|
+
entry.deltas = entry.deltas.slice(-CONTEXT_MAX_SAMPLES);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Context went down = compaction happened or new session, reset tracking
|
|
223
|
+
if (last && contextUsed < last.pct - 1) {
|
|
224
|
+
entry.readings = [{ pct: contextUsed, ts: now }];
|
|
225
|
+
entry.deltas = [];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Keep only last 2 readings (we just need prev + current to detect jumps)
|
|
229
|
+
if (entry.readings.length > 2) {
|
|
230
|
+
entry.readings = entry.readings.slice(-2);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
history[projectDir] = entry;
|
|
234
|
+
try { writeFileSync(CONTEXT_HISTORY_FILE, JSON.stringify(history)); } catch {}
|
|
235
|
+
|
|
236
|
+
// Need at least 2 turn deltas to estimate
|
|
237
|
+
if (entry.deltas.length < 2) return null;
|
|
238
|
+
|
|
239
|
+
// Weighted average: recent deltas matter more
|
|
240
|
+
let weightSum = 0;
|
|
241
|
+
let deltaSum = 0;
|
|
242
|
+
for (let i = 0; i < entry.deltas.length; i++) {
|
|
243
|
+
const weight = i + 1; // linear: older=1, newest=N
|
|
244
|
+
deltaSum += entry.deltas[i] * weight;
|
|
245
|
+
weightSum += weight;
|
|
246
|
+
}
|
|
247
|
+
const avgDelta = deltaSum / weightSum;
|
|
248
|
+
|
|
249
|
+
const remaining = CONTEXT_COMPACT_THRESHOLD - contextUsed;
|
|
250
|
+
if (remaining <= 0 || avgDelta <= 0) return { arrow: '\u2191', turnsLeft: 0 };
|
|
251
|
+
|
|
252
|
+
const turnsLeft = Math.round(remaining / avgDelta);
|
|
253
|
+
|
|
254
|
+
// Arrow based on trend (last 3 deltas vs overall average)
|
|
255
|
+
const recentSlice = entry.deltas.slice(-3);
|
|
256
|
+
const recentAvg = recentSlice.reduce((a, b) => a + b, 0) / recentSlice.length;
|
|
257
|
+
let arrow;
|
|
258
|
+
if (recentAvg > avgDelta * 1.3) arrow = '\u2191'; // accelerating ↑
|
|
259
|
+
else if (recentAvg < avgDelta * 0.7) arrow = '\u2193'; // decelerating ↓
|
|
260
|
+
else arrow = '\u2192'; // steady →
|
|
261
|
+
|
|
262
|
+
return { arrow, turnsLeft };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Predict whether quota will be exhausted before the reset window
|
|
266
|
+
// windowKey: '5h' or '7d' — used to track readings separately
|
|
267
|
+
// Returns 'safe' | 'tight' | 'danger' | null
|
|
268
|
+
function getQuotaPressure(windowKey, utilization, resetsAtIso) {
|
|
269
|
+
if (typeof utilization !== 'number' || !resetsAtIso) return null;
|
|
270
|
+
|
|
271
|
+
const now = Date.now();
|
|
272
|
+
const resetsAt = new Date(resetsAtIso).getTime();
|
|
273
|
+
const msUntilReset = resetsAt - now;
|
|
274
|
+
if (msUntilReset <= 0) return null; // reset imminent, no point predicting
|
|
275
|
+
|
|
276
|
+
const allHistory = readJsonFile(QUOTA_HISTORY_FILE) || {};
|
|
277
|
+
const history = allHistory[windowKey] || { readings: [] };
|
|
278
|
+
|
|
279
|
+
// Record new reading if quota changed (API refreshes every ~120s)
|
|
280
|
+
const last = history.readings[history.readings.length - 1];
|
|
281
|
+
if (!last || last.pct !== utilization) {
|
|
282
|
+
history.readings.push({ pct: utilization, ts: now });
|
|
283
|
+
if (history.readings.length > QUOTA_MAX_READINGS) {
|
|
284
|
+
history.readings = history.readings.slice(-QUOTA_MAX_READINGS);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Quota dropped = new window started, reset history
|
|
289
|
+
if (last && utilization < last.pct - 5) {
|
|
290
|
+
history.readings = [{ pct: utilization, ts: now }];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
allHistory[windowKey] = history;
|
|
294
|
+
try { writeFileSync(QUOTA_HISTORY_FILE, JSON.stringify(allHistory)); } catch {}
|
|
295
|
+
|
|
296
|
+
// Need at least 2 distinct readings to compute rate
|
|
297
|
+
if (history.readings.length < 2) return null;
|
|
298
|
+
|
|
299
|
+
const oldest = history.readings[0];
|
|
300
|
+
const elapsedMs = now - oldest.ts;
|
|
301
|
+
if (elapsedMs < 60_000) return null; // need at least 1 min of data
|
|
302
|
+
|
|
303
|
+
const pctGained = utilization - oldest.pct;
|
|
304
|
+
if (pctGained <= 0) return 'safe'; // not growing
|
|
305
|
+
|
|
306
|
+
// % per millisecond → extrapolate time to 100%
|
|
307
|
+
const rate = pctGained / elapsedMs;
|
|
308
|
+
const pctRemaining = 100 - utilization;
|
|
309
|
+
const msTo100 = pctRemaining / rate;
|
|
310
|
+
|
|
311
|
+
// Compare projected exhaustion time to reset time
|
|
312
|
+
const exhaustsAt = now + msTo100;
|
|
313
|
+
const bufferMs = 30 * 60_000; // 30 min buffer for "tight"
|
|
314
|
+
|
|
315
|
+
if (exhaustsAt < resetsAt) return 'danger'; // will hit limit before reset
|
|
316
|
+
if (exhaustsAt < resetsAt + bufferMs) return 'tight'; // cutting it close
|
|
317
|
+
return 'safe';
|
|
318
|
+
}
|
|
319
|
+
|
|
191
320
|
// Main
|
|
192
321
|
async function main() {
|
|
193
322
|
// Read stdin
|
|
@@ -201,6 +330,7 @@ async function main() {
|
|
|
201
330
|
let contextUsed = 0;
|
|
202
331
|
let totalCostUsd = null;
|
|
203
332
|
let projectName = null;
|
|
333
|
+
let projectDir = null;
|
|
204
334
|
try {
|
|
205
335
|
const data = JSON.parse(input);
|
|
206
336
|
model = data.model?.display_name || 'Unknown';
|
|
@@ -209,7 +339,8 @@ async function main() {
|
|
|
209
339
|
totalCostUsd = data.cost.total_cost_usd;
|
|
210
340
|
}
|
|
211
341
|
if (data.workspace?.project_dir) {
|
|
212
|
-
|
|
342
|
+
projectDir = data.workspace.project_dir;
|
|
343
|
+
projectName = basename(projectDir).toUpperCase();
|
|
213
344
|
}
|
|
214
345
|
} catch {}
|
|
215
346
|
|
|
@@ -238,6 +369,8 @@ async function main() {
|
|
|
238
369
|
let fiveHourPct = '?';
|
|
239
370
|
let sevenDayPct = '?';
|
|
240
371
|
let resetLocal = '--:--';
|
|
372
|
+
let fiveHourResetsAt = null;
|
|
373
|
+
let sevenDayResetsAt = null;
|
|
241
374
|
const cacheIsStale = !quotaData || getFileAge(CACHE_FILE) > CACHE_STALE_AGE;
|
|
242
375
|
|
|
243
376
|
if (cacheIsStale) {
|
|
@@ -253,7 +386,9 @@ async function main() {
|
|
|
253
386
|
} else {
|
|
254
387
|
fiveHourPct = quotaData.five_hour?.utilization ?? '?';
|
|
255
388
|
sevenDayPct = quotaData.seven_day?.utilization ?? '?';
|
|
256
|
-
|
|
389
|
+
fiveHourResetsAt = quotaData.five_hour?.resets_at;
|
|
390
|
+
sevenDayResetsAt = quotaData.seven_day?.resets_at;
|
|
391
|
+
resetLocal = toLocalTime(fiveHourResetsAt);
|
|
257
392
|
}
|
|
258
393
|
}
|
|
259
394
|
|
|
@@ -264,11 +399,14 @@ async function main() {
|
|
|
264
399
|
hasError = errContent.length > 0;
|
|
265
400
|
} catch {}
|
|
266
401
|
|
|
267
|
-
// Get
|
|
402
|
+
// Get context velocity estimate
|
|
403
|
+
const velocity = getContextVelocity(projectDir, contextUsed);
|
|
404
|
+
|
|
405
|
+
// Get rig name (claude-rig sets CLAUDE_CONFIG_DIR to ~/.claude-rig/rigs/<name>)
|
|
268
406
|
const rigProfile = (() => {
|
|
269
407
|
const configDir = process.env.CLAUDE_CONFIG_DIR;
|
|
270
408
|
if (!configDir) return null;
|
|
271
|
-
const match = configDir.match(/\.claude-rig\/
|
|
409
|
+
const match = configDir.match(/\.claude-rig\/rigs\/([^/]+)\/?$/);
|
|
272
410
|
return match ? match[1] : null;
|
|
273
411
|
})();
|
|
274
412
|
|
|
@@ -279,9 +417,30 @@ async function main() {
|
|
|
279
417
|
const projectSegment = projectName
|
|
280
418
|
? `${WHITE_BOLD}${projectName}${branch ? ` ${YELLOW_BOLD}[${branch}]` : ''}${RESET}`
|
|
281
419
|
: (branch ? `${YELLOW_BOLD}${branch}${RESET}` : '');
|
|
282
|
-
|
|
420
|
+
// Format velocity: "42% →~8t" or just "42%" if not enough data yet
|
|
421
|
+
let contextDisplay = colorPct(contextUsed);
|
|
422
|
+
if (velocity) {
|
|
423
|
+
const turnsStr = velocity.turnsLeft === 0 ? '!' : `~${velocity.turnsLeft}t`;
|
|
424
|
+
const turnsColor = velocity.turnsLeft <= 5 ? RED : velocity.turnsLeft <= 15 ? ORANGE : GREEN;
|
|
425
|
+
contextDisplay += ` ${turnsColor}${velocity.arrow}${turnsStr}${RESET}`;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Color the reset time based on 5h quota burn rate projection
|
|
429
|
+
const fiveHourPressure = getQuotaPressure('5h', fiveHourPct, fiveHourResetsAt);
|
|
430
|
+
let resetDisplay = resetLocal;
|
|
431
|
+
if (fiveHourPressure === 'danger') resetDisplay = `${RED}${resetLocal}${RESET}`;
|
|
432
|
+
else if (fiveHourPressure === 'tight') resetDisplay = `${ORANGE}${resetLocal}${RESET}`;
|
|
433
|
+
else if (fiveHourPressure === 'safe') resetDisplay = `${GREEN}${resetLocal}${RESET}`;
|
|
434
|
+
|
|
435
|
+
// Override 7d percentage color when burn rate projects exhaustion before reset
|
|
436
|
+
const sevenDayPressure = getQuotaPressure('7d', sevenDayPct, sevenDayResetsAt);
|
|
437
|
+
let sevenDayDisplay = colorPct(sevenDayPct);
|
|
438
|
+
if (sevenDayPressure === 'danger') sevenDayDisplay = `${RED}${sevenDayPct}%${RESET}`;
|
|
439
|
+
else if (sevenDayPressure === 'tight') sevenDayDisplay = `${ORANGE}${sevenDayPct}%${RESET}`;
|
|
440
|
+
|
|
441
|
+
let output = `${projectSegment ? `${projectSegment} | ` : ''}${rigProfile ? `${MAGENTA_BOLD}${rigProfile}${RESET} | ` : ''}${CYAN}${model}${RESET} | ${contextDisplay}`;
|
|
283
442
|
if (token) {
|
|
284
|
-
output += ` | ${
|
|
443
|
+
output += ` | ${resetDisplay} | 5h:${colorPct(fiveHourPct)} | 7d:${sevenDayDisplay}`;
|
|
285
444
|
if (hasError) {
|
|
286
445
|
output += ` | ${RED}ERR${RESET}`;
|
|
287
446
|
}
|