claude-limit-statusline 0.2.0 → 0.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.
Files changed (3) hide show
  1. package/README.md +50 -3
  2. package/bin/cli.js +163 -35
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,13 +1,25 @@
1
1
  # claude-limit-statusline
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/claude-limit-statusline.svg)](https://www.npmjs.com/package/claude-limit-statusline)
4
+ [![npm downloads](https://img.shields.io/npm/dm/claude-limit-statusline.svg)](https://www.npmjs.com/package/claude-limit-statusline)
5
+ [![license](https://img.shields.io/npm/l/claude-limit-statusline.svg)](./LICENSE)
6
+ [![node](https://img.shields.io/node/v/claude-limit-statusline.svg)](https://nodejs.org)
7
+
3
8
  A [Claude Code](https://code.claude.com/docs) status line that shows your **real
4
9
  subscription limits** — the 5‑hour session window and the 7‑day weekly window —
5
10
  with a **live reset countdown**.
6
11
 
12
+ 📦 [npm](https://www.npmjs.com/package/claude-limit-statusline) ·
13
+ 🔗 [GitHub](https://github.com/ann0nip/claude-limit-statusline)
14
+
7
15
  ```
8
16
  🤖 Opus 4.8 (1M context) | 🧠 42k (4%) | ⏳ Session 17% · resets in 0h47m (23:12) | 📅 Week 10% · resets in 2d 21h (Jun 03 19:54)
9
17
  ```
10
18
 
19
+ The line **auto-fits your terminal width** — on a narrow window it sheds detail
20
+ gracefully (see [Narrow terminals](#narrow-terminals)) while always keeping the
21
+ session/week percentages.
22
+
11
23
  Unlike tools that estimate the 5‑hour block from local logs, this reads the
12
24
  **official `rate_limits` payload** that Claude Code provides on stdin — the same
13
25
  numbers you see when you run `/usage`. No guessing, no rounding to the hour.
@@ -82,6 +94,10 @@ If the bar stays blank (nvm/Volta `PATH` issue), use absolute paths instead —
82
94
  | `⏳ Session 17% · resets in 0h47m (23:12)` | **Real** 5‑hour limit used + reset | server |
83
95
  | `📅 Week 10% · resets in 2d 21h (Jun 03 19:54)` | **Real** 7‑day limit used + reset | server |
84
96
 
97
+ The reset countdowns appear when the terminal is wide enough; on narrower
98
+ windows they drop out automatically (week first, then session). Use `--reset` to
99
+ cap which ones may ever show.
100
+
85
101
  The percentage **is** your "how close am I to the limit" gauge. Subscription
86
102
  limits are dynamic, so Anthropic does not expose a fixed token cap — only a
87
103
  percentage, which is exactly what this surfaces.
@@ -89,6 +105,32 @@ percentage, which is exactly what this surfaces.
89
105
  Before the first API response (and right after `/compact`) the session segment
90
106
  shows `⏳ Session --` until fresh data arrives.
91
107
 
108
+ ### Narrow terminals
109
+
110
+ The line **auto-shrinks to fit your terminal width**, so it stays readable on a
111
+ small window and expands again when you make the window bigger — no config
112
+ needed. It sheds detail in steps, dropping the least important parts first and
113
+ **always keeping the session/week percentages**:
114
+
115
+ ```text
116
+ full 🤖 Opus 4.8 (1M context) | 🧠 42k (4%) | ⏳ Session 17% · resets in 0h47m (23:12) | 📅 Week 10% · resets in 2d 21h (Jun 03 19:54)
117
+ ↓ week countdown drops
118
+ medium 🤖 Opus 4.8 (1M context) | 🧠 42k (4%) | ⏳ Session 17% · resets in 0h47m (23:12) | 📅 Week 10%
119
+ ↓ session countdown drops
120
+ plain 🤖 Opus 4.8 (1M context) | 🧠 42k (4%) | ⏳ Session 17% | 📅 Week 10%
121
+ ↓ labels shorten, model/context trim away
122
+ narrow 🤖 Opus 4.8 | 🧠 42k | ⏳ S 17% | 📅 W 10%
123
+ tiny ⏳ S 17% | 📅 W 10%
124
+ ```
125
+
126
+ The width comes from the terminal itself (read via `/dev/tty`), so it follows
127
+ live window resizes. Disable it with `--no-adapt` (or `CC_LIMITS_ADAPT=0`) to
128
+ always print the full line, or pin a width with `--width=N` / `CC_LIMITS_WIDTH=N`.
129
+
130
+ > **Upgrading from 0.2.0?** Adaptive width is new and on by default. If you
131
+ > preferred the old always-full line (and let Claude Code truncate it), add
132
+ > `--no-adapt`.
133
+
92
134
  ## Configuration
93
135
 
94
136
  Pick **which segments** to show (and their order). The four segments are
@@ -102,10 +144,11 @@ Pick **which segments** to show (and their order). The four segments are
102
144
  "command": "cc-limits --no-context"
103
145
  ```
104
146
 
105
- Pick **which reset countdowns** to show:
147
+ Cap **which reset countdowns** may show (they still drop out on narrow
148
+ terminals):
106
149
 
107
150
  ```jsonc
108
- "command": "cc-limits --reset=session" // session reset only
151
+ "command": "cc-limits --reset=session" // never show the week countdown
109
152
  "command": "cc-limits --no-reset" // just percentages, no countdowns
110
153
  ```
111
154
 
@@ -115,8 +158,10 @@ Pick **which reset countdowns** to show:
115
158
  | --- | --- |
116
159
  | `--segments=a,b,c` | Allowlist + order. Subset of `model,context,session,week` |
117
160
  | `--no-<segment>` | Hide one segment (e.g. `--no-context`). Repeatable |
118
- | `--reset=both\|session\|week\|none` | Which reset countdowns to show (default `both`) |
161
+ | `--reset=both\|session\|week\|none` | Cap which reset countdowns may show (default `both`) |
119
162
  | `--no-reset` | Shorthand for `--reset=none` |
163
+ | `--no-adapt` | Don't shrink to terminal width; always print the full line |
164
+ | `--width=N` | Assume `N` columns instead of auto-detecting the terminal width |
120
165
  | `--no-color` | Disable ANSI colors |
121
166
  | `--demo` | Print a sample line (no stdin needed) |
122
167
  | `-h`, `--help` | Show help |
@@ -129,6 +174,8 @@ Equivalent to the flags, handy if you don't want to edit the command string:
129
174
  | --- | --- | --- |
130
175
  | `CC_LIMITS_SEGMENTS` | `model,context,session,week` | Segments + order |
131
176
  | `CC_LIMITS_RESET` | `both` | `both` / `session` / `week` / `none` |
177
+ | `CC_LIMITS_ADAPT` | `1` | Set to `0` to disable adaptive width (=`--no-adapt`) |
178
+ | `CC_LIMITS_WIDTH` | — | Force a column width instead of auto-detecting |
132
179
  | `CC_LIMITS_WARN` | `70` | % at/above which a limit turns yellow |
133
180
  | `CC_LIMITS_CRIT` | `90` | % at/above which a limit turns red |
134
181
  | `CC_LIMITS_SEP` | `" \| "` | Separator between segments |
package/bin/cli.js CHANGED
@@ -18,6 +18,7 @@
18
18
  const fs = require("fs");
19
19
  const path = require("path");
20
20
  const os = require("os");
21
+ const tty = require("tty");
21
22
 
22
23
  const argv = process.argv.slice(2);
23
24
 
@@ -57,14 +58,16 @@ if (segSel != null && segSel !== "") {
57
58
  SEGMENTS = ALL_SEGMENTS.filter((s) => !argv.includes(`--no-${s}`));
58
59
  }
59
60
 
60
- // Which reset countdowns to show: both | session | week | none.
61
+ // Which reset countdowns are *allowed*: both | session | week | none. This is
62
+ // a cap — the adaptive width logic shows them only while they fit, dropping the
63
+ // week countdown before the session one. Default both (full line when wide).
61
64
  let RESET_MODE = (
62
65
  getFlagValue("--reset") ||
63
66
  process.env.CC_LIMITS_RESET ||
64
67
  "both"
65
68
  ).toLowerCase();
66
69
  if (argv.includes("--no-reset")) RESET_MODE = "none";
67
- function showReset(which) {
70
+ function resetAllowed(which) {
68
71
  return RESET_MODE === "both" || RESET_MODE === which;
69
72
  }
70
73
 
@@ -75,6 +78,86 @@ const NO_COLOR =
75
78
  argv.includes("--no-color") ||
76
79
  (process.env.NO_COLOR != null && process.env.NO_COLOR !== "");
77
80
 
81
+ // Adaptive width: progressively shrink the line so it fits the terminal,
82
+ // keeping the session/week percentages last to die. On by default; disable
83
+ // with --no-adapt or CC_LIMITS_ADAPT=0 to always render the full line.
84
+ const ADAPT =
85
+ !argv.includes("--no-adapt") &&
86
+ process.env.CC_LIMITS_ADAPT !== "0" &&
87
+ process.env.CC_LIMITS_ADAPT !== "false";
88
+
89
+ // Detect the usable terminal width. The status-line JSON payload does NOT
90
+ // carry the width, and Claude Code captures our stdout (so stdout.columns is
91
+ // undefined), so we ask the controlling terminal directly via /dev/tty — that
92
+ // reflects the real width and updates when the user resizes. Falls back
93
+ // through stdout/COLUMNS to "unknown" (null => render full line).
94
+ function termWidth() {
95
+ const override = getFlagValue("--width") || process.env.CC_LIMITS_WIDTH;
96
+ if (override != null && override !== "") {
97
+ const n = Number(override);
98
+ if (Number.isFinite(n) && n > 0) return n;
99
+ return null; // explicit but invalid width => skip adaptation, full line
100
+ }
101
+ if (process.stdout && process.stdout.columns) return process.stdout.columns;
102
+ let fd;
103
+ try {
104
+ fd = fs.openSync("/dev/tty", "r");
105
+ if (tty.isatty(fd)) {
106
+ // tty.ReadStream takes ownership of fd; destroy() closes it (async).
107
+ // Do NOT also closeSync it below, or we double-close / reuse the fd.
108
+ const s = new tty.ReadStream(fd);
109
+ const c = s.columns;
110
+ s.destroy();
111
+ fd = undefined;
112
+ if (c) return c;
113
+ }
114
+ } catch (_) {
115
+ /* no controlling terminal — fall through */
116
+ } finally {
117
+ if (fd !== undefined) {
118
+ try {
119
+ fs.closeSync(fd);
120
+ } catch (_) {
121
+ /* ignore */
122
+ }
123
+ }
124
+ }
125
+ const env = Number(process.env.COLUMNS);
126
+ if (Number.isFinite(env) && env > 0) return env;
127
+ return null;
128
+ }
129
+
130
+ // Terminal display width of one Unicode code point: emoji / CJK render as 2
131
+ // columns, everything else as 1. A small wcwidth-lite so we measure the line
132
+ // the way the terminal draws it (e.g. ⏳ is one code unit but two columns).
133
+ function isWide(cp) {
134
+ return (
135
+ cp >= 0x1100 &&
136
+ (cp <= 0x115f || // Hangul Jamo
137
+ cp === 0x2329 ||
138
+ cp === 0x232a ||
139
+ cp === 0x231a ||
140
+ cp === 0x231b ||
141
+ (cp >= 0x23e9 && cp <= 0x23f3) || // ⏳ and clock/hourglass emoji
142
+ (cp >= 0x2600 && cp <= 0x27bf) || // misc symbols & dingbats
143
+ (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) || // CJK
144
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
145
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compat
146
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
147
+ (cp >= 0xff00 && cp <= 0xff60) ||
148
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
149
+ (cp >= 0x1f000 && cp <= 0x1faff)) // emoji planes (🤖 🧠 📅 …)
150
+ );
151
+ }
152
+
153
+ // Visible display width of a string, ignoring ANSI color escapes.
154
+ function visibleLen(s) {
155
+ const plain = s.replace(/\x1b\[[0-9;]*m/g, "");
156
+ let w = 0;
157
+ for (const ch of plain) w += isWide(ch.codePointAt(0)) ? 2 : 1;
158
+ return w;
159
+ }
160
+
78
161
  // ---------- colors ----------
79
162
  const C = {
80
163
  reset: "\x1b[0m",
@@ -133,65 +216,100 @@ function fmtClock(epochSec, withDate) {
133
216
  }
134
217
 
135
218
  // ---------- segment renderers ----------
136
- function renderModel(data) {
137
- const model = clean(data?.model?.display_name || "Claude");
219
+ // Each renderer takes a `v` (variant) describing how compact to be:
220
+ // v.reset 2 = "· resets in Xd Yh (clock)", 1 = "· resets in Xd Yh", 0 = none
221
+ // v.short true => short labels (Session→S, Week→W), trimmed model, no ctx %
222
+ function renderModel(data, v) {
223
+ let model = clean(data?.model?.display_name || "Claude");
224
+ // Drop trailing parenthetical (e.g. "Opus 4.8 (1M context)" → "Opus 4.8").
225
+ if (v.short) model = model.replace(/\s*\([^)]*\)\s*$/, "").trim();
138
226
  return paint("🤖 " + model, C.cyan);
139
227
  }
140
- function renderContext(data) {
228
+ function renderContext(data, v) {
141
229
  const cw = data?.context_window || {};
142
230
  const tokens =
143
231
  (Number(cw.total_input_tokens) || 0) +
144
232
  (Number(cw.total_output_tokens) || 0);
145
233
  let s = "🧠 " + humanTokens(tokens);
146
- if (cw.used_percentage != null) s += ` (${round(cw.used_percentage)}%)`;
234
+ if (!v.short && cw.used_percentage != null) s += ` (${round(cw.used_percentage)}%)`;
147
235
  return paint(s, C.gray);
148
236
  }
149
- function renderLimit(limit, { icon, label, which, withDate }) {
237
+ // `reset`: 2 = "· resets in Xd Yh (clock)", 1 = resets in Xd Yh", 0 = none.
238
+ function renderLimit(limit, { icon, label, shortLabel, withDate }, { short, reset }) {
239
+ const lbl = short ? shortLabel : label;
150
240
  if (!limit || limit.used_percentage == null) {
151
- return paint(`${icon} ${label} --`, C.dim);
241
+ return paint(`${icon} ${lbl} --`, C.dim);
152
242
  }
153
243
  const p = round(limit.used_percentage);
154
- let s = `${icon} ${label} ${paint(p + "%", pctColor(p))}`;
155
- if (limit.resets_at > 0 && showReset(which)) {
156
- s += paint(
157
- ` · resets in ${fmtCountdown(limit.resets_at)} (${fmtClock(
158
- limit.resets_at,
159
- withDate
160
- )})`,
161
- C.dim
162
- );
244
+ let s = `${icon} ${lbl} ${paint(p + "%", pctColor(p))}`;
245
+ if (limit.resets_at > 0 && reset > 0) {
246
+ const clock = reset >= 2 ? ` (${fmtClock(limit.resets_at, withDate)})` : "";
247
+ s += paint(` · resets in ${fmtCountdown(limit.resets_at)}${clock}`, C.dim);
163
248
  }
164
249
  return s;
165
250
  }
166
251
 
167
- function render(data) {
252
+ // Build one full status line at a given compactness variant. The variant gives
253
+ // independent reset levels for session (`rs`) and week (`rw`); each is also
254
+ // capped by the user's --reset choice.
255
+ function buildLine(data, v) {
168
256
  const rl = data?.rate_limits || {};
257
+ const cap = (which, level) => (resetAllowed(which) ? level : 0);
169
258
  const out = [];
170
259
  for (const seg of SEGMENTS) {
171
- if (seg === "model") out.push(renderModel(data));
172
- else if (seg === "context") out.push(renderContext(data));
173
- else if (seg === "session")
260
+ if (seg === "model") {
261
+ if (!v.dropModel) out.push(renderModel(data, v));
262
+ } else if (seg === "context") {
263
+ if (!v.dropContext) out.push(renderContext(data, v));
264
+ } else if (seg === "session") {
174
265
  out.push(
175
- renderLimit(rl.five_hour, {
176
- icon: "⏳",
177
- label: "Session",
178
- which: "session",
179
- withDate: false,
180
- })
266
+ renderLimit(
267
+ rl.five_hour,
268
+ { icon: "⏳", label: "Session", shortLabel: "S", withDate: false },
269
+ { short: v.short, reset: cap("session", v.rs) }
270
+ )
181
271
  );
182
- else if (seg === "week")
272
+ } else if (seg === "week") {
183
273
  out.push(
184
- renderLimit(rl.seven_day, {
185
- icon: "📅",
186
- label: "Week",
187
- which: "week",
188
- withDate: true,
189
- })
274
+ renderLimit(
275
+ rl.seven_day,
276
+ { icon: "📅", label: "Week", shortLabel: "W", withDate: true },
277
+ { short: v.short, reset: cap("week", v.rw) }
278
+ )
190
279
  );
280
+ }
191
281
  }
192
282
  return out.join(SEP);
193
283
  }
194
284
 
285
+ // Compactness variants, richest → poorest. We pick the richest one that fits
286
+ // the terminal. The week countdown is dropped before the session one, and the
287
+ // session/week percentages survive every tier.
288
+ const VARIANTS = [
289
+ { rs: 2, rw: 2, short: false }, // full: both resets + clock
290
+ { rs: 2, rw: 1, short: false }, // week loses its clock
291
+ { rs: 2, rw: 0, short: false }, // medium: session reset only
292
+ { rs: 1, rw: 0, short: false }, // session reset, no clock
293
+ { rs: 0, rw: 0, short: false }, // plain %, full labels
294
+ { rs: 0, rw: 0, short: true }, // short labels, trim model/ctx
295
+ { rs: 0, rw: 0, short: true, dropContext: true }, // drop context
296
+ { rs: 0, rw: 0, short: true, dropContext: true, dropModel: true }, // limits only
297
+ ];
298
+
299
+ function render(data) {
300
+ const full = buildLine(data, VARIANTS[0]);
301
+ if (!ADAPT) return full;
302
+ const w = termWidth();
303
+ if (w == null) return full; // width unknown — never truncate ourselves
304
+ const usable = w - 1; // small safety margin for emoji width rounding
305
+ if (visibleLen(full) <= usable) return full;
306
+ for (let i = 1; i < VARIANTS.length; i++) {
307
+ const line = buildLine(data, VARIANTS[i]);
308
+ if (visibleLen(line) <= usable) return line;
309
+ }
310
+ return buildLine(data, VARIANTS[VARIANTS.length - 1]);
311
+ }
312
+
195
313
  // ---------- demo payload ----------
196
314
  function demoPayload() {
197
315
  const now = Math.floor(Date.now() / 1000);
@@ -236,14 +354,24 @@ if (argv.includes("--help") || argv.includes("-h")) {
236
354
  " --no-model --no-week ...",
237
355
  "",
238
356
  "Reset countdowns:",
239
- " --reset=both|session|week|none Which resets to show (default both)",
357
+ " --reset=both|session|week|none Which resets MAY show (default both)",
240
358
  " --no-reset Same as --reset=none",
241
359
  "",
360
+ "Narrow terminals (on by default):",
361
+ " The line auto-shrinks to fit the terminal width: it drops the week",
362
+ " countdown first, then the session countdown, then shortens labels —",
363
+ " always keeping the session/week %. Width is read from the terminal",
364
+ " (via /dev/tty) and follows live resizes.",
365
+ " --no-adapt Always print the full line (let CC truncate it)",
366
+ " --width=N Assume N columns instead of auto-detecting",
367
+ "",
242
368
  "Other flags: --demo, --no-color, -h/--help",
243
369
  "",
244
370
  "Env vars:",
245
371
  " CC_LIMITS_SEGMENTS=model,context,session,week",
246
372
  " CC_LIMITS_RESET=both|session|week|none",
373
+ " CC_LIMITS_ADAPT=0 disable adaptive width (=--no-adapt)",
374
+ " CC_LIMITS_WIDTH=N force a column width",
247
375
  " CC_LIMITS_WARN=70 yellow threshold (% of a limit)",
248
376
  " CC_LIMITS_CRIT=90 red threshold",
249
377
  " CC_LIMITS_SEP=' | ' segment separator",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-limit-statusline",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Claude Code status line that shows your REAL Pro/Max subscription limits — 5-hour session and 7-day weekly usage with a live reset countdown. Uses the official rate_limits payload, not a local estimate.",
5
5
  "type": "commonjs",
6
6
  "bin": {