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 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
- ![statusline screenshot](assets/statusline.png)
14
+ ![statusline subscription](assets/statusline-subscription.png)
15
+
16
+ ![statusline api](assets/statusline-api.png)
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. Converts UTC reset time to your local timezone
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. Since Claude Code calls the statusline on every message update, this avoids excessive API calls while keeping the data fresh.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-simple-status",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "A simple statusline for Claude Code — project name, git branch, model, context usage, quota, and API costs at a glance",
5
5
  "type": "module",
6
6
  "bin": {
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
- projectName = basename(data.workspace.project_dir).toUpperCase();
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
- resetLocal = toLocalTime(quotaData.five_hour?.resets_at);
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 rig profile (claude-rig sets CLAUDE_CONFIG_DIR to ~/.claude-rig/profiles/<name>)
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\/profiles\/([^/]+)\/?$/);
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
- let output = `${projectSegment ? `${projectSegment} | ` : ''}${rigProfile ? `${MAGENTA_BOLD}${rigProfile}${RESET} | ` : ''}${CYAN}${model}${RESET} | ${colorPct(contextUsed)}`;
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 += ` | ${resetLocal} | 5h:${colorPct(fiveHourPct)} | 7d:${colorPct(sevenDayPct)}`;
443
+ output += ` | ${resetDisplay} | 5h:${colorPct(fiveHourPct)} | 7d:${sevenDayDisplay}`;
285
444
  if (hasError) {
286
445
  output += ` | ${RED}ERR${RESET}`;
287
446
  }